DocsDeveloper

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:

  1. NotificationService (validates keys, resolves defaults, looks up templates)
  2. /settings/notifications UI (one card per category, one row per key)
  3. The preferences API (rejects unknown keys, refuses disabling locked notifications)
  4. 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

CategoryKeys
Courses & enrollmentENROLL_WELCOME, ENROLL_COURSE_PUBLISHED, ENROLL_ACCESS_EXPIRING
Assignments & deadlinesLESSON_ASSIGNMENT_DUE_REMINDER, LESSON_ASSIGNMENT_OVERDUE, LESSON_NEW_CONTENT
Grades & feedbackASSESS_GRADED, ASSESS_RESUBMIT_REQUIRED, ASSESS_FEEDBACK_ADDED, ASSESS_QUIZ_FAILED_RETRY, ASSESS_AT_RISK_GRADE, PEER_REVIEW_ASSIGNED, ASSESS_PEER_GRADED
Live classesLIVE_CLASS_STARTED, LIVE_CLASS_REMINDER_24H, LIVE_CLASS_REMINDER_15MIN, LIVE_CLASS_CANCELLED, LIVE_CLASS_RECORDING_READY
Progress & engagementREENGAGE_MILESTONE, REENGAGE_NUDGE
CertificatesCERT_EARNED, CERT_EXPIRY_WARNING
Digests & summariesDIGEST_STUDENT_DAILY, DIGEST_STUDENT_WEEKLY
TeachingTEACHER_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
AdministrationADMIN_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:

  1. Resolve preference key (child → parent)
  2. Fetch user preference row (fall back to definition defaults)
  3. Compute active channel set (enabled AND requested)
  4. If frequency is DAILY_DIGEST/WEEKLY_DIGEST and not forceImmediate → re-route to queueForDigest()
  5. Run suppression rules
  6. Look up template (institution override → system default)
  7. Render with Handlebars + helpers
  8. Fan out per channel:
    • IN_APP — synchronous LMSNotification.create({ status: "SENT" })
    • EMAILLMSNotification.create({ status: "PENDING" }) + enqueue
    • PUSH — same pattern, push queue

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.

#RuleEffect
1BOUNCENotificationUserState.bouncedAt set → skip EMAIL channel
2DUPLICATESame (key, entityId) sent within 1h → drop entirely
3DAILY_CAPUser received 3 SENT rows in last 24h → drop (unless key in bypass list or forceImmediate)
4REENGAGE_COOLDOWNFor REENGAGE_* keys: any SENT in last 24h → delay all channels by 24h
5QUIET_HOURS22: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:

  1. Institution override (institutionId = <tenant>, key, isActive: true)
  2. 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:

HelperOutput
{{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:

QueueConcurrencyPurpose
notifications-email10Render-already-done email send via sendEmail()
notifications-push20FCM via sendToUser()
notifications-digest5Flush a (userId, key, window) group of DigestQueueItem rows
notifications-reengage1Cron — scan ReEngagementTracking for 7/14/30-day boundaries
notifications-due-reminder1Cron — 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:

SchedulePatternWhat it does
Digest scan*/15 * * * *Walk DigestQueueItem for processed=false, scheduledFor<=now; enqueue one digest job per (userId, key)
Reengage scan0 */6 * * *Trigger processReengageJob
Due-reminder scan0 * * * *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:

StatusMeaning
PENDINGEnqueued, awaiting worker
SENTSuccessfully delivered
SKIPPEDSuppressed (with failureReason like quiet_hours, daily_cap_exceeded, duplicate_within_1h, email_bounced, no_push_tokens)
FAILEDFinal 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

  1. Add the entry to NOTIFICATION_DEFINITIONS
  2. Add a system-default NotificationTemplate row (edit the seed file + run npm run seed:notifications)
  3. Call notificationService.send({ key: "MY_NEW_KEY", ... }) from your handler
  4. If the new key needs a deep link, extend _buildActionUrl() switch
  5. Verify the new card renders on /settings/notifications for the right roles
Warning

Never prisma.lMSNotification.create() directly. The audit, suppression, and digest stories all assume the service runs first.