Notification Features and Flows
Complete feature list, actor journeys, state flows, business rules, edge cases, and diagrams for notification.
Notification Features and Flows
The platform tells people things through two different features that share a noun, and separating them is the first thing to understand here.
Anything addressed to a person — a parent, a pupil, a teacher — fans out on write. A business event is recorded, an audience specification is resolved into individuals in the background, and each individual gets a row per channel that carries what actually happened to their copy of the message. Four channels carry it: email, SMS, push and in-app.
Operational events for administrators fan out on read. One row records the event once, carrying the permission code a viewer must hold, and the panel's bell filters by the viewer's active role at query time. Nobody is named, nobody can opt out, and granting somebody a permission tomorrow makes yesterday's events visible to them.
Everything below is one or the other, and the two never mix.
1. Documentation Evidence
| Source Type | Files or Docs | What Was Extracted |
|---|---|---|
| Backend | Backend doc, and directly: every service under apps/api/src/modules/notification/shared/, channels/, workers/, admin/*/, customer/*/, plus apps/api/src/modules/notification-feed/ | Business behaviour, side effects, persistence, state machines, and what each worker actually does. |
| API | API doc, and directly: every *.controller.ts in those directories | Route surface, actors, auth, response-visible behaviour. |
| Schema | packages/db/src/schema/notification/*.ts, packages/db/src/schema/notifications.ts | Constraints, indexes, delete behaviour, and the invariants they enforce. |
| Vocabulary | packages/db/src/notification/notification-contract.ts | Every channel, category, priority, delivery status, audience kind, push platform and invalidation reason. |
| Templates | apps/api/src/modules/notification/templates/template-registry.ts | Every kind the system can send today, its category, priority, dedupe policy, suppressibility and channels. |
| Route ground truth | apps/api/test/structure/structure.baseline.json | Confirmed the 21 routes documented here: 10 admin, 3 admin feed, 8 mobile. |
| Config | apps/api/src/modules/notification/shared/notification.constants.ts, apps/api/.env.example | Every tunable, its clamp and its default. |
| Callers | apps/api/src/modules/auth/services/auth-email.service.ts | The four production call sites that raise notifications today. |
| Tests | apps/api/src/modules/notification/__tests__/*.int.spec.ts and the per-submodule *.int.spec.ts files | Confirmed edge cases and expected behaviour. |
2. Feature Summary
| Field | Value |
|---|---|
| Module | notification |
| Submodule | notification-feed is a sibling module documented here, because it is the second delivery model. |
| Primary user value | A parent, pupil or member of staff receives what the school needs them to know, through the channels they chose, in their own language, with a record an operator can inspect when it does not arrive — and an administrator sees operational events in the panel filtered to what their role is allowed to know about. |
| Actors | Guardian, student, teacher, staff, admin, superadmin, worker/system. No guest actor — every route requires a session. |
| Main entry points | NotificationService.send() called from inside another module's transaction; eight mobile routes; ten admin routes; three admin-feed routes; four cron-driven maintenance jobs. |
| Main outputs | notification_event / notification_recipient / notification_delivery rows; an email, an SMS or a push message; an in-app row in the notification centre; a notification row in the admin bell; a job_failures row when a send fails terminally; a per-user realtime publish. |
| Related docs | API, Backend |
3. Actor Matrix
| Actor | Can Do | Cannot Do | Auth Requirement | Notes |
|---|---|---|---|---|
| Guardian | Read their own notification centre; mark one or all read; see the unread badge; register and remove push devices; set per-category channel preferences | See a notification addressed to another family, see a staff-audience notification, turn off a security notification, read anything on an admin surface | JWT | Sees only rows where they are the recipient and the row is not role-scoped to a role they are not currently acting as. |
| Student | The same as a guardian | The same | JWT | A pupil resolved through a class audience carries their student-scoped role on the recipient row. |
| Teacher / staff | The same, plus staff-audience notifications while acting in a staff role | See staff-audience notifications after being dismissed, even though their user row may still exist because they are also a parent | JWT | The notification centre re-checks for a live staff row on every read. This is the one predicate that ownership alone would not cover. |
| Admin | List and read template overrides; create, edit and delete them; read notification history; cancel a scheduled event; read and replay channel dead letters; read the operational feed and mark it read | Read a recipient's name, email, phone or the rendered message body; replay a failure from any queue outside the three notification channel queues; see a feed row their active role has no permission for | Admin JWT plus the relevant NotificationTemplate_*, NotificationHistory_* or NotificationFailure_* permission — except the feed, which is filtered per row | The permissions are separate modules on purpose: replay is a write capability wearing a diagnostic name, and history is deliberately unable to re-aggregate personal data. |
| Superadmin | Everything an admin can, plus the operational feed with no permission filter | — | Admin JWT with a superadmin active role | The feed treats a superadmin as "no filter" rather than materialising the whole permission catalogue. |
| Any signed-in person with no active role | Very little: the notification centre narrows to rows that are not role-scoped; the operational feed returns nothing at all | — | JWT | The panel should send such a user to the role chooser rather than showing an empty bell. |
| Calling module (internal) | Raise a notification of a known kind, for an audience specification, on chosen channels, with variables and an optional action | Choose the category, the priority, whether the kind is unsuppressible, or whether a repeat is a duplicate | In-process, inside its own database transaction | All four are properties of the kind and come from the registry, so no module can send a marketing blast as system past every opt-out. |
| Worker / system | Resolve audiences; write recipient and delivery rows; render; call providers; record outcomes; reclaim expired leases; re-queue elapsed backoffs; release stale fan-out claims; re-dispatch orphan events; re-drive skipped deliveries once credentials arrive; delete aged history; prune invalidated push tokens; check the SMS balance; send fixed-mailbox operational alerts | Bypass a preference, persist a secret, or send without a delivery row (except the operational-alert queue, which has none by design) | BullMQ, in-process cron | Every enqueue is an outbox row, never a direct queue.add. |
4. Capability Matrix
| Capability | Surface | Actor | Route/Trigger | State Read | State Written | Linked API Section |
|---|---|---|---|---|---|---|
| Raise a notification | Internal | Calling module | NotificationService.send(tx, input) | The template registry | notification_event, outbox_events | Backend 7.1 |
| Resolve an audience and write recipients | Worker | System | notification.fan_out | The audience spec, identity and school tables, preferences, active push tokens, template overrides, locales | notification_recipient, notification_delivery, outbox_events, notification_event completion columns | Backend 7.2 |
| Send one delivery | Worker | System | notification_channel.send_email / _sms / _push | The delivery, its recipient, the event, the user, the push token, template overrides, the Redis secret vault | notification_delivery, sometimes notification_push_token.is_active | Backend 7.3 |
| Deliver in-app inline | Worker | System | Part of notification.fan_out | Template overrides, the recipient's locale | A delivered delivery with rendered content | Backend 7.2 |
| Publish a realtime arrival | Worker | System | After each fan-out batch commits | — | A Redis publish on realtime:user:<id> | Backend 10 |
| List my notifications | Mobile | Guardian, student, teacher, staff | GET /api/mobile/notifications | Recipient rows, events, roles, staff liveness, in-app deliveries | — | API 8.14 |
| See my unread badge | Mobile | The same | GET /api/mobile/notifications/unread-count | The same | — | API 8.15 |
| Mark one read | Mobile | The same | POST /api/mobile/notifications/{publicId}/read | The same | notification_recipient.read_at | API 8.17 |
| Mark everything read | Mobile | The same | POST /api/mobile/notifications/read-all | The same | notification_recipient.read_at | API 8.16 |
| Register a push device | Mobile | The same | POST /api/mobile/notification-devices | Active tokens for this token value and for this user | notification_push_token | API 8.18 |
| Remove a push device | Mobile | The same | DELETE /api/mobile/notification-devices/{publicId} | The caller's own active tokens | notification_push_token invalidation columns | API 8.19 |
| Read my preference matrix | Mobile | The same | GET /api/mobile/notification-preferences | Three preference tables, the template registry | — | API 8.20 |
| Save my preferences | Mobile | The same | PUT /api/mobile/notification-preferences | The version row | notification_preference_set, notification_preference | API 8.21 |
| List template overrides | Admin | Admin | GET /api/notification-templates | notification_template | — | API 8.1 |
| Read one override | Admin | Admin | GET /api/notification-templates/{publicId} | The same | — | API 8.2 |
| Create an override | Admin | Admin | POST /api/notification-templates | The template registry | notification_template, activity | API 8.3 |
| Edit an override | Admin | Admin | PATCH /api/notification-templates/{publicId} | The row and its version | notification_template, an activity record with per-field changes | API 8.4 |
| Delete an override | Admin | Admin | DELETE /api/notification-templates/{publicId} | The row and its version | notification_template, activity | API 8.5 |
| Browse notification history | Admin | Admin | GET /api/notification-history | Events, plus a grouped failure count | — | API 8.6 |
| Inspect one event in full | Admin | Admin | GET /api/notification-history/{publicId} | The event, every recipient, every delivery | — | API 8.7 |
| Cancel a scheduled event | Admin | Admin | DELETE /api/notification-events/{publicId} | The event | notification_event.cancelled_at, activity | API 8.8 |
| Browse channel dead letters | Admin | Admin | GET /api/notification-failures | job_failures, hard-scoped to three queues | — | API 8.9 |
| Replay a failed send | Admin | Admin | POST /api/notification-failures/{publicId}/replay | The failure row, the original delivery | A new notification_delivery, outbox_events, job_failures claim | API 8.10 |
| Read the operational feed | Admin feed | Any signed-in admin actor | GET /api/notifications | notification, this caller's read receipts, the active role's permissions | — | API 8.11 |
| Mark one feed row read | Admin feed | The same | POST /api/notifications/{publicId}/read | The same | notification_read | API 8.12 |
| Clear the bell | Admin feed | The same | POST /api/notifications/read-all | The same | notification_read | API 8.13 |
| Reclaim expired delivery leases | Worker | System | notification.reap, every 30 seconds | notification_delivery | notification_delivery | Backend 7.4 |
| Re-queue elapsed backoffs | Worker | System | The same tick | The same | The same | Backend 7.4 |
| Release stale fan-out claims | Worker | System | The same tick | notification_event | notification_event.fanout_claimed_at | Backend 7.4 |
| Re-dispatch orphan events | Worker | System | The same tick | notification_event | outbox_events | Backend 7.4 |
| Re-drive the pre-credential backlog | Worker | System | notification.backfill_unconfigured, every 5 minutes | Provider configuration, notification_delivery | notification_delivery, outbox_events | Backend 7.5 |
| Delete aged history | Worker | System | notification.prune, hourly | Four tables plus job_failures | notification_delivery, notification_recipient, notification_event, notification_push_token, notification | Backend 7.6 |
| Warn on low SMS credit | Worker | System | notification.check_sms_credit, daily at 01:00 local | The provider balance | A log line | Backend 7.7 |
| Send an operational alert | Worker | System | notification_operational.send_email | The rendered payload | An email; job_failures on terminal failure | Backend 7.8 |
5. User-Facing Flows
5.1 Receiving a notification
Summary
From the recipient's side this is invisible until it arrives. Somebody at the school does something — publishes an announcement, requests a password reset on their behalf, records an absence — and shortly afterwards a message appears: an email, a text, a push notification on the phone, a row in the app's notification centre, or several of those at once. Which ones arrive depends on what the sender asked for, what the person's preferences allow, and whether they have a usable address or device.
Preconditions
- The person has a live
usersrow withcan_login = true. A deleted or disabled account is not addressable. - They match the event's audience specification at the moment the fan-out batch reaches them.
- For email, the user row carries an address (or the sender supplied one through the secret vault, as change-email verification does).
- For SMS, the user row carries a phone number that normalises.
- For push, they hold at least one active token.
- For in-app, nothing further — it always works if the kind has an in-app template.
- The relevant provider has credentials, or the delivery is recorded
skipped_unconfiguredand re-driven later.
Main Flow
| Step | Actor/System | Action | Result | Source |
|---|---|---|---|---|
| 1 | Calling module | Raises a notification inside its own transaction. | An event row and an outbox row commit together with the business write. | NotificationService.send |
| 2 | Outbox dispatcher | Relays the fan-out job. | A job on notification_fanout. | The outbox module |
| 3 | Fan-out worker | Claims the event, then resolves the audience one bounded page at a time. | Up to 500 people per batch. | AudienceResolverService |
| 4 | Fan-out worker | For each person, applies their preferences per requested channel. | A delivery row per channel, either queued or terminal. | PreferenceResolverService |
| 5 | Fan-out worker | Renders and completes the in-app channel inline. | A delivered row carrying the title and body the person will read. | TemplateRendererService |
| 6 | Fan-out worker | Writes an outbox row per queued remote delivery, in the same transaction. | Email, SMS and push jobs scheduled. | The batch transaction |
| 7 | Fan-out worker | After the batch commits, publishes a per-user realtime event. | An open session updates its badge without polling. | NotificationRealtimePublisherService |
| 8 | Channel worker | Claims one delivery under a lease, re-checks liveness, resolves any secret, renders, calls the provider. | sent, a skipped_* state, or a failure. | ChannelSendService |
| 9 | Recipient | Opens the app or their inbox. | The message. | — |
Sequence Diagram
Branches and Edge Cases
| Branch | Condition | Behavior | Error/Result |
|---|---|---|---|
| Preference suppresses a channel | The person turned that category off for that channel | A delivery row is still written, terminal | skipped_preference — which is what makes "why did this parent not get the SMS" answerable from the record |
| Unsuppressible kind | The kind is a security or system kind marked unsuppressible | Preferences are ignored entirely | Delivered regardless |
| No push token | The person has no active device | One terminal row per recipient | skipped_no_destination |
| Several push tokens | The person has N active devices | N delivery rows, one per token | Each with its own outcome and provider message id |
| No provider credentials | The channel's provider reports itself unconfigured | Terminal, and not a failure | skipped_unconfigured, re-driven automatically once credentials arrive |
| No template for the channel | The kind has no builder, or a placeholder cannot be resolved | Terminal | skipped_no_template — never a message containing literal {{name}} |
| Recipient deleted between fan-out and send | An event scheduled a day ahead, a deletion an hour later | The send path re-checks liveness | skipped_no_destination |
| Secret expired | The Redis vault entry was consumed or expired before the send | No message | skipped_no_destination — a reset link that goes nowhere is worse than no email |
| Provider refuses the message | A bad address, a blocked domain, a dead push token, no SMS credit | Non-retryable | Straight to dead; a dead push token is deactivated so future sends stop failing |
| Provider unreachable | DNS, TLS, a timeout, a 5xx | Retryable | failed with a backoff, returned to queued when it elapses |
| Attempts exhausted | Five attempts | Terminal | dead, plus a dead-letter row an operator can replay |
| Duplicate raise | The same kind and aggregate raised twice, with a collapse policy | The second insert is a no-op | One event, one set of messages |
| Legitimate repeat | A second password reset or OTP request | The caller supplies a request id, so it is a distinct event | A second message, which is the whole point |
5.2 Reading the notification centre
Summary
A parent opens the app and sees what the school has told them, newest first, with a badge showing how many they have not read. Tapping one marks it read and, if it carries an action, takes them somewhere.
Preconditions
- A session, and an active role. Without an active role the list narrows to notifications that are not role-scoped.
- At least one notification whose in-app delivery was not suppressed.
Main Flow
| Step | Actor/System | Action | Result | Source |
|---|---|---|---|---|
| 1 | Person | Opens the notification screen. | GET /api/mobile/notifications. | Controller |
| 2 | Backend | Applies ownership, active-role scoping, staff liveness and an in-app delivery check. | Only rows this person may see in this role. | NotificationCentreService |
| 3 | Backend | Reads the title and body frozen at fan-out. | The wording as it was delivered, not as the template reads today. | The in-app delivery row |
| 4 | Person | Taps one. | POST /api/mobile/notifications/{publicId}/read, returning the updated row. | Controller |
| 5 | Backend | Sets read_at if it was null. | The badge decreases. | coalesce(read_at, now()) |
Sequence Diagram
Branches and Edge Cases
| Branch | Condition | Behavior | Error/Result |
|---|---|---|---|
| Empty state | No notifications yet | An empty list and a zero badge | data: [], count: 0 |
| Switching active role | A teacher who is also a parent changes role | The list and badge both change | Staff-audience rows appear or disappear |
| Dismissed teacher | The staff row is soft-deleted but the users row survives because they are a parent | Staff-audience rows disappear | The liveness EXISTS is what enforces it |
| Email-only notification | The event requested only email | Never appears in the centre | The in-app EXISTS excludes it |
| Suppressed in-app | The person turned in-app off for that category | Never appears | skipped_preference is excluded by the predicate |
| Marking read twice | Two devices, or a double tap | Idempotent | The first timestamp stands |
| A notification older than retention | 30 days by default | Gone entirely | 404 on a stale publicId |
| Guest | No session | Refused before anything runs | 401 |
5.3 Managing notification preferences
Summary
A person opens their settings and sees a grid: nine categories by four channels, each switch showing what would actually happen today. They change some, save, and the change takes effect on the next thing the school sends.
Preconditions
A session. Nothing else — a person who has never saved sees the shipped defaults with version: 0.
Main Flow
| Step | Actor/System | Action | Result | Source |
|---|---|---|---|---|
| 1 | Person | Opens preferences. | GET, returning the resolved matrix plus a version. | NotificationPreferencesService.get |
| 2 | Backend | Applies the precedence order per cell: per-category override, then the global channel switch, then the code default. | A grid the client can render directly. | PreferenceResolverService |
| 3 | Backend | Marks a category locked when every kind in it is unsuppressible. | The UI hides that row's switches. | Computed from the registry |
| 4 | Person | Flips some switches and saves. | PUT with the version and the changed cells. | Controller |
| 5 | Backend | Compare-and-set on the version, then upserts each override, in one transaction. | Atomic across categories. | .update |
| 6 | Backend | Re-reads and returns the resolved matrix. | The client never has to guess what it saved. | .get |
Branches and Edge Cases
| Branch | Condition | Behavior | Error/Result |
|---|---|---|---|
| First save | No preference set row exists | version: 0 is an insert that must succeed | Version becomes 1 |
| Concurrent save | Two devices with the same version | The first wins entirely; the second writes nothing | 409 NOTIFICATION_PREFERENCE_VERSION_CONFLICT |
| Locked category | An override naming security | Accepted and silently not persisted | The response still shows it locked and fully on |
| Duplicate cells | The same (category, channel) twice in one body | De-duplicated, last wins | No error |
| Empty body | overrides: [] | Legal; bumps the version, changes nothing | A valid way to take the lock |
| A new category ships | A category added after the person last saved | Appears immediately with its code default | Because absence means "the default" rather than a stored row |
| Trying to mute a password reset | Unsuppressible kind | Impossible | Suppressibility is a property of the kind in code, not a column and not a request field |
| Global channel switch | "No email at all" | Honoured by the resolver, but not writable through this API today | Read-only from the client's perspective |
5.4 Registering a device for push
Summary
A phone or a browser asks for permission, receives a token from the platform's push service, and posts it. From then on push notifications reach that device. Signing out removes it.
Preconditions
A session, and a token from FCM or a browser subscription.
Main Flow
| Step | Actor/System | Action | Result | Source |
|---|---|---|---|---|
| 1 | App or browser | Obtains a push token. | — | Platform SDK |
| 2 | App | Posts the token and its platform. | POST /api/mobile/notification-devices. | Controller |
| 3 | Backend | Binds it to the session's user — never to anything in the body. | A token cannot be attached to somebody else's account. | NotificationDevicesService |
| 4 | Backend | Checks the active-token cap. | Refused at ten, rather than evicting one. | The cap check |
| 5 | Fan-out | Reads active tokens when a push channel is requested. | One delivery row per token. | The fan-out worker |
| 6 | Person | Signs out. | DELETE, which soft-invalidates with reason user_logout. | Controller |
Branches and Edge Cases
| Branch | Condition | Behavior | Error/Result |
|---|---|---|---|
| Re-registering on every launch | The same user, the same token | A touch: last_used_at and platform updated | 201, with the original createdAt, and the cap is not consulted |
| A token that belongs to somebody else | A shared tablet, a resold device, a sibling | The old row is invalidated with reason replaced and a fresh one inserted | The first user's record survives, and nobody can silently steal a victim's push by presenting their token |
| At the cap | Ten active tokens | Refused | NOTIFICATION_DEVICE_LIMIT_REACHED — the caller learns the limit rather than losing a device it never asked to remove |
| Web push | platform: "web" | No user_device row, because a browser has no installation | FCM receives a webpush block, and the click destination is fcmOptions.link — the only thing that decides what a click opens when every tab is closed |
| A non-https action URL on web | Development, or a bad template | The link is dropped rather than sent | FCM refuses a non-https link with a non-retryable error, which would otherwise fail every web delivery and blame the token |
| FCM declares a token dead | Reinstall, uninstall, expiry | The send path deactivates it with unregistered or invalid_argument | Future sends to that device stop failing forever |
| Removing twice | A double tap on sign-out | The second is 404 | The row is no longer active |
| Removing the last device | — | Permitted | Push deliveries then record skipped_no_destination |
5.5 Reading the operational feed
Summary
An administrator sees the bell in the panel header light up when something operational happens — a feedback submission, and every school event added to the realtime map. It is one row per event, not per person, and what an operator sees is exactly what their current role could already have read by opening the relevant screen.
Preconditions
An admin session with an active role. A session with no active role sees nothing at all.
Main Flow
| Step | Actor/System | Action | Result | Source |
|---|---|---|---|---|
| 1 | Some module | Records an operational event through the outbox. | A notification row carrying the permission of the screen it belongs to. | The outbox dispatcher |
| 2 | Panel | Reads the bell. | GET /api/notifications. | Controller |
| 3 | Backend | Resolves the active role's permission set. | null for a superadmin, [] for no active role. | RoleService |
| 4 | Backend | Filters rows by that set, joins this caller's read receipts, counts unread across everything visible. | The list and the badge. | NotificationFeedService |
| 5 | Operator | Clicks one, or clears the bell. | A notification_read row per notification per person. | Controller |
Branches and Edge Cases
| Branch | Condition | Behavior | Error/Result |
|---|---|---|---|
| Superadmin | Active role is superadmin | No permission filter at all | Rather than materialising the entire catalogue into an IN (...) |
| No active role | The session has not chosen one | The permission list is empty, which becomes SQL false | Nothing, deliberately — an empty list read as "no filter" would show everything |
| Newly granted permission | An operator gains a screen's READ | Past events for that screen become visible, and unread | The property fan-out-on-read exists for |
| Revoked permission | — | Those rows disappear from both list and count | Same mechanism |
| A row somebody else read | — | Still unread for this caller | The read join carries user_id = actor.id; without it one person reading would clear it for the office |
| Marking an invisible row read | Guessing a public id | 404, exactly as an absent row | A 403 would confirm it exists |
| Clearing the bell | read-all | Only rows currently visible get a receipt | So gaining the permission later does not silently hide them |
| The badge versus the page | Fifty rows returned, two hundred unread | The badge is the true total | Counted across everything visible, not the page |
6. Admin Flows
6.1 Changing the wording of a notification
Summary
An operator wants a different greeting, a translated body, or a shorter SMS. They create an override for one (kind, channel, locale). The code registry stays the source of truth and the fallback, so a bad edit degrades to the shipped wording rather than silencing anything.
Route: POST /api/notification-templates, then PATCH and DELETE on /{publicId}. Permissions NotificationTemplate_CREATE, _UPDATE, _DELETE.
| Step | Action | Side effects |
|---|---|---|
| 1 | List overrides. An empty list is correct on a fresh deployment — the table is never seeded. | Read only |
| 2 | Create an override, naming a kind the registry knows. | One row; the global activity interceptor records it |
| 3 | Edit it, echoing the version read from GET. | The row, version + 1, and an explicit activity record carrying per-field before-and-after values |
| 4 | Delete it to revert to the shipped copy. | The row is hard-deleted; nothing is lost, because the registry is the durable copy |
Cache invalidation: none, because there is no cache. Overrides are loaded once per fan-out batch, so an edit takes effect on the next batch and never rewrites a message already delivered.
Why an operator cannot break notifications with a bad edit. Three layers. An unresolved placeholder makes the override unrenderable and the renderer falls through to the built-in builder, logging once per key rather than once per recipient. An override that throws does the same. And an override is only ever an override — deleting it, deactivating it, or getting it wrong all end at the same place, which is the wording the code ships with.
What an operator cannot do: evaluate an expression. Interpolation is {{name}} substitution against a flat record, from an allowlist the variables themselves define. An admin-editable template that could evaluate expressions would be a code execution surface behind a CRUD permission.
6.2 Investigating a message that did not arrive
Summary
A parent says they never got the announcement. An operator opens notification history, finds the event, and reads the delivery rows beneath the relevant recipient. The status is the answer.
Routes: GET /api/notification-history, then GET /api/notification-history/{publicId}. Permission NotificationHistory_READ.
| Status found | What it means | What to do |
|---|---|---|
delivered (in-app) | It is in their notification centre. | Ask them to open the app; check they are acting in the right role. |
sent | The provider accepted it. | Check spam, or the number. sent is acceptance, not proof of arrival — no delivery receipts are implemented. |
skipped_preference | They turned that category off for that channel. | Their own setting. Nothing is broken. |
skipped_no_destination | No address, no active push token, an unnormalisable phone, an expired secret, or the account is gone. | Fix the contact detail, or ask them to re-register the device. |
skipped_unconfigured | The channel has no provider credentials. | Supply them; the backfill worker re-drives the last 24 hours automatically. |
skipped_no_template | The kind has no builder for that channel, or a variable could not be resolved. | An engineering issue, not an operational one. |
failed | A retryable failure with a backoff pending. | Wait — the reaper returns it to queued. |
dead | Attempts exhausted, or a failure the provider says will never succeed. | Check lastError, fix the cause, then replay. |
cancelled | The event was cancelled before this delivery was claimed. | Expected. |
| No delivery row for that channel at all | The channel was never requested for that kind. | Check the sending module. |
What the screen deliberately will not tell you: the recipient's name, email address or phone number, and the rendered message body. NotificationHistory_READ is not a superadmin-only permission, and returning any of those would turn one operational grant into the personal-data re-aggregation the permission catalogue was split apart to prevent. What it gives instead is the recipient row's own public id and a masked destination hint.
6.3 Replaying a failed send
Summary
After fixing the cause — topping up SMS credit, supplying a provider key, correcting an address — an operator re-sends. The replay creates a new delivery pointing at the original and schedules that; the original is left untouched, because its failedAt, lastError and providerMessageId are the evidence they opened the screen to read.
Routes: GET /api/notification-failures, then POST /api/notification-failures/{publicId}/replay. Permissions NotificationFailure_READ and _UPDATE.
Why the queue allowlist matters. job_failures is global across every queue in the platform, including database restores. Without a hardcoded allowlist, a clerk holding NotificationFailure_READ would see a restore's failures, and _UPDATE would let them re-run one. The allowlist is deny-by-default, ANDed unconditionally, and the channel filter can only narrow it.
Why the screen is not the same as a dead delivery. A delivery reaches dead through the row's own retry budget; a dead-letter row is written when the BullMQ job throws on its final attempt. They usually coincide, but a skipped_* delivery raises no dead letter at all, because a skip is deliberately not a failure.
6.4 Cancelling a scheduled send
Summary
A scheduled announcement to three thousand families is about to go out and should not. Cancelling works while the event has not yet fanned out.
Route: DELETE /api/notification-events/{publicId}. Permission NotificationHistory_UPDATE — the single write this history surface makes, on the rows it already reads.
| Outcome | Condition |
|---|---|
200 with cancelledAt | The claim matched: not fanned out, not already cancelled. |
409 NOTIFICATION_EVENT_ALREADY_FANNED_OUT | The fan-out worker won the race. The messages are queued or gone. |
409 NOTIFICATION_EVENT_CANCELLED | Somebody already cancelled it. |
404 NOTIFICATION_EVENT_NOT_FOUND | No such event. |
The claim is a compare-and-set rather than a read-then-write, because the fan-out worker can be racing the same row and a read-then-write would let a cancel appear to succeed a heartbeat after the worker committed. The two 409s are distinguished by a deliberate re-read, so the operator learns which happened rather than being told "conflict". The fan-out worker's own claim carries cancelled_at IS NULL, so a cancel landing between its read and its claim wins.
A partially fanned-out event is a partial cancel. fanned_out_at is written only on completion, so an event mid-fan-out still satisfies the cancel predicate — but the batches already committed have already queued their deliveries, and those are not withdrawn.
6.5 Bringing a channel online
Summary
A deployment starts with no SMS credentials. Everything works; SMS deliveries record skipped_unconfigured, which consumes no attempt, raises no dead letter and is excluded from every failure metric. When the credentials arrive, the backlog moves on its own.
| Step | Actor | Effect |
|---|---|---|
| 1 | Operator | Sets the provider environment variables and restarts. |
| 2 | Boot | The provider reports itself configured; the "not configured" warning stops. |
| 3 | Backfill worker, within five minutes | Selects skipped_unconfigured deliveries on now-configured channels, newer than 24 hours. |
| 4 | Backfill worker | Compare-and-sets each back to queued, clearing skipped_at, and schedules it through the outbox. |
| 5 | Channel worker | Sends normally. |
Why the window is bounded. Adding a provider a month late must not resurrect weeks of stale announcements at somebody's expense. Twenty-four hours by default, and a boot assertion keeps that window shorter than the retention window — otherwise retention would delete exactly the backlog the backfill worker exists to rescue.
Why an unconfigured channel is not silent, either. A single boot warning names each unconfigured provider, and the history screen surfaces skipped_unconfigured as a first-class status, so the condition is visible without reading a log. Where a deployment genuinely depends on a channel, naming it in NOTIFICATION_REQUIRED_CHANNELS makes an unconfigured provider refuse to boot — and requiring a channel while mocking it in production is also a hard refusal, because that is the one combination that looks healthy and delivers nothing.
6.6 Watching the SMS balance
The gateway answers "Not enough balance." with HTTP 200 and an error flag, and that failure is non-retryable — the delivery goes straight to dead. So the first symptom of an exhausted account, without a check, is a school-wide SMS outage discovered by a parent who never got their OTP, with a pile of dead deliveries behind it. A daily balance read turns that into a warning with days of lead time, at the cost of one HTTP call.
7. Lifecycle and State Transitions
7.1 The delivery state machine
| Entity | From | Event/Action | To | Guard Condition | Side Effects |
|---|---|---|---|---|---|
notification_delivery | — | Fan-out insert | queued | The channel is enabled for this person | queued_at set; an outbox row for remote channels |
| — | Fan-out insert | skipped_preference | The preference suppressed it | skipped_at set; terminal | |
| — | Fan-out insert | skipped_no_destination | Push, and no active token | skipped_at set; terminal | |
| — | Fan-out insert | skipped_no_template | In-app, and the template did not render | skipped_at set; terminal | |
| — | Fan-out insert | delivered | In-app, rendered | delivered_at, provider = 'in_app', rendered content; sent_at stays null | |
queued | Channel worker claim | processing | Still queued | claimed_at, lease_expires_at, claimed_by | |
processing | Provider accepted | sent | — | sent_at, provider, provider_message_id, masked hint; lease cleared | |
processing | Provider skipped | skipped_unconfigured / skipped_no_destination | — | skipped_at; lease cleared; terminal | |
processing | Liveness, kind, secret or template check failed | skipped_no_destination / skipped_no_template | — | The same | |
processing | Retryable failure, budget remaining | failed | attempts + 1 < 5 | failed_at, last_error, next_attempt_at, counters; lease cleared | |
processing | Non-retryable, or budget exhausted | dead | — | failed_at, last_error; no backoff; a dead letter on the job's final attempt | |
processing | Lease expired, budget remaining | queued | attempts + 1 < 5 | attempts + 1, lease_expiry_count + 1, a backoff; failed_at and last_error cleared | |
processing | Lease expired at the budget | dead | attempts + 1 >= 5 | last_error = LEASE_EXPIRED | |
failed | Backoff elapsed | queued | attempts < 5 | failed_at, last_error, next_attempt_at all cleared | |
skipped_unconfigured | Credentials arrive | queued | Within the backfill window | skipped_at cleared; an outbox row with a distinct dedupe key | |
sent | A delivery receipt | delivered | Not implemented | Would set delivered_at |
Why skipped_* is not failed. Conflating them would make an unconfigured development machine look like a production outage, and would bury a real outage in the noise. Every failure count in every admin surface excludes them.
Why there is no created status. A delivery does not exist before it is queued. A created default with no writer for created -> queued strands every delivery in the system: the channel worker claims WHERE status = 'queued', matches nothing, and reads a zero-row claim as "another worker holds it" — so every job reports success having sent nothing.
7.2 The event fan-out lifecycle
| Entity | From | Event/Action | To | Guard Condition | Side Effects |
|---|---|---|---|---|---|
notification_event | — | send() | Pending | The kind, channels, audience caps and action URL all pass | The event row plus an outbox row, in the caller's transaction |
| Pending | Worker claim | Claimed | Not already claimed, not cancelled | fanout_claimed_at | |
| Pending | Cancel route | Cancelled | Not fanned out, not already cancelled | cancelled_at; the orphan sweep ignores it thereafter | |
| Pending | Orphan sweep | Pending | Unclaimed past the grace period, and due | A fresh outbox row; the outbox's own dedupe makes a repeat a no-op | |
| Claimed | Batches complete | Complete | — | fanned_out_at and recipient_count in one statement | |
| Claimed | Reaper, ten lease periods later | Pending | Still incomplete | fanout_claimed_at cleared; the fan-out later resumes from fanout_cursor |
Why the claim and the completion are separate. One column cannot be both. As a claim it must be set before the recipient count is known; as a completion marker it leaves no claim, so two workers fan the same event out concurrently. With one column, a crash after batch three of six left the event looking complete with half its recipients missing, matched by no sweep, permanently under-delivered.
Why a claimed-then-dead event needs its own sweep. The orphan sweep is self-clearing because a claim removes the event from its predicate — but that same claim means an event whose worker died is in neither predicate: not unclaimed, not complete, matched by nothing. The stale-claim sweep is a lease on the fan-out claim, the same shape as the lease on a delivery claim.
7.3 The push token lifecycle
| Entity | From | Event/Action | To | Guard Condition | Side Effects |
|---|---|---|---|---|---|
notification_push_token | — | Registration | Active | Under the per-user cap, and the token is not already active | A row with is_active = true |
| Active | The same user re-registers the same token | Active | — | last_used_at and platform touched; no new row | |
| Active | Another user registers the same token | Invalidated (replaced) | — | The old row is invalidated and a fresh one inserted for the new user | |
| Active | The owner signs out | Invalidated (user_logout) | Owned by the caller, still active | is_active = false, invalidated_at, reason | |
| Active | FCM says the token is dead | Invalidated (unregistered / invalid_argument) | During a send | The same, plus a warning | |
| Invalidated | Retention, 180 days later | Deleted | — | The partial unique index stops growing |
7.4 Read state
Two different mechanisms, for the two delivery models.
| Model | Where read state lives | Why |
|---|---|---|
| Person-addressed | notification_recipient.read_at, one row per person per event | The row already exists per person, so a column is the natural home. null means unread. |
| Operational feed | A notification_read join row per (notification, person) | One notification is seen by everyone holding its permission. A column would make "read" a property of the event, so the first admin to open the bell would clear it for the whole office. Absence means unread, which keeps the table proportional to what people actually looked at. |
9. Data and Side Effects by Flow
| Flow | DB Writes | Cache Effects | Jobs | Realtime | Analytics | Notifications |
|---|---|---|---|---|---|---|
| Raise a notification | notification_event, outbox_events | None | notification.fan_out scheduled | None | None | This is the notification |
| Fan-out batch | notification_recipient, notification_delivery, outbox_events, notification_event.fanout_cursor | None | notification_channel.send_* per queued remote delivery | A per-user publish after the commit | None | In-app rows become readable immediately |
| Fan-out completion | notification_event.fanned_out_at, recipient_count, unresolved_count | None | None | None | None | None |
| Channel send | notification_delivery; sometimes notification_push_token.is_active | Redis GETDEL on a secret reference | None; a failure writes job_failures on the final attempt | None | None | The actual email, SMS or push |
| Mark one read | notification_recipient.read_at | None | None | None | None | The badge drops |
| Mark all read | notification_recipient.read_at, bounded | None | None | None | None | The badge clears |
| Register a device | notification_push_token insert, and sometimes an invalidation | None | None | None | None | Future push sends reach the device |
| Remove a device | notification_push_token invalidation columns | None | None | None | None | Push stops for that device |
| Save preferences | notification_preference_set, notification_preference | None | None | None | None | Takes effect on the next fan-out batch |
| Create or edit a template | notification_template, activity | None | None | None | None | Takes effect on the next fan-out batch |
| Cancel an event | notification_event.cancelled_at, activity | None | None | None | None | The fan-out will not run |
| Replay a failure | A new notification_delivery, outbox_events, job_failures claim | None | A channel send job | None | None | The message is re-sent |
| Feed read-all | notification_read, one row per visible notification | None | None | None | None | The bell clears |
| Reaper tick | notification_delivery, notification_event.fanout_claimed_at, outbox_events | None | Re-dispatches fan-out for orphans | None | None | Stalled messages resume |
| Backfill tick | notification_delivery, outbox_events | None | Channel send jobs | None | None | The pre-credential backlog moves |
| Retention tick | Deletes from notification_delivery, notification_recipient, notification_event, notification_push_token, notification | None | None | None | None | Old history disappears from every screen |
| Operational alert | job_failures on terminal failure | None | None | The admin toast fires independently, at dispatch | None | An email to the support mailbox |
10. Error and Recovery Flows
| Scenario | Trigger | User/System Experience | Recovery | Source |
|---|---|---|---|---|
| The scheduling enqueue fails | Redis unreachable at the moment a business write commits | The business write still succeeds. The event row and outbox row commit together, so nothing is lost — or, if the whole transaction fails, nothing was written at all | The outbox dispatcher relays when Redis returns | OutboxService |
| The outbox row was purged before dispatch | Maintenance | Nobody receives the notification | The orphan sweep re-dispatches it after the grace period, keyed on created_at because scheduled_for IS NULL never satisfies <= now() | The reaper |
| A fan-out worker dies mid-event | Crash, deploy, OOM | The event is half fanned out and matched by no other sweep | The stale-claim sweep releases the claim after ten lease periods; the fan-out resumes from fanout_cursor, so committed batches are not re-walked | The reaper |
| A channel worker dies after the provider call | Crash between sending and recording | The message went out but the record says it did not | The lease expires, the reaper reclaims and increments attempts, so the loop terminates rather than re-sending forever at metered cost | DeliveryRecorderService |
| The provider is temporarily unreachable | DNS, TLS, timeout, 5xx | Nothing arrives yet | failed with an exponential backoff capped at an hour; the reaper re-queues it | The same |
| The provider refuses the message | Bad address, blocked domain, dead token, no credit | Nothing arrives, ever | Straight to dead — retrying a decision is a guaranteed failure per attempt, and on SMS each one is a paid call. A dead letter is written for replay | The same |
| The SMS balance runs out | Metered account | Every SMS goes straight to dead | The daily credit check warns days ahead; top up and replay | NotificationCreditProcessor |
| A provider has no credentials | A new deployment | Deliveries record skipped_unconfigured — no retry, no dead letter, no failure metric | Supply credentials; the backfill worker re-drives the last 24 hours within five minutes | NotificationBackfillProcessor |
| An operator's template edit is broken | An unresolvable placeholder, or a builder that throws | Recipients see the shipped wording | Fix or delete the override. One warning per key, not per recipient | TemplateRendererService |
| A secret expires before the send | Redis flushed, or a slow queue | That one security email is not sent | skipped_no_destination. The Postgres token is still valid, so the user simply asks again | SecretReferenceService |
| A recipient is deleted between fan-out and send | A scheduled event, a later deletion | Nothing is sent to them | Send-time liveness re-check records skipped_no_destination | ChannelSendService |
| A push token goes stale | Reinstall, uninstall | That device stops receiving | FCM's rejection deactivates the row automatically; the app re-registers on next launch | The same |
| Two operators edit one template | Concurrency | The second gets a conflict rather than losing their work silently | Reload and retry | Optimistic concurrency |
| Two devices save preferences at once | Concurrency | The second gets a conflict; nothing is half-applied | Re-read and re-apply | The version compare-and-set |
| Two operators replay one failure | Concurrency | Exactly one new delivery exists | The loser's inserts roll back with the claim | The replay transaction |
| A cancel races the fan-out | Timing | The operator is told which happened | 409 naming either already-cancelled or already-fanned-out | NotificationEventService |
| Retention deletes something in flight | — | Cannot happen | The sweep refuses any event with a queued, processing or failed delivery, and any with an unreplayed dead letter | NotificationRetentionProcessor |
| Redis is unreachable while a person reads | — | The badge and list are still correct | Postgres is the authoritative unread store; the realtime stream is only an enhancement that saves polling | NotificationRealtimePublisherService |
| A job name has no handler | A deploy that removed one | The job throws rather than completing silently | BullMQ records it; a silent success would be indistinguishable from work done | NotificationFanoutQueueProcessor |
11. Diagrams Required Per Module
| Diagram | Where |
|---|---|
| Actor capability diagram | Section 4 |
| High-level module flow | 5.1 |
| Sequence per major flow | 5.1, 5.2 |
| State machine per lifecycle | 7.1, 7.2, 7.3 |
| Data side-effect diagram | Section 9 |
| Error branch diagram | Section 10 |
| Admin activity diagrams | 6.1, 6.3 |
| Swimlane and service blueprint | 12.2 |
| Flow-to-data trace | 12.6 |
12. Feature and Flow Deep-Dive Pack
12.1 Feature Inventory With Minor Behaviors
| Feature | Minor Behavior | Actor | Trigger | User/System Result | Backend Side Effect | Source |
|---|---|---|---|---|---|---|
| Raise a notification | Duplicate raises of a collapse kind fold into one | Calling module | send() twice with the same aggregate | One set of messages | The second insert is a no-op via the dedupe unique | notification.service.ts |
A repeatable kind requires a request id | The same | A second reset or OTP request | A second, real message | A distinct dedupe key | The same | |
A collapse kind is refused a request id | The same | A caller misuses it | 400 | Nothing written | The same | |
| An audience over 500 explicit ids is refused | The same | A large list | 400 | Nothing written | The same | |
| A compound audience outside 1–10 members is refused | The same | Breadth abuse | 400 | Nothing written | The same | |
A javascript: or protocol-relative action URL is refused | The same | A bad link | 400 | Nothing written | safe-action-url.util.ts | |
| Fan-out | A suppressed channel still gets a row | System | Preferences say no | The person receives nothing on that channel | skipped_preference — which is what makes the reason readable later | notification-fanout.processor.ts |
| Push fans out per token | System | Several devices | Each device gets its own attempt | N delivery rows | The same | |
| In-app completes inline with its content | System | An in-app channel | The notification is readable the instant the batch commits | delivered with rendered title and body | The same | |
| Template overrides and locales load only when in-app is requested | System | An email-only announcement | Faster batches | Two queries skipped | The same | |
| A zero-recipient fan-out is logged | System | A class with no enrolments | Nothing sent | A warn, so an operator who sent to nobody can see it | The same | |
| The batch cursor advances inside the batch transaction | System | A crash mid-fan-out | Resumes exactly where it stopped | No duplicate channel outbox rows | The same | |
Recipient counting is a COUNT(*), not an accumulator | System | A resumed fan-out | An honest count | Not doubled | The same | |
| Channel send | A zero-row claim is not an error | System | Two workers, or an already-terminal row | Nothing happens; no retry | Returns quietly rather than re-driving somebody else's work | channel-send.service.ts |
| A delivery on the wrong queue throws | System | A routing bug | Loud failure | It means every delivery of that channel is misrouted | The same | |
| An email destination from the vault wins over the user row | System | Change-email verification | The code reaches the new address | The unverified address never enters retained history | The same | |
| A phone number is normalised, and an unnormalisable one is a skip | System | A malformed number | Nothing sent, nothing billed | skipped_no_destination rather than failed | The same | |
| SMS is truncated by segments, not characters | System | A long Nepali message | The bill matches the message | Devanagari is UCS-2 at 70 characters a segment | packages/sms | |
| A dead push token is deactivated during the send | System | FCM rejects it | That device stops failing forever | is_active = false with a reason | channel-send.service.ts | |
| Web push gets a click destination; mobile does not need one | System | platform: "web" | Clicking the notification opens something | webpush.fcmOptions.link, https only | packages/firebase | |
| Notification centre | Fixed newest-first order with an id tie-break | Person | Paging | No dropped or duplicated rows | Two events in one transaction share a timestamp | notification-centre.service.ts |
pagination=false is refused | Person | A client trying to fetch everything | 400 | The table only grows | The same | |
| Marking read twice keeps the first timestamp | Person | A double tap | Idempotent | coalesce(read_at, now()) | The same | |
An invisible row answers 404, never 403 | Person | Guessing a public id | Indistinguishable from absent | A 403 would confirm existence | The same | |
read-all skips rows scoped to another role | Person | A teacher clearing their parent badge | The staff badge is untouched | The predicate carries the active role | The same | |
| Preferences | A locked category's overrides are accepted and dropped | Person | Trying to mute security | The switch has no effect, and the API does not pretend it did | isEnabled short-circuits on unsuppressible | notification-preferences.service.ts |
| Duplicate cells de-duplicate last-wins | Person | A noisy client | No error | A Map keyed on category and channel | The same | |
| An empty overrides array is legal | Person | Taking the lock | Version bumps, nothing changes | — | The same | |
| A brand-new category appears immediately for everyone | Person | A category ships | It uses its code default | Because absence means the default rather than a stored row | preference-resolver.service.ts | |
| Devices | Re-registering the same token is a touch | Person | Every app launch | No churn, and the cap is not consumed | last_used_at and platform updated | notification-devices.service.ts |
| A token presented by a second user invalidates the first | Person | A shared or resold device | The first user's push stops, with a recorded reason | replaced, and a fresh row | The same | |
| The cap refuses rather than evicts | Person | An eleventh device | The caller learns the limit | Nothing is silently removed | The same | |
| Removal is soft | Person | Signing out | The history survives | user_logout | The same | |
| Templates | An empty override table is correct | Admin | A fresh deployment | Notifications still have copy | The registry is the source of truth and the fallback | template-registry.ts |
| A broken override degrades, never silences | Admin | A bad edit | The shipped wording | One warning per key | template-renderer.service.ts | |
DELETE carries a version | Admin | A concurrent edit | The delete cannot silently win | A compare-and-set | notification-template.service.ts | |
| Editing records per-field changes | Admin | Any PATCH | An auditable diff | The global interceptor has no before-value | The same | |
| History | failedDeliveryCount excludes every skip | Admin | An unconfigured channel | The screen does not cry outage | Only failed and dead count | notification-history.service.ts |
| The detail response is unpaginated | Admin | A school-wide announcement | A large payload | Deliberate: the screen exists to show the whole picture | The same | |
| No name, address or message body is ever returned | Admin | Any history read | Personal data cannot be re-aggregated | Those columns are never read into memory | The same | |
| Dead letters | Only three queues are reachable | Admin | Any request | A restore's failures are invisible here | A hardcoded allowlist, ANDed unconditionally | notification-failure.service.ts |
| Replay inserts rather than resets | Admin | Clicking replay | The original evidence survives | replay_of_delivery_id, and both uniques exclude replays | The same | |
| A second replay is refused | Admin | Two operators | Exactly one new delivery | A compare-and-set inside the transaction | The same | |
| Operational feed | A superadmin bypasses the filter | Superadmin | Reading the bell | Everything | Rather than materialising the catalogue | notification-feed.service.ts |
| No active role sees nothing | Any | A session before role selection | An empty bell | An empty permission list becomes SQL false | The same | |
| Read state is per person | Admin | One operator reads | Everyone else still sees it unread | A join table, not a column | notifications.ts | |
| The unread count spans everything visible | Admin | Two hundred unread, fifty returned | An honest badge | Counted separately from the page | notification-feed.service.ts | |
| Recovery | The reaper runs four sweeps every 30 seconds | System | Always | Stalled work resumes quickly | An idle tick is four indexed scans returning nothing | notification-reaper.processor.ts |
A lease reclaim increments attempts | System | A crash-looping worker | The loop terminates | Otherwise unbounded duplicate SMS at metered cost | delivery-recorder.service.ts | |
Requeueing clears failed_at and last_error | System | A backoff elapsing | Failure counts stay honest | A row that failed then succeeded would otherwise still look failed | The same | |
| Retention | Both delivery models are swept | System | Hourly | Neither table grows forever | The operational feed is the one nothing else deletes from | notification-retention.processor.ts |
| Token pruning runs even on an idle tick | System | A quiet holiday with steady reinstalls | The partial unique index stops growing | It is an independent retention that happens to share the tick | The same | |
| Nothing in flight is ever deleted | System | Always | No message vanishes mid-send | The in-flight predicate | The same | |
| Nothing with an unreplayed dead letter is deleted | System | Always | Replay still works | payload_ref is not an FK, so nothing else would stop it | The same |
Nothing above is grouped away. A skip, a touch, a refusal, a cap, a fall-through, a de-duplication and a no-op are each documented, because a person can notice each one, an operator can be asked about each one, and a test asserts most of them.
12.2 Business Process Diagram Pack
| Diagram | Required When | Purpose |
|---|---|---|
| User journey map | Always | Actor intent from entry to outcome |
| Service blueprint | Backend-heavy flows | Separates the person's step from the API, the worker and the provider |
| Activity diagram | Every major flow | Decisions and branches |
| State diagram | Every lifecycle | 7.1, 7.2, 7.3 |
| Swimlane | Multi-actor flows | Ownership by actor and system |
| Sequence | API-backed flows | 5.1, 5.2 |
| Data side-effect graph | Every mutation | Section 9 |
| Exception flow | Critical failures | Section 10 |
Swimlane — one notification, end to end:
Service blueprint — what the person sees against what the system does:
User journey — a parent who is not receiving SMS:
Activity — a fan-out batch:
12.3 Business Rules and Policy Traceability
| Rule | Business Reason | Actor Impact | Enforced In | API Impact | Backend Impact | Tests |
|---|---|---|---|---|---|---|
| A caller cannot choose category, priority, suppressibility or dedupe policy | Otherwise any module can send a marketing blast as system past every opt-out, at real SMS cost | Recipients keep control of what reaches them | The SendNotificationInput type and the registry | Not reachable from any route | The registry supplies all four | Registry and send specs |
| A user cannot switch off a security notification | A person must be able to recover their own account | Locked switches in the UI | unsuppressible on the registry entry; step 1 of the resolver | locked: true in the preference response | Preferences are skipped entirely | Preference specs |
Only security and system kinds may be unsuppressible | Scope limitation on an exemption | — | UNSUPPRESSIBLE_CATEGORY | — | Applied in the registry | Registry spec |
| SMS is off by default in every category except security | It is metered; a school-wide default of "on" is a bill nobody chose | Parents opt in to texts | CATEGORY_DEFAULTS | Reflected in GET preferences | Applied at fan-out | Preference specs |
| Marketing is off on every channel by default | Opt-in, not opt-out | Nobody receives promotion without asking | CATEGORY_DEFAULTS | The same | The same | The same |
| The audience is a specification, resolved per batch | A materialised list keeps notifying people who left and misses people who arrived | A pupil enrolled an hour ago is included | The audience jsonb column and the resolver | audience is returned in the history detail | Re-evaluated per page | Fan-out spec |
| Guardians resolve through the guardian relationship, never a caller-supplied user list | A caller could otherwise address one family's notification to another | Families see only their own | The guardian audience SQL | Not reachable | student_guardian join | Fan-out spec |
| The notification centre is scoped by the active role | A teacher who is also a parent must not see staff notices while acting as a guardian | Cleaner, correct lists | The centre predicate | Affects list and badge | Four-part SQL predicate | Centre spec |
| A dismissed teacher stops seeing staff notices | They keep a live users row because they are also a parent | Disciplinary and roster content stops | The staff-liveness EXISTS | The same | The same | The same |
| A person sees only notifications that were actually delivered in-app | Otherwise the centre lists email-only messages and suppressed ones | An honest list | The in-app EXISTS | The same | The same | The same |
| Content is frozen at fan-out | A later template edit must not rewrite what somebody already received | Historical accuracy | persistRendered and the rendered columns | title/body in the centre response | Written by the fan-out worker | Fan-out spec |
| A kind with an in-app channel must persist its rendering | Otherwise the notification arrives, counts toward the badge, and displays blank | No blank notifications | A boot-time assertion | — | The registry refuses to load | The assertion itself |
| An unresolved placeholder suppresses the send | Delivering literal {{name}} is metered, paid for, and invisible to every gate | No broken messages | The interpolator returning null | skipped_no_template on the history screen | Falls through to the shipped copy first | Renderer spec |
| An operator override never silences a notification | A bad edit must be cosmetic | Messages always have copy | The fall-through to the registry | — | One warning per key | Renderer spec |
| A template cannot evaluate expressions | An admin-editable template that could would be a code execution surface behind a CRUD permission | — | Allowlisted {{name}} substitution | — | A replace callback, never a replacement string | Renderer spec |
An action URL must be https: or a single-slash relative path | escapeHtml does not neutralise javascript: or data:, so an unvalidated URL becomes a working link in the recipient's mail client | No hostile links | isSafeActionUrl, at write and at render | 400 on send; a dropped button at render | Validated twice, deliberately | The utility's own tests |
| A live token never reaches Postgres | NotificationHistory_READ is not superadmin-only; a stored token turns an operational read into account takeover | — | The Redis vault, plus a CHECK as a backstop | No route ever returns one | Resolved at send time with GETDEL | Channel-send spec |
A raw provider response never reaches last_error | The SMS gateway echoes the message text back, which for an OTP is the OTP | — | Fixed error tables in all three providers | lastError is a code | Mapped, never passed through | Provider specs |
| Addresses are masked | The full set is a contact-details export of every family in the school | — | maskEmail, maskPhone | destinationHint only | Written masked | Provider specs |
| History returns no name, address or body | One grant must not re-aggregate personal data | — | The service never reads those columns | Absent from the DTO | Nothing to spread | History spec |
| The dead-letter screen reaches only three queues | A clerk must not see or re-run a database restore | — | A hardcoded allowlist, ANDed unconditionally | 409 for anything else | Deny-by-default | Dead-letter spec |
| A push token is never reassigned in place | Anyone who learns a victim's token could otherwise silently stop their push, including unsuppressible security notices | Devices stay honest | Invalidate-and-reinsert | 201 either way | Reason replaced | Devices spec |
| A token is bound to the session, never the body | RoleGuard runs no permission check on this handler | — | actor.id only | The DTO has no userId | The only remaining control | Devices spec |
| The delivery row owns retry, not BullMQ | Two budgets mean nobody owns the terminate decision | Messages are not sent twice | attempts: 1 per channel queue | — | The row's attempts, backoff and reaper | Reaper spec |
| A skip is never a failure | An unconfigured machine must not look like an outage, and a real outage must not be buried | Honest dashboards | SKIPPED_DELIVERY_STATUS | Excluded from failedDeliveryCount | Excluded from every metric | History spec |
| Retention deletes nothing in flight | The worker's claim would return zero rows and report success | No message vanishes mid-send | The in-flight predicate | — | — | Retention spec |
| Every enqueue goes through the outbox | Otherwise the write commits, the enqueue throws, the caller returns 200, and nothing is scheduled | No silently lost notifications | OutboxService | — | One transaction | Fan-out and backfill specs |
| Every list is paginated with a stable order | An unstable sort under offset paging silently drops and duplicates rows | Correct lists | Fixed order plus an id tie-break | pagination=false is 400 | — | Every list spec |
12.4 Tradeoffs and Product Rationale
| Product Decision | User Benefit | Engineering Benefit | Alternative | Tradeoff | Risk |
|---|---|---|---|---|---|
| Fan-out happens in the background | The action that triggered it returns immediately | The caller's transaction stays short | Resolve inline | The notification exists before anybody has it | A worker outage delays everything; the orphan sweep is the answer |
| The audience is a spec, not a list | New pupils are included, departed ones are not | One jsonb column instead of a materialised set | Snapshot at publish | The audience can shift during a long fan-out | Acceptable, and the intended behaviour |
| Preferences resolve at fan-out, not at send | A change takes effect on the next thing sent, predictably | One place to reason about | Resolve at send | A preference changed after fan-out does not apply to queued messages | Small window; documented |
| In-app content is frozen at fan-out | A notification says what it said when it arrived | No re-rendering on a read path, and no secret resolution on one | Render at read time | A template fix does not repair old notifications | Correct: rewriting delivered history is worse |
| A suppressed channel still gets a row | "Why did I not get this?" is answerable | The status is reachable and testable | Write nothing | More rows | Bounded by retention |
| Skips are their own statuses | An operator is not misled into chasing an outage | Failure metrics stay meaningful | One failed status | Four more statuses to learn | Documented per status |
A dead message needs a human to replay | Nobody is spammed by an automatic loop | The terminate decision has one owner | Retry forever | Somebody must notice | The dead-letter screen and the credit warning are the prompts |
| Push fans out per device | A working phone still gets it when the tablet's token is dead | Per-target outcomes survive | One row per recipient | More rows on the largest table | Capped at ten tokens per person |
| Devices are capped at ten | Nobody's push is silently dropped | Bounds amplification | Evict the oldest | The eleventh registration is refused | The caller is told the limit |
| The operational feed fans out on read | A newly promoted operator sees the history their role covers | One row per event, not per staff member | Fan out on write | The feed cannot be personalised | It is not meant to be |
| Read state is a join table for the feed | One operator reading does not clear the office's bell | Proportional to what people looked at | A read_at column | An extra table | Small |
| Retention is 30 days | Screens stay fast | The largest tables stay bounded | A year | A delivery from last quarter cannot be investigated | Nobody investigates one |
| Nothing is sent synchronously | The UI never blocks on a provider | Providers can be slow or down without affecting requests | Send inline | A 201 means scheduled, not sent | The history screen is the outcome surface |
| An unconfigured channel degrades rather than fails | A new deployment works immediately | One code path in both states | Refuse to start | It can be silently unconfigured | The boot warning, the first-class status, and NOTIFICATION_REQUIRED_CHANNELS |
12.5 Flow Edge-Case Matrix
| Flow | Edge Case | Trigger | Expected Behavior | User/System Feedback | Source |
|---|---|---|---|---|---|
| Raise | Duplicate raise, collapse | Same aggregate twice | One event, one set of messages | The second call returns created: false | notification.service.ts |
| Raise | Duplicate raise, repeatable | Two reset requests | Two events, two messages | Both delivered | The same |
| Raise | Missing request id on a repeatable kind | A caller forgets | Refused at the boundary | 400 — refused rather than defaulted, because the alternative is a 200 that sent nothing | The same |
| Raise | Zero channels | Impossible by CHECK | Rejected | A constraint violation | The event schema |
| Fan-out | Empty audience | A class with no enrolments | Completes with recipientCount: 0 | Logged at warn | notification-fanout.processor.ts |
| Fan-out | Concurrent workers | A redelivered job | The second claim matches zero rows | Returns { skipped: true } | The same |
| Fan-out | Crash at batch five of twenty | Deploy, OOM | Resumes at batch five | The cursor advanced inside each batch transaction | The same |
| Fan-out | Cancel lands mid-claim | Timing | The cancel wins | cancelled_at IS NULL is part of the claim predicate | The same |
| Fan-out | The audience changes between pages | A pupil enrols | They are included | The spec is re-evaluated per page | audience-resolver.service.ts |
| Fan-out | A person matched by two compound members | Overlapping audiences | One recipient row | The union deduplicates on users.id | The same |
| Send | Claim returns zero rows | Another worker, or already terminal | Quiet return, no retry | Not an error | channel-send.service.ts |
| Send | Recipient deleted after fan-out | A scheduled event | skipped_no_destination | Visible on the history screen | The same |
| Send | Hard-erased recipient | user_id is null | skipped_no_destination | The LEFT JOIN lets the branch record why | The same |
| Send | Expired secret | Redis flushed | skipped_no_destination | The user asks again | secret-reference.service.ts |
| Send | Redelivered job after a successful send | At-least-once | The secret is already consumed | GETDEL makes the second attempt skip | The same |
| Send | Provider timeout | Network | failed, retryable | A backoff | delivery-recorder.service.ts |
| Send | Worker dies after the provider call | Crash | The lease expires and the reaper reclaims, incrementing attempts | Bounded duplicates rather than unbounded | The same |
| Send | Slow but successful send | A provider slower than the lease | Possible duplicate | The lease is set to exceed the worst-case provider call | Documented risk |
| Centre | No active role | Before role selection | Only not-role-scoped rows | x = NULL is never true, so no extra branch is needed | notification-centre.service.ts |
| Centre | Role switch | A teacher-parent | List and badge both change | Immediate | The same |
| Centre | First use | No notifications | Empty list, zero badge | Not an error | The same |
| Centre | Last item on a page | Offset paging | Stable | The id tie-break | The same |
| Centre | Stale public id | Retention removed it | 404 | Clients must not treat ids as permanent | The same |
| Preferences | Never saved | version: 0 | Defaults returned, nothing written | A read that wrote would be a read that lies | notification-preferences.service.ts |
| Preferences | Concurrent save | Two devices | The loser writes nothing | 409 | The same |
| Preferences | Locked category | Muting security | Accepted, not persisted | The response still shows it locked | The same |
| Preferences | A category added later | A new feature | Uses its code default for everyone | Absence means the default | preference-resolver.service.ts |
| Devices | Same token, same user | App launch | A touch | The cap is not consumed | notification-devices.service.ts |
| Devices | Same token, different user | A shared device | Invalidate and reinsert | Reason replaced | The same |
| Devices | At the cap | Eleven devices | Refused | NOTIFICATION_DEVICE_LIMIT_REACHED | The same |
| Devices | Remove twice | Double tap | 404 | Already inactive | The same |
| Templates | Concurrent edit | Two operators | The second is refused | 409 | notification-template.service.ts |
| Templates | Delete versus edit | Racing | Whichever compare-and-set matched wins | 409 for the loser | The same |
| Templates | Unknown kind | A typo | 400 | Registry-checked | The same |
| Templates | Whitespace-only body | A form | Spaces are rejected; a lone tab passes | Deliberate scope for the constraint | The template schema |
| Dead letters | A failure from another queue | Guessing an id | 409, not 404 | The row exists elsewhere; pretending otherwise would mislead | notification-failure.service.ts |
| Dead letters | Double replay | Two operators | One new delivery | The loser rolls back entirely | The same |
| Dead letters | The delivery is gone | Retention or manual deletion | 404 | Retention normally refuses while a dead letter is unreplayed | The same |
| Dead letters | Replaying a push whose token died | A reinstall | A new delivery that skips | skipped_no_destination | channel-send.service.ts |
| Feed | Empty permission set | No active role | Nothing, via SQL false | An empty inArray would read as "no filter" in some drivers | notification-feed.service.ts |
| Feed | Reading an invisible row | Guessing an id | 404 | A 403 would confirm it exists | The same |
| Feed | A non-v7 uuid | A bad client | 400 | ParseUUIDPipe({ version: "7" }) | The controller |
| Retention | An in-flight delivery | Always | The event survives to the next tick | The in-flight predicate | notification-retention.processor.ts |
| Retention | An unreplayed dead letter | Always | The event survives | The job_failures predicate | The same |
| Retention | A partially deleted event | A batch boundary | Survives to the next tick | Only fully childless events are deleted | The same |
| Retention | No aged events at all | A quiet period | Tokens and the feed are still pruned | Three independent retentions share one tick | The same |
| Backfill | No configured channels | A fresh deployment | Returns immediately | Three isConfigured() calls | notification-backfill.processor.ts |
| Backfill | Two ticks racing | Overlap | One re-queue | A compare-and-set on the status | The same |
| Backfill | A row older than the window | A provider added late | Left alone | Bounded on purpose | The same |
12.6 Flow-to-Data Trace
| Flow | Reads | Writes | Cache | Jobs/Events | Response Fields |
|---|---|---|---|---|---|
| Raise a notification | The template registry | notification_event, outbox_events | None | notification.fan_out | eventPublicId, created |
| Fan-out batch | Audience tables, preferences, tokens, overrides, locales | notification_recipient, notification_delivery, outbox_events, the event cursor | None | notification_channel.send_*; a per-user publish | None — it is a worker |
| Channel send | The delivery, recipient, event, user, token, overrides | The delivery; sometimes a token's is_active | The secret vault, via GETDEL | A dead letter on terminal failure | None |
| Centre list | Recipients, events, roles, staff, in-app deliveries | — | None | None | publicId, kind, category, priority, title, body, actionUrl, actionLabel, occurredAt, readAt |
| Unread count | The same | — | None | None | count |
| Mark read | The same | read_at | None | None | The full centre row |
| Register a device | Active tokens | notification_push_token | None | None | publicId, platform, createdAt |
| Preferences read | Three preference tables, the registry | — | None | None | version, categories[] with locked and channels |
| Preferences save | The version row | notification_preference_set, notification_preference | None | None | The resolved matrix |
| Template CRUD | notification_template, the registry | notification_template, activity | None | None | The full template row including version |
| History list | Events, recipients, deliveries | — | None | None | Event fields plus failedDeliveryCount |
| History detail | The same | — | None | None | Everything above plus audience, requestedChannels, recipients and deliveries |
| Cancel | The event | cancelled_at, activity | None | None | publicId, cancelledAt |
| Dead-letter list | job_failures | — | None | None | Failure fields including payloadRef |
| Replay | job_failures, the original delivery | A new delivery, outbox_events, the failure claim | None | A channel send job | The claimed failure plus newDeliveryPublicId |
| Feed list | notification, notification_read, role permissions | — | None | None | Rows plus unreadCount |
| Feed mark read | The same | notification_read | None | None | null |
12.7 Experience Quality Checklist
- The doc explains what each actor is trying to accomplish.
- The doc explains what the backend does that the actor never sees.
- The doc covers every minor flow and branch, including skips, touches, refusals, caps and no-ops.
- The doc includes person, admin, worker and system flows.
- The doc explains business logic, tradeoffs and rationale, including the cost of each choice.
- The doc maps every flow to API routes and backend side effects.
- The doc includes diagrams appropriate to each flow type.
- The doc covers edge cases and failure recovery for every flow.
13. Completion Checklist
- Every feature, minor action and submodule capability is listed.
- Every actor has allowed and forbidden behaviour.
- Every major and minor flow includes steps, branches and diagrams.
- Every lifecycle — delivery, event fan-out, push token, and both read-state models — has a transition table and a state diagram.
- Every flow links to the API and backend docs.
- The two delivery models are described as the different features they are.
See Also
- API doc: /docs/developer/notification/api
- Backend doc: /docs/developer/notification/backend