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.NotificationTemplatemodel — 12 columns,codeunique,channelenum (email/sms/whatsapp/in_app),isActiveboolean,rateLimitJSON, nullableactiveVersionIdpointer.NotificationTemplateVersionmodel — immutable snapshot per edit:(templateId, version)unique, mirrorsname/subject/body/channelat the moment of snapshot;createdByactor id.NotificationTemplateService(innotifications-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).Notificationdispatch record —templateVersionIdFK 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
templateVersionIdFK onNotificationis 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 theNotificationrow, 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
localecolumn; 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/updateaudit 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:writetoken can spamPATCH /:idwith 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 withtemplateVersionIdFK). - No cross-callers of
NotificationTemplatesServiceoutside the own module;NotificationTemplateService(sibling) is the primary consumer viarenderByCodefromNotificationDispatchService.
What works today
- Immutable per-edit version snapshot:
snapshotVersion()is idempotent (skips no-op edits) and every meaningful edit lands a new row inNotificationTemplateVersion.Notification.templateVersionIdFK 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_consentrow — DPPA §16 enforced at the outermost point. - Body-at-rest encryption:
Notification.bodyis AES-256-GCM underNOTIFICATION_BODY_KEYwithbodyEncryptedflag; 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.rateLimitJSON (per-hour / per-day) + a claim-based dedup window prevent runaway send loops. - Retention policy is deliberately absent: neither
NotificationTemplatenorNotificationTemplateVersionis touched byRetentionPurgeJob— versions live forever for dispute defense. ✓
Gaps
All 12 gaps closed. See shipped-notes below.
Release notification-templates-p1-20260719-0521. Closes gaps
1-4:
- gap 1 —
NotificationDispatchService.recordDispatchAuditemitsnotification.dispatchedon every send (bothdispatchDirect+dispatchByTemplateToClientpaths) 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/htmlURLs, and<svg>-with-event-handler payloads atPOST /notification-templates+PATCH /:idwhen the effective channel is email. Refused → 400 with aSANITISATION_REJECTEDdetail. SMS / WhatsApp / in-app channels bypass since they render plaintext downstream. - gap 3 —
GET /notification-templates/:id/versions+GET /notification-templates/:id/versions/:versionexpose the immutable version chain to compliance officers. Both gated byNotificationTemplateRead. - gap 4 —
NotificationTemplateService.renderByCodenow invokesContentAssetsService.renderWithContentAssetson bothsubjectandbodybefore 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).ContentAssetsModuleimported intoNotificationsDispatchModule+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).
Release notification-templates-p2-20260719-0613. Closes gaps
5-8:
- gap 5 — Migration
20260719030000_notification_templates_phase2addsNotificationTemplate.locale(defaulten-UG) + swaps thecodeunique for a composite(code, locale).renderByCodeaccepts an optional locale + falls back toen-UGwhen the requested locale isn't published for that code — mirroring Content Assets Phase 3 gap 10. - gap 6 —
POST /notification-templates/:id/previewrenders the template body with caller-suppliedvariables+ optionallocalewithout dispatching. Read-only permission (NotificationTemplateRead) so ops can dry-run without a write token. - gap 7 —
@Throttleon every mutation:POST /5/min,PATCH /:id20/min,POST /:id/preview30/min. - gap 8 —
POST /notification-templates+PATCH /:idacceptIdempotency-Key.createIdempotent+updateIdempotentwrap through the sharedIdempotencyService. Body mismatch → 409IDEMPOTENCY_CONFLICT.
Locked in by src/modules/notification-templates/notification-templates-phase2.spec.ts
(5 cases). Test suite: 609/609 (90 suites).
Release notification-templates-p3-20260719-0633. Closes gaps
9-12:
- gap 9 —
NotificationDispatchService.recordSuppressionAuditemitsnotification.dispatch.suppressedon 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_phase3addsdeclaredVariablesJSONB ([{ path, piiClass }]). ThefindUndeclaredPlaceholdershelper 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
statussoft-enum (draft/published/archived, defaultpublishedon backfill).createlands new templates indraft;POST /:id/publish+POST /:id/archivedo the transitions.PATCH /:idrefuses whenstatus='published'— ops must archive + create a new draft.renderByCoderefuses when the matched row's status isn'tpublished. - gap 12 —
updatesplitsisActivetransitions off into distinctnotification_template.activate/notification_template.deactivateaudit events. Non- isActive edits still emitnotification_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.