Skip to Content

Notification Center

The Notification Center is the single cross-persona in-app inbox that replaced the scattered alert surfaces a teacher, parent, principal, manager, or admin encountered before WP-NOTIF-BE landed. Every notification is addressed to one recipient, readable and dismissable only by that recipient, and backed by structured payloads that pass a language-safety filter before they are ever stored.

The center ships the write seam (dispatch) and the five reader routes. Alert-producing WPs (RTI, CBM, Standards, and others) adopt the seam additively in their own changes.

What it is (and what students never get)

Five recipient personas receive notifications: teacher, parent, principal, manager, and admin. Students never receive operational notifications. Students see gamification feedback (stars, badges, XP) through a separate surface, not alerts from this module.

Each Notification row is addressed to exactly one recipientUserId (an Int FK to User.id). There is no broadcast row and no role-addressed row. Fan-out is N individual rows, one per resolved recipient.

PersonaHow recipients are resolved
TeacherClassTeacher(classId, teacherUserId) junction; all co-teachers of the source class receive a row
ParentParentStudent(studentUserId, parentUserId) junction; all linked parents receive a row
Principal / Manager / AdminThe producer resolves a single concrete recipientUserId from its own scope tables

A producer never passes a role to dispatch. It passes a concrete recipientUserId. The teacher fan-out never reads a singular Class.teacherId (no such column exists per the ClassTeacher rule, B.4).

The 5 recipient-scoped routes

All routes are authenticated (requireAuth). No route accepts a :userId path parameter.

MethodPathNotes
GET/api/notificationsList notifications; filter by state and kind; cursor-paginated; urgent-first then newest-first
GET/api/notifications/unread-countReturns { count }; intended as a REST poll target (no WebSocket; hard rule 8)
POST/api/notifications/:id/readMarks one notification read; recipient-only; idempotent; terminal-state is a no-op
POST/api/notifications/:id/dismissDismisses a notification permanently; recipient-only; never resurfaces in lists
POST/api/notifications/mark-all-readReturns { updated } with the count of rows transitioned

No permission gate guards the reader routes beyond authentication. Notifications are personal; the database WHERE clause confines every result to the caller’s own rows. (requireTenantContext still runs for SUPER-admin and disabled-user or disabled-org reconfirmation.)

Recipient-only scope (no IDOR)

Every route derives the recipient from ctx.userId (the JWT sub), never from a path parameter. A mutation on a notification the caller does not own returns 404, never 403. The existence of a notification in another user’s inbox is never disclosed (B.6).

All read and write queries carry a row-level WHERE recipientUserId = ctx.userId clause. For mutation routes (/read, /dismiss), the handler runs a findFirst ownership check first; if it returns null the response is 404.

Proving tests cover the no-IDOR surface explicitly: user A creates a notification; user B (same org) calls GET /api/notifications (absent), POST /:id/read (404), and POST /:id/dismiss (404); unread-count for B is unaffected.

The dispatcher seam

dispatch({ recipientUserId, kind, payload, sourceEntity? }) is the sole write path. Producers call it in-process with no HTTP hop. The dispatcher runs six steps in order:

  1. Catalog lookup — resolves the NotificationKindCatalog row by kind. Returns 422 if the kind is unknown or inactive.
  2. Per-kind Zod payload validation — validates payload against the kind’s registered schema in schemas/registry.ts. No free-text field is permitted in any schema (Q18). Returns 422 on failure; no row is inserted.
  3. Arabic template render — substitutes {{var}} tokens from the validated payload into arabicTitleTemplate and arabicBodyTemplate from the catalog row. The renderer is deterministic and stateless (V-6).
  4. 73-language-safety filter — passes both rendered strings through sanitize() from modules/language-safety. Parent-facing kinds (defaultPersona = PARENT) load the parent-stricter tier on top of the UI tiers. If any rule would rewrite either string, the insert is aborted, a level=error event is logged, and NotificationLanguageSafetyError is thrown. There is no fallback string and no silent pass-through (Q15).
  5. Recipient and org resolution — resolves organizationId from the recipient’s User record at dispatch time.
  6. INSERT — writes the row with the stored rendered strings. No LLM is involved at any step (V-5). The language-safety pass is a deterministic rule-based substring belt.

Fan-out helpers dispatchToClass and dispatchToStudent resolve the full recipient set via the ClassTeacher and ParentStudent junctions (B.4) and then call dispatch once per recipient.

This WP ships the seam and its tests. Alert-producing WPs wire their calls to dispatch in their own changes.

The 9 Wave-1 kinds

The NotificationKindCatalog table carries exactly 9 active kinds for Wave 1. Each kind has a stable kind string (FK target for the Notification table), Arabic title and body templates, a defaultSeverity, a defaultPersona, and a retentionDays threshold. The 9 kinds are seeded by catalog-seed.ts:

KindDefault personaDefault severity
q5_24h_ackTEACHERaction_required
skill_alert_assignedTEACHERaction_required
student_alert_assignedTEACHERurgent
benchmark_profile_swapADMINinfo
dismissal_expiryTEACHERaction_required
plan_modification_suggestionTEACHERaction_required
cbm_alert_lifecycleTEACHERaction_required
parent_tsp_activationPARENTinfo
lms_connection_revokedADMINurgent

Source: apps/server/src/modules/notifications/catalog-seed.ts. The WP-RDM-BE catalog stub (a forward-declared notification_kind_catalog placeholder with soft-string fields, flagged TODO owner-spec 71) was superseded and dropped by this WP’s migration. There is one canonical model.

Tenant and cross-persona isolation

organizationId is denormalized onto every Notification row and indexed. It is resolved from the recipient’s User at dispatch time, so a notification’s org is always the recipient’s org.

Cross-org callers receive empty results (zero rows on list, 404 on /:id/read, { count: 0 } on unread-count). The recipient-scoped WHERE clause and the tenant boundary are independent defense-in-depth layers.

A SUPER admin (organizationId = null) can be a recipientUserId for platform-wide kinds (for example benchmark_profile_swap). The recipient-scoped WHERE still confines the SUPER admin to their own addressed rows. skipTenantFilter is not consulted on these reader routes; notifications are personal and there is no cross-tenant inbox view.

Cross-persona isolation within the same org follows the same mechanism: a parent-addressed parent_tsp_activation and a teacher-addressed skill_alert_assigned in the same org are never visible to each other’s inbox because recipientUserId differs.

Q18 structured payloads and Q15 language safety

Q18 — no free text: Every kind has a registered Zod schema in schemas/registry.ts. No schema contains a field named note, comment, reason, freeText, or any unconstrained string. An override rationale field references the closed 9-code OverrideCodeCatalog enum, never prose (B.3). The CI lint pnpm schema:lint:notification-payload guards the registry file. A payload that fails schema validation causes dispatch to throw NotificationPayloadValidationError before any row is inserted.

Q15 — language safety: Both rendered strings pass the 73-language-safety sanitize() filter at dispatch time. Parent-facing kinds additionally load the parent-stricter filter tier (FR-LANG-6). A violation aborts the insert with no fallback. The rendered, filtered strings are stored (renderedTitleAr, renderedBodyAr) and are immutable after insert. A later catalog template edit is forward-only; it never re-renders an existing row (V-6).

Append-only and mutability surface

The following columns are write-once: createdAt, recipientUserId, kind, payload, organizationId, renderedTitleAr, renderedBodyAr.

The only mutable column is deliveryState (plus the associated readAt and dismissedAt timestamps). The three state-transition writers (markRead, markDismissed, markAllRead) each carry the lint-allow-audit-update marker. Valid transitions are:

delivered_in_app -> read delivered_in_app -> dismissed

dismissed is terminal. A dismissed notification never resurfaces in any list. There is no reverse transition.

Notification is not in the append-only lint set. It is a whitelisted-mutable header, following the same pattern as bundleAssignment and cbmAlert.

Out of scope (Wave 2)

The following are explicitly deferred:

  • Email channel (no deliveryChannel column exists on the model)
  • Per-kind opt-in and opt-out toggles
  • Push notifications and service-worker integration
  • SMS and WhatsApp routing
  • Notification grouping or bundling
  • Daily retention purge cron — the per-kind retentionDays field is seeded and present on the catalog rows, but the scheduled hard-delete worker is not built. The purge cron is a follow-up operational task. The append-only invariant holds in the meantime.
  • Producer wiring (RTI, CBM, Standards, and other WPs calling dispatch)
  • Deep-link CTA target paths (resolved with WP-NOTIF-FE)

Q-NTF-2 (open): the 90-day default retention on parent_tsp_activation rows awaits partner Privacy-team confirmation for GDPR and FERPA alignment with the Jordanian and Palestinian pilot schools. The purge worker is deferred, so no parent row is auto-deleted yet. This open question is not yet load-bearing.

  • Language Safety: the sanitize() filter and parent-stricter tier that guard every rendered string
  • Provision Users: creating the teacher and parent records whose User.id values become recipientUserId
  • Classes and Rosters: the ClassTeacher and ParentStudent junctions used by the fan-out helpers
  • Glossary: notification kind, dispatcher, append-only
Last updated on