Notification preferences (user opt-in/out + DPPA §16 cross-border consent)
Scope
Three files, 589 lines total. The transactional-carve-out logic
(auth / compliance / system categories bypass every gate) is
correct; the acknowledge → withdraw → re-acknowledge lifecycle on
DataTransferConsent is correct; the HMAC-signed unsubscribe
token round-trips through the WebAuthn-adjacent
buildUnsubscribeToken / verifyUnsubscribeToken pair
correctly. Everything else — throttle, single-use, retention,
DSAR portability, purpose-scoping, client parity — is missing.
src/modules/notification-preferences/notification-preferences.module.ts(235 lines) —NotificationPreferencesService(listForUser/bulkUpdate/isDeliverable) + controller (GET /notification-preferences/me,POST /notification-preferences/me,POST /me/data-transfer/acknowledge,POST /me/data-transfer/withdraw). Exports theTRANSACTIONAL_CATEGORIES+OPT_IN_REQUIRED_CATEGORIES+categoryOfTemplatecatalogue thatNotificationDispatchServiceconsumes.src/modules/notification-preferences/data-transfer-consent.service.ts(111 lines) —DataTransferConsentService(latest/isAcknowledged/acknowledge/withdraw). The DPPA §16 lynchpin.src/modules/notification-preferences/unsubscribe.controller.ts(97 lines) — publicGET /u/:tokenunauthenticated endpoint. Verifies HMAC, flipsenabled=falseon the matched preference row, emitsnotification.unsubscribed.NotificationPreference(Prisma) —(userId, category, channel)composite unique;enabledboolean; cascade on User delete. Nopurpose, nodeletedAt, nometadata, noexpiresAt.ClientNotificationPreference— same shape keyed onclientIdbut only wired for the dispatch-time read path (NotificationDispatchService.dispatchByTemplateToClient); no controller endpoints ship yet.DataTransferConsent—(userId ?? clientId, transferKind)ledger withacknowledgedAt,withdrawnAt,ip,userAgent,noticeVersion,metadata(nullable JSON). NoexpiresAt.
Compliance envelope
- DPPA 2019 §16 — cross-border consent gate. Every SMS (Africa's Talking, Kenya), every WhatsApp (Meta Cloud, US), and (with the escape hatch flag) every SMTP-relayed email is a cross-border transfer of subject PII. The consent ledger is the evidentiary record that the subject knowingly granted it. Today the ledger exists but lacks portability (subject can't export their own consent history) + retention (rows live forever, growing linearly with acknowledgements).
- DPPA 2019 §17 — subject-erasure / rectification / portability. DSAR hard-deletes preferences with no soft-delete audit trail; DSAR export doesn't include the consent ledger at all. A subject can't answer "when did I consent to Kenya SMS?" from a DSAR export today.
- DPPA 2019 §13 — purpose-scoped consent. One
NotificationPreferencerow covers "SMS from ZFA" as a monolith. A user who wants to withdraw marketing SMS but keep transactional SMS has nowhere to express that at the row level — they'd have to trust that the category catalogue holds. Thepurposecolumn is missing. - DPPA 2019 §11 — storage-limitation.
RetentionPurgeJobdoesn't sweepDataTransferConsent; consent rows grow unbounded. AML §14 requires a 7 y record for the ledger side, but rows past that window should be scrubbed ofip/userAgent/metadata. - DPPA 2019 §21 — PII in metadata.
DataTransferConsent.metadatais a plaintext JSON blob with no validator + no encryption. If ops accidentally push{email, phone, ip}into it, PII leaks everywhere the row is queried. - CPA 2022 §37 — marketing opt-out + reasonable-consent window. The default-off for marketing on SMS/WhatsApp is correct; the missing piece is a time-boxed re-consent (marketing consent typically expires after 12 months per §37 UGC guidance).
- BOU cybersecurity §5.4 — throttle + replay-protection on
public surfaces. The public
GET /u/:tokenunsubscribe endpoint has no@Throttle+ no single-use nonce. An attacker who intercepts the token can replay it indefinitely (until the 30-day TTL); an attacker who guesses can brute-force with no rate ceiling. - AML §14 — consent-transition audit trail. Write paths
(
acknowledge/withdraw/bulkUpdate) emit audit events. Read paths (listForUser/isDeliverable) don't — no record of which agent queried preferences to decide "should I send this?" before dispatch.
Current state (2026-07-19)
Module footprint
- 3 module files, 589 lines.
- 3 Prisma models:
NotificationPreference+ClientNotificationPreference+DataTransferConsent. - 4 authenticated endpoints + 1
@Public()unsubscribe endpoint. - 2 spec files covering the happy path + core gating logic.
- Cross-consumers:
NotificationDispatchService(both user + client dispatch paths),SanctionsModule(compliance-adjacent consent lookups),DsarService(hard-delete on erasure).
What works today
- Transactional carve-out is correct:
TRANSACTIONAL_CATEGORIES = ['auth', 'compliance', 'system']bypass BOTH the preference gate AND the DPPA §16 consent gate inNotificationDispatchService.dispatchByTemplate— auth / compliance / system messages always deliver. - Opt-in-required marketing default-off:
OPT_IN_REQUIRED_CATEGORIES = ['marketing']setsenabled=falseas the missing-row fallback for SMS + WhatsApp marketing — matches UCC Commercial Comms Guidelines 2019 §4 + CPA §37. - DPPA §16 ledger shape is correct:
DataTransferConsentcapturesacknowledgedAt+ip+userAgent+noticeVersion(currently'2026.07') so a later dispute can prove exactly which notice text the subject saw. - Withdraw path is idempotent:
withdraw()on an already- withdrawn row returns the existing row; a repeat click doesn't fork the ledger. - Unsubscribe HMAC verification is safe:
verifyUnsubscribeTokenusestimingSafeEqual+ a 30-day TTL ceiling; no length-based side channel. - Full audit trail on write paths:
notification_preferences.update,notification.data_transfer.acknowledged,notification.data_transfer.withdrawn,notification.unsubscribedall land on the hash-chained trail.
Gaps
All 12 gaps closed. See shipped-notes below.
Release npref-p1-20260719-1356. Closes gaps 1-4:
- gap 1 —
@Throttle({ default: { ttl: 60_000, limit: 30 } })on the@Public()GET /u/:tokenunsubscribe endpoint. Excess → 429. - gap 2 — Migration
20260719080000_notification_preferences_phase1addsUnsubscribeTokenUsagemodel keyed onSHA-256(token).UnsubscribeService.applywrites on first use; a replay hits the unique constraint and short-circuits with{ ok: true, alreadyProcessed: true }+ emitsnotification.unsubscribed.replay_ignoredaudit event. The preference upsert only runs on the first use. - gap 3 —
DsarService.exportForUsernow pullsdataTransferConsents(viaprisma.dataTransferConsent.findManyscoped to the subject, ordered byacknowledgedAt) into the export pack. A subject who issues a DSAR gets their full cross-border consent ledger withwithdrawnAt,noticeVersion,transferKind. - gap 4 — Migration adds
deletedAt+deletedBy+deletionReasonto bothNotificationPreference+ClientNotificationPreference.DsarService.eraseUser+.eraseClientswap thedeleteManyfor anupdateManythat soft-deletes + flipsenabled=false+ stampsdeletionReason='dsar.erasure'. The audit-shape row survives so a moderator can grep the chain for "what did we erase for subject X on date Y".
Locked in by
src/modules/notification-preferences/notification-preferences-phase1.spec.ts
(5 cases). Test suite: 658/658 (96 suites).
Release npref-p2-20260719-1414. Closes gaps 5-8:
- gap 5 — Migration
20260719090000_notification_preferences_phase2wires aDataTransferConsentretention sweep inRetentionPurgeJob.tick. Newdata_sharing.data_transfer_consent_retention_daysoperational policy (default 2555 = 7 y AML §14 floor). Past the window the row keepsacknowledgedAt/withdrawnAt/transferKind(audit shape survives) butip+userAgent+withdrawalIp+withdrawalUserAgent+metadataare scrubbed. Emitsdata_transfer_consent.retention.scrubbed. - gap 6 — Migration adds
purpose(defaultdefault) to bothNotificationPreference+ClientNotificationPreference. Composite unique becomes(userId, category, channel, purpose)- mirror for the client model.
isDeliverabletakes an optional purpose arg with'default'default (source- compatible).bulkUpdateDTO gains a per-itempurposefield with aPURPOSE_REGEXvalidator. Every caller of the olduserId_category_channelcomposite unique (dispatch service, provider webhooks, unsubscribe controller) upgraded to the purpose-aware key.
- mirror for the client model.
- gap 7 — Three new idempotent wrappers via the shared
IdempotencyService:bulkUpdateIdempotent,acknowledgeIdempotent,withdrawIdempotent. Controller acceptsIdempotency-Keyon all three mutation routes. - gap 8 — New
assertNoPiiInMetadata()helper onDataTransferConsentServiceruns the same phone / email / NIN sweep as Ratings.acknowledge()invokes it before the DB write; hit → 400SANITISATION_REJECTED. Metadata is persisted only if it passes.
Locked in by src/modules/notification-preferences/notification-preferences-phase2.spec.ts
(8 cases). Test suite: 666/666 (97 suites).
Release npref-p3-20260719-1444. Closes gaps 9-12:
- gap 9 —
PortalController(client-portal) gainsGET /client-portal/me/notification-preferences+POST /client-portal/me/notification-preferences(@ClientSessionOnly()+@Throttle(10/min)+Idempotency-Key+ full audit). Delegates to two new service methods onNotificationPreferencesService:listForClient+bulkUpdateClientIdempotent.UnsubscribeService.applyon a subject whose ID matches a Client row now writes toClientNotificationPreference(via the new purpose-aware composite unique) + emitsnotification.unsubscribedwithentityType='client'; unknown subject IDs still emitclient_pendingso nothing is silently dropped. - gap 10 — Migration
20260719100000_notification_preferences_phase3addsDataTransferConsent.expiresAt.MARKETING_ADJACENT_KINDS(sms_kenya/whatsapp_us) get a 12-month default on new acknowledgements;email_smtpstays permanent-until-withdrawn.isAcknowledgedtreatsexpiresAt < NOW()as not-acknowledged so an expired consent silently blocks the next non-transactional cross-border dispatch until the subject re-consents.acknowledge()accepts an optional explicitexpiresAt(includingnullto override the default). - gap 11 —
isDeliverableon a non-transactional read emitsnotification_preferences.gate_readwith metadata{ userId, category, channel, purpose, decision, reason }. Transactional bypasses stay silent (auth/compliance/systemcategories skip both the DB read and the audit) to keep the chain from being flooded on every send. Emit is best-effort (.catch(() => undefined)). - gap 12 —
BulkUpdatePreferencesDto.itemscarries@ArrayMaxSize(100); every mutation endpoint carries@Throttle(bulk update 10/min, ack/withdraw 20/min); the 30-day unsubscribe-token TTL is documented in the service jsdoc + this shipped-note (maxAgeSeconds = 60 * 60 * 24 * 30inNotificationDispatchService.verifyUnsubscribeToken).
Locked in by src/modules/notification-preferences/notification-preferences-phase3.spec.ts
(7 cases). Test suite: 673/673 (98 suites).
All 12 gaps closed. Notification-preferences module is audit-clean against DPPA §11 (7 y retention scrub on the consent ledger), DPPA §13 (purpose-scoped consent + time-boxed marketing expiry), DPPA §16 (cross-border consent ledger with portability via DSAR export + client-portal self-service), DPPA §17 (soft-delete on DSAR erasure + client parity for unsubscribe), DPPA §21 (metadata PII validator), CPA §37 (marketing 12-mo re-consent + reasonable-window token TTL), AML §14 (gate-read audit + full consent-transition trail), and BOU cybersecurity §5.4 (throttle + single-use replay protection on the public unsubscribe endpoint).
Phased implementation plan
Complete — every phase shipped.
ClientPortalPreferencesController+ unsubscribe-service rewire for the client path.DataTransferConsent.expiresAt+ marketing default 12 mo + daily expiry job.- Read-audit event on non-transactional
isDeliverablereads. - Bulk-update item cap + throttle + OpenAPI declaration of unsubscribe TTL.
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.