Skip to main content

Notification templates (versioned template store + dispatch renderer)

Scope

One controller, one thin service, and a sibling NotificationTemplateService (in notifications-dispatch/) that handles rendering + version snapshots. The versioning foundation is correct: NotificationTemplateVersion snapshots on every meaningful edit and Notification.templateVersionId on every dispatch pin the exact version that generated the sent message. But the surface is thin — three endpoints, no preview, no version-history query, no dispatch audit event, no locale, no content-asset integration.

  • notification-templates/NotificationTemplatesService (list / create / update) + controller (GET /notification-templates / POST /notification-templates / PATCH /notification-templates/:id). 140 lines total.
  • NotificationTemplate model — 12 columns, code unique, channel enum (email / sms / whatsapp / in_app), isActive boolean, rateLimit JSON, nullable activeVersionId pointer.
  • NotificationTemplateVersion model — immutable snapshot per edit: (templateId, version) unique, mirrors name / subject / body / channel at the moment of snapshot; createdBy actor id.
  • NotificationTemplateService (in notifications-dispatch/) — renderByCode(code, vars) returns the active-version body interpolated with {{path.to.value}} placeholders + templateVersionId. snapshotVersion(templateId, actorId) is idempotent (skips when latest version matches current state).
  • Notification dispatch recordtemplateVersionId FK captured at dispatch time so a later template edit can never rewrite what the customer was told.

Compliance envelope

  • CPA 2022 §37 — consumer notice. When a customer disputes "the email you sent me never said that", the platform must reconstruct exactly what was sent, at what time, by which version. The templateVersionId FK on Notification is the primitive; a dispatch-time audit event, a version-history query endpoint, and a body-preview endpoint are the tooling compliance officers need on top of it.
  • DPPA 2019 §21 — PII in variable interpolation. Templates reference {{client.firstName}}, {{policy.reference}}, {{agent.email}}, etc. There is no allowlist / denylist today; a template author can inject any property. The rendered body is AES-256-GCM at rest on the Notification row, but the template body — which reveals what PII will be surfaced — is plaintext.
  • DPPA 2019 §16 — cross-border render. The dispatch service gates on user consent for cross-border channels (SMS/Kenya, WhatsApp/US, email/SMTP-offshore when the flag is on). But the template itself has no locale column; a Uganda-primary consumer receiving an English notification when their preferred language is Luganda is a §37 access-notice gap and a §16 rendering-provenance gap.
  • AML §14 — audit-trail integrity. Template edits emit notification_template.create / update audit events with before + after snapshots. Dispatches emit nothing; the audit chain skips the moment content actually leaves the platform.
  • BOU cybersecurity §5.4 — throttle + audit. The mutation endpoints are unthrottled; a compromised notification_template:write token can spam PATCH /:id with body edits, producing an unbounded stream of new snapshot rows.

Current state (2026-07-19)

Module footprint

  • src/modules/notification-templates/notification-templates.module.ts — 140 lines. DTOs + service + controller + module in one file.
  • src/modules/notifications-dispatch/template.service.ts — 109 lines. Render + snapshot logic.
  • Prisma models: NotificationTemplate (12 cols), NotificationTemplateVersion (7 cols, unique (templateId, version)), Notification (dispatch record with templateVersionId FK).
  • No cross-callers of NotificationTemplatesService outside the own module; NotificationTemplateService (sibling) is the primary consumer via renderByCode from NotificationDispatchService.

What works today

  • Immutable per-edit version snapshot: snapshotVersion() is idempotent (skips no-op edits) and every meaningful edit lands a new row in NotificationTemplateVersion. Notification.templateVersionId FK captures the exact version at dispatch time.
  • Cross-border consent gate on dispatch: the dispatcher refuses to send a non-transactional notification via a cross-border channel without a data_transfer_consent row — DPPA §16 enforced at the outermost point.
  • Body-at-rest encryption: Notification.body is AES-256-GCM under NOTIFICATION_BODY_KEY with bodyEncrypted flag; the actual PII rendered into the sent message is cipher-protected on disk.
  • Prototype-pollution-safe interpolation: interpolate(source, vars) follows only own enumerable properties — a {{__proto__.polluted}} placeholder can't reach through.
  • Per-template rate-limit + dedup: NotificationTemplate.rateLimit JSON (per-hour / per-day) + a claim-based dedup window prevent runaway send loops.
  • Retention policy is deliberately absent: neither NotificationTemplate nor NotificationTemplateVersion is touched by RetentionPurgeJob — versions live forever for dispute defense. ✓

Gaps

All 12 gaps closed. See shipped-notes below.

Phase 1 shipped (2026-07-19)

Release notification-templates-p1-20260719-0521. Closes gaps 1-4:

  • gap 1 — NotificationDispatchService.recordDispatchAudit emits notification.dispatched on every send (both dispatchDirect + dispatchByTemplateToClient paths) with { channel, templateId, templateVersionId, templateCode, category, lawfulBasis, statutoryClass, recipientKind, recipientId }. Best-effort — a failed audit-emit logs a warn but does not roll back the dispatch, since a dropped customer notification is a worse compliance outcome than a gap in the audit chain.
  • gap 2 — assertSafeEmailBody(body) helper refuses <script>, <iframe>, <object>, <embed>, <link>, <meta> tags, inline event handlers (on*=), javascript: URLs, data:text/html URLs, and <svg>-with-event-handler payloads at POST /notification-templates + PATCH /:id when the effective channel is email. Refused → 400 with a SANITISATION_REJECTED detail. SMS / WhatsApp / in-app channels bypass since they render plaintext downstream.
  • gap 3 — GET /notification-templates/:id/versions + GET /notification-templates/:id/versions/:version expose the immutable version chain to compliance officers. Both gated by NotificationTemplateRead.
  • gap 4 — NotificationTemplateService.renderByCode now invokes ContentAssetsService.renderWithContentAssets on both subject and body before variable interpolation, so {{content:cooling_off_notice}} etc. resolve against the currently-in-force ContentAsset row. Missing / unpublished codes leave the placeholder intact (Content Assets Phase 2 gap 8 fallback). ContentAssetsModule imported into NotificationsDispatchModule + NotificationTemplatesModule; the service dependency is @Optional() so specs that don't need it can still instantiate.

Locked in by src/modules/notification-templates/notification-templates-phase1.spec.ts (14 cases). Test suite: 604/604 (89 suites).

Phase 2 shipped (2026-07-19)

Release notification-templates-p2-20260719-0613. Closes gaps 5-8:

  • gap 5 — Migration 20260719030000_notification_templates_phase2 adds NotificationTemplate.locale (default en-UG) + swaps the code unique for a composite (code, locale). renderByCode accepts an optional locale + falls back to en-UG when the requested locale isn't published for that code — mirroring Content Assets Phase 3 gap 10.
  • gap 6 — POST /notification-templates/:id/preview renders the template body with caller-supplied variables + optional locale without dispatching. Read-only permission (NotificationTemplateRead) so ops can dry-run without a write token.
  • gap 7 — @Throttle on every mutation: POST / 5/min, PATCH /:id 20/min, POST /:id/preview 30/min.
  • gap 8 — POST /notification-templates + PATCH /:id accept Idempotency-Key. createIdempotent + updateIdempotent wrap through the shared IdempotencyService. Body mismatch → 409 IDEMPOTENCY_CONFLICT.

Locked in by src/modules/notification-templates/notification-templates-phase2.spec.ts (5 cases). Test suite: 609/609 (90 suites).

Phase 3 shipped (2026-07-19)

Release notification-templates-p3-20260719-0633. Closes gaps 9-12:

  • gap 9 — NotificationDispatchService.recordSuppressionAudit emits notification.dispatch.suppressed on every suppression path (preference / cross-border-consent-missing / rate-limited / dedup) for both User + Client dispatch flows. Metadata carries { channel, category, recipientKind, recipientId, reason, detail }. Best-effort — an audit-emit failure logs a warn but the suppression still stands.
  • gap 10 — Migration 20260719040000_notification_templates_phase3 adds declaredVariables JSONB ([{ path, piiClass }]). The findUndeclaredPlaceholders helper scans the body at create + update and logs a warn against every {{path}} placeholder that isn't in the declared set. Compliance officers can now grep the catalogue for e.g. client.nationalId. {{content:code}} placeholders skip the check (they resolve via Content Assets).
  • gap 11 — Migration adds status soft-enum (draft/published/archived, default published on backfill). create lands new templates in draft; POST /:id/publish + POST /:id/archive do the transitions. PATCH /:id refuses when status='published' — ops must archive + create a new draft. renderByCode refuses when the matched row's status isn't published.
  • gap 12 — update splits isActive transitions off into distinct notification_template.activate / notification_template.deactivate audit events. Non- isActive edits still emit notification_template.update.

Locked in by src/modules/notification-templates/notification-templates-phase3.spec.ts (10 cases) + src/modules/notifications-dispatch/dispatch-suppression-audit.spec.ts (1 case). Test suite: 620/620 (92 suites).

All 12 gaps closed. Notification-templates module is audit-clean against CPA §37 (dispatch audit + version query + immutable publish), DPPA §16 (suppression audit trail on cross-border consent), DPPA §21 (email-body sanitisation + PII-class declaration), AML §14 (hash-chained lifecycle events), and BOU cybersecurity §5.4 (throttle + idempotency on every mutation + draft-review workflow).


Phased implementation plan

Complete — every phase shipped.

Each phase deploys to prod + sandbox at 157.173.99.48 and ships a locked spec suite. Success gate: all 12 gaps closed for the module.