Notifications¶
The platform's single user-notification seam. Consumers inject
NOTIFICATION_PORT and call send() after their transaction commits. The
engine fans out in order to the always-on inbox, email, FID push, and the
log/memory observability leg. Each leg is isolated and send() never rejects.
Governing specs:
docs/superpowers/specs/2026-07-23-notification-engine-design.mddocs/superpowers/specs/2026-08-12-push-notifications-design.mddocs/superpowers/specs/2026-08-25-notification-catalogue-implementation-design.mddocs/superpowers/specs/2026-08-25-notification-catalogue-navigation-iteration-1-design.md
1. Engine contract¶
consumer -> NotificationPort.send()
-> inbox row (canonical)
-> email (per-kind Resend template)
-> push (per-kind BE copy -> PUSH_SENDER)
-> log/memory observability
- Call only after the domain transaction commits.
- Inbox is the canonical durable copy until its recipient explicitly deletes it; other channels are advisory.
- Audience is always tenant + resolved
Userids and is identical on every channel. - A missing channel spec or unreachable recipient is a silent skip.
- Every leg is independently fail-soft. Provider, lookup, and prune failures never reach a consumer.
- Background channels open their own
withTenantGuc(tenantId)transaction.
notification.interfaces.ts owns the kinds and payload contracts. Payloads
contain destination-neutral stable IDs, event dates, and the bounded display
context needed to reload a reasonable authorized destination; they never carry
routes, URLs, screen names, authorization assumptions, or pre-rendered copy.
discipline.note deliberately omits note text, and profile-completion reminders
deliberately omit missing field names.
notification-target.registry.ts exhaustively maps every event kind to a
route-neutral targetType. kind continues to select event copy and payload
shape; targetType selects the coarser domain destination family. Producers do
not send it and inbox rows do not store it: inbox DTOs and push data derive it
from the central mapping.
2. Channels¶
Inbox¶
Notification stores one row per recipient. The auth-only, self-scoped API is:
| Method | Path | Behavior |
|---|---|---|
| GET | /notifications |
Newest-first caller inbox; optional unread=true |
| GET | /notifications/unread-count |
Cheap badge poll target |
| PATCH | /notifications/:id/read |
Idempotent; missing/not-owned is the same 404 |
| POST | /notifications/read-all |
Marks all caller rows read |
| DELETE | /notifications/:id |
Permanently deletes one caller-owned row; missing/not-owned is the same 404 |
Every returned NotificationDto contains kind, targetType, and payload.
The list and mark-read responses derive targetType in the same DTO mapper.
Email¶
notification-email.registry.ts maps kinds to published Resend template aliases
and localized variables. School primaryLanguage selects en or it, falling
back to en. Publish both aliases before enabling a new email kind.
Push¶
notification-push.registry.ts owns short English/Italian push copy because FCM
has no template store. Bodies lead with the payload date and disciplinary copy
never includes note text.
PushNotificationTransport:
- loads caller-recipient registrations and school language under tenant GUC;
- renders one notification and creates string-only
datacontainingkind, derivedtargetType, and defined payload fields; scalars useString(value)and array values useJSON.stringify(inbox payload arrays remain JSON arrays);kindandtargetTypeare engine-owned reserved keys; - delegates to the module-internal
PUSH_SENDER; - prunes only outcomes marked for
messaging/installation-id-not-registered; - emits one metadata-only summary and at most one warning per error code.
The production adapter uses Firebase Installation IDs only. FcmPushSender is
the sole firebase-admin import site, targets the Admin SDK fids field, and
chunks at 500 FIDs. The minimum supported Admin SDK is 14.1; this implementation
uses firebase-admin 14.2.x. Legacy registration-token token/tokens
targeting is not part of this integration.
Sender bindings:
log: hosted-safe metadata-only would-send line;memory: full local/test capture, rejected outside local/test;fcm: real Firebase delivery.
FIDs, title, body, payload data, student names, dates, and domain ids never
appear in hosted push logs. Request logging redacts req.body.installationId.
3. Registration API and lifecycle¶
PushDeviceRegistration binds one Firebase Installation ID to its current SIS
user within a tenant. (tenantId, installationId) is unique; the same FID text
may exist independently in another tenant. The table has Class-S RLS and is
included in strict-tenancy and tenant-reset coverage.
Both endpoints are auth-only and self-scoped:
| Method | Path | Body | Result |
| ------ | ----------------------------------- | -------------------------------- | -------------------------------------------------- | ------ | --------------------------------------------------- |
| PUT | /notifications/push-registrations | { installationId, platform: WEB | ANDROID | IOS } | 204; create, refresh, or reassign within the tenant |
| DELETE | /notifications/push-registrations | { installationId } | 204; idempotently remove only the caller-owned row |
The identifier is never placed in a path, query string, response, or log. The
client owns installation rotation when {tenantId, userId} changes and deletes
the registration before logout while authentication is still valid. See the
authoritative FE guide for exact ordering.
4. Environment and Firebase rollout¶
| Variable | Values | Meaning |
|---|---|---|
NOTIFICATION_TRANSPORT |
log | memory |
Observability/test leg only |
PUSH_TRANSPORT |
log | memory | fcm |
Actual push sender |
FIREBASE_SERVICE_ACCOUNT_BASE64 |
strict base64 JSON | Required only for PUSH_TRANSPORT=fcm |
Unset transport variables fall back to log only in local/test. Hosted
environments must set them explicitly. Hosted memory is a boot error. For
fcm, boot validation checks strict canonical base64, JSON-object shape,
non-empty project_id/private_key/client_email, and parses the private key.
One-time setup, performed when the technical account is ready:
- create/select the Firebase project and enable the FCM API;
- register the web app and create the Web Push VAPID key;
- create the backend service account and download its JSON once;
- strict-base64 the complete JSON and store it as a hosted secret;
- keep
PUSH_TRANSPORT=loguntil credentials and FE are deployed; - switch to
fcmand perform a real-browser registration/delivery/tap smoke.
Never commit the JSON or its base64 form. Future mobile apps use the same project and native Firebase Messaging FID APIs with their platform configuration. Expo/native tokens are not interchangeable with FIDs: the authoritative FE guide defines the required native proof spike and records the production auth/origin-check dependency that must be resolved before mobile writes ship.
5. Product-approved catalogue producers (2026-08-25)¶
The five catalogue additions are inbox + push only; none has an email spec.
The complete FE payload schemas and destination-neutral loading contract live
in docs/fe-guides/2026-08-04-notifications-FE-guide.md. The domain-event and
Italian-copy approval map lives in
docs/fe-guides/2026-08-20-notification-event-map.md.
| Kind | Producer and audience |
|---|---|
timetable.published |
A real first publication or republication. Resolve on the new version's effectiveFrom: involved lesson teachers/students, supervised activities, and break/lunch duties; notify active teacher users plus active referents of involved students. Dual-role users receive one recipient-specific payload. |
subject_group.teacher_assigned |
A newly opened course-teacher episode from direct create/update or create-from-homeroom. Notify the active linked teacher user; pure reorder, stayer, removal, and failed/no-op commands do not notify. Same-command assignments group per teacher/date. |
roster.changed |
Actual student add/remove/move deltas from explicit homeroom/course commands, including homeroom-linked course cascades. Homeroom tutors and course teachers are resolved for the movement date. Initial formation, import, rollover, unrelated reconciliation, and deletion are excluded. |
curriculum.selection_reminder |
Automatic referent milestones while a writable linked child's applicable choice remains incomplete: window opening, 14 days before the shared deadline, 7 days before it, and deadline day. |
profile.completion_reminder |
Automatic referent milestones while the referent or a writable linked child has a referent-actionable required field missing: 14 days before the earliest relevant department start, 7 days before it, school-start day, and the first day after the pre-year deadline. Missing field names never leave the authenticated domain read. |
Command producers compute persisted deltas and recipient context inside their
own transaction, then register immutable intents with PostCommitCoordinator.
The port is called only after the outer request transaction commits; a rollback
drops the queued jobs.
Automatic family milestones are produced by
NotificationMilestonesService. It captures school-today once per tenant,
scans only the ACTIVE academic year under withTenantGuc, and attempts a
ScheduledNotificationClaim insert before each send. The unique
(tenantId, recipientUserId, kind, occurrenceKey) key is the cross-process
at-most-once backstop. Claims commit before the advisory send, so a process
failure between claim and fan-out can lose one alert; this is the same accepted
at-most-once posture as the attendance notifier. Claims have Class-S RLS,
strict-tenancy registration, and tenant-reset coverage; they are internal
machine evidence, not user-facing audit rows.
NOTIFICATION_MILESTONE_SWEEP_INTERVAL_SECONDS controls the dynamic sweep
cadence: default 300, range 0..3600, and default 0 under NODE_ENV=test.
The public sweep() seam remains callable for deterministic tests. The worker
is re-entrancy guarded and fail-soft per tenant/recipient.
Manual curriculum/completeness reminder buttons, sender permissions, APIs,
recipient targeting, repeated-click semantics, and copy are deferred as one
future block. Concrete frontend routes remain deferred. The backend supplies a
route-neutral targetType plus the stable ids already present in the payload;
it does not supply a URL, route name, screen name, or authorization assumption.
Student-derived audiences are ENROLLED-only (2026-09-03). Every producer
that resolves recipients through students applies the live participation
fence (participatingStudentWhere, src/students/constants/participation-status.ts):
the timetable.published lesson leg now matches its break/activity legs, and
the attendance notifier's notify and reminder passes skip events of students
who have since left or were never enrolled (the event rows stay open and
hidden). The curriculum-selection and profile-completeness reminders keep
their pre-year {ENROLLED, PRE_ENROLLED} cohort on purpose. Design:
2026-09-03 spec.
6. Adding a notification kind¶
- Add the typed payload and kind in
notification.interfaces.ts. - Map the kind in
notification-target.registry.ts; reuse an existing domain target unless the notification opens a genuinely new destination family. - Add a Resend spec/template pair if it should email.
- Add a
NOTIFICATION_PUSH_SPECSentry if it should push, covering both languages and every enum branch. - Resolve domain recipients to
Userids in the consumer. - Call
NOTIFICATION_PORT.send()after commit. - Update Swagger, this chapter, and the FE guide.
No consumer selects channels, handles FIDs, or imports a sender.
7. Deferred work¶
Durable notification outbox/retries; per-user channel preferences; per-recipient locale; registration staleness policy; platform-specific message configuration; multi-account installation aggregation; delivery reports; inbox retention; and admin cross-user inbox views.