Skoolsewa - Ecommerce Docs
Developer ResourcesNotification

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 TypeFiles or DocsWhat Was Extracted
BackendBackend 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.
APIAPI doc, and directly: every *.controller.ts in those directoriesRoute surface, actors, auth, response-visible behaviour.
Schemapackages/db/src/schema/notification/*.ts, packages/db/src/schema/notifications.tsConstraints, indexes, delete behaviour, and the invariants they enforce.
Vocabularypackages/db/src/notification/notification-contract.tsEvery channel, category, priority, delivery status, audience kind, push platform and invalidation reason.
Templatesapps/api/src/modules/notification/templates/template-registry.tsEvery kind the system can send today, its category, priority, dedupe policy, suppressibility and channels.
Route ground truthapps/api/test/structure/structure.baseline.jsonConfirmed the 21 routes documented here: 10 admin, 3 admin feed, 8 mobile.
Configapps/api/src/modules/notification/shared/notification.constants.ts, apps/api/.env.exampleEvery tunable, its clamp and its default.
Callersapps/api/src/modules/auth/services/auth-email.service.tsThe four production call sites that raise notifications today.
Testsapps/api/src/modules/notification/__tests__/*.int.spec.ts and the per-submodule *.int.spec.ts filesConfirmed edge cases and expected behaviour.

2. Feature Summary

FieldValue
Modulenotification
Submodulenotification-feed is a sibling module documented here, because it is the second delivery model.
Primary user valueA 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.
ActorsGuardian, student, teacher, staff, admin, superadmin, worker/system. No guest actor — every route requires a session.
Main entry pointsNotificationService.send() called from inside another module's transaction; eight mobile routes; ten admin routes; three admin-feed routes; four cron-driven maintenance jobs.
Main outputsnotification_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 docsAPI, Backend

3. Actor Matrix

ActorCan DoCannot DoAuth RequirementNotes
GuardianRead their own notification centre; mark one or all read; see the unread badge; register and remove push devices; set per-category channel preferencesSee a notification addressed to another family, see a staff-audience notification, turn off a security notification, read anything on an admin surfaceJWTSees only rows where they are the recipient and the row is not role-scoped to a role they are not currently acting as.
StudentThe same as a guardianThe sameJWTA pupil resolved through a class audience carries their student-scoped role on the recipient row.
Teacher / staffThe same, plus staff-audience notifications while acting in a staff roleSee staff-audience notifications after being dismissed, even though their user row may still exist because they are also a parentJWTThe notification centre re-checks for a live staff row on every read. This is the one predicate that ownership alone would not cover.
AdminList 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 readRead 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 forAdmin JWT plus the relevant NotificationTemplate_*, NotificationHistory_* or NotificationFailure_* permission — except the feed, which is filtered per rowThe 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.
SuperadminEverything an admin can, plus the operational feed with no permission filterAdmin JWT with a superadmin active roleThe feed treats a superadmin as "no filter" rather than materialising the whole permission catalogue.
Any signed-in person with no active roleVery little: the notification centre narrows to rows that are not role-scoped; the operational feed returns nothing at allJWTThe 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 actionChoose the category, the priority, whether the kind is unsuppressible, or whether a repeat is a duplicateIn-process, inside its own database transactionAll 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 / systemResolve 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 alertsBypass a preference, persist a secret, or send without a delivery row (except the operational-alert queue, which has none by design)BullMQ, in-process cronEvery enqueue is an outbox row, never a direct queue.add.

4. Capability Matrix

CapabilitySurfaceActorRoute/TriggerState ReadState WrittenLinked API Section
Raise a notificationInternalCalling moduleNotificationService.send(tx, input)The template registrynotification_event, outbox_eventsBackend 7.1
Resolve an audience and write recipientsWorkerSystemnotification.fan_outThe audience spec, identity and school tables, preferences, active push tokens, template overrides, localesnotification_recipient, notification_delivery, outbox_events, notification_event completion columnsBackend 7.2
Send one deliveryWorkerSystemnotification_channel.send_email / _sms / _pushThe delivery, its recipient, the event, the user, the push token, template overrides, the Redis secret vaultnotification_delivery, sometimes notification_push_token.is_activeBackend 7.3
Deliver in-app inlineWorkerSystemPart of notification.fan_outTemplate overrides, the recipient's localeA delivered delivery with rendered contentBackend 7.2
Publish a realtime arrivalWorkerSystemAfter each fan-out batch commitsA Redis publish on realtime:user:<id>Backend 10
List my notificationsMobileGuardian, student, teacher, staffGET /api/mobile/notificationsRecipient rows, events, roles, staff liveness, in-app deliveriesAPI 8.14
See my unread badgeMobileThe sameGET /api/mobile/notifications/unread-countThe sameAPI 8.15
Mark one readMobileThe samePOST /api/mobile/notifications/{publicId}/readThe samenotification_recipient.read_atAPI 8.17
Mark everything readMobileThe samePOST /api/mobile/notifications/read-allThe samenotification_recipient.read_atAPI 8.16
Register a push deviceMobileThe samePOST /api/mobile/notification-devicesActive tokens for this token value and for this usernotification_push_tokenAPI 8.18
Remove a push deviceMobileThe sameDELETE /api/mobile/notification-devices/{publicId}The caller's own active tokensnotification_push_token invalidation columnsAPI 8.19
Read my preference matrixMobileThe sameGET /api/mobile/notification-preferencesThree preference tables, the template registryAPI 8.20
Save my preferencesMobileThe samePUT /api/mobile/notification-preferencesThe version rownotification_preference_set, notification_preferenceAPI 8.21
List template overridesAdminAdminGET /api/notification-templatesnotification_templateAPI 8.1
Read one overrideAdminAdminGET /api/notification-templates/{publicId}The sameAPI 8.2
Create an overrideAdminAdminPOST /api/notification-templatesThe template registrynotification_template, activityAPI 8.3
Edit an overrideAdminAdminPATCH /api/notification-templates/{publicId}The row and its versionnotification_template, an activity record with per-field changesAPI 8.4
Delete an overrideAdminAdminDELETE /api/notification-templates/{publicId}The row and its versionnotification_template, activityAPI 8.5
Browse notification historyAdminAdminGET /api/notification-historyEvents, plus a grouped failure countAPI 8.6
Inspect one event in fullAdminAdminGET /api/notification-history/{publicId}The event, every recipient, every deliveryAPI 8.7
Cancel a scheduled eventAdminAdminDELETE /api/notification-events/{publicId}The eventnotification_event.cancelled_at, activityAPI 8.8
Browse channel dead lettersAdminAdminGET /api/notification-failuresjob_failures, hard-scoped to three queuesAPI 8.9
Replay a failed sendAdminAdminPOST /api/notification-failures/{publicId}/replayThe failure row, the original deliveryA new notification_delivery, outbox_events, job_failures claimAPI 8.10
Read the operational feedAdmin feedAny signed-in admin actorGET /api/notificationsnotification, this caller's read receipts, the active role's permissionsAPI 8.11
Mark one feed row readAdmin feedThe samePOST /api/notifications/{publicId}/readThe samenotification_readAPI 8.12
Clear the bellAdmin feedThe samePOST /api/notifications/read-allThe samenotification_readAPI 8.13
Reclaim expired delivery leasesWorkerSystemnotification.reap, every 30 secondsnotification_deliverynotification_deliveryBackend 7.4
Re-queue elapsed backoffsWorkerSystemThe same tickThe sameThe sameBackend 7.4
Release stale fan-out claimsWorkerSystemThe same ticknotification_eventnotification_event.fanout_claimed_atBackend 7.4
Re-dispatch orphan eventsWorkerSystemThe same ticknotification_eventoutbox_eventsBackend 7.4
Re-drive the pre-credential backlogWorkerSystemnotification.backfill_unconfigured, every 5 minutesProvider configuration, notification_deliverynotification_delivery, outbox_eventsBackend 7.5
Delete aged historyWorkerSystemnotification.prune, hourlyFour tables plus job_failuresnotification_delivery, notification_recipient, notification_event, notification_push_token, notificationBackend 7.6
Warn on low SMS creditWorkerSystemnotification.check_sms_credit, daily at 01:00 localThe provider balanceA log lineBackend 7.7
Send an operational alertWorkerSystemnotification_operational.send_emailThe rendered payloadAn email; job_failures on terminal failureBackend 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 users row with can_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_unconfigured and re-driven later.

Main Flow

StepActor/SystemActionResultSource
1Calling moduleRaises a notification inside its own transaction.An event row and an outbox row commit together with the business write.NotificationService.send
2Outbox dispatcherRelays the fan-out job.A job on notification_fanout.The outbox module
3Fan-out workerClaims the event, then resolves the audience one bounded page at a time.Up to 500 people per batch.AudienceResolverService
4Fan-out workerFor each person, applies their preferences per requested channel.A delivery row per channel, either queued or terminal.PreferenceResolverService
5Fan-out workerRenders and completes the in-app channel inline.A delivered row carrying the title and body the person will read.TemplateRendererService
6Fan-out workerWrites an outbox row per queued remote delivery, in the same transaction.Email, SMS and push jobs scheduled.The batch transaction
7Fan-out workerAfter the batch commits, publishes a per-user realtime event.An open session updates its badge without polling.NotificationRealtimePublisherService
8Channel workerClaims one delivery under a lease, re-checks liveness, resolves any secret, renders, calls the provider.sent, a skipped_* state, or a failure.ChannelSendService
9RecipientOpens the app or their inbox.The message.

Sequence Diagram

Branches and Edge Cases

BranchConditionBehaviorError/Result
Preference suppresses a channelThe person turned that category off for that channelA delivery row is still written, terminalskipped_preference — which is what makes "why did this parent not get the SMS" answerable from the record
Unsuppressible kindThe kind is a security or system kind marked unsuppressiblePreferences are ignored entirelyDelivered regardless
No push tokenThe person has no active deviceOne terminal row per recipientskipped_no_destination
Several push tokensThe person has N active devicesN delivery rows, one per tokenEach with its own outcome and provider message id
No provider credentialsThe channel's provider reports itself unconfiguredTerminal, and not a failureskipped_unconfigured, re-driven automatically once credentials arrive
No template for the channelThe kind has no builder, or a placeholder cannot be resolvedTerminalskipped_no_template — never a message containing literal {{name}}
Recipient deleted between fan-out and sendAn event scheduled a day ahead, a deletion an hour laterThe send path re-checks livenessskipped_no_destination
Secret expiredThe Redis vault entry was consumed or expired before the sendNo messageskipped_no_destination — a reset link that goes nowhere is worse than no email
Provider refuses the messageA bad address, a blocked domain, a dead push token, no SMS creditNon-retryableStraight to dead; a dead push token is deactivated so future sends stop failing
Provider unreachableDNS, TLS, a timeout, a 5xxRetryablefailed with a backoff, returned to queued when it elapses
Attempts exhaustedFive attemptsTerminaldead, plus a dead-letter row an operator can replay
Duplicate raiseThe same kind and aggregate raised twice, with a collapse policyThe second insert is a no-opOne event, one set of messages
Legitimate repeatA second password reset or OTP requestThe caller supplies a request id, so it is a distinct eventA 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

StepActor/SystemActionResultSource
1PersonOpens the notification screen.GET /api/mobile/notifications.Controller
2BackendApplies ownership, active-role scoping, staff liveness and an in-app delivery check.Only rows this person may see in this role.NotificationCentreService
3BackendReads 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
4PersonTaps one.POST /api/mobile/notifications/{publicId}/read, returning the updated row.Controller
5BackendSets read_at if it was null.The badge decreases.coalesce(read_at, now())

Sequence Diagram

Branches and Edge Cases

BranchConditionBehaviorError/Result
Empty stateNo notifications yetAn empty list and a zero badgedata: [], count: 0
Switching active roleA teacher who is also a parent changes roleThe list and badge both changeStaff-audience rows appear or disappear
Dismissed teacherThe staff row is soft-deleted but the users row survives because they are a parentStaff-audience rows disappearThe liveness EXISTS is what enforces it
Email-only notificationThe event requested only emailNever appears in the centreThe in-app EXISTS excludes it
Suppressed in-appThe person turned in-app off for that categoryNever appearsskipped_preference is excluded by the predicate
Marking read twiceTwo devices, or a double tapIdempotentThe first timestamp stands
A notification older than retention30 days by defaultGone entirely404 on a stale publicId
GuestNo sessionRefused before anything runs401

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

StepActor/SystemActionResultSource
1PersonOpens preferences.GET, returning the resolved matrix plus a version.NotificationPreferencesService.get
2BackendApplies 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
3BackendMarks a category locked when every kind in it is unsuppressible.The UI hides that row's switches.Computed from the registry
4PersonFlips some switches and saves.PUT with the version and the changed cells.Controller
5BackendCompare-and-set on the version, then upserts each override, in one transaction.Atomic across categories..update
6BackendRe-reads and returns the resolved matrix.The client never has to guess what it saved..get

Branches and Edge Cases

BranchConditionBehaviorError/Result
First saveNo preference set row existsversion: 0 is an insert that must succeedVersion becomes 1
Concurrent saveTwo devices with the same versionThe first wins entirely; the second writes nothing409 NOTIFICATION_PREFERENCE_VERSION_CONFLICT
Locked categoryAn override naming securityAccepted and silently not persistedThe response still shows it locked and fully on
Duplicate cellsThe same (category, channel) twice in one bodyDe-duplicated, last winsNo error
Empty bodyoverrides: []Legal; bumps the version, changes nothingA valid way to take the lock
A new category shipsA category added after the person last savedAppears immediately with its code defaultBecause absence means "the default" rather than a stored row
Trying to mute a password resetUnsuppressible kindImpossibleSuppressibility 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 todayRead-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

StepActor/SystemActionResultSource
1App or browserObtains a push token.Platform SDK
2AppPosts the token and its platform.POST /api/mobile/notification-devices.Controller
3BackendBinds it to the session's user — never to anything in the body.A token cannot be attached to somebody else's account.NotificationDevicesService
4BackendChecks the active-token cap.Refused at ten, rather than evicting one.The cap check
5Fan-outReads active tokens when a push channel is requested.One delivery row per token.The fan-out worker
6PersonSigns out.DELETE, which soft-invalidates with reason user_logout.Controller

Branches and Edge Cases

BranchConditionBehaviorError/Result
Re-registering on every launchThe same user, the same tokenA touch: last_used_at and platform updated201, with the original createdAt, and the cap is not consulted
A token that belongs to somebody elseA shared tablet, a resold device, a siblingThe old row is invalidated with reason replaced and a fresh one insertedThe first user's record survives, and nobody can silently steal a victim's push by presenting their token
At the capTen active tokensRefusedNOTIFICATION_DEVICE_LIMIT_REACHED — the caller learns the limit rather than losing a device it never asked to remove
Web pushplatform: "web"No user_device row, because a browser has no installationFCM 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 webDevelopment, or a bad templateThe link is dropped rather than sentFCM 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 deadReinstall, uninstall, expiryThe send path deactivates it with unregistered or invalid_argumentFuture sends to that device stop failing forever
Removing twiceA double tap on sign-outThe second is 404The row is no longer active
Removing the last devicePermittedPush 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

StepActor/SystemActionResultSource
1Some moduleRecords an operational event through the outbox.A notification row carrying the permission of the screen it belongs to.The outbox dispatcher
2PanelReads the bell.GET /api/notifications.Controller
3BackendResolves the active role's permission set.null for a superadmin, [] for no active role.RoleService
4BackendFilters rows by that set, joins this caller's read receipts, counts unread across everything visible.The list and the badge.NotificationFeedService
5OperatorClicks one, or clears the bell.A notification_read row per notification per person.Controller

Branches and Edge Cases

BranchConditionBehaviorError/Result
SuperadminActive role is superadminNo permission filter at allRather than materialising the entire catalogue into an IN (...)
No active roleThe session has not chosen oneThe permission list is empty, which becomes SQL falseNothing, deliberately — an empty list read as "no filter" would show everything
Newly granted permissionAn operator gains a screen's READPast events for that screen become visible, and unreadThe property fan-out-on-read exists for
Revoked permissionThose rows disappear from both list and countSame mechanism
A row somebody else readStill unread for this callerThe read join carries user_id = actor.id; without it one person reading would clear it for the office
Marking an invisible row readGuessing a public id404, exactly as an absent rowA 403 would confirm it exists
Clearing the bellread-allOnly rows currently visible get a receiptSo gaining the permission later does not silently hide them
The badge versus the pageFifty rows returned, two hundred unreadThe badge is the true totalCounted 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.

StepActionSide effects
1List overrides. An empty list is correct on a fresh deployment — the table is never seeded.Read only
2Create an override, naming a kind the registry knows.One row; the global activity interceptor records it
3Edit it, echoing the version read from GET.The row, version + 1, and an explicit activity record carrying per-field before-and-after values
4Delete 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 foundWhat it meansWhat 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.
sentThe provider accepted it.Check spam, or the number. sent is acceptance, not proof of arrival — no delivery receipts are implemented.
skipped_preferenceThey turned that category off for that channel.Their own setting. Nothing is broken.
skipped_no_destinationNo 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_unconfiguredThe channel has no provider credentials.Supply them; the backfill worker re-drives the last 24 hours automatically.
skipped_no_templateThe kind has no builder for that channel, or a variable could not be resolved.An engineering issue, not an operational one.
failedA retryable failure with a backoff pending.Wait — the reaper returns it to queued.
deadAttempts exhausted, or a failure the provider says will never succeed.Check lastError, fix the cause, then replay.
cancelledThe event was cancelled before this delivery was claimed.Expected.
No delivery row for that channel at allThe 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.

OutcomeCondition
200 with cancelledAtThe claim matched: not fanned out, not already cancelled.
409 NOTIFICATION_EVENT_ALREADY_FANNED_OUTThe fan-out worker won the race. The messages are queued or gone.
409 NOTIFICATION_EVENT_CANCELLEDSomebody already cancelled it.
404 NOTIFICATION_EVENT_NOT_FOUNDNo 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.

StepActorEffect
1OperatorSets the provider environment variables and restarts.
2BootThe provider reports itself configured; the "not configured" warning stops.
3Backfill worker, within five minutesSelects skipped_unconfigured deliveries on now-configured channels, newer than 24 hours.
4Backfill workerCompare-and-sets each back to queued, clearing skipped_at, and schedules it through the outbox.
5Channel workerSends 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

EntityFromEvent/ActionToGuard ConditionSide Effects
notification_deliveryFan-out insertqueuedThe channel is enabled for this personqueued_at set; an outbox row for remote channels
Fan-out insertskipped_preferenceThe preference suppressed itskipped_at set; terminal
Fan-out insertskipped_no_destinationPush, and no active tokenskipped_at set; terminal
Fan-out insertskipped_no_templateIn-app, and the template did not renderskipped_at set; terminal
Fan-out insertdeliveredIn-app, rendereddelivered_at, provider = 'in_app', rendered content; sent_at stays null
queuedChannel worker claimprocessingStill queuedclaimed_at, lease_expires_at, claimed_by
processingProvider acceptedsentsent_at, provider, provider_message_id, masked hint; lease cleared
processingProvider skippedskipped_unconfigured / skipped_no_destinationskipped_at; lease cleared; terminal
processingLiveness, kind, secret or template check failedskipped_no_destination / skipped_no_templateThe same
processingRetryable failure, budget remainingfailedattempts + 1 < 5failed_at, last_error, next_attempt_at, counters; lease cleared
processingNon-retryable, or budget exhausteddeadfailed_at, last_error; no backoff; a dead letter on the job's final attempt
processingLease expired, budget remainingqueuedattempts + 1 < 5attempts + 1, lease_expiry_count + 1, a backoff; failed_at and last_error cleared
processingLease expired at the budgetdeadattempts + 1 >= 5last_error = LEASE_EXPIRED
failedBackoff elapsedqueuedattempts < 5failed_at, last_error, next_attempt_at all cleared
skipped_unconfiguredCredentials arrivequeuedWithin the backfill windowskipped_at cleared; an outbox row with a distinct dedupe key
sentA delivery receiptdeliveredNot implementedWould 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

EntityFromEvent/ActionToGuard ConditionSide Effects
notification_eventsend()PendingThe kind, channels, audience caps and action URL all passThe event row plus an outbox row, in the caller's transaction
PendingWorker claimClaimedNot already claimed, not cancelledfanout_claimed_at
PendingCancel routeCancelledNot fanned out, not already cancelledcancelled_at; the orphan sweep ignores it thereafter
PendingOrphan sweepPendingUnclaimed past the grace period, and dueA fresh outbox row; the outbox's own dedupe makes a repeat a no-op
ClaimedBatches completeCompletefanned_out_at and recipient_count in one statement
ClaimedReaper, ten lease periods laterPendingStill incompletefanout_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

EntityFromEvent/ActionToGuard ConditionSide Effects
notification_push_tokenRegistrationActiveUnder the per-user cap, and the token is not already activeA row with is_active = true
ActiveThe same user re-registers the same tokenActivelast_used_at and platform touched; no new row
ActiveAnother user registers the same tokenInvalidated (replaced)The old row is invalidated and a fresh one inserted for the new user
ActiveThe owner signs outInvalidated (user_logout)Owned by the caller, still activeis_active = false, invalidated_at, reason
ActiveFCM says the token is deadInvalidated (unregistered / invalid_argument)During a sendThe same, plus a warning
InvalidatedRetention, 180 days laterDeletedThe partial unique index stops growing

7.4 Read state

Two different mechanisms, for the two delivery models.

ModelWhere read state livesWhy
Person-addressednotification_recipient.read_at, one row per person per eventThe row already exists per person, so a column is the natural home. null means unread.
Operational feedA 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

FlowDB WritesCache EffectsJobsRealtimeAnalyticsNotifications
Raise a notificationnotification_event, outbox_eventsNonenotification.fan_out scheduledNoneNoneThis is the notification
Fan-out batchnotification_recipient, notification_delivery, outbox_events, notification_event.fanout_cursorNonenotification_channel.send_* per queued remote deliveryA per-user publish after the commitNoneIn-app rows become readable immediately
Fan-out completionnotification_event.fanned_out_at, recipient_count, unresolved_countNoneNoneNoneNoneNone
Channel sendnotification_delivery; sometimes notification_push_token.is_activeRedis GETDEL on a secret referenceNone; a failure writes job_failures on the final attemptNoneNoneThe actual email, SMS or push
Mark one readnotification_recipient.read_atNoneNoneNoneNoneThe badge drops
Mark all readnotification_recipient.read_at, boundedNoneNoneNoneNoneThe badge clears
Register a devicenotification_push_token insert, and sometimes an invalidationNoneNoneNoneNoneFuture push sends reach the device
Remove a devicenotification_push_token invalidation columnsNoneNoneNoneNonePush stops for that device
Save preferencesnotification_preference_set, notification_preferenceNoneNoneNoneNoneTakes effect on the next fan-out batch
Create or edit a templatenotification_template, activityNoneNoneNoneNoneTakes effect on the next fan-out batch
Cancel an eventnotification_event.cancelled_at, activityNoneNoneNoneNoneThe fan-out will not run
Replay a failureA new notification_delivery, outbox_events, job_failures claimNoneA channel send jobNoneNoneThe message is re-sent
Feed read-allnotification_read, one row per visible notificationNoneNoneNoneNoneThe bell clears
Reaper ticknotification_delivery, notification_event.fanout_claimed_at, outbox_eventsNoneRe-dispatches fan-out for orphansNoneNoneStalled messages resume
Backfill ticknotification_delivery, outbox_eventsNoneChannel send jobsNoneNoneThe pre-credential backlog moves
Retention tickDeletes from notification_delivery, notification_recipient, notification_event, notification_push_token, notificationNoneNoneNoneNoneOld history disappears from every screen
Operational alertjob_failures on terminal failureNoneNoneThe admin toast fires independently, at dispatchNoneAn email to the support mailbox

10. Error and Recovery Flows

ScenarioTriggerUser/System ExperienceRecoverySource
The scheduling enqueue failsRedis unreachable at the moment a business write commitsThe 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 allThe outbox dispatcher relays when Redis returnsOutboxService
The outbox row was purged before dispatchMaintenanceNobody receives the notificationThe 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-eventCrash, deploy, OOMThe event is half fanned out and matched by no other sweepThe stale-claim sweep releases the claim after ten lease periods; the fan-out resumes from fanout_cursor, so committed batches are not re-walkedThe reaper
A channel worker dies after the provider callCrash between sending and recordingThe message went out but the record says it did notThe lease expires, the reaper reclaims and increments attempts, so the loop terminates rather than re-sending forever at metered costDeliveryRecorderService
The provider is temporarily unreachableDNS, TLS, timeout, 5xxNothing arrives yetfailed with an exponential backoff capped at an hour; the reaper re-queues itThe same
The provider refuses the messageBad address, blocked domain, dead token, no creditNothing arrives, everStraight 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 replayThe same
The SMS balance runs outMetered accountEvery SMS goes straight to deadThe daily credit check warns days ahead; top up and replayNotificationCreditProcessor
A provider has no credentialsA new deploymentDeliveries record skipped_unconfigured — no retry, no dead letter, no failure metricSupply credentials; the backfill worker re-drives the last 24 hours within five minutesNotificationBackfillProcessor
An operator's template edit is brokenAn unresolvable placeholder, or a builder that throwsRecipients see the shipped wordingFix or delete the override. One warning per key, not per recipientTemplateRendererService
A secret expires before the sendRedis flushed, or a slow queueThat one security email is not sentskipped_no_destination. The Postgres token is still valid, so the user simply asks againSecretReferenceService
A recipient is deleted between fan-out and sendA scheduled event, a later deletionNothing is sent to themSend-time liveness re-check records skipped_no_destinationChannelSendService
A push token goes staleReinstall, uninstallThat device stops receivingFCM's rejection deactivates the row automatically; the app re-registers on next launchThe same
Two operators edit one templateConcurrencyThe second gets a conflict rather than losing their work silentlyReload and retryOptimistic concurrency
Two devices save preferences at onceConcurrencyThe second gets a conflict; nothing is half-appliedRe-read and re-applyThe version compare-and-set
Two operators replay one failureConcurrencyExactly one new delivery existsThe loser's inserts roll back with the claimThe replay transaction
A cancel races the fan-outTimingThe operator is told which happened409 naming either already-cancelled or already-fanned-outNotificationEventService
Retention deletes something in flightCannot happenThe sweep refuses any event with a queued, processing or failed delivery, and any with an unreplayed dead letterNotificationRetentionProcessor
Redis is unreachable while a person readsThe badge and list are still correctPostgres is the authoritative unread store; the realtime stream is only an enhancement that saves pollingNotificationRealtimePublisherService
A job name has no handlerA deploy that removed oneThe job throws rather than completing silentlyBullMQ records it; a silent success would be indistinguishable from work doneNotificationFanoutQueueProcessor

11. Diagrams Required Per Module

DiagramWhere
Actor capability diagramSection 4
High-level module flow5.1
Sequence per major flow5.1, 5.2
State machine per lifecycle7.1, 7.2, 7.3
Data side-effect diagramSection 9
Error branch diagramSection 10
Admin activity diagrams6.1, 6.3
Swimlane and service blueprint12.2
Flow-to-data trace12.6

12. Feature and Flow Deep-Dive Pack

12.1 Feature Inventory With Minor Behaviors

FeatureMinor BehaviorActorTriggerUser/System ResultBackend Side EffectSource
Raise a notificationDuplicate raises of a collapse kind fold into oneCalling modulesend() twice with the same aggregateOne set of messagesThe second insert is a no-op via the dedupe uniquenotification.service.ts
A repeatable kind requires a request idThe sameA second reset or OTP requestA second, real messageA distinct dedupe keyThe same
A collapse kind is refused a request idThe sameA caller misuses it400Nothing writtenThe same
An audience over 500 explicit ids is refusedThe sameA large list400Nothing writtenThe same
A compound audience outside 1–10 members is refusedThe sameBreadth abuse400Nothing writtenThe same
A javascript: or protocol-relative action URL is refusedThe sameA bad link400Nothing writtensafe-action-url.util.ts
Fan-outA suppressed channel still gets a rowSystemPreferences say noThe person receives nothing on that channelskipped_preference — which is what makes the reason readable laternotification-fanout.processor.ts
Push fans out per tokenSystemSeveral devicesEach device gets its own attemptN delivery rowsThe same
In-app completes inline with its contentSystemAn in-app channelThe notification is readable the instant the batch commitsdelivered with rendered title and bodyThe same
Template overrides and locales load only when in-app is requestedSystemAn email-only announcementFaster batchesTwo queries skippedThe same
A zero-recipient fan-out is loggedSystemA class with no enrolmentsNothing sentA warn, so an operator who sent to nobody can see itThe same
The batch cursor advances inside the batch transactionSystemA crash mid-fan-outResumes exactly where it stoppedNo duplicate channel outbox rowsThe same
Recipient counting is a COUNT(*), not an accumulatorSystemA resumed fan-outAn honest countNot doubledThe same
Channel sendA zero-row claim is not an errorSystemTwo workers, or an already-terminal rowNothing happens; no retryReturns quietly rather than re-driving somebody else's workchannel-send.service.ts
A delivery on the wrong queue throwsSystemA routing bugLoud failureIt means every delivery of that channel is misroutedThe same
An email destination from the vault wins over the user rowSystemChange-email verificationThe code reaches the new addressThe unverified address never enters retained historyThe same
A phone number is normalised, and an unnormalisable one is a skipSystemA malformed numberNothing sent, nothing billedskipped_no_destination rather than failedThe same
SMS is truncated by segments, not charactersSystemA long Nepali messageThe bill matches the messageDevanagari is UCS-2 at 70 characters a segmentpackages/sms
A dead push token is deactivated during the sendSystemFCM rejects itThat device stops failing foreveris_active = false with a reasonchannel-send.service.ts
Web push gets a click destination; mobile does not need oneSystemplatform: "web"Clicking the notification opens somethingwebpush.fcmOptions.link, https onlypackages/firebase
Notification centreFixed newest-first order with an id tie-breakPersonPagingNo dropped or duplicated rowsTwo events in one transaction share a timestampnotification-centre.service.ts
pagination=false is refusedPersonA client trying to fetch everything400The table only growsThe same
Marking read twice keeps the first timestampPersonA double tapIdempotentcoalesce(read_at, now())The same
An invisible row answers 404, never 403PersonGuessing a public idIndistinguishable from absentA 403 would confirm existenceThe same
read-all skips rows scoped to another rolePersonA teacher clearing their parent badgeThe staff badge is untouchedThe predicate carries the active roleThe same
PreferencesA locked category's overrides are accepted and droppedPersonTrying to mute securityThe switch has no effect, and the API does not pretend it didisEnabled short-circuits on unsuppressiblenotification-preferences.service.ts
Duplicate cells de-duplicate last-winsPersonA noisy clientNo errorA Map keyed on category and channelThe same
An empty overrides array is legalPersonTaking the lockVersion bumps, nothing changesThe same
A brand-new category appears immediately for everyonePersonA category shipsIt uses its code defaultBecause absence means the default rather than a stored rowpreference-resolver.service.ts
DevicesRe-registering the same token is a touchPersonEvery app launchNo churn, and the cap is not consumedlast_used_at and platform updatednotification-devices.service.ts
A token presented by a second user invalidates the firstPersonA shared or resold deviceThe first user's push stops, with a recorded reasonreplaced, and a fresh rowThe same
The cap refuses rather than evictsPersonAn eleventh deviceThe caller learns the limitNothing is silently removedThe same
Removal is softPersonSigning outThe history survivesuser_logoutThe same
TemplatesAn empty override table is correctAdminA fresh deploymentNotifications still have copyThe registry is the source of truth and the fallbacktemplate-registry.ts
A broken override degrades, never silencesAdminA bad editThe shipped wordingOne warning per keytemplate-renderer.service.ts
DELETE carries a versionAdminA concurrent editThe delete cannot silently winA compare-and-setnotification-template.service.ts
Editing records per-field changesAdminAny PATCHAn auditable diffThe global interceptor has no before-valueThe same
HistoryfailedDeliveryCount excludes every skipAdminAn unconfigured channelThe screen does not cry outageOnly failed and dead countnotification-history.service.ts
The detail response is unpaginatedAdminA school-wide announcementA large payloadDeliberate: the screen exists to show the whole pictureThe same
No name, address or message body is ever returnedAdminAny history readPersonal data cannot be re-aggregatedThose columns are never read into memoryThe same
Dead lettersOnly three queues are reachableAdminAny requestA restore's failures are invisible hereA hardcoded allowlist, ANDed unconditionallynotification-failure.service.ts
Replay inserts rather than resetsAdminClicking replayThe original evidence survivesreplay_of_delivery_id, and both uniques exclude replaysThe same
A second replay is refusedAdminTwo operatorsExactly one new deliveryA compare-and-set inside the transactionThe same
Operational feedA superadmin bypasses the filterSuperadminReading the bellEverythingRather than materialising the cataloguenotification-feed.service.ts
No active role sees nothingAnyA session before role selectionAn empty bellAn empty permission list becomes SQL falseThe same
Read state is per personAdminOne operator readsEveryone else still sees it unreadA join table, not a columnnotifications.ts
The unread count spans everything visibleAdminTwo hundred unread, fifty returnedAn honest badgeCounted separately from the pagenotification-feed.service.ts
RecoveryThe reaper runs four sweeps every 30 secondsSystemAlwaysStalled work resumes quicklyAn idle tick is four indexed scans returning nothingnotification-reaper.processor.ts
A lease reclaim increments attemptsSystemA crash-looping workerThe loop terminatesOtherwise unbounded duplicate SMS at metered costdelivery-recorder.service.ts
Requeueing clears failed_at and last_errorSystemA backoff elapsingFailure counts stay honestA row that failed then succeeded would otherwise still look failedThe same
RetentionBoth delivery models are sweptSystemHourlyNeither table grows foreverThe operational feed is the one nothing else deletes fromnotification-retention.processor.ts
Token pruning runs even on an idle tickSystemA quiet holiday with steady reinstallsThe partial unique index stops growingIt is an independent retention that happens to share the tickThe same
Nothing in flight is ever deletedSystemAlwaysNo message vanishes mid-sendThe in-flight predicateThe same
Nothing with an unreplayed dead letter is deletedSystemAlwaysReplay still workspayload_ref is not an FK, so nothing else would stop itThe 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

DiagramRequired WhenPurpose
User journey mapAlwaysActor intent from entry to outcome
Service blueprintBackend-heavy flowsSeparates the person's step from the API, the worker and the provider
Activity diagramEvery major flowDecisions and branches
State diagramEvery lifecycle7.1, 7.2, 7.3
SwimlaneMulti-actor flowsOwnership by actor and system
SequenceAPI-backed flows5.1, 5.2
Data side-effect graphEvery mutationSection 9
Exception flowCritical failuresSection 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

RuleBusiness ReasonActor ImpactEnforced InAPI ImpactBackend ImpactTests
A caller cannot choose category, priority, suppressibility or dedupe policyOtherwise any module can send a marketing blast as system past every opt-out, at real SMS costRecipients keep control of what reaches themThe SendNotificationInput type and the registryNot reachable from any routeThe registry supplies all fourRegistry and send specs
A user cannot switch off a security notificationA person must be able to recover their own accountLocked switches in the UIunsuppressible on the registry entry; step 1 of the resolverlocked: true in the preference responsePreferences are skipped entirelyPreference specs
Only security and system kinds may be unsuppressibleScope limitation on an exemptionUNSUPPRESSIBLE_CATEGORYApplied in the registryRegistry spec
SMS is off by default in every category except securityIt is metered; a school-wide default of "on" is a bill nobody choseParents opt in to textsCATEGORY_DEFAULTSReflected in GET preferencesApplied at fan-outPreference specs
Marketing is off on every channel by defaultOpt-in, not opt-outNobody receives promotion without askingCATEGORY_DEFAULTSThe sameThe sameThe same
The audience is a specification, resolved per batchA materialised list keeps notifying people who left and misses people who arrivedA pupil enrolled an hour ago is includedThe audience jsonb column and the resolveraudience is returned in the history detailRe-evaluated per pageFan-out spec
Guardians resolve through the guardian relationship, never a caller-supplied user listA caller could otherwise address one family's notification to anotherFamilies see only their ownThe guardian audience SQLNot reachablestudent_guardian joinFan-out spec
The notification centre is scoped by the active roleA teacher who is also a parent must not see staff notices while acting as a guardianCleaner, correct listsThe centre predicateAffects list and badgeFour-part SQL predicateCentre spec
A dismissed teacher stops seeing staff noticesThey keep a live users row because they are also a parentDisciplinary and roster content stopsThe staff-liveness EXISTSThe sameThe sameThe same
A person sees only notifications that were actually delivered in-appOtherwise the centre lists email-only messages and suppressed onesAn honest listThe in-app EXISTSThe sameThe sameThe same
Content is frozen at fan-outA later template edit must not rewrite what somebody already receivedHistorical accuracypersistRendered and the rendered columnstitle/body in the centre responseWritten by the fan-out workerFan-out spec
A kind with an in-app channel must persist its renderingOtherwise the notification arrives, counts toward the badge, and displays blankNo blank notificationsA boot-time assertionThe registry refuses to loadThe assertion itself
An unresolved placeholder suppresses the sendDelivering literal {{name}} is metered, paid for, and invisible to every gateNo broken messagesThe interpolator returning nullskipped_no_template on the history screenFalls through to the shipped copy firstRenderer spec
An operator override never silences a notificationA bad edit must be cosmeticMessages always have copyThe fall-through to the registryOne warning per keyRenderer spec
A template cannot evaluate expressionsAn admin-editable template that could would be a code execution surface behind a CRUD permissionAllowlisted {{name}} substitutionA replace callback, never a replacement stringRenderer spec
An action URL must be https: or a single-slash relative pathescapeHtml does not neutralise javascript: or data:, so an unvalidated URL becomes a working link in the recipient's mail clientNo hostile linksisSafeActionUrl, at write and at render400 on send; a dropped button at renderValidated twice, deliberatelyThe utility's own tests
A live token never reaches PostgresNotificationHistory_READ is not superadmin-only; a stored token turns an operational read into account takeoverThe Redis vault, plus a CHECK as a backstopNo route ever returns oneResolved at send time with GETDELChannel-send spec
A raw provider response never reaches last_errorThe SMS gateway echoes the message text back, which for an OTP is the OTPFixed error tables in all three providerslastError is a codeMapped, never passed throughProvider specs
Addresses are maskedThe full set is a contact-details export of every family in the schoolmaskEmail, maskPhonedestinationHint onlyWritten maskedProvider specs
History returns no name, address or bodyOne grant must not re-aggregate personal dataThe service never reads those columnsAbsent from the DTONothing to spreadHistory spec
The dead-letter screen reaches only three queuesA clerk must not see or re-run a database restoreA hardcoded allowlist, ANDed unconditionally409 for anything elseDeny-by-defaultDead-letter spec
A push token is never reassigned in placeAnyone who learns a victim's token could otherwise silently stop their push, including unsuppressible security noticesDevices stay honestInvalidate-and-reinsert201 either wayReason replacedDevices spec
A token is bound to the session, never the bodyRoleGuard runs no permission check on this handleractor.id onlyThe DTO has no userIdThe only remaining controlDevices spec
The delivery row owns retry, not BullMQTwo budgets mean nobody owns the terminate decisionMessages are not sent twiceattempts: 1 per channel queueThe row's attempts, backoff and reaperReaper spec
A skip is never a failureAn unconfigured machine must not look like an outage, and a real outage must not be buriedHonest dashboardsSKIPPED_DELIVERY_STATUSExcluded from failedDeliveryCountExcluded from every metricHistory spec
Retention deletes nothing in flightThe worker's claim would return zero rows and report successNo message vanishes mid-sendThe in-flight predicateRetention spec
Every enqueue goes through the outboxOtherwise the write commits, the enqueue throws, the caller returns 200, and nothing is scheduledNo silently lost notificationsOutboxServiceOne transactionFan-out and backfill specs
Every list is paginated with a stable orderAn unstable sort under offset paging silently drops and duplicates rowsCorrect listsFixed order plus an id tie-breakpagination=false is 400Every list spec

12.4 Tradeoffs and Product Rationale

Product DecisionUser BenefitEngineering BenefitAlternativeTradeoffRisk
Fan-out happens in the backgroundThe action that triggered it returns immediatelyThe caller's transaction stays shortResolve inlineThe notification exists before anybody has itA worker outage delays everything; the orphan sweep is the answer
The audience is a spec, not a listNew pupils are included, departed ones are notOne jsonb column instead of a materialised setSnapshot at publishThe audience can shift during a long fan-outAcceptable, and the intended behaviour
Preferences resolve at fan-out, not at sendA change takes effect on the next thing sent, predictablyOne place to reason aboutResolve at sendA preference changed after fan-out does not apply to queued messagesSmall window; documented
In-app content is frozen at fan-outA notification says what it said when it arrivedNo re-rendering on a read path, and no secret resolution on oneRender at read timeA template fix does not repair old notificationsCorrect: rewriting delivered history is worse
A suppressed channel still gets a row"Why did I not get this?" is answerableThe status is reachable and testableWrite nothingMore rowsBounded by retention
Skips are their own statusesAn operator is not misled into chasing an outageFailure metrics stay meaningfulOne failed statusFour more statuses to learnDocumented per status
A dead message needs a human to replayNobody is spammed by an automatic loopThe terminate decision has one ownerRetry foreverSomebody must noticeThe dead-letter screen and the credit warning are the prompts
Push fans out per deviceA working phone still gets it when the tablet's token is deadPer-target outcomes surviveOne row per recipientMore rows on the largest tableCapped at ten tokens per person
Devices are capped at tenNobody's push is silently droppedBounds amplificationEvict the oldestThe eleventh registration is refusedThe caller is told the limit
The operational feed fans out on readA newly promoted operator sees the history their role coversOne row per event, not per staff memberFan out on writeThe feed cannot be personalisedIt is not meant to be
Read state is a join table for the feedOne operator reading does not clear the office's bellProportional to what people looked atA read_at columnAn extra tableSmall
Retention is 30 daysScreens stay fastThe largest tables stay boundedA yearA delivery from last quarter cannot be investigatedNobody investigates one
Nothing is sent synchronouslyThe UI never blocks on a providerProviders can be slow or down without affecting requestsSend inlineA 201 means scheduled, not sentThe history screen is the outcome surface
An unconfigured channel degrades rather than failsA new deployment works immediatelyOne code path in both statesRefuse to startIt can be silently unconfiguredThe boot warning, the first-class status, and NOTIFICATION_REQUIRED_CHANNELS

12.5 Flow Edge-Case Matrix

FlowEdge CaseTriggerExpected BehaviorUser/System FeedbackSource
RaiseDuplicate raise, collapseSame aggregate twiceOne event, one set of messagesThe second call returns created: falsenotification.service.ts
RaiseDuplicate raise, repeatableTwo reset requestsTwo events, two messagesBoth deliveredThe same
RaiseMissing request id on a repeatable kindA caller forgetsRefused at the boundary400 — refused rather than defaulted, because the alternative is a 200 that sent nothingThe same
RaiseZero channelsImpossible by CHECKRejectedA constraint violationThe event schema
Fan-outEmpty audienceA class with no enrolmentsCompletes with recipientCount: 0Logged at warnnotification-fanout.processor.ts
Fan-outConcurrent workersA redelivered jobThe second claim matches zero rowsReturns { skipped: true }The same
Fan-outCrash at batch five of twentyDeploy, OOMResumes at batch fiveThe cursor advanced inside each batch transactionThe same
Fan-outCancel lands mid-claimTimingThe cancel winscancelled_at IS NULL is part of the claim predicateThe same
Fan-outThe audience changes between pagesA pupil enrolsThey are includedThe spec is re-evaluated per pageaudience-resolver.service.ts
Fan-outA person matched by two compound membersOverlapping audiencesOne recipient rowThe union deduplicates on users.idThe same
SendClaim returns zero rowsAnother worker, or already terminalQuiet return, no retryNot an errorchannel-send.service.ts
SendRecipient deleted after fan-outA scheduled eventskipped_no_destinationVisible on the history screenThe same
SendHard-erased recipientuser_id is nullskipped_no_destinationThe LEFT JOIN lets the branch record whyThe same
SendExpired secretRedis flushedskipped_no_destinationThe user asks againsecret-reference.service.ts
SendRedelivered job after a successful sendAt-least-onceThe secret is already consumedGETDEL makes the second attempt skipThe same
SendProvider timeoutNetworkfailed, retryableA backoffdelivery-recorder.service.ts
SendWorker dies after the provider callCrashThe lease expires and the reaper reclaims, incrementing attemptsBounded duplicates rather than unboundedThe same
SendSlow but successful sendA provider slower than the leasePossible duplicateThe lease is set to exceed the worst-case provider callDocumented risk
CentreNo active roleBefore role selectionOnly not-role-scoped rowsx = NULL is never true, so no extra branch is needednotification-centre.service.ts
CentreRole switchA teacher-parentList and badge both changeImmediateThe same
CentreFirst useNo notificationsEmpty list, zero badgeNot an errorThe same
CentreLast item on a pageOffset pagingStableThe id tie-breakThe same
CentreStale public idRetention removed it404Clients must not treat ids as permanentThe same
PreferencesNever savedversion: 0Defaults returned, nothing writtenA read that wrote would be a read that liesnotification-preferences.service.ts
PreferencesConcurrent saveTwo devicesThe loser writes nothing409The same
PreferencesLocked categoryMuting securityAccepted, not persistedThe response still shows it lockedThe same
PreferencesA category added laterA new featureUses its code default for everyoneAbsence means the defaultpreference-resolver.service.ts
DevicesSame token, same userApp launchA touchThe cap is not consumednotification-devices.service.ts
DevicesSame token, different userA shared deviceInvalidate and reinsertReason replacedThe same
DevicesAt the capEleven devicesRefusedNOTIFICATION_DEVICE_LIMIT_REACHEDThe same
DevicesRemove twiceDouble tap404Already inactiveThe same
TemplatesConcurrent editTwo operatorsThe second is refused409notification-template.service.ts
TemplatesDelete versus editRacingWhichever compare-and-set matched wins409 for the loserThe same
TemplatesUnknown kindA typo400Registry-checkedThe same
TemplatesWhitespace-only bodyA formSpaces are rejected; a lone tab passesDeliberate scope for the constraintThe template schema
Dead lettersA failure from another queueGuessing an id409, not 404The row exists elsewhere; pretending otherwise would misleadnotification-failure.service.ts
Dead lettersDouble replayTwo operatorsOne new deliveryThe loser rolls back entirelyThe same
Dead lettersThe delivery is goneRetention or manual deletion404Retention normally refuses while a dead letter is unreplayedThe same
Dead lettersReplaying a push whose token diedA reinstallA new delivery that skipsskipped_no_destinationchannel-send.service.ts
FeedEmpty permission setNo active roleNothing, via SQL falseAn empty inArray would read as "no filter" in some driversnotification-feed.service.ts
FeedReading an invisible rowGuessing an id404A 403 would confirm it existsThe same
FeedA non-v7 uuidA bad client400ParseUUIDPipe({ version: "7" })The controller
RetentionAn in-flight deliveryAlwaysThe event survives to the next tickThe in-flight predicatenotification-retention.processor.ts
RetentionAn unreplayed dead letterAlwaysThe event survivesThe job_failures predicateThe same
RetentionA partially deleted eventA batch boundarySurvives to the next tickOnly fully childless events are deletedThe same
RetentionNo aged events at allA quiet periodTokens and the feed are still prunedThree independent retentions share one tickThe same
BackfillNo configured channelsA fresh deploymentReturns immediatelyThree isConfigured() callsnotification-backfill.processor.ts
BackfillTwo ticks racingOverlapOne re-queueA compare-and-set on the statusThe same
BackfillA row older than the windowA provider added lateLeft aloneBounded on purposeThe same

12.6 Flow-to-Data Trace

FlowReadsWritesCacheJobs/EventsResponse Fields
Raise a notificationThe template registrynotification_event, outbox_eventsNonenotification.fan_outeventPublicId, created
Fan-out batchAudience tables, preferences, tokens, overrides, localesnotification_recipient, notification_delivery, outbox_events, the event cursorNonenotification_channel.send_*; a per-user publishNone — it is a worker
Channel sendThe delivery, recipient, event, user, token, overridesThe delivery; sometimes a token's is_activeThe secret vault, via GETDELA dead letter on terminal failureNone
Centre listRecipients, events, roles, staff, in-app deliveriesNoneNonepublicId, kind, category, priority, title, body, actionUrl, actionLabel, occurredAt, readAt
Unread countThe sameNoneNonecount
Mark readThe sameread_atNoneNoneThe full centre row
Register a deviceActive tokensnotification_push_tokenNoneNonepublicId, platform, createdAt
Preferences readThree preference tables, the registryNoneNoneversion, categories[] with locked and channels
Preferences saveThe version rownotification_preference_set, notification_preferenceNoneNoneThe resolved matrix
Template CRUDnotification_template, the registrynotification_template, activityNoneNoneThe full template row including version
History listEvents, recipients, deliveriesNoneNoneEvent fields plus failedDeliveryCount
History detailThe sameNoneNoneEverything above plus audience, requestedChannels, recipients and deliveries
CancelThe eventcancelled_at, activityNoneNonepublicId, cancelledAt
Dead-letter listjob_failuresNoneNoneFailure fields including payloadRef
Replayjob_failures, the original deliveryA new delivery, outbox_events, the failure claimNoneA channel send jobThe claimed failure plus newDeliveryPublicId
Feed listnotification, notification_read, role permissionsNoneNoneRows plus unreadCount
Feed mark readThe samenotification_readNoneNonenull

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