Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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