Skip to content

date: 2026-07-23 slug: notification-engine status: Approved # Draft | Approved | Superseded clickup_us: none — extracted from the attendance-v2 iteration when product paused attendance dev (chat 2026-07-23); generalized by Fabio: "the single way we send notifications to users, reusable by other modules"; channels + notification center folded in (chat 2026-07-24); email folded in as the first real transport (chat 2026-08-04) epic: platform related_specs: - docs/superpowers/specs/2026-08-04-attendance-family-loop-design.md (slices A–C in tree; slice D is the first consumer) - docs/superpowers/specs/2026-08-04-attendance-loop-notifications-design.md (the consumer spec — sweeper + cadence) supersedes: nothing live — it absorbed the NotificationPort/transport half of the attendance-v2 design, deleted 2026-07-25


Notification engine + notification center — platform user-notification seam with durable inbox

Resumed 2026-07-23 (decoupled from the attendance iteration). Iterated 2026-07-24: channel decisions landed (center + web push + mobile push, broadcast routing) and the notification center folded into v1 scope. Iterated 2026-08-04 for the attendance family-loop slice D: the three §10 confirms resolved, email joins v1 as the first real transport (MailerPort-backed, Resend template mode), and the consumer references repointed at the family loop (the attendance-v2 lineage this was extracted from is long deleted).

1. Problem distillation

  • The platform needs one way to send a notification to users. Today there is none: the only outbound channel is MailerPort (transactional email for invitations/credentials), and the attendance iteration was about to mint an attendance-private port. Extracted and generalized here before any consumer lands, so every future module (attendance communication loop, discipline notes, whatever comes next) programs against the same seam.
  • Channel decision (Fabio, chat 2026-07-24, extended 2026-08-04): the product will have an in-app notification center, email (first real out-of-app channel, v1 — Fabio 2026-08-04: "inbox + web push will definitely need copy sooner or later, make MailerPort an engine channel too"), and later real push — Web Push for browsers (delivery with the tab/browser closed is a requirement) and FCM/APNs for the mobile app. Per-flow channel policy is not yet known, so v1 routing is broadcast: every notification goes to every bound channel (a channel that cannot render a kind — no email spec, no address — skips silently). When product later differentiates (per-kind routing, user preferences), that lands as engine-side policy with zero consumer change — which is exactly why send() is channel-blind (no channels: field, ever).
  • Persist-first fan-out is the architecture:
consumer → send() → [engine] → inbox row  (canonical, always-on channel)  ← center UI / polling reads this
                             ↘ email      (v1 — MailerPort template mode, per-kind Resend template + variables)
                             ↘ web push   (deferred — ephemeral pointer, deep-links to the inbox row)
                             ↘ FCM/APNs   (deferred — same)

The inbox row is the source of truth; every other channel is a pointer/copy of it. A missed email or push loses nothing — the row waits in the center. Fan-out legs are independent and each fail-soft: one channel failing (mailer down, expired subscription) never blocks the others, and the inbox leg in particular must always be attempted. "Unreachable on a channel" (no email spec for the kind, no subscription, no device token) is a silent skip, not an error. - Rendering is per-channel (sharpened 2026-08-04): in-app surfaces (center, mobile app screens) render client-side from kind + payload — same division of labor as BE error-i18n. Out-of-app channels render at the transport: the email transport uses the house Resend template mode (per-language published template owns subject + body; the engine supplies a per-kind template alias + a variables map built from the payload — the credentials-email precedent, ${prefix}-${lang} alias on the school's primary language). No notification copy is ever hardcoded in BE strings. - In-session freshness is a pull problem: the open web app polls a cheap GET /notifications/unread-count (~30–60s). SSE/WebSocket rejected (§8) — the domain has no real-time requirement (the attendance loop itself delays notification by 15 minutes by design), EventSource fights our bearer-token auth model, and long-lived connections couple deployment topology for latency nobody asked for. - v1 scope (this spec): the engine seam, the inbox channel + its read API (the notification center's backend), and the email transport (MailerPort-backed). Mock transports log (structured line per send) and memory (test capture) ship alongside as the observability/test legs. Push channels arrive later as NotificationTransport implementations + per-device registries — no consumer or engine-contract change. - The engine remains not a scheduler: no timers. Timing/state machines (review windows, escalations, acks) are consumer-domain logic — the attendance sweeper lives in its own spec (2026-08-04-attendance-loop-notifications-design.md); it merely calls send() when a transition fires. - Addressing is tenant + resolved User ids. Consumers map domain recipients to users (a student's linked referents → their User ids; a referent with no account simply isn't reachable — the consumer decides what that means). The engine knows nothing about students, referents, or roles. The email leg resolves User.id → User.email engine-internally — audience is identical on every channel (settled 2026-08-04: "email every linked referent with an account"). - Payloads are structured data, not copy: kind (namespaced, centrally registered, per-kind typed) + a JSON payload. Because inbox payloads are stored now, rendered later, payload discipline is normative: ids plus the minimal denormalized display fields each kind explicitly declares in the registry — nothing free-form. - Post-commit convention (normative, confirmed 2026-08-04): send() is called only after the owning transaction commits (existing after-commit idiom). send() never throws into a transaction, but nothing else stops a consumer notifying about a write that then rolls back — so the convention is the guard. Accepted v1 semantics: at-most-once (process death between commit and send loses the notification; the durable-outbox deferral in §9 addresses this when push channels raise the stakes). - RLS note: the inbox channel writes from both request contexts and background jobs (the attendance sweeper has no request CLS). The channel therefore writes under withTenantGuc(notification.tenantId) (tenants-provisioning precedent) — RLS stays honest without an admin-prisma bypass.

Success criteria (observable behavior that proves this works): - NotificationsModule.forRoot() registered in app.module.ts: with NOTIFICATION_TRANSPORT unset it boots on the log transport in NODE_ENV ∈ {local, test} and refuses to boot in prod-like envs (same guard semantics as MAIL_TRANSPORT); memory in a prod-like env is a boot error too. - Any module can @Inject(NOTIFICATION_PORT) and await port.send({...}) with N recipient ids → N notifications rows (one per recipient) plus one structured log line (log transport: tenantId, kind, recipient count — no payload dump) or one sent[] capture (memory transport). - A kind with an email spec → one MailerPort.sendEmail template-mode call per recipient with an email, alias ${templatePrefix}-${lang} on the school's primary language, variables built from the payload; a kind without an email spec → the email leg skips silently (debug log). With MAIL_TRANSPORT=log this is observable as one mailer log line per recipient. - send() never rejects, per leg: a failing transport (or a failing inbox write) is logged via logger.error and swallowed; the other legs still run; the consumer's flow is unaffected. - GET /notifications returns only the caller's rows (e2e: user A can neither list nor mark-read user B's row — the latter 404s); newest first, paginated {data, meta}. - GET /notifications/unread-count reflects PATCH /notifications/:id/read immediately; mark-read is idempotent; POST /notifications/read-all zeroes the count. - Adding a future channel (web push, FCM) is one NotificationTransport class + module wiring: no consumer file changes, no contract change (asserted by the module spec wiring multiple transports against the same consumer call). - git grep-level check: no module outside src/notifications/ defines a notification transport, port, or kind.

Non-goals (in-scope-shaped things this iteration is explicitly not doing): - No real push channel: no Web Push (VAPID, service worker, subscription registry) and no FCM/APNs (device-token registry) — each is a follow-up transport spec (§9). - No channel routing policy or per-user preferences — v1 is broadcast-to-all by decision, not by gap. - No durable outbox, retries, delivery reports, or read-model beyond the inbox itself (no grouping, no threading). Notification-email bounces reaching the Resend webhook map to null (warn log) — accepted (§9). - No scheduling/timers — consumer-owned (the attendance sweeper is the consumer spec's concern). - No BE-hardcoded notification copy: the center's FE renders from kind + payload; email copy lives in per-language Resend templates (engine supplies alias + variables); future push transports render in their own transports. - No per-recipient locale — email language is the school's primary language, tenant-wide (invitations precedent); per-recipient locale is an additive field decided later (§9). - No retention/archival policy for inbox rows (§9 deferral; indexes chosen so future pruning is cheap). - No consumer implementation in this spec — the attendance sweeper lands beside it under its own design (2026-08-04-attendance-loop-notifications-design.md).


2. Patterns survey

Analogous module/spec What we'd borrow What doesn't fit
src/mailer/mailer.module.ts + mailer-port.interface.ts Dynamic forRoot() reading process.env at module-graph construction, log fallback restricted to NODE_ENV ∈ {local,test} with a fatal throw otherwise, Symbol DI token, global: true, exports the port only Mailer binds one transport; here the inbox + email channels are always-on and the env var selects the observability/test leg beside them (divergence §7). Mailer's webhook surface (onDeliveryReport) not carried over — notification delivery reports are deferred
src/mailer/log-mailer.transport.ts + memory-mailer.transport.ts Log + memory transport shapes, incl. logger injection and the public sent[] test surface Fits cleanly
src/invitations/invitation-template.ts + invitations.service.ts resolveInvitationEmailContext The email idiom the email transport mirrors: Resend template mode, per-language alias ${prefix}-${lang} with fallback to the default language, language/school context resolved once per tenant batch Invitations resolve recipients from invitation rows; the email transport resolves User.id → email. Invitations consume delivery reports; notifications don't (v1)
src/mailer/resend/resend.transport.ts Template-mode dispatch + tags plumbing The adapter derives invitation_id/send_count tags from correlationId unconditionally — notification emails must not masquerade as invitations → additive optional MailPayload.tags override (§3, §7)
src/config/env.validation.ts (MAIL_TRANSPORT block) NOTIFICATION_TRANSPORT validation: @ValidateIf(isProductionLikeEnv) + @IsIn([...]) Fits cleanly
src/common/constants/entity-keys.ts Central-registry pattern for kinds: one engine-owned file; consumer modules add entries (typo-proof, greppable, single review point) Kinds are dot-namespaced strings with per-kind payload types (typed map, not a bare string union) + optional per-kind email specs
src/profile/profile.controller.ts The auth-only self-scoped read surface: @UseGuards(JwtAuthGuard), no permission guard — record-level access is "own rows only" by construction, so no RBAC scope exists Profile is a singleton read; notifications add pagination + writes (mark-read), still self-scoped
src/common/dto/paginated-response.dto.ts + pagination-query.dto.ts PaginatedResponseDto<T> / PaginationMetaDto / PaginationQueryDto for the list endpoint Fits cleanly
AuditLog model (prisma/schema.prisma) Schema idiom for an append-heavy tenant+user row: @db.Uuid ids, snake-case @maps, Json payload column, composite (tenantId, userId, createdAt) indexes, @@map AuditLog is admin-read + never-updated; notifications are self-read and get one lifecycle write (readAt)
ch12 "New tenant-bearing model checklist" + src/prisma/rls-coverage.ts / tenanted-models.ts Required-tenantId model: RLS policy in the migration + both registry entries + tenant-reset coverage, enforced by drift specs Fits cleanly (the slice-C EIGHT-guards checklist applies)
2026-08-04-attendance-loop-notifications-design.md (consumer spec, same slice) The consumer-side contract this engine must satisfy: attendance.event/attendance.reminder sends from the sweeper with tightened payloads + email specs The sweeper's scheduling/claiming is entirely consumer-side — engine stays timer-free

On-axis / off-axis check (ch16 §4–§5): the engine is on-axis (transport axis, ch16 §4.5 — this spec is an instance of "add a mailer-style transport family"). The inbox is a new domain entity (new Prisma model + module HTTP surface) — this spec is its design gate. Its read surface deliberately follows the profile/filters auth-only precedent rather than the standard RBAC-gated CRUD path — called out in §7, not silently assumed.


3. Architecture mapping

Primitive Apply? How Justify
Tenant scope yes notifications.tenantId required → RLS policy + rls-coverage.ts + tenanted-models.ts + tenant-reset coverage entries (ch12 checklist, EIGHT guards); inbox writes under withTenantGuc(tenantId) (works from request ctx AND background jobs); every transport line/record carries tenantId Notifications are tenant events; the inbox is tenant data
Academic-year scope no Not an AY-scoped concept; consumers put AY context in payload if their kind needs it
RBAC entity key none Read surface is self-scoped by construction (recipientUserId = caller), gated by JwtAuthGuard only — profile precedent; send() has no HTTP surface A scope grant that every role must hold READ+WRITE on is a no-op grant; record-level filter does all the work
Scopes none
Actions none
Service base custom, stateless NotificationService implements NotificationPort fanning out to bound channels; separate NotificationsReadService for the center's read/mark endpoints Engine has no entity semantics; read side is a thin self-scoped query layer
queries.ts shape yes src/notifications/queries.ts, named functions only: insertNotifications, findNotificationsPage, countUnread, markNotificationRead, markAllNotificationsRead; email transport adds findRecipientEmails (User id → email) + school-language lookup Standard convention
Error codes NOTIFICATION_NOT_FOUND (404) Mark-read on a missing or not-own row → 404 (no existence leak); ch06 registry + messages{en,it} Only read-API error; send() never throws
DTO conventions yes (read API only) NotificationDto { id, kind, payload, createdAt, readAt }, UnreadCountDto { count }; list = PaginatedResponseDto<NotificationDto>; engine side stays TS interfaces (no HTTP boundary)
File-backed sub-resources n/a
Custom fields no
Profile completeness no

Contract (normative)

// src/notifications/notification.interfaces.ts
export const NOTIFICATION_PORT = Symbol('NOTIFICATION_PORT');

/** Central kind registry — consumer modules append here (entity-keys pattern).
 *  Namespaced `<module>.<event>`. Each kind declares its payload shape: ids +
 *  the minimal denormalized display fields the channels render from. Payloads
 *  are stored (inbox) and rendered later — keep them lean and stable. */
export interface AttendanceEventNotificationPayload {
  eventId: string;
  studentId: string;
  studentName: string;
  /** ISO calendar day the event refers to — all copy leads with it. */
  date: string;
  eventType: 'ABSENT' | 'LATE_ENTRY';
}
export interface NotificationPayloads {
  'attendance.event': AttendanceEventNotificationPayload;
  'attendance.reminder': AttendanceEventNotificationPayload;
  'discipline.note': Record<string, unknown>; // tightened when a discipline module is specced
}
export type NotificationKind = keyof NotificationPayloads;
export const NOTIFICATION_KINDS = [
  'attendance.event',
  'attendance.reminder',
  'discipline.note',
] as const satisfies readonly NotificationKind[];

export interface AppNotification<K extends NotificationKind = NotificationKind> {
  tenantId: string;
  kind: K;
  /** Resolved User ids — consumers map domain recipients (referents, staff…)
   *  to users; unreachable domain recipients are the consumer's concern. */
  recipientUserIds: string[];
  /** Structured data only — no rendered copy; channels render on delivery. */
  payload: NotificationPayloads[K];
}

export interface NotificationPort {
  /** Fire-and-forget, called AFTER the owning tx commits: fans out to every
   *  bound channel (inbox always; email when the kind has an email spec;
   *  push channels when they exist; log/memory observability leg). Each leg
   *  is independently fail-soft; NEVER rejects — failures are logged and
   *  swallowed. */
  send<K extends NotificationKind>(notification: AppNotification<K>): Promise<void>;
}

/** Transport SPI — one class per channel; bound by NotificationsModule.
 *  The inbox channel implements this same interface and is always bound. */
export const NOTIFICATION_TRANSPORT_TOKEN = Symbol('NOTIFICATION_TRANSPORT');
export interface NotificationTransport {
  deliver(notification: AppNotification): Promise<void>;
}
// src/notifications/notification-email.registry.ts — per-kind email rendering
// spec. A kind WITHOUT an entry is simply unreachable on the email channel
// (silent skip). Copy lives in per-language published Resend templates
// (`${templatePrefix}-${lang}`, school primary language, invitations alias
// convention); the builder maps the payload to template variables —
// pre-localizing enum labels via the VALUE_LABELS catalog where needed.
export interface NotificationEmailSpec<K extends NotificationKind> {
  templatePrefix: string;
  variables(payload: NotificationPayloads[K], lang: string): Record<string, string>;
}
export const NOTIFICATION_EMAIL_SPECS: {
  [K in NotificationKind]?: NotificationEmailSpec<K>;
} = {
  'attendance.event': { templatePrefix: 'attendance-event', variables: /* … */ },
  'attendance.reminder': { templatePrefix: 'attendance-reminder', variables: /* … */ },
};

Email transport mechanics (email-notification.transport.ts): look up the kind's email spec (none → debug-log skip); resolve recipient User emails + the school's primary language under withTenantGuc(tenantId) (one context resolution per send — invitations batch precedent); one MailerPort.sendEmail template-mode call per recipient, correlationId: '<kind>:<recipientUserId>', tags: [{ name: 'notification_kind', value: kind }]. Per-recipient failures are logged and swallowed (never-throw leg contract).

Mailer delta (additive): MailPayload gains optional tags?: { name: string; value: string }[]. When present, the Resend adapter uses them verbatim and skips the correlationId → invitation_id/send_count derivation + shape assertion; when absent, behavior is unchanged (invitations untouched). ResendReportMapper already fails soft (warn + null) on webhook events lacking invitation tags, so notification-email bounces are benign noise — accepted v1 (§9).

Files: src/notifications/{notification.interfaces.ts, notification-email.registry.ts, notification.service.ts, inbox-notification.channel.ts, email-notification.transport.ts, log-notification.transport.ts, memory-notification.transport.ts, notifications.controller.ts, notifications-read.service.ts, queries.ts, dto/notification-response.dto.ts, notifications.swagger.ts, notifications.module.ts, index.ts} + specs. Plus the additive MailPayload.tags change under src/mailer/.

Env: NOTIFICATION_TRANSPORT?: 'log' | 'memory' — selects the observability/test leg that runs beside the always-on inbox + email channels. Prod-like envs require it set explicitly and only log is prod-legal (until a real push channel exists); memory outside test is a boot error. The email channel needs no env of its own — real delivery is already governed by MAIL_TRANSPORT.


4. Data model plan

Schema deltas

One new model — the inbox (one row per logical notification × recipient; payload denormalized per row, no shared parent — nothing ever reads across recipients, and scale is school-sized):

model Notification {
  id              String    @id @default(uuid()) @db.Uuid
  tenantId        String    @map("tenant_id") @db.Uuid
  tenant          Tenant    @relation(fields: [tenantId], references: [id], onDelete: Restrict)
  recipientUserId String    @map("recipient_user_id") @db.Uuid
  recipientUser   User      @relation(fields: [recipientUserId], references: [id], onDelete: Cascade)
  kind            String    @db.VarChar(100)
  payload         Json
  readAt          DateTime? @map("read_at")
  createdAt       DateTime  @default(now()) @map("created_at")

  @@index([tenantId, recipientUserId, createdAt])
  @@index([tenantId, recipientUserId, readAt])
  @@map("notifications")
}
  • kind is a string column, not a DB enum: the registry in code is the source of truth; a DB enum would need a migration for every new consumer kind.
  • recipientUser cascades on user deletion (inbox rows are meaningless without their recipient); tenant is Restrict per house idiom.
  • (tenantId, recipientUserId, createdAt) serves the list; (tenantId, recipientUserId, readAt) serves the unread count (poll target — must be cheap).

Migration shape

  • CREATE TABLE notifications + the two indexes + the RLS policy stanza (ch12 new tenant-bearing model checklist). Registry entries in src/prisma/rls-coverage.ts, src/prisma/tenanted-models.ts, and src/tenants/tenant-reset.coverage.ts (+ resetTenantSetupData deleteMany + tools/reset-tenant.sql line) in the same change — drift specs fail otherwise (slice-C EIGHT-guards checklist).
  • Ch12 procedure applies: check for an uncommitted migration first (ask whether to fold), audit the generated SQL against the hazard checklist. Purely additive — no data movement, no prod risk.

Indexes and uniqueness

  • No uniqueness constraints: the same (kind, recipient, payload) may legitimately repeat (e.g. reminder after event). Dedup/collapse semantics, if ever wanted, are a push-channel concern (collapse keys — §9).

5. API surface

All endpoints auth-only (JwtAuthGuard, no permission guard) and self-scoped by construction — every query filters recipientUserId = caller.userId (+ tenant). Profile-module precedent; see §7 divergence row.

Method + path Purpose Request Response
GET /notifications The caller's inbox, newest first PaginationQueryDto + optional unread=true filter PaginatedResponseDto<NotificationDto>
GET /notifications/unread-count The FE poll target (~30–60s while the app is open) UnreadCountDto { count }
PATCH /notifications/:id/read Mark one read — idempotent (readAt set once, re-PATCH is a no-op) NotificationDto
POST /notifications/read-all Mark all of the caller's unread rows read { updated: number }
  • NotificationDto { id, kind, payload, createdAt, readAt } — no tenantId, no recipient echo.
  • Missing id or someone else's id → 404 NOTIFICATION_NOT_FOUND (no existence leak).
  • FE rendering contract: the center renders localized copy from kind + payload; the kind registry is the shared vocabulary. Kinds and their payload shapes are documented in the Swagger description of GET /notifications (public FE-facing copy) as they are added.
  • No send/broadcast HTTP endpoint — send() is in-process DI only.

Swagger considerations

  • New notifications.swagger.ts + controller JSDoc (public copy — contract only: self-scoped inbox, poll guidance on the unread-count endpoint, per-kind payload documentation as kinds land).

6. RBAC seed plan

Seed file Delta
PermissionScope (rbac-catalogue.ts) none
PermissionAction (rbac-catalogue.ts) none
ScopeFieldMapping (rbac-catalogue.ts) none
Role grants (roles.ts) none
*_SCOPES runtime constant none

No RBAC surface: the inbox is user-private data with record-level self-scoping (a scope every role must hold identically is a no-op grant). If an admin-facing cross-user view is ever asked for, that gets a scope — deferral §9.


7. Divergence ledger

Pattern We diverge by Reason Tradeoff accepted
Mailer idiom: env var selects the single transport Inbox + email channels are always-on; NOTIFICATION_TRANSPORT selects only the observability/test leg beside them Product decision: broadcast to all channels; the inbox is canonical (persist-first); real email delivery is already governed by MAIL_TRANSPORT Env var name suggests exclusivity it no longer has — documented in env.validation.ts comment; revisit naming (e.g. a channel set) when push channels land
"MailerPort and notifications are separate seams" (this spec's own 2026-07-24 draft) The email notification transport consumes MailerPort as its dispatch layer Fabio 2026-08-04: out-of-app channels need engine-side rendering sooner or later — email joins the engine now; MailerPort stays the low-level email dispatch; invitations/credentials keep calling it directly (transactional mail, not notifications) Every new kind that wants email needs a Resend template pair (-en/-it) published before the kind goes live — a deploy-external step, same as credential templates
Resend adapter derives invitation_id/send_count tags from correlationId unconditionally Additive optional MailPayload.tags overrides the derived pair Notification emails must not masquerade as invitations in provider tags/reports Notification-email bounces hit the webhook mapper without invitation tags → warn + null (benign noise); real handling deferred with delivery reports (§9)
CRUD modules gate reads with @RequireScopes Auth-only endpoints, record-level self-scoping only The resource is inherently caller-private; profile + filters precedents No per-role differentiation possible on this surface; acceptable — any future admin/cross-user view is a separate scoped endpoint
MailerPort.sendEmail returns MailSendResult, failures surface to caller NotificationPort.send returns void, never rejects, per-leg fail-soft Notifications are advisory side-effects of domain writes; a failed email/push must never fail or roll back attendance/notes Consumers can't react to delivery failure; acceptable — revisit with the outbox deferral
Kinds as free strings per consumer Central typed registry (NotificationPayloads + NOTIFICATION_EMAIL_SPECS) in the engine Typo-proof, shape-proof, greppable, single review point (entity-keys precedent) Engine files touched by every new consumer (one-line PR noise — accepted)
BE copy nowhere / FE renders everything (error-i18n idiom) Email copy lives in per-language Resend templates; the engine builds variables (pre-localizing enum labels via VALUE_LABELS) An email is rendered at send time, not read time — client-side rendering is impossible; template mode is the existing credentials idiom Copy edits happen in Resend, not in the repo; template/variable drift is caught only by sending — same exposure credentials already accept

8. Pushback log

Source says Conflicts with Proposed instead Status
The attendance-v2 draft (deleted 2026-07-25) addressed notifications to referent ids and scoped the port to attendance "Single way to send notifications to users, reusable by other modules" (Fabio 2026-07-23) Address User ids; consumers resolve domain→user; kinds namespaced + centrally registered Resolved by extraction; settled structurally 2026-07-24 (inbox rows FK to User)
"Push to the browser" read as needing a live connection (SSE/WebSocket) for the open app Stateless bearer-token HTTP model; no real-time requirement anywhere in the domain (attendance delays notification 15m by design); multi-node topology coupling Poll unread-count for in-session freshness; Web Push (deferred channel) for closed-browser delivery — two different problems, neither needs SSE/WS Resolved (chat 2026-07-24) — SSE revisited only on concrete latency complaints
Per-kind channel routing designed up front "We don't know yet each notification flow" (Fabio 2026-07-24) Broadcast to all bound channels; routing/preferences land later as engine-side policy, zero consumer change Resolved (chat 2026-07-24)
Assistant recommendation (2026-08-04): keep email consumer-side (sweeper calls MailerPort directly) to preserve the "no BE rendering" rule Fabio: "inbox + web push will definitely need copy in the BE sooner or later — why not make MailerPort an engine channel too?" Email as an engine transport; rendering principle restated as per-channel (in-app renders client-side, out-of-app renders at the transport via Resend template mode) — which the spec's own non-goals line already anticipated for push Resolved (chat 2026-08-04) — engine channel

9. Deferrals

Ordered channel roadmap (each lands as a NotificationTransport + its own spec, zero consumer change): 1. Web Push transport — VAPID keys, FE service worker, per-device PushSubscription registry (new model), expired-subscription cleanup, payload → ephemeral pointer deep-linking to the inbox row. 2. FCM/APNs transport (mobile app) — device-token registry, token refresh handling.

Other deferrals: - Durable outbox / retries / delivery reports — needed the moment channel failures must be reacted to; lands behind the port. Also upgrades the post-commit at-most-once semantics and picks up notification-email bounce handling (today: webhook mapper warn + null, accepted). - Routing policy & per-user preferences — broadcast until product knows the flows; then per-kind/per-user policy inside the engine. - Per-recipient locale — email language is the school's primary language v1 (invitations precedent); an additive AppNotification/user-profile concern later. - Inbox retention/archival — rows grow unboundedly; (tenantId, recipientUserId, createdAt) makes future pruning cheap. Decide a policy before the first big tenant year-rollover. - Admin/cross-user notification view — would be a new scoped endpoint (own RBAC design), not a widening of the self-scoped surface. - Collapse/dedup keys, priority, deep-link route hints — all additive AppNotification fields; decided with the channels that consume them. - Drift guard fencing "no port/transport/kind definitions outside src/notifications/" — convention + review for now. - Further consumers — a future discipline-notes module tightens discipline.note when specced.


10. Open questions

Blockers requiring user resolution before code starts. Must be empty (all resolved) before sign-off.

Resolved: - [x] Never-throw contractsend() never rejects; per-leg fail-soft (inbox write failure included: logged, swallowed, other legs still run). Confirmed (chat 2026-08-04). - [x] Typed per-kind payload registryNotificationPayloads map instead of an untyped bag (contract in §3). Confirmed (chat 2026-08-04) — slice D tightens the two attendance kinds immediately. - [x] Post-commit conventionsend() only after the owning tx commits; at-most-once accepted for v1 (outbox deferred). Confirmed (chat 2026-08-04). - [x] Email placement — engine channel (MailerPort-backed transport, Resend template mode, school-language alias), not consumer-side. Decided by Fabio (chat 2026-08-04); audience = users with accounts, identical on every channel. - [x] Recipient addressing = recipientUserIds (consumers own domain→user mapping) — settled structurally by folding the center in: inbox rows FK to User (chat 2026-07-24). - [x] Channels = notification center + email (v1) + Web Push + FCM/APNs (deferred), broadcast to all, persist-first, poll for in-session freshness (chats 2026-07-24 / 2026-08-04). - [x] v1 scope = engine + inbox channel + read API + email transport ("fold the center in the spec" 2026-07-24; email folded 2026-08-04).


11. Verification plan

  • Unit specs:
  • notification.service.spec.ts — fan-out: one send → inbox channel and email transport and bound observability transport all invoked; a rejecting leg → send() resolves, logger.error fired, the other legs still ran (each direction).
  • inbox-notification.channel.spec.ts — N recipients → createMany of N rows with tenantId/kind/payload under withTenantGuc; empty recipients → no write.
  • email-notification.transport.spec.ts — kind with email spec → one template-mode sendEmail per recipient with an email (alias ${prefix}-${lang}, variables from payload, notification_kind tag, correlationId <kind>:<userId>); kind without spec → zero mailer calls + debug log; a recipient-level mailer rejection → other recipients still sent; school-language fallback to default.
  • log-notification.transport.spec.ts — one structured line per deliver, carries tenantId/kind/recipient count, no payload dump.
  • memory-notification.transport.spec.tssent[] accumulates in order, exposes full notifications.
  • notifications.module.spec.ts — mirror mailer.module.spec.ts: boots with NOTIFICATION_TRANSPORT=memory, resolves NOTIFICATION_PORT, sends, asserts capture + inbox + email invocation; unset transport + prod-like NODE_ENV → boot throws; memory + prod-like → boot throws.
  • Resend transport spec additions — tags override: payload with explicit tags skips the invitation-pair derivation/assertion; without tags, existing behavior byte-identical.
  • Env validation spec additions for NOTIFICATION_TRANSPORT (mirror MAIL_TRANSPORT cases).
  • E2E specs (notifications.e2e-spec.ts): seed rows for two users → user A lists only own rows (newest first, paginated, unread=true filter); unread-count; mark-read idempotency; mark-read on user B's id → 404 NOTIFICATION_NOT_FOUND; read-all zeroes the count.
  • Drift/regression: rls-coverage + tenanted-models + tenant-reset coverage drift specs green with the new model registered; migration SQL audited per ch12 checklist.
  • Manual verification: boot dev server, trigger an attendance sweep (consumer spec) or temporarily inject a send → watch the log line + row in Prisma Studio + mailer log line; hit GET /notifications as the recipient; flip env to memory in a test run.

Patterns: chapter 09 (testing). Docs follow-up on landing: docs/REFERENCE.md §4 row + CLAUDE.md structure line for src/notifications/; new chapter docs/23-notifications.md (engine seam + center read API + poll contract + email template convention + channel roadmap); FE guide for the notification center (new — the read API is FE-facing).


12. Sign-off

  • Approved by: Fabio
  • Date: 2026-08-04
  • Chat reference: "approved" in chat 2026-08-04, after the email-channel iteration (engine transport via Resend template mode) and resolution of the three §10 confirms

Until this section is filled, no implementation code is written. When you fill it, flip the frontmatter status: to Approved in the same edit.