Aller au contenu

Story NOTIF-001b: SMS provider adapter + @Async submit + status webhook + delivery_attempt lifecycle

Module: notifications Slice: NOTIF-001 sub-slice b (of NOTIF-001 in architecture §11) Side: [BACKEND] Version target: V0.0.0 Sub-version: v0.0.0 (architecture-backed) Priority: 3 Depends on: NOTIF-001a, NOTIF-001d (country-profile resolver wiring), AUDIT-002 (catalog extension) Can develop concurrently with: None — depends on NOTIF-001a and NOTIF-001d Merge order: Before NOTIF-002, NOTIF-003, NOTIF-004 Estimated complexity: M PRD User Stories: NOTIF-US-01 (AC-01.1 completion); NOTIF-US-06 partial (webhook signature + idempotency); NOTIF-US-09 (AC-09.1, AC-09.2, AC-09.4, AC-09.5) Wireframe: N/A — backend only


Objective

Complete the SMS happy path: after POST /dispatch commits, submit the message to the SMS provider via the SmsProvider adapter strategy (LetsTalk or Hub2 per OQ-NOTIF-01 — concrete pick lands before dev-start). Handle the status webhook (signature-verified, idempotent). Drive the notification through PENDING → DISPATCHING_SMS → SMS_SENT → AWAITING_CASCADE (or FAILED). Emit NOTIF_SENT / NOTIF_FAILURE audit events. Set wa_scheduled_for = sms_sent_at + 5 days to arm the v0.0.0 WhatsApp cascade (which actually fires in NOTIF-003).


Backend Scope

Entities

No new tables — extends behavior on notification and delivery_attempt from NOTIF-001a.

Migrations

None new. (No DDL changes; this story uses the columns already created in NOTIF-001a.)

Service Layer

  • SmsProvider interface (named-interface, Spring Modulith):
  • SmsSubmitResult send(SmsSubmitRequest req) — returns {provider_message_id, status:'SENT'|'HARD_ERROR', failure_class?}.
  • Per-call 10 s timeout (Resilience4j config — architecture §8).
  • Implements idempotency via a client-ref (delivery_attempt.attempt_id carried as the provider's idempotency key — both LetsTalk and Meta support a client-ref per architecture §5 row 2).
  • LetsTalkSmsAdapter implements SmsProvider (OR Hub2SmsAdapter — concrete choice pending OQ-NOTIF-01; interface is fixed). Configured via env vars per §14.1: NOTIF_SMS_PROVIDER_BASE_URL, NOTIF_SMS_PROVIDER_API_KEY.
  • SmsDispatcher@Async, runs after-commit (@TransactionalEventListener(phase=AFTER_COMMIT) on the NotificationEnqueued Spring event published from NOTIF-001a's transaction):
  • CAS-update notification.status = 'DISPATCHING_SMS' (compare-and-swap from PENDING).
  • Call SmsProvider.send(...) with client_ref = attempt_id.
  • On 2xx: update delivery_attempt(status='SENT', provider_message_id=?, submitted_at=now()); update notification(status='SMS_SENT', sms_sent_at=now(), wa_scheduled_for = sms_sent_at + INTERVAL '5 days'); transition to AWAITING_CASCADE in the same TX (architecture §2.3 — after-commit). Emit NOTIF_SENT outbox event + notification.sent Spring application event.
  • On hard-error / network timeout / non-2xx: update delivery_attempt(status='HARD_ERROR'); update notification(status='FAILED', last_failure_class=?). Emit NOTIF_FAILURE outbox event + notification.failed Spring event. No retry in v0.0.0 (architecture §5 row 5 — v0.1.0 adds policy).
  • StatusWebhookControllerPOST /api/v1/notifications/webhooks/{providerId}/status:
  • Verify HMAC signature on the raw body via the provider's adapter (each adapter exposes a verifySignature(rawBody, signatureHeader) method). Reject 401 if invalid (logged as structured WARN — NOTIF_WEBHOOK_REJECTED audit lands at v0.1.0 per NOTIF-ADR-06; OQ-NOTIF-04).
  • Body ≤ 8 KB Content-Length cap (architecture §12 R3); Content-Type allow-list application/json.
  • Resolve tenant via provider_message_iddelivery_attempt.tenant_id (denormalized for this purpose — architecture §4.1).
  • Idempotency: dedup key (provider_id, provider_message_id, status). If terminal status already applied, return 200 no-op.
  • On DELIVERED: update delivery_attempt.status='DELIVERED', update notification.status='DELIVERED' IF currently in SMS_SENT (CAS — don't override an AWAITING_CASCADE row, see edge case below); append to status_timeline JSONB array.
  • On FAILED post-submit: update delivery_attempt.status='FAILED', append to timeline; do not auto-flip notification — v0.0.0 has no webhook-loss reconciliation (architecture §5 row 4) and a late-FAILED webhook after SMS_SENT is honest-scope acknowledged.
  • Webhook for unknown provider_message_id: return 200 no-op (avoid provider retry storms — architecture §4.1).
  • NotificationEnqueued Spring application event — published from NOTIF-001a dispatch() (this story adds the listener).
  • notification.dispatched / notification.sent / notification.failed Spring application events — emitted (consumed by OP in V1; v0.0.0 fires silently per architecture §1.2 last row).

API Endpoints

Method Path Request Response Auth
POST /api/v1/notifications/webhooks/{providerId}/status Provider envelope per §3.3 200 (idempotent) / 401 Provider HMAC signature

Validation Rules

  • Webhook Content-Length ≤ 8192 bytes; Content-Type: application/json. Else 415 / 413.
  • Signature header REQUIRED — missing or invalid → 401 + WARN log.
  • provider_message_id REQUIRED in body. status{SENT, DELIVERED, FAILED}.

Multi-Tenant Considerations

  • Webhook tenant resolution NEVER uses request body — always derived from delivery_attempt.tenant_id (architecture §4.1). Webhooks for unknown provider_message_id get 200 + drop, no cross-tenant data leak.
  • SmsDispatcher runs with tenant_id in MDC; all DB queries scoped by tenant_id.
  • Both V1 DB profiles supported — NOTIF tables remain on platformDataSource (NOTIF-ADR-01).
  • correlation_id propagated through MDC into the @Async task and into every outbox + Spring event (NOTIF-ADR-07).

Audit & Logging

  • NOTIF_SENT — emitted on provider 2xx with mandatory fields per audit-log §B.3: {tenant_id, agency_id, country_code, customer_id, contract_id, signed_link_id, correlation_id, channel:'SMS', provider_id, provider_message_id, attempt_index, result:'INFO'}. PII hashed via PiiHasher.
  • NOTIF_FAILURE — emitted on terminal SMS failure: {tenant_id, agency_id, country_code, customer_id, contract_id, correlation_id, last_channel:'SMS', failure_class, attempt_count:1, result:'FAILURE'}.
  • Webhook signature-rejection emits structured WARN log (NOT NOTIF_WEBHOOK_REJECTED audit — that lands v0.1.0 per NOTIF-ADR-06).
  • terminal_event_published_at set on first terminal Spring-event publication to guarantee exactly-once (architecture §2.1).

Frontend Scope

N/A — backend only.


Acceptance Criteria

AC-001b.1: Happy path — SMS submitted after commit, NOTIF_SENT emitted
Given a notification was enqueued in PENDING via NOTIF-001a
When the transaction commits and SmsDispatcher runs the @Async submit
And the SMS provider returns 2xx with a provider_message_id
Then `notification.status = 'AWAITING_CASCADE'`, `sms_sent_at` set, `wa_scheduled_for = sms_sent_at + 5 days`
And `delivery_attempt.status = 'SENT'`, `provider_message_id` populated
And outbox contains a `NOTIF_SENT` event with PII-hashed payload and the original `correlation_id`
And the Spring application event `notification.sent` is published exactly once

AC-001b.2: Status webhook is signature-verified + idempotent
Given a delivered SMS for `(provider_id='letstalk_sms_ci', provider_message_id='abc')`
When the provider POSTs `/webhooks/letstalk_sms_ci/status` with `{provider_message_id:'abc', status:'DELIVERED'}` and a valid HMAC header
Then the response is 200, `delivery_attempt.status='DELIVERED'`, `notification.status='DELIVERED'` (transitioned from SMS_SENT)
And the `status_timeline` JSONB has one new entry `{status:'DELIVERED', at:..., raw_response_truncated:<=8KB}`
When the same webhook payload is replayed (idempotency)
Then the response is 200 no-op, no duplicate timeline entry, `notification.status` unchanged
When a webhook arrives with a missing/invalid HMAC header
Then the response is 401, no DB write, a structured WARN log line is emitted

AC-001b.3: Provider hard-error → FAILED + NOTIF_FAILURE
Given a notification dispatched in NOTIF-001a
When SmsDispatcher calls the provider and receives a hard error (4xx) OR the 10s timeout fires with no 2xx
Then `notification.status = 'FAILED'`, `last_failure_class` populated (e.g. `INVALID_PHONE` / `SMS_HARD_ERROR` / `PROVIDER_TIMEOUT`)
And `delivery_attempt.status = 'HARD_ERROR'`
And outbox contains a `NOTIF_FAILURE` event with the correlation_id
And the Spring application event `notification.failed` is published exactly once
And no retry is attempted (v0.0.0 scope honest-acknowledged)

Compliance Rules

  • ARTCI mitigation #4 (audit-log per send)NOTIF_SENT / NOTIF_FAILURE emission is non-negotiable. Status VÉRIFIÉ. Critical-rule #3.
  • Proof of submission (CIMA Reg. 01-24 — proof of broker outreach)NOTIF_SENT is the proof of submission, NOT of delivery. Architecture §6.3 explicitly accepts this honest-scope limitation for v0.0.0. Webhook-loss reconciliation lands at V1 (NOTIF-US-06).
  • Webhook signature gate — FR-09 of the V1 PRD applies in spirit; structured WARN log instead of NOTIF_WEBHOOK_REJECTED audit event in v0.0.0 (NOTIF-ADR-06 + OQ-NOTIF-04).
  • Law n°2013-450 (PII pseudonymization)PiiHasher applied to phone + customer_first_name in every audit payload (audit-log AC-06). Status VÉRIFIÉ for the mechanism.

Standards & Conventions

  • docs/standards/critical-rules.md (full)
  • docs/standards/api-guidelines.md (webhook idempotency, error envelope)
  • docs/standards/database-guidelines.md (JSONB append patterns, partial indexes)
  • docs/standards/java-spring-guidelines.md (@TransactionalEventListener(AFTER_COMMIT), @Async, Resilience4j, MDC propagation across async tasks)
  • docs/standards/multi-datasource-patterns.md
  • docs/standards/multi-tenant-model.md
  • docs/standards/compliance-discipline.md
  • docs/standards/tech-stack.md

Testing Requirements

Unit Tests

  • SmsDispatcherHappyPathTest — mocks SmsProvider, asserts state transitions, asserts wa_scheduled_for = sms_sent_at + 5 days.
  • SmsDispatcherHardErrorTest — provider returns 422 hard-error → FAILED + NOTIF_FAILURE.
  • SmsDispatcherTimeoutTest — provider hangs > 10s → timeout → FAILED.
  • StatusWebhookIdempotencyTest — replay of terminal status returns 200 no-op.
  • StatusWebhookSignatureTest — invalid HMAC → 401, structured log emitted.
  • LetsTalkSmsAdapterIdempotencyTest — repeated send with same client_ref returns the same provider_message_id (WireMock stub asserts no double-submit).

Integration Tests

  • SmsSubmitE2ETest (WireMock) — full POST /dispatch → WireMock receives the SMS submission → status webhook → notification ends in DELIVERED.
  • WebhookTenantResolutionTest — webhook arrives, tenant correctly resolved via provider_message_id lookup; unknown provider_message_id returns 200 no-op without DB write.
  • CorrelationIdPropagationTestcorrelation_id from POST /dispatch appears in NOTIF_SENT outbox payload AND in @Async task log lines (MDC).
  • AsyncMdcPropagationTest — confirms MDC values survive the @Async task boundary.

What QA Will Validate

  • WireMock receipt count == dispatch count (no duplicate submits, no silent drops).
  • p95 POST /dispatch → SMS submit ≤ 5 s (architecture §8 end-to-end target).
  • terminal_event_published_at set exactly once per notification — replay of a webhook does NOT re-publish.
  • Smoke test from architecture §14.2 ends with NOTIF_SENT count == dispatch count in audit_log.

Out of Scope

  • Inbound STOP webhook — NOTIF-002.
  • Suppression pre-check at dispatch time — NOTIF-001c (this story still doesn't consult suppression; safe because NOTIF-001c lands before any real customer is reached in production).
  • WhatsApp cascade firer — NOTIF-003 (this story only ARMS the cascade by setting wa_scheduled_for; the firer that actually sends WA lives in NOTIF-003).
  • Crash recovery on DISPATCHING_SMS rows — NOTIF-004.
  • Retry policy (transient errors) — v0.1.0.
  • NOTIF_WEBHOOK_REJECTED audit catalog entry — v0.1.0 (NOTIF-ADR-06).
  • Webhook-loss reconciliation polling — V1 (NOTIF-US-06).
  • Operator timeline UI / manual retry — V1.

Definition of Done

  • [ ] SmsProvider interface + at least one concrete adapter (LetsTalkSmsAdapter or Hub2SmsAdapter — final pick per OQ-NOTIF-01)
  • [ ] SmsDispatcher runs after-commit via @TransactionalEventListener(AFTER_COMMIT) + @Async
  • [ ] POST /api/v1/notifications/webhooks/{providerId}/status implemented, HMAC-verified, idempotent
  • [ ] All 3 ACs pass against WireMock stubs (200/422/timeout scenarios)
  • [ ] Unit + integration tests written and passing
  • [ ] Tenant isolation verified on webhook resolution (unknown provider_message_id → 200 no-op, no DB write)
  • [ ] correlation_id end-to-end propagation through MDC + @Async + outbox payloads
  • [ ] NOTIF_SENT and NOTIF_FAILURE audit events present with correct mandatory fields + PII hashed
  • [ ] terminal_event_published_at guards exactly-once Spring event publication
  • [ ] wa_scheduled_for correctly computed as sms_sent_at + INTERVAL '5 days' on the happy path
  • [ ] No silent failures on provider errors (every failure path produces a NOTIF_FAILURE audit event and notification.failed Spring event)
  • [ ] Webhook signature-rejection produces a structured WARN log line (audit-eventification deferred to v0.1.0)
  • [ ] p95 POST /dispatchSMS_SENT ≤ 5 s (architecture §8)