Notification Backend Documentation
Backend architecture, data model, services, cache, queues, runtime rules, and operational behavior for the notification module.
Notification Backend Documentation
The platform carries two delivery models over one word, and almost every mistake made in this area comes from conflating them.
Anything addressed to a person fans out on write: notification_event records the business fact, notification_recipient records each individual it concerns, and notification_delivery records one attempt per channel per provider target. That chain is what this module owns.
Operational events for administrators fan out on read: one row per event in the notification table carrying the permission code a viewer must hold, filtered at query time against the caller's active role. That is the notification-feed module, and it is a different feature that happens to share a noun. Neither is a special case of the other, and one table cannot carry both without a discriminator every query then has to remember.
1. Documentation Evidence
| Area | Files Inspected | Verified Details |
|---|---|---|
| Module wiring | apps/api/src/modules/notification/notification.module.ts, notification-worker.module.ts, admin/notification-admin-aggregate.module.ts | Imports, providers, exports, the service/worker split, queue registration. |
| Route prefixes | apps/api/src/modules/mobile/mobile.module.ts, apps/api/src/main.ts | setGlobalPrefix("api"); RouterModule.register({ path: "mobile", children }) mounts the three consumer leaves. |
| Controllers | admin/template/, admin/history/, admin/dead-letter/, admin/event/, customer/notification-centre/, customer/notification-devices/, customer/notification-preferences/, apps/api/src/modules/notification-feed/notification-feed.controller.ts | Route ownership, guards, permissions, thin-controller boundaries. |
| Services | Every *.service.ts under apps/api/src/modules/notification/ and notification-feed/ | Business logic, validation order, transaction boundaries, writes, mappings, error codes. |
| Workers | apps/api/src/modules/notification/workers/*.ts | Queue routing, claim semantics, sweeps, retention, backfill, dead letters. |
| Providers | channels/email.provider.ts, channels/notification-providers.module.ts, packages/sms/src/aakash.provider.ts, packages/firebase/src/fcm.provider.ts | isConfigured() semantics, fixed error tables, token invalidation, the webpush block. |
| Schema | packages/db/src/schema/notification/*.ts, packages/db/src/schema/notifications.ts, packages/db/src/schema/jobs/job-failures.ts, packages/db/src/schema/identity.ts | Tables, columns, nullability, CHECK constraints, indexes, FK delete behaviour. |
| Vocabulary | packages/db/src/notification/notification-contract.ts | Delivery statuses, channels, categories, priorities, audience kinds, push platforms, invalidation reasons. |
| Jobs | packages/jobs/src/index.ts, apps/api/src/services/bullmq/bull.module.ts | Queue names, job names, payload shapes, per-queue attempts. |
| Config | apps/api/src/modules/notification/shared/notification.constants.ts, apps/api/.env.example | Every tunable, its clamp, its default, and the boot assertion between backfill and retention. |
| Permissions | packages/db/src/authorization/permission-catalog.ts, apps/api/src/common/authorization/role.guard.ts | NotificationTemplate, NotificationHistory, NotificationFailure; the no-permission handler allowlist. |
| Errors | apps/api/src/common/types/error-codes.ts | Every NOTIFICATION_* code and the condition that raises it. |
| Tests | apps/api/src/modules/notification/__tests__/*.int.spec.ts and the per-submodule *.int.spec.ts files | Confirmed fan-out, channel send, retention, operational email, centre, devices, preferences, history, dead letter, event cancel, template behaviour. |
2. Backend Scope and Boundaries
Owns
- The public send interface —
NotificationService.send(tx, input)— that every other module calls to tell somebody something. - The notification vocabulary: channels, categories, priorities, delivery statuses, audience kinds, push platforms and token invalidation reasons.
- The template registry in code, and the operator override table that layers on top of it.
- Audience resolution: turning an audience specification into people, one bounded keyset page at a time.
- Preference resolution: the four-step order that decides whether a given channel is delivered for a given person.
- The delivery state machine, its lease, its retry budget, its reaper and its backoff.
- The four channel implementations —
email,sms,push,in_app— and their provider adapters. - Push token registration, invalidation and per-user capping.
- The consumer notification centre, the consumer preference matrix and consumer device registration.
- The admin surfaces: template overrides, delivery history, the channel dead-letter queue with replay, and cancellation of a scheduled event.
- Retention for both delivery models plus invalidated push tokens.
- Operational alerts to a fixed support mailbox on
QueueName.NOTIFICATION_OPERATIONAL. - The per-user realtime publish that lets an open session update its badge without polling.
Does Not Own
- The transactional outbox.
OutboxService.enqueueandOutboxDispatcherProcessorlive inapps/api/src/modules/outbox/. This module writes outbox rows and never callsqueue.add()on a send path. - The admin operational feed.
notification+notification_readand their routes belong toapps/api/src/modules/notification-feed/. This module's only contact with that table is the retention sweep, which had no other owner. - Identity and liveness.
users,students,guardians,staff,user_roleandroleare read by the audience resolvers and never written. - Email transport.
packages/emailresolves the from-address, renders preview text and talks to Resend;EMAIL_CLIENTis provided byapps/api/src/modules/notifications/.EmailChannelProvideradapts it rather than reimplementing it. - SMS and push transport.
packages/smsandpackages/firebaseown the HTTP calls, the response parsing and the fixed error tables. - Authentication.
JwtAuthGuardandRoleGuardare applied here but implemented inapps/api/src/modules/auth/andapps/api/src/common/authorization/. - The dead-letter writer.
JobFailureRecorderServiceis a sharedcommon/jobsservice; this module supplies thepayload_refand nothing else. - The secrets themselves. Auth mints a reset token or an OTP and stashes it; this module resolves a reference at send time and never learns how the value was produced.
Source of Truth
| Concern | Source of Truth | Notes |
|---|---|---|
| Whether an event happened | notification_event | Written in the caller's own transaction, so it commits with the business write or not at all. |
| Who a notification concerns | notification_event.audience, re-evaluated per batch | A specification, never a materialised list — a pupil enrolled an hour later is included, one who left is not. |
| What happened to a message | notification_delivery.status | The only writer is DeliveryRecorderService. BullMQ's own state is not a business record. |
| Retry budget | notification_delivery.attempts | The channel queues run one BullMQ attempt. The row owns the terminate decision. |
| Unread state, consumer | notification_recipient.read_at IS NULL | Redis pub/sub is an enhancement; every screen must be correct on a plain refresh with Redis unreachable. |
| Unread state, admin feed | Absence of a notification_read row | A join table, not a column — one row is seen by everyone holding its permission. |
| Category, priority, suppressibility, dedupe policy | TEMPLATE_REGISTRY in code | None is on the send input, so a caller cannot declare its own blast to be system and step over every opt-out. |
| Message copy | TEMPLATE_REGISTRY, overridden by notification_template | The row overrides; it never replaces. A missing, inactive or throwing override falls through to the shipped wording. |
| A live token or OTP | A short-TTL Redis vault entry | Never Postgres. notification_event.variables carries only a secretRef. |
| Preference set version | notification_preference_set.version | Absence is a real state and reports version: 0. |
3. Module Composition
The module is split into a service half and a worker half, and the split is load-bearing. Importing a module that declares a @Processor starts a BullMQ worker. Every module that wants to send a notification imports NotificationModule; if the processors lived there, every such import would start a duplicate set of workers, and the fast runtime profile — which deliberately runs none — could not opt out.
| Module | Type | Path | Controllers | Providers | Exports | Responsibility |
|---|---|---|---|---|---|---|
NotificationModule | Aggregate + providers | apps/api/src/modules/notification/notification.module.ts | None | NotificationService, AudienceResolverService, PreferenceResolverService, TemplateRendererService, DeliveryRecorderService, SecretReferenceService, EmailChannelProvider, ChannelSendService, JobFailureRecorderService | All of the above plus NotificationProvidersModule | The public send interface and every shared service. Declares no @Processor. |
NotificationWorkerModule | Worker composition | apps/api/src/modules/notification/notification-worker.module.ts | None | NotificationFanoutQueueProcessor, NotificationFanoutProcessor, NotificationReaperProcessor, NotificationCreditProcessor, NotificationBackfillProcessor, NotificationRetentionProcessor, NotificationScheduler, NotificationEmailProcessor, NotificationSmsProcessor, NotificationPushProcessor, NotificationOperationalProcessor, NotificationRealtimePublisherService | None | Every @Processor and the cron. Composed by the application root alone, and only outside the fast runtime profile. |
NotificationProvidersModule | Leaf | channels/notification-providers.module.ts | None | SMS_PROVIDER, PUSH_PROVIDER factories | Both tokens | Builds one provider per external channel from validated configuration and fails boot for an unconfigured required channel. |
NotificationAdminAggregateModule | Aggregate | admin/notification-admin-aggregate.module.ts | None | None | The four admin leaves | NestJS graph composition only. Swagger's ADMIN_MODULES names each leaf directly, because include does not recurse. |
NotificationTemplateModule | Leaf | admin/template/notification-template.module.ts | NotificationTemplateController | NotificationTemplateService | None | Operator overrides of the code registry. |
NotificationHistoryModule | Leaf | admin/history/notification-history.module.ts | NotificationHistoryController | NotificationHistoryService | None | Read-only history of every event fanned out to real people. |
NotificationFailureModule | Leaf | admin/dead-letter/notification-failure.module.ts | NotificationFailureController | NotificationFailureService | None | The channel dead-letter screen and replay. |
NotificationEventModule | Leaf | admin/event/notification-event.module.ts | NotificationEventController | NotificationEventService | None | Cancelling a scheduled event before it fans out. |
NotificationCentreModule | Leaf | customer/notification-centre/notification-centre.module.ts | NotificationCentreController | NotificationCentreService | None | The consumer notification centre. Mounted at /api/mobile/notifications. |
NotificationDevicesModule | Leaf | customer/notification-devices/notification-devices.module.ts | NotificationDevicesController | NotificationDevicesService | None | Push token registration. Mounted at /api/mobile/notification-devices. |
NotificationPreferencesModule | Leaf | customer/notification-preferences/notification-preferences.module.ts | NotificationPreferencesController | NotificationPreferencesService | None | The consumer preference matrix. Mounted at /api/mobile/notification-preferences. |
NotificationFeedModule | Leaf, separate module | apps/api/src/modules/notification-feed/notification-feed.module.ts | NotificationFeedController | NotificationFeedService | NotificationFeedService | The admin operational feed — a different delivery model. Mounted at /api/notifications. |
Each consumer leaf is registered in MOBILE_CHILDREN as a concrete leaf, never an aggregate: RouterModule.register() does not recurse, so listing an aggregate mounts its controllers at /api/<thing>/... instead of /api/mobile/<thing>/..., silently and with no error.
EmailChannelProvider, SecretReferenceService, DeliveryRecorderService, TemplateRendererService, PreferenceResolverService, AudienceResolverService, ChannelSendService and JobFailureRecorderService are all exported as well as provided. A provider in providers but not exports resolves fine inside its own module and dies at InstanceLoader in the consumer — naming the consumer rather than the module that failed to export it — and pnpm build exits 0 on it, because it is a runtime graph error and not a type error. NotificationProvidersModule is re-exported so the worker module receives the same provider instances: an FCM app initialised twice under one name throws, and a second mock would record into an array nobody reads.
4. File and Directory Map
apps/api/src/modules/notification/
notification.module.ts
notification-worker.module.ts
shared/
notification.service.ts
notification.types.ts
notification.constants.ts
audience-resolver.service.ts
preference-resolver.service.ts
template-renderer.service.ts
delivery-recorder.service.ts
secret-reference.service.ts
templates/
template-registry.ts
channels/
channel-send.service.ts
email.provider.ts
notification-providers.module.ts
workers/
notification-fanout-queue.processor.ts
notification-fanout.processor.ts
notification-channel.processors.ts
notification-reaper.processor.ts
notification-backfill.processor.ts
notification-retention.processor.ts
notification-credit.processor.ts
notification-operational.processor.ts
notification.scheduler.ts
admin/
notification-admin-aggregate.module.ts
template/ { controller, dto, module, service }
history/ { controller, dto, module, service }
dead-letter/{ controller, dto, module, service }
event/ { controller, dto, module, service }
customer/
notification-centre/ { controller, module, service, dto/ }
notification-devices/ { controller, module, service, dto/ }
notification-preferences/ { controller, module, service, dto/ }
realtime/
notification-realtime-publisher.service.ts
__tests__/
notification-fanout.int.spec.ts
channel-send.int.spec.ts
notification-retention.int.spec.ts
notification-operational.int.spec.ts
apps/api/src/modules/notification-feed/
notification-feed.module.ts
notification-feed.controller.ts
notification-feed.service.ts
dto/notification.dto.ts| File | Purpose | Key Exports | Notes |
|---|---|---|---|
shared/notification.service.ts | The public send interface. | NotificationService | Writes exactly two rows and returns. Resolves no audience, renders no template, contacts no provider. |
shared/notification.types.ts | The module's type vocabulary. | AudienceSpec, LeafAudienceSpec, ResolvedRecipient, ResolvedRecipientPage, SendNotificationInput, SendNotificationResult, RenderedNotification, TemplateVariables, DedupePolicy, NotificationKindDefinition, ProviderOutcome, NotificationProvider | _AudienceKindsAreExhaustive stops compiling if AUDIENCE_KIND gains a member the union does not. |
shared/notification.constants.ts | Every tunable, clamped. | 15 constants plus computeNotificationBackoffSeconds | Throws at import if the backfill window is not shorter than the retention window. |
shared/audience-resolver.service.ts | Specification to people, one keyset page at a time. | AudienceResolverService | Nine SQL fragments behind a Record over the audience kind, unioned and paged as one set. |
shared/preference-resolver.service.ts | Whether a channel is delivered for a person. | PreferenceResolverService, CATEGORY_DEFAULTS | Two queries per batch, not two per person. |
shared/template-renderer.service.ts | Override first, code default always. | TemplateRendererService, RenderOutcome | An unresolved placeholder suppresses the send rather than delivering literal {{name}}. |
shared/delivery-recorder.service.ts | The only writer of notification_delivery.status. | DeliveryRecorderService | Every transition names every column it owes, including the ones it sets to NULL. |
shared/secret-reference.service.ts | Resolves a secret at send time so none is persisted. | SecretReferenceService, SecretResolver | Redis SET ... EX on stash, GETDEL on read. |
templates/template-registry.ts | Every kind the system can send. | TEMPLATE_REGISTRY, NotificationKind, isKnownKind, kindDefinition, escapeHeader, escapeHtml | A module-level loop throws at boot for an in_app kind that does not set persistRendered. |
channels/channel-send.service.ts | Sends one delivery, for any channel. | ChannelSendService | Claim, liveness, secret, render, address, send, record — in that order, for all three remote channels. |
channels/email.provider.ts | The email channel over the existing EmailClient. | EmailChannelProvider, EMAIL_ERROR_CODE, maskEmail | A mocked send is reported skipped, never sent. |
channels/notification-providers.module.ts | Provider construction from validated config. | SMS_PROVIDER, PUSH_PROVIDER, NotificationProvidersModule | One WARN per unconfigured provider at boot; a hard throw for a required channel. |
workers/notification-fanout-queue.processor.ts | The one BullMQ worker on NOTIFICATION_FANOUT. | NotificationFanoutQueueProcessor | Routes by job.name through a Record over a six-member union. Throws on an unknown name. |
workers/notification-fanout.processor.ts | Audience resolution and recipient/delivery writes. | NotificationFanoutProcessor | Claim, batch loop, cursor inside the batch transaction, completion in one statement. |
workers/notification-channel.processors.ts | One worker per channel queue. | NotificationEmailProcessor, NotificationSmsProcessor, NotificationPushProcessor | All three delegate to one ChannelSendService; each carries an onFailed handler. |
workers/notification-reaper.processor.ts | The recovery tick — four bounded sweeps. | NotificationReaperProcessor | No watermark, deliberately: every sweep mutates the rows it finds. |
workers/notification-backfill.processor.ts | Re-drives skipped_unconfigured once credentials arrive. | NotificationBackfillProcessor | The read path that makes "works the moment keys are added" true of the backlog. |
workers/notification-retention.processor.ts | Aged history, invalidated tokens, and the admin feed. | NotificationRetentionProcessor | Bottom-up and bounded, never a cascade. |
workers/notification-credit.processor.ts | Reads the SMS balance and warns. | NotificationCreditProcessor | An exhausted balance is a non-retryable failure, so it needs days of lead time. |
workers/notification-operational.processor.ts | Fixed-mailbox internal notices. | NotificationOperationalProcessor | The one notification queue that uses BullMQ retry, because there is no delivery row. |
workers/notification.scheduler.ts | Four crons. | NotificationScheduler | Job ids are bucketed to match each interval and are built by buildJobId. |
customer/realtime/notification-realtime-publisher.service.ts | Per-user pub/sub for an open session. | NotificationRealtimePublisherService, realtimeUserChannel, and its payload types | Never throws; the durable record is already written when it runs. |
5. Data Model
5.1 Schema Source
packages/db/src/notification/
notification-contract.ts the vocabulary every consumer shares
packages/db/src/schema/notification/
index.ts
notification-event.ts
notification-recipient.ts
notification-delivery.ts
notification-preference.ts three tables: set, channel, category
notification-push-token.ts
notification-template.ts
packages/db/src/schema/
notifications.ts the ADMIN OPERATIONAL FEED: notification + notification_read
packages/db/src/schema/jobs/
job-failures.ts shared dead-letter table, read by the replay screenThe vocabulary lives in packages/db/src/notification/notification-contract.ts rather than beside the tables because three things need the same lists across package boundaries: packages/db generates the CHECK constraints from them, apps/api builds compile-time unions from them, and the queue payload types reference them. A package must never import from an app, so the lists live in the one package both already depend on. Generating each constraint from its constant makes drift impossible — a status that does not appear in the list cannot be written by code that type-checks, and a status that does appear is in the constraint by construction.
Vocabulary constants
| Constant | Members | Used By |
|---|---|---|
DELIVERY_STATUS | queued, processing, sent, delivered, failed, dead, skipped_unconfigured, skipped_preference, skipped_no_template, skipped_no_destination, cancelled | chk_notification_delivery_status_known; the state machine. |
TERMINAL_DELIVERY_STATUS | dead, skipped_preference, skipped_no_template, skipped_no_destination, cancelled | Nothing advances from these without an operator or a configuration change. |
IN_FLIGHT_DELIVERY_STATUS | queued, processing, failed | Retention refuses to delete an event with any delivery in one of these. |
SKIPPED_DELIVERY_STATUS | skipped_unconfigured, skipped_preference, skipped_no_template, skipped_no_destination | chk_notification_delivery_skipped_at_present; excluded from every failure count. |
NOTIFICATION_CHANNEL | email, sms, push, in_app | Channel CHECKs on delivery, template and both preference tables. |
REMOTE_NOTIFICATION_CHANNEL | email, sms, push | Channels with an external provider. in_app's absence is load-bearing. |
NOTIFICATION_CATEGORY | security, system, assignment, announcement, message, payment, attendance, event, marketing | Event and preference category CHECKs; the preference matrix. |
UNSUPPRESSIBLE_CATEGORY | security, system | The only categories in which a kind may declare itself unsuppressible. |
NOTIFICATION_PRIORITY | low, normal, high, critical | chk_notification_event_priority_known. |
PRIORITY_TO_BULL_PRIORITY | critical: 1, high: 3, normal: 5, low: 9 | Orders work within a channel queue. Lower is more urgent; it does not preempt a running job. |
AUDIENCE_KIND | users, role, class, section, grade, guardians_of_class, guardians_of_users, staff_department, all_users, compound | chk_notification_event_audience_kind_known; the resolver Record. |
PUSH_PLATFORM | android, ios, web | chk_notification_push_token_platform_known; the FCM platform block. |
PUSH_TOKEN_INVALIDATION_REASON | unregistered, invalid_argument, user_logout, replaced, cap_exceeded | chk_notification_push_token_reason_known. |
There is deliberately no created delivery status. A delivery row does not exist before it is queued: the fan-out worker writes queued and queued_at in the same INSERT. A created default with no writer for created -> queued strands every delivery in the system, because the channel worker's claim is WHERE status = 'queued', which matches nothing — and a zero-row claim is indistinguishable from "another worker holds it", so every job reports success having sent nothing.
5.2 Tables
notification_event
One row per business event that somebody should be told about. Written by NotificationService.send() inside the caller's transaction.
| Column | Type | Nullable | Default | Index/Constraint | Relation | Notes |
|---|---|---|---|---|---|---|
id | serial | No | generated | PK | — | Internal only. Never crosses an API boundary. |
public_id | uuid | No | uuid7() via $defaultFn | UNIQUE | — | The external identifier. v7, so it sorts by creation. |
source_module | varchar(64) | No | — | Half of notification_event_source_dedupe_key | — | Which module raised it: auth, support, school. |
kind | varchar(100) | No | — | notification_event_kind_idx | — | The registry key, e.g. auth.password_reset. Not an FK — the registry is code. |
category | varchar(40) | No | — | chk_notification_event_category_known, notification_event_category_idx | — | From the registry, never from the caller. |
priority | varchar(10) | No | 'normal' | chk_notification_event_priority_known | — | From the registry. |
dedupe_key | varchar(64) | No | — | Half of notification_event_source_dedupe_key | — | A SHA-256 prefix, never the raw domain string. |
audience | jsonb | No | — | chk_notification_event_audience_kind_known | — | The AudienceSpec union. A specification, never a list. |
variables | jsonb | No | {} | chk_notification_event_variables_object, chk_notification_event_variables_no_credentials | — | Shared template variables. Never a credential. |
action_url | text | Yes | NULL | chk_notification_event_action_is_complete | — | Stored, never interpreted here. Validated at write and again at render. |
action_label | varchar(60) | Yes | NULL | Same CHECK | — | Paired with action_url. |
requested_channels | jsonb | No | — | chk_notification_event_channels_known, chk_notification_event_channels_non_empty | — | What the caller asked for, before preferences. |
scheduled_for | timestamptz | Yes | NULL | — | — | NULL means as soon as possible. |
occurred_at | timestamptz | No | — | notification_event_occurred_at_idx (desc) | — | When the thing happened, not when the row was written. |
fanout_claimed_at | timestamptz | Yes | NULL | chk_notification_event_completion_implies_claim, notification_event_sweep_idx | — | The fan-out claim, taken before the work. |
fanout_cursor | varchar(64) | Yes | NULL | — | — | The last users.id of the last committed batch. |
fanned_out_at | timestamptz | Yes | NULL | chk_notification_event_fanout_complete | — | Completion, written after. |
recipient_count | integer | Yes | NULL | Same CHECK | — | A COUNT(*), never an accumulator. |
unresolved_count | integer | Yes | NULL | — | — | How many ids in the audience specification name nothing that exists. Written once at completion by an existence probe per audience kind. It separates the two causes of recipient_count = 0: a real group that is empty, which is fine, and a stale or mistyped id, which means nobody was ever going to receive it. |
cancelled_at | timestamptz | Yes | NULL | Part of the sweep predicate | — | Set by the cancel route. |
outbox_event_id | integer | Yes | NULL | — | Soft reference to outbox_events | Deliberately not an FK: dispatched outbox rows are purged, and a cascade would delete notification history with them. |
created_at | timestamptz | No | now() | notification_event_sweep_idx | — | NOT NULL and monotonic, which is why the orphan sweep keys on it. |
Why the claim and the completion are two columns. One column cannot be both. Used as a claim it must be set before the work, when the recipient count is unknown; used as a completion marker it is set after, leaving no claim and letting 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 variables must never carry a secret. A rendered password-reset email embeds a live single-use token. This column is retained and readable by anyone holding NotificationHistory_READ, which is not a superadmin-only permission — so a token here turns an operational read permission into account takeover: list the history filtered to auth.password_reset, copy the token, complete the reset as that user. Security kinds pass { secretRef: "<type>:<publicId>" } instead. The CHECK is a backstop, not the control.
notification_recipient
One row per (event, person). The unit a notification centre lists and a person marks read.
| Column | Type | Nullable | Default | Index/Constraint | Relation | Notes |
|---|---|---|---|---|---|---|
id | serial | No | generated | PK | — | — |
public_id | uuid | No | uuid7() | UNIQUE | — | What the centre returns and POST .../{publicId}/read addresses. |
event_id | integer | No | — | Leads notification_recipient_event_user_key | notification_event.id, ON DELETE CASCADE | Needs no separate index — the composite unique leads with it. |
user_id | uuid | Yes | NULL | notification_recipient_user_idx, notification_recipient_unread_idx | users.id, ON DELETE SET NULL | NULL once the person is hard-erased. Its own send-time branch. |
user_public_id_snapshot | uuid | No | — | Second half of the composite unique | — | Survives the erasure that nulls user_id. A nullable column cannot carry a meaningful unique constraint, because NULL is not equal to NULL. |
audience_role_id | integer | Yes | NULL | notification_recipient_audience_role_idx | role.id, ON DELETE SET NULL | The role this person was resolved through. NULL means not role-scoped. |
variables | jsonb | No | {} | — | — | The per-recipient overlay, merged over the event's own. This is what makes Hello {{name}} possible. |
read_at | timestamptz | Yes | NULL | notification_recipient_unread_idx (partial) | — | NULL means unread. |
created_at | timestamptz | No | now() | notification_recipient_created_at_idx | — | Retention drives from here, bottom-up. |
Why user_id is SET NULL and not CASCADE. A cascade would erase, along with the person, every record that a security notification was ever sent to them — at exactly the moment that record is most likely to be needed, because production deletion is soft and a hard delete fires only on a genuine erasure request. So the person goes and the record stays, identified only by a snapshot of an id that no longer resolves. What survives is: that a notification of a given kind was sent, to a subject now identified only by an orphaned uuid, and when. What does not survive is anything personal — an erasure hook nulls destination_hint, rendered_title and rendered_body on every delivery beneath the row in the same transaction, because those carry a masked address and, for kinds that persist their rendering, the pupil's name. This is a deliberate trade; if total erasure is ever required, this becomes CASCADE and the audit evidence goes with it.
Why audience_role_id exists at all. The notification centre filters on it against the caller's active role, so a teacher who is also a parent, viewing as Guardian, does not see staff-audience notifications in the same list. Without it the centre's only predicate is user_id = actor.id, and a dismissed teacher who is still a parent keeps a live users row — so they would keep reading disciplinary and roster notifications after dismissal. ON DELETE SET NULL rather than the default NO ACTION, because a NO ACTION key makes role deletion impossible once any recipient references it.
The uniqueness on (event_id, user_public_id_snapshot) is declared as a table unique() and not a uniqueIndex. drizzle-kit emits every ADD CONSTRAINT ... FOREIGN KEY before every CREATE INDEX, so a composite key whose target is only an index fails on a clean database with 42830 — while a hand-ordered probe passes, because the probe uses dependency order and the migration uses generator order.
notification_delivery
One row per (recipient, channel, provider target). The delivery state machine, and the highest-growth table in the design.
| Column | Type | Nullable | Default | Index/Constraint | Relation | Notes |
|---|---|---|---|---|---|---|
id | serial | No | generated | PK | — | — |
public_id | uuid | No | uuid7() | UNIQUE | — | The only thing a queue payload carries. |
recipient_id | integer | No | — | notification_delivery_recipient_idx | notification_recipient.id, ON DELETE CASCADE | — |
channel | varchar(16) | No | — | chk_notification_delivery_channel_known | — | email, sms, push or in_app. |
provider_target_id | varchar(64) | Yes | NULL | Partitions the two partial uniques | Push token public_id | NULL for every single-target channel. |
status | varchar(24) | No | — | chk_notification_delivery_status_known | — | Written only by DeliveryRecorderService and the fan-out worker's insert. |
provider | varchar(32) | Yes | NULL | notification_delivery_provider_message_idx | — | resend, aakash, sparrow, mock, fcm, in_app. |
provider_message_id | varchar(500) | Yes | NULL | Same partial index | — | 500 because FCM message names are long, and raising 22001 after a successful send is the worst possible time. |
destination_hint | varchar(64) | Yes | NULL | — | — | Masked — 9779●●●●●123, j●●●@example.com. Never the full address. |
rendered_title | text | Yes | NULL | — | — | Written at send, only for kinds whose registry entry sets persistRendered. Never for a security kind. |
rendered_body | text | Yes | NULL | — | — | Same. The notification centre has no other source. |
attempts | integer | No | 0 | chk_notification_delivery_counters_non_negative | — | Drives termination. Incremented by a failure and by a lease reclaim. |
failure_count | integer | No | 0 | Same CHECK | — | Provider failures only. |
lease_expiry_count | integer | No | 0 | Same CHECK | — | Lease reclaims only. Kept apart so "the provider is failing" and "workers are dying" stay distinguishable. |
last_error | text | Yes | NULL | — | — | A code from a fixed table, truncated to 500. Redacted, not merely truncated. |
claimed_at | timestamptz | Yes | NULL | chk_notification_delivery_lease_is_complete | — | — |
lease_expires_at | timestamptz | Yes | NULL | Same CHECK, notification_delivery_lease_idx | — | The reaper's scan column. |
claimed_by | varchar(64) | Yes | NULL | — | — | pid-plus-random worker identity, truncated at 64. |
next_attempt_at | timestamptz | Yes | NULL | notification_delivery_retry_idx | — | Stored, not recomputed per scan. |
replay_of_delivery_id | integer | Yes | NULL | notification_delivery_replay_of_idx, excluded from both uniques | Self-reference, ON DELETE SET NULL | A replay inserts rather than resetting the original. |
queued_at | timestamptz | No | — | notification_delivery_queued_idx (partial) | — | Written in the same INSERT as status = 'queued'. |
sent_at | timestamptz | Yes | NULL | chk_notification_delivery_sent_at_present, chk_notification_delivery_sent_before_delivered | — | — |
delivered_at | timestamptz | Yes | NULL | chk_notification_delivery_delivered_at_present | — | — |
failed_at | timestamptz | Yes | NULL | chk_notification_delivery_failed_at_present | — | Cleared when a backoff elapses, so a "failed once then succeeded" row does not over-count. |
skipped_at | timestamptz | Yes | NULL | chk_notification_delivery_skipped_at_present | — | Biconditional with the four skipped_* statuses. |
created_at | timestamptz | No | now() | notification_delivery_created_at_idx | — | Retention and backfill both key on it. |
Two partial uniques, exact complements, both excluding replays.
notification_delivery_single_target_key
UNIQUE (recipient_id, channel)
WHERE provider_target_id IS NULL AND replay_of_delivery_id IS NULL
notification_delivery_multi_target_key
UNIQUE (recipient_id, channel, provider_target_id)
WHERE provider_target_id IS NOT NULL AND replay_of_delivery_id IS NULLprovider_target_id IS NULL and IS NOT NULL partition the space, so every non-replay row is covered by exactly one and none by both. Replay rows fall outside both on purpose — that is what lets a replay of a dead push delivery insert at all, since it shares (recipient, channel, token) with the row it replaces. A double replay is prevented one level up, by NotificationFailureService compare-and-setting job_failures.replayed_at in its own UPDATE before ever reaching this insert.
Why push fans out per token. One row cannot hold two devices with different outcomes: token A returning UNREGISTERED and token B delivering is two statuses and two provider message ids, and collapsing them discards exactly the per-target information the SMS adapter is written to preserve.
Why the sent_at CHECK is channel-scoped. in_app is written inline by the fan-out worker. There is no send step, so it legitimately reaches delivered with sent_at NULL. The unscoped form of that implication rejects the row — and because the fan-out writes recipient, delivery and outbox rows in one transaction, the rejection aborts the entire batch.
Why the skipped CHECK is an explicit IN list and not LIKE 'skipped_%'. A backslash inside a drizzle sql template is a JavaScript NonEscapeCharacter, so the cooked string Postgres receives contains a bare _ — which is LIKE's single-character wildcard, not a literal underscore. The constraint would still have looked right under \d+ and would still have passed a probe written from the same assumption.
Why notification_delivery_recipient_idx is not redundant. Both uniques are partial, and Postgres can use a partial index only where the query implies its predicate. The notification centre's EXISTS, the unread count, the retention join and the ON DELETE CASCADE referential scan all filter on recipient_id alone and mention neither provider_target_id nor replay_of_delivery_id — so without this index none of them has one, on the largest table here.
notification_preference_set
The concurrency token for a person's whole preference set.
| Column | Type | Nullable | Default | Index/Constraint | Relation | Notes |
|---|---|---|---|---|---|---|
user_id | uuid | No | — | PK | users.id, ON DELETE CASCADE | One row per person, at most. |
version | integer | No | 1 | — | — | Compare-and-set token for the whole matrix. |
updated_at | timestamptz | No | now(), $onUpdateFn | — | — | — |
A "turn off email everywhere" write touches one row per category, so two devices saving at once interleave into a mixed state with no error. A version on the set is the smallest thing that makes the write atomic from the client's point of view. A person who has never saved has no row here: GET reports version: 0 for that state and the first PUT sends 0 and upserts at 1. Absence is a real state and gets a real token, rather than a row lazily created on read — which would make a read a write.
notification_channel_preference
The global per-channel switch — "no email at all".
| Column | Type | Nullable | Default | Index/Constraint | Relation | Notes |
|---|---|---|---|---|---|---|
user_id | uuid | No | — | PK part 1 | users.id, ON DELETE CASCADE | — |
channel | varchar(16) | No | — | PK part 2, chk_notification_channel_preference_channel_known | — | — |
enabled | boolean | No | — | — | — | — |
created_at | timestamptz | No | now() | — | — | — |
updated_at | timestamptz | No | now(), $onUpdateFn | — | — | — |
Implementing the global switch as one row per category would materialise a list, which is the same snapshot defect the audience specification exists to avoid one table over: a user mutes email in March, a transport category ships in June, that category has no row, absence means the code default, and they start receiving transport email having explicitly turned email off. Two levels resolved in order has no such state, and needs no sentinel category — a magic '__all__' value would have to be permitted by the CHECK, special-cased by the resolver, and remembered by every category added later.
notification_preference
The per-category override.
| Column | Type | Nullable | Default | Index/Constraint | Relation | Notes |
|---|---|---|---|---|---|---|
user_id | uuid | No | — | PK part 1 | users.id, ON DELETE CASCADE | — |
category | varchar(40) | No | — | PK part 2, chk_notification_preference_category_known | — | Generated from the same constant as the event's category CHECK. |
channel | varchar(16) | No | — | PK part 3, chk_notification_preference_channel_known | — | — |
enabled | boolean | No | — | — | — | — |
created_at | timestamptz | No | now() | — | — | Not decoration: "when did this person opt out of marketing" is a consent question, and updated_at is overwritten by every later unrelated toggle. |
updated_at | timestamptz | No | now(), $onUpdateFn | — | — | — |
Absence means the category's code default, so the table stays proportional to real choices rather than to categories times channels times users. Enforcing the category vocabulary at one end and not the other would let an event persist in a category no user is able to express a preference for — the event delivers to nobody through an undefined default, and the one control that could have surfaced it is rejected by this very CHECK. Neither this table nor the channel table can override an unsuppressible kind: that property lives on the registry entry, not on any column here and not on anything a caller passes.
notification_push_token
A push credential, one row per token per device per user.
| Column | Type | Nullable | Default | Index/Constraint | Relation | Notes |
|---|---|---|---|---|---|---|
id | serial | No | generated | PK | — | — |
public_id | uuid | No | uuid7() | UNIQUE | — | What a delivery's provider_target_id carries, and what DELETE addresses. |
user_id | uuid | No | — | notification_push_token_active_idx (partial), notification_push_token_user_idx | users.id, ON DELETE CASCADE | — |
user_device_id | uuid | Yes | NULL | notification_push_token_device_idx | user_device.id, ON DELETE SET NULL | NULL for web push, which has no app installation. |
token | text | No | — | notification_push_token_active_token_key (partial, WHERE is_active) | — | Never echoed back in any response. |
platform | varchar(10) | No | — | chk_notification_push_token_platform_known | — | android, ios or web. Decides which FCM block is applied. |
is_active | boolean | No | true | chk_notification_push_token_invalidation_is_complete | — | — |
last_used_at | timestamptz | Yes | NULL | — | — | Touched by a same-user re-registration. |
invalidated_at | timestamptz | Yes | NULL | Same CHECK, notification_push_token_invalidated_idx | — | Biconditional with is_active = false. |
invalidated_reason | varchar(40) | Yes | NULL | chk_notification_push_token_reason_known, chk_notification_push_token_reason_needs_invalidation | — | — |
created_at | timestamptz | No | now() | — | — | — |
updated_at | timestamptz | No | now(), $onUpdateFn | — | — | — |
Why not a column on user_device. Three reasons, each independently sufficient. sessions.user_device_id foreign-keys user_device, so a push credential on that table sits directly in the session join path. A token rotates independently of the device — FCM invalidates asynchronously and the app re-registers — so one device produces many tokens over its life, and a single column would overwrite the invalidation history that answers "why did this parent stop receiving notifications". And web push has no user_device row at all, because a browser has no installation.
Why a token arriving for a second user is invalidated, not reassigned. Reassigning mutates the one row and destroys the record that the first user ever held it. Worse, reassignment on its own is a denial of service: anyone who learns a victim's token — a shared school tablet, a resold device, a sibling — can POST it and silently stop the victim's push, including the security notifications that are otherwise unsuppressible. So registration invalidates the existing row with reason replaced and inserts a fresh one, and the caller must present the token on their own session.
The unique on token is partial on is_active, so an invalidated token's history survives alongside the live row that replaced it. A full unique would force reassignment-in-place, which is the behaviour the table exists to avoid. The invalidation CHECK is biconditional, because a one-directional form permits (is_active = false, invalidated_at = NULL) — "deactivated, cause and time unknown" — which is precisely the state the invalidation columns exist to prevent, and which the partial index would then hide from every read.
notification_template
An operator override of a template. Not the template itself.
| Column | Type | Nullable | Default | Index/Constraint | Relation | Notes |
|---|---|---|---|---|---|---|
id | serial | No | generated | PK | — | — |
public_id | uuid | No | uuid7() | UNIQUE | — | — |
kind | varchar(100) | No | — | Part of notification_template_kind_channel_locale_key | — | Must match a registry key. Not an FK — the registry is code. Validated by isKnownKind. |
channel | varchar(16) | No | — | Same unique, chk_notification_template_channel_known | — | — |
locale | varchar(10) | No | 'en' | Same unique | — | Matched against users.locale at render. |
subject | text | Yes | NULL | — | — | Email only. NULL for SMS, push and in-app. |
body | text | No | — | chk_notification_template_body_present | — | length(btrim(body)) > 0. |
is_active | boolean | No | true | — | — | An inactive row is not loaded by the renderer. |
version | integer | No | 1 | — | — | Optimistic concurrency, incremented by the application. |
updated_by | uuid | Yes | NULL | notification_template_updated_by_idx | users.id, ON DELETE SET NULL | — |
created_at | timestamptz | No | now() | — | — | Primary sort, with id as tie-break. |
updated_at | timestamptz | No | now(), $onUpdateFn | — | — | — |
This table is not seeded, and empty is the correct state. Seeding the registry into it would create the rename-then-duplicate hole a bare onConflictDoNothing produces: an operator renames a seeded row, the next deploy's insert collides with nothing, and a duplicate appears silently. The table holds only rows somebody deliberately created.
Why version is not a trigger and not a timestamp. Not a timestamp, because updated_at is millisecond-resolution and two commits inside one millisecond produce the identical token, so a stale write passes its own check. Not a trigger, despite a trigger being the usual answer, because drizzle-kit does not generate them: a hand-written trigger appears in neither the snapshot nor the .down.sql, DROP TABLE removes the trigger and not its function, and the second up of an up -> down -> up then dies on 42723 creating a function the down never dropped. So the application increments it, and zero rows returned is the conflict.
The body CHECK uses btrim, which strips spaces only — a body of just a tab or a newline still passes. That is deliberate: the constraint exists to catch the empty string a form submits, not to be a whitespace validator, and a POSIX class here would invite the backslash-in-a-template trap for no gain.
notification and notification_read — the admin operational feed
A different delivery model, owned by notification-feed. Documented here because this module's retention worker is the only thing that deletes from it.
| Column | Type | Nullable | Default | Index/Constraint | Notes |
|---|---|---|---|---|---|
id | serial | No | generated | PK | — |
public_id | uuid | No | uuid7() | UNIQUE | — |
outbox_event_id | integer | No | — | UNIQUE | The whole dedupe story. Outbox dispatch is at-least-once, so without it a redelivery is a second notification for one thing that happened once. Not an FK — dispatched outbox rows are purged. |
kind | text | No | — | — | The contract's event kind, e.g. feedback.submitted. |
permission | text | No | — | notification_permission_idx | The permission a viewer must hold. The READ permission of the screen the event belongs to, and nothing weaker. |
aggregate_id | text | No | — | — | Public id of the thing that changed. |
summary | text | No | — | — | Already-safe summary text. Never a raw payload. |
sound_class | text | No | — | — | How the panel should announce it. |
occurred_at | timestamptz | No | — | notification_occurred_at_idx (desc) | The list is always newest-first; the permission filter rides along. |
created_at | timestamptz | No | now() | — | — |
notification_read is (notification_id, user_id) primary-keyed with a read_at default of now(), plus notification_read_user_idx on user_id; both foreign keys cascade. It is a join table rather than a read_at column, because one notification row is seen by everyone holding its permission and each of them reads it — or does not — separately. 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.
Why fan-out on read here. One row per event, not per recipient. Fan-out on write would mean resolving every holder of a permission at publish time and inserting a row each: a query per event over user_role and role_permission, rows that multiply by staff count, and — the reason it is actually wrong — a snapshot, so someone granted the permission an hour later would never see the event and someone who lost it would keep seeing it.
job_failures — the shared dead-letter table
Not owned here, but read and written by the channel workers and the replay screen. The columns that matter to this module: public_id, queue_name, job_name, job_id, payload_ref (jsonb, Record<string, string>), actor_id, attempts, last_error, replayed_at, replayed_by, replay_job_id, failed_at. A notification channel failure writes payload_ref as { notificationDeliveryPublicId, channel }; an operational-email failure writes { aggregateType, aggregateId, eventType }, omitting absent keys rather than writing nulls, because payload_ref is jsonb an operator reads by eye and a row of nulls is indistinguishable from a row nobody populated.
payload_ref, never the payload. A rendered notification payload carries the message body, and a password-reset body embeds a live single-use token — storing it behind a read permission turns that permission into account takeover. The reference answers every operational question without carrying a secret.
5.3 Relationship Diagram
The two clusters do not touch. NOTIFICATION and NOTIFICATION_READ are the read-time fan-out; everything else is the write-time one.
6. Services and Responsibilities
6.1 NotificationService
The public interface. Every module that needs to tell somebody something calls this and nothing else.
| Method | Called By | Reads | Writes | Side Effects | Errors |
|---|---|---|---|---|---|
send(executor, input) | Any module, inside its own transaction. Currently AuthEmailService at four call sites. | TEMPLATE_REGISTRY | notification_event, outbox_events | Schedules notification.fan_out on NOTIFICATION_FANOUT through the outbox | NOTIFICATION_TEMPLATE_NOT_FOUND, NOTIFICATION_CHANNEL_UNKNOWN, NOTIFICATION_AUDIENCE_TOO_LARGE, NOTIFICATION_ACTION_URL_SCHEME_FORBIDDEN, NOTIFICATION_DEDUPE_REQUEST_ID_REQUIRED, NOTIFICATION_DEDUPE_REQUEST_ID_FORBIDDEN, NOTIFICATION_EVENT_NOT_FOUND |
It writes exactly two rows and returns. It resolves no audience, renders no template, reads no preference and contacts no provider — which is what makes it safe to call from inside a business transaction of any size. A three-thousand-parent announcement resolved here would hold the caller's transaction open for the duration and put a notification concern on the critical path of the thing being notified about.
executor must be a transaction, not the root handle. Passing the root handle compiles and appears to work, and silently gives up the atomicity the method exists to provide: the event row would commit independently of the business write it describes. There is no way to detect that from inside, which is why it is stated loudly rather than checked.
Validation order, all before any write:
assertKnownKind— the kind exists in the registry, or400 NOTIFICATION_TEMPLATE_NOT_FOUND.assertChannelsAreBuildable— every requested channel has a builder for that kind, or400 NOTIFICATION_CHANNEL_UNKNOWN. The alternative is a delivery row that terminatesskipped_no_template— correct, but invisible to the caller, who asked for a channel and got silence. A caller's mistake belongs in the caller's400.assertAudienceIsWithinCaps— an explicit list at mostNOTIFICATION_MAX_EXPLICIT_RECIPIENTS(500), a compound between 1 andNOTIFICATION_MAX_COMPOUND_MEMBERS(10), or400 NOTIFICATION_AUDIENCE_TOO_LARGE.compoundis depth-1 by type and unbounded in breadth without this: a caller could send ten thousandall_usersmembers, depth-1 and type-legal, expanding to ten thousand keyset walks over every person in the school.assertActionUrlIsSafe—https:or an app-relative path beginning with a single/, or400 NOTIFICATION_ACTION_URL_SCHEME_FORBIDDEN.buildDedupeKey— enforces the kind's dedupe policy.
The insert uses onConflictDoUpdate with a no-op SET, not onConflictDoNothing. A bare onConflictDoNothing().returning() yields zero rows on the dedupe path, leaving the method with no event id for the outbox row it must write in the same transaction, and no id to return. The no-op update makes RETURNING always produce the row, whether it was inserted now or already existed. created is then derived as row.fanoutClaimedAt === null.
The dedupe key is hashed and bounded. A key built from a title or a path overflows varchar(64) and raises 22001 inside the caller's own business transaction, turning a notification concern into a failed write of the thing being notified about. Hashing also normalises, so "foo " and "foo" cannot become two events. The material is NUL-separated so ("a","bc") and ("ab","c") cannot hash alike, and the separator is written as an escape rather than a literal byte because a raw NUL survives an editor round-trip only by luck.
The policy is the kind's, not the caller's. collapse folds only domain facts, so a repeat is a duplicate. repeatable folds the caller's requestId, so a second password reset for one user is a second event. Getting this backwards in either direction is a real defect: collapse on a reset denies account recovery permanently, repeatable on a reminder sends it twice.
6.2 AudienceResolverService
| Method | Called By | Reads | Writes | Side Effects | Errors |
|---|---|---|---|---|---|
resolvePage(audience, cursor, limit) | NotificationFanoutProcessor | users, user_role, role, students, guardians, staff, student_class_enrollments, classes, sections, grades, student_guardian, departments | Nothing | None | NOTIFICATION_AUDIENCE_KIND_UNKNOWN |
The spec is re-evaluated for every page rather than materialised once, which is the whole reason the audience is stored as a spec. A pupil enrolled between page two and page five is included; one who left is not.
Keyset, never offset. Ordered by users.id and filtered u.id > cursor. An offset walk over an audience being written to re-reads and skips rows as the underlying set shifts.
Liveness lifts as a pair. users.deleted_at IS NULL is applied in the outer query, and each profile table's own deleted_at inside its fragment. A liveness filter over a join lifts together or none does — the split version has shipped twice in this repository, and the symptom was a screen permanently empty with no error anywhere. That is still only the first pass: the channel workers re-check liveness at send time, because an event scheduled twenty-four hours out fans out before a deletion an hour later.
can_login is deliberately absent. It means "may authenticate" and nothing more. A pupil with no portal account still has a name on a register and a guardian who must be told things, so gating delivery on it made every such person invisible to every audience — and most primary-age pupils are exactly that person. Deletion decides who is reachable; sign-in access decides who can log in.
| Audience kind | Resolved through | Role id yielded | Liveness applied |
|---|---|---|---|
users | users.id = ANY(...) | NULL | Outer query only |
role | user_role joined to role.public_id | The named role's id | Outer query only |
class | student_class_enrollments with status = 'active', joined to classes and students | Correlated lookup of the person's student-scoped role | students.deleted_at IS NULL |
section | The same, additionally joined through sections | student scope | students.deleted_at IS NULL |
grade | The same, additionally joined through grades | student scope | students.deleted_at IS NULL |
guardians_of_class | student_guardian from the class's active enrolments | guardian scope | students.deleted_at, guardians.deleted_at |
guardians_of_users | student_guardian from the named students | guardian scope | students.deleted_at, guardians.deleted_at |
staff_department | staff joined to departments.public_id | NULL | staff.deleted_at IS NULL |
all_users | Every row in users | NULL | Outer query only |
compound | A SQL UNION of up to ten of the above | Per member | Per member |
A compound audience is a union deduplicated by users.id, unioned in SQL rather than merged in memory — resolving each member separately would break the keyset, because each member would have its own cursor.
fragmentFor is a Record over the audience kind, so a new kind with no resolver is a compile error. The runtime symptom of a missing branch is a fan-out that completes having notified nobody. The CHECK constraint on audience->>'kind' guards the data path; this guards the code path, and neither alone is sufficient.
The guardian audiences resolve through student_guardian, never from a caller-supplied user list. A guardian audience that accepted arbitrary user ids would let a caller address one family's notification to another.
roleForScope is a correlated subquery rather than a join, so a person holding no matching role yields NULL — "not role-scoped" — instead of dropping out of the audience entirely. A join would silently shrink a class announcement to the pupils who happen to have been granted the student role.
uuidArray builds ARRAY[$1, $2]::uuid[] rather than interpolating the array straight into a sql template. Drizzle renders a JS array in a template as a record — ($1, $2) — and cannot cast type record to uuid[] is what comes back. Every element is still a bound parameter, so an id list can never be concatenated into SQL. An empty list yields ARRAY[]::uuid[], which is valid and matches nothing — the correct answer for an audience naming nobody.
The per-recipient overlay is { name: full_name ?? "there" }. full_name is a generated column so it is never absent for a real row, but it is nullable in the type, and a null here would render the literal text {{name}} to a parent.
6.3 PreferenceResolverService
| Method | Called By | Reads | Writes | Side Effects | Errors |
|---|---|---|---|---|---|
loadForUsers(executor, userIds) | NotificationFanoutProcessor | notification_channel_preference, notification_preference | Nothing | None | None |
isEnabled({ snapshot, category, channel, unsuppressible }) | NotificationFanoutProcessor | The snapshot and CATEGORY_DEFAULTS | Nothing | None | None |
defaultsFor(category) | NotificationPreferencesService | CATEGORY_DEFAULTS | Nothing | None | None |
loadForUsers runs two queries per batch, not two per person. Five hundred recipients times four channels resolved one at a time is two thousand round trips per batch.
Resolution order, and the order is the design:
- Unsuppressible — the kind says so. Nothing below can turn it off.
- Per-category override — the most specific thing the person said.
- Global channel switch — "no email at all".
- Category default, from code.
Step 1 reads a property of the kind, not a column any caller sets. A caller-settable bypass with a CHECK constraint is not a control, because the same caller supplies both operands — it would let any module send a marketing blast as system past every opt-out in the school, at real SMS cost, with the constraint satisfied. Steps 2 and 3 are in that order because a person who turned email off globally and then turned assignment email back on meant the second thing.
Category defaults:
| Category | sms | push | in_app | Why | |
|---|---|---|---|---|---|
security | on | on | on | on | Account safety. The unsuppressible kinds inside it cannot be turned off at all. |
system | on | off | on | on | Operational notices; SMS is metered. |
assignment | off | off | on | on | High volume, low urgency per item. |
announcement | on | off | on | on | Deliberate, occasional, worth an email. |
message | off | off | on | on | Person-to-person; the app is the natural surface. |
payment | on | off | on | on | Money needs a durable copy. |
attendance | off | off | on | on | Daily. |
event | off | off | on | on | Calendar-shaped. |
marketing | off | off | off | off | Opt-in, not opt-out. |
SMS is off by default across the board except security, because it is metered and a school-wide default of "on" is a bill nobody chose. The final fallback returns false rather than asserting: if a category ever reaches the resolver without an entry, silently sending is the wrong failure and silently not sending is the safe one.
Defaults live in code and not as seeded rows, because seeding one row per person per category per channel is nine categories times four channels times every user in the school, all of it saying "the default" — and absence meaning the default lets a default change take effect for everyone who never expressed an opinion, which is the point of having one.
6.4 TemplateRendererService
| Method | Called By | Reads | Writes | Side Effects | Errors |
|---|---|---|---|---|---|
loadOverrides(executor, kind) | NotificationFanoutProcessor (in-app only), ChannelSendService | notification_template where is_active | Nothing | None | None |
render({ kind, channel, locale, variables, overrides }) | Both of the above | The override map and TEMPLATE_REGISTRY | Nothing | Logs once per bad override key | Returns { ok: false, reason: "no_template" }; never throws |
loadOverrides runs once per fan-out batch. Rendering three thousand recipients across three channels with a per-render lookup is nine thousand identical SELECTs for one event.
Render order: an active override for (kind, channel, locale); if it throws or leaves a variable unresolved, log once and fall through; then the registry builder, which always exists and always compiles. The fall-through is what makes an operator's bad edit a cosmetic problem rather than a silenced password reset, and it is also why the registry is never seeded into the table — the code default has to be a real fallback, not a row somebody can delete.
Interpolation is {{name}} against a flat record. No expressions, no property paths, no function calls. An admin-editable template that can evaluate expressions is a code execution surface behind a CRUD permission. Three further properties matter:
- An unresolved placeholder suppresses the send, returning
null, rather than delivering the literal text{{name}}to a parent — which would be metered, paid for, and invisible to every gate. - The replacement is a callback, not a replacement string.
String.prototype.replacewith a string second argument treats$&,$',$`and$1as substitution patterns, so a variable value containing$'would splice in the remainder of the template — an information leak driven by data. - Email bodies are HTML, so every interpolated value is passed through
escapeHtml, because a variable is the one part of a template a caller controls. Email subjects additionally pass throughescapeHeader, which collapses CR and LF:escapeHtmlis an HTML-body escape and does not touch them, and a newline in an interpolated subject splits the header.
A builder that throws is a code defect, not an operator's — it is logged with the stack and the channel is skipped, rather than failing the whole batch. warnOnce keys on kind:channel:locale, so one bad template is one log line and not three thousand identical ERRORs.
6.5 DeliveryRecorderService
The only writer of notification_delivery.status outside the fan-out worker's initial insert.
| Method | Called By | From -> To | Writes | Notes |
|---|---|---|---|---|
claim(deliveryPublicId, workerId) | ChannelSendService | queued -> processing | status, claimed_at, lease_expires_at, claimed_by | Returns the row, or null when another worker holds it. |
recordSent(id, details) | ChannelSendService | processing -> sent | status, sent_at, provider, provider_message_id, destination_hint; clears claimed_at, lease_expires_at, claimed_by, next_attempt_at, last_error | — |
recordInAppDelivered(id) | Available for the in-app path | processing -> delivered | status, delivered_at, provider = 'in_app'; clears the lease and retry state | Legitimately leaves sent_at NULL. |
recordFailure(delivery, failure) | ChannelSendService | processing -> failed or -> dead | status, failed_at, last_error, destination_hint, attempts, failure_count, next_attempt_at; clears the lease | Returns which terminal it chose. |
recordSkipped(id, reason, hint?) | ChannelSendService | processing -> skipped_* | status, skipped_at, destination_hint; clears the lease and next_attempt_at | Terminal, and not a failure. |
reclaimExpiredLeases(limit) | NotificationReaperProcessor | processing -> queued or -> dead | Increments attempts and lease_expiry_count; sets a backoff on requeue; clears the lease, failed_at, last_error | Two bounded UPDATEs, split on the attempt budget. |
requeueElapsedBackoffs(limit) | NotificationReaperProcessor | failed -> queued | status; clears failed_at, last_error, next_attempt_at | Bounded by a subquery, ordered by next_attempt_at. |
Every transition writes every column it owes. The table carries paired CHECKs — (claimed_at IS NULL) = (lease_expires_at IS NULL), and skipped-status implies skipped_at — so a transition that clears one half of a pair and not the other is rejected by Postgres inside a queue worker, and surfaces as a job failure rather than as anything traceable.
Every claim carries a lease. UPDATE ... SET status='processing' WHERE status='queued' is safe against a second worker and useless against a dead one. Without lease_expires_at and a reaper, a worker that dies after the provider call and before the result write leaves the row processing forever: no sweep looks at that status, the redelivered job's claim matches zero rows, and zero rows is indistinguishable from "another worker holds it" — so the job reports success. The message was sent and the record says it never was.
The reclaim increments attempts, and that is load-bearing. A reclaim that incremented nothing re-queues a persistently crashing send forever, because attempts >= max never fires. On SMS that is unbounded duplicate messages at metered cost. lease_expiry_count is kept separately from failure_count so "how often did workers die here" stays answerable apart from "how often did the provider refuse".
A non-retryable failure goes straight to dead with attempts incremented but no backoff: retrying "Not enough balance." or a dead FCM token is a guaranteed failure per attempt, and on SMS each one is a paid HTTP call.
Requeueing a backoff clears failed_at and last_error. Leaving them set would make any query written failed_at IS NOT NULL — the obvious form, given the column exists — permanently over-count failures, because a delivery that failed once and then succeeded would still carry both.
Backoff is min(NOTIFICATION_BACKOFF_MAX_SECONDS, base * 2^(failureCount - 1)), with the shift guarded above exponent 30: 2 ** 1024 is Infinity, and Infinity * base is NaN, which would write a NULL next_attempt_at and strand the row. Without the ceiling, a large attempt count means "never" — the row would be alive, never dead, and never retried.
6.6 SecretReferenceService
| Method | Called By | Reads | Writes | Side Effects | Errors |
|---|---|---|---|---|---|
stash(prefix, payload, ttl?) | AuthEmailService | — | Redis notification:secret:<prefix>:<uuid> with EX 900 | Returns "<prefix>:<id>" | None |
register(prefix, resolver) | A module at boot | — | An in-memory map | Throws on a duplicate prefix | Error |
hasReference(variables) | Callers checking shape | — | — | — | None |
resolve(variables) | ChannelSendService | Redis GETDEL, or a registered resolver | Deletes the Redis key | Returns merged variables, or null | None; logs a warning |
A rendered password-reset email embeds a live single-use token. Put it in notification_event.variables and it is stored in plaintext, retained, and readable behind NotificationHistory_READ — an operational read permission, not a superadmin one. The caller stashes the secret and stores only { secretRef }; the channel worker resolves it immediately before rendering, and the resolved values live in memory for the duration of one send.
Why Redis and not a row. The obvious design references a database row and cannot work here: the verification table stores token_hash and otp_hash, not the token — deliberately — so the raw value exists only in memory at the moment it is created and can never be re-derived from Postgres. What the vault buys: the secret is never in Postgres, so it is not in the retained history; it is never in a dead-letter row, a log line, or rendered_body; it expires on its own, so an abandoned notification does not leave a live token lying about; and it is deleted on read, so a redelivered job cannot re-render it. The cost is stated rather than hidden: if Redis is flushed between the write and the send, that one email cannot be rendered — the delivery records skipped_no_destination and the user asks again.
GETDEL, not GET then DEL. At-least-once delivery means the same job can arrive twice, and a secret that survives its first read is a live token available to a redelivery long after the first send.
Two resolution paths exist: the vault (the default, and what auth uses), and a registered resolver for a secret that can be re-derived from a row its owning module holds. A module registers one at boot; a switch here would make this service import the modules that already import it. An unregistered prefix falls through to the vault, and an unresolvable reference returns null — never a partial render. secretRef itself is dropped from the merged set so a template cannot interpolate the reference into a message. The reference is logged on failure; the resolved value never is.
6.7 ChannelSendService
Sends one delivery, for any channel.
| Method | Called By | Reads | Writes | Side Effects | Errors |
|---|---|---|---|---|---|
send(deliveryPublicId, expectedChannel) | All three channel processors | notification_delivery, notification_recipient, notification_event, users, notification_push_token, notification_template | Delivery status via the recorder; rendered_title/rendered_body when the kind opts in; notification_push_token.is_active on an invalidating FCM response | One provider call | Throws on a wrong-queue delivery and on a provider failure |
Order: claim, liveness, secret, render, address, send, record — for email, SMS and push alike. Three near-identical workers would be three places for the liveness re-check or the secret rule to be forgotten. The claim comes first so nothing else can be doing this work; liveness comes before rendering so a deleted person's name is never rendered; the secret is resolved last before sending and never persisted.
Branches, in order:
| Branch | Condition | Outcome |
|---|---|---|
| Not claimable | The claim returned zero rows | Returns { skipped: true, reason: "not_claimable" }. Not retried — a throw would re-drive work somebody else is doing. |
| Wrong queue | delivery.channel !== expectedChannel | Throws. It means a fan-out routed by the wrong map and every delivery of that channel is misrouted. |
| No context | The recipient/event join returned nothing | skipped_no_destination |
| Recipient not live | user_id IS NULL or deleted_at IS NOT NULL. Not can_login — see can_login is deliberately absent above | skipped_no_destination |
| Unknown kind | The registry no longer knows it | skipped_no_template |
| Secret unresolvable | resolve() returned null | skipped_no_destination — a reset link that goes nowhere is worse than no email |
| No template | render() returned ok: false | skipped_no_template |
| Provider skipped | unconfigured or no_destination | skipped_unconfigured or skipped_no_destination |
| Provider sent | — | recordSent, then a second UPDATE writing the rendering when persistRendered is set |
| Provider failed | — | recordFailure, then throws so BullMQ records it and onFailed writes a dead letter on the final attempt |
The context join is a LEFT JOIN on users, not an inner one: user_id is nullable after an erasure, and an inner join would silently drop the row instead of letting the liveness branch record why nothing was sent.
Per channel:
- Email. A vault-supplied
toEmailwins over the address on the user row, because change-email verification must reach the new address, which is not on the row yet — that is what is being verified. The override arrives through the vault, so an unverified address is never written to retained history either. - SMS. The same override applies as
toPhone, thennormalizePhone. An unnormalisable number is a missing destination, not a failed send: the provider would refuse it and bill nothing, and retrying cannot change the number. The body is truncated bytruncateToSegments(body, 3), which is encoding-aware — Devanagari is UCS-2 at 70 characters a segment, and a plain character count over-charges roughly twofold on Nepali text against a provider that bills per segment. - Push. Requires a
provider_target_id; the token row is re-read and must still beis_active.platformis read because FCM applies a different block per platform.actionUrlis re-derived throughsafeActionUrlOrNullrather than passed down, so a second caller cannot supply an unchecked URL by forgetting an argument. When the provider answers withinvalidateToken, the token row is deactivated in the same call — which is what stops every future send to that device failing forever, in a school that accumulates stale tokens continuously. - In-app. Never reaches a worker; the fan-out completes it inline.
Push reports no destinationHint: its target is a token whose public id is already on the row, and masking a token would say nothing an operator could use.
6.8 EmailChannelProvider
| Method | Called By | Reads | Writes | Side Effects | Errors |
|---|---|---|---|---|---|
isConfigured() | ChannelSendService, NotificationBackfillProcessor, the providers module | RESEND_API_KEY, RESEND_DEFAULT_FROM | Nothing | None | None |
send(message) | ChannelSendService, NotificationOperationalProcessor | — | Nothing | One Resend call via EmailClient | Returns a ProviderOutcome; never throws |
An adapter rather than a rewrite: packages/email already resolves the from-address, renders preview text and talks to Resend, and it is used by paths this module has not absorbed. Wrapping it keeps one implementation of "how an email is sent" while giving this module the same ProviderOutcome shape the SMS and push ports return, so the channel worker has one code path for three channels.
A mocked result is reported skipped, never sent — marking a mocked send as sent is the "looks healthy, delivers nothing" failure, and skipped_unconfigured is the state the backfill worker re-drives. A provider rejection is non-retryable, because it is a decision about this message — a bad address, a blocked domain — and retrying produces the same answer at the same cost. A throw is transport (DNS, TLS, a timeout, a 5xx) and is retryable; it is logged, because errorCode collapses every transport fault into one value and without the log a misconfigured DNS entry and a provider outage are indistinguishable on the record. The log carries the message only — never the body, and the address is masked, because a rendered password-reset email carries a live token.
EMAIL_ERROR_CODE is a fixed table: EMAIL_NO_ADDRESS, EMAIL_REJECTED (suffixed with the provider's own error name), EMAIL_TRANSPORT, EMAIL_UNKNOWN. maskEmail renders j●●●@example.com; the full set of addresses behind an operational permission is a contact-details export of every family in the school.
6.9 Provider construction — NotificationProvidersModule
Every provider is constructed whether or not its credentials exist, and isConfigured() answers the question. An unconfigured provider returns { outcome: "skipped", reason: "unconfigured" } rather than throwing, and the delivery goes to skipped_unconfigured — no attempt consumed, no dead letter, excluded from every failure metric. The path from send() down to the provider is identical in both states, which is what makes "correct the moment credentials arrive" a property of the design rather than a hope, and why the backfill worker can simply re-queue the backlog with no other change.
| Token | SMS_PROVIDER env | Constructed | Notes |
|---|---|---|---|
SMS_PROVIDER | aakash | AakashSmsProvider | AAKASH_SMS_TOKEN, AAKASH_SMS_BASE_URL, plus both timeouts. |
SMS_PROVIDER | sparrow | SparrowSmsProvider | SPARROW_SMS_TOKEN, SPARROW_SMS_FROM, SPARROW_SMS_URL. |
SMS_PROVIDER | anything else | MockSmsProvider | Records and sends nothing. |
PUSH_PROVIDER | fcm | FcmProvider | Accepts either FIREBASE_SERVICE_ACCOUNT_BASE64 or the three-field form (FIREBASE_CLIENT_EMAIL, FIREBASE_PRIVATE_KEY, FIREBASE_PROJECT_ID), and logs an explicit error on a credential/project mismatch — left undetected that is a bare auth error at send time with nothing pointing at the cause. |
PUSH_PROVIDER | anything else | MockProvider | — |
NOTIFICATION_REQUIRED_CHANNELS is the guard against silent degradation: a channel named there and not configured refuses to boot. It is per channel, deliberately — a single global "require every provider" flag set true in production with credentials not yet issued fails the first deploy outright, the whole API down rather than one channel degraded. A mock provider reports itself configured, so requiring a channel and mocking it in production is also a hard throw: that is the one combination that looks healthy and delivers nothing.
Outside that, a mock logs one WARN at boot saying messages are recorded and not sent, and an unconfigured provider logs one WARN saying deliveries will be recorded skipped_unconfigured and re-driven automatically. Once at boot, not once per send — a per-send warning on an unconfigured machine is a log nobody reads, which is exactly how a real failure gets buried. The admin delivery-history screen surfaces the skipped_unconfigured count as a first-class state, so the condition is visible without reading a log at all.
6.10 NotificationRealtimePublisherService
| Method | Called By | Reads | Writes | Side Effects | Errors |
|---|---|---|---|---|---|
publishInApp(recipients, event) | NotificationFanoutProcessor, after each batch commits | — | Nothing durable | PUBLISH realtime:user:<userId> | Never throws; logs at error per recipient |
Postgres is the authoritative unread store — notification_recipient.read_at IS NULL. This stream is an enhancement that lets an open session update itself without polling, and every screen must still be correct on a plain refresh with Redis unreachable. It needs no durable record of its own, unlike the admin stream's publisher, because the fan-out worker's batch transaction has already written the recipient row before this runs.
It is called after the batch transaction commits, never inside it: a rolled-back batch must never notify anyone in real time about something that, per the durable record, never happened. And it never throws, because a throw would fail the whole fan-out batch and re-deliver notifications already recorded and, for remote channels, already queued.
The payload carries recipientPublicId, kind, category, priority and occurredAt — no title and no body. A payload rich enough to render a screen from would become a second, unversioned read API that drifts from the real one; the centre's own GET /api/mobile/notifications is that real one.
6.11 Consumer services
| Service | Method | Reads | Writes | Errors |
|---|---|---|---|---|
NotificationCentreService | list(actor, query) | notification_recipient, notification_event, role, staff, notification_delivery | — | PAGINATION_LIMIT_INVALID |
unreadCount(actor) | The same | — | — | |
markAllRead(actor) | The same | notification_recipient.read_at | — | |
markRead(actor, publicId) | The same | notification_recipient.read_at | NOTIFICATION_NOT_FOUND | |
NotificationDevicesService | register(actor, dto) | notification_push_token | notification_push_token | NOTIFICATION_DEVICE_LIMIT_REACHED |
remove(actor, publicId) | notification_push_token | notification_push_token | NOTIFICATION_DEVICE_NOT_FOUND | |
NotificationPreferencesService | get(actor) | All three preference tables, TEMPLATE_REGISTRY | — | — |
update(actor, dto) | The same | notification_preference_set, notification_preference | NOTIFICATION_PREFERENCE_VERSION_CONFLICT |
The centre's predicate is the only access control. These handlers declare no @Permissions() — guardians and students hold no admin-catalogue permission at all, so a permissioned handler would refuse them before any scoping ran — and RoleGuard returns true for an allowlisted handler before it ever reads request.user. So every method builds its own predicate:
r.user_id = actor.id— ownership.r.audience_role_id IS NULL OR r.audience_role_id = activeRoleId— active-role scoping.activeRoleIdisnullwhen the session has no active role, andx = NULLis never true in SQL, so that state correctly narrows to not-role-scoped rows only, with no extra branch.- A staff-audience row (its role's
scope_kindis neitherguardiannorstudent) additionally requires a livestaffrow for that user. A dismissed teacher who is also a parent keeps a liveusersrow, so (1) and (2) alone would keep serving them disciplinary and roster notifications after dismissal. EXISTSanin_appdelivery whose status is notskipped_preferenceorcancelled. Without it the centre lists rows whose only channels were email or SMS, and rows the user suppressed.
title and body come from the in-app delivery's rendered_title/rendered_body, correlated with ORDER BY d.id DESC LIMIT 1 so a replay's row wins. They are typed nullable because the columns are, but a row reaching this query always has them: the registry refuses at boot any kind declaring in_app without persistRendered, and a kind whose template fails to render records skipped_no_template, which predicate 4 excludes.
Order is fixed at e.occurred_at DESC, r.id DESC so two events fanned out in one transaction cannot be dropped or duplicated under offset paging. pagination=false is refused outright. markRead uses coalesce(read_at, now()) so re-reading does not move the timestamp, and zero rows is a 404, never a 403 — a 403 would confirm the row exists for someone else, or under a role the caller is not currently acting as.
Device registration never takes user_id from the body. The DTO deliberately carries no userId or deviceId field; every write is keyed on actor.id from the verified session. The three cases: same user and same token is a touch (updates last_used_at and platform), because invalidating and reinserting on every app launch would defeat the per-user cap for no reason; a token held by another user is invalidated with reason replaced and a fresh row inserted; and the cap is checked against active rows and refused rather than silently evicting the oldest, so the caller learns the limit rather than losing a device it never asked to remove. Removal is a soft invalidation with reason user_logout, not a hard delete — the table exists to keep the history that answers "why did this parent stop receiving notifications".
The preference matrix computes locked categories from the registry, not from UNSUPPRESSIBLE_CATEGORY. That constant names categories a kind is allowed to lock, not categories that are fully locked today: system permits an unsuppressible kind but currently holds none, so locking it would show the UI a switch that works for a category with nothing to switch. A category is locked only when every registered kind in it is unsuppressible. Locked entries are dropped from a PUT before persisting, because isEnabled always returns true for an unsuppressible kind regardless of any stored row, so persisting one would be a switch with no effect on delivery. Overrides are de-duplicated last-write-wins on (category, channel).
The version guard has two shapes in one transaction. version === 0 means "I read no row", so the write is an INSERT ... onConflictDoNothing that must produce a row; any other value is an UPDATE ... WHERE version = $expected that must match. Either returning zero rows is 409 NOTIFICATION_PREFERENCE_VERSION_CONFLICT.
6.12 Admin services
| Service | Method | Reads | Writes | Errors |
|---|---|---|---|---|
NotificationTemplateService | findAll(query) | notification_template | — | PAGINATION_LIMIT_INVALID |
findById(publicId) | The same | — | NOTIFICATION_TEMPLATE_NOT_FOUND | |
create(actor, dto) | TEMPLATE_REGISTRY | notification_template | NOTIFICATION_TEMPLATE_INVALID, SYS_INTERNAL_ERROR | |
update(actor, publicId, dto, audit?) | The same | notification_template, an activity record | NOTIFICATION_TEMPLATE_NOT_FOUND, NOTIFICATION_TEMPLATE_INVALID, NOTIFICATION_TEMPLATE_VERSION_CONFLICT | |
remove(publicId, dto) | The same | notification_template | NOTIFICATION_TEMPLATE_NOT_FOUND, NOTIFICATION_TEMPLATE_VERSION_CONFLICT | |
NotificationHistoryService | findAll(query) | notification_event, notification_recipient, notification_delivery | — | PAGINATION_LIMIT_INVALID |
findById(publicId) | The same | — | NOTIFICATION_EVENT_NOT_FOUND | |
NotificationEventService | cancel(publicId) | notification_event | notification_event.cancelled_at | NOTIFICATION_EVENT_NOT_FOUND, NOTIFICATION_EVENT_CANCELLED, NOTIFICATION_EVENT_ALREADY_FANNED_OUT |
NotificationFailureService | findAll(query) | job_failures | — | PAGINATION_LIMIT_INVALID |
replay(publicId, actorId) | job_failures, notification_delivery | notification_delivery, outbox_events, job_failures | JOB_FAILURE_NOT_FOUND, JOB_FAILURE_QUEUE_NOT_PERMITTED, JOB_FAILURE_ALREADY_REPLAYED, NOTIFICATION_DELIVERY_NOT_FOUND, SYS_INTERNAL_ERROR |
NotificationTemplateService.update is the one caller that computes changes by hand. The global ActivityAuditInterceptor records every mutation automatically from the handler's own @Permissions(), but it has no before-value — it never reads the row a PATCH changed. So update also calls ActivityRecordService directly with per-field changes. create and remove need no such call; the interceptor covers them.
Every admin list refuses pagination=false. These tables only grow, and there is no ceiling small enough to make an unbounded read safe to buffer at once. Every list sorts with id as the tie-break, because two rows saved in the same millisecond share a timestamp and an unstable sort under offset pagination silently drops and duplicates rows across pages.
The history service enforces its own security, not only its DTO. The SELECTs never touch notification_event.variables, never touch rendered_title/rendered_body, and never join a people table — so there is nothing to accidentally spread, because nothing carrying a name, email or secret is read into memory in the first place. failedDeliveryCount counts statuses that are neither the happy path nor any skipped_* value, which leaves exactly failed and dead.
Cancellation is a compare-and-set, not a read-then-write. 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 already committed. On zero rows the service re-reads to report precisely why: already cancelled, or the fan-out won.
The dead-letter screen is deny-by-default. job_failures is global across every queue in the platform, including QueueName.BACKUP_RESTORE — an unfiltered read would show a clerk holding NotificationFailure_READ a database restore's failures, and an unfiltered replay would let them re-run one. REPLAYABLE_QUEUES is a hardcoded allowlist of the three channel queues, ANDed unconditionally; the channel query parameter can only narrow it. A failure from another queue is reported as a 409 refusal rather than a 404: the row exists and another screen may legitimately show it, so pretending it is absent would be a lie to an operator who can see it elsewhere.
Replay inserts a new delivery rather than re-enqueueing the original job. A channel job's payload is { deliveryPublicId }, so re-queueing the same delivery would leave two in-flight claims on one row — and the original's failed_at, last_error and provider_message_id are the evidence an operator opened the screen to read. The new row points replay_of_delivery_id at the original, which is exactly why both delivery uniques carry AND replay_of_delivery_id IS NULL. The job_failures compare-and-set happens inside the same transaction and last, so a concurrent claim rolls back the delivery and outbox inserts with it.
6.13 NotificationFeedService — the admin operational feed
| Method | Reads | Writes | Errors |
|---|---|---|---|
list(actor, { limit }) | notification, notification_read, the caller's role permissions | — | — |
unreadCount(actor, known?) | The same | — | — |
markRead(actor, publicId) | The same | notification_read | NOTIFICATION_NOT_FOUND |
markAllRead(actor) | The same | notification_read | — |
The permission filter is the access control. There is no single permission that gates "notifications": each row carries the permission of the screen its event belongs to, and a caller sees exactly the rows their active role could already have read by opening that screen. One blanket permission would either gate the whole feed behind the widest of them or show every operator everything. It is the same rule the live admin stream applies at connect time, resolved the same way — from the active role, never the union of roles held — so the bell's history and its live events cannot disagree about who may see what.
visiblePermissions returns null for a superadmin active role, which the callers turn into no filter at all rather than materialising the whole catalogue into an IN (...). It returns [] for a session with no active role, and an empty list becomes the SQL literal false — inArray with an empty list is not portable and reads as "no filter" in some drivers, which would show a role with no permissions everything.
The read-state join is LEFT JOIN notification_read ON notification_id = ... AND user_id = actor.id. Joining without the user predicate would mark a row read because somebody read it. unreadCount counts across everything visible, not just the page returned, because a badge that counted the page would cap at its size. markAllRead is scoped to what the caller can see, so clearing the bell never creates a receipt for a notification they were not shown — which would otherwise silently hide it if they gained the permission later. markRead checks visibility first and answers 404 for an invisible row, exactly as it does for an absent one: the id is a uuid, and a 403 would confirm it exists.
7. Runtime Flows
7.1 Raising a notification
| Step | Code Path | Behavior | Failure Case |
|---|---|---|---|
| 1 | NotificationService.send | Validates kind, channels, audience caps, action URL. | 400 with a NOTIFICATION_* code. |
| 2 | buildDedupeKey | Enforces the kind's dedupe policy and hashes the material. | 400 NOTIFICATION_DEDUPE_REQUEST_ID_REQUIRED / _FORBIDDEN. |
| 3 | executor.insert(notificationEvent) | Inserts, or returns the existing row on the dedupe conflict. | A CHECK violation aborts the caller's transaction. |
| 4 | outbox.enqueue(executor, ...) | Writes the outbox row in the same transaction, keyed on the event's public id. | Rolls back with everything else. |
The outbox row goes in the same transaction. A direct queue.add() here would be exactly the defect the outbox exists to prevent: the event commits, the enqueue throws, the caller returns 200, and nothing ever fans it out. The outbox dedupeKey is the event's own public id, so a retried business transaction cannot schedule two fan-outs for one event.
Every caller in AuthEmailService swallows a failure and logs it: a notification that cannot be scheduled must not roll back the password change that prompted it. The account holder is worse off with a failed change than with a missing email, and that log line is the only channel the failure has.
7.2 Fan-out
| Step | Code Path | Behavior | Failure Case |
|---|---|---|---|
| 1 | process | Rejects a payload with no eventPublicId. | Throws, so BullMQ records it. |
| 2 | claim | Compare-and-set on fanout_claimed_at IS NULL AND cancelled_at IS NULL. | Zero rows returns { skipped: true } — another worker holds it, or it was cancelled. |
| 3 | audience.resolvePage | One bounded keyset page. | An unknown persisted audience kind throws NOTIFICATION_AUDIENCE_KIND_UNKNOWN. |
| 4 | writeBatch | Recipients, deliveries, outbox rows and the cursor, in one transaction. | A CHECK violation aborts the batch; the claim's lease lets the reaper release it. |
| 5 | realtime.publishInApp | Per-user pub/sub, after the commit. | Caught and logged per recipient. |
| 6 | Completion UPDATE | fanned_out_at and recipient_count in one statement. | A CHECK failure would abort — which is why they are one statement. |
The cursor advances inside the batch transaction. Advancing it after would mean a crash between commit and cursor-write replays the batch — idempotent for recipients, but the channel outbox rows for that batch were already committed and dispatched, so a restart writes a second set.
recipient_count is a COUNT(*), never an accumulator, because a resumed fan-out would double it. Both completion columns move in one UPDATE because a CHECK is evaluated at the end of the statement and cannot be deferred, so "write the marker now, count on the next line" is rejected and takes the whole transaction with it.
The recipient insert uses a targeted onConflictDoNothing naming (event_id, user_public_id_snapshot). A targetless call is idempotent only when the inserted column is the conflict target, which is not the case here — a redelivered batch would otherwise raise a unique violation and fail the whole job.
Per recipient, per requested channel:
| Case | Delivery row written |
|---|---|
| Preference suppresses the channel | skipped_preference, terminal, with skipped_at |
push and the user holds no active token | skipped_no_destination, terminal |
push with N active tokens | N rows, queued, each with provider_target_id |
in_app and the template renders | delivered with provider = 'in_app', delivered_at, rendered_title, rendered_body |
in_app and the template does not render | skipped_no_template, terminal |
email or sms | queued |
A suppressed channel still gets a row. That is what makes the status reachable and what answers "why did this parent not get it" from the record rather than from a preference set that has since changed.
In-app is complete the moment it is written, rendered content and all. Inserting it queued and flipping it to delivered by a bulk update that wrote no content is the shape to avoid: every notification in the centre would carry a null title and body, so it would arrive, count toward the unread badge, and display as blank. Nothing would fail — not the fan-out, not the delivery, not a test that only counts rows.
Template overrides and locales are loaded only when in-app is actually requested: an email-only announcement should not pay for a template lookup and a locale scan on every batch.
A fan-out that resolves to zero recipients is legal — a class with no enrolments — and is logged at warn rather than passed over silently, because an operator who sent to nobody should be able to see that they did.
7.3 Channel send
| Step | Code Path | Behavior | Failure Case |
|---|---|---|---|
| 1 | BaseChannelProcessor.process | Rejects a payload with no deliveryPublicId. | Throws. |
| 2 | ChannelSendService.send | Claim, liveness, secret, render, address, send, record. | Each branch records a terminal status. |
| 3 | DeliveryRecorderService.recordFailure | Chooses failed or dead from retryability and the budget. | — |
| 4 | onFailedShared | JobFailureRecorderService.recordIfTerminal with a payload_ref. | Swallowed and logged — a failed handler that throws loses both failures. |
The failure is thrown rather than returned quietly, so BullMQ records it and the onFailed handler can write the dead letter. Returning quietly is how a failed send becomes invisible.
The dead letter is the gap the outbox does not cover. outbox_events.status = 'dead' records a job that could not be enqueued; the moment queue.add returns, the row is marked dispatched and the outbox never hears about that job again. A job enqueued successfully and then failing while running reaches the outbox never, and would otherwise live and die in BullMQ's own failed set, which is age- and count-trimmed and is not queryable as a business record — so "the invitation never arrived" becomes unanswerable.
7.4 The recovery tick
NotificationJob.REAP runs four bounded sweeps in order.
| Sweep | Predicate | Action | Bound |
|---|---|---|---|
| Expired delivery leases | status = 'processing' AND lease_expires_at <= now() | attempts + 1 < max returns it to queued with a backoff; otherwise dead with LEASE_EXPIRED | NOTIFICATION_RETENTION_BATCH_SIZE, ordered by lease_expires_at |
| Elapsed backoffs | status = 'failed' AND next_attempt_at <= now() AND attempts < max | Back to queued, clearing failed_at and last_error | The same, ordered by next_attempt_at |
| Stale fan-out claims | fanned_out_at IS NULL AND cancelled_at IS NULL AND fanout_claimed_at <= now() - lease*10 | Clears fanout_claimed_at | The same, ordered by fanout_claimed_at |
| Orphan events | fanned_out_at IS NULL AND fanout_claimed_at IS NULL AND cancelled_at IS NULL AND created_at <= now() - grace AND (scheduled_for IS NULL OR scheduled_for <= now()) | Re-enqueues notification.fan_out through the outbox | The same, one transaction |
There is deliberately no watermark. The general rule is that a bounded sweep with no cursor re-reads the same prefix forever and never audits the rest — but that rule applies to a sweep whose candidates are unchanged by examining them. These are not: each mutates the rows it finds, so every row it handles leaves the candidate set immediately. A watermark here would skip rows that legitimately reappear, and a watermark ahead of a backdated row hides that row permanently.
Sweep 3 exists because sweep 4 cannot see the gap it leaves. Sweep 4 is self-clearing because the fan-out worker's claim removes the event from its predicate — but that same claim means an event whose fan-out claimed it and then died is in neither predicate: not unclaimed, not complete, matched by nothing, half-fanned-out forever. Sweep 3 is a lease on the fan-out claim, the same shape as the lease on a delivery claim; without it the delivery lease would protect the small unit while the large one had no protection at all. The threshold is a generous ten times the delivery lease, because reclaiming a fan-out that is still working would run two workers over one event — survivable for the idempotent recipient insert, but the channel outbox rows would be written twice. On release, the fan-out resumes from fanout_cursor.
Sweep 4 keys on created_at, not scheduled_for. scheduled_for IS NULL means immediate, which is the majority of all events, and NULL never satisfies <= now() — so a scheduled_for predicate makes the sweep structurally unreachable for the common case and permanently hides any backdated row. The grace period is what stops it racing the normal path: the outbox relays within seconds, and sweeping immediately would double-dispatch every event in the system. Re-dispatch is one transaction, and the outbox's own dedupe index makes a repeat a no-op.
7.5 Backfill
Without this, skipped_unconfigured is terminal and "works the moment keys are added" holds only for future sends: every notification produced before the key arrived is permanently unsent, with no replay path, because job_failures never sees them — a skip is deliberately not a failure. A write path with a fallback and no matching read path is a 200 that delivers nothing; this is the read path.
Only channels configured now are considered — re-queueing a delivery whose provider is still absent just walks it back to skipped_unconfigured, burning a claim and a queue round trip each tick. The status change is a compare-and-set so two ticks cannot both re-queue one row, and skipped_at is cleared in the same statement or the biconditional CHECK rejects the write and takes the transaction with it. The outbox dedupeKey is <publicId>:backfill, distinct from the original enqueue's, or the outbox's own unique index would treat this as the already-dispatched first attempt and drop it silently.
The window is bounded by NOTIFICATION_BACKFILL_MAX_AGE_HOURS so adding a provider a month late does not resurrect weeks of stale announcements at somebody's expense.
7.6 Retention
Two predicates make it safe:
- No in-flight delivery. A
queued,processingorfailedrow means a worker may still touch it. Deleting it makes the worker's claim return zero rows — which the worker reads as "another worker holds it" and reports as success. The message is then neither sent nor recorded. - No unreplayed dead letter.
job_failures.payload_refis a jsonb reference and deliberately not a foreign key, so nothing cascades and nothing else would stop this deleting the delivery an operator is about to replay.
Bottom-up, not a cascade. Deleting one all_users event cascades roughly three thousand recipients and twelve thousand deliveries in a single statement, holding a long lock on the table the fan-out is inserting into and the notification centre is reading. Each level is deleted in its own bounded statement instead, and because Postgres has no DELETE ... LIMIT, each bound is a subquery. Only events whose children are all gone are deleted; a partially-deleted event survives to the next tick.
Both sweeps key on occurred_at — the column an operator reasons about, and the one indexed for it. created_at would delete a long-scheduled event before it ever fired.
Token pruning and the admin feed sweep run even when no aged event is found, and that early-return path is the interesting one. These are independent retentions that happen to share a tick: a school can go a whole holiday without an expiring notification while its pupils reinstall the app steadily, and every reinstall invalidates a token. Returning early without pruning meant UNIQUE (token) WHERE is_active grew monotonically for as long as the notification tables happened to stay inside the window — and nothing reported it, because the job kept completing successfully.
The admin feed had no sweeper at all. notification grew for the lifetime of the deployment, one row per operational event forever, and no gate could see that because an ever-growing table is not an error. notification_read cascades from it, so the read receipts go with their notification and need no statement of their own.
7.7 SMS credit check
Aakash answers "Not enough balance." with HTTP 200 and error: true, and that failure is non-retryable — the delivery goes straight to dead. Without this check, the first symptom of an exhausted balance is a school-wide SMS outage discovered by a parent who never got their OTP, with a pile of dead deliveries behind it. Reading the balance costs one HTTP call a day and turns that into a warning with days of lead time.
It returns early and quietly when the provider is unconfigured (saying so once a day would be noise on every development machine) or when the provider exposes no credit API at all (a mock has no balance, and absence is not a failure). A throw is swallowed and logged at error: a credit check that throws must not fail a maintenance tick and take the other sweeps with it, so that log line is the only channel the failure has.
7.8 Operational email
Everything on the channel queues resolves a notification_delivery row: a recipient, their preferences, their locale, their in-app copy, a lease the reaper can reclaim. An internal notice has none of that. It is addressed to a support mailbox, not to a person — nobody can opt out of it, it does not belong in anybody's notification centre, and there is no user whose row could carry it. Forcing it through the recipient machinery would mean inventing a fake user to hang it on. So it shares the one thing that matters — EmailChannelProvider, and with it a single Resend configuration, a single isConfigured() rule and a single place a provider is ever swapped — and skips the rest.
The payload is rendered at enqueue, the opposite of every other notification: an internal notice is three label/value rows about a row written in the same transaction, so rendering it at the producer keeps the notification workers from having to learn about feedback and reviews to build one. The trade is that editing a template does not change a notice already enqueued — for something with a lifetime measured in minutes, that is the right side of it.
An unconfigured provider completes the job with a warning rather than throwing. Throwing would dead-letter every internal notice raised before the key arrived, which is noise about a known state rather than information. The cost is stated rather than hidden: unlike a delivery row there is nothing for the backfill worker to re-drive, so a notice skipped this way is not sent later. The admin toast still fires — it is relayed at dispatch, independently of this send — so the operator does learn the thing happened; only the durable email copy is lost.
An unknown job name on this queue throws rather than returning quietly, even though the queue carries exactly one name: the silent alternative is a job that completes having done nothing.
7.9 Consumer and admin request flows
The 404-not-403 rule is applied consistently on every id-addressed consumer route and on the admin feed: public ids are uuids, and a 403 confirms that a row exists.
8. Caching
This module has no read-through cache, and that is deliberate. Every read surface is either per-caller (the centre, the preference matrix, the feed) or an operator screen whose value is being current (history, dead letters). A cache over a per-caller predicate is a cache with a cardinality of one entry per person, and a cache over an operator screen trades correctness for a saving nobody asked for. The CacheKeyUtil / Redis-cache pattern used elsewhere in the platform is therefore absent here on purpose.
Redis is used for exactly two things, neither of them a cache:
| Redis usage | Key pattern | Value | TTL | Written by | Read by | Failure handling |
|---|---|---|---|---|---|---|
| Secret vault | notification:secret:<prefix>:<uuid> | JSON object of secret fields, e.g. a reset URL, an OTP, a destination override | 900s (SET ... EX) | SecretReferenceService.stash, called by AuthEmailService | SecretReferenceService.resolve, via GETDEL — deleted on read | An unresolvable reference records skipped_no_destination; the user asks again. |
| Per-user realtime channel | realtime:user:<userId> | NotificationRealtimeUserEvent JSON | None — pub/sub, not storage | NotificationRealtimePublisherService.publishInApp | An open session's stream | Caught per recipient and logged at error; never throws. |
Two in-memory caches exist inside a process and are not shared:
TemplateRendererService.warnedOverrides— aSetofkind:channel:localekeys already logged. Bounded by the number of templates; exists so one bad override is one log line rather than three thousand identicalERRORs.SecretReferenceService.resolvers— the prefix-to-resolver map registered at boot. A duplicate registration throws, because two resolvers for one prefix means one of them silently never runs.
Template overrides and preference snapshots are loaded once per fan-out batch and held for the duration of that batch only. That is batching, not caching: nothing survives the transaction, so a template edit takes effect on the next batch.
9. BullMQ, Schedulers, and Async Work
| Queue | Job | Producer | Processor | Payload | Retry/Backoff | Idempotency |
|---|---|---|---|---|---|---|
NOTIFICATION_FANOUT | notification.fan_out | NotificationService.send and the orphan sweep, both through the outbox | NotificationFanoutProcessor | NotificationFanOutPayload | BullMQ default | Outbox dedupeKey = the event public id; the worker's claim is a compare-and-set |
NOTIFICATION_FANOUT | notification.reap | NotificationScheduler, every 30s | NotificationReaperProcessor | NotificationMaintenancePayload | BullMQ default | jobId bucketed on a 30s window |
NOTIFICATION_FANOUT | notification.sweep_orphans | Operator, on demand | NotificationReaperProcessor (same handler) | The same | BullMQ default | Every sweep is a compare-and-set or an outbox insert |
NOTIFICATION_FANOUT | notification.backfill_unconfigured | NotificationScheduler, every 5 minutes | NotificationBackfillProcessor | The same | BullMQ default | jobId bucketed on a 5-minute window; per-row compare-and-set |
NOTIFICATION_FANOUT | notification.prune | NotificationScheduler, hourly | NotificationRetentionProcessor | The same | BullMQ default | jobId on the hour key; deletion is naturally idempotent |
NOTIFICATION_FANOUT | notification.check_sms_credit | NotificationScheduler, daily at 01:00 Asia/Kathmandu | NotificationCreditProcessor | The same | BullMQ default | jobId on the local day key |
NOTIFICATION_EMAIL | notification_channel.send_email | Fan-out, backfill and replay, all through the outbox | NotificationEmailProcessor, concurrency 4 | NotificationChannelSendPayload | attempts: 1 — the delivery row owns retry | The claim is WHERE status = 'queued'; a zero-row claim returns quietly |
NOTIFICATION_SMS | notification_channel.send_sms | The same | NotificationSmsProcessor, concurrency 2 | The same | attempts: 1 | The same |
NOTIFICATION_PUSH | notification_channel.send_push | The same | NotificationPushProcessor, concurrency 8 | The same | attempts: 1 | The same |
NOTIFICATION_OPERATIONAL | notification_operational.send_email | InternalNoticeQueueService, through the outbox | NotificationOperationalProcessor, concurrency 2 | NotificationOperationalEmailPayload | BULL_DEFAULT_ATTEMPTS — the only notification queue that uses BullMQ retry | The outbox dedupe key |
Concurrency is set per channel for a reason. SMS is 2 because every message costs real money and the provider rate-limits — a burst of workers against a metered gateway spends faster and gets throttled sooner. Push is 8 because it is high-volume and cheap. Email sits between at 4.
Why exactly one decorated class per queue. @nestjs/bullmq builds one BullMQ Worker per decorated class, and WorkerOptions.name is a monitoring label, never a filter. Two classes each carrying @Processor(queue, { name: "x" }) therefore produce two workers racing for every job on that queue, each receiving the other's work, so a job won by the wrong class completes having done nothing. So NotificationFanoutQueueProcessor is the only @Processor on NOTIFICATION_FANOUT and routes by job.name.
Its handler map is a Record over a six-member union. Adding a job to that union without registering a handler is a compile error, because the runtime symptom otherwise is a job that completes having done nothing — silent, and indistinguishable from success. The union is deliberately a subset of NotificationJob: the legacy queue's own job names are excluded, because including them would force a stub handler or make the map non-exhaustive. An unknown name at runtime throws rather than logging and returning, because BullMQ records a throw and records nothing at all about a job that returns successfully having done nothing.
Why the channel queues pin attempts to 1. The notification_delivery row owns retry — its attempts column, its backoff and the reaper. Two independent budgets mean nobody owns the terminate decision: if BullMQ's is larger, the row reaches dead while the job keeps retrying against a claim that now fails, and job_failures is written on the wrong attempt; if smaller, the job stops while attempts < max and the row sits in processing with nothing to reclaim it. The pin is per queue in bull.module.ts's defaultJobOptions, driven by BULL_QUEUE_NOTIFICATION_EMAIL_ATTEMPTS, BULL_QUEUE_NOTIFICATION_SMS_ATTEMPTS and BULL_QUEUE_NOTIFICATION_PUSH_ATTEMPTS, all 1 in .env.example. Without those three lines each queue silently falls back to BULL_DEFAULT_ATTEMPTS.
NOTIFICATION_OPERATIONAL is registered beside them for the opposite reason: it must take the ordinary default, because an operational alert has no delivery row and nothing else would ever retry it, so a transient SMTP refusal would lose the notice outright.
Scheduler job ids. jobId deduplicates — BullMQ's add() returns the existing job for a duplicate id rather than throwing — so the bucket must match the interval. A per-minute key on a thirty-second cron collapses both ticks of each minute into the first: the schedule would look applied and run at half the stated rate. Each bucket is derived from the same interval its cron uses, so two ticks in one window (a restart, two replicas with no distributed lock) still collapse to one job, which is what the id is for.
And the id must not contain a colon. BullMQ rejects a jobId containing : and the rejection does not surface — the job is simply never scheduled, while type-check, lint, build, the full suite and every rule gate stay green. buildJobId turns that into a throw, and the rules:job-ids gate catches it statically.
| Cron | Interval | Bucket key | Why that interval |
|---|---|---|---|
enqueueReap | Every 30 seconds | floor(now / 30000) | What it recovers is a message somebody is waiting for — an OTP whose worker died should not wait a minute — and every sweep it runs is bounded and indexed, so an idle tick is four cheap index scans returning nothing. |
enqueueBackfill | Every 5 minutes | floor(now / 300000) | The moment a key is added the backlog should start moving. An idle tick is three isConfigured() calls and one indexed query. |
enqueuePrune | Hourly | YYYYMMDDHH | Bulk deletion competing for the tables the fan-out inserts into and the centre reads, and nothing depends on a row disappearing promptly. |
enqueueCreditCheck | Daily 01:00 | YYYYMMDD in Asia/Kathmandu | A paid HTTP call, and a balance does not move fast. The day key is local because a UTC key rolls over at 05:45 local and would run twice or not at all around that boundary. |
Every cron handler catches its own enqueue failure, because a cron handler that throws can take the scheduler's next tick with it. That absorption is exactly why each catch logs at error — the log line is the only channel the failure has.
Provider timeouts (NOTIFICATION_PROVIDER_CONNECT_TIMEOUT_MS 3000, NOTIFICATION_PROVIDER_READ_TIMEOUT_MS 10000) are both below BullMQ's default stalledInterval of 30s, so a slow provider cannot cause a stalled-job redelivery while the first call is still open — which would send the message twice and bill for both.
10. Realtime and Events
| Event | Producer | Room/Target | Payload | Consumer | Reliability Notes |
|---|---|---|---|---|---|
| In-app arrival | NotificationRealtimePublisherService.publishInApp, called by the fan-out worker after each batch commits | Redis channel realtime:user:<userId> | recipientPublicId, kind, category, priority, occurredAt | An open consumer session | Best-effort. Postgres is authoritative; a plain refresh must be correct with Redis unreachable. |
notification.fan_out_requested | NotificationService.send, and the orphan sweep | outbox_events -> NOTIFICATION_FANOUT | { eventPublicId } | NotificationFanoutProcessor | At-least-once; deduped by the outbox unique index and by the worker's claim. |
notification.delivery_queued | The fan-out worker, per queued remote delivery | outbox_events -> the channel queue | { deliveryPublicId } | ChannelSendService | At-least-once; deduped by the queued claim. |
notification.delivery_backfilled | NotificationBackfillProcessor | The same | { deliveryPublicId } | The same | Dedupe key <publicId>:backfill. |
notification.delivery_replayed | NotificationFailureService.replay | The same | { deliveryPublicId } of the new row | The same | Dedupe key <publicId>:replay. |
Three different outbox dedupe keys can legitimately exist for one delivery public id — one per code path that schedules it. Reusing a key across paths would make the outbox's unique index silently drop the second enqueue.
11. Security, Auth, and Abuse Controls
Guards. Every controller in this module applies JwtAuthGuard then RoleGuard. Nothing here is @Public().
Permissions. Three catalogue modules, each producing _CREATE, _READ, _UPDATE, _DELETE and _RESTORE codes:
| Module | Granted for | Notes |
|---|---|---|
NotificationTemplate | The template override screen | Editing one changes what every future recipient of that (kind, channel, locale) reads. |
NotificationHistory | The history screen, and _UPDATE for cancelling an event | Read behind its own module rather than folded into an existing one, because the response deliberately excludes every recipient's name and the rendered body — so this grant cannot become the re-aggregation StaffSalary and StudentMedical were split apart to avoid. |
NotificationFailure | The dead-letter screen, _UPDATE for replay | Replay is a write capability wearing a diagnostic name — it re-sends a message — hence its own module rather than folding into the data-import dead-letter screen, whose queue allowlist this module does not share. |
None is in SUPERADMIN_ONLY_MODULES, which is precisely why notification_event.variables may never carry a credential and why the history DTO excludes rendered content.
Handlers with no permission. Eleven handlers appear on RoleGuard's NO_PERMISSION_ADMIN_HANDLERS allowlist: the four NotificationCentreController methods, the two NotificationDevicesController methods, the two NotificationPreferencesController methods, and the three NotificationFeedController methods. An admin route that declares no permission is otherwise refused at runtime, precisely so one cannot ship open by accident. For each of them the service predicate is the only control, because RoleGuard returns true for an allowlisted handler before it ever reads request.user:
- The centre scopes by ownership, active role and staff liveness.
- Devices key every write on
actor.idfrom the verified session; the DTO carries nouserIdfield at all. - Preferences key every write on
actor.id. - The feed filters every row by the caller's active-role permission set.
Input normalisation. Phone numbers pass normalizePhone before a send and an unnormalisable one is a skip, not a failure. escapeHtml is applied to every interpolated value in an email body; escapeHeader collapses CR and LF in a subject, because a newline in an interpolated header splits it and everything after becomes a header of the attacker's choosing.
Action URLs are allowlisted, and validated twice. buildEmailLayout emits href="${escapeHtml(ctaUrl)}", and escapeHtml replaces exactly five characters — & < > " ' — none of which appear in javascript:alert(1) or data:text/html;base64,..., so both would pass through intact and become working links in the recipient's mail client and in the parent and student app. isSafeActionUrl allows https: absolute URLs, or an app-relative path beginning with exactly one slash: //evil.example is a protocol-relative URL a browser resolves to an absolute one, so a naive startsWith("/") is an open redirect wearing a relative path. The check is on url.protocol, not on whether parsing threw, because URL parses javascript:alert(1) happily. http: is refused even in development — a link in an email leaves the machine that generated it. Validation happens at write so a bad value never reaches the database, and again at render, because a row can arrive by migration, by a fixture, or from a future writer that forgets; the render-time check is the one that actually protects the recipient, and it degrades to "no button" rather than failing the send.
Sensitive data redaction.
| Value | Where it must never appear | Mechanism |
|---|---|---|
| A live token, OTP or signed URL | notification_event.variables, notification_delivery.rendered_*, last_error, job_failures.payload_ref, any log | The vault plus chk_notification_event_variables_no_credentials as a backstop |
| A full email address or phone number | notification_delivery.destination_hint, any log | maskEmail, maskPhone |
| A raw provider response | last_error | Fixed error tables in packages/sms, packages/firebase and email.provider.ts |
| A recipient's name, email or phone | Every admin history response | The service never reads them into memory |
| A push token | Every response | NotificationDeviceDto returns publicId, platform, createdAt only |
The Aakash failure body echoes back the text it was asked to send, which for an OTP message is the OTP. That single fact is why last_error is redacted rather than merely truncated, and why every provider maps failures to a code from a fixed table plus a bounded allowlisted detail.
Abuse controls.
| Control | Value | Prevents |
|---|---|---|
NOTIFICATION_MAX_EXPLICIT_RECIPIENTS | 500 | An unbounded explicit recipient list |
NOTIFICATION_MAX_COMPOUND_MEMBERS | 10 | Ten thousand type-legal all_users members, each a keyset walk over the school |
NOTIFICATION_MAX_TOKENS_PER_USER | 10 | One account registering N tokens so every push send fans out to N provider calls |
NOTIFICATION_PREFERENCE_MAX_OVERRIDES | categories × channels = 36 | An unbounded PUT body |
NOTIFICATION_CHANNEL_MAX_ATTEMPTS | 5 | Unbounded retries at metered cost |
Fixed centre ordering and refused pagination=false | — | An unbounded read of every notification in the school |
| Token invalidation rather than reassignment | — | Silently stopping a victim's push by presenting their token |
Audit. NotificationTemplateService.update writes an explicit ActivityRecordService entry with per-field changes, module NotificationTemplate, resource type notification_template. create and remove are covered by the global ActivityAuditInterceptor. Replay records replayed_by on the job_failures row.
Fail-closed decisions. An unknown audience kind throws rather than resolving to nobody. An empty permission list becomes SQL false rather than "no filter". A category with no default entry resolves to false rather than sending. An unresolved template variable suppresses the send. A required channel with no credentials refuses to boot.
Fail-open decisions, each stated as a trade: a bad template override falls through to the shipped wording rather than silencing the notification; an unsafe action URL is dropped rather than failing the send; a realtime publish failure is logged rather than failing the batch; a scheduling failure inside AuthEmailService is swallowed rather than rolling back the password change.
13. Error Handling
| Error Code | HTTP Status | Thrown By | Condition | Client Action |
|---|---|---|---|---|
NOTIFICATION_TEMPLATE_NOT_FOUND | 400 | NotificationService.assertKnownKind | The kind is not in the code registry. | Fix the kind. Internal callers only. |
NOTIFICATION_TEMPLATE_NOT_FOUND | 404 | NotificationTemplateService.findRawByPublicId | No override row with that public id. | Reload the list. |
NOTIFICATION_CHANNEL_UNKNOWN | 400 | NotificationService.assertChannelsAreBuildable | The kind has no builder for a requested channel. | Request only channels the kind supports. |
NOTIFICATION_AUDIENCE_TOO_LARGE | 400 | NotificationService.assertAudienceIsWithinCaps | Over 500 explicit recipients, or a compound outside 1–10 members. | Split the send. |
NOTIFICATION_ACTION_URL_SCHEME_FORBIDDEN | 400 | NotificationService.assertActionUrlIsSafe | Not https: and not a single-slash relative path. | Supply a safe URL. |
NOTIFICATION_DEDUPE_REQUEST_ID_REQUIRED | 400 | NotificationService.buildDedupeKey | A repeatable kind raised without dedupe.requestId. | Supply one. Refused rather than defaulted: without it the second reset or OTP request inserts nothing and the endpoint returns 200 having sent no message. |
NOTIFICATION_DEDUPE_REQUEST_ID_FORBIDDEN | 400 | The same | A collapse kind given a requestId, which would defeat its own dedupe. | Remove it. |
NOTIFICATION_EVENT_NOT_FOUND | 400 | NotificationService.send | The insert returned no row. Unreachable given the no-op conflict update; asserted rather than assumed. | Retry; report if it recurs. |
NOTIFICATION_EVENT_NOT_FOUND | 404 | NotificationHistoryService, NotificationEventService | No event with that public id. | Reload the list. |
NOTIFICATION_EVENT_ALREADY_FANNED_OUT | 409 | NotificationEventService.cancel | The fan-out won the race. | Nothing to do — the messages have gone. |
NOTIFICATION_EVENT_CANCELLED | 409 | NotificationEventService.cancel | Somebody already cancelled it. | Reload. |
NOTIFICATION_AUDIENCE_KIND_UNKNOWN | 400 | AudienceResolverService.fragmentFor | A persisted audience.kind with no resolver — reachable only from a row written outside the DTO. | Operator action; the event fails loudly instead of being swept forever. |
NOTIFICATION_TEMPLATE_INVALID | 400 | NotificationTemplateService.assertKnownKind | kind is not a registry key. | Choose a known kind. |
NOTIFICATION_TEMPLATE_VERSION_CONFLICT | 409 | NotificationTemplateService.update / remove | version did not match. | Reload and retry. |
NOTIFICATION_PREFERENCE_VERSION_CONFLICT | 409 | NotificationPreferencesService.update | version did not match, or version: 0 when a row already exists. | Re-GET and retry. |
NOTIFICATION_DEVICE_LIMIT_REACHED | 400 | NotificationDevicesService.register | Already at NOTIFICATION_MAX_TOKENS_PER_USER active tokens. | Remove a device first. |
NOTIFICATION_DEVICE_NOT_FOUND | 404 | NotificationDevicesService.remove | No active token with that public id for this user. | Reload the device list. |
NOTIFICATION_NOT_FOUND | 404 | NotificationCentreService, NotificationFeedService | Not visible to this caller, or absent. | Reload. Never 403. |
NOTIFICATION_DELIVERY_NOT_FOUND | 404 | NotificationFailureService.replay | The dead letter carries no notificationDeliveryPublicId, or the delivery it names is gone. | Nothing to replay. |
JOB_FAILURE_NOT_FOUND | 404 | NotificationFailureService.replay | No job_failures row with that public id. | Reload. |
JOB_FAILURE_QUEUE_NOT_PERMITTED | 409 | The same | The row belongs to a queue outside the three channel queues. | Use the screen that owns that queue. |
JOB_FAILURE_ALREADY_REPLAYED | 409 | The same | replayed_at is set, or a concurrent request claimed it. | Reload. |
PAGINATION_LIMIT_INVALID | 400 | Every list service | pagination=false on a table that only grows. | Paginate. |
SYS_INTERNAL_ERROR | 500 | Template create, replay insert | A RETURNING produced zero rows. Unreachable in PostgreSQL — an insert violating a CHECK or unique index throws first. | Report. |
Internal, non-HTTP failure states are recorded on the delivery row rather than raised: skipped_unconfigured, skipped_preference, skipped_no_template, skipped_no_destination, failed and dead. Provider-level codes reaching last_error come from three fixed tables — EMAIL_ERROR_CODE, SMS_ERROR_CODE and PUSH_ERROR_CODE — and never from a provider's own response text.
14. Observability
| Signal | Location | Purpose |
|---|---|---|
[start] / [success] / [skip] with a correlationId | Every processor | Traces one job end to end. correlationId defaults to the BullMQ job id. |
[failure] / [retry] | Channel and operational onFailed handlers | Distinguishes "failed again" from "gave up", which is what recordIfTerminal's return value answers. |
WARN at boot, once per unconfigured or mocked provider | NotificationProvidersModule | The condition that makes deliveries skipped_unconfigured, or makes them look sent while sending nothing. |
ERROR on an FCM credential/project mismatch | The push factory | Left undetected this is a bare auth error at send time with nothing pointing at the cause. |
WARN on a zero-recipient fan-out | NotificationFanoutProcessor | An operator who sent to nobody should be able to see that they did. |
WARN on reaper activity | DeliveryRecorderService, NotificationReaperProcessor | Lease reclaims, budget kills, stale fan-out claims and orphan re-dispatches are all abnormal. |
WARN on low SMS credit | NotificationCreditProcessor | Days of lead time before an exhausted balance turns every OTP into a dead delivery. |
WARN once per bad template override key | TemplateRendererService | One bad row is one line, not one per recipient. |
WARN on a provider-invalidated push token | ChannelSendService | Explains a device that stops receiving. |
ERROR on a realtime publish failure | NotificationRealtimePublisherService | The only channel it has — it is forbidden from throwing. |
ERROR on a swallowed schedule failure | AuthEmailService | The only channel it has. |
job_failures rows | JobFailureRecorderService | The queryable business record of a send that failed while running. |
activity records | ActivityRecordService and the global interceptor | Who changed which template field, and who replayed which failure. |
notification_delivery itself | — | The primary operational signal. Status, attempts, failure_count, lease_expiry_count, last_error and every timestamp are queryable and surfaced by the history screen. |
failure_count and lease_expiry_count are separate columns specifically so that "the provider is failing" and "workers are dying" remain distinguishable in a query, and skipped_* statuses are excluded from every failure count so an unconfigured environment never looks like an outage.
15. Testing and Validation
| Test Type | Files | Coverage |
|---|---|---|
| Integration | apps/api/src/modules/notification/__tests__/notification-fanout.int.spec.ts | Claim semantics, batch writes, per-channel delivery rows, in-app rendering, cursor resumption, completion columns. |
| Integration | .../__tests__/channel-send.int.spec.ts | Claim, liveness, secret resolution, render fall-through, provider outcomes, token invalidation, state transitions. |
| Integration | .../__tests__/notification-retention.int.spec.ts | In-flight and dead-letter predicates, bottom-up deletion, token pruning, the admin-feed sweep. |
| Integration | .../__tests__/notification-operational.int.spec.ts | Fixed-mailbox sends, the unconfigured skip, unknown job names, dead-letter references. |
| Integration | customer/notification-centre/notification-centre.service.int.spec.ts | The four-part visibility predicate, and Object.keys(response) asserted against the DTO's exact field set. |
| Integration | customer/notification-devices/notification-devices.service.int.spec.ts | Touch, replace, cap, scoped removal. |
| Integration | customer/notification-preferences/notification-preferences.service.int.spec.ts | Version 0 and version N paths, locked-category dropping, resolution order. |
| Integration | admin/history/notification-history.service.int.spec.ts | Filters, failure counting, and the fields the response must never carry. |
| Integration | admin/dead-letter/notification-failure.service.int.spec.ts | The queue allowlist, replay insert, compare-and-set. |
| Integration | admin/event/notification-event.service.int.spec.ts | Cancel, already-cancelled, already-fanned-out. |
| Integration | admin/template/notification-template.service.int.spec.ts | Version conflicts, registry validation, activity records. |
| Integration | customer/realtime/notification-realtime-publisher.int.spec.ts | Channel naming, payload shape, swallow-on-failure. |
| Integration | apps/api/src/modules/notification-feed/notification-feed.service.int.spec.ts | Permission filtering, per-caller read state, unread counting. |
| Structure | apps/api/test/structure/structure.baseline.json | The 21 route paths this module and the feed publish. |
| Boot assertions | notification.constants.ts, template-registry.ts, notification-providers.module.ts | The backfill/retention window relationship, in_app implying persistRendered, and required-channel configuration. |
Validation commands:
pnpm check-types
pnpm lint
pnpm --filter @skoolsewa/api test
pnpm --filter @skoolsewa/api test:int
pnpm rules
pnpm buildThree of the invariants above are enforced at import or boot time rather than by a test, because the failure they prevent is invisible to every gate: a legal-but-wrong backfill/retention pair silently loses the backlog, an in_app kind without persistRendered delivers a blank notification that counts toward the badge, and a required channel with no credentials returns 200 and sends nothing.
16. Backend Deep-Dive Pack
16.1 Submodule Coverage Matrix
| Unit | Type | Owns | Depends On | Called By | Calls | State Touched | Failure Modes |
|---|---|---|---|---|---|---|---|
NotificationService | Service | The public send interface | OutboxService, TEMPLATE_REGISTRY, isSafeActionUrl | Any module, inside its transaction | outbox.enqueue | notification_event, outbox_events | Six 400 codes; a CHECK violation aborts the caller's transaction |
AudienceResolverService | Service | Specification-to-people resolution | DATABASE | NotificationFanoutProcessor | Raw SQL over identity and school tables | Reads only | NOTIFICATION_AUDIENCE_KIND_UNKNOWN |
PreferenceResolverService | Service | The four-step preference order | DbExecutor passed in | Fan-out worker, preferences service | Two batched SELECTs | Reads only | None; defaults to false |
TemplateRendererService | Service | Override-first rendering | DbExecutor passed in, TEMPLATE_REGISTRY | Fan-out worker, ChannelSendService | One batched SELECT | Reads only | Returns no_template; logs once per key |
DeliveryRecorderService | Service | The delivery state machine | DATABASE | ChannelSendService, reaper | Bounded UPDATEs | notification_delivery | A paired-CHECK violation surfaces as a job failure |
SecretReferenceService | Service | Send-time secret resolution | REDIS_CLIENT | AuthEmailService, ChannelSendService | Redis SET/GETDEL | Redis only | Returns null; duplicate prefix registration throws at boot |
ChannelSendService | Service | One send path for three channels | DATABASE, both provider tokens, EmailChannelProvider, recorder, renderer, secrets | All three channel processors | One provider call | notification_delivery, notification_push_token | Throws on wrong-queue and on provider failure |
EmailChannelProvider | Provider adapter | The email channel | EMAIL_CLIENT | ChannelSendService, backfill, operational worker | Resend via EmailClient | None | Returns skipped or failed; never throws |
NotificationProvidersModule | Module factory | Provider construction | ConfigService, packages/sms, packages/firebase | Nest DI | — | None | Throws at boot for a required-but-unconfigured or mocked-in-production channel |
NotificationRealtimePublisherService | Service | Per-user pub/sub | REDIS_CLIENT | NotificationFanoutProcessor | Redis PUBLISH | Redis only | Caught per recipient |
NotificationFanoutQueueProcessor | Processor | The single worker on NOTIFICATION_FANOUT | Five processors | BullMQ | Routes by job.name | — | Throws on an unknown job name |
NotificationFanoutProcessor | Processor | Audience resolution and row writing | DATABASE, resolvers, renderer, outbox, realtime | The queue processor | Many | notification_event, notification_recipient, notification_delivery, outbox_events | Throws on a missing payload; a batch CHECK violation aborts that batch |
NotificationEmailProcessor | Processor | The email queue | ChannelSendService, JobFailureRecorderService | BullMQ | sender.send | Via the recorder | onFailed writes a dead letter on the final attempt |
NotificationSmsProcessor | Processor | The SMS queue | The same | BullMQ | The same | The same | The same |
NotificationPushProcessor | Processor | The push queue | The same | BullMQ | The same | The same | The same |
NotificationOperationalProcessor | Processor | Fixed-mailbox notices | EmailChannelProvider, JobFailureRecorderService | BullMQ | email.send | None | Throws on unknown name, missing recipient/subject, or a provider failure |
NotificationReaperProcessor | Processor | Four recovery sweeps | DATABASE, recorder, outbox | The queue processor | Bounded UPDATEs and one transaction | notification_delivery, notification_event, outbox_events | Propagates; BullMQ records it |
NotificationBackfillProcessor | Processor | Re-driving the pre-credential backlog | DATABASE, three providers, outbox | The queue processor | Per-row transactions | notification_delivery, outbox_events | Propagates |
NotificationRetentionProcessor | Processor | Both retentions and token pruning | DATABASE | The queue processor | Bounded DELETEs | Four tables | Propagates |
NotificationCreditProcessor | Processor | The SMS balance check | SMS_PROVIDER, ConfigService | The queue processor | sms.getCredit() | None | Swallowed and logged |
NotificationScheduler | Scheduler | Four crons | The fan-out Queue | @nestjs/schedule | queue.add | Redis only | Each handler catches its own enqueue failure |
NotificationTemplateService | Service | Override CRUD | DATABASE, ActivityRecordService, isKnownKind | Its controller | Activity records | notification_template | Four codes |
NotificationHistoryService | Service | Read-only history | DATABASE | Its controller | — | Reads only | Two codes |
NotificationEventService | Service | Cancellation | DATABASE | Its controller | — | notification_event.cancelled_at | Three codes |
NotificationFailureService | Service | Dead letters and replay | DATABASE, OutboxService | Its controller | outbox.enqueue | job_failures, notification_delivery, outbox_events | Six codes |
NotificationCentreService | Service | The consumer centre | DATABASE | Its controller | — | notification_recipient.read_at | Two codes |
NotificationDevicesService | Service | Token registration | DATABASE | Its controller | — | notification_push_token | Two codes |
NotificationPreferencesService | Service | The preference matrix | DATABASE, an internal PreferenceResolverService, TEMPLATE_REGISTRY | Its controller | — | Two preference tables | One code |
NotificationFeedService | Service | The admin operational feed | DATABASE, RoleService | Its controller | roleService.getPermissionsForRoleId | notification_read | One code |
JobFailureRecorderService | Shared service | Dead-letter writing | DATABASE | Both onFailed handlers | — | job_failures | Swallows and logs |
Imported from elsewhere, and why. OutboxService because no send path may call queue.add directly. EMAIL_CLIENT because "how an email is sent" must have one implementation. RoleService because the feed's filter is the caller's active-role permission set. ActivityRecordService because a template edit needs a before-value the global interceptor cannot supply. JobFailureRecorderService because the dead-letter shape is platform-wide.
Deliberately local rather than shared. NotificationPreferencesService constructs its own PreferenceResolverService rather than injecting one, because it needs only defaultsFor — a pure function over a constant — and injecting would couple a read screen to the fan-out module's provider graph. computeLockedCategories is a module-level function in the preferences service rather than a registry export, because it answers a UI question ("would this switch do anything?") that the registry has no reason to know about.
16.2 Architecture Diagram Pack
Component view:
Class view:
Delivery state machine:
Event fan-out state machine:
Runtime topology:
Data lineage for one recipient:
16.3 Code Flow Narrative — ChannelSendService.send
| Step | Code Location | What Happens | Why It Happens | Failure/Edge Case |
|---|---|---|---|---|
| 1 | BaseChannelProcessor.process | Reads deliveryPublicId and correlationId from the payload. | The payload is a reference, never a rendered message, so no token ever enters Redis. | A missing id throws. |
| 2 | recorder.claim | queued -> processing with claimed_at, lease_expires_at, claimed_by. | Nothing else may work this row, and a dead worker's claim must be reclaimable. | Zero rows returns quietly — another worker holds it, or it is cancelled, sent or dead. |
| 3 | Channel assertion | Compares delivery.channel with the queue's channel. | A mismatch means the fan-out routed by the wrong map, so every delivery of that channel is misrouted. | Throws loudly. |
| 4 | loadContext | One LEFT JOIN across recipient, event and user. | LEFT, because user_id is nullable after erasure and an inner join would drop the row instead of recording why nothing was sent. | Nothing back is skipped_no_destination. |
| 5 | Liveness re-check | user_id IS NULL, deleted_at. Not can_login, which decides authentication rather than reachability. | An event scheduled a day out fans out before a deletion an hour later. | skipped_no_destination. |
| 6 | isKnownKind | The registry still knows the kind. | A deleted feature leaves rows behind. | skipped_no_template. |
| 7 | secrets.resolve | Merges event and recipient variables, then GETDELs any secretRef. | The live token exists in memory for one call and is never persisted. | null is skipped_no_destination — a reset link to nothing is worse than no email. |
| 8 | safeActionUrlOrNull | Re-validates the stored action URL. | A row can arrive by migration or fixture; this check is the one that protects the recipient. | Unsafe becomes null, so no button rather than a failed send. |
| 9 | renderer.loadOverrides + render | Active override for (kind, channel, locale), else the registry builder. | A bad operator edit degrades to shipped wording rather than silence. | ok: false is skipped_no_template. |
| 10 | dispatch | Resolves the destination and calls the provider. | Vault overrides win for email and SMS, so verification reaches the address being verified. | An unnormalisable phone or an inactive token is no_destination. |
| 11 | recordSent / recordSkipped / recordFailure | Writes the terminal state and clears the lease. | Every transition names every column it owes, or a paired CHECK rejects it. | — |
| 12 | persistRendered update | A second UPDATE writing rendered_title/rendered_body. | Only for kinds that opt in; never a security kind, whose rendering contains a resolved secret. | — |
| 13 | Throw on failure | Error naming the delivery, state, channel and error code. | BullMQ must record it, and onFailed must get a chance to write the dead letter. | — |
| 14 | onFailedShared | recordIfTerminal with { notificationDeliveryPublicId, channel }. | payload_ref, never the payload. | Swallowed and logged if the insert itself fails. |
| 15 | Known tradeoff | No delivery receipt is implemented, so a remote channel stops at sent. | sent is provider acceptance, not proof of arrival, and the schema already models delivered for when receipts land. | — |
16.4 Data Layer Deep Dive
Field-level meaning, nullability, validation source, delete behaviour, timestamps and JSON shapes are given per table in section 5.2. What follows is the index rationale and the JSON contracts.
| Index/Constraint | Columns | Type | Query/Invariant Supported | Tradeoff |
|---|---|---|---|---|
notification_event_source_dedupe_key | source_module, dedupe_key | unique | The whole duplicate-protection story at the event level | One write amplification per insert |
notification_event_occurred_at_idx | occurred_at DESC | btree | The admin history default sort, and the retention scan | — |
notification_event_kind_idx | kind, occurred_at DESC | btree | History filtered by kind | — |
notification_event_category_idx | category, occurred_at DESC | btree | History filtered by category | — |
notification_event_sweep_idx | created_at, id where unclaimed, unfanned, uncancelled | partial btree | The orphan sweep. id is in the index so the tie-break is not a sort — a batch boundary splitting equal timestamps is exactly where a watermark loses rows | Only serves the sweep's own predicate |
notification_recipient_event_user_key | event_id, user_public_id_snapshot | unique constraint | Idempotent batch re-insert, and equality on event_id alone | Declared as a constraint, not an index, because drizzle-kit orders FKs before indexes |
notification_recipient_user_idx | user_id, created_at DESC, id DESC | btree | The centre's list, with a stable tie-break for offset paging | — |
notification_recipient_unread_idx | user_id where read_at IS NULL | partial | The unread badge | Only counts unread rows, which is all it is for |
notification_recipient_created_at_idx | created_at | btree | Retention, bottom-up | — |
notification_recipient_audience_role_idx | audience_role_id | btree | The FK's referential scan on role deletion | — |
notification_delivery_single_target_key | recipient_id, channel where no target and not a replay | partial unique | One delivery per single-target channel | Partial, so it serves no query that omits its predicate |
notification_delivery_multi_target_key | recipient_id, channel, provider_target_id where a target and not a replay | partial unique | One delivery per push token | The same |
notification_delivery_recipient_idx | recipient_id | btree | The centre's EXISTS, the unread count, the retention join, the cascade scan — none of which mentions the partial predicates | Write overhead on the largest table |
notification_delivery_lease_idx | lease_expires_at where processing | partial | The reaper's lease scan | — |
notification_delivery_retry_idx | next_attempt_at where failed | partial | The reaper's backoff scan | — |
notification_delivery_queued_idx | queued_at where queued | partial | Backlog inspection | — |
notification_delivery_created_at_idx | created_at | btree | Retention and backfill | — |
notification_delivery_provider_message_idx | provider, provider_message_id where present | partial | A delivery-receipt webhook, when one lands | Speculative until receipts exist |
notification_delivery_replay_of_idx | replay_of_delivery_id where present | partial | Finding a replay chain | — |
notification_push_token_active_token_key | token where is_active | partial unique | One active row per token, while keeping invalidation history | Grows monotonically without the retention sweep |
notification_push_token_active_idx | user_id where is_active | partial | Every push send reads exactly this | — |
notification_push_token_user_idx | user_id | btree | The FK's referential scan over inactive rows, which are the majority after a year of reinstalls | — |
notification_push_token_device_idx | user_device_id | btree | The FK's covering index | — |
notification_push_token_invalidated_idx | invalidated_at where present | partial | The retention sweep | — |
notification_template_kind_channel_locale_key | kind, channel, locale | unique | One override per addressable slot | — |
notification_template_updated_by_idx | updated_by | btree | The FK's covering index | — |
notification_occurred_at_idx | occurred_at DESC | btree | The feed's list and its retention sweep | — |
notification_permission_idx | permission | btree | The feed's permission filter | — |
notification_read_user_idx | user_id | btree | "Everything this person has read", for the unread count | — |
JSON contracts:
// notification_event.audience — a specification, never a list
{ "kind": "class", "classPublicIds": ["6b1f...", "9c22..."] }
{ "kind": "compound", "any": [
{ "kind": "role", "rolePublicIds": ["..."] },
{ "kind": "guardians_of_class", "classPublicIds": ["..."] }
] }
{ "kind": "all_users" }// notification_event.variables — shared template variables, never a credential
{ "title": "Sports day moved", "message": "It is now on Friday." }
{ "secretRef": "password_reset:0f2b1c9e-..." }// notification_recipient.variables — the per-recipient overlay
{ "name": "Aarati Shrestha" }// job_failures.payload_ref — a reference, never the payload
{ "notificationDeliveryPublicId": "018f...", "channel": "sms" }
{ "aggregateType": "feedback", "aggregateId": "018f...", "eventType": "feedback.submitted" }Money units, timezones and seeds. No column here carries money. Every timestamp is timestamptz, and the only local-time reasoning in the module is the credit check's day key, computed in Asia/Kathmandu because a UTC key rolls over at 05:45 local. No table in this module is seeded — notification_template deliberately so, and the preference tables because absence is the code default.
16.5 Business Logic and Invariant Catalog
| Invariant | Enforced By | Why It Exists | Failure Error | Tests |
|---|---|---|---|---|
| A caller cannot choose category, priority, suppressibility or dedupe policy | SendNotificationInput omitting them; the registry supplying them | Otherwise any module sends a marketing blast as system past every opt-out, at real SMS cost | Type error at compile time | Registry and send specs |
One event per (source_module, dedupe_key) | notification_event_source_dedupe_key | A caller that raises the same event twice inserts once | Silent no-op via onConflictDoUpdate | Fan-out spec |
A repeatable kind must carry a request id | NotificationService.buildDedupeKey | Otherwise the second reset request returns 200 having sent nothing — permanently, for anyone whose first code went astray | NOTIFICATION_DEDUPE_REQUEST_ID_REQUIRED | Send spec |
A collapse kind must not | The same | A request id would defeat its own dedupe | NOTIFICATION_DEDUPE_REQUEST_ID_FORBIDDEN | Send spec |
| Event variables carry no credential | chk_notification_event_variables_no_credentials, plus the vault | NotificationHistory_READ is not superadmin-only | 23514 | Constraint probe |
| Requested channels are a non-empty subset of the vocabulary | Two CHECKs on the event | A zero-channel event can never deliver and would sit forever looking scheduled; a typo'd channel produces zero delivery rows with no error and no log | 23514 | Constraint probe |
| A persisted audience kind has a resolver | chk_notification_event_audience_kind_known plus the Record in code | The data path and the code path each need their own guard | 23514, or NOTIFICATION_AUDIENCE_KIND_UNKNOWN | Constraint probe, resolver spec |
| An action label implies an action URL | chk_notification_event_action_is_complete | A label with no destination renders a dead button in every channel | 23514 | Constraint probe |
| Fan-out completion is atomic | chk_notification_event_fanout_complete, and one UPDATE | A CHECK is evaluated at statement end and cannot be deferred | 23514 | Fan-out spec |
| Completion implies a claim | chk_notification_event_completion_implies_claim | The reverse is the legitimate in-progress state | 23514 | Constraint probe |
| One delivery per single-target channel per recipient | notification_delivery_single_target_key | Duplicate sends at metered cost | 23505 | Fan-out spec |
| One delivery per push token per recipient | notification_delivery_multi_target_key | Per-target outcomes must not collapse | 23505 | Fan-out spec |
| A replay may reuse a slot | Both uniques excluding replay_of_delivery_id IS NOT NULL | Otherwise a replay cannot insert at all | — | Dead-letter spec |
| A delivery is never replayed twice | job_failures.replayed_at compare-and-set, inside the replay transaction | Two sends for one operator click | JOB_FAILURE_ALREADY_REPLAYED | Dead-letter spec |
| A claim implies a lease and vice versa | chk_notification_delivery_lease_is_complete | A lease with no claim hands the row to a second worker while the first still runs | 23514 | Constraint probe |
sent/delivered implies sent_at, except in-app | chk_notification_delivery_sent_at_present | The unscoped form aborts the whole fan-out batch | 23514 | Fan-out spec, constraint probe accept case |
A skipped status implies skipped_at, and vice versa | chk_notification_delivery_skipped_at_present | Half-written skip states | 23514 | Backfill spec |
| Counters never go negative | chk_notification_delivery_counters_non_negative | An arithmetic bug would otherwise hide | 23514 | Constraint probe |
| Retry terminates | attempts incremented by both failures and lease reclaims | A reclaim that incremented nothing re-queues a crashing send forever, unbounded, at metered cost | Row reaches dead | Reaper spec |
Backoff has a ceiling and no NaN | computeNotificationBackoffSeconds | 2 ** 1024 is Infinity, and Infinity * base is NaN, which writes a NULL next_attempt_at and strands the row | — | Constants spec |
| An unsuppressible kind is always delivered | PreferenceResolverService.isEnabled step 1 | A user must be able to recover their own account | — | Preferences spec |
Only security and system may hold an unsuppressible kind | UNSUPPRESSIBLE_CATEGORY, applied by convention in the registry | Scope limitation | — | Registry spec |
An in_app kind persists its rendering | A boot-time loop in template-registry.ts | Otherwise the notification arrives, counts toward the badge, and displays blank, with nothing failing | Error at boot | Registry assertion |
| An unresolved template variable suppresses the send | TemplateRendererService.interpolate returning null | Delivering literal {{name}} is metered, paid for and invisible to every gate | Falls through, then skipped_no_template | Renderer spec |
| A template override never silences a notification | Fall-through to the registry builder | A bad edit must be cosmetic | — | Renderer spec |
| A token belongs to one user at a time | Invalidate-and-reinsert, plus the partial unique | Reassignment is a denial of service on the victim's push | — | Devices spec |
| A person holds at most N active tokens | NOTIFICATION_MAX_TOKENS_PER_USER, checked in the register transaction | Push amplification | NOTIFICATION_DEVICE_LIMIT_REACHED | Devices spec |
| Preferences save atomically across categories | notification_preference_set.version compare-and-set | Two devices saving at once otherwise interleave into a mixed state with no error | NOTIFICATION_PREFERENCE_VERSION_CONFLICT | Preferences spec |
| A template edit cannot silently overwrite a concurrent one | notification_template.version compare-and-set on PATCH and DELETE | A delete could otherwise win over an edit | NOTIFICATION_TEMPLATE_VERSION_CONFLICT | Template spec |
| The centre never shows a row the caller may not see | The four-part predicate on every method | RoleGuard returns true before reading the user for these handlers | 404 | Centre spec |
| The feed never shows a row the active role may not see | The permission filter, with false for an empty set | inArray with an empty list reads as "no filter" in some drivers | — | Feed spec |
| The dead-letter screen cannot reach another queue | REPLAYABLE_QUEUES, ANDed unconditionally | A clerk could otherwise re-run a database restore | JOB_FAILURE_QUEUE_NOT_PERMITTED | Dead-letter spec |
| Retention never deletes an in-flight delivery | The IN_FLIGHT_DELIVERY_STATUS predicate | The worker's claim would return zero rows and report success | — | Retention spec |
| Retention never deletes an unreplayed dead letter's delivery | The job_failures predicate | payload_ref is not an FK, so nothing else would stop it | — | Retention spec |
| The backfill window is shorter than the retention window | A throw at import in notification.constants.ts | Both are env-tunable and their legal ranges overlap, so a legal pair can be wrong | Error at boot | Constants assertion |
| A required channel is configured | assertConfiguredIfRequired | A 200 that delivers nothing | Error at boot | Providers spec |
No send path calls queue.add directly | Every enqueue goes through OutboxService | The write commits, the enqueue throws, the caller returns 200, nothing is scheduled | — | Fan-out and backfill specs |
16.6 Tradeoffs, Alternatives, and ADR Notes
| Decision | Context | Chosen Option | Alternatives | Why Chosen | Tradeoffs | Revisit Trigger |
|---|---|---|---|---|---|---|
| Two delivery models | An operational feed and a person-addressed system share a noun | Keep them separate: notification fans out on read, notification_event on write | One table with a discriminator | Neither is a special case of the other, and one table cannot carry both without a discriminator every query must remember | Two vocabularies, two retention paths, a naming hazard | A third model appears |
| Audience as a specification | A list snapshots membership at publish time | Store the spec, resolve per batch | Materialise recipients at send | A pupil enrolled an hour later receives it; one who left does not | The audience can change under a long fan-out | A requirement for a frozen audience |
| Fan-out asynchronous | Three thousand parents cannot resolve inside a request | Write two rows, resolve in a worker | Resolve inline | The caller's transaction stays short and a notification concern stays off the critical path | The event exists before anybody has it | — |
| Claim and completion as separate columns | One column cannot be both | fanout_claimed_at plus fanned_out_at | A single fanned_out_at | A crash mid-fan-out is recoverable and matched by a sweep | One more column and one more CHECK | — |
| The delivery row owns retry | Two budgets mean nobody owns termination | Row-level attempts, BullMQ pinned to 1 | BullMQ retry | The terminate decision has exactly one owner | Three env lines that must not be lost | — |
| Lease plus reaper | A dead worker's claim is indistinguishable from a live one | claimed_at/lease_expires_at plus a 30s sweep | A bare status claim | A message sent by a worker that then died is not recorded as never sent | A slow-but-succeeding send can be reclaimed and sent twice, which is why the lease exceeds the worst-case provider call | Provider timeouts change |
| Skips are not failures | An unconfigured machine must not look like an outage | Four skipped_* statuses excluded from every failure count | Mark them failed | A real outage is not buried in the noise | More statuses to reason about | — |
| Backfill worker | Otherwise skipped_unconfigured is terminal | Re-queue on a bounded age window | Leave them | A write path with a fallback needs a matching read path | Adding a provider late can resurrect a day of messages | The window proves wrong in practice |
| Secrets in Redis, not Postgres | verification stores hashes, so the raw value cannot be re-derived | A short-TTL vault, GETDEL on read | A DB reference, or the value in variables | The secret is never in retained history, a dead letter, a log or rendered_body | A Redis flush loses one email | — |
| Render at fan-out for in-app, at send for the rest | The centre needs frozen content; a security kind must not persist one | persistRendered per kind | Render everything at read time | A template edit cannot rewrite what was already delivered, and no secret is stored | Two rendering call sites | — |
| Registry in code, overrides in a table | An operator needs to change wording; a bad edit must not silence a reset | Code is the source of truth and the fallback | Templates only in the database | A bad edit degrades to shipped wording | The table cannot be the whole story for a new kind | — |
| Template table not seeded | A seeded row that is renamed then re-inserted duplicates silently | Empty is correct | Seed the registry | No rename-then-duplicate hole | Operators start from a blank screen | — |
Application-incremented version | drizzle-kit does not generate triggers | SET version = version + 1 WHERE version = $expected | A trigger, or updated_at | Survives up -> down -> up; immune to same-millisecond commits | Every writer must remember it | drizzle-kit gains trigger support |
| Two-level preferences | A per-category "all" row materialises a list | A global channel switch plus per-category overrides | One row per category, or a sentinel category | A category added later is covered by an existing global mute | Two lookups instead of one | — |
| Push per token | One row cannot hold two outcomes | One delivery row per token | One row per recipient per channel | Per-target status and message id survive | More rows on the largest table | — |
| Separate channel queues | SMS is metered, push is high-volume | Three queues, three concurrencies | One notifications queue | An announcement blast cannot delay a login OTP | Three workers to operate | — |
| Operational alerts on their own queue | They need BullMQ retry; channel queues must not have it | NOTIFICATION_OPERATIONAL with the default attempts | A job on the email queue | attempts is per queue and the two need opposite values | A fourth queue | — |
| Retention at 30 days | One announcement is roughly four thousand rows | 30 days, floor 7 | A year | The table would outgrow every other within a term, and the rows have no use once read | A delivery from last quarter cannot be investigated | An audit requirement |
| Retention sweeps the admin feed too | It had no owner and grew forever | Prune it in the same tick | A separate worker | An ever-growing table is not an error and no gate could see it | A module deleting from a table it does not own, stated here | The feed gains its own worker |
| No read-through cache | Every read is per-caller or an operator screen | None | Cache the centre or the feed | A cache with one entry per person saves nothing; an operator screen's value is being current | Every read hits Postgres | A read surface becomes shared and hot |
404, never 403, on an id-addressed route | Public ids are uuids | Invisible answers as absent | 403 | A 403 confirms the row exists | An operator debugging cannot tell the two apart | — |
| Public id vs internal id | Enumeration and coupling | uuid v7 public_id on every externally addressable row; serial internally | uuid primary keys | v7 sorts by creation, so it is a usable tie-break | Two identifiers per row | — |
16.7 Operational Runbook
| Operation | How to Inspect | Healthy State | Failure Signal | Recovery |
|---|---|---|---|---|
| Is anything being sent at all? | The admin history screen, or SELECT status, count(*) FROM notification_delivery GROUP BY 1 | A mix of sent, delivered and a small skipped_preference tail | Everything skipped_unconfigured | Supply provider credentials; the backfill worker re-drives the last 24 hours automatically |
| A specific person did not receive something | History detail for the event, then that recipient's deliveries | A row per requested channel with an explanatory status | skipped_preference, skipped_no_destination, dead | The status is the answer. skipped_preference is the person's own setting; skipped_no_destination is a missing address or token |
Deliveries stuck processing | SELECT count(*) FROM notification_delivery WHERE status='processing' AND lease_expires_at < now() | Zero, or briefly non-zero between reaper ticks | A growing count | Check the reaper is running — notification.reap every 30s. lease_expiry_count rising means workers are dying |
Deliveries stuck queued | notification_delivery_queued_idx; the outbox's own pending count | A short queue that drains | A growing backlog with no worker log lines | Confirm NotificationWorkerModule is composed, that the outbox dispatcher is running, and that the channel queue worker is registered |
| An event that never fanned out | SELECT * FROM notification_event WHERE fanned_out_at IS NULL ORDER BY created_at | Only recent or future-scheduled rows | Old rows with fanout_claimed_at set | The reaper's stale-claim sweep releases them after ten lease periods; the orphan sweep re-dispatches unclaimed ones after the grace period |
| Duplicate messages | attempts, lease_expiry_count on the affected rows | attempts 1, lease_expiry_count 0 | lease_expiry_count above 0 | The lease is shorter than the real provider call. Raise NOTIFICATION_LEASE_SECONDS |
SMS suddenly all dead | last_error on recent SMS deliveries; the daily credit log line | SMS credit is LOW well before zero | SMS_INSUFFICIENT_CREDIT | Top up. The failure is non-retryable by design, so nothing self-heals |
| Push failing for one device | notification_push_token.is_active, invalidated_reason | unregistered or invalid_argument after a reinstall | Every token for a user inactive | The app re-registers on next launch. replaced means the token moved to another account |
| A template edit broke wording | The template list; the warnOnce log line | No warning | override ... could not be rendered — falling back | The built-in wording is already being used. Fix or deactivate the row |
| Tables growing | Row counts on the four notification tables and on notification | Bounded by the retention window | Continuous growth | Confirm notification.prune runs hourly and that nothing is permanently in-flight blocking deletion |
| Queue health | Bull Board | Jobs completing; notification_fanout mostly idle | Failed or delayed jobs | job_failures is the durable record — use the dead-letter screen, not BullMQ's trimmed failed set |
| A fan-out to nobody | The resolved to ZERO recipients warning | Absent | Present | The audience matched nothing — usually an empty class or a wrong public id |
16.8 Backend Risk Register
| Risk | Area | Impact | Current Mitigation | Remaining Gap |
|---|---|---|---|---|
| A worker dies mid-provider-call | Channel send | The message goes out and the record says it did not | Lease plus reaper; attempts incremented on reclaim | A reclaim during a slow-but-successful send sends twice. Bounded by the lease exceeding the worst-case provider call |
| Two workers on one queue | Worker composition | Jobs silently handled by the wrong class | Exactly one @Processor per queue, enforced by convention and reviewed | Not statically enforced |
| A job name with no handler | Fan-out queue | A job completes having done nothing | Record over a closed union; a throw on an unknown name at runtime | A job already in Redis carries its name as a string, so removing a name strands it |
| Redis flushed between stash and send | Secret vault | One security email cannot be rendered | skipped_no_destination, and the Postgres token is still valid | The user must ask again; nothing retries |
| Retention deletes a row a worker holds | Retention | Neither sent nor recorded | The in-flight predicate | A status added to DELIVERY_STATUS and not to IN_FLIGHT_DELIVERY_STATUS reopens it |
| Retention deletes a delivery about to be replayed | Retention | Replay finds nothing | The unreplayed dead-letter predicate | payload_ref is jsonb, so the predicate is a string comparison rather than a join |
| A legal-but-wrong backfill/retention pair | Configuration | The backlog is deleted before it is re-driven | A throw at import | Only checked at boot, so a config change needs a restart to be caught |
| An event stuck half-fanned-out | Fan-out | Under-delivery with no error | The stale-claim sweep plus fanout_cursor | A worker that hangs without dying holds the claim for ten lease periods |
| Push token table growth | Data | The partial unique index grows monotonically | The token retention sweep, which now runs even on an empty event tick | Bounded by NOTIFICATION_PUSH_TOKEN_RETENTION_DAYS |
| Admin feed growth | Data | Unbounded table | The feed sweep in the retention tick | Owned by this module rather than by notification-feed |
A raw provider response reaching last_error | Security | An OTP behind an operational permission | Fixed error tables in all three providers | A new provider must follow the same rule |
| An operator override that evaluates expressions | Security | Code execution behind a CRUD permission | {{name}} substitution only, from an allowlist the variables define | — |
| Guardian audience addressing the wrong family | Security | One family's notification to another | Guardians resolve through student_guardian, never a caller-supplied user list | — |
| A dismissed teacher still reading staff notifications | Security | Disciplinary and roster content after dismissal | The centre's staff-liveness predicate | Only the centre applies it; the history screen carries no per-recipient content to leak |
| Duplicate SMS at metered cost | Cost | Real money | One BullMQ attempt, a lease, a bounded budget, non-retryable classification for provider rejections | A reaper reclaim during a successful send |
| A mock provider in production | Correctness | Looks healthy, delivers nothing | A boot throw when the channel is required, a boot WARN otherwise | Only a required channel throws |
| No delivery receipts | Completeness | sent is not proof of arrival | The schema already models delivered; the provider-message index is in place | Not implemented |
17. Zero-Omission Backend Checklist
- Every file in the module directory is represented in section 4 and 16.1.
- Every controller, service, provider, processor, scheduler, helper, DTO, enum and schema is documented.
- Every method with business behaviour has a code-flow narrative or a per-method table.
- Every table, cache object and job payload has field-level detail.
- Every index, constraint, relation and delete behaviour has a rationale.
- Both lifecycles — the delivery state machine and the event fan-out — have a transition diagram and a table.
- Every read, write, action and job flow has a diagram and branch notes.
- Every business invariant is catalogued.
- Every Redis key, queue job, realtime event and external call is documented.
- Every architectural tradeoff is documented with alternatives and a revisit trigger.
- Every operational failure mode has a runbook entry.
18. Backend Completion Checklist
- Module boundaries are documented, including what this module explicitly does not own.
- Every controller, service, DTO, schema file, job, Redis key and event is covered.
- Every table has a field table, and the two clusters have a relationship diagram.
- Every runtime flow has a diagram and branch notes.
- The API and features/flows docs are linked.
- No claim is made without a source file behind it.
See Also
- API doc: /docs/developer/notification/api
- Features and flows doc: /docs/developer/notification/feature