From b928a2d79e06681ea9f0ebd21f5574434e1d79eb Mon Sep 17 00:00:00 2001 From: Rajat Date: Sat, 21 Feb 2026 18:02:50 +0530 Subject: [PATCH 1/2] Notifications --- AGENTS.md | 7 + .../public/assets/users/notifications-hub.png | Bin 0 -> 164885 bytes apps/docs/src/config.ts | 1 + apps/docs/src/pages/en/users/notifications.md | 42 ++ apps/queue/AGENTS.md | 10 + apps/queue/jest.config.ts | 1 + apps/queue/package.json | 7 +- apps/queue/src/domain/handler.ts | 14 +- apps/queue/src/domain/model/mail-job.ts | 1 + apps/queue/src/domain/model/notification.ts | 89 --- apps/queue/src/domain/worker.ts | 3 +- apps/queue/src/index.ts | 3 +- apps/queue/src/job/routes.ts | 82 ++- .../model/notification-preference.ts | 16 + .../src/notifications/model/notification.ts | 11 + .../queue/dispatch-notification.ts | 8 + .../queue/notification.ts} | 2 +- .../notifications/services/channels/app.ts | 19 + .../notifications/services/channels/email.ts | 64 ++ .../notifications/services/channels/types.ts | 16 + .../src/notifications/services/enqueue.ts | 16 + .../utils/emitter.ts} | 0 .../worker/dispatch-notification.ts | 164 +++++ .../worker/notification.ts} | 6 +- apps/queue/src/sse/routes.ts | 2 +- ...-26_18-10-seed-notification-preferences.js | 159 +++++ .../notifications/__tests__/page.test.tsx | 112 ++++ .../(sidebar)/notifications/layout.tsx | 18 + .../(sidebar)/notifications/page.tsx | 608 ++++++++++++++++++ apps/web/app/api/unsubscribe/[token]/route.ts | 31 +- .../admin/dashboard-skeleton/nav-user.tsx | 22 +- apps/web/graphql/communities/logic.ts | 167 ++--- .../notifications/__tests__/logic.test.ts | 328 ++++++++++ apps/web/graphql/notifications/enums.ts | 22 + apps/web/graphql/notifications/helpers.ts | 69 ++ apps/web/graphql/notifications/logic.ts | 482 +++++++------- apps/web/graphql/notifications/mutation.ts | 45 +- apps/web/graphql/notifications/query.ts | 13 +- apps/web/graphql/notifications/types.ts | 9 + .../users/__tests__/delete-user.test.ts | 27 +- apps/web/graphql/users/helpers.ts | 5 + apps/web/graphql/users/logic.ts | 37 +- apps/web/jest.server.config.ts | 3 + apps/web/lib/record-activity.ts | 42 +- apps/web/models/Notification.ts | 93 +-- apps/web/models/NotificationPreference.ts | 16 + apps/web/package.json | 231 +++---- apps/web/services/queue.ts | 75 +-- apps/web/ui-config/strings.ts | 12 + packages/common-logic/package.json | 1 + packages/common-logic/src/index.ts | 2 + .../src/notification-entity-resolver.ts | 158 +++++ .../get-notification-message-and-href.ts | 405 ++++++++++++ packages/common-models/src/constants.ts | 39 ++ packages/common-models/src/index.ts | 2 + .../common-models/src/notification-channel.ts | 4 + .../src/notification-preference.ts | 8 + packages/orm-models/src/index.ts | 1 + .../src/models/notification-preference.ts | 62 ++ .../orm-models/src/models/notification.ts | 13 +- packages/scripts/src/cleanup-domain.ts | 7 + packages/utils/src/get-email-from.ts | 9 + packages/utils/src/index.ts | 1 + pnpm-lock.yaml | 305 ++++++++- services/app/Dockerfile | 13 +- services/queue/Dockerfile | 1 + 66 files changed, 3512 insertions(+), 729 deletions(-) create mode 100644 apps/docs/public/assets/users/notifications-hub.png create mode 100644 apps/docs/src/pages/en/users/notifications.md create mode 100644 apps/queue/AGENTS.md delete mode 100644 apps/queue/src/domain/model/notification.ts create mode 100644 apps/queue/src/notifications/model/notification-preference.ts create mode 100644 apps/queue/src/notifications/model/notification.ts create mode 100644 apps/queue/src/notifications/queue/dispatch-notification.ts rename apps/queue/src/{domain/notification-queue.ts => notifications/queue/notification.ts} (81%) create mode 100644 apps/queue/src/notifications/services/channels/app.ts create mode 100644 apps/queue/src/notifications/services/channels/email.ts create mode 100644 apps/queue/src/notifications/services/channels/types.ts create mode 100644 apps/queue/src/notifications/services/enqueue.ts rename apps/queue/src/{domain/emitters/notification.ts => notifications/utils/emitter.ts} (100%) create mode 100644 apps/queue/src/notifications/worker/dispatch-notification.ts rename apps/queue/src/{workers/notifications.ts => notifications/worker/notification.ts} (76%) create mode 100644 apps/web/.migrations/17-02-26_18-10-seed-notification-preferences.js create mode 100644 apps/web/app/(with-contexts)/dashboard/(sidebar)/notifications/__tests__/page.test.tsx create mode 100644 apps/web/app/(with-contexts)/dashboard/(sidebar)/notifications/layout.tsx create mode 100644 apps/web/app/(with-contexts)/dashboard/(sidebar)/notifications/page.tsx create mode 100644 apps/web/graphql/notifications/__tests__/logic.test.ts create mode 100644 apps/web/graphql/notifications/enums.ts create mode 100644 apps/web/graphql/notifications/helpers.ts create mode 100644 apps/web/models/NotificationPreference.ts create mode 100644 packages/common-logic/src/notification-entity-resolver.ts create mode 100644 packages/common-logic/src/utils/get-notification-message-and-href.ts create mode 100644 packages/common-models/src/notification-channel.ts create mode 100644 packages/common-models/src/notification-preference.ts create mode 100644 packages/orm-models/src/models/notification-preference.ts create mode 100644 packages/utils/src/get-email-from.ts diff --git a/AGENTS.md b/AGENTS.md index 28e01ae09..f8b0c5550 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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 diff --git a/apps/docs/public/assets/users/notifications-hub.png b/apps/docs/public/assets/users/notifications-hub.png new file mode 100644 index 0000000000000000000000000000000000000000..58dc2eff1f3d8a7603e25bd2dac16a478eff7720 GIT binary patch literal 164885 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 z%0D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 literal 0 HcmV?d00001 diff --git a/apps/docs/src/config.ts b/apps/docs/src/config.ts index fdc69ddae..dc75f676b 100644 --- a/apps/docs/src/config.ts +++ b/apps/docs/src/config.ts @@ -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" }, diff --git a/apps/docs/src/pages/en/users/notifications.md b/apps/docs/src/pages/en/users/notifications.md new file mode 100644 index 000000000..3ef50bff0 --- /dev/null +++ b/apps/docs/src/pages/en/users/notifications.md @@ -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 Discord 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 Discord channel or send a tweet at @CourseLit. diff --git a/apps/queue/AGENTS.md b/apps/queue/AGENTS.md new file mode 100644 index 000000000..9dcfa83cb --- /dev/null +++ b/apps/queue/AGENTS.md @@ -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/`. +- Inside `src/` 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. diff --git a/apps/queue/jest.config.ts b/apps/queue/jest.config.ts index e2f3fb996..f63b0036b 100644 --- a/apps/queue/jest.config.ts +++ b/apps/queue/jest.config.ts @@ -7,6 +7,7 @@ const config = { "@courselit/common-logic": "/../../packages/common-logic/src", "@courselit/common-models": "/../../packages/common-models/src", + "@courselit/orm-models": "/../../packages/orm-models/src", "@courselit/email-editor": "/__mocks__/@courselit/email-editor.ts", nanoid: "/__mocks__/nanoid.ts", diff --git a/apps/queue/package.json b/apps/queue/package.json index badd2d105..b20521a2c 100644 --- a/apps/queue/package.json +++ b/apps/queue/package.json @@ -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", @@ -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" } diff --git a/apps/queue/src/domain/handler.ts b/apps/queue/src/domain/handler.ts index 8822cfaff..2cb701dd6 100644 --- a/apps/queue/src/domain/handler.ts +++ b/apps/queue/src/domain/handler.ts @@ -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); -} diff --git a/apps/queue/src/domain/model/mail-job.ts b/apps/queue/src/domain/model/mail-job.ts index da94323f0..47d57bb5c 100644 --- a/apps/queue/src/domain/model/mail-job.ts +++ b/apps/queue/src/domain/model/mail-job.ts @@ -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; diff --git a/apps/queue/src/domain/model/notification.ts b/apps/queue/src/domain/model/notification.ts deleted file mode 100644 index 9e256fdd4..000000000 --- a/apps/queue/src/domain/model/notification.ts +++ /dev/null @@ -1,89 +0,0 @@ -import { - Constants, - Notification, - NotificationEntityAction, -} from "@courselit/common-models"; -import { generateUniqueId } from "@courselit/utils"; -import mongoose from "mongoose"; - -export interface InternalNotification - extends Omit, - mongoose.Document { - domain: mongoose.Types.ObjectId; - notificationId: string; - userId: string; - entityAction: NotificationEntityAction; - entityId: string; - read: boolean; - createdAt: Date; - updatedAt: Date; - entityTargetId?: string; -} - -const NotificationSchema = new mongoose.Schema( - { - domain: { - type: mongoose.Schema.Types.ObjectId, - required: true, - }, - notificationId: { - type: String, - required: true, - unique: true, - default: generateUniqueId, - }, - userId: { - type: String, - required: true, - ref: "User", - }, - forUserId: { - type: String, - required: true, - ref: "User", - }, - entityAction: { - type: String, - required: true, - enum: Object.values(Constants.NotificationEntityAction), - }, - entityId: { - type: String, - required: true, - }, - read: { - type: Boolean, - required: true, - default: false, - }, - entityTargetId: { - type: String, - }, - }, - { - timestamps: true, - }, -); - -NotificationSchema.statics.paginate = async function (userId, options) { - const page = options.page || 1; - const limit = options.limit || 10; - const skip = (page - 1) * limit; - - const query = { - forUserId: userId, - }; - - const notifications = await this.find(query) - .sort({ createdAt: -1 }) - .skip(skip) - .limit(limit) - .lean(); - - const total = await this.countDocuments(query); - - return { notifications, total }; -}; - -export default mongoose.models.Notification || - mongoose.model("Notification", NotificationSchema); diff --git a/apps/queue/src/domain/worker.ts b/apps/queue/src/domain/worker.ts index e2ecd90ea..035137e08 100644 --- a/apps/queue/src/domain/worker.ts +++ b/apps/queue/src/domain/worker.ts @@ -15,7 +15,7 @@ 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({ @@ -23,6 +23,7 @@ const worker = new Worker( to, subject, html: body, + headers, }); } catch (err: any) { logger.error(err); diff --git a/apps/queue/src/index.ts b/apps/queue/src/index.ts index 806350bca..a0466e1a6 100644 --- a/apps/queue/src/index.ts +++ b/apps/queue/src/index.ts @@ -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"; diff --git a/apps/queue/src/job/routes.ts b/apps/queue/src/job/routes.ts index 1cb131fe7..3ccba53f0 100644 --- a/apps/queue/src/job/routes.ts +++ b/apps/queue/src/job/routes.ts @@ -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) { @@ -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 ( @@ -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 }); } diff --git a/apps/queue/src/notifications/model/notification-preference.ts b/apps/queue/src/notifications/model/notification-preference.ts new file mode 100644 index 000000000..bbb3dec1b --- /dev/null +++ b/apps/queue/src/notifications/model/notification-preference.ts @@ -0,0 +1,16 @@ +import { + InternalNotificationPreference, + NotificationPreferenceSchema, +} from "@courselit/orm-models"; +import mongoose, { Model } from "mongoose"; + +const NotificationPreferenceModel = + (mongoose.models.NotificationPreference as + | Model + | undefined) || + mongoose.model( + "NotificationPreference", + NotificationPreferenceSchema, + ); + +export default NotificationPreferenceModel; diff --git a/apps/queue/src/notifications/model/notification.ts b/apps/queue/src/notifications/model/notification.ts new file mode 100644 index 000000000..59c16a126 --- /dev/null +++ b/apps/queue/src/notifications/model/notification.ts @@ -0,0 +1,11 @@ +import { + InternalNotification, + NotificationSchema, +} from "@courselit/orm-models"; +import mongoose, { Model } from "mongoose"; + +const NotificationModel = + (mongoose.models.Notification as Model | undefined) || + mongoose.model("Notification", NotificationSchema); + +export default NotificationModel; diff --git a/apps/queue/src/notifications/queue/dispatch-notification.ts b/apps/queue/src/notifications/queue/dispatch-notification.ts new file mode 100644 index 000000000..9dd2b9ffc --- /dev/null +++ b/apps/queue/src/notifications/queue/dispatch-notification.ts @@ -0,0 +1,8 @@ +import { Queue } from "bullmq"; +import redis from "../../redis"; + +const dispatchNotificationQueue = new Queue("dispatch-notification", { + connection: redis, +}); + +export default dispatchNotificationQueue; diff --git a/apps/queue/src/domain/notification-queue.ts b/apps/queue/src/notifications/queue/notification.ts similarity index 81% rename from apps/queue/src/domain/notification-queue.ts rename to apps/queue/src/notifications/queue/notification.ts index bb7c21b7e..d38fa6d7a 100644 --- a/apps/queue/src/domain/notification-queue.ts +++ b/apps/queue/src/notifications/queue/notification.ts @@ -1,5 +1,5 @@ import { Queue } from "bullmq"; -import redis from "../redis"; +import redis from "../../redis"; const notificationQueue = new Queue("notification", { connection: redis, diff --git a/apps/queue/src/notifications/services/channels/app.ts b/apps/queue/src/notifications/services/channels/app.ts new file mode 100644 index 000000000..f05c76dbc --- /dev/null +++ b/apps/queue/src/notifications/services/channels/app.ts @@ -0,0 +1,19 @@ +import NotificationModel from "../../model/notification"; +import { addNotificationJob } from "../enqueue"; +import { ChannelPayload, NotificationChannel } from "./types"; + +export class AppChannel implements NotificationChannel { + async send(payload: ChannelPayload): Promise { + const notification = await (NotificationModel as any).create({ + domain: payload.domain._id, + userId: payload.actorUserId, + forUserId: payload.recipient.userId, + activityType: payload.activityType, + entityId: payload.entityId, + entityTargetId: payload.entityTargetId, + metadata: payload.metadata || {}, + }); + + await addNotificationJob(notification); + } +} diff --git a/apps/queue/src/notifications/services/channels/email.ts b/apps/queue/src/notifications/services/channels/email.ts new file mode 100644 index 000000000..a34e7fe3e --- /dev/null +++ b/apps/queue/src/notifications/services/channels/email.ts @@ -0,0 +1,64 @@ +import { getNotificationMessageAndHref } from "@courselit/common-logic"; +import { getEmailFrom } from "@courselit/utils"; +import { addMailJob } from "../../../domain/handler"; +import { getSiteUrl } from "../../../utils/get-site-url"; +import { getUnsubLink } from "../../../utils/get-unsub-link"; +import { ChannelPayload, NotificationChannel } from "./types"; + +export class EmailChannel implements NotificationChannel { + async send(payload: ChannelPayload): Promise { + if (!payload.recipient.email || !payload.recipient.unsubscribeToken) { + return; + } + + if (!payload.recipient.subscribedToUpdates) { + return; + } + + const actorName = + payload.actor?.name || + payload.actor?.email || + payload.actor?.userId || + "Someone"; + const notificationDetails = await getNotificationMessageAndHref({ + activityType: payload.activityType, + entityId: payload.entityId, + actorName, + recipientUserId: payload.recipient.userId, + entityTargetId: payload.entityTargetId, + metadata: payload.metadata, + hrefPrefix: getSiteUrl(payload.domain), + domainId: payload.domain?._id, + }); + + if (!notificationDetails.message || !notificationDetails.href) { + return; + } + + const unsubscribeUrl = getUnsubLink( + payload.domain, + payload.recipient.unsubscribeToken, + ); + + await addMailJob({ + to: [payload.recipient.email], + from: getEmailFrom({ + name: payload.domain.settings?.title || payload.domain.name, + email: process.env.EMAIL_FROM || payload.domain.email, + }), + subject: notificationDetails.message, + body: ` +

${notificationDetails.message}

+

View notification

+
+

+ Unsubscribe from email notifications +

+ `, + headers: { + "List-Unsubscribe": `<${unsubscribeUrl}>`, + "List-Unsubscribe-Post": "List-Unsubscribe=One-Click", + }, + }); + } +} diff --git a/apps/queue/src/notifications/services/channels/types.ts b/apps/queue/src/notifications/services/channels/types.ts new file mode 100644 index 000000000..6492e0129 --- /dev/null +++ b/apps/queue/src/notifications/services/channels/types.ts @@ -0,0 +1,16 @@ +import { ActivityType, User } from "@courselit/common-models"; + +export interface ChannelPayload { + domain: any; + actorUserId: string; + actor: Partial | null; + recipient: any; + activityType: ActivityType; + entityId: string; + entityTargetId?: string; + metadata?: Record; +} + +export interface NotificationChannel { + send(payload: ChannelPayload): Promise; +} diff --git a/apps/queue/src/notifications/services/enqueue.ts b/apps/queue/src/notifications/services/enqueue.ts new file mode 100644 index 000000000..07fe7a516 --- /dev/null +++ b/apps/queue/src/notifications/services/enqueue.ts @@ -0,0 +1,16 @@ +import dispatchNotificationQueue from "../queue/dispatch-notification"; +import notificationQueue from "../queue/notification"; + +export async function addNotificationJob(notification) { + await notificationQueue.add("notification", notification); +} + +export async function addDispatchNotificationJob(notification) { + await dispatchNotificationQueue.add("dispatch-notification", notification, { + attempts: 3, + backoff: { + type: "exponential", + delay: 1000, + }, + }); +} diff --git a/apps/queue/src/domain/emitters/notification.ts b/apps/queue/src/notifications/utils/emitter.ts similarity index 100% rename from apps/queue/src/domain/emitters/notification.ts rename to apps/queue/src/notifications/utils/emitter.ts diff --git a/apps/queue/src/notifications/worker/dispatch-notification.ts b/apps/queue/src/notifications/worker/dispatch-notification.ts new file mode 100644 index 000000000..4e4dd074d --- /dev/null +++ b/apps/queue/src/notifications/worker/dispatch-notification.ts @@ -0,0 +1,164 @@ +import { Worker } from "bullmq"; +import { + ActivityType, + Constants, + NotificationChannel as NotificationChannelType, +} from "@courselit/common-models"; +import redis from "../../redis"; +import { logger } from "../../logger"; +import UserModel from "../../domain/model/user"; +import DomainModel from "../../domain/model/domain"; +import mongoose from "mongoose"; +import { checkPermission } from "@courselit/utils"; +import NotificationPreferenceModel from "../model/notification-preference"; +import { AppChannel } from "../services/channels/app"; +import { EmailChannel } from "../services/channels/email"; +import { + ChannelPayload, + NotificationChannel, +} from "../services/channels/types"; + +interface DispatchNotificationJob { + domain: string | mongoose.Types.ObjectId; + userId: string; + activityType: ActivityType; + entityId: string; + entityTargetId?: string; + metadata?: Record; +} + +const channelRegistry: Record = { + [Constants.NotificationChannel.APP]: new AppChannel(), + [Constants.NotificationChannel.EMAIL]: new EmailChannel(), +}; + +const worker = new Worker( + "dispatch-notification", + async (job) => { + try { + await processDispatchNotificationJob( + job.data as DispatchNotificationJob, + ); + } catch (err: any) { + logger.error(err); + throw err; + } + }, + { connection: redis }, +); + +export default worker; + +async function processDispatchNotificationJob(job: DispatchNotificationJob) { + if (!Object.values(Constants.ActivityType).includes(job.activityType)) { + return; + } + + const domainId = + typeof job.domain === "string" + ? new mongoose.Types.ObjectId(job.domain) + : job.domain; + + const [domain, actor] = await Promise.all([ + (DomainModel as any).findById(domainId).lean(), + (UserModel as any) + .findOne({ domain: domainId, userId: job.userId }) + .lean(), + ]); + + if (!domain) { + return; + } + + const hasTargetUserIds = Array.isArray(job.metadata?.forUserIds); + const targetUserIds = new Set( + hasTargetUserIds ? (job.metadata?.forUserIds as string[]) : [], + ); + + if (hasTargetUserIds && !targetUserIds.size) { + return; + } + + const query: Record = { + domain: domainId, + activityType: job.activityType, + channels: { $ne: [] }, + }; + + if (hasTargetUserIds) { + query.userId = { + $in: Array.from(targetUserIds), + }; + } + + const cursor = (NotificationPreferenceModel as any).find(query).cursor(); + const recipientCache = new Map(); + + for await (const preference of cursor as any) { + if (preference.userId === job.userId) { + continue; + } + + if (!preference.channels?.length) { + continue; + } + + if (hasTargetUserIds && !targetUserIds.has(preference.userId)) { + continue; + } + + let recipient = recipientCache.get(preference.userId); + if (!recipient) { + recipient = await (UserModel as any) + .findOne({ + domain: domainId, + userId: preference.userId, + }) + .lean(); + if (recipient) { + recipientCache.set(preference.userId, recipient); + } + } + + if (!recipient) { + continue; + } + + const requiredPermission = + Constants.ActivityPermissionMap[job.activityType]; + const isGeneralActivity = requiredPermission === ""; + if ( + requiredPermission && + !isGeneralActivity && + !checkPermission(recipient.permissions || [], [requiredPermission]) + ) { + continue; + } + + const payload: ChannelPayload = { + domain, + actorUserId: job.userId, + actor, + recipient, + activityType: job.activityType, + entityId: job.entityId, + entityTargetId: + job.entityTargetId || + (job.metadata?.entityTargetId as string) || + undefined, + metadata: job.metadata || {}, + }; + + await Promise.allSettled( + Array.from(new Set(preference.channels)).map((channel) => { + const handler = + channelRegistry[channel as NotificationChannelType]; + if (!handler) { + return Promise.resolve(); + } + + return handler.send(payload); + }), + ); + } +} diff --git a/apps/queue/src/workers/notifications.ts b/apps/queue/src/notifications/worker/notification.ts similarity index 76% rename from apps/queue/src/workers/notifications.ts rename to apps/queue/src/notifications/worker/notification.ts index 13ff89d68..aea8dd101 100644 --- a/apps/queue/src/workers/notifications.ts +++ b/apps/queue/src/notifications/worker/notification.ts @@ -1,7 +1,7 @@ import { Worker } from "bullmq"; -import redis from "../redis"; -import { logger } from "../logger"; -import { notificationEmitter } from "../domain/emitters/notification"; +import redis from "../../redis"; +import { logger } from "../../logger"; +import { notificationEmitter } from "../utils/emitter"; const worker = new Worker( "notification", diff --git a/apps/queue/src/sse/routes.ts b/apps/queue/src/sse/routes.ts index 0a3dd0483..9418d7c62 100644 --- a/apps/queue/src/sse/routes.ts +++ b/apps/queue/src/sse/routes.ts @@ -1,5 +1,5 @@ import express from "express"; -import { notificationEmitter } from "../domain/emitters/notification"; +import { notificationEmitter } from "../notifications/utils/emitter"; const router: any = express.Router(); diff --git a/apps/web/.migrations/17-02-26_18-10-seed-notification-preferences.js b/apps/web/.migrations/17-02-26_18-10-seed-notification-preferences.js new file mode 100644 index 000000000..acc9e2b6f --- /dev/null +++ b/apps/web/.migrations/17-02-26_18-10-seed-notification-preferences.js @@ -0,0 +1,159 @@ +/** + * Seeds general notification preferences for existing users and + * deletes legacy notifications that depend on `entityAction`. + * + * Usage: DB_CONNECTION_STRING= node 17-02-26_18-10-seed-notification-preferences.js + */ +import mongoose from "mongoose"; + +const DB_CONNECTION_STRING = process.env.DB_CONNECTION_STRING; +if (!DB_CONNECTION_STRING) { + throw new Error("DB_CONNECTION_STRING is not set"); +} + +const NotificationChannel = { + APP: "app", + EMAIL: "email", +}; + +const GeneralActivityTypes = [ + "community_post_created", + "community_post_liked", + "community_comment_created", + "community_comment_replied", + "community_comment_liked", + "community_reply_created", + "community_reply_liked", + "community_membership_granted", +]; +const BATCH_SIZE = 500; + +const UserSchema = new mongoose.Schema({ + domain: { type: mongoose.Schema.Types.ObjectId, required: true }, + userId: { type: String, required: true }, +}); + +const NotificationPreferenceSchema = new mongoose.Schema( + { + domain: { type: mongoose.Schema.Types.ObjectId, required: true }, + userId: { type: String, required: true }, + activityType: { type: String, required: true }, + channels: { type: [String], default: [] }, + }, + { + timestamps: true, + }, +); + +NotificationPreferenceSchema.index( + { + domain: 1, + userId: 1, + activityType: 1, + }, + { + unique: true, + }, +); + +const User = mongoose.model("User", UserSchema); +const NotificationPreference = mongoose.model( + "NotificationPreference", + NotificationPreferenceSchema, +); + +function getDefaultPreferenceOps({ domain, userId }) { + return GeneralActivityTypes.map((activityType) => ({ + updateOne: { + filter: { + domain, + userId, + activityType, + }, + update: { + $setOnInsert: { + domain, + userId, + activityType, + channels: [ + NotificationChannel.APP, + NotificationChannel.EMAIL, + ], + }, + }, + upsert: true, + }, + })); +} + +async function deleteLegacyNotifications(db) { + const deleteLegacyNotificationsResult = await db + .collection("notifications") + .deleteMany({ + entityAction: { + $exists: true, + }, + }); + + console.log( + `🧹 Deleted ${deleteLegacyNotificationsResult.deletedCount || 0} legacy notifications containing entityAction.`, + ); +} + +async function seedNotificationPreferences() { + const cursor = User.find( + {}, + { + _id: 0, + domain: 1, + userId: 1, + }, + ).cursor(); + + let processedUsers = 0; + let totalOps = 0; + let batch = []; + + for await (const user of cursor) { + const ops = getDefaultPreferenceOps({ + domain: user.domain, + userId: user.userId, + }); + + processedUsers += 1; + totalOps += ops.length; + batch.push(...ops); + + if (batch.length >= BATCH_SIZE) { + await NotificationPreference.bulkWrite(batch, { ordered: false }); + batch = []; + } + } + + if (batch.length) { + await NotificationPreference.bulkWrite(batch, { ordered: false }); + } + + console.log( + `āœ… Seeded notification preferences for ${processedUsers} users (${totalOps} activity preference upserts).`, + ); +} + +(async () => { + try { + await mongoose.connect(DB_CONNECTION_STRING, { + useNewUrlParser: true, + useUnifiedTopology: true, + }); + + const db = mongoose.connection.db; + if (!db) { + throw new Error("Could not connect to database"); + } + + await deleteLegacyNotifications(db); + await seedNotificationPreferences(); + } finally { + await mongoose.connection.close(); + } +})(); diff --git a/apps/web/app/(with-contexts)/dashboard/(sidebar)/notifications/__tests__/page.test.tsx b/apps/web/app/(with-contexts)/dashboard/(sidebar)/notifications/__tests__/page.test.tsx new file mode 100644 index 000000000..432bae0ef --- /dev/null +++ b/apps/web/app/(with-contexts)/dashboard/(sidebar)/notifications/__tests__/page.test.tsx @@ -0,0 +1,112 @@ +import React from "react"; +import { render, screen, waitFor } from "@testing-library/react"; +import NotificationsPage from "../page"; +import { AddressContext, ProfileContext } from "@components/contexts"; +import { FetchBuilder } from "@courselit/utils"; +import { Constants } from "@courselit/common-models"; + +const mockToast = jest.fn(); +const mockExec = jest.fn(); + +jest.mock("@components/admin/dashboard-content", () => ({ + __esModule: true, + default: ({ children }: { children: React.ReactNode }) => ( +
{children}
+ ), +})); + +jest.mock("@courselit/components-library", () => ({ + Checkbox: ({ + checked, + disabled, + onChange, + }: { + checked: boolean; + disabled?: boolean; + onChange: (value: boolean) => void; + }) => ( + onChange(event.target.checked)} + /> + ), + useToast: () => ({ + toast: mockToast, + }), +})); + +jest.mock("@courselit/utils", () => { + const actual = jest.requireActual("@courselit/utils"); + return { + ...actual, + FetchBuilder: jest.fn().mockImplementation(() => ({ + setUrl: jest.fn().mockReturnThis(), + setPayload: jest.fn().mockReturnThis(), + setIsGraphQLEndpoint: jest.fn().mockReturnThis(), + build: jest.fn().mockReturnThis(), + exec: mockExec, + })), + }; +}); + +function renderPage(permissions: string[]) { + return render( + + + + + , + ); +} + +describe("Notifications Page", () => { + beforeEach(() => { + jest.clearAllMocks(); + mockExec.mockResolvedValue({ + preferences: [], + }); + }); + + it("renders general notification rows from permissions even when DB has no rows", async () => { + renderPage([]); + + await waitFor(() => { + expect( + screen.getByText("Community Post Created"), + ).toBeInTheDocument(); + }); + + expect( + screen.getByText("Community Membership Granted"), + ).toBeInTheDocument(); + expect( + screen.queryByText( + "No notification preferences are available for your account.", + ), + ).not.toBeInTheDocument(); + }); + + it("renders non-general activity rows only when user has required permissions", async () => { + renderPage([ + Constants.ActivityPermissionMap[Constants.ActivityType.PURCHASED], + ]); + + await waitFor(() => { + expect(screen.getByText("Purchased")).toBeInTheDocument(); + }); + + expect(screen.getByText("Community Post Created")).toBeInTheDocument(); + expect(FetchBuilder).toHaveBeenCalled(); + }); +}); diff --git a/apps/web/app/(with-contexts)/dashboard/(sidebar)/notifications/layout.tsx b/apps/web/app/(with-contexts)/dashboard/(sidebar)/notifications/layout.tsx new file mode 100644 index 000000000..f3fac20cb --- /dev/null +++ b/apps/web/app/(with-contexts)/dashboard/(sidebar)/notifications/layout.tsx @@ -0,0 +1,18 @@ +import { NOTIFICATION_SETTINGS_PAGE_HEADER } from "@ui-config/strings"; +import type { Metadata, ResolvingMetadata } from "next"; +import { ReactNode } from "react"; + +export async function generateMetadata( + _: any, + parent: ResolvingMetadata, +): Promise { + return { + title: `${NOTIFICATION_SETTINGS_PAGE_HEADER} | ${ + (await parent)?.title?.absolute + }`, + }; +} + +export default function Layout({ children }: { children: ReactNode }) { + return children; +} diff --git a/apps/web/app/(with-contexts)/dashboard/(sidebar)/notifications/page.tsx b/apps/web/app/(with-contexts)/dashboard/(sidebar)/notifications/page.tsx new file mode 100644 index 000000000..820867069 --- /dev/null +++ b/apps/web/app/(with-contexts)/dashboard/(sidebar)/notifications/page.tsx @@ -0,0 +1,608 @@ +"use client"; + +import DashboardContent from "@components/admin/dashboard-content"; +import { AddressContext, ProfileContext } from "@components/contexts"; +import Resources from "@components/resources"; +import { Card, CardContent, CardHeader, CardTitle } from "@components/ui/card"; +import { Checkbox, useToast } from "@courselit/components-library"; +import { + ActivityType, + Constants, + NotificationChannel, +} from "@courselit/common-models"; +import { checkPermission, FetchBuilder } from "@courselit/utils"; +import { + LOADING, + NOTIFICATION_SETTINGS_COLUMN_ACTIVITY, + NOTIFICATION_SETTINGS_EMPTY_STATE, + NOTIFICATION_SETTINGS_GROUP_COMMUNITY_MANAGEMENT, + NOTIFICATION_SETTINGS_GROUP_GENERAL, + NOTIFICATION_SETTINGS_GROUP_PRODUCT_MANAGEMENT, + NOTIFICATION_SETTINGS_GROUP_USER_MANAGEMENT, + NOTIFICATION_SETTINGS_PAGE_DESCRIPTION, + NOTIFICATION_SETTINGS_PAGE_HEADER, + NOTIFICATION_SETTINGS_RESOURCE_TEXT, + TOAST_TITLE_ERROR, +} from "@ui-config/strings"; +import { + useCallback, + useContext, + useEffect, + useMemo, + useRef, + useState, +} from "react"; + +const breadcrumbs = [{ label: NOTIFICATION_SETTINGS_PAGE_HEADER, href: "#" }]; +const notificationChannels = Object.values( + Constants.NotificationChannel, +) as NotificationChannel[]; +const activityTypeEnumNameByValue = new Map( + Object.entries(Constants.ActivityType).map(([key, value]) => [ + value as ActivityType, + key, + ]), +); +const notificationChannelEnumNameByValue = new Map( + Object.entries(Constants.NotificationChannel).map(([key, value]) => [ + value as NotificationChannel, + key, + ]), +); +const permissionGroupOrder = [ + "", + "course:manage_any", + "user:manage", + "community:manage", +] as const; +const permissionGroupLabels: Record< + (typeof permissionGroupOrder)[number], + string +> = { + "": NOTIFICATION_SETTINGS_GROUP_GENERAL, + "course:manage_any": NOTIFICATION_SETTINGS_GROUP_PRODUCT_MANAGEMENT, + "user:manage": NOTIFICATION_SETTINGS_GROUP_USER_MANAGEMENT, + "community:manage": NOTIFICATION_SETTINGS_GROUP_COMMUNITY_MANAGEMENT, +}; + +interface NotificationPreferenceState { + activityType: ActivityType; + channels: NotificationChannel[]; +} + +interface ActivityGroupData { + label: string; + preferences: NotificationPreferenceState[]; +} + +function isGeneralActivity(activityType: ActivityType): boolean { + return Constants.ActivityPermissionMap[activityType] === ""; +} + +function isActivityAllowedForPermissions( + activityType: ActivityType, + permissions: string[], +): boolean { + if (isGeneralActivity(activityType)) { + return true; + } + + const requiredPermission = Constants.ActivityPermissionMap[activityType]; + if (!requiredPermission) { + return false; + } + + return checkPermission(permissions, [requiredPermission]); +} + +function getAllowedActivityTypesForPermissions( + permissions: string[], +): ActivityType[] { + return (Object.values(Constants.ActivityType) as ActivityType[]) + .filter((activityType) => + isActivityAllowedForPermissions(activityType, permissions), + ) + .sort((a, b) => a.localeCompare(b)); +} + +function getDefaultChannelsForActivity( + _activityType: ActivityType, +): NotificationChannel[] { + return []; +} + +function getDefaultPreferencesForPermissions( + permissions: string[], +): NotificationPreferenceState[] { + return getAllowedActivityTypesForPermissions(permissions).map( + (activityType) => ({ + activityType, + channels: getDefaultChannelsForActivity(activityType), + }), + ); +} + +function mergePersistedPreferences({ + defaults, + persisted, +}: { + defaults: NotificationPreferenceState[]; + persisted: NotificationPreferenceState[]; +}): NotificationPreferenceState[] { + const persistedByActivityType = new Map< + ActivityType, + NotificationChannel[] + >( + persisted.map((preference) => [ + preference.activityType, + normalizeChannels(preference.channels), + ]), + ); + + return defaults.map((preference) => ({ + activityType: preference.activityType, + channels: + persistedByActivityType.get(preference.activityType) || + preference.channels, + })); +} + +function prettifyToken(value: string): string { + return value + .split("_") + .map((token) => token.charAt(0).toUpperCase() + token.slice(1)) + .join(" "); +} + +function normalizeChannels( + channels: NotificationChannel[], +): NotificationChannel[] { + const uniqueChannels = new Set(channels); + return notificationChannels.filter((channel) => + uniqueChannels.has(channel), + ); +} + +function areChannelsEqual( + currentChannels: NotificationChannel[], + nextChannels: NotificationChannel[], +): boolean { + return ( + currentChannels.length === nextChannels.length && + currentChannels.every( + (channel, index) => nextChannels[index] === channel, + ) + ); +} + +function getUpdatedChannels({ + channels, + channel, + checked, +}: { + channels: NotificationChannel[]; + channel: NotificationChannel; + checked: boolean; +}): NotificationChannel[] { + const updatedChannels = new Set(channels); + + if (checked) { + updatedChannels.add(channel); + } else { + updatedChannels.delete(channel); + } + + return normalizeChannels(Array.from(updatedChannels)); +} + +function ActivityRow({ + preference, + isUpdating, + onChannelToggle, +}: { + preference: NotificationPreferenceState; + isUpdating: boolean; + onChannelToggle: ( + activityType: ActivityType, + channel: NotificationChannel, + checked: boolean, + ) => Promise; +}) { + return ( + + + {prettifyToken(preference.activityType)} + + {notificationChannels.map((channel) => ( + +
+ + onChannelToggle( + preference.activityType, + channel, + value === true, + ) + } + /> +
+ + ))} + + ); +} + +function ActivityGroup({ + group, + updatingActivityTypes, + onChannelToggle, +}: { + group: ActivityGroupData; + updatingActivityTypes: string[]; + onChannelToggle: ( + activityType: ActivityType, + channel: NotificationChannel, + checked: boolean, + ) => Promise; +}) { + return ( + + + {group.label} + + +
+ + + + + {notificationChannels.map((channel) => ( + + ))} + + + + {group.preferences.map((preference) => ( + + ))} + +
+ {NOTIFICATION_SETTINGS_COLUMN_ACTIVITY} + + {prettifyToken(channel)} +
+
+
+
+ ); +} + +function NotificationSettings({ + isLoading, + groups, + updatingActivityTypes, + onChannelToggle, +}: { + isLoading: boolean; + groups: ActivityGroupData[]; + updatingActivityTypes: string[]; + onChannelToggle: ( + activityType: ActivityType, + channel: NotificationChannel, + checked: boolean, + ) => Promise; +}) { + if (isLoading) { + return ( + + {LOADING} + + ); + } + + if (!groups.length) { + return ( + + + {NOTIFICATION_SETTINGS_EMPTY_STATE} + + + ); + } + + return ( + <> + {groups.map((group) => ( + + ))} + + ); +} + +export default function Page() { + const address = useContext(AddressContext); + const { profile } = useContext(ProfileContext); + const { toast } = useToast(); + const [isLoading, setIsLoading] = useState(true); + const [preferences, setPreferences] = useState< + NotificationPreferenceState[] + >([]); + const [updatingActivityTypes, setUpdatingActivityTypes] = useState< + string[] + >([]); + const preferencesRef = useRef([]); + + useEffect(() => { + preferencesRef.current = preferences; + }, [preferences]); + + const defaultPreferences = useMemo( + () => getDefaultPreferencesForPermissions(profile?.permissions || []), + [profile?.permissions], + ); + + const getPersistedNotificationPreferences = useCallback(async () => { + const query = ` + query { + preferences: getNotificationPreferences { + activityType + channels + } + } + `; + + const fetch = new FetchBuilder() + .setUrl(`${address.backend}/api/graph`) + .setPayload(query) + .setIsGraphQLEndpoint(true) + .build(); + + const response = await fetch.exec(); + return (response.preferences || []) as NotificationPreferenceState[]; + }, [address.backend]); + + const updateNotificationPreference = useCallback( + async ({ + activityType, + channels, + }: { + activityType: ActivityType; + channels: NotificationChannel[]; + }) => { + const activityTypeEnumName = + activityTypeEnumNameByValue.get(activityType); + const channelEnumNames = channels + .map((channel) => + notificationChannelEnumNameByValue.get(channel), + ) + .filter((channel): channel is string => Boolean(channel)); + + if ( + !activityTypeEnumName || + channelEnumNames.length !== channels.length + ) { + throw new Error(TOAST_TITLE_ERROR); + } + + const mutation = ` + mutation UpdateNotificationPreference( + $activityType: NotificationPreferenceActivityType! + $channels: [NotificationChannelType!]! + ) { + preference: updateNotificationPreference( + activityType: $activityType + channels: $channels + ) { + activityType + channels + } + } + `; + + const fetch = new FetchBuilder() + .setUrl(`${address.backend}/api/graph`) + .setPayload({ + query: mutation, + variables: { + activityType: activityTypeEnumName, + channels: channelEnumNames, + }, + }) + .setIsGraphQLEndpoint(true) + .build(); + + const response = await fetch.exec(); + return response.preference as NotificationPreferenceState; + }, + [address.backend], + ); + + useEffect(() => { + if (!address.backend) { + return; + } + + let cancelled = false; + + (async () => { + setIsLoading(true); + try { + const loadedPreferences = + await getPersistedNotificationPreferences(); + if (!cancelled) { + setPreferences( + mergePersistedPreferences({ + defaults: defaultPreferences, + persisted: loadedPreferences, + }), + ); + } + } catch (err: any) { + if (!cancelled) { + setPreferences(defaultPreferences); + toast({ + title: TOAST_TITLE_ERROR, + description: err.message, + variant: "destructive", + }); + } + } finally { + if (!cancelled) { + setIsLoading(false); + } + } + })(); + + return () => { + cancelled = true; + }; + }, [ + address.backend, + defaultPreferences, + getPersistedNotificationPreferences, + toast, + ]); + + const onChannelToggle = useCallback( + async ( + activityType: ActivityType, + channel: NotificationChannel, + checked: boolean, + ) => { + if (updatingActivityTypes.includes(activityType)) { + return; + } + + const currentPreference = preferencesRef.current.find( + (preference) => preference.activityType === activityType, + ); + if (!currentPreference) { + return; + } + + const previousChannels = currentPreference.channels; + const nextChannels = getUpdatedChannels({ + channels: previousChannels, + channel, + checked, + }); + + if (areChannelsEqual(previousChannels, nextChannels)) { + return; + } + + setPreferences((currentPreferences) => + currentPreferences.map((preference) => + preference.activityType === activityType + ? { + ...preference, + channels: nextChannels, + } + : preference, + ), + ); + setUpdatingActivityTypes((current) => [...current, activityType]); + + try { + const updatedPreference = await updateNotificationPreference({ + activityType, + channels: nextChannels, + }); + setPreferences((currentPreferences) => + currentPreferences.map((preference) => + preference.activityType === activityType + ? { + ...preference, + channels: normalizeChannels( + updatedPreference.channels, + ), + } + : preference, + ), + ); + } catch (err: any) { + setPreferences((currentPreferences) => + currentPreferences.map((preference) => + preference.activityType === activityType + ? { + ...preference, + channels: previousChannels, + } + : preference, + ), + ); + toast({ + title: TOAST_TITLE_ERROR, + description: err.message, + variant: "destructive", + }); + } finally { + setUpdatingActivityTypes((current) => + current.filter((item) => item !== activityType), + ); + } + }, + [toast, updateNotificationPreference, updatingActivityTypes], + ); + + const groupedPreferences = useMemo( + () => + permissionGroupOrder + .map((permission) => ({ + label: permissionGroupLabels[permission], + preferences: preferences.filter( + (preference) => + Constants.ActivityPermissionMap[ + preference.activityType + ] === permission, + ), + })) + .filter((group) => group.preferences.length), + [preferences], + ); + + return ( + +

+ {NOTIFICATION_SETTINGS_PAGE_HEADER} +

+

+ {NOTIFICATION_SETTINGS_PAGE_DESCRIPTION} +

+ + +
+ ); +} diff --git a/apps/web/app/api/unsubscribe/[token]/route.ts b/apps/web/app/api/unsubscribe/[token]/route.ts index 40315c5e2..346b6f995 100644 --- a/apps/web/app/api/unsubscribe/[token]/route.ts +++ b/apps/web/app/api/unsubscribe/[token]/route.ts @@ -2,8 +2,10 @@ import { NextRequest } from "next/server"; import { responses } from "@/config/strings"; import User from "@models/User"; import DomainModel, { Domain } from "@models/Domain"; +import { recordActivity } from "@/lib/record-activity"; +import { Constants } from "@courselit/common-models"; -export async function GET( +async function unsubscribe( req: NextRequest, { params }: { params: Promise<{ token: string }> }, ) { @@ -16,7 +18,11 @@ export async function GET( const token = (await params).token; - const user = await User.findOne({ unsubscribeToken: token }); + const user = await User.findOne({ + domain: domain._id, + unsubscribeToken: token, + subscribedToUpdates: true, + }); if (!user) { return Response.json({ message: responses.unsubscribe_success }); @@ -24,5 +30,26 @@ export async function GET( await user.updateOne({ subscribedToUpdates: false }); + await recordActivity({ + domain: domain._id, + userId: user.userId, + type: Constants.ActivityType.NEWSLETTER_UNSUBSCRIBED, + entityId: user.userId, + }); + return Response.json({ message: responses.unsubscribe_success }); } + +export async function GET( + req: NextRequest, + context: { params: Promise<{ token: string }> }, +) { + return unsubscribe(req, context); +} + +export async function POST( + req: NextRequest, + context: { params: Promise<{ token: string }> }, +) { + return unsubscribe(req, context); +} diff --git a/apps/web/components/admin/dashboard-skeleton/nav-user.tsx b/apps/web/components/admin/dashboard-skeleton/nav-user.tsx index febcd9369..2f5944be9 100644 --- a/apps/web/components/admin/dashboard-skeleton/nav-user.tsx +++ b/apps/web/components/admin/dashboard-skeleton/nav-user.tsx @@ -1,6 +1,6 @@ "use client"; -import { ChevronsUpDown, LogOut, UserPen } from "lucide-react"; +import { Bell, ChevronsUpDown, LogOut, UserPen } from "lucide-react"; import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"; import { @@ -21,6 +21,13 @@ import { import { useContext } from "react"; import { ProfileContext } from "@components/contexts"; import Link from "next/link"; +import { Chip } from "@courselit/components-library"; +import { + BETA_LABEL, + LOGOUT, + MAIN_MENU_ITEM_NOTIFICATIONS, + MAIN_MENU_ITEM_PROFILE, +} from "@ui-config/strings"; export function NavUser() { const { isMobile } = useSidebar(); @@ -95,7 +102,16 @@ export function NavUser() { - Profile + {MAIN_MENU_ITEM_PROFILE} + + + + +
+ + {MAIN_MENU_ITEM_NOTIFICATIONS} +
+ {BETA_LABEL}
@@ -118,7 +134,7 @@ export function NavUser() { - Log out + {LOGOUT} diff --git a/apps/web/graphql/communities/logic.ts b/apps/web/graphql/communities/logic.ts index 244b37e7d..bf488b329 100644 --- a/apps/web/graphql/communities/logic.ts +++ b/apps/web/graphql/communities/logic.ts @@ -56,8 +56,6 @@ import { toggleContentVisibility, } from "./helpers"; import { error } from "@/services/logger"; -import NotificationModel from "@models/Notification"; -import { addNotification } from "@/services/queue"; import { hasActiveSubscription } from "../users/logic"; import { internal } from "@config/strings"; import { hasCommunityPermission as hasPermission } from "@ui-lib/utils"; @@ -68,6 +66,7 @@ import CommunityPostSubscriberModel from "@models/CommunityPostSubscriber"; import InvoiceModel from "@models/Invoice"; import { InternalMembership } from "@courselit/common-logic"; import { replaceTempMediaWithSealedMediaInProseMirrorDoc } from "@/lib/replace-temp-media-with-sealed-media-in-prosemirror-doc"; +import { recordActivity } from "@/lib/record-activity"; const { permissions, communityPage } = constants; @@ -547,14 +546,14 @@ export async function joinCommunity({ role: Constants.MembershipRole.MODERATE, }); - addNotification({ - domain: ctx.subdomain._id.toString(), - entityId: community.communityId, - entityAction: - Constants.NotificationEntityAction - .COMMUNITY_MEMBERSHIP_REQUESTED, - forUserIds: communityManagers.map((m) => m.userId), + await recordActivity({ + domain: ctx.subdomain._id, userId: ctx.user.userId, + type: Constants.ActivityType.COMMUNITY_MEMBERSHIP_REQUESTED, + entityId: community.communityId, + metadata: { + forUserIds: communityManagers.map((m) => m.userId), + }, }); } @@ -641,14 +640,17 @@ export async function createCommunityPost({ status: Constants.MembershipStatus.ACTIVE, }).lean(); - await addNotification({ - domain: ctx.subdomain._id.toString(), - entityId: post.postId, - entityAction: Constants.NotificationEntityAction.COMMUNITY_POSTED, - forUserIds: members - .map((m) => m.userId) - .filter((id) => id !== ctx.user.userId), + await recordActivity({ + domain: ctx.subdomain._id, userId: ctx.user.userId, + type: Constants.ActivityType.COMMUNITY_POST_CREATED, + entityId: post.postId, + metadata: { + communityId: community.communityId, + forUserIds: members + .map((m) => m.userId) + .filter((id) => id !== ctx.user.userId), + }, }); } catch (err) { error( @@ -1097,13 +1099,14 @@ export async function updateMemberStatus({ }); } - await addNotification({ - domain: ctx.subdomain._id.toString(), - entityId: community.communityId, - entityAction: - Constants.NotificationEntityAction.COMMUNITY_MEMBERSHIP_GRANTED, - forUserIds: [userId], + await recordActivity({ + domain: ctx.subdomain._id, userId: ctx.user.userId, + type: Constants.ActivityType.COMMUNITY_MEMBERSHIP_GRANTED, + entityId: community.communityId, + metadata: { + forUserIds: [userId], + }, }); } @@ -1237,24 +1240,16 @@ export async function togglePostLike({ await post.save(); if (liked && post.userId !== ctx.user.userId) { - const existingNotification = await NotificationModel.findOne({ + await recordActivity({ domain: ctx.subdomain._id, - entityId: post.postId, - entityAction: - Constants.NotificationEntityAction.COMMUNITY_POST_LIKED, - forUserId: post.userId, userId: ctx.user.userId, - }); - if (!existingNotification) { - await addNotification({ - domain: ctx.subdomain._id.toString(), - entityId: post.postId, - entityAction: - Constants.NotificationEntityAction.COMMUNITY_POST_LIKED, + type: Constants.ActivityType.COMMUNITY_POST_LIKED, + entityId: post.postId, + metadata: { + communityId: community.communityId, forUserIds: [post.userId], - userId: ctx.user.userId, - }); - } + }, + }); } return formatPost(post, ctx.user.userId); @@ -1387,13 +1382,18 @@ export async function postComment({ userId: ctx.user.userId, }); - await addNotification({ - domain: ctx.subdomain._id.toString(), - entityId: replyId, - entityAction: Constants.NotificationEntityAction.COMMUNITY_REPLIED, - forUserIds: postSubscribers.map((s) => s.userId), + await recordActivity({ + domain: ctx.subdomain._id, userId: ctx.user.userId, - entityTargetId: comment.commentId, + type: Constants.ActivityType.COMMUNITY_REPLY_CREATED, + entityId: replyId, + metadata: { + communityId: community.communityId, + postId: post.postId, + commentId: comment.commentId, + entityTargetId: comment.commentId, + forUserIds: postSubscribers.map((s) => s.userId), + }, }); } else { comment = await CommunityCommentModel.create({ @@ -1411,13 +1411,16 @@ export async function postComment({ userId: ctx.user.userId, }); - await addNotification({ - domain: ctx.subdomain._id.toString(), - entityId: post.postId, - entityAction: - Constants.NotificationEntityAction.COMMUNITY_COMMENTED, - forUserIds: postSubscribers.map((s) => s.userId), + await recordActivity({ + domain: ctx.subdomain._id, userId: ctx.user.userId, + type: Constants.ActivityType.COMMUNITY_COMMENT_CREATED, + entityId: comment.commentId, + metadata: { + communityId: community.communityId, + postId: post.postId, + forUserIds: postSubscribers.map((s) => s.userId), + }, }); } @@ -1524,24 +1527,17 @@ export async function toggleCommentLike({ await comment.save(); if (liked && comment.userId !== ctx.user.userId) { - const existingNotification = await NotificationModel.findOne({ + await recordActivity({ domain: ctx.subdomain._id, - entityId: comment.commentId, - entityAction: - Constants.NotificationEntityAction.COMMUNITY_COMMENT_LIKED, - forUserId: comment.userId, userId: ctx.user.userId, - }); - if (!existingNotification) { - await addNotification({ - domain: ctx.subdomain._id.toString(), - entityId: comment.commentId, - entityAction: - Constants.NotificationEntityAction.COMMUNITY_COMMENT_LIKED, + type: Constants.ActivityType.COMMUNITY_COMMENT_LIKED, + entityId: comment.commentId, + metadata: { + communityId: community.communityId, + postId, forUserIds: [comment.userId], - userId: ctx.user.userId, - }); - } + }, + }); } return formatComment(comment, ctx.user.userId); @@ -1605,25 +1601,19 @@ export async function toggleCommentReplyLike({ await comment.save(); if (liked && reply.userId !== ctx.user.userId) { - const existingNotification = await NotificationModel.findOne({ + await recordActivity({ domain: ctx.subdomain._id, - entityId: reply.replyId, - entityAction: - Constants.NotificationEntityAction.COMMUNITY_REPLY_LIKED, - forUserId: reply.userId, userId: ctx.user.userId, - }); - if (!existingNotification) { - await addNotification({ - domain: ctx.subdomain._id.toString(), - entityId: reply.replyId, - entityAction: - Constants.NotificationEntityAction.COMMUNITY_REPLY_LIKED, - forUserIds: [reply.userId], - userId: ctx.user.userId, + type: Constants.ActivityType.COMMUNITY_REPLY_LIKED, + entityId: reply.replyId, + metadata: { + communityId: community.communityId, + postId, + commentId: comment.commentId, entityTargetId: comment.commentId, - }); - } + forUserIds: [reply.userId], + }, + }); } return formatComment(comment, ctx.user.userId); @@ -1785,6 +1775,23 @@ export async function leaveCommunity({ await member.deleteOne(); + const communityManagers: Membership[] = await MembershipModel.find({ + domain: ctx.subdomain._id, + entityId: community.communityId, + entityType: Constants.MembershipEntityType.COMMUNITY, + role: Constants.MembershipRole.MODERATE, + }); + + await recordActivity({ + domain: ctx.subdomain._id, + userId: ctx.user.userId, + type: Constants.ActivityType.COMMUNITY_LEFT, + entityId: community.communityId, + metadata: { + forUserIds: communityManagers.map((m) => m.userId), + }, + }); + return true; } diff --git a/apps/web/graphql/notifications/__tests__/logic.test.ts b/apps/web/graphql/notifications/__tests__/logic.test.ts new file mode 100644 index 000000000..cc87ef11e --- /dev/null +++ b/apps/web/graphql/notifications/__tests__/logic.test.ts @@ -0,0 +1,328 @@ +import { + getNotificationPreferences, + getNotification, + seedNotificationPreferencesForUser, + updateNotificationPreference, +} from "../logic"; +import DomainModel from "@models/Domain"; +import UserModel from "@models/User"; +import NotificationPreferenceModel from "@models/NotificationPreference"; +import NotificationModel from "@models/Notification"; +import CommunityModel from "@models/Community"; +import CommunityPostModel from "@models/CommunityPost"; +import constants from "@/config/constants"; +import { Constants } from "@courselit/common-models"; + +const SUITE_PREFIX = `notification-preferences-${Date.now()}`; +const id = (suffix: string) => `${SUITE_PREFIX}-${suffix}`; +const email = (suffix: string) => `${suffix}-${SUITE_PREFIX}@example.com`; + +describe("Notification Preferences", () => { + let domain: any; + let learner: any; + let manager: any; + + beforeAll(async () => { + domain = await DomainModel.create({ + name: id("domain"), + email: email("domain"), + }); + + learner = await UserModel.create({ + domain: domain._id, + userId: id("learner"), + email: email("learner"), + name: "Learner", + permissions: [constants.permissions.enrollInCourse], + active: true, + purchases: [], + unsubscribeToken: id("unsub-learner"), + }); + + manager = await UserModel.create({ + domain: domain._id, + userId: id("manager"), + email: email("manager"), + name: "Manager", + permissions: [constants.permissions.manageAnyCourse], + active: true, + purchases: [], + unsubscribeToken: id("unsub-manager"), + }); + }); + + afterEach(async () => { + await NotificationPreferenceModel.deleteMany({ domain: domain._id }); + await NotificationModel.deleteMany({ domain: domain._id }); + await CommunityPostModel.deleteMany({ domain: domain._id }); + await CommunityModel.deleteMany({ domain: domain._id }); + }); + + afterAll(async () => { + await NotificationModel.deleteMany({ domain: domain._id }); + await CommunityPostModel.deleteMany({ domain: domain._id }); + await CommunityModel.deleteMany({ domain: domain._id }); + await NotificationPreferenceModel.deleteMany({ domain: domain._id }); + await UserModel.deleteMany({ domain: domain._id }); + await DomainModel.deleteOne({ _id: domain._id }); + }); + + it("should return an empty list when preferences are not seeded", async () => { + const preferences = await getNotificationPreferences({ + ctx: { + user: learner, + subdomain: domain, + } as any, + }); + + expect(preferences).toEqual([]); + }); + + it("should seed only general preferences", async () => { + await seedNotificationPreferencesForUser({ + domain: domain._id, + userId: manager.userId, + }); + + const preferences = await getNotificationPreferences({ + ctx: { + user: manager, + subdomain: domain, + } as any, + }); + + const purchasedPreference = preferences.find( + (preference) => + preference.activityType === Constants.ActivityType.PURCHASED, + ); + + const generalPreference = preferences.find( + (preference) => + preference.activityType === + Constants.ActivityType.COMMUNITY_POST_CREATED, + ); + + expect(generalPreference).toBeTruthy(); + expect(generalPreference?.channels).toEqual([ + Constants.NotificationChannel.APP, + Constants.NotificationChannel.EMAIL, + ]); + expect(purchasedPreference).toBeUndefined(); + }); + + it("should include general preferences for users", async () => { + await seedNotificationPreferencesForUser({ + domain: domain._id, + userId: manager.userId, + }); + + const preferences = await getNotificationPreferences({ + ctx: { + user: manager, + subdomain: domain, + } as any, + }); + + const generalPreference = preferences.find( + (preference) => + preference.activityType === + Constants.ActivityType.COMMUNITY_POST_CREATED, + ); + + expect(generalPreference).toBeTruthy(); + expect(generalPreference?.channels).toEqual([ + Constants.NotificationChannel.APP, + Constants.NotificationChannel.EMAIL, + ]); + }); + + it("should return persisted channels for saved preference", async () => { + await NotificationPreferenceModel.create({ + domain: domain._id, + userId: manager.userId, + activityType: Constants.ActivityType.COMMUNITY_POST_CREATED, + channels: [Constants.NotificationChannel.APP], + }); + + const preferences = await getNotificationPreferences({ + ctx: { + user: manager, + subdomain: domain, + } as any, + }); + + const preference = preferences.find( + (item) => + item.activityType === + Constants.ActivityType.COMMUNITY_POST_CREATED, + ); + + expect(preference?.channels).toEqual([ + Constants.NotificationChannel.APP, + ]); + }); + + it("should update a valid notification preference", async () => { + const updated = await updateNotificationPreference({ + ctx: { + user: learner, + subdomain: domain, + } as any, + activityType: Constants.ActivityType.COMMUNITY_POST_CREATED, + channels: [Constants.NotificationChannel.APP], + }); + + expect(updated.activityType).toBe( + Constants.ActivityType.COMMUNITY_POST_CREATED, + ); + expect(updated.channels).toEqual([Constants.NotificationChannel.APP]); + + const persisted = await NotificationPreferenceModel.findOne({ + domain: domain._id, + userId: learner.userId, + activityType: Constants.ActivityType.COMMUNITY_POST_CREATED, + }).lean(); + + expect(persisted?.channels).toEqual([ + Constants.NotificationChannel.APP, + ]); + }); + + it("should reject updates for unauthorized activity", async () => { + await expect( + updateNotificationPreference({ + ctx: { + user: learner, + subdomain: domain, + } as any, + activityType: Constants.ActivityType.USER_CREATED, + channels: [Constants.NotificationChannel.APP], + }), + ).rejects.toThrow("You do not have rights to perform this action"); + }); + + it("should allow updating general preference", async () => { + const updated = await updateNotificationPreference({ + ctx: { + user: manager, + subdomain: domain, + } as any, + activityType: Constants.ActivityType.COMMUNITY_POST_CREATED, + channels: [Constants.NotificationChannel.APP], + }); + + expect(updated.activityType).toBe( + Constants.ActivityType.COMMUNITY_POST_CREATED, + ); + expect(updated.channels).toEqual([Constants.NotificationChannel.APP]); + }); + + it("should delete preference row when channels are cleared", async () => { + await NotificationPreferenceModel.create({ + domain: domain._id, + userId: learner.userId, + activityType: Constants.ActivityType.COMMUNITY_POST_CREATED, + channels: [Constants.NotificationChannel.APP], + }); + + const updated = await updateNotificationPreference({ + ctx: { + user: learner, + subdomain: domain, + } as any, + activityType: Constants.ActivityType.COMMUNITY_POST_CREATED, + channels: [], + }); + + expect(updated).toEqual({ + activityType: Constants.ActivityType.COMMUNITY_POST_CREATED, + channels: [], + }); + + const persisted = await NotificationPreferenceModel.findOne({ + domain: domain._id, + userId: learner.userId, + activityType: Constants.ActivityType.COMMUNITY_POST_CREATED, + }).lean(); + + expect(persisted).toBeNull(); + }); + + it("should format notification message and href using shared formatter", async () => { + const community = await CommunityModel.create({ + domain: domain._id, + communityId: id("community"), + name: "Community A", + pageId: id("community-page"), + }); + + const post = await CommunityPostModel.create({ + domain: domain._id, + postId: id("post"), + communityId: community.communityId, + userId: learner.userId, + title: "A post title for notification formatting", + content: "Sample content", + category: "General", + }); + + const notification = await NotificationModel.create({ + domain: domain._id, + notificationId: id("notification"), + userId: learner.userId, + forUserId: manager.userId, + activityType: Constants.ActivityType.COMMUNITY_POST_CREATED, + entityId: post.postId, + }); + + const response = await getNotification({ + ctx: { + user: manager, + subdomain: domain, + } as any, + notificationId: notification.notificationId, + }); + + expect(response).toBeTruthy(); + expect(response?.href).toBe( + `/dashboard/community/${community.communityId}`, + ); + expect(response?.message).toContain("created a post"); + expect(response?.message).toContain("Community A"); + }); + + it("should return empty message and href when entity cannot be resolved", async () => { + const notification = await NotificationModel.create({ + domain: domain._id, + notificationId: id("missing-entity-notification"), + userId: learner.userId, + forUserId: manager.userId, + activityType: Constants.ActivityType.COMMUNITY_POST_CREATED, + entityId: id("missing-post"), + }); + + const response = await getNotification({ + ctx: { + user: manager, + subdomain: domain, + } as any, + notificationId: notification.notificationId, + }); + + expect(response).toBeTruthy(); + expect(response?.href).toBe(""); + expect(response?.message).toBe(""); + }); + + it("should require activityType on notification documents", async () => { + await expect( + NotificationModel.create({ + domain: domain._id, + notificationId: id("invalid-notification"), + userId: learner.userId, + forUserId: manager.userId, + entityId: id("entity"), + } as any), + ).rejects.toThrow("activityType"); + }); +}); diff --git a/apps/web/graphql/notifications/enums.ts b/apps/web/graphql/notifications/enums.ts new file mode 100644 index 000000000..073b3a1c7 --- /dev/null +++ b/apps/web/graphql/notifications/enums.ts @@ -0,0 +1,22 @@ +import { Constants } from "@courselit/common-models"; +import { GraphQLEnumType } from "graphql"; + +export const notificationPreferenceActivityType = new GraphQLEnumType({ + name: "NotificationPreferenceActivityType", + values: Object.fromEntries( + Object.entries(Constants.ActivityType).map(([key, value]) => [ + key, + { value }, + ]), + ), +}); + +export const notificationChannelType = new GraphQLEnumType({ + name: "NotificationChannelType", + values: Object.fromEntries( + Object.entries(Constants.NotificationChannel).map(([key, value]) => [ + key, + { value }, + ]), + ), +}); diff --git a/apps/web/graphql/notifications/helpers.ts b/apps/web/graphql/notifications/helpers.ts new file mode 100644 index 000000000..bc54a6646 --- /dev/null +++ b/apps/web/graphql/notifications/helpers.ts @@ -0,0 +1,69 @@ +import { + ActivityType, + Constants, + NotificationChannel, +} from "@courselit/common-models"; +import { checkPermission } from "@courselit/utils"; + +function isGeneralActivity(activityType: ActivityType): boolean { + return getRequiredPermissionForActivity(activityType) === ""; +} + +function getRequiredPermissionForActivity( + activityType: ActivityType, +): string | null { + return Constants.ActivityPermissionMap[activityType] ?? null; +} + +export function isActivityAllowedForPermissions( + activityType: ActivityType, + permissions: string[], +): boolean { + if (isGeneralActivity(activityType)) { + return true; + } + + const requiredPermission = getRequiredPermissionForActivity(activityType); + if (!requiredPermission) { + return false; + } + + return checkPermission(permissions, [requiredPermission]); +} + +export function getAllowedActivityTypesForPermissions( + permissions: string[], +): ActivityType[] { + return ( + Object.values(Constants.ActivityType).filter((activityType) => + isActivityAllowedForPermissions(activityType, permissions), + ) as ActivityType[] + ).sort((a, b) => a.localeCompare(b)); +} + +function getDefaultChannelsForActivity( + activityType: ActivityType, +): NotificationChannel[] { + if (isGeneralActivity(activityType)) { + return [ + Constants.NotificationChannel.APP, + Constants.NotificationChannel.EMAIL, + ]; + } + + return []; +} + +export function getGeneralDefaultPreferences(): { + activityType: ActivityType; + channels: NotificationChannel[]; +}[] { + return ( + Object.values(Constants.ActivityType) + .filter((activityType) => isGeneralActivity(activityType)) + .sort((a, b) => a.localeCompare(b)) as ActivityType[] + ).map((activityType) => ({ + activityType, + channels: getDefaultChannelsForActivity(activityType), + })); +} diff --git a/apps/web/graphql/notifications/logic.ts b/apps/web/graphql/notifications/logic.ts index b0de7726e..8666d2f03 100644 --- a/apps/web/graphql/notifications/logic.ts +++ b/apps/web/graphql/notifications/logic.ts @@ -1,16 +1,192 @@ +import { responses } from "@/config/strings"; import { checkIfAuthenticated } from "@/lib/graphql"; +import { getNotificationMessageAndHref } from "@courselit/common-logic"; import { + ActivityType, Constants, Notification, - NotificationEntityAction, + NotificationChannel, } from "@courselit/common-models"; -import Community from "@models/Community"; -import CommunityComment from "@models/CommunityComment"; -import CommunityPost from "@models/CommunityPost"; import GQLContext from "@models/GQLContext"; -import NotificationModel, { InternalNotification } from "@models/Notification"; +import NotificationModel from "@models/Notification"; +import NotificationPreferenceModel from "@models/NotificationPreference"; import UserModel from "@models/User"; -import { truncate } from "@ui-lib/utils"; +import mongoose from "mongoose"; +import { + getGeneralDefaultPreferences, + getAllowedActivityTypesForPermissions, + isActivityAllowedForPermissions, +} from "./helpers"; + +interface NotificationDocument { + notificationId: string; + userId: string; + activityType: ActivityType; + entityId: string; + entityTargetId?: string; + metadata?: Record; + read: boolean; + createdAt: Date; +} + +export interface NotificationPreferenceItem { + activityType: ActivityType; + channels: NotificationChannel[]; +} + +export async function seedNotificationPreferencesForUser({ + domain, + userId, +}: { + domain: mongoose.Types.ObjectId; + userId: string; +}): Promise { + const defaults = getGeneralDefaultPreferences(); + + if (!defaults.length) { + return; + } + + await NotificationPreferenceModel.bulkWrite( + defaults.map(({ activityType, channels }) => ({ + updateOne: { + filter: { + domain, + userId, + activityType, + }, + update: { + $setOnInsert: { + domain, + userId, + activityType, + channels, + }, + }, + upsert: true, + }, + })), + ); +} + +export async function getNotificationPreferences({ + ctx, +}: { + ctx: GQLContext; +}): Promise { + checkIfAuthenticated(ctx); + + const allowedActivityTypes = getAllowedActivityTypesForPermissions( + ctx.user.permissions, + ); + + const preferences = await NotificationPreferenceModel.find( + { + domain: ctx.subdomain._id, + userId: ctx.user.userId, + activityType: { + $in: allowedActivityTypes, + }, + }, + { + _id: 0, + activityType: 1, + channels: 1, + }, + ) + .sort({ activityType: 1 }) + .lean(); + + const preferencesByActivityType = new Map< + ActivityType, + NotificationPreferenceItem + >( + preferences.map((preference) => [ + preference.activityType, + { + activityType: preference.activityType, + channels: preference.channels, + }, + ]), + ); + + return allowedActivityTypes + .map((activityType) => preferencesByActivityType.get(activityType)) + .filter((preference): preference is NotificationPreferenceItem => + Boolean(preference), + ); +} + +export async function updateNotificationPreference({ + ctx, + activityType, + channels, +}: { + ctx: GQLContext; + activityType: ActivityType; + channels: NotificationChannel[]; +}): Promise { + checkIfAuthenticated(ctx); + + if (!Object.values(Constants.ActivityType).includes(activityType)) { + throw new Error(responses.invalid_input); + } + + if (!isActivityAllowedForPermissions(activityType, ctx.user.permissions)) { + throw new Error(responses.action_not_allowed); + } + + const uniqueChannels = Array.from(new Set(channels)); + const validChannels = Object.values(Constants.NotificationChannel); + + if (!uniqueChannels.every((channel) => validChannels.includes(channel))) { + throw new Error(responses.invalid_input); + } + + if (!uniqueChannels.length) { + await NotificationPreferenceModel.deleteOne({ + domain: ctx.subdomain._id, + userId: ctx.user.userId, + activityType, + }); + + return { + activityType, + channels: [], + }; + } + + const preference = await NotificationPreferenceModel.findOneAndUpdate( + { + domain: ctx.subdomain._id, + userId: ctx.user.userId, + activityType, + }, + { + $set: { + channels: uniqueChannels, + }, + $setOnInsert: { + domain: ctx.subdomain._id, + userId: ctx.user.userId, + activityType, + }, + }, + { + upsert: true, + new: true, + }, + ); + + if (!preference) { + throw new Error(responses.internal_error); + } + + return { + activityType: preference.activityType, + channels: preference.channels, + }; +} export async function getNotification({ ctx, @@ -21,17 +197,20 @@ export async function getNotification({ }): Promise { checkIfAuthenticated(ctx); - const notification = await NotificationModel.findOne({ + const notification = await NotificationModel.findOne({ domain: ctx.subdomain._id, forUserId: ctx.user.userId, notificationId, - }); + }).lean(); if (!notification) { return null; } - return await formatNotification(notification, ctx); + return await formatNotification( + notification as unknown as NotificationDocument, + ctx, + ); } export async function getNotifications({ @@ -46,61 +225,60 @@ export async function getNotifications({ notifications: Notification[]; total: number; }> { - const { notifications, total } = await (NotificationModel as any).paginate( - ctx.user.userId, - { - page, - limit, - }, - ); + checkIfAuthenticated(ctx); - const result = notifications.length - ? { - notifications: await formatNotifications(notifications, ctx), - total, - } - : { - notifications: [], - total: 0, - }; - - return result; -} + const safePage = Math.max(1, page); + const safeLimit = Math.max(1, limit); + const skip = (safePage - 1) * safeLimit; -async function formatNotifications( - notifications: InternalNotification[], - ctx: GQLContext, -): Promise { - // const users = await UserModel.find( - // { - // userId: { - // $in: notifications.map((n) => n.userId), - // }, - // }, - // { - // userId: 1, - // name: 1, - // email: 1, - // _id: 0, - // }, - // ); - - return Promise.all( - notifications.map(async (notification) => { - return await formatNotification(notification, ctx); - }), - ); + const query = { + domain: ctx.subdomain._id, + forUserId: ctx.user.userId, + }; + + const [notifications, total] = await Promise.all([ + NotificationModel.find(query) + .sort({ createdAt: -1 }) + .skip(skip) + .limit(safeLimit) + .lean(), + NotificationModel.countDocuments(query), + ]); + + if (!notifications.length) { + return { + notifications: [], + total: 0, + }; + } + + return { + notifications: await Promise.all( + notifications.map((notification) => + formatNotification( + notification as unknown as NotificationDocument, + ctx, + ), + ), + ), + total, + }; } -async function formatNotification(notification, ctx): Promise { +async function formatNotification( + notification: NotificationDocument, + ctx: GQLContext, +): Promise { return { notificationId: notification.notificationId, - ...(await getMessage({ - entityAction: notification.entityAction, + ...(await getNotificationMessageAndHref({ + activityType: notification.activityType, entityId: notification.entityId, - userName: await getUserName(notification.userId), - loggedInUserId: ctx.user.userId, + actorName: await getUserName(notification.userId), + recipientUserId: ctx.user.userId, entityTargetId: notification.entityTargetId, + metadata: notification.metadata, + domainId: ctx.subdomain._id, })), read: notification.read, createdAt: notification.createdAt, @@ -112,198 +290,6 @@ async function getUserName(userId: string): Promise { return user?.name || user?.email || "Someone"; } -async function getMessage({ - entityAction, - entityId, - userName, - loggedInUserId, - entityTargetId, -}: { - entityAction: NotificationEntityAction; - entityId: string; - entityTargetId?: string; - userName: string; - loggedInUserId: string; -}): Promise<{ message: string; href: string }> { - switch (entityAction) { - case Constants.NotificationEntityAction.COMMUNITY_POSTED: - let post = await CommunityPost.findOne({ - postId: entityId, - }); - if (!post) { - return { message: "", href: "" }; - } - let community = await Community.findOne({ - communityId: post.communityId, - }); - if (!community) { - return { message: "", href: "" }; - } - return { - message: `${userName} created a post '${truncate(post.title, 20).trim()}' in ${community.name}`, - href: `/dashboard/community/${community.communityId}`, - }; - case Constants.NotificationEntityAction.COMMUNITY_COMMENTED: - const post1 = await CommunityPost.findOne({ - postId: entityId, - }); - if (!post1) { - return { message: "", href: "" }; - } - const community1 = await Community.findOne({ - communityId: post1.communityId, - }); - if (!community1) { - return { message: "", href: "" }; - } - - return { - message: `${userName} commented on ${loggedInUserId === post1.userId ? "your" : ""} post '${truncate(post1.title, 20).trim()}' in ${community1.name}`, - href: `/dashboard/community/${community1.communityId}`, - }; - case Constants.NotificationEntityAction.COMMUNITY_REPLIED: - const comment = await CommunityComment.findOne({ - commentId: entityTargetId, - }); - if (!comment) { - return { message: "", href: "" }; - } - const reply = comment.replies.find((r) => r.replyId === entityId); - if (!reply) { - return { message: "", href: "" }; - } - let parentReply; - if (reply.parentReplyId) { - parentReply = comment.replies.find( - (r) => r.replyId === reply.parentReplyId, - ); - } - - const [post2, community2] = await Promise.all([ - CommunityPost.findOne({ - postId: comment.postId, - }), - Community.findOne({ - communityId: comment.communityId, - }), - ]); - - if (!post2 || !community2) { - return { message: "", href: "" }; - } - - const prefix = parentReply - ? loggedInUserId === parentReply.userId - ? "your" - : "a" - : loggedInUserId === comment.userId - ? "your" - : "a"; - - return { - message: `${userName} replied to ${prefix} comment on '${truncate(post2.title, 20).trim()}' in ${community2.name}`, - href: `/dashboard/community/${community2.communityId}`, - }; - case Constants.NotificationEntityAction.COMMUNITY_POST_LIKED: - const post3 = await CommunityPost.findOne({ - postId: entityId, - }); - if (!post3) { - return { message: "", href: "" }; - } - const community3 = await Community.findOne({ - communityId: post3.communityId, - }); - if (!community3) { - return { message: "", href: "" }; - } - - return { - message: `${userName} liked your post '${truncate(post3.title, 20).trim()}' in ${community3.name}`, - href: `/dashboard/community/${community3.communityId}`, - }; - case Constants.NotificationEntityAction.COMMUNITY_COMMENT_LIKED: - const comment1 = await CommunityComment.findOne({ - commentId: entityId, - }); - if (!comment1) { - return { message: "", href: "" }; - } - const [post4, community4] = await Promise.all([ - CommunityPost.findOne({ - postId: comment1.postId, - }), - Community.findOne({ - communityId: comment1.communityId, - }), - ]); - - if (!post4 || !community4) { - return { message: "", href: "" }; - } - - return { - message: `${userName} liked your comment '${truncate(comment1.content, 20).trim()}' on '${truncate(post4.title, 20).trim()}' in ${community4.name}`, - href: `/dashboard/community/${community4.communityId}`, - }; - case Constants.NotificationEntityAction.COMMUNITY_REPLY_LIKED: - const comment2 = await CommunityComment.findOne({ - commentId: entityTargetId, - }); - if (!comment2) { - return { message: "", href: "" }; - } - const reply1 = comment2.replies.find((r) => r.replyId === entityId); - if (!reply1) { - return { message: "", href: "" }; - } - - const [post5, community5] = await Promise.all([ - CommunityPost.findOne({ - postId: comment2.postId, - }), - Community.findOne({ - communityId: comment2.communityId, - }), - ]); - - if (!post5 || !community5) { - return { message: "", href: "" }; - } - - return { - message: `${userName} liked your reply '${truncate(reply1.content, 20).trim()}' on '${truncate(post5.title, 20).trim()}' in ${community5.name}`, - href: `/dashboard/community/${community5.communityId}`, - }; - case Constants.NotificationEntityAction.COMMUNITY_MEMBERSHIP_REQUESTED: - const community6 = await Community.findOne({ - communityId: entityId, - }); - if (!community6) { - return { message: "", href: "" }; - } - - return { - message: `${userName} requested to join ${community6.name}`, - href: `/dashboard/community/${community6.communityId}/manage/memberships`, - }; - case Constants.NotificationEntityAction.COMMUNITY_MEMBERSHIP_GRANTED: - const community7 = await Community.findOne({ - communityId: entityId, - }); - if (!community7) { - return { message: "", href: "" }; - } - - return { - message: `${userName} granted your request to join ${community7.name}`, - href: `/dashboard/community/${community7.communityId}`, - }; - default: - return { message: "", href: "" }; - } -} - export async function markAsRead({ ctx, notificationId, diff --git a/apps/web/graphql/notifications/mutation.ts b/apps/web/graphql/notifications/mutation.ts index d300f7bac..f7d65ce05 100644 --- a/apps/web/graphql/notifications/mutation.ts +++ b/apps/web/graphql/notifications/mutation.ts @@ -1,6 +1,21 @@ import GQLContext from "@models/GQLContext"; -import { markAllAsRead, markAsRead } from "./logic"; -import { GraphQLBoolean, GraphQLNonNull, GraphQLString } from "graphql"; +import { + markAllAsRead, + markAsRead, + updateNotificationPreference, +} from "./logic"; +import { + GraphQLBoolean, + GraphQLList, + GraphQLNonNull, + GraphQLString, +} from "graphql"; +import { + notificationChannelType, + notificationPreferenceActivityType, +} from "./enums"; +import { ActivityType, NotificationChannel } from "@courselit/common-models"; +import types from "./types"; const mutations = { markAsRead: { @@ -18,6 +33,32 @@ const mutations = { type: GraphQLBoolean, resolve: async (_: any, __: any, ctx: GQLContext) => markAllAsRead(ctx), }, + updateNotificationPreference: { + type: types.notificationPreference, + args: { + activityType: { + type: new GraphQLNonNull(notificationPreferenceActivityType), + }, + channels: { + type: new GraphQLNonNull( + new GraphQLList( + new GraphQLNonNull(notificationChannelType), + ), + ), + }, + }, + resolve: async ( + _: any, + { + activityType, + channels, + }: { + activityType: ActivityType; + channels: NotificationChannel[]; + }, + ctx: GQLContext, + ) => updateNotificationPreference({ ctx, activityType, channels }), + }, }; export default mutations; diff --git a/apps/web/graphql/notifications/query.ts b/apps/web/graphql/notifications/query.ts index 0719cfdaa..44729b5fa 100644 --- a/apps/web/graphql/notifications/query.ts +++ b/apps/web/graphql/notifications/query.ts @@ -1,6 +1,10 @@ import GQLContext from "@models/GQLContext"; -import { GraphQLInt, GraphQLString } from "graphql"; -import { getNotification, getNotifications } from "./logic"; +import { GraphQLInt, GraphQLList, GraphQLString } from "graphql"; +import { + getNotification, + getNotificationPreferences, + getNotifications, +} from "./logic"; import types from "./types"; const queries = { @@ -33,6 +37,11 @@ const queries = { ctx: GQLContext, ) => getNotifications({ ctx, page, limit }), }, + getNotificationPreferences: { + type: new GraphQLList(types.notificationPreference), + resolve: (_: any, __: any, ctx: GQLContext) => + getNotificationPreferences({ ctx }), + }, }; export default queries; diff --git a/apps/web/graphql/notifications/types.ts b/apps/web/graphql/notifications/types.ts index 55ef816c7..ba46838bb 100644 --- a/apps/web/graphql/notifications/types.ts +++ b/apps/web/graphql/notifications/types.ts @@ -26,7 +26,16 @@ const notifications = new GraphQLObjectType({ }, }); +const notificationPreference = new GraphQLObjectType({ + name: "NotificationPreference", + fields: { + activityType: { type: GraphQLString }, + channels: { type: new GraphQLList(GraphQLString) }, + }, +}); + export default { notification, notifications, + notificationPreference, }; diff --git a/apps/web/graphql/users/__tests__/delete-user.test.ts b/apps/web/graphql/users/__tests__/delete-user.test.ts index 7c5d228d7..9d3c5e15c 100644 --- a/apps/web/graphql/users/__tests__/delete-user.test.ts +++ b/apps/web/graphql/users/__tests__/delete-user.test.ts @@ -14,6 +14,7 @@ import UserThemeModel from "@models/UserTheme"; import PaymentPlanModel from "@models/PaymentPlan"; import OngoingSequenceModel from "@models/OngoingSequence"; import NotificationModel from "@models/Notification"; +import NotificationPreferenceModel from "@models/NotificationPreference"; import MailRequestStatusModel from "@models/MailRequestStatus"; import LessonEvaluationModel from "@models/LessonEvaluation"; import DownloadLinkModel from "@models/DownloadLink"; @@ -136,6 +137,7 @@ describe("deleteUser - Comprehensive Test Suite", () => { PaymentPlanModel.deleteMany({ domain: testDomain._id }), OngoingSequenceModel.deleteMany({ domain: testDomain._id }), NotificationModel.deleteMany({ domain: testDomain._id }), + NotificationPreferenceModel.deleteMany({ domain: testDomain._id }), MailRequestStatusModel.deleteMany({ domain: testDomain._id }), LessonEvaluationModel.deleteMany({ domain: testDomain._id }), DownloadLinkModel.deleteMany({ domain: testDomain._id }), @@ -526,8 +528,7 @@ describe("deleteUser - Comprehensive Test Suite", () => { notificationId: "notif-1", userId: DU_OTHER_USER_ID, forUserId: targetUser.userId, - entityAction: - Constants.NotificationEntityAction.COMMUNITY_POSTED, + activityType: Constants.ActivityType.COMMUNITY_POST_CREATED, entityId: "post-123", }); @@ -545,8 +546,7 @@ describe("deleteUser - Comprehensive Test Suite", () => { notificationId: "notif-2", userId: targetUser.userId, forUserId: DU_OTHER_USER_ID, - entityAction: - Constants.NotificationEntityAction.COMMUNITY_POSTED, + activityType: Constants.ActivityType.COMMUNITY_POST_CREATED, entityId: "post-123", }); @@ -558,6 +558,22 @@ describe("deleteUser - Comprehensive Test Suite", () => { expect(notifications).toHaveLength(0); }); + it("should delete user's notification preferences", async () => { + await NotificationPreferenceModel.create({ + domain: testDomain._id, + userId: targetUser.userId, + activityType: Constants.ActivityType.COMMUNITY_POST_CREATED, + channels: [Constants.NotificationChannel.APP], + }); + + await deleteUser(targetUser.userId, mockCtx); + + const preferences = await NotificationPreferenceModel.find({ + userId: targetUser.userId, + }); + expect(preferences).toHaveLength(0); + }); + it("should delete mail request status", async () => { await MailRequestStatusModel.create({ domain: testDomain._id, @@ -983,8 +999,7 @@ describe("deleteUser - Comprehensive Test Suite", () => { notificationId: "notif-1", userId: targetUser.userId, forUserId: DU_OTHER_USER_ID, - entityAction: - Constants.NotificationEntityAction.COMMUNITY_POSTED, + activityType: Constants.ActivityType.COMMUNITY_POST_CREATED, entityId: "post-123", }); diff --git a/apps/web/graphql/users/helpers.ts b/apps/web/graphql/users/helpers.ts index c7b99362a..e4f47fca4 100644 --- a/apps/web/graphql/users/helpers.ts +++ b/apps/web/graphql/users/helpers.ts @@ -22,6 +22,7 @@ import OngoingSequenceModel from "@models/OngoingSequence"; import LessonModel from "@models/Lesson"; import MembershipModel from "@models/Membership"; import NotificationModel from "@models/Notification"; +import NotificationPreferenceModel from "@models/NotificationPreference"; import MailRequestStatusModel from "@models/MailRequestStatus"; import LessonEvaluationModel from "@models/LessonEvaluation"; import DownloadLinkModel from "@models/DownloadLink"; @@ -264,6 +265,10 @@ export async function cleanupPersonalData( { userId: userToDelete.userId }, ], }), + NotificationPreferenceModel.deleteMany({ + domain: ctx.subdomain._id, + userId: userToDelete.userId, + }), MailRequestStatusModel.deleteMany({ domain: ctx.subdomain._id, userId: userToDelete.userId, diff --git a/apps/web/graphql/users/logic.ts b/apps/web/graphql/users/logic.ts index 80a915288..b1dd2ae24 100644 --- a/apps/web/graphql/users/logic.ts +++ b/apps/web/graphql/users/logic.ts @@ -7,7 +7,12 @@ import constants from "@/config/constants"; import GQLContext from "@/models/GQLContext"; import { initMandatoryPages } from "../pages/logic"; import { Domain } from "@models/Domain"; -import { checkPermission, generateUniqueId } from "@courselit/utils"; +import { + checkPermission, + generateUniqueId, + getEmailFrom, + getPlanPrice, +} from "@courselit/utils"; import UserSegmentModel from "@models/UserSegment"; import { InternalCourse, @@ -32,7 +37,6 @@ import { triggerSequences } from "@/lib/trigger-sequences"; import { getCourseOrThrow } from "../courses/logic"; import pug from "pug"; import courseEnrollTemplate from "@/templates/course-enroll"; -import { generateEmailFrom } from "@/lib/utils"; import MembershipModel from "@models/Membership"; import CommunityModel from "@models/Community"; import CourseModel from "@models/Course"; @@ -49,7 +53,6 @@ import { convertFiltersToDBConditions, InternalMembership, } from "@courselit/common-logic"; -import { getPlanPrice } from "@courselit/utils"; import CertificateModel from "@models/Certificate"; import CertificateTemplateModel, { CertificateTemplate, @@ -62,6 +65,7 @@ import { const { permissions } = UIConstants; import { ObjectId } from "mongodb"; import { sealMedia } from "@/services/medialit"; +import { seedNotificationPreferencesForUser } from "../notifications/logic"; const removeAdminFieldsFromUserObject = (user: any) => ({ id: user._id, @@ -154,6 +158,17 @@ export const updateUser = async (userData: UserData, ctx: GQLContext) => { user = await user.save(); + if (Object.prototype.hasOwnProperty.call(userData, "subscribedToUpdates")) { + recordActivity({ + domain: ctx.subdomain._id, + userId: user.userId, + type: userData.subscribedToUpdates + ? Constants.ActivityType.NEWSLETTER_SUBSCRIBED + : Constants.ActivityType.NEWSLETTER_UNSUBSCRIBED, + entityId: user.userId, + }); + } + return user; }; @@ -220,7 +235,7 @@ export const inviteCustomer = async ( to: [user.email], subject: `You have been invited to ${course.title}`, body: emailBody, - from: generateEmailFrom({ + from: getEmailFrom({ name: ctx.subdomain?.settings?.title || ctx.subdomain.name, email: process.env.EMAIL_FROM || ctx.subdomain.email, }), @@ -424,6 +439,11 @@ export async function createUser({ const isNewUser = !rawResult.lastErrorObject!.updatedExisting; if (isNewUser) { + await seedNotificationPreferencesForUser({ + domain: domain._id, + userId: createdUser.userId, + }); + if (superAdmin) { await initMandatoryPages(domain, createdUser); await createInternalPaymentPlan(domain, createdUser.userId); @@ -463,6 +483,13 @@ export async function updateUserAfterCreationViaAuth( { new: true }, ); + if (updatedUser) { + await seedNotificationPreferencesForUser({ + domain: domain._id, + userId: updatedUser.userId, + }); + } + await recordActivityAndTriggerSequences(updatedUser, domain); } @@ -474,6 +501,7 @@ async function recordActivityAndTriggerSequences( domain: domain._id, userId: user.userId, type: Constants.ActivityType.USER_CREATED, + entityId: user.userId, }); if (user.subscribedToUpdates) { @@ -486,6 +514,7 @@ async function recordActivityAndTriggerSequences( domain: domain!._id, userId: user.userId, type: Constants.ActivityType.NEWSLETTER_SUBSCRIBED, + entityId: user.userId, }); } } diff --git a/apps/web/jest.server.config.ts b/apps/web/jest.server.config.ts index 8218f5b8d..4e6b0c83f 100644 --- a/apps/web/jest.server.config.ts +++ b/apps/web/jest.server.config.ts @@ -7,6 +7,9 @@ const config: Config = { moduleNameMapper: { "@courselit/utils": "/../../packages/utils/src", "@courselit/common-logic": "/../../packages/common-logic/src", + "@courselit/common-models": + "/../../packages/common-models/src", + "@courselit/orm-models": "/../../packages/orm-models/src", "@courselit/page-primitives": "/../../packages/page-primitives/src", nanoid: "/__mocks__/nanoid.ts", diff --git a/apps/web/lib/record-activity.ts b/apps/web/lib/record-activity.ts index 96b7af0a2..28913b57b 100644 --- a/apps/web/lib/record-activity.ts +++ b/apps/web/lib/record-activity.ts @@ -1,20 +1,48 @@ import ActivityModel, { Activity } from "@models/Activity"; import { error } from "../services/logger"; +import { addNotificationDispatchJob } from "@/services/queue"; +import { Constants } from "@courselit/common-models"; + +const MULTIPLE_ENTRIES_ALLOWED = [ + Constants.ActivityType.NEWSLETTER_SUBSCRIBED, + Constants.ActivityType.NEWSLETTER_UNSUBSCRIBED, + Constants.ActivityType.COMMUNITY_MEMBERSHIP_REQUESTED, + Constants.ActivityType.COMMUNITY_MEMBERSHIP_GRANTED, + Constants.ActivityType.COMMUNITY_JOINED, + Constants.ActivityType.COMMUNITY_LEFT, +]; export async function recordActivity(activity: Activity) { try { - const existingActivity = await ActivityModel.findOne({ - domain: activity.domain, - userId: activity.userId, - type: activity.type, - entityId: activity.entityId, - }); + let existingActivity = null; + if (!MULTIPLE_ENTRIES_ALLOWED.includes(activity.type as any)) { + existingActivity = await ActivityModel.findOne({ + domain: activity.domain, + userId: activity.userId, + type: activity.type, + entityId: activity.entityId, + metadata: activity.metadata, + }); + } if (existingActivity) { return; } - await ActivityModel.create(activity); + const createdActivity = await ActivityModel.create(activity); + + await addNotificationDispatchJob({ + domain: activity.domain.toString(), + entityId: activity.entityId || activity.userId, + activityType: activity.type, + userId: activity.userId, + entityTargetId: + (activity.metadata?.entityTargetId as string) || undefined, + metadata: { + ...activity.metadata, + activityId: createdActivity._id.toString(), + }, + }); } catch (err: any) { error(err.message, { stack: err.stack, diff --git a/apps/web/models/Notification.ts b/apps/web/models/Notification.ts index 634eab3d5..59c16a126 100644 --- a/apps/web/models/Notification.ts +++ b/apps/web/models/Notification.ts @@ -1,88 +1,11 @@ import { - Constants, - Notification, - NotificationEntityAction, -} from "@courselit/common-models"; -import { generateUniqueId } from "@courselit/utils"; -import mongoose from "mongoose"; + InternalNotification, + NotificationSchema, +} from "@courselit/orm-models"; +import mongoose, { Model } from "mongoose"; -export interface InternalNotification - extends Omit, - mongoose.Document { - domain: mongoose.Types.ObjectId; - notificationId: string; - userId: string; - entityAction: NotificationEntityAction; - entityId: string; - read: boolean; - createdAt: Date; - updatedAt: Date; - entityTargetId?: string; -} +const NotificationModel = + (mongoose.models.Notification as Model | undefined) || + mongoose.model("Notification", NotificationSchema); -const NotificationSchema = new mongoose.Schema( - { - domain: { - type: mongoose.Schema.Types.ObjectId, - required: true, - }, - notificationId: { - type: String, - required: true, - default: generateUniqueId, - unique: true, - }, - userId: { - type: String, - required: true, - ref: "User", - }, - forUserId: { - type: String, - required: true, - ref: "User", - }, - entityAction: { - type: String, - required: true, - enum: Object.values(Constants.NotificationEntityAction), - }, - entityId: { - type: String, - required: true, - }, - read: { - type: Boolean, - default: false, - }, - entityTargetId: { - type: String, - }, - }, - { - timestamps: true, - }, -); - -NotificationSchema.statics.paginate = async function (userId, options) { - const page = options.page || 1; - const limit = options.limit || 10; - const skip = (page - 1) * limit; - - const query = { - forUserId: userId, - }; - - const notifications = await this.find(query) - .sort({ createdAt: -1 }) - .skip(skip) - .limit(limit) - .lean(); - - const total = await this.countDocuments(query); - - return { notifications, total }; -}; - -export default mongoose.models.Notification || - mongoose.model("Notification", NotificationSchema); +export default NotificationModel; diff --git a/apps/web/models/NotificationPreference.ts b/apps/web/models/NotificationPreference.ts new file mode 100644 index 000000000..bbb3dec1b --- /dev/null +++ b/apps/web/models/NotificationPreference.ts @@ -0,0 +1,16 @@ +import { + InternalNotificationPreference, + NotificationPreferenceSchema, +} from "@courselit/orm-models"; +import mongoose, { Model } from "mongoose"; + +const NotificationPreferenceModel = + (mongoose.models.NotificationPreference as + | Model + | undefined) || + mongoose.model( + "NotificationPreference", + NotificationPreferenceSchema, + ); + +export default NotificationPreferenceModel; diff --git a/apps/web/package.json b/apps/web/package.json index 850a76b34..c0eae52f0 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -1,118 +1,119 @@ { - "name": "@courselit/web", - "version": "0.72.3", - "private": true, - "scripts": { - "dev": "next dev", - "build": "next build", - "start": "next start", - "prettier": "prettier --write **/*.ts" - }, - "dependencies": { - "@better-auth/sso": "^1.4.6", - "@courselit/common-logic": "workspace:^", - "@courselit/common-models": "workspace:^", - "@courselit/components-library": "workspace:^", - "@courselit/email-editor": "workspace:^", - "@courselit/icons": "workspace:^", - "@courselit/page-blocks": "workspace:^", - "@courselit/page-models": "workspace:^", - "@courselit/page-primitives": "workspace:^", - "@courselit/text-editor": "workspace:^", - "@courselit/utils": "workspace:^", - "@hookform/resolvers": "^3.9.1", - "@radix-ui/react-alert-dialog": "^1.1.11", - "@radix-ui/react-avatar": "^1.1.3", - "@radix-ui/react-checkbox": "^1.1.4", - "@radix-ui/react-collapsible": "^1.1.3", - "@radix-ui/react-compose-refs": "^1.1.1", - "@radix-ui/react-dialog": "^1.1.6", - "@radix-ui/react-dropdown-menu": "^2.1.6", - "@radix-ui/react-label": "^2.1.4", - "@radix-ui/react-popover": "^1.1.6", - "@radix-ui/react-progress": "^1.1.7", - "@radix-ui/react-radio-group": "^1.2.3", - "@radix-ui/react-scroll-area": "^1.2.3", - "@radix-ui/react-select": "^2.1.6", - "@radix-ui/react-separator": "^1.1.4", - "@radix-ui/react-slot": "^1.2.3", - "@radix-ui/react-switch": "^1.1.3", - "@radix-ui/react-tabs": "^1.1.3", - "@radix-ui/react-toast": "^1.2.6", - "@radix-ui/react-toggle": "^1.1.6", - "@radix-ui/react-toggle-group": "^1.1.7", - "@radix-ui/react-tooltip": "^1.1.8", - "@radix-ui/react-visually-hidden": "^1.1.0", - "@stripe/stripe-js": "^5.4.0", - "@types/base-64": "^1.0.0", - "adm-zip": "^0.5.16", - "archiver": "^5.3.1", - "aws4": "^1.13.2", - "base-64": "^1.0.0", - "better-auth": "^1.4.1", - "chart.js": "^4.4.7", - "class-variance-authority": "^0.7.0", - "clsx": "^2.1.1", - "color-convert": "^3.1.0", - "cookie": "^0.4.2", - "date-fns": "^4.1.0", - "graphql": "^16.10.0", - "graphql-type-json": "^0.3.2", - "jsdom": "^26.1.0", - "lodash.debounce": "^4.0.8", - "lucide-react": "^0.553.0", - "medialit": "0.2.0", - "mongodb": "^6.15.0", - "mongoose": "^8.13.1", - "next": "^16.0.10", - "next-themes": "^0.4.6", - "nodemailer": "^6.7.2", - "pug": "^3.0.2", - "razorpay": "^2.9.4", - "react": "19.2.0", - "react-chartjs-2": "^5.3.0", - "react-csv": "^2.2.2", - "react-dom": "19.2.0", - "react-hook-form": "^7.54.1", - "recharts": "^2.15.1", - "remirror": "^3.0.1", - "sharp": "^0.33.2", - "slugify": "^1.6.5", - "sonner": "^2.0.7", - "stripe": "^17.5.0", - "tailwind-merge": "^2.5.4", - "tailwindcss-animate": "^1.0.7", - "xml2js": "^0.6.2", - "zod": "^3.24.1" - }, - "devDependencies": { - "@eslint/eslintrc": "^3.3.1", - "@shelf/jest-mongodb": "^5.2.2", - "@types/adm-zip": "^0.5.7", - "@types/bcryptjs": "^2.4.2", - "@types/cookie": "^0.4.1", - "@types/mongodb": "^4.0.7", - "@types/node": "17.0.21", - "@types/nodemailer": "^6.4.4", - "@types/pug": "^2.0.6", - "@types/react": "19.2.4", - "@types/xml2js": "^0.4.14", - "eslint": "^9.12.0", - "eslint-config-next": "16.0.3", - "eslint-config-prettier": "^9.0.0", - "identity-obj-proxy": "^3.0.0", - "mongodb-memory-server": "^10.1.4", - "postcss": "^8.4.27", - "prettier": "^3.0.2", - "tailwind-config": "workspace:^", - "tailwindcss": "^3.4.1", - "ts-jest": "^29.4.4", - "tsconfig": "workspace:^", - "typescript": "^5.6.2" - }, - "pnpm": { - "overrides": { - "@types/react": "19.2.4" + "name": "@courselit/web", + "version": "0.72.3", + "private": true, + "scripts": { + "dev": "next dev", + "build": "next build", + "start": "next start", + "prettier": "prettier --write **/*.ts" + }, + "dependencies": { + "@better-auth/sso": "^1.4.6", + "@courselit/common-logic": "workspace:^", + "@courselit/common-models": "workspace:^", + "@courselit/components-library": "workspace:^", + "@courselit/email-editor": "workspace:^", + "@courselit/icons": "workspace:^", + "@courselit/orm-models": "workspace:^", + "@courselit/page-blocks": "workspace:^", + "@courselit/page-models": "workspace:^", + "@courselit/page-primitives": "workspace:^", + "@courselit/text-editor": "workspace:^", + "@courselit/utils": "workspace:^", + "@hookform/resolvers": "^3.9.1", + "@radix-ui/react-alert-dialog": "^1.1.11", + "@radix-ui/react-avatar": "^1.1.3", + "@radix-ui/react-checkbox": "^1.1.4", + "@radix-ui/react-collapsible": "^1.1.3", + "@radix-ui/react-compose-refs": "^1.1.1", + "@radix-ui/react-dialog": "^1.1.6", + "@radix-ui/react-dropdown-menu": "^2.1.6", + "@radix-ui/react-label": "^2.1.4", + "@radix-ui/react-popover": "^1.1.6", + "@radix-ui/react-progress": "^1.1.7", + "@radix-ui/react-radio-group": "^1.2.3", + "@radix-ui/react-scroll-area": "^1.2.3", + "@radix-ui/react-select": "^2.1.6", + "@radix-ui/react-separator": "^1.1.4", + "@radix-ui/react-slot": "^1.2.3", + "@radix-ui/react-switch": "^1.1.3", + "@radix-ui/react-tabs": "^1.1.3", + "@radix-ui/react-toast": "^1.2.6", + "@radix-ui/react-toggle": "^1.1.6", + "@radix-ui/react-toggle-group": "^1.1.7", + "@radix-ui/react-tooltip": "^1.1.8", + "@radix-ui/react-visually-hidden": "^1.1.0", + "@stripe/stripe-js": "^5.4.0", + "@types/base-64": "^1.0.0", + "adm-zip": "^0.5.16", + "archiver": "^5.3.1", + "aws4": "^1.13.2", + "base-64": "^1.0.0", + "better-auth": "^1.4.1", + "chart.js": "^4.4.7", + "class-variance-authority": "^0.7.0", + "clsx": "^2.1.1", + "color-convert": "^3.1.0", + "cookie": "^0.4.2", + "date-fns": "^4.1.0", + "graphql": "^16.10.0", + "graphql-type-json": "^0.3.2", + "jsdom": "^26.1.0", + "lodash.debounce": "^4.0.8", + "lucide-react": "^0.553.0", + "medialit": "0.2.0", + "mongodb": "^6.15.0", + "mongoose": "^8.13.1", + "next": "^16.0.10", + "next-themes": "^0.4.6", + "nodemailer": "^6.7.2", + "pug": "^3.0.2", + "razorpay": "^2.9.4", + "react": "19.2.0", + "react-chartjs-2": "^5.3.0", + "react-csv": "^2.2.2", + "react-dom": "19.2.0", + "react-hook-form": "^7.54.1", + "recharts": "^2.15.1", + "remirror": "^3.0.1", + "sharp": "^0.33.2", + "slugify": "^1.6.5", + "sonner": "^2.0.7", + "stripe": "^17.5.0", + "tailwind-merge": "^2.5.4", + "tailwindcss-animate": "^1.0.7", + "xml2js": "^0.6.2", + "zod": "^3.24.1" + }, + "devDependencies": { + "@eslint/eslintrc": "^3.3.1", + "@shelf/jest-mongodb": "^5.2.2", + "@types/adm-zip": "^0.5.7", + "@types/bcryptjs": "^2.4.2", + "@types/cookie": "^0.4.1", + "@types/mongodb": "^4.0.7", + "@types/node": "17.0.21", + "@types/nodemailer": "^6.4.4", + "@types/pug": "^2.0.6", + "@types/react": "19.2.4", + "@types/xml2js": "^0.4.14", + "eslint": "^9.12.0", + "eslint-config-next": "16.0.3", + "eslint-config-prettier": "^9.0.0", + "identity-obj-proxy": "^3.0.0", + "mongodb-memory-server": "^10.1.4", + "postcss": "^8.4.27", + "prettier": "^3.0.2", + "tailwind-config": "workspace:^", + "tailwindcss": "^3.4.1", + "ts-jest": "^29.4.4", + "tsconfig": "workspace:^", + "typescript": "^5.6.2" + }, + "pnpm": { + "overrides": { + "@types/react": "19.2.4" + } } - } } diff --git a/apps/web/services/queue.ts b/apps/web/services/queue.ts index bab4249ae..5ebe6e288 100644 --- a/apps/web/services/queue.ts +++ b/apps/web/services/queue.ts @@ -1,10 +1,8 @@ -import { NotificationEntityAction } from "@courselit/common-models"; +import { ActivityType } from "@courselit/common-models"; import { jwtUtils } from "@courselit/utils"; import { error } from "./logger"; import nodemailer from "nodemailer"; import { responses } from "@/config/strings"; -import NotificationModel from "@models/Notification"; -import { ObjectId } from "mongodb"; const queueServer = process.env.QUEUE_SERVER || "http://localhost:4000"; @@ -27,6 +25,7 @@ interface MailProps { subject: string; body: string; from: string; + headers?: Record; } if (mailHost && mailUser && mailPass && mailPort) { transporter = nodemailer.createTransport({ @@ -50,7 +49,13 @@ if (mailHost && mailUser && mailPass && mailPort) { }; } -export async function addMailJob({ to, from, subject, body }: MailProps) { +export async function addMailJob({ + to, + from, + subject, + body, + headers, +}: MailProps) { try { const jwtSecret = getJwtSecret(); const token = jwtUtils.generateToken({ service: "app" }, jwtSecret); @@ -65,6 +70,7 @@ export async function addMailJob({ to, from, subject, body }: MailProps) { from, subject, body, + headers, }), }); const jsonResponse = await response.json(); @@ -88,6 +94,7 @@ export async function addMailJob({ to, from, subject, body }: MailProps) { to: recipient, subject, html: body, + headers, }); atLeastOneSuccessfulSend = true; } catch (err: any) { @@ -103,20 +110,20 @@ export async function addMailJob({ to, from, subject, body }: MailProps) { } } -export async function addNotification({ +export async function addNotificationDispatchJob({ domain, entityId, - entityAction, - forUserIds, + activityType, userId, entityTargetId, + metadata = {}, }: { domain: string; entityId: string; - entityAction: NotificationEntityAction; - forUserIds: string[]; + activityType: ActivityType; userId: string; entityTargetId?: string; + metadata?: Record; }) { try { const jwtSecret = getJwtSecret(); @@ -130,49 +137,35 @@ export async function addNotification({ }, jwtSecret, ); - const response = await fetch(`${queueServer}/job/notification`, { - method: "POST", - headers: { - "content-type": "application/json", - Authorization: `Bearer ${token}`, + const response = await fetch( + `${queueServer}/job/dispatch-notification`, + { + method: "POST", + headers: { + "content-type": "application/json", + Authorization: `Bearer ${token}`, + }, + body: JSON.stringify({ + activityType, + entityId, + entityTargetId, + metadata, + }), }, - body: JSON.stringify({ - forUserIds, - entityAction, - entityId, - entityTargetId, - }), - }); + ); const jsonResponse = await response.json(); if (response.status !== 200) { throw new Error(jsonResponse.error); } } catch (err) { - error(`Error adding notification job: ${err.message}`, { + error(`Error adding notification dispatch job: ${err.message}`, { domain, entityId, - entityAction, - forUserIds, + activityType, userId, entityTargetId, + metadata, }); - - try { - for (const forUserId of forUserIds) { - await NotificationModel.create({ - domain: new ObjectId(domain), - userId, - forUserId, - entityAction, - entityId, - entityTargetId, - }); - } - } catch (err) { - error(`Error adding notification locally: ${err.message}`, { - stack: err.stack, - }); - } } } diff --git a/apps/web/ui-config/strings.ts b/apps/web/ui-config/strings.ts index d19b96dde..0ae207abc 100644 --- a/apps/web/ui-config/strings.ts +++ b/apps/web/ui-config/strings.ts @@ -282,6 +282,7 @@ export const SUBHEADER_SECTION_PAYMENT_CONFIRMATION_WEBHOOK = export const PURCHASE_STATUS_PAGE_HEADER = "Purchase Status"; export const MAIN_MENU_ITEM_DASHBOARD = "Dashboard"; export const MAIN_MENU_ITEM_PROFILE = "Profile"; +export const MAIN_MENU_ITEM_NOTIFICATIONS = "Notifications"; export const LAYOUT_SECTION_MAIN_CONTENT = "Main Content"; export const LAYOUT_SECTION_FOOTER_LEFT = "Left Section"; export const LAYOUT_SECTION_FOOTER_RIGHT = "Right Section"; @@ -309,6 +310,17 @@ export const HEADER_YOUR_PROFILE = "Your Profile"; export const PROFILE_PAGE_MESSAGE_NOT_LOGGED_IN = "to see your profile."; export const PROFILE_PAGE_HEADER = "Profile"; export const MY_CONTENT_HEADER = "My content"; +export const NOTIFICATION_SETTINGS_PAGE_HEADER = "Notifications"; +export const NOTIFICATION_SETTINGS_PAGE_DESCRIPTION = + "Manage how you receive notifications for each activity."; +export const NOTIFICATION_SETTINGS_RESOURCE_TEXT = "Customize notifications"; +export const NOTIFICATION_SETTINGS_COLUMN_ACTIVITY = "Activity"; +export const NOTIFICATION_SETTINGS_EMPTY_STATE = + "No notification preferences are available for your account."; +export const NOTIFICATION_SETTINGS_GROUP_GENERAL = "General"; +export const NOTIFICATION_SETTINGS_GROUP_PRODUCT_MANAGEMENT = "Product"; +export const NOTIFICATION_SETTINGS_GROUP_USER_MANAGEMENT = "User"; +export const NOTIFICATION_SETTINGS_GROUP_COMMUNITY_MANAGEMENT = "Community"; export const PROFILE_EMAIL_PREFERENCES = "Email preferences"; export const PROFILE_SECTION_DETAILS = "Personal details"; export const PROFILE_SECTION_DETAILS_NAME = "Name"; diff --git a/packages/common-logic/package.json b/packages/common-logic/package.json index 8e94171c4..0f377707e 100644 --- a/packages/common-logic/package.json +++ b/packages/common-logic/package.json @@ -42,6 +42,7 @@ "typescript": "^4.9.5" }, "dependencies": { + "@courselit/orm-models": "workspace:^", "@courselit/common-models": "workspace:^", "@courselit/utils": "workspace:^", "@courselit/email-editor": "workspace:^", diff --git a/packages/common-logic/src/index.ts b/packages/common-logic/src/index.ts index bd214c902..150163e28 100644 --- a/packages/common-logic/src/index.ts +++ b/packages/common-logic/src/index.ts @@ -10,3 +10,5 @@ export * from "./models/rule"; export * from "./models/email"; export * from "./models/email-delivery"; export * from "./models/email-event"; +export * from "./utils/get-notification-message-and-href"; +export * from "./notification-entity-resolver"; diff --git a/packages/common-logic/src/notification-entity-resolver.ts b/packages/common-logic/src/notification-entity-resolver.ts new file mode 100644 index 000000000..20eed1826 --- /dev/null +++ b/packages/common-logic/src/notification-entity-resolver.ts @@ -0,0 +1,158 @@ +import mongoose from "mongoose"; +import { + CommunityCommentSchema, + CommunityPostSchema, + CommunitySchema, + CourseSchema, +} from "@courselit/orm-models"; +import type { NotificationEntityResolver } from "./utils/get-notification-message-and-href"; + +export interface CreateNotificationEntityResolverOptions { + domainId?: unknown; +} + +export function createNotificationEntityResolver( + options: CreateNotificationEntityResolverOptions = {}, +): NotificationEntityResolver { + const defaultDomainId = options.domainId; + + return { + async getCommunity(communityId, domainId) { + return await getCommunityModel() + .findOne( + { + ...getDomainQuery(domainId ?? defaultDomainId), + communityId, + }, + { + _id: 0, + communityId: 1, + name: 1, + }, + ) + .lean<{ communityId: string; name: string } | null>(); + }, + async getPost(postId, domainId) { + return await getCommunityPostModel() + .findOne( + { + ...getDomainQuery(domainId ?? defaultDomainId), + postId, + }, + { + _id: 0, + postId: 1, + title: 1, + userId: 1, + communityId: 1, + }, + ) + .lean<{ + postId: string; + title: string; + userId: string; + communityId: string; + } | null>(); + }, + async getComment(commentId, domainId) { + const comment = await getCommunityCommentModel() + .findOne( + { + ...getDomainQuery(domainId ?? defaultDomainId), + commentId, + }, + { + _id: 0, + commentId: 1, + userId: 1, + content: 1, + postId: 1, + communityId: 1, + replies: 1, + }, + ) + .lean<{ + commentId: string; + userId: string; + content: string; + postId: string; + communityId: string; + replies?: Array<{ + replyId: string; + userId: string; + content: string; + parentReplyId?: string; + }>; + } | null>(); + + if (!comment) { + return null; + } + + return { + commentId: comment.commentId, + userId: comment.userId, + content: comment.content, + postId: comment.postId, + communityId: comment.communityId, + replies: (comment.replies || []).map((reply) => ({ + replyId: reply.replyId, + userId: reply.userId, + content: reply.content, + parentReplyId: reply.parentReplyId, + })), + }; + }, + async getCourse(courseId, domainId) { + return await getCourseModel() + .findOne( + { + ...getDomainQuery(domainId ?? defaultDomainId), + courseId, + }, + { + _id: 0, + courseId: 1, + title: 1, + }, + ) + .lean<{ courseId: string; title: string } | null>(); + }, + }; +} + +function getDomainQuery(domainId?: unknown): Record { + if (!domainId) { + return {}; + } + + return { + domain: domainId, + }; +} + +function getCommunityModel(): mongoose.Model { + return (mongoose.models.Community || + mongoose.model("Community", CommunitySchema)) as mongoose.Model; +} + +function getCommunityPostModel(): mongoose.Model { + return (mongoose.models.CommunityPost || + mongoose.model( + "CommunityPost", + CommunityPostSchema, + )) as mongoose.Model; +} + +function getCommunityCommentModel(): mongoose.Model { + return (mongoose.models.CommunityComment || + mongoose.model( + "CommunityComment", + CommunityCommentSchema, + )) as mongoose.Model; +} + +function getCourseModel(): mongoose.Model { + return (mongoose.models.Course || + mongoose.model("Course", CourseSchema)) as mongoose.Model; +} diff --git a/packages/common-logic/src/utils/get-notification-message-and-href.ts b/packages/common-logic/src/utils/get-notification-message-and-href.ts new file mode 100644 index 000000000..07428c732 --- /dev/null +++ b/packages/common-logic/src/utils/get-notification-message-and-href.ts @@ -0,0 +1,405 @@ +import { ActivityType, Constants } from "@courselit/common-models"; +import { truncate } from "@courselit/utils"; +import { createNotificationEntityResolver } from "../notification-entity-resolver"; + +export interface NotificationReplyEntity { + replyId: string; + userId: string; + content: string; + parentReplyId?: string; +} + +export interface NotificationCommentEntity { + commentId: string; + userId: string; + content: string; + postId: string; + communityId: string; + replies: NotificationReplyEntity[]; +} + +export interface NotificationPostEntity { + postId: string; + title: string; + userId: string; + communityId: string; +} + +export interface NotificationCommunityEntity { + communityId: string; + name: string; +} + +export interface NotificationCourseEntity { + courseId: string; + title: string; +} + +export interface NotificationEntityResolver { + getCommunity( + communityId: string, + domainId?: unknown, + ): Promise; + getPost( + postId: string, + domainId?: unknown, + ): Promise; + getComment( + commentId: string, + domainId?: unknown, + ): Promise; + getCourse( + courseId: string, + domainId?: unknown, + ): Promise; +} + +const defaultNotificationEntityResolver = createNotificationEntityResolver(); + +export async function getNotificationMessageAndHref({ + activityType, + entityId, + actorName, + recipientUserId, + resolver, + entityTargetId, + metadata, + hrefPrefix = "", + domainId, +}: { + activityType: ActivityType; + entityId: string; + actorName: string; + recipientUserId: string; + resolver?: NotificationEntityResolver; + entityTargetId?: string; + metadata?: Record; + hrefPrefix?: string; + domainId?: unknown; +}): Promise<{ message: string; href: string }> { + const entityResolver = resolver || defaultNotificationEntityResolver; + + switch (activityType) { + case Constants.ActivityType.COMMUNITY_POST_CREATED: { + const post = await entityResolver.getPost(entityId, domainId); + if (!post) { + return { message: "", href: "" }; + } + + const community = await entityResolver.getCommunity( + post.communityId, + domainId, + ); + if (!community) { + return { message: "", href: "" }; + } + + return { + message: `${actorName} created a post '${truncate(post.title, 20).trim()}' in ${community.name}`, + href: toHref( + `/dashboard/community/${community.communityId}`, + hrefPrefix, + ), + }; + } + + case Constants.ActivityType.COMMUNITY_COMMENT_CREATED: { + const postId = + (metadata?.postId as string) || + (await entityResolver.getComment(entityId, domainId))?.postId || + entityId; + + const post = await entityResolver.getPost(postId, domainId); + if (!post) { + return { message: "", href: "" }; + } + + const community = await entityResolver.getCommunity( + post.communityId, + domainId, + ); + if (!community) { + return { message: "", href: "" }; + } + + return { + message: `${actorName} commented on ${recipientUserId === post.userId ? "your" : "a"} post '${truncate(post.title, 20).trim()}' in ${community.name}`, + href: toHref( + `/dashboard/community/${community.communityId}`, + hrefPrefix, + ), + }; + } + + case Constants.ActivityType.COMMUNITY_REPLY_CREATED: + case Constants.ActivityType.COMMUNITY_COMMENT_REPLIED: { + const commentId = + entityTargetId || (metadata?.commentId as string) || ""; + if (!commentId) { + return { message: "", href: "" }; + } + + const comment = await entityResolver.getComment( + commentId, + domainId, + ); + if (!comment) { + return { message: "", href: "" }; + } + + const reply = comment.replies.find((r) => r.replyId === entityId); + if (!reply) { + return { message: "", href: "" }; + } + + const parentReply = reply.parentReplyId + ? comment.replies.find((r) => r.replyId === reply.parentReplyId) + : undefined; + + const [post, community] = await Promise.all([ + entityResolver.getPost(comment.postId, domainId), + entityResolver.getCommunity(comment.communityId, domainId), + ]); + if (!post || !community) { + return { message: "", href: "" }; + } + + const prefix = parentReply + ? recipientUserId === parentReply.userId + ? "your" + : "a" + : recipientUserId === comment.userId + ? "your" + : "a"; + + return { + message: `${actorName} replied to ${prefix} comment on '${truncate(post.title, 20).trim()}' in ${community.name}`, + href: toHref( + `/dashboard/community/${community.communityId}`, + hrefPrefix, + ), + }; + } + + case Constants.ActivityType.COMMUNITY_POST_LIKED: { + const post = await entityResolver.getPost(entityId, domainId); + if (!post) { + return { message: "", href: "" }; + } + + const community = await entityResolver.getCommunity( + post.communityId, + domainId, + ); + if (!community) { + return { message: "", href: "" }; + } + + return { + message: `${actorName} liked your post '${truncate(post.title, 20).trim()}' in ${community.name}`, + href: toHref( + `/dashboard/community/${community.communityId}`, + hrefPrefix, + ), + }; + } + + case Constants.ActivityType.COMMUNITY_COMMENT_LIKED: { + const comment = await entityResolver.getComment(entityId, domainId); + if (!comment) { + return { message: "", href: "" }; + } + + const [post, community] = await Promise.all([ + entityResolver.getPost(comment.postId, domainId), + entityResolver.getCommunity(comment.communityId, domainId), + ]); + if (!post || !community) { + return { message: "", href: "" }; + } + + return { + message: `${actorName} liked your comment '${truncate(comment.content, 20).trim()}' on '${truncate(post.title, 20).trim()}' in ${community.name}`, + href: toHref( + `/dashboard/community/${community.communityId}`, + hrefPrefix, + ), + }; + } + + case Constants.ActivityType.COMMUNITY_REPLY_LIKED: { + const commentId = + entityTargetId || (metadata?.commentId as string) || ""; + if (!commentId) { + return { message: "", href: "" }; + } + + const comment = await entityResolver.getComment( + commentId, + domainId, + ); + if (!comment) { + return { message: "", href: "" }; + } + + const reply = comment.replies.find((r) => r.replyId === entityId); + if (!reply) { + return { message: "", href: "" }; + } + + const [post, community] = await Promise.all([ + entityResolver.getPost(comment.postId, domainId), + entityResolver.getCommunity(comment.communityId, domainId), + ]); + if (!post || !community) { + return { message: "", href: "" }; + } + + return { + message: `${actorName} liked your reply '${truncate(reply.content, 20).trim()}' on '${truncate(post.title, 20).trim()}' in ${community.name}`, + href: toHref( + `/dashboard/community/${community.communityId}`, + hrefPrefix, + ), + }; + } + + case Constants.ActivityType.COMMUNITY_MEMBERSHIP_REQUESTED: { + const community = await entityResolver.getCommunity( + entityId, + domainId, + ); + if (!community) { + return { message: "", href: "" }; + } + + return { + message: `${actorName} requested to join ${community.name}`, + href: toHref( + `/dashboard/community/${community.communityId}/manage/memberships`, + hrefPrefix, + ), + }; + } + + case Constants.ActivityType.COMMUNITY_MEMBERSHIP_GRANTED: { + const community = await entityResolver.getCommunity( + entityId, + domainId, + ); + if (!community) { + return { message: "", href: "" }; + } + + return { + message: `${actorName} granted your request to join ${community.name}`, + href: toHref( + `/dashboard/community/${community.communityId}`, + hrefPrefix, + ), + }; + } + + case Constants.ActivityType.COMMUNITY_JOINED: { + const community = await entityResolver.getCommunity( + entityId, + domainId, + ); + if (!community) { + return { message: "", href: "" }; + } + + return { + message: `${actorName} joined ${community.name}`, + href: toHref( + `/dashboard/community/${community.communityId}/manage/memberships`, + hrefPrefix, + ), + }; + } + + case Constants.ActivityType.COMMUNITY_LEFT: { + const community = await entityResolver.getCommunity( + entityId, + domainId, + ); + if (!community) { + return { message: "", href: "" }; + } + + return { + message: `${actorName} left ${community.name}`, + href: toHref( + `/dashboard/community/${community.communityId}/manage/memberships`, + hrefPrefix, + ), + }; + } + + case Constants.ActivityType.NEWSLETTER_SUBSCRIBED: + return { + message: `${actorName} subscribed to the updates`, + href: toHref(`/dashboard/users/${entityId}`, hrefPrefix), + }; + + case Constants.ActivityType.NEWSLETTER_UNSUBSCRIBED: + return { + message: `${actorName} unsubscribed from the updates`, + href: toHref(`/dashboard/users/${entityId}`, hrefPrefix), + }; + + case Constants.ActivityType.ENROLLED: { + const course = await entityResolver.getCourse(entityId, domainId); + if (!course) { + return { message: "", href: "" }; + } + + return { + message: `${actorName} enrolled in ${truncate(course.title, 20).trim()}`, + href: toHref( + `/dashboard/product/${course.courseId}/customers`, + hrefPrefix, + ), + }; + } + + case Constants.ActivityType.USER_CREATED: + return { + message: `${actorName} signed up`, + href: toHref(`/dashboard/users/${entityId}`, hrefPrefix), + }; + + case Constants.ActivityType.DOWNLOADED: { + const course = await entityResolver.getCourse(entityId, domainId); + if (!course) { + return { message: "", href: "" }; + } + + return { + message: `${actorName} downloaded ${truncate(course.title, 20).trim()}`, + href: toHref( + `/dashboard/product/${course.courseId}/customers`, + hrefPrefix, + ), + }; + } + + default: + return { + message: `${actorName} triggered ${humanizeActivityType(activityType)}`, + href: toHref("/dashboard", hrefPrefix), + }; + } +} + +function humanizeActivityType(activityType: ActivityType): string { + return activityType.replace(/_/g, " "); +} + +function toHref(path: string, hrefPrefix: string): string { + if (!hrefPrefix) { + return path; + } + + return `${hrefPrefix.replace(/\/$/, "")}${path}`; +} diff --git a/packages/common-models/src/constants.ts b/packages/common-models/src/constants.ts index 42196f9b7..11f0977ae 100644 --- a/packages/common-models/src/constants.ts +++ b/packages/common-models/src/constants.ts @@ -103,6 +103,10 @@ export const NotificationEntityAction = { COMMUNITY_MEMBERSHIP_REQUESTED: "community:membership:requested", COMMUNITY_MEMBERSHIP_GRANTED: "community:membership:granted", } as const; +export const NotificationChannel = { + APP: "app", + EMAIL: "email", +} as const; export const ProductPriceType = { FREE: "free", PAID: "paid", @@ -135,8 +139,12 @@ export const ActivityType = { NEWSLETTER_SUBSCRIBED: "newsletter_subscribed", NEWSLETTER_UNSUBSCRIBED: "newsletter_unsubscribed", USER_CREATED: "user_created", + TAG_ADDED: "tag_added", + TAG_REMOVED: "tag_removed", COMMUNITY_JOINED: "community_joined", COMMUNITY_LEFT: "community_left", + COMMUNITY_POST_CREATED: "community_post_created", + COMMUNITY_POST_LIKED: "community_post_liked", COMMUNITY_COMMENT_CREATED: "community_comment_created", COMMUNITY_COMMENT_REPLIED: "community_comment_replied", COMMUNITY_COMMENT_LIKED: "community_comment_liked", @@ -145,6 +153,37 @@ export const ActivityType = { COMMUNITY_MEMBERSHIP_REQUESTED: "community_membership_requested", COMMUNITY_MEMBERSHIP_GRANTED: "community_membership_granted", } as const; +export const ActivityPermissionMap = { + [ActivityType.ENROLLED]: "course:manage_any", + [ActivityType.PURCHASED]: "course:manage_any", + [ActivityType.DOWNLOADED]: "course:manage_any", + // [ActivityType.LESSON_STARTED]: "course:manage_any", + // [ActivityType.LESSON_COMPLETED]: "course:manage_any", + // [ActivityType.COURSE_COMPLETED]: "course:manage_any", + // [ActivityType.QUIZ_ATTEMPTED]: "course:manage_any", + // [ActivityType.QUIZ_PASSED]: "course:manage_any", + // [ActivityType.VIDEO_STARTED]: "course:manage_any", + // [ActivityType.VIDEO_FINISHED]: "course:manage_any", + // [ActivityType.CERTIFICATE_ISSUED]: "course:manage_any", + // [ActivityType.CERTIFICATE_DOWNLOADED]: "course:manage_any", + // [ActivityType.REVIEWED]: "course:manage_any", + [ActivityType.NEWSLETTER_SUBSCRIBED]: "user:manage", + [ActivityType.NEWSLETTER_UNSUBSCRIBED]: "user:manage", + [ActivityType.USER_CREATED]: "user:manage", + // [ActivityType.TAG_ADDED]: "user:manage", + // [ActivityType.TAG_REMOVED]: "user:manage", + [ActivityType.COMMUNITY_JOINED]: "community:manage", + [ActivityType.COMMUNITY_LEFT]: "community:manage", + [ActivityType.COMMUNITY_POST_CREATED]: "", + [ActivityType.COMMUNITY_POST_LIKED]: "", + [ActivityType.COMMUNITY_COMMENT_CREATED]: "", + [ActivityType.COMMUNITY_COMMENT_REPLIED]: "", + [ActivityType.COMMUNITY_COMMENT_LIKED]: "", + [ActivityType.COMMUNITY_REPLY_CREATED]: "", + [ActivityType.COMMUNITY_REPLY_LIKED]: "", + [ActivityType.COMMUNITY_MEMBERSHIP_REQUESTED]: "community:manage", + [ActivityType.COMMUNITY_MEMBERSHIP_GRANTED]: "", +} as const; export const CourseType = { COURSE: "course", DOWNLOAD: "download", diff --git a/packages/common-models/src/index.ts b/packages/common-models/src/index.ts index 2edf80d94..dfa46d776 100644 --- a/packages/common-models/src/index.ts +++ b/packages/common-models/src/index.ts @@ -65,6 +65,8 @@ export * from "./membership"; export * from "./invoice"; export * from "./community-report"; export * from "./notification"; +export * from "./notification-channel"; +export * from "./notification-preference"; export * from "./course"; export * from "./activity-type"; export * from "./email-event-action"; diff --git a/packages/common-models/src/notification-channel.ts b/packages/common-models/src/notification-channel.ts new file mode 100644 index 000000000..472a50c5e --- /dev/null +++ b/packages/common-models/src/notification-channel.ts @@ -0,0 +1,4 @@ +import { Constants } from "."; + +export type NotificationChannel = + (typeof Constants.NotificationChannel)[keyof typeof Constants.NotificationChannel]; diff --git a/packages/common-models/src/notification-preference.ts b/packages/common-models/src/notification-preference.ts new file mode 100644 index 000000000..bca283cd2 --- /dev/null +++ b/packages/common-models/src/notification-preference.ts @@ -0,0 +1,8 @@ +import { ActivityType } from "."; +import { NotificationChannel } from "./notification-channel"; + +export interface NotificationPreference { + userId: string; + activityType: ActivityType; + channels: NotificationChannel[]; +} diff --git a/packages/orm-models/src/index.ts b/packages/orm-models/src/index.ts index 31a300cbb..b4099ace8 100644 --- a/packages/orm-models/src/index.ts +++ b/packages/orm-models/src/index.ts @@ -29,6 +29,7 @@ export * from "./models/invoice"; export * from "./models/theme"; export * from "./models/ongoing-sequence"; export * from "./models/notification"; +export * from "./models/notification-preference"; export * from "./models/download-link"; export * from "./models/apikey"; export * from "./models/user-theme"; diff --git a/packages/orm-models/src/models/notification-preference.ts b/packages/orm-models/src/models/notification-preference.ts new file mode 100644 index 000000000..78e9cc62d --- /dev/null +++ b/packages/orm-models/src/models/notification-preference.ts @@ -0,0 +1,62 @@ +import { + ActivityType, + Constants, + NotificationChannel, + NotificationPreference, +} from "@courselit/common-models"; +import mongoose from "mongoose"; + +export interface InternalNotificationPreference + extends Omit, + mongoose.Document { + domain: mongoose.Types.ObjectId; + userId: string; + activityType: ActivityType; + channels: NotificationChannel[]; + createdAt: Date; + updatedAt: Date; +} + +export const NotificationPreferenceSchema = new mongoose.Schema( + { + domain: { + type: mongoose.Schema.Types.ObjectId, + required: true, + }, + userId: { + type: String, + required: true, + ref: "User", + }, + activityType: { + type: String, + required: true, + enum: Object.values(Constants.ActivityType), + }, + channels: { + type: [String], + required: true, + default: [], + enum: Object.values(Constants.NotificationChannel), + }, + }, + { + timestamps: true, + }, +); + +NotificationPreferenceSchema.index( + { + domain: 1, + userId: 1, + activityType: 1, + }, + { + unique: true, + }, +); + +NotificationPreferenceSchema.index({ + domain: 1, + activityType: 1, +}); diff --git a/packages/orm-models/src/models/notification.ts b/packages/orm-models/src/models/notification.ts index db50bab71..8657863de 100644 --- a/packages/orm-models/src/models/notification.ts +++ b/packages/orm-models/src/models/notification.ts @@ -1,7 +1,7 @@ import { + ActivityType, Constants, Notification, - NotificationEntityAction, } from "@courselit/common-models"; import { generateUniqueId } from "@courselit/utils"; import mongoose from "mongoose"; @@ -12,12 +12,13 @@ export interface InternalNotification domain: mongoose.Types.ObjectId; notificationId: string; userId: string; - entityAction: NotificationEntityAction; + activityType: ActivityType; entityId: string; read: boolean; createdAt: Date; updatedAt: Date; entityTargetId?: string; + metadata?: Record; } export const NotificationSchema = new mongoose.Schema( @@ -42,10 +43,10 @@ export const NotificationSchema = new mongoose.Schema( required: true, ref: "User", }, - entityAction: { + activityType: { type: String, required: true, - enum: Object.values(Constants.NotificationEntityAction), + enum: Object.values(Constants.ActivityType), }, entityId: { type: String, @@ -58,6 +59,10 @@ export const NotificationSchema = new mongoose.Schema( entityTargetId: { type: String, }, + metadata: { + type: mongoose.Schema.Types.Mixed, + default: {}, + }, }, { timestamps: true, diff --git a/packages/scripts/src/cleanup-domain.ts b/packages/scripts/src/cleanup-domain.ts index c46448226..04662236d 100644 --- a/packages/scripts/src/cleanup-domain.ts +++ b/packages/scripts/src/cleanup-domain.ts @@ -108,6 +108,12 @@ const OngoingSequenceModel = mongoose.model( OngoingSequenceSchema, ); const NotificationModel = mongoose.model("Notification", NotificationSchema); +const NotificationPreferenceModel = + mongoose.models.NotificationPreference || + mongoose.model( + "NotificationPreference", + new mongoose.Schema({}, { strict: false }), + ); const RuleModel = mongoose.model("Rule", RuleSchema); const EmailEventModel = mongoose.model("EmailEvent", EmailEventSchema); const EmailDeliveryModel = mongoose.model("EmailDelivery", EmailDeliverySchema); @@ -132,6 +138,7 @@ async function cleanupDomain(name: string) { await UserSegmentModel.deleteMany({ domain: domain._id }); await UserThemeModel.deleteMany({ domain: domain._id }); await NotificationModel.deleteMany({ domain: domain._id }); + await NotificationPreferenceModel.deleteMany({ domain: domain._id }); await RuleModel.deleteMany({ domain: domain._id }); await OngoingSequenceModel.deleteMany({ domain: domain._id }); await SequenceModel.deleteMany({ domain: domain._id }); diff --git a/packages/utils/src/get-email-from.ts b/packages/utils/src/get-email-from.ts new file mode 100644 index 000000000..44083125c --- /dev/null +++ b/packages/utils/src/get-email-from.ts @@ -0,0 +1,9 @@ +export const getEmailFrom = ({ + name, + email, +}: { + name: string; + email: string; +}) => { + return `${name} <${email}>`; +}; diff --git a/packages/utils/src/index.ts b/packages/utils/src/index.ts index 8ac6c0da5..fd49918ce 100644 --- a/packages/utils/src/index.ts +++ b/packages/utils/src/index.ts @@ -9,6 +9,7 @@ export { default as getGraphQLQueryStringFromObject } from "./get-graphql-query- export { default as slugify } from "@sindresorhus/slugify"; export { default as jwtUtils } from "./jwt-utils"; export { getPlanPrice } from "./get-plan-price"; +export { getEmailFrom } from "./get-email-from"; export { truncate } from "./truncate"; export { isVideo } from "./is-video"; export { extractMediaIDs } from "./extract-media-ids"; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 3f39058c8..68c312114 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -128,6 +128,9 @@ importers: '@courselit/email-editor': specifier: workspace:^ version: link:../../packages/email-editor + '@courselit/orm-models': + specifier: workspace:^ + version: link:../../packages/orm-models '@courselit/utils': specifier: workspace:^ version: link:../../packages/utils @@ -189,6 +192,9 @@ importers: tsup: specifier: ^7.2.0 version: 7.3.0(postcss@8.5.6)(ts-node@10.9.2(@types/node@24.10.1)(typescript@5.9.3))(typescript@5.9.3) + tsx: + specifier: ^4.21.0 + version: 4.21.0 typescript: specifier: ^5.9.3 version: 5.9.3 @@ -216,6 +222,9 @@ importers: '@courselit/icons': specifier: workspace:^ version: link:../../packages/icons + '@courselit/orm-models': + specifier: workspace:^ + version: link:../../packages/orm-models '@courselit/page-blocks': specifier: workspace:^ version: link:../../packages/page-blocks @@ -1927,6 +1936,12 @@ packages: cpu: [ppc64] os: [aix] + '@esbuild/aix-ppc64@0.27.3': + resolution: {integrity: sha512-9fJMTNFTWZMh5qwrBItuziu834eOCUcEqymSH7pY+zoMVEZg3gcPuBNxH1EvfVYe9h0x/Ptw8KBzv7qxb7l8dg==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [aix] + '@esbuild/android-arm64@0.17.19': resolution: {integrity: sha512-KBMWvEZooR7+kzY0BtbTQn0OAYY7CsiydT63pVEaPtVYF0hXbUaOyZog37DKxK7NF3XacBJOpYT4adIJh+avxA==} engines: {node: '>=12'} @@ -1939,6 +1954,12 @@ packages: cpu: [arm64] os: [android] + '@esbuild/android-arm64@0.27.3': + resolution: {integrity: sha512-YdghPYUmj/FX2SYKJ0OZxf+iaKgMsKHVPF1MAq/P8WirnSpCStzKJFjOjzsW0QQ7oIAiccHdcqjbHmJxRb/dmg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [android] + '@esbuild/android-arm@0.15.18': resolution: {integrity: sha512-5GT+kcs2WVGjVs7+boataCkO5Fg0y4kCjzkB5bAip7H4jfnOS3dA6KPiww9W1OEKTKeAcUVhdZGvgI65OXmUnw==} engines: {node: '>=12'} @@ -1957,6 +1978,12 @@ packages: cpu: [arm] os: [android] + '@esbuild/android-arm@0.27.3': + resolution: {integrity: sha512-i5D1hPY7GIQmXlXhs2w8AWHhenb00+GxjxRncS2ZM7YNVGNfaMxgzSGuO8o8SJzRc/oZwU2bcScvVERk03QhzA==} + engines: {node: '>=18'} + cpu: [arm] + os: [android] + '@esbuild/android-x64@0.17.19': resolution: {integrity: sha512-uUTTc4xGNDT7YSArp/zbtmbhO0uEEK9/ETW29Wk1thYUJBz3IVnvgEiEwEa9IeLyvnpKrWK64Utw2bgUmDveww==} engines: {node: '>=12'} @@ -1969,6 +1996,12 @@ packages: cpu: [x64] os: [android] + '@esbuild/android-x64@0.27.3': + resolution: {integrity: sha512-IN/0BNTkHtk8lkOM8JWAYFg4ORxBkZQf9zXiEOfERX/CzxW3Vg1ewAhU7QSWQpVIzTW+b8Xy+lGzdYXV6UZObQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [android] + '@esbuild/darwin-arm64@0.17.19': resolution: {integrity: sha512-80wEoCfF/hFKM6WE1FyBHc9SfUblloAWx6FJkFWTWiCoht9Mc0ARGEM47e67W9rI09YoUxJL68WHfDRYEAvOhg==} engines: {node: '>=12'} @@ -1981,6 +2014,12 @@ packages: cpu: [arm64] os: [darwin] + '@esbuild/darwin-arm64@0.27.3': + resolution: {integrity: sha512-Re491k7ByTVRy0t3EKWajdLIr0gz2kKKfzafkth4Q8A5n1xTHrkqZgLLjFEHVD+AXdUGgQMq+Godfq45mGpCKg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [darwin] + '@esbuild/darwin-x64@0.17.19': resolution: {integrity: sha512-IJM4JJsLhRYr9xdtLytPLSH9k/oxR3boaUIYiHkAawtwNOXKE8KoU8tMvryogdcT8AU+Bflmh81Xn6Q0vTZbQw==} engines: {node: '>=12'} @@ -1993,6 +2032,12 @@ packages: cpu: [x64] os: [darwin] + '@esbuild/darwin-x64@0.27.3': + resolution: {integrity: sha512-vHk/hA7/1AckjGzRqi6wbo+jaShzRowYip6rt6q7VYEDX4LEy1pZfDpdxCBnGtl+A5zq8iXDcyuxwtv3hNtHFg==} + engines: {node: '>=18'} + cpu: [x64] + os: [darwin] + '@esbuild/freebsd-arm64@0.17.19': resolution: {integrity: sha512-pBwbc7DufluUeGdjSU5Si+P3SoMF5DQ/F/UmTSb8HXO80ZEAJmrykPyzo1IfNbAoaqw48YRpv8shwd1NoI0jcQ==} engines: {node: '>=12'} @@ -2005,6 +2050,12 @@ packages: cpu: [arm64] os: [freebsd] + '@esbuild/freebsd-arm64@0.27.3': + resolution: {integrity: sha512-ipTYM2fjt3kQAYOvo6vcxJx3nBYAzPjgTCk7QEgZG8AUO3ydUhvelmhrbOheMnGOlaSFUoHXB6un+A7q4ygY9w==} + engines: {node: '>=18'} + cpu: [arm64] + os: [freebsd] + '@esbuild/freebsd-x64@0.17.19': resolution: {integrity: sha512-4lu+n8Wk0XlajEhbEffdy2xy53dpR06SlzvhGByyg36qJw6Kpfk7cp45DR/62aPH9mtJRmIyrXAS5UWBrJT6TQ==} engines: {node: '>=12'} @@ -2017,6 +2068,12 @@ packages: cpu: [x64] os: [freebsd] + '@esbuild/freebsd-x64@0.27.3': + resolution: {integrity: sha512-dDk0X87T7mI6U3K9VjWtHOXqwAMJBNN2r7bejDsc+j03SEjtD9HrOl8gVFByeM0aJksoUuUVU9TBaZa2rgj0oA==} + engines: {node: '>=18'} + cpu: [x64] + os: [freebsd] + '@esbuild/linux-arm64@0.17.19': resolution: {integrity: sha512-ct1Tg3WGwd3P+oZYqic+YZF4snNl2bsnMKRkb3ozHmnM0dGWuxcPTTntAF6bOP0Sp4x0PjSF+4uHQ1xvxfRKqg==} engines: {node: '>=12'} @@ -2029,6 +2086,12 @@ packages: cpu: [arm64] os: [linux] + '@esbuild/linux-arm64@0.27.3': + resolution: {integrity: sha512-sZOuFz/xWnZ4KH3YfFrKCf1WyPZHakVzTiqji3WDc0BCl2kBwiJLCXpzLzUBLgmp4veFZdvN5ChW4Eq/8Fc2Fg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [linux] + '@esbuild/linux-arm@0.17.19': resolution: {integrity: sha512-cdmT3KxjlOQ/gZ2cjfrQOtmhG4HJs6hhvm3mWSRDPtZ/lP5oe8FWceS10JaSJC13GBd4eH/haHnqf7hhGNLerA==} engines: {node: '>=12'} @@ -2041,6 +2104,12 @@ packages: cpu: [arm] os: [linux] + '@esbuild/linux-arm@0.27.3': + resolution: {integrity: sha512-s6nPv2QkSupJwLYyfS+gwdirm0ukyTFNl3KTgZEAiJDd+iHZcbTPPcWCcRYH+WlNbwChgH2QkE9NSlNrMT8Gfw==} + engines: {node: '>=18'} + cpu: [arm] + os: [linux] + '@esbuild/linux-ia32@0.17.19': resolution: {integrity: sha512-w4IRhSy1VbsNxHRQpeGCHEmibqdTUx61Vc38APcsRbuVgK0OPEnQ0YD39Brymn96mOx48Y2laBQGqgZ0j9w6SQ==} engines: {node: '>=12'} @@ -2053,6 +2122,12 @@ packages: cpu: [ia32] os: [linux] + '@esbuild/linux-ia32@0.27.3': + resolution: {integrity: sha512-yGlQYjdxtLdh0a3jHjuwOrxQjOZYD/C9PfdbgJJF3TIZWnm/tMd/RcNiLngiu4iwcBAOezdnSLAwQDPqTmtTYg==} + engines: {node: '>=18'} + cpu: [ia32] + os: [linux] + '@esbuild/linux-loong64@0.15.18': resolution: {integrity: sha512-L4jVKS82XVhw2nvzLg/19ClLWg0y27ulRwuP7lcyL6AbUWB5aPglXY3M21mauDQMDfRLs8cQmeT03r/+X3cZYQ==} engines: {node: '>=12'} @@ -2071,6 +2146,12 @@ packages: cpu: [loong64] os: [linux] + '@esbuild/linux-loong64@0.27.3': + resolution: {integrity: sha512-WO60Sn8ly3gtzhyjATDgieJNet/KqsDlX5nRC5Y3oTFcS1l0KWba+SEa9Ja1GfDqSF1z6hif/SkpQJbL63cgOA==} + engines: {node: '>=18'} + cpu: [loong64] + os: [linux] + '@esbuild/linux-mips64el@0.17.19': resolution: {integrity: sha512-LKJltc4LVdMKHsrFe4MGNPp0hqDFA1Wpt3jE1gEyM3nKUvOiO//9PheZZHfYRfYl6AwdTH4aTcXSqBerX0ml4A==} engines: {node: '>=12'} @@ -2083,6 +2164,12 @@ packages: cpu: [mips64el] os: [linux] + '@esbuild/linux-mips64el@0.27.3': + resolution: {integrity: sha512-APsymYA6sGcZ4pD6k+UxbDjOFSvPWyZhjaiPyl/f79xKxwTnrn5QUnXR5prvetuaSMsb4jgeHewIDCIWljrSxw==} + engines: {node: '>=18'} + cpu: [mips64el] + os: [linux] + '@esbuild/linux-ppc64@0.17.19': resolution: {integrity: sha512-/c/DGybs95WXNS8y3Ti/ytqETiW7EU44MEKuCAcpPto3YjQbyK3IQVKfF6nbghD7EcLUGl0NbiL5Rt5DMhn5tg==} engines: {node: '>=12'} @@ -2095,6 +2182,12 @@ packages: cpu: [ppc64] os: [linux] + '@esbuild/linux-ppc64@0.27.3': + resolution: {integrity: sha512-eizBnTeBefojtDb9nSh4vvVQ3V9Qf9Df01PfawPcRzJH4gFSgrObw+LveUyDoKU3kxi5+9RJTCWlj4FjYXVPEA==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [linux] + '@esbuild/linux-riscv64@0.17.19': resolution: {integrity: sha512-FC3nUAWhvFoutlhAkgHf8f5HwFWUL6bYdvLc/TTuxKlvLi3+pPzdZiFKSWz/PF30TB1K19SuCxDTI5KcqASJqA==} engines: {node: '>=12'} @@ -2107,6 +2200,12 @@ packages: cpu: [riscv64] os: [linux] + '@esbuild/linux-riscv64@0.27.3': + resolution: {integrity: sha512-3Emwh0r5wmfm3ssTWRQSyVhbOHvqegUDRd0WhmXKX2mkHJe1SFCMJhagUleMq+Uci34wLSipf8Lagt4LlpRFWQ==} + engines: {node: '>=18'} + cpu: [riscv64] + os: [linux] + '@esbuild/linux-s390x@0.17.19': resolution: {integrity: sha512-IbFsFbxMWLuKEbH+7sTkKzL6NJmG2vRyy6K7JJo55w+8xDk7RElYn6xvXtDW8HCfoKBFK69f3pgBJSUSQPr+4Q==} engines: {node: '>=12'} @@ -2119,6 +2218,12 @@ packages: cpu: [s390x] os: [linux] + '@esbuild/linux-s390x@0.27.3': + resolution: {integrity: sha512-pBHUx9LzXWBc7MFIEEL0yD/ZVtNgLytvx60gES28GcWMqil8ElCYR4kvbV2BDqsHOvVDRrOxGySBM9Fcv744hw==} + engines: {node: '>=18'} + cpu: [s390x] + os: [linux] + '@esbuild/linux-x64@0.17.19': resolution: {integrity: sha512-68ngA9lg2H6zkZcyp22tsVt38mlhWde8l3eJLWkyLrp4HwMUr3c1s/M2t7+kHIhvMjglIBrFpncX1SzMckomGw==} engines: {node: '>=12'} @@ -2131,6 +2236,18 @@ packages: cpu: [x64] os: [linux] + '@esbuild/linux-x64@0.27.3': + resolution: {integrity: sha512-Czi8yzXUWIQYAtL/2y6vogER8pvcsOsk5cpwL4Gk5nJqH5UZiVByIY8Eorm5R13gq+DQKYg0+JyQoytLQas4dA==} + engines: {node: '>=18'} + cpu: [x64] + os: [linux] + + '@esbuild/netbsd-arm64@0.27.3': + resolution: {integrity: sha512-sDpk0RgmTCR/5HguIZa9n9u+HVKf40fbEUt+iTzSnCaGvY9kFP0YKBWZtJaraonFnqef5SlJ8/TiPAxzyS+UoA==} + engines: {node: '>=18'} + cpu: [arm64] + os: [netbsd] + '@esbuild/netbsd-x64@0.17.19': resolution: {integrity: sha512-CwFq42rXCR8TYIjIfpXCbRX0rp1jo6cPIUPSaWwzbVI4aOfX96OXY8M6KNmtPcg7QjYeDmN+DD0Wp3LaBOLf4Q==} engines: {node: '>=12'} @@ -2143,6 +2260,18 @@ packages: cpu: [x64] os: [netbsd] + '@esbuild/netbsd-x64@0.27.3': + resolution: {integrity: sha512-P14lFKJl/DdaE00LItAukUdZO5iqNH7+PjoBm+fLQjtxfcfFE20Xf5CrLsmZdq5LFFZzb5JMZ9grUwvtVYzjiA==} + engines: {node: '>=18'} + cpu: [x64] + os: [netbsd] + + '@esbuild/openbsd-arm64@0.27.3': + resolution: {integrity: sha512-AIcMP77AvirGbRl/UZFTq5hjXK+2wC7qFRGoHSDrZ5v5b8DK/GYpXW3CPRL53NkvDqb9D+alBiC/dV0Fb7eJcw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openbsd] + '@esbuild/openbsd-x64@0.17.19': resolution: {integrity: sha512-cnq5brJYrSZ2CF6c35eCmviIN3k3RczmHz8eYaVlNasVqsNY+JKohZU5MKmaOI+KkllCdzOKKdPs762VCPC20g==} engines: {node: '>=12'} @@ -2155,6 +2284,18 @@ packages: cpu: [x64] os: [openbsd] + '@esbuild/openbsd-x64@0.27.3': + resolution: {integrity: sha512-DnW2sRrBzA+YnE70LKqnM3P+z8vehfJWHXECbwBmH/CU51z6FiqTQTHFenPlHmo3a8UgpLyH3PT+87OViOh1AQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [openbsd] + + '@esbuild/openharmony-arm64@0.27.3': + resolution: {integrity: sha512-NinAEgr/etERPTsZJ7aEZQvvg/A6IsZG/LgZy+81wON2huV7SrK3e63dU0XhyZP4RKGyTm7aOgmQk0bGp0fy2g==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openharmony] + '@esbuild/sunos-x64@0.17.19': resolution: {integrity: sha512-vCRT7yP3zX+bKWFeP/zdS6SqdWB8OIpaRq/mbXQxTGHnIxspRtigpkUcDMlSCOejlHowLqII7K2JKevwyRP2rg==} engines: {node: '>=12'} @@ -2167,6 +2308,12 @@ packages: cpu: [x64] os: [sunos] + '@esbuild/sunos-x64@0.27.3': + resolution: {integrity: sha512-PanZ+nEz+eWoBJ8/f8HKxTTD172SKwdXebZ0ndd953gt1HRBbhMsaNqjTyYLGLPdoWHy4zLU7bDVJztF5f3BHA==} + engines: {node: '>=18'} + cpu: [x64] + os: [sunos] + '@esbuild/win32-arm64@0.17.19': resolution: {integrity: sha512-yYx+8jwowUstVdorcMdNlzklLYhPxjniHWFKgRqH7IFlUEa0Umu3KuYplf1HUZZ422e3NU9F4LGb+4O0Kdcaag==} engines: {node: '>=12'} @@ -2179,6 +2326,12 @@ packages: cpu: [arm64] os: [win32] + '@esbuild/win32-arm64@0.27.3': + resolution: {integrity: sha512-B2t59lWWYrbRDw/tjiWOuzSsFh1Y/E95ofKz7rIVYSQkUYBjfSgf6oeYPNWHToFRr2zx52JKApIcAS/D5TUBnA==} + engines: {node: '>=18'} + cpu: [arm64] + os: [win32] + '@esbuild/win32-ia32@0.17.19': resolution: {integrity: sha512-eggDKanJszUtCdlVs0RB+h35wNlb5v4TWEkq4vZcmVt5u/HiDZrTXe2bWFQUez3RgNHwx/x4sk5++4NSSicKkw==} engines: {node: '>=12'} @@ -2191,6 +2344,12 @@ packages: cpu: [ia32] os: [win32] + '@esbuild/win32-ia32@0.27.3': + resolution: {integrity: sha512-QLKSFeXNS8+tHW7tZpMtjlNb7HKau0QDpwm49u0vUp9y1WOF+PEzkU84y9GqYaAVW8aH8f3GcBck26jh54cX4Q==} + engines: {node: '>=18'} + cpu: [ia32] + os: [win32] + '@esbuild/win32-x64@0.17.19': resolution: {integrity: sha512-lAhycmKnVOuRYNtRtatQR1LPQf2oYCkRGkSFnseDAKPl8lu5SOsK/e1sXe5a0Pc5kHIHe6P2I/ilntNv2xf3cA==} engines: {node: '>=12'} @@ -2203,6 +2362,12 @@ packages: cpu: [x64] os: [win32] + '@esbuild/win32-x64@0.27.3': + resolution: {integrity: sha512-4uJGhsxuptu3OcpVAzli+/gWusVGwZZHTlS63hh++ehExkVT8SgiEf7/uC/PclrPPkLhZqGgCTjd0VWLo6xMqA==} + engines: {node: '>=18'} + cpu: [x64] + os: [win32] + '@eslint-community/eslint-utils@4.6.1': resolution: {integrity: sha512-KTsJMmobmbrFLe3LDh0PC2FXpcSYJt/MLjlkh/9LEnmKYLSYmT/0EW9JWANjeoemiuZrmogti0tW5Ch+qNUYDw==} engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} @@ -6852,6 +7017,11 @@ packages: engines: {node: '>=12'} hasBin: true + esbuild@0.27.3: + resolution: {integrity: sha512-8VwMnyGCONIs6cWue2IdpHxHnAjzxnw2Zr7MkVxB2vjmQ2ivqGFb4LEG3SMnv0Gb2F/G/2yA8zUaiL1gywDCCg==} + engines: {node: '>=18'} + hasBin: true + escalade@3.2.0: resolution: {integrity: sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==} engines: {node: '>=6'} @@ -7388,20 +7558,22 @@ packages: glob@10.4.5: resolution: {integrity: sha512-7Bv8RF0k6xjo7d4A/PxYLbUCfb6c+Vpd2/mB2yRDlew7Jb5hEXiCD9ibfO7wpk8i4sevK6DFny9h7EYbM3/sHg==} + deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me hasBin: true glob@7.2.3: resolution: {integrity: sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==} - deprecated: Glob versions prior to v9 are no longer supported + deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me glob@8.1.0: resolution: {integrity: sha512-r8hpEjiQEYlF2QU0df3dS+nxxSIreXQS1qRhMJM0Q5NDdR386C7jb7Hwwod8Fgiuex+k0GFjgft18yvxm5XoCQ==} engines: {node: '>=12'} - deprecated: Glob versions prior to v9 are no longer supported + deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me glob@9.3.5: resolution: {integrity: sha512-e1LleDykUz2Iu+MTYdkSsuWX8lvAjAcs0Xef0lNIu0S2wOAzuTxCJtcd9S3cijlwYF18EsU3rzb8jPVobxDh9Q==} engines: {node: '>=16 || 14 >=14.17'} + deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me global-agent@3.0.0: resolution: {integrity: sha512-PT6XReJ+D07JvGoxQMkT6qji/jVNfX/h364XHZOWeRzy64sSFr+xJ5OX7LI3b4MPQzdL4H8Y8M0xzPpsVMwA8Q==} @@ -10544,6 +10716,11 @@ packages: typescript: optional: true + tsx@4.21.0: + resolution: {integrity: sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw==} + engines: {node: '>=18.0.0'} + hasBin: true + turndown-plugin-gfm@1.0.2: resolution: {integrity: sha512-vwz9tfvF7XN/jE0dGoBei3FXWuvll78ohzCZQuOb+ZjWrs3a0XhQVomJEb2Qh4VHTPNRO4GPZh0V7VRbiWwkRg==} @@ -12431,12 +12608,18 @@ snapshots: '@esbuild/aix-ppc64@0.19.12': optional: true + '@esbuild/aix-ppc64@0.27.3': + optional: true + '@esbuild/android-arm64@0.17.19': optional: true '@esbuild/android-arm64@0.19.12': optional: true + '@esbuild/android-arm64@0.27.3': + optional: true + '@esbuild/android-arm@0.15.18': optional: true @@ -12446,54 +12629,81 @@ snapshots: '@esbuild/android-arm@0.19.12': optional: true + '@esbuild/android-arm@0.27.3': + optional: true + '@esbuild/android-x64@0.17.19': optional: true '@esbuild/android-x64@0.19.12': optional: true + '@esbuild/android-x64@0.27.3': + optional: true + '@esbuild/darwin-arm64@0.17.19': optional: true '@esbuild/darwin-arm64@0.19.12': optional: true + '@esbuild/darwin-arm64@0.27.3': + optional: true + '@esbuild/darwin-x64@0.17.19': optional: true '@esbuild/darwin-x64@0.19.12': optional: true + '@esbuild/darwin-x64@0.27.3': + optional: true + '@esbuild/freebsd-arm64@0.17.19': optional: true '@esbuild/freebsd-arm64@0.19.12': optional: true + '@esbuild/freebsd-arm64@0.27.3': + optional: true + '@esbuild/freebsd-x64@0.17.19': optional: true '@esbuild/freebsd-x64@0.19.12': optional: true + '@esbuild/freebsd-x64@0.27.3': + optional: true + '@esbuild/linux-arm64@0.17.19': optional: true '@esbuild/linux-arm64@0.19.12': optional: true + '@esbuild/linux-arm64@0.27.3': + optional: true + '@esbuild/linux-arm@0.17.19': optional: true '@esbuild/linux-arm@0.19.12': optional: true + '@esbuild/linux-arm@0.27.3': + optional: true + '@esbuild/linux-ia32@0.17.19': optional: true '@esbuild/linux-ia32@0.19.12': optional: true + '@esbuild/linux-ia32@0.27.3': + optional: true + '@esbuild/linux-loong64@0.15.18': optional: true @@ -12503,72 +12713,117 @@ snapshots: '@esbuild/linux-loong64@0.19.12': optional: true + '@esbuild/linux-loong64@0.27.3': + optional: true + '@esbuild/linux-mips64el@0.17.19': optional: true '@esbuild/linux-mips64el@0.19.12': optional: true + '@esbuild/linux-mips64el@0.27.3': + optional: true + '@esbuild/linux-ppc64@0.17.19': optional: true '@esbuild/linux-ppc64@0.19.12': optional: true + '@esbuild/linux-ppc64@0.27.3': + optional: true + '@esbuild/linux-riscv64@0.17.19': optional: true '@esbuild/linux-riscv64@0.19.12': optional: true + '@esbuild/linux-riscv64@0.27.3': + optional: true + '@esbuild/linux-s390x@0.17.19': optional: true '@esbuild/linux-s390x@0.19.12': optional: true + '@esbuild/linux-s390x@0.27.3': + optional: true + '@esbuild/linux-x64@0.17.19': optional: true '@esbuild/linux-x64@0.19.12': optional: true + '@esbuild/linux-x64@0.27.3': + optional: true + + '@esbuild/netbsd-arm64@0.27.3': + optional: true + '@esbuild/netbsd-x64@0.17.19': optional: true '@esbuild/netbsd-x64@0.19.12': optional: true + '@esbuild/netbsd-x64@0.27.3': + optional: true + + '@esbuild/openbsd-arm64@0.27.3': + optional: true + '@esbuild/openbsd-x64@0.17.19': optional: true '@esbuild/openbsd-x64@0.19.12': optional: true + '@esbuild/openbsd-x64@0.27.3': + optional: true + + '@esbuild/openharmony-arm64@0.27.3': + optional: true + '@esbuild/sunos-x64@0.17.19': optional: true '@esbuild/sunos-x64@0.19.12': optional: true + '@esbuild/sunos-x64@0.27.3': + optional: true + '@esbuild/win32-arm64@0.17.19': optional: true '@esbuild/win32-arm64@0.19.12': optional: true + '@esbuild/win32-arm64@0.27.3': + optional: true + '@esbuild/win32-ia32@0.17.19': optional: true '@esbuild/win32-ia32@0.19.12': optional: true + '@esbuild/win32-ia32@0.27.3': + optional: true + '@esbuild/win32-x64@0.17.19': optional: true '@esbuild/win32-x64@0.19.12': optional: true + '@esbuild/win32-x64@0.27.3': + optional: true + '@eslint-community/eslint-utils@4.6.1(eslint@8.57.1)': dependencies: eslint: 8.57.1 @@ -18987,6 +19242,35 @@ snapshots: '@esbuild/win32-ia32': 0.19.12 '@esbuild/win32-x64': 0.19.12 + esbuild@0.27.3: + optionalDependencies: + '@esbuild/aix-ppc64': 0.27.3 + '@esbuild/android-arm': 0.27.3 + '@esbuild/android-arm64': 0.27.3 + '@esbuild/android-x64': 0.27.3 + '@esbuild/darwin-arm64': 0.27.3 + '@esbuild/darwin-x64': 0.27.3 + '@esbuild/freebsd-arm64': 0.27.3 + '@esbuild/freebsd-x64': 0.27.3 + '@esbuild/linux-arm': 0.27.3 + '@esbuild/linux-arm64': 0.27.3 + '@esbuild/linux-ia32': 0.27.3 + '@esbuild/linux-loong64': 0.27.3 + '@esbuild/linux-mips64el': 0.27.3 + '@esbuild/linux-ppc64': 0.27.3 + '@esbuild/linux-riscv64': 0.27.3 + '@esbuild/linux-s390x': 0.27.3 + '@esbuild/linux-x64': 0.27.3 + '@esbuild/netbsd-arm64': 0.27.3 + '@esbuild/netbsd-x64': 0.27.3 + '@esbuild/openbsd-arm64': 0.27.3 + '@esbuild/openbsd-x64': 0.27.3 + '@esbuild/openharmony-arm64': 0.27.3 + '@esbuild/sunos-x64': 0.27.3 + '@esbuild/win32-arm64': 0.27.3 + '@esbuild/win32-ia32': 0.27.3 + '@esbuild/win32-x64': 0.27.3 + escalade@3.2.0: {} escape-html@1.0.3: {} @@ -19010,7 +19294,7 @@ snapshots: '@next/eslint-plugin-next': 16.0.3 eslint: 9.39.1(jiti@1.21.7) eslint-import-resolver-node: 0.3.9 - eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0)(eslint@9.39.1(jiti@1.21.7)) + eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.46.4(eslint@9.39.1(jiti@1.21.7))(typescript@5.9.3))(eslint@9.39.1(jiti@1.21.7)))(eslint@9.39.1(jiti@1.21.7)) eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.46.4(eslint@9.39.1(jiti@1.21.7))(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.1(jiti@1.21.7)) eslint-plugin-jsx-a11y: 6.10.2(eslint@9.39.1(jiti@1.21.7)) eslint-plugin-react: 7.37.5(eslint@9.39.1(jiti@1.21.7)) @@ -19041,7 +19325,7 @@ snapshots: transitivePeerDependencies: - supports-color - eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0)(eslint@9.39.1(jiti@1.21.7)): + eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.46.4(eslint@9.39.1(jiti@1.21.7))(typescript@5.9.3))(eslint@9.39.1(jiti@1.21.7)))(eslint@9.39.1(jiti@1.21.7)): dependencies: '@nolyfill/is-core-module': 1.0.39 debug: 4.4.1 @@ -19056,14 +19340,14 @@ snapshots: transitivePeerDependencies: - supports-color - eslint-module-utils@2.12.1(@typescript-eslint/parser@8.46.4(eslint@9.39.1(jiti@1.21.7))(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.1(jiti@1.21.7)): + eslint-module-utils@2.12.1(@typescript-eslint/parser@8.46.4(eslint@9.39.1(jiti@1.21.7))(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.46.4(eslint@9.39.1(jiti@1.21.7))(typescript@5.9.3))(eslint@9.39.1(jiti@1.21.7)))(eslint@9.39.1(jiti@1.21.7)))(eslint@9.39.1(jiti@1.21.7)): dependencies: debug: 3.2.7 optionalDependencies: '@typescript-eslint/parser': 8.46.4(eslint@9.39.1(jiti@1.21.7))(typescript@5.9.3) eslint: 9.39.1(jiti@1.21.7) eslint-import-resolver-node: 0.3.9 - eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0)(eslint@9.39.1(jiti@1.21.7)) + eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.46.4(eslint@9.39.1(jiti@1.21.7))(typescript@5.9.3))(eslint@9.39.1(jiti@1.21.7)))(eslint@9.39.1(jiti@1.21.7)) transitivePeerDependencies: - supports-color @@ -19084,7 +19368,7 @@ snapshots: doctrine: 2.1.0 eslint: 9.39.1(jiti@1.21.7) eslint-import-resolver-node: 0.3.9 - eslint-module-utils: 2.12.1(@typescript-eslint/parser@8.46.4(eslint@9.39.1(jiti@1.21.7))(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.1(jiti@1.21.7)) + eslint-module-utils: 2.12.1(@typescript-eslint/parser@8.46.4(eslint@9.39.1(jiti@1.21.7))(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.46.4(eslint@9.39.1(jiti@1.21.7))(typescript@5.9.3))(eslint@9.39.1(jiti@1.21.7)))(eslint@9.39.1(jiti@1.21.7)))(eslint@9.39.1(jiti@1.21.7)) hasown: 2.0.2 is-core-module: 2.16.1 is-glob: 4.0.3 @@ -24356,6 +24640,13 @@ snapshots: - supports-color - ts-node + tsx@4.21.0: + dependencies: + esbuild: 0.27.3 + get-tsconfig: 4.10.0 + optionalDependencies: + fsevents: 2.3.3 + turndown-plugin-gfm@1.0.2: {} turndown@7.2.0: diff --git a/services/app/Dockerfile b/services/app/Dockerfile index f375f38d6..1912d4924 100644 --- a/services/app/Dockerfile +++ b/services/app/Dockerfile @@ -20,11 +20,12 @@ COPY packages/tailwind-config /app/packages/tailwind-config COPY packages/tsconfig /app/packages/tsconfig COPY packages/page-models /app/packages/page-models COPY packages/email-editor /app/packages/email-editor -COPY packages/common-models /app/packages/common-models -COPY packages/common-logic /app/packages/common-logic -COPY packages/page-primitives /app/packages/page-primitives -COPY packages/page-blocks /app/packages/page-blocks -COPY packages/components-library /app/packages/components-library +COPY packages/common-models /app/packages/common-models +COPY packages/common-logic /app/packages/common-logic +COPY packages/orm-models /app/packages/orm-models +COPY packages/page-primitives /app/packages/page-primitives +COPY packages/page-blocks /app/packages/page-blocks +COPY packages/components-library /app/packages/components-library COPY packages/text-editor /app/packages/text-editor COPY packages/utils /app/packages/utils COPY apps/web /app/apps/web @@ -64,4 +65,4 @@ USER nextjs ENV PORT=${PORT:-3000} -CMD ["node", "apps/web/server.js"] \ No newline at end of file +CMD ["node", "apps/web/server.js"] diff --git a/services/queue/Dockerfile b/services/queue/Dockerfile index 33d2e44cb..e3d5ec382 100644 --- a/services/queue/Dockerfile +++ b/services/queue/Dockerfile @@ -15,6 +15,7 @@ COPY packages/page-models ./packages/page-models COPY packages/email-editor ./packages/email-editor COPY packages/common-models ./packages/common-models COPY packages/common-logic ./packages/common-logic +COPY packages/orm-models ./packages/orm-models COPY packages/utils ./packages/utils COPY apps/queue ./apps/queue From c2bc7e972d6e3835e25f80a99de65a5e9a5158f4 Mon Sep 17 00:00:00 2001 From: Rajat Date: Sun, 22 Feb 2026 11:06:13 +0530 Subject: [PATCH 2/2] Updated lockfile --- pnpm-lock.yaml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 68c312114..384b25471 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -511,6 +511,9 @@ importers: '@courselit/email-editor': specifier: workspace:^ version: link:../email-editor + '@courselit/orm-models': + specifier: workspace:^ + version: link:../orm-models '@courselit/utils': specifier: workspace:^ version: link:../utils