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.

Canonical implementation: src/notifications/notification.service.ts, the kind and channel registries in the same module, and producer services in their owning domains.

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.
  • Every channel receives the same tenant and resolved User IDs. Channel-specific eligibility still applies: email requires an active account/address and push requires a registration.
  • 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; the attendance retraction, for example, uses the attendance-retracted-it / attendance-retracted-en templates.

Push

notification-push.registry.ts owns short English/Italian push copy because FCM has no template store. Attendance and disciplinary bodies lead with the payload date; disciplinary copy never includes note text. communication.received has generic copy and carries no date or message content.

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 Firebase adapter uses Installation IDs only. FcmPushSender is the sole firebase-admin import site, targets the SDK's fids field, and chunks at 500 FIDs. Dependency versions are pinned by package-lock.json. Registration-token token/tokens targeting is not accepted by 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 }; platform is WEB, ANDROID or 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. Register only after the target SIS tenant/user session is established.

4. Environment and credentials

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.

For real delivery, configure a Firebase project, its platform applications, and a service account. Store the complete base64-encoded service-account JSON as a hosted secret; never commit either form. Web clients require their Firebase web configuration and Web Push key. Native clients must supply Installation IDs compatible with the sender contract and satisfy the API's authentication/origin policy. Token formats from other push services are not interchangeable with FIDs.

5. Producers and audience

Channel participation is declared centrally: attendance events/reminders/retractions and disciplinary notes have email specifications; timetable publication, course assignment, roster changes, family milestones, communication receipts, assignment creation and recorded grades use inbox and push. Consult the channel registries when adding or changing a kind.

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, active users of involved ENROLLED students, and those students' active referents. Deduplicate by user with recipient-specific context.
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.
assignment.created AssignmentsService.create, once per created assignment. Audience: students who are current members of the assignment's course on school-today, ENROLLED, with an active user, minus the author's own user; one intent per student, deduplicated by user, carrying that student's ID. The payload omits the assignment text. Update, delete, attachment and lesson-content writes do not notify. Target assignment; inbox and push only.
grade.recorded GradesService.record, registered inside the transaction that creates revision 1 and resolved after commit; one intent to the graded student's own user when the student is ENROLLED and the user is active, and that user is not the recording caller. Corrections and withdrawals do not notify. The payload carries the grade, student, course, frozen subject label, assessment date and grade type, never the mark, title or note. Target type grade.
conferences.booking_cancelled ConferencesBookingsService, registered inside the cancellation transaction and resolved after commit; one intent to the booking's referent when that referent has an active user account, whether the family or an administrator cancelled. The payload carries { bookingId, conferenceId, conferenceTitle, teacherId, teacherName, date, startTime, endTime, cancelledBy } (FAMILY or ADMIN). Target type conference.booking; inbox only (no email or push specification). See Conferences.

For timetable.published, a student's own payload includes their student ID and relevant department IDs. A missing or inactive student account does not prevent their active referents from receiving the notification. Non-ENROLLED students contribute neither their own user nor referent recipients.

Command producers compute persisted deltas and recipient context inside their own transaction, then register immutable intents with sendAfterCommit (src/notifications/send-after-commit.ts), which queues one PostCommitCoordinator job that calls the port for every intent. The port is called only after the outer request transaction commits; a rollback drops the queued jobs.

Automatic family milestones are produced by NotificationMilestonesService in src/command-center/notification-milestones.service.ts. 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 extends TenantSweeper (src/common/scheduling/tenant-sweeper.ts), which owns the dynamic interval registration, the re-entrancy guard and the fail-soft per-tenant loop; the service itself is fail-soft per recipient.

Notification destinations are route-neutral: the backend supplies targetType and stable payload IDs; the client loads the destination through its authorized domain API. Possessing a notification grants no domain access.

Timetable publication and attendance alerts apply the live ENROLLED participation fence from src/students/constants/participation-status.ts. Attendance notify/reminder/retraction passes skip students who have left or were never enrolled while retaining the underlying event. Curriculum-selection and profile-completeness reminders include the pre-year {ENROLLED, PRE_ENROLLED} cohort because those actions are relevant before school starts.

Communication receipts

communication.received is emitted by communications/communications-notifications.service.ts through the existing port when an addressed recipient row becomes readable (status != PENDING). The target is communication.inbox; payload is exactly { communicationId, inboxItemId }. The latter is a CommunicationRecipient ID for GET /communications/inbox/:id, not a sender-archive ID.

At composition, accepted To/CC person references snapshot their active school User. At claim time the producer rechecks that account and the existing inbox ownership predicate. It claims all aliases for one communication/User under a parent row lock, commits, then attempts delivery with at most ten concurrent recipient sends. Both notification inbox and push participate; this kind has no notification email spec. Communication email is sent separately by its queue worker.

An initial post-commit callback covers immediately readable rows such as SKIPPED_NO_EMAIL; sender callbacks cover SENT batches and SENT/FAILED singles. Recovery scans at most 100 communication IDs per tenant/pass in keyset pages of 25 before the existing email sweep, independently of email work. It recovers pre-claim interruptions only. A committed claim is never retried, including after process/channel failure; historical null account snapshots stay excluded. Notification and communication read markers remain independent.

See Communications for receipt ownership and the email queue lifecycle.

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 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 and the relevant durable handbook contract.

No consumer selects channels, handles FIDs, or imports a sender.

7. Delivery boundaries

The engine has no durable outbox or retry loop of its own. Each producer owns its transaction boundary and claim semantics; do not infer delivery guarantees from a successful send() call. Inbox rows persist until recipient deletion, and the API has no cross-user administrative inbox. Channel selection and locale come from the engine registries and school configuration, rather than per-recipient preferences.