Skip to content

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.md
  • docs/superpowers/specs/2026-08-12-push-notifications-design.md
  • docs/superpowers/specs/2026-08-25-notification-catalogue-implementation-design.md
  • docs/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 User ids 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:

  1. loads caller-recipient registrations and school language under tenant GUC;
  2. renders one notification and creates string-only data containing kind, derived targetType, and defined payload fields; scalars use String(value) and array values use JSON.stringify (inbox payload arrays remain JSON arrays); kind and targetType are engine-owned reserved keys;
  3. delegates to the module-internal PUSH_SENDER;
  4. prunes only outcomes marked for messaging/installation-id-not-registered;
  5. 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:

  1. create/select the Firebase project and enable the FCM API;
  2. register the web app and create the Web Push VAPID key;
  3. create the backend service account and download its JSON once;
  4. strict-base64 the complete JSON and store it as a hosted secret;
  5. keep PUSH_TRANSPORT=log until credentials and FE are deployed;
  6. switch to fcm and 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

  1. Add the typed payload and kind in notification.interfaces.ts.
  2. Map the kind in notification-target.registry.ts; reuse an existing domain target unless the notification opens a genuinely new destination family.
  3. Add a Resend spec/template pair if it should email.
  4. Add a NOTIFICATION_PUSH_SPECS entry if it should push, covering both languages and every enum branch.
  5. Resolve domain recipients to User ids in the consumer.
  6. Call NOTIFICATION_PORT.send() after commit.
  7. 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.