Aller au contenu

Story NOTIF-002: Inbound STOP detection (capture side of mitigation #5)

Module: notifications Slice: NOTIF-002 Side: [BACKEND] Version target: V0.0.0 Sub-version: v0.0.0 (architecture-backed) Priority: 5 Depends on: NOTIF-001a (need notification rows to derive tenant from phone), NOTIF-001b (status webhook controller shares the HMAC verifier), NOTIF-001c (suppression table exists) Can develop concurrently with: NOTIF-003, NOTIF-004 (no shared code path) Merge order: Any order after dependencies Estimated complexity: M PRD User Stories: NOTIF-US-07 (AC-07.1 STOP detection, AC-07.4 per-tenant scope — adapted to phone-only resolution for v0.0.0) Wireframe: N/A — backend only


Objective

Implement POST /api/v1/notifications/webhooks/{providerId}/inbound — the inbound SMS endpoint that captures customer STOP replies. Verify the HMAC signature, normalize the body, and upsert a phone-scoped suppression row on the right tenant. Tenant resolution uses a 14-day lookback against notification.customer_phone_e164 (architecture §6.4) — this closes ARTCI mitigation #5 on the WRITE side, partnering with NOTIF-001c on the READ side.


Backend Scope

Entities

No new tables — extends behavior on suppression (from NOTIF-001c).

Migrations

None new.

Service Layer

  • InboundWebhookControllerPOST /api/v1/notifications/webhooks/{providerId}/inbound:
  • HMAC signature verified via the per-provider adapter (reuses the verifySignature(rawBody, signatureHeader) API introduced in NOTIF-001b). Invalid signature → 401 + structured WARN log (NOTIF_WEBHOOK_REJECTED audit lands at v0.1.0 per NOTIF-ADR-06).
  • Content-Length cap 8 KB (architecture §12 R3); Content-Type allow-list application/json.
  • Delegates to StopDetector.process(providerId, body).
  • StopDetector:
  • Normalize: body.trim().toUpperCase().stripAccents() (per architecture §6.4 + FR-11).
  • Match: normalized ∈ {"STOP", "ARRET", "NON", "NO"}. Else: 200 no-op (conversational text dropped — architecture §6.4).
  • Resolve tenant(s): SELECT DISTINCT tenant_id FROM notification WHERE customer_phone_e164 = ? AND enqueued_at > now() - INTERVAL '14 days'. (Architecture §6.4: scope is "per tenant the phone appears under in the last 14 days".)
  • If zero tenants resolve → log + drop, NO suppression row created (architecture §NOTIF-ADR-08).
  • For each resolved tenant → SuppressionService.upsert(tenant_id, customer_id=NULL, phone_e164, channel='SMS', reason='customer_stop'). Upsert is idempotent: duplicate STOP for the same (tenant, phone, SMS) does NOT create a duplicate row (active-row UNIQUE index from NOTIF-001c).
  • SuppressionService.upsert(...) — new write-side method on the service introduced read-only in NOTIF-001c. Uses ON CONFLICT DO NOTHING against the active-row UNIQUE partial index.
  • WA customer_block events — NOT in v0.0.0. The InboundWebhookController accepts the meta_wa_cloud_ci provider path but treats any non-STOP-like payload as 200 no-op (true WA suppression source lands V1).

API Endpoints

Method Path Request Response Auth
POST /api/v1/notifications/webhooks/{providerId}/inbound {from_phone_e164, body, received_at} per §3.3 200 (always, even on no-op or drop) / 401 (invalid signature) / 415 / 413 Provider HMAC signature

Validation Rules

  • HMAC signature REQUIRED (header per provider adapter scheme). Invalid → 401.
  • from_phone_e164 REQUIRED. body REQUIRED (may be empty after normalization).
  • Content-Length ≤ 8192. Content-Type application/json.

Multi-Tenant Considerations

  • Tenant resolution NEVER trusts the request body (provider does not know about tenants). It is derived from a 14-day lookback on notification.customer_phone_e164 — the only way phone-to-tenant routing can happen in v0.0.0.
  • Per-tenant scope (AC-07.4): if the same phone appears under 2 tenants in the lookback window, the STOP is recorded under EACH tenant the phone matches in those 14 days. Per architecture §6.4 final paragraph this is "v0.0.0-acceptable" — V1 may tighten.
  • Zero-tenant resolution: phone is unknown to NOTIF → log + drop, no suppression row created (NOTIF-ADR-08 — avoids leaking cross-tenant suppression by creating a global row).
  • Both V1 DB profiles: notification + suppression on platformDataSource (NOTIF-ADR-01).

Audit & Logging

  • No NOTIF_* audit event in v0.0.0 for STOP capture — the existing NOTIF_SUPPRESSED_OPT_OUT from NOTIF-001c will fire the next time dispatch attempts the suppressed phone, which is the audit trail that matters for ARTCI mitigation #5.
  • Structured INFO log line on STOP success: {tenant_id, phone_hashed, normalized_body, resolved_via:'14d_lookback'} (phone hashed via PiiHasher).
  • Structured WARN on invalid signature, on zero-tenant-drop, and on bodies > 8 KB.
  • NOTIF_WEBHOOK_REJECTED audit event ships at v0.1.0 (architecture §7 last row + OQ-NOTIF-04). Until then, logs are retained via Coolify and back-filled when the catalog admits the event.

Frontend Scope

N/A — backend only.


Acceptance Criteria

AC-002.1: STOP body normalized + suppression row upserted
Given a notification was sent in the last 14 days from tenant_T to phone_P
When the SMS provider POSTs `/webhooks/letstalk_sms_ci/inbound` with `{from_phone_e164:'+225...', body:'  stop  '}` and a valid HMAC header
Then the response is 200
And a `suppression` row exists with `(tenant_id=T, customer_id=NULL, phone_e164=P, channel='SMS', reason='customer_stop', removed_at IS NULL)`
And the body normalizations pass for any of {"STOP", "ARRET", "ARRÊT", "NON", "NO"} with leading/trailing whitespace and case variants

AC-002.2: Per-tenant scope — STOP lands on every tenant the phone appears under in 14d
Given two tenants A and B have BOTH sent a notification to phone_P in the last 14 days
When an inbound STOP arrives for phone_P
Then a `suppression` row is created under tenant_A AND a `suppression` row is created under tenant_B
And neither row is created under any other tenant
And the architecture §6.4 final paragraph rule is met (v0.0.0-acceptable per-tenant scope)
Given a phone_P with NO notification in the last 14 days
When an inbound STOP arrives for phone_P
Then no suppression row is created (NOTIF-ADR-08 log + drop)
And the response is 200 (no provider retry storm)

AC-002.3: Duplicate STOP is idempotent
Given a `suppression` row already exists for `(tenant_T, NULL, phone_P, SMS, customer_stop)`
When a second inbound STOP arrives for the same phone within the 14-day window
Then no duplicate row is created (ON CONFLICT DO NOTHING against the active-row UNIQUE index)
And the response is 200
And the original `suppressed_at` timestamp is unchanged

Compliance Rules

  • ARTCI mitigation #5 (no-resend after opt-out) — capture side completed by this slice (combined with NOTIF-001c's enforcement side). Status VÉRIFIÉ for the mechanism. Critical-rule #3 (audit completeness) is satisfied by the downstream NOTIF_SUPPRESSED_OPT_OUT that fires on the NEXT dispatch attempt.
  • Law n°2013-450 right of objection — TV-03: STOP-based right of objection accepted as the V1 mechanism without per-customer pre-consent. Status PRÉLIMINAIRE — emit [LEGAL-REVIEW] warning at NOTIF-US-08 milestone (V1).
  • Critical-rule #1 — tenant isolation enforced via the 14-day lookback (NOT a global suppression).
  • NOTIF-ADR-08 — zero-tenant-resolution → log + drop, no suppression created. This is the V1 mitigation for cross-tenant phone collisions.

Standards & Conventions

  • docs/standards/critical-rules.md
  • docs/standards/multi-tenant-model.md
  • docs/standards/compliance-discipline.md
  • docs/standards/api-guidelines.md (webhook signature, idempotency)
  • docs/standards/database-guidelines.md (ON CONFLICT DO NOTHING, partial UNIQUE indexes)
  • docs/standards/java-spring-guidelines.md
  • docs/standards/multi-datasource-patterns.md
  • docs/standards/tech-stack.md

Testing Requirements

Unit Tests

  • StopDetectorNormalizationTest — every variant of {"stop", "STOP", " ARRET ", "Arrêt", "non", "NO"} matches; "I want to STOP" does NOT match (FR-11: body must equal exactly one token).
  • StopDetectorZeroTenantDropTest — phone with no notification in 14d → no DB write, INFO+drop log.
  • SuppressionServiceUpsertIdempotencyTest — second STOP from same phone within window does not duplicate the row.

Integration Tests

  • InboundStopE2ETest (WireMock + Testcontainers) — full HMAC-signed inbound STOP → suppression row created → next POST /dispatch for same (tenant, customer/phone) short-circuits to OPT_OUT (cross-checks NOTIF-001c).
  • InboundStopMultiTenantTest — same phone under 2 tenants in 14d → 2 suppression rows after STOP (AC-002.2).
  • InboundStopInvalidSignatureTest — invalid HMAC → 401, no DB write, WARN log.

What QA Will Validate

  • Curl a STOP via the WireMock inbound stub, observe the suppression row and the next dispatch returning OPT_OUT.
  • Negative test: STOP from a phone never seen by NOTIF → no DB rows mutated; the system logs the drop and returns 200.
  • Multi-tenant negative: SMS sent only by tenant A → STOP creates a row under A only.

Out of Scope

  • WA customer_block capture from Meta — V1 (no source in v0.0.0).
  • Suppression visible in operator timeline (AC-07.5) — V1.
  • Platform-admin suppression removal (AC-07.6) — V1.
  • NOTIF_WEBHOOK_REJECTED audit catalog entry — v0.1.0 (NOTIF-ADR-06).
  • Per-provider scoped suppression (v0.0.0 scope only (tenant, phone, channel)) — V1.
  • Conversational handling beyond STOP — V3.

Definition of Done

  • [ ] POST /api/v1/notifications/webhooks/{providerId}/inbound implemented and HMAC-verified
  • [ ] StopDetector body normalization handles all FR-11 token variants
  • [ ] SuppressionService.upsert(...) is idempotent via the active-row UNIQUE partial index
  • [ ] All 3 ACs pass
  • [ ] Tenant isolation verified (14-day lookback; multi-tenant scope works; zero-tenant drops cleanly)
  • [ ] Both V1 DB profiles tested
  • [ ] PII hashed in every log line touching the phone
  • [ ] No silent failures: invalid signature → 401 + WARN; unknown phone → INFO + drop + 200; valid STOP → row + 200
  • [ ] Integration test confirms the next POST /dispatch after a STOP returns OPT_OUT (cross-cuts NOTIF-001c)