Aller au contenu

Story NOTIF-001c: Suppression table + pre-dispatch lookup + NOTIF_SUPPRESSED_OPT_OUT emission

Module: notifications Slice: NOTIF-001 sub-slice c (of NOTIF-001 in architecture §11) Side: [BACKEND] Version target: V0.0.0 Sub-version: v0.0.0 (architecture-backed) Priority: 4 Depends on: NOTIF-001a, AUDIT-002 (catalog extension for NOTIF_SUPPRESSED_OPT_OUT — architecture §11 R1) Can develop concurrently with: NOTIF-001b (no shared code path) Merge order: Before NOTIF-001b reaches production (no real SMS goes out until suppression is honored — ARTCI mitigation #5) Estimated complexity: S PRD User Stories: NOTIF-US-07 (AC-07.3 pre-dispatch honoring); NOTIF-US-09 (AC-09.3 NOTIF_OPTED_OUT — renamed NOTIF_SUPPRESSED_OPT_OUT in v0.0.0 catalog) Wireframe: N/A — backend only


Objective

Introduce the suppression table and the pre-dispatch suppression lookup. Before NOTIF-001a inserts a delivery_attempt, consult suppression for (tenant_id, customer_id|phone, channel). If every viable channel is suppressed, set the notification to OPT_OUT and emit the NOTIF_SUPPRESSED_OPT_OUT audit event — no provider call is made. This closes ARTCI mitigation #5 (no-resend after opt-out) for the dispatch side; the inbound STOP capture that populates the table lives in NOTIF-002.


Backend Scope

Entities

suppression (full v0.0.0 schema — architecture §2.1): - suppression_id UUID PK - tenant_id UUID NOT NULL - customer_id UUID NULL (some STOPs identify by phone only in v0.0.0 — see NOTIF-002) - phone_e164 TEXT NOT NULL (supports phone-only opt-out — mitigation #5) - channel TEXT NOT NULL — SMS / WA - reason TEXT NOT NULL — customer_stop / customer_block / admin_override - suppressed_at TIMESTAMPTZ NOT NULL - removed_at TIMESTAMPTZ NULL — platform-admin removal (V1 only; column exists Day 1)

Migrations

  • V0_0_0__notif_create_suppression.sql — creates suppression table + indexes:
  • UNIQUE (tenant_id, COALESCE(customer_id, '00000000-0000-0000-0000-000000000000'::uuid), phone_e164, channel) WHERE removed_at IS NULL — active-row uniqueness per architecture §2.2.
  • (tenant_id, phone_e164) WHERE removed_at IS NULL — STOP webhook lookup (phone-only path used by NOTIF-002).

Service Layer

  • SuppressionService.isSuppressed(UUID tenantId, UUID customerId, String phoneE164, Channel ch): boolean — returns true iff an active row exists matching (tenant_id, channel) AND (customer_id = ? OR (customer_id IS NULL AND phone_e164 = ?)) with removed_at IS NULL.
  • Hook into NotificationService.dispatch(...) (NOTIF-001a) — extends the existing dispatch path:
  • Before inserting the SMS delivery_attempt, call SuppressionService.isSuppressed(tenant, customer, phone, SMS).
  • If SMS is suppressed AND the customer has no other viable channel in v0.0.0 (v0.0.0 sends SMS only at T0 — WA is the cascade in NOTIF-003, which itself re-checks suppression at firer time per architecture §6.2), then DO NOT insert a delivery_attempt, set notification.status='OPT_OUT', emit NOTIF_SUPPRESSED_OPT_OUT outbox event + notification.opted_out Spring application event, and return 202 {notification_id, status:'OPT_OUT'} (per architecture §2.3 transition PENDING → OPT_OUT).
  • If SMS is not suppressed → proceed with the existing NOTIF-001a / NOTIF-001b path.
  • SuppressionService reads only — writes happen in NOTIF-002 (STOP webhook).

API Endpoints

None new. (This story modifies POST /api/notifications/dispatch behavior — same endpoint, same contract.)

Validation Rules

None new. (Existing validation from NOTIF-001a continues to apply BEFORE the suppression lookup; an invalid request is still rejected with 400 and no suppression query is run — that path consumes no cap, no audit, per OQ-NOTIF-08.)

Multi-Tenant Considerations

  • Suppression rows are STRICTLY tenant-scoped — critical-rule #1. Every lookup carries tenant_id. AC-07.4 (per-tenant scope) enforced architecturally: there is no global suppression in v0.0.0.
  • BYO-DB profile: suppression lives on platformDataSource along with the rest of NOTIF (NOTIF-ADR-01 / TV-05).

Audit & Logging

  • NOTIF_SUPPRESSED_OPT_OUT — emitted when dispatch is short-circuited by an active suppression. Mandatory fields per audit-log §B.3 NOTIF row + the suppressed_channels array: {tenant_id, agency_id, country_code, customer_id, correlation_id, suppressed_channels: ['SMS'] (v0.0.0), result:'INFO'}. PII hashed via PiiHasher.
  • Spring application event notification.opted_out published (consumed by OP in V1).
  • The audit catalog admits NOTIF_SUPPRESSED_OPT_OUT — this entry is the v0.0.0 scope of OQ-NOTIF-04 (architecture §0 + §7) and is shipped by AUDIT-002. This story HARD-DEPENDS on AUDIT-002 being merged first.

Frontend Scope

N/A — backend only.


Acceptance Criteria

AC-001c.1: Active suppression short-circuits dispatch
Given an active row in `suppression` for `(tenant_id, customer_id, phone_e164, channel='SMS', removed_at IS NULL)`
When `POST /api/notifications/dispatch` is called for that `(tenant_id, customer_id)` with the same phone
Then NOTIF returns 202 with `{notification_id, status:'OPT_OUT'}`
And no `delivery_attempt` row is created
And no SMS submission is attempted
And outbox contains a `NOTIF_SUPPRESSED_OPT_OUT` event with `suppressed_channels:['SMS']` and PII-hashed payload
And the Spring application event `notification.opted_out` is published

AC-001c.2: Per-tenant scope — no cross-tenant leak
Given an active suppression for `(tenant_A, customer_X, phone_P, SMS)`
When tenant_B (different tenant_id) dispatches to the same physical phone_P for a different customer
Then dispatch proceeds normally (no OPT_OUT, no NOTIF_SUPPRESSED_OPT_OUT)
And tenant_A's suppression row is NOT visible to tenant_B (no cross-tenant lookup)

AC-001c.3: Phone-only suppression (customer_id IS NULL) honored
Given an active suppression with `customer_id=NULL, phone_e164='+225...', channel='SMS', tenant_id=T`
When dispatch is called with that phone for tenant T (with any customer_id)
Then dispatch short-circuits as in AC-001c.1
And the audit event records the suppression_id as the source

Compliance Rules

  • ARTCI mitigation #5 (no-resend after opt-out) — non-negotiable; closure of this mitigation depends on this slice + NOTIF-002 (capture side). Status VÉRIFIÉ for the mechanism. Architecture §0.
  • Law n°2013-450 right of objection (TV-03) — STOP suppression mechanism is the V1-accepted compensating control. Status PRÉLIMINAIRE — emit [LEGAL-REVIEW] warning at NOTIF-002 / NOTIF-US-08 milestones, not here.
  • Critical-rule #1 — per-tenant scope enforced via tenant_id filter on every suppression query.
  • Critical-rule #3NOTIF_SUPPRESSED_OPT_OUT audit emission is mandatory whenever suppression triggers; AUDIT rejects unknown event_type so AUDIT-002 catalog extension MUST be merged first.

Standards & Conventions

  • docs/standards/critical-rules.md
  • docs/standards/multi-tenant-model.md
  • docs/standards/compliance-discipline.md
  • docs/standards/database-guidelines.md (partial unique indexes with COALESCE for nullable columns)
  • docs/standards/java-spring-guidelines.md
  • docs/standards/multi-datasource-patterns.md
  • docs/standards/tech-stack.md

Testing Requirements

Unit Tests

  • SuppressionServiceLookupTest — active row hits; removed_at IS NOT NULL row misses; customer_id match + phone-only match.
  • NotificationServiceSuppressionShortCircuitTest — full path: suppression → OPT_OUT + audit emitted + no delivery_attempt.

Integration Tests

  • SuppressionTenantIsolationTest — tenant_A's suppression does not affect tenant_B (AC-001c.2 verbatim).
  • SuppressionAuditPayloadTest — outbox NOTIF_SUPPRESSED_OPT_OUT payload carries hashed phone + suppressed_channels:['SMS'] + correlation_id.
  • PhoneOnlySuppressionTestcustomer_id IS NULL row matches when dispatch carries any customer_id with that phone (AC-001c.3).

What QA Will Validate

  • An opt-out customer cannot be reached: 100 sequential POST /dispatch to a suppressed (tenant, customer) produce 0 SMS in the WireMock log, 100 NOTIF_SUPPRESSED_OPT_OUT audit events, 0 NOTIF_SENDING events.
  • Cross-tenant negative test: same phone in 2 tenants, suppressed only in 1 → only 1 OPT_OUT, the other tenant's dispatch reaches WireMock normally.

Out of Scope

  • Inbound STOP webhook (suppression WRITE path) — NOTIF-002.
  • Suppression visible in operator timeline (AC-07.5) — V1.
  • Platform-admin suppression removal (AC-07.6 / NOTIF_SUPPRESSION_REMOVED audit) — V1.
  • WA suppression at cascade time (re-check in WhatsAppCascadeFirer) — NOTIF-003.
  • WhatsApp customer_block events from Meta — V1 (no WA suppression source in v0.0.0; WA cascade simply re-checks SMS suppression — see NOTIF-003).

Definition of Done

  • [ ] V0_0_0__notif_create_suppression.sql Flyway migration applied cleanly
  • [ ] SuppressionService.isSuppressed(...) implemented and unit-tested
  • [ ] NotificationService.dispatch(...) short-circuits to OPT_OUT before any delivery_attempt insert when suppression hits
  • [ ] All 3 ACs pass
  • [ ] AUDIT-002 catalog extension confirmed in place (CI test fails loudly if NOTIF_SUPPRESSED_OPT_OUT is rejected by AUDIT)
  • [ ] notification.opted_out Spring application event published
  • [ ] Tenant isolation verified (cross-tenant negative test in CI)
  • [ ] Both V1 DB profiles tested — suppression on platformDataSource regardless of tenant profile
  • [ ] PII hashed in audit payload via PiiHasher
  • [ ] correlation_id propagated to NOTIF_SUPPRESSED_OPT_OUT event
  • [ ] No silent failures: a suppression hit ALWAYS produces a NOTIF_SUPPRESSED_OPT_OUT audit event