Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,13 @@
- When working with forms, always use refs to keep the current state of the form's data and use it to enable/disable the form submit button.
- Check the name field inside each package's package.json to confirm the right name—skip the top-level one.
- While working with forms, always use zod and react-hook-form to validate the form. Take reference implementation from `apps/web/components/admin/settings/sso/new.tsx`.
- `packages/scripts` is meant to contain maintenance scripts which can be re-used over and over, not one-off migrations. One-off migrations should be in `apps/web/.migrations`.
- `packages/utils` should be the place for containing utilities which are used in more than one package.
- `apps/web` and `apps/queue` can share business logic and db models. Common business logic should be moved to `packages/common-logic`. Common DB related functionality should be moved to `packages/orm-models`.
- For migrations (located in `apps/web/.migrations`), follow the "Gold Standard" pattern:
- Use **Cursors** (`.cursor()`) to stream data from MongoDB, ensuring the script remains memory-efficient regardless of dataset size.
- Use **Batching** with `bulkWrite` (e.g., batches of 500) to maximize performance and minimize network roundtrips.
- Ensure **Idempotency** (safe to re-run) by using upserts or `$setOnInsert` where applicable.

## Documentation tips

Expand Down
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
1 change: 1 addition & 0 deletions apps/docs/src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -122,6 +122,7 @@ export const SIDEBAR: Sidebar = {
Users: [
{ text: "Introduction", link: "en/users/introduction" },
{ text: "Manage users", link: "en/users/manage" },
{ text: "Customize notifications", link: "en/users/notifications" },
{ text: "User permissions", link: "en/users/permissions" },
{ text: "Filter users", link: "en/users/filters" },
{ text: "Segment users", link: "en/users/segments" },
Expand Down
42 changes: 42 additions & 0 deletions apps/docs/src/pages/en/users/notifications.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
---
title: Customize notifications
description: Customize app and email notifications
layout: ../../../layouts/MainLayout.astro
---

CourseLit lets each user control how they receive notifications for different activities.

> This feature is currently in beta, which means you may encounter bugs. Please report them in our <a href="https://discord.com/invite/GR4bQsN" target="_blank">Discord</a> group if you run into any issues.

## Open notification settings

1. Log in to your school.
2. Open `Dashboard`.
3. Click on your avatar (in the bottom-left corner) to open up the user menu.
4. Click on `Notifications`.

![Notifications hub](/assets/users/notifications-hub.png)

## Understand notification groups

Notification preferences are shown in groups based on activity type:

- **General**
- **Product**
- **User**
- **Community**

General notification preferences are available to all users.

## Choose channels

Each activity row has two channels:

- **App**: sends notifications inside your CourseLit dashboard.
- **Email**: sends notifications to your email inbox.

Tick or untick the checkboxes to turn each channel on or off for that activity. Changes are saved immediately.

## Stuck somewhere?

We are always here for you. Come chat with us in our <a href="https://discord.com/invite/GR4bQsN" target="_blank">Discord</a> channel or send a tweet at <a href="https://twitter.com/courselit" target="_blank">@CourseLit</a>.
10 changes: 10 additions & 0 deletions apps/queue/AGENTS.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
## Development Tips

- The code is organised domain wise. All related resources for a domain are kept in a folder under `src/<domain-name>`.
- Inside `src/<domain-name>` folder, you will find `model`, `queue`, `routes`, `services`, `utils` folders/files.
- `model` contains the mongoose models for the domain.
- `queue` contains the bullmq queues for the domain.
- `worker` contains the bullmq workers for the domain.
- `routes` contains the express routes for the domain.
- `services` contains the services for the domain.
- `utils` contains the utils for the domain.
1 change: 1 addition & 0 deletions apps/queue/jest.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ const config = {
"@courselit/common-logic": "<rootDir>/../../packages/common-logic/src",
"@courselit/common-models":
"<rootDir>/../../packages/common-models/src",
"@courselit/orm-models": "<rootDir>/../../packages/orm-models/src",
"@courselit/email-editor":
"<rootDir>/__mocks__/@courselit/email-editor.ts",
nanoid: "<rootDir>/__mocks__/nanoid.ts",
Expand Down
7 changes: 5 additions & 2 deletions apps/queue/package.json
Original file line number Diff line number Diff line change
@@ -1,20 +1,22 @@
{
"name": "@courselit/queue",
"version": "0.25.10",
"type": "module",
"private": true,
"packageManager": "pnpm@9.14.2",
"scripts": {
"build": "tsup",
"tsc:build": "tsc",
"check-types": "tsc --noEmit",
"start": "node dist/index.mjs",
"start": "node dist/index.js",
"build:dev": "tsup --watch",
"dev": "node --env-file .env.local --watch dist/index.mjs"
"dev": "node --watch --env-file .env.local --import tsx src/index.ts"
},
"dependencies": {
"@courselit/common-logic": "workspace:^",
"@courselit/common-models": "workspace:^",
"@courselit/email-editor": "workspace:^",
"@courselit/orm-models": "workspace:^",
"@courselit/utils": "workspace:^",
"@types/jsdom": "^21.1.7",
"bullmq": "^4.14.0",
Expand All @@ -37,6 +39,7 @@
"ts-jest": "^29.4.4",
"tsconfig": "workspace:^",
"tsup": "^7.2.0",
"tsx": "^4.21.0",
"typescript": "^5.9.3",
"typescript-eslint": "^8.46.4"
}
Expand Down
14 changes: 8 additions & 6 deletions apps/queue/src/domain/handler.ts
Original file line number Diff line number Diff line change
@@ -1,18 +1,20 @@
import type { MailJob } from "./model/mail-job";
import notificationQueue from "./notification-queue";
import mailQueue from "./queue";

export async function addMailJob({ to, subject, body, from }: MailJob) {
export async function addMailJob({
to,
subject,
body,
from,
headers,
}: MailJob) {
for (const recipient of to) {
await mailQueue.add("mail", {
to: recipient,
subject,
body,
from,
headers,
});
}
}

export async function addNotificationJob(notification) {
await notificationQueue.add("notification", notification);
}
1 change: 1 addition & 0 deletions apps/queue/src/domain/model/mail-job.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ export const MailJob = z.object({
from: z.string(),
subject: z.string(),
body: z.string(),
headers: z.record(z.string()).optional(),
});

export type MailJob = z.infer<typeof MailJob>;
89 changes: 0 additions & 89 deletions apps/queue/src/domain/model/notification.ts

This file was deleted.

3 changes: 2 additions & 1 deletion apps/queue/src/domain/worker.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,14 +15,15 @@ const transporter = nodemailer.createTransport({
const worker = new Worker(
"mail",
async (job) => {
const { to, from, subject, body } = job.data;
const { to, from, subject, body, headers } = job.data;

try {
await transporter.sendMail({
from,
to,
subject,
html: body,
headers,
});
} catch (err: any) {
logger.error(err);
Expand Down
3 changes: 2 additions & 1 deletion apps/queue/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,8 @@ import sseRoutes from "./sse/routes";

// start workers
import "./domain/worker";
import "./workers/notifications";
import "./notifications/worker/notification";
import "./notifications/worker/dispatch-notification";

// start loops
import { startEmailAutomation } from "./start-email-automation";
Expand Down
82 changes: 69 additions & 13 deletions apps/queue/src/job/routes.ts
Original file line number Diff line number Diff line change
@@ -1,19 +1,24 @@
import express from "express";
import { addMailJob, addNotificationJob } from "../domain/handler";
import { addMailJob } from "../domain/handler";
import {
addDispatchNotificationJob,
addNotificationJob,
} from "../notifications/services/enqueue";
import { logger } from "../logger";
import { MailJob } from "../domain/model/mail-job";
import NotificationModel from "../domain/model/notification";
import NotificationModel from "../notifications/model/notification";
import { ObjectId } from "mongodb";
import { User } from "@courselit/common-models";
import { Constants, User } from "@courselit/common-models";
import { z } from "zod";

const router: any = express.Router();

router.post("/mail", async (req: express.Request, res: express.Response) => {
try {
const { to, from, subject, body } = req.body;
MailJob.parse({ to, from, subject, body });
const { to, from, subject, body, headers } = req.body;
MailJob.parse({ to, from, subject, body, headers });

await addMailJob({ to, from, subject, body });
await addMailJob({ to, from, subject, body, headers });

res.status(200).json({ message: "Success" });
} catch (err: any) {
Expand All @@ -22,6 +27,57 @@ router.post("/mail", async (req: express.Request, res: express.Response) => {
}
});

const DispatchNotificationJob = z.object({
activityType: z
.string()
.refine((type) =>
Object.values(Constants.ActivityType).includes(type as any),
),
entityId: z.string(),
entityTargetId: z.string().optional(),
metadata: z.record(z.any()).optional(),
});

const NotificationJob = z.object({
forUserIds: z.array(z.string()).min(1),
activityType: z
.string()
.refine((type) =>
Object.values(Constants.ActivityType).includes(type as any),
),
entityId: z.string(),
entityTargetId: z.string().optional(),
metadata: z.record(z.any()).optional(),
});

router.post(
"/dispatch-notification",
async (
req: express.Request & { user: User & { domain: string } },
res: express.Response,
) => {
const { user } = req;

try {
const payload = DispatchNotificationJob.parse(req.body);

await addDispatchNotificationJob({
domain: new ObjectId(user.domain),
userId: user.userId,
activityType: payload.activityType,
entityId: payload.entityId,
entityTargetId: payload.entityTargetId,
metadata: payload.metadata || {},
});

res.status(200).json({ message: "Success" });
} catch (err: any) {
logger.error(err);
res.status(500).json({ error: err.message });
}
},
);

router.post(
"/notification",
async (
Expand All @@ -31,25 +87,25 @@ router.post(
const { user } = req;

try {
const { forUserIds, entityAction, entityId, entityTargetId } =
req.body;
const payload = NotificationJob.parse(req.body);

for (const forUserId of forUserIds) {
for (const forUserId of payload.forUserIds) {
// @ts-ignore - Mongoose type compatibility issue
const notification = await NotificationModel.create({
domain: new ObjectId(user.domain),
userId: user.userId,
forUserId,
entityAction,
entityId,
entityTargetId,
activityType: payload.activityType,
entityId: payload.entityId,
entityTargetId: payload.entityTargetId,
metadata: payload.metadata || {},
});

await addNotificationJob(notification);
}

res.status(200).json({ message: "Success" });
} catch (err) {
} catch (err: any) {
logger.error(err);
res.status(500).json({ error: err.message });
}
Expand Down
Loading
Loading