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 000000000..58dc2eff1 Binary files /dev/null and b/apps/docs/public/assets/users/notifications-hub.png differ 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..384b25471 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 @@ -502,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 @@ -1927,6 +1939,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 +1957,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 +1981,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 +1999,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 +2017,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 +2035,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 +2053,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 +2071,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 +2089,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 +2107,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 +2125,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 +2149,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 +2167,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 +2185,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 +2203,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 +2221,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 +2239,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 +2263,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 +2287,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 +2311,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 +2329,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 +2347,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 +2365,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 +7020,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 +7561,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 +10719,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 +12611,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 +12632,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 +12716,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 +19245,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 +19297,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 +19328,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 +19343,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 +19371,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 +24643,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