Notifications System
Email, push, and in-app delivery — definitions, suppression, queues, templates
A unified, role-aware engine for every email, push, and in-app message OptiLearn sends. Direct calls to sendEmail() / sendToUser() / prisma.lMSNotification.create() are legacy — every new send must go through NotificationService.
Source-of-truth files
src/lib/notifications/
├── notification-definitions.ts # 31 keys + per-key defaults
├── NotificationService.ts # send / sendBulk / queueForDigest
├── suppression.ts # 5 rules
├── template-render.ts # Handlebars + helpers
├── queues.ts # 5 BullMQ queues
├── workers.ts # processors (called by scripts/worker.ts)
└── recipient-resolver.ts # campaign fan-out helper
scripts/worker.ts # long-lived worker entrypoint
prisma/seed-notification-templates.ts # one row per definition key
Notification definitions
NOTIFICATION_DEFINITIONS is the single registry. Every new key must be added here first — the array drives:
NotificationService(validates keys, resolves defaults, looks up templates)/settings/notificationsUI (one card per category, one row per key)- The preferences API (rejects unknown keys, refuses disabling locked notifications)
- The seed (template coverage check at build time)
Each definition declares:
{
key: string; // stable machine name — never rename
label: string; // shown on prefs page
description: string;
category: string; // groups cards
defaultChannels: { email, push, inApp };
canDisable: boolean; // false = locked on (grades, live class started)
canChangeFrequency: boolean; // false = always IMMEDIATE
availableTo: ("STUDENT" | "TEACHER" | "ADMIN" | "PARENT")[];
controls?: string[]; // child keys this user-facing knob owns
}
The 31 keys
| Category | Keys |
|---|---|
| Courses & enrollment | ENROLL_WELCOME, ENROLL_COURSE_PUBLISHED, ENROLL_ACCESS_EXPIRING |
| Assignments & deadlines | LESSON_ASSIGNMENT_DUE_REMINDER, LESSON_ASSIGNMENT_OVERDUE, LESSON_NEW_CONTENT |
| Grades & feedback | ASSESS_GRADED, ASSESS_RESUBMIT_REQUIRED, ASSESS_FEEDBACK_ADDED, ASSESS_QUIZ_FAILED_RETRY, ASSESS_AT_RISK_GRADE, PEER_REVIEW_ASSIGNED, ASSESS_PEER_GRADED |
| Live classes | LIVE_CLASS_STARTED, LIVE_CLASS_REMINDER_24H, LIVE_CLASS_REMINDER_15MIN, LIVE_CLASS_CANCELLED, LIVE_CLASS_RECORDING_READY |
| Progress & engagement | REENGAGE_MILESTONE, REENGAGE_NUDGE |
| Certificates | CERT_EARNED, CERT_EXPIRY_WARNING |
| Digests & summaries | DIGEST_STUDENT_DAILY, DIGEST_STUDENT_WEEKLY |
| Teaching | TEACHER_NEW_SUBMISSION, TEACHER_UNGRADED_BACKLOG, TEACHER_ENGAGEMENT_DROP, DIGEST_TEACHER_WEEKLY |
| Family (parent) | PARENT_CHILD_GRADED, PARENT_CHILD_ABSENT, PARENT_CHILD_LOW_GRADE, PARENT_WEEKLY_DIGEST |
| Administration | ADMIN_WEEKLY_DIGEST, ADMIN_COMPLETION_RATE_DROP |
Child keys via controls
REENGAGE_NUDGE controls REENGAGE_7_DAY_INACTIVITY, REENGAGE_14_DAY_INACTIVITY, REENGAGE_30_DAY_LAPSED, REENGAGE_STALLED_LESSON. The engagement worker emits the child keys; resolvePreferenceKey() walks back to the parent so the user only sees one toggle. Same shape for REENGAGE_MILESTONE (25/50/75/100).
NotificationService
Single entry point. Three methods:
send(params)
notificationService.send({
key: "ASSESS_GRADED",
recipientId: studentContactId,
institutionId: user.institutionId,
entityId: submission.id, // dedup key
forceImmediate: true, // bypass digest + daily cap
data: { assignmentName, score, courseId, submissionId },
});
Flow:
- Resolve preference key (child → parent)
- Fetch user preference row (fall back to definition defaults)
- Compute active channel set (enabled AND requested)
- If frequency is
DAILY_DIGEST/WEEKLY_DIGESTand notforceImmediate→ re-route toqueueForDigest() - Run suppression rules
- Look up template (institution override → system default)
- Render with Handlebars + helpers
- Fan out per channel:
- IN_APP — synchronous
LMSNotification.create({ status: "SENT" }) - EMAIL —
LMSNotification.create({ status: "PENDING" })+ enqueue - PUSH — same pattern, push queue
- IN_APP — synchronous
Errors are caught — notification failures must never break the caller. The worst case is a SKIPPED log row.
sendBulk(params)
Fans out via Promise.allSettled over the same send() path so each recipient gets its own preference + suppression check. BullMQ concurrency handles delivery throughput.
queueForDigest(params)
Writes a DigestQueueItem row scheduled for the user's next daily/weekly delivery time (from NotificationUserState, defaults 19:00 daily / Sunday 09:00 weekly). The digest scanner inside the worker batches and dispatches at delivery time.
Suppression rules
Five rules, applied in order. The first to fire wins.
| # | Rule | Effect |
|---|---|---|
| 1 | BOUNCE | NotificationUserState.bouncedAt set → skip EMAIL channel |
| 2 | DUPLICATE | Same (key, entityId) sent within 1h → drop entirely |
| 3 | DAILY_CAP | User received 3 SENT rows in last 24h → drop (unless key in bypass list or forceImmediate) |
| 4 | REENGAGE_COOLDOWN | For REENGAGE_* keys: any SENT in last 24h → delay all channels by 24h |
| 5 | QUIET_HOURS | 22:00–07:00 user-local → delay EMAIL only to next 07:00; push still sends |
Bypass list (always immune to daily cap): LIVE_CLASS_STARTED, ASSESS_RESUBMIT_REQUIRED, ASSESS_GRADED, CERT_EARNED, LIVE_CLASS_CANCELLED, LIVE_CLASS_REMINDER_15MIN.
When suppression delays a channel, the service enqueues the BullMQ job with { delay: ms } — the worker doesn't have to know about quiet hours.
Templates
Every key needs a row in NotificationTemplate. Lookup order:
- Institution override (
institutionId = <tenant>,key,isActive: true) - System default (
institutionId = null,key,isActive: true)
If neither exists, the service writes a FAILED log row with failureReason: "missing_template" and returns. Always run npm run seed:notifications after adding new keys.
Handlebars helpers
Registered on a private Handlebars instance in template-render.ts:
| Helper | Output |
|---|---|
{{firstName}} {{lastName}} | From data.user.name |
{{courseUrl courseId}} | https://learn.opticrm.app/learn/<id> |
{{lessonUrl courseId lessonId}} | Lesson player deep link |
{{formatDate value}} | "15 Apr 2026" |
{{formatDateTime value}} | "15 Apr 2026 at 2:30 PM IST" |
{{unsubscribeUrl userId key}} | Signed HMAC unsub URL |
{{eq a b}}, {{gt a b}}, {{lt a b}} | Comparators |
Compiled templates are cached in-process by SHA1 of source — updating a template in the DB invalidates because the source string changes.
Action URLs
NotificationService._buildActionUrl() derives a deep-link per key from the data payload. e.g. ASSESS_GRADED with submissionId becomes /submissions/<id>; LIVE_CLASS_STARTED with sessionId becomes /live-sessions/<id>/room. Stored on LMSNotification.actionUrl.
BullMQ queues
Five queues, all defined in queues.ts and processed by scripts/worker.ts:
| Queue | Concurrency | Purpose |
|---|---|---|
notifications-email | 10 | Render-already-done email send via sendEmail() |
notifications-push | 20 | FCM via sendToUser() |
notifications-digest | 5 | Flush a (userId, key, window) group of DigestQueueItem rows |
notifications-reengage | 1 | Cron — scan ReEngagementTracking for 7/14/30-day boundaries |
notifications-due-reminder | 1 | Cron — scan Assignment.dueDate for 3-day / 1-day / day-of windows |
Default job options: 3 attempts, exponential backoff starting at 2 minutes, removeOnComplete: 500, removeOnFail: 200. All queues are fail-soft — if Redis is unavailable at boot, createQueue() returns null and producers no-op.
Worker entrypoint
scripts/worker.ts instantiates one Worker per queue plus three repeatable scanners:
| Schedule | Pattern | What it does |
|---|---|---|
| Digest scan | */15 * * * * | Walk DigestQueueItem for processed=false, scheduledFor<=now; enqueue one digest job per (userId, key) |
| Reengage scan | 0 */6 * * * | Trigger processReengageJob |
| Due-reminder scan | 0 * * * * | Trigger processDueReminderJob |
Stale repeat keys are cleared on start so changed schedules take effect on redeploy. See Worker Deployment for prod ops.
Logging
Every send writes one or more LMSNotification rows. Status values:
| Status | Meaning |
|---|---|
PENDING | Enqueued, awaiting worker |
SENT | Successfully delivered |
SKIPPED | Suppressed (with failureReason like quiet_hours, daily_cap_exceeded, duplicate_within_1h, email_bounced, no_push_tokens) |
FAILED | Final delivery failure (template missing, all FCM tokens dead, retries exhausted) |
The Phase 10 admin analytics page reads these to chart delivery health by reason.
Adding a new notification
- Add the entry to
NOTIFICATION_DEFINITIONS - Add a system-default
NotificationTemplaterow (edit the seed file + runnpm run seed:notifications) - Call
notificationService.send({ key: "MY_NEW_KEY", ... })from your handler - If the new key needs a deep link, extend
_buildActionUrl()switch - Verify the new card renders on
/settings/notificationsfor the right roles
Never prisma.lMSNotification.create() directly. The audit, suppression, and digest stories all assume the service runs first.
Related
- Worker Deployment — running the worker in prod
- Notifications API — HTTP surface
- Notifications Admin Setup — post-deploy checklist