---
title: "Notification Center"
url: https://docs.growamal.com/en/guides/notifications/
language: en
---

# 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.

| Persona | How recipients are resolved |
|---|---|
| Teacher | `ClassTeacher(classId, teacherUserId)` junction; all co-teachers of the source class receive a row |
| Parent | `ParentStudent(studentUserId, parentUserId)` junction; all linked parents receive a row |
| Principal / Manager / Admin | The 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.

| Method | Path | Notes |
|---|---|---|
| `GET` | `/api/notifications` | List notifications; filter by `state`, `kind`, and `severity`; cursor-paginated; urgent-first then newest-first |
| `GET` | `/api/notifications/unread-count` | Returns `{ count }`; intended as a REST poll target (no WebSocket; hard rule 8) |
| `POST` | `/api/notifications/:id/read` | Marks one notification read; recipient-only; idempotent; terminal-state is a no-op |
| `POST` | `/api/notifications/:id/dismiss` | Dismisses a notification permanently; recipient-only; never resurfaces in lists |
| `POST` | `/api/notifications/mark-all-read` | Returns `{ 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)

> **Warning:** 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. **Recipient and org resolution** -- resolves `organizationId` and `persona` from the recipient's `User` record. If the recipient is inactive or deleted, dispatch short-circuits with `{ skipped: "recipient_inactive" }` and no row is inserted.
5. **73-language-safety filter** -- passes both rendered strings through `sanitize()` from `modules/language-safety`. The parent-stricter tier is loaded when either the catalog `defaultPersona` is `PARENT` or the resolved recipient `persona` is `PARENT` -- so a teacher-default kind dispatched to a parent recipient via `dispatchToStudent` is also subject to the stricter belt (BUG-4 fix, documented in `dispatcher.ts`). 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).
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`:

| Kind | Default persona | Default severity |
|---|---|---|
| `q5_24h_ack` | TEACHER | action\_required |
| `skill_alert_assigned` | TEACHER | action\_required |
| `student_alert_assigned` | TEACHER | urgent |
| `benchmark_profile_swap` | ADMIN | info |
| `dismissal_expiry` | TEACHER | action\_required |
| `plan_modification_suggestion` | TEACHER | action\_required |
| `cbm_alert_lifecycle` | TEACHER | action\_required |
| `parent_tsp_activation` | PARENT | info |
| `lms_connection_revoked` | ADMIN | urgent |

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. The parent-stricter filter tier (FR-LANG-6) is loaded when either the catalog `defaultPersona` is `PARENT` or the resolved recipient `persona` is `PARENT` -- so a teacher-default kind dispatched fan-out to a parent recipient also clears the stricter belt (BUG-4 fix). 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)

> **Note:** **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.

## Related

- [Language Safety](https://docs.growamal.com/en/guides/language-safety/): the `sanitize()` filter and parent-stricter tier that guard every rendered string
- [Provision Users](https://docs.growamal.com/en/guides/provision-users/): creating the teacher and parent records whose `User.id` values become `recipientUserId`
- [Classes and Rosters](https://docs.growamal.com/en/guides/classes-and-rosters/): the `ClassTeacher` and `ParentStudent` junctions used by the fan-out helpers
- [Glossary](https://docs.growamal.com/en/glossary/): notification kind, dispatcher, append-only
