Skip to main content

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 the TRANSACTIONAL_CATEGORIES + OPT_IN_REQUIRED_CATEGORIES + categoryOfTemplate catalogue that NotificationDispatchService consumes.
  • 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) — public GET /u/:token unauthenticated endpoint. Verifies HMAC, flips enabled=false on the matched preference row, emits notification.unsubscribed.
  • NotificationPreference (Prisma) — (userId, category, channel) composite unique; enabled boolean; cascade on User delete. No purpose, no deletedAt, no metadata, no expiresAt.
  • ClientNotificationPreference — same shape keyed on clientId but only wired for the dispatch-time read path (NotificationDispatchService.dispatchByTemplateToClient); no controller endpoints ship yet.
  • DataTransferConsent(userId ?? clientId, transferKind) ledger with acknowledgedAt, withdrawnAt, ip, userAgent, noticeVersion, metadata (nullable JSON). No expiresAt.

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 NotificationPreference row 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. The purpose column is missing.
  • DPPA 2019 §11 — storage-limitation. RetentionPurgeJob doesn't sweep DataTransferConsent; consent rows grow unbounded. AML §14 requires a 7 y record for the ledger side, but rows past that window should be scrubbed of ip / userAgent / metadata.
  • DPPA 2019 §21 — PII in metadata. DataTransferConsent.metadata is 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/:token unsubscribe 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 in NotificationDispatchService.dispatchByTemplate — auth / compliance / system messages always deliver.
  • Opt-in-required marketing default-off: OPT_IN_REQUIRED_CATEGORIES = ['marketing'] sets enabled=false as the missing-row fallback for SMS + WhatsApp marketing — matches UCC Commercial Comms Guidelines 2019 §4 + CPA §37.
  • DPPA §16 ledger shape is correct: DataTransferConsent captures acknowledgedAt + 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: verifyUnsubscribeToken uses timingSafeEqual + 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.unsubscribed all land on the hash-chained trail.

Gaps

All 12 gaps closed. See shipped-notes below.

Phase 1 shipped (2026-07-19)

Release npref-p1-20260719-1356. Closes gaps 1-4:

  • gap 1 — @Throttle({ default: { ttl: 60_000, limit: 30 } }) on the @Public() GET /u/:token unsubscribe endpoint. Excess → 429.
  • gap 2 — Migration 20260719080000_notification_preferences_phase1 adds UnsubscribeTokenUsage model keyed on SHA-256(token). UnsubscribeService.apply writes on first use; a replay hits the unique constraint and short-circuits with { ok: true, alreadyProcessed: true } + emits notification.unsubscribed.replay_ignored audit event. The preference upsert only runs on the first use.
  • gap 3 — DsarService.exportForUser now pulls dataTransferConsents (via prisma.dataTransferConsent.findMany scoped to the subject, ordered by acknowledgedAt) into the export pack. A subject who issues a DSAR gets their full cross-border consent ledger with withdrawnAt, noticeVersion, transferKind.
  • gap 4 — Migration adds deletedAt + deletedBy + deletionReason to both NotificationPreference + ClientNotificationPreference. DsarService.eraseUser + .eraseClient swap the deleteMany for an updateMany that soft-deletes + flips enabled=false + stamps deletionReason='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).

Phase 2 shipped (2026-07-19)

Release npref-p2-20260719-1414. Closes gaps 5-8:

  • gap 5 — Migration 20260719090000_notification_preferences_phase2 wires a DataTransferConsent retention sweep in RetentionPurgeJob.tick. New data_sharing.data_transfer_consent_retention_days operational policy (default 2555 = 7 y AML §14 floor). Past the window the row keeps acknowledgedAt / withdrawnAt / transferKind (audit shape survives) but ip + userAgent + withdrawalIp + withdrawalUserAgent + metadata are scrubbed. Emits data_transfer_consent.retention.scrubbed.
  • gap 6 — Migration adds purpose (default default) to both NotificationPreference + ClientNotificationPreference. Composite unique becomes (userId, category, channel, purpose)
    • mirror for the client model. isDeliverable takes an optional purpose arg with 'default' default (source- compatible). bulkUpdate DTO gains a per-item purpose field with a PURPOSE_REGEX validator. Every caller of the old userId_category_channel composite unique (dispatch service, provider webhooks, unsubscribe controller) upgraded to the purpose-aware key.
  • gap 7 — Three new idempotent wrappers via the shared IdempotencyService: bulkUpdateIdempotent, acknowledgeIdempotent, withdrawIdempotent. Controller accepts Idempotency-Key on all three mutation routes.
  • gap 8 — New assertNoPiiInMetadata() helper on DataTransferConsentService runs the same phone / email / NIN sweep as Ratings. acknowledge() invokes it before the DB write; hit → 400 SANITISATION_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).

Phase 3 shipped (2026-07-19)

Release npref-p3-20260719-1444. Closes gaps 9-12:

  • gap 9 — PortalController (client-portal) gains GET /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 on NotificationPreferencesService: listForClient + bulkUpdateClientIdempotent. UnsubscribeService.apply on a subject whose ID matches a Client row now writes to ClientNotificationPreference (via the new purpose-aware composite unique) + emits notification.unsubscribed with entityType='client'; unknown subject IDs still emit client_pending so nothing is silently dropped.
  • gap 10 — Migration 20260719100000_notification_preferences_phase3 adds DataTransferConsent.expiresAt. MARKETING_ADJACENT_KINDS (sms_kenya / whatsapp_us) get a 12-month default on new acknowledgements; email_smtp stays permanent-until-withdrawn. isAcknowledged treats expiresAt < 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 explicit expiresAt (including null to override the default).
  • gap 11 — isDeliverable on a non-transactional read emits notification_preferences.gate_read with metadata { userId, category, channel, purpose, decision, reason }. Transactional bypasses stay silent (auth / compliance / system categories 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.items carries @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 * 30 in NotificationDispatchService.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 isDeliverable reads.
  • 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.