Aller au contenu

Story NOTIF-003: WhatsApp cascade at T+5d (with AUTH reissue + cancel-on-click)

Module: notifications Slice: NOTIF-003 Side: [BACKEND] Version target: V0.0.0 Sub-version: v0.0.0 (architecture-backed) Priority: 6 Depends on: NOTIF-001a/b/c/d merged; AUTH must ship SignedLinkService.reissueForCascade(originalLinkId, ttl) Spring named interface (OQ-NOTIF-13 — cross-module dependency); CP must publish the customer.session_opened Spring application event (OQ-NOTIF-14 — cross-module dependency) Can develop concurrently with: NOTIF-002, NOTIF-004 (no shared code path) Merge order: After AUTH reissueForCascade and CP customer.session_opened are merged Estimated complexity: L PRD User Stories: NOTIF-US-09 partial (audit emission with channel=WA); architecture §11 NOTIF-003 native ACs Wireframe: N/A — backend only


Objective

Implement the WhatsApp cascade scheduled task. Every 5 minutes, scan notification rows in AWAITING_CASCADE with wa_scheduled_for <= now(), re-check suppression, ask AUTH for a freshly-reissued signed link, submit to the WhatsApp Cloud API (or BSP per OQ-NOTIF-01), and emit the cascade audit/event chain. Cancel rows where the customer has already clicked (via the customer.session_opened event from CP). This is the SMS-first → WA-at-T+5d cascade that NOTIF-ADR-02 locks (overriding the parallel-send V0 envelope wording).


Backend Scope

Entities

Extends behavior on notification and delivery_attempt (uses fields wa_scheduled_for, cascade_signed_link_id, cascade_signed_link_url, wa_dispatched_at, customer_clicked_at already declared in NOTIF-001a).

Migrations

None new.

Service Layer

  • WhatsAppProvider interface — symmetric to SmsProvider from NOTIF-001b; carries String providerId() discriminator (registered via WaProviderRegistry introduced in NOTIF-001d).
  • MetaWaCloudAdapter implements WhatsAppProvider (OR <BSP>WaAdapter — concrete choice pending OQ-NOTIF-01). Configured via env vars per architecture §14.1: NOTIF_WA_PROVIDER_BASE_URL, NOTIF_WA_PHONE_NUMBER_ID, NOTIF_WA_ACCESS_TOKEN, NOTIF_WA_TEMPLATE_NAME (template name hard-coded via env var in v0.0.0; per-tenant template = V1 per architecture §10).
  • WhatsAppCascadeFirer@Scheduled(fixedDelayString = "${notif.cascade.firer.tick.seconds:300}000"):
  • SELECT ... FROM notification WHERE status='AWAITING_CASCADE' AND wa_scheduled_for <= now() FOR UPDATE SKIP LOCKED LIMIT ${notif.cascade.firer.batch.size:50}. Uses the partial index (status, wa_scheduled_for) WHERE status='AWAITING_CASCADE' from NOTIF-001a.
  • For each row, re-check SuppressionService.isSuppressed(tenant, customer, phone, WA) — if suppressed, CAS to CASCADE_CANCELLED (reason wa_suppressed), emit notification.cascade_cancelled Spring event, skip.
  • Call SignedLinkService.reissueForCascade(originalLinkId, ttl=Duration.ofHours(72)) (AUTH named interface). AUTH inserts a new signed_link row with the same correlation_id, same case key, lineage_of=originalLinkId. Returns {new_signed_link_id, url}.
  • CAS-update: UPDATE notification SET status='DISPATCHING_WA', cascade_signed_link_id=?, cascade_signed_link_url=?, wa_dispatched_at=now() WHERE id=? AND status='AWAITING_CASCADE' AND customer_clicked_at IS NULL. If 0 rows updated → late click race → set CASCADE_CANCELLED (reason race_with_click), emit notification.cascade_cancelled, continue (architecture §5 row 6).
  • Submit to WhatsAppProvider.send(...) with the cascade link URL + template name + tenant's per-country WA business identity. On 2xx → insert delivery_attempt(channel='WA', provider_id, provider_message_id, status='SENT', attempt_index=1), emit NOTIF_SENDING + NOTIF_SENT outbox events + notification.dispatched + notification.sent Spring events (each with channel='WA'). On hard-error → notification.status='FAILED', last_failure_class=?, emit NOTIF_FAILURE.
  • AUTH reissueForCascade retry policy: if AUTH returns 5xx, the firer leaves the row in AWAITING_CASCADE (does not mutate state). Next tick (5 min) retries. After 3 consecutive failed ticks for the same notification → FAILED + last_failure_class='CASCADE_REISSUE_FAILED' + NOTIF_FAILURE (architecture §12 R5).
  • CustomerSessionOpenedListener@EventListener on the CP-published customer.session_opened {tenant_id, customer_id, signed_link_id, opened_at} Spring application event:
  • Filter by tenant_id (NOTIF re-filters even though CP carries it — defense in depth).
  • UPDATE notification SET status='CASCADE_CANCELLED', customer_clicked_at=now() WHERE tenant_id=? AND signed_link_id=? AND status='AWAITING_CASCADE'. CAS — 0 rows updated means the firer beat the listener (architecture §5 row 6); leave row in DISPATCHING_WA, do not emit cascade_cancelled.
  • On success: emit notification.cascade_cancelled Spring event.
  • WA status webhook reuses StatusWebhookController from NOTIF-001b — the controller is already provider-agnostic via {providerId} path param.
  • Reuses WaProviderRegistry skeleton from NOTIF-001d.

API Endpoints

Method Path Request Response Auth
POST /api/v1/notifications/webhooks/meta_wa_cloud_ci/status Provider envelope per §3.3 200 / 401 Meta-style HMAC signature (X-Hub-Signature-256)

Validation Rules

  • WA template body MUST include the cascade signed link URL in a body parameter slot. The template is registered with Meta out-of-band (OQ-NOTIF-01 deliverable).
  • customer_phone_e164 MUST be unchanged from the original SMS (carried forward from the notification row — no re-validation needed at cascade time).
  • Feature flag gate: FEATURE_NOTIF_WHATSAPP_CASCADE_ENABLED (architecture §12 R2 + §14.1). When false, the firer no-ops (rows stay in AWAITING_CASCADE until the flag flips OR the row ages out — V1 ages-out policy land later). This is the OQ-NOTIF-02 ARTCI cross-border safety valve.

Multi-Tenant Considerations

  • The firer's SELECT ... FOR UPDATE SKIP LOCKED is global but each iteration carries the row's tenant_id into MDC + every subsequent call. Critical-rule #1 enforced per-row.
  • agency_id is nullable in v0.0.0 (per architecture §4.3); v0.0.2 adds it as a partition key for fair scheduling across agencies. This story does NOT implement per-agency partitioning — single-tenant + single-agency assumption is acceptable for v0.0.0.
  • Both V1 DB profiles supported (all NOTIF tables on platformDataSource).
  • correlation_id from the original SMS notification is carried into the WA delivery_attempt, every audit event, the WA provider's client-ref, AND the AUTH reissueForCascade call (so the reissued link inherits the chain per D16 + NOTIF-ADR-07).

Audit & Logging

  • NOTIF_SENDING (channel=WA) + NOTIF_SENT (channel=WA) on each cascade attempt that goes out — mandatory fields per audit-log §B.3, PII hashed.
  • NOTIF_FAILURE on terminal WA failure OR AUTH reissue exhausted-retries.
  • notification.dispatched, notification.sent, notification.failed, notification.cascade_cancelled Spring application events with channel='WA' (or null for cascade_cancelled).
  • Cascade-cancel race: when CAS update returns 0 rows in step 4, log INFO cascade_cancel_race with both notification_id and correlation_id.
  • NOTIF_WEBHOOK_REJECTED for invalid WA webhook signature — same v0.0.0 honest-scope: WARN log instead of audit event (NOTIF-ADR-06).

Frontend Scope

N/A — backend only.


Acceptance Criteria

AC-003.1: Cascade fires at T+5d only for un-clicked rows
Given a notification at `status='AWAITING_CASCADE'` with `wa_scheduled_for = now() - 1 minute` and `customer_clicked_at IS NULL` and no WA suppression
When the WhatsAppCascadeFirer tick runs
Then exactly one row is picked under FOR UPDATE SKIP LOCKED
And `SignedLinkService.reissueForCascade` is called with the original signed_link_id
And the notification transitions to `DISPATCHING_WA` (CAS succeeds)
And the WA provider receives one submit with the new cascade link URL
And a `delivery_attempt(channel='WA', provider_id, status='SENT', attempt_index=1)` row is persisted
And outbox contains `NOTIF_SENDING` + `NOTIF_SENT` events with `channel='WA'` and `correlation_id` inherited from the original SMS

AC-003.2: CAS prevents race with late session_opened
Given a notification in AWAITING_CASCADE, wa_scheduled_for past
When CustomerSessionOpenedListener processes `customer.session_opened` for that notification's signed_link_id
And the WhatsAppCascadeFirer concurrently attempts the CAS update
Then exactly one wins: either the listener (status=CASCADE_CANCELLED, customer_clicked_at set, `notification.cascade_cancelled` emitted) OR the firer (status=DISPATCHING_WA, WA submit reaches provider exactly once)
And NEVER both (the row never ends up in CASCADE_CANCELLED with a WA delivery_attempt row alongside)

AC-003.3: WA-channel suppression skips the firer cleanly
Given an active suppression for `(tenant, customer, channel='WA')` (e.g. customer_block event, or admin override)
And a notification in AWAITING_CASCADE for that customer past wa_scheduled_for
When the firer tick runs
Then the notification is set to `CASCADE_CANCELLED` with reason `wa_suppressed`
And NO call is made to AUTH reissueForCascade
And NO call is made to the WhatsApp provider
And `notification.cascade_cancelled` Spring event is emitted

AC-003.4: AUTH reissue 5xx retried up to 3 ticks then FAILED
Given a notification in AWAITING_CASCADE past wa_scheduled_for
When `SignedLinkService.reissueForCascade` returns 5xx for 3 consecutive firer ticks (5 min apart)
Then on tick 4 the notification is set to `FAILED` with `last_failure_class='CASCADE_REISSUE_FAILED'`
And `NOTIF_FAILURE` is emitted with `correlation_id` and `last_channel='WA'`
And `notification.failed` Spring event fires exactly once

Compliance Rules

  • ARTCI mitigations (5) — all five remain active for the WA cascade leg: opt-out link in body (template-locked), consent flag verified at the SMS leg (carried forward via notification row), privacy notice link in body (template-locked), audit emission per send (this story emits NOTIF_SENDING + NOTIF_SENT), suppression honored (WA suppression check before reissue). Status VÉRIFIÉ for the mechanism.
  • OQ-NOTIF-02 (ARTCI cross-border WA filing)INCERTAIN — avocat requis. This is the BLOQUANT carried by FEATURE_NOTIF_WHATSAPP_CASCADE_ENABLED. Pilot proceeds under [FOUNDER-RISK-ACCEPTED 2026-05-20] (pre-GA window per architecture §0). PRODUCTION GA requires ARTCI B5 + B10 authorizations (PRD V0 envelope). This slice ships behind the flag and DOES NOT lift the BLOQUANT.
  • AUTH boundary (NOTIF-ADR-03) — NOTIF cannot mint signed links itself; the reissue MUST go through AUTH reissueForCascade. Architecture §7 row "AUTH boundary".
  • Critical-rule #3 — every cascade attempt produces NOTIF_SENDING + NOTIF_SENT or NOTIF_FAILURE. Audit completeness non-negotiable.

Standards & Conventions

  • docs/standards/critical-rules.md
  • docs/standards/multi-tenant-model.md
  • docs/standards/compliance-discipline.md (OQ-NOTIF-02 BLOQUANT positioning + [FOUNDER-RISK-ACCEPTED] marker)
  • docs/standards/java-spring-guidelines.md (@Scheduled, @EventListener, FOR UPDATE SKIP LOCKED, Resilience4j)
  • docs/standards/database-guidelines.md (CAS via conditional UPDATE; partial index usage)
  • docs/standards/api-guidelines.md
  • docs/standards/multi-datasource-patterns.md
  • docs/standards/tech-stack.md

Testing Requirements

Unit Tests

  • WhatsAppCascadeFirerHappyPathTestClock.fixed advances to T+5d, single-row pick, AUTH reissue mocked, WA WireMock receives one submit.
  • WhatsAppCascadeFirerSuppressionTest — WA suppression row → CASCADE_CANCELLED reason wa_suppressed, AUTH not called, WA not called.
  • CustomerSessionOpenedListenerTest — event arrives for an AWAITING_CASCADE row → CASCADE_CANCELLED + customer_clicked_at set.
  • CascadeReissueRetryExhaustionTest — AUTH returns 5xx → row stays AWAITING_CASCADE on ticks 1-3; on tick 4 → FAILED with CASCADE_REISSUE_FAILED.

Integration Tests

  • WhatsAppCascadeE2ETest — full flow: NOTIF-001a/b ships SMS, Clock advanced to T+5d, firer runs, WA WireMock receives submit, status webhook delivered, notification → DELIVERED.
  • CascadeCancelRaceTest — concurrent firer + listener; assert exactly-one-winner property (AC-003.2 — uses architecture §13.4 sample test structure).
  • FeatureFlagDisabledTestFEATURE_NOTIF_WHATSAPP_CASCADE_ENABLED=false → firer ticks, no AUTH call, no WA call, rows remain in AWAITING_CASCADE.
  • CascadeMultiTenantIsolationTest — two tenants both at T+5d on the same tick → each row carries its own tenant_id end-to-end; no cross-tenant pollution in audit payloads.

What QA Will Validate

  • WireMock count: exactly one WA submit per cascade-eligible un-clicked row per tick.
  • AUTH call count: at most one reissueForCascade per notification per tick (no retry within a single tick).
  • Feature-flag flip: setting FEATURE_NOTIF_WHATSAPP_CASCADE_ENABLED=false and re-running the firer produces zero outbound traffic.
  • Cascade-cancel timing: clicking the SMS link at T+4d59min causes a CASCADE_CANCELLED row + zero WA submit on the next tick.

Out of Scope

  • AUTH SignedLinkService.reissueForCascade implementation — AUTH-side cross-module dependency (OQ-NOTIF-13). This story HARD-DEPENDS on it being merged.
  • CP customer.session_opened event publication — CP-side cross-module dependency (OQ-NOTIF-14). This story HARD-DEPENDS on the event source being live.
  • Per-agency partitioning of the firer queue (v0.0.2).
  • Redis token-bucket rate-limit + tenant daily cap + platform kill-switch (v0.1.0).
  • Operator timeline UI / manual retry (V1).
  • WA customer_block capture from Meta (V1 — no WA suppression source in v0.0.0; the WA-suppressed AC-003.3 path is exercised in v0.0.0 only via admin override / phone-based SMS suppression flagged WA at admin time, which itself is V1).
  • Webhook-loss reconciliation polling (V1 NOTIF-US-06).
  • Per-tenant Meta template overrides — V1 (architecture §10).

Definition of Done

  • [ ] WhatsAppProvider interface + at least one concrete adapter (Meta Cloud direct OR BSP — per OQ-NOTIF-01)
  • [ ] WhatsAppCascadeFirer @Scheduled task with batch + tick configurable via properties
  • [ ] CustomerSessionOpenedListener @EventListener subscribed to the CP-published event
  • [ ] AUTH SignedLinkService.reissueForCascade integration through a Spring named interface
  • [ ] All 4 ACs pass against WireMock + a mocked AUTH bean
  • [ ] CAS race-test in CI (architecture §13.4 pattern) demonstrates exactly-one-winner
  • [ ] FEATURE_NOTIF_WHATSAPP_CASCADE_ENABLED gates all WA outbound traffic
  • [ ] Tenant isolation verified (multi-tenant cascade-on-the-same-tick test)
  • [ ] Both V1 DB profiles tested
  • [ ] correlation_id propagated through firer thread MDC + AUTH call + WA provider client-ref + every audit/event payload
  • [ ] PII hashed in every audit payload via PiiHasher
  • [ ] No silent failures: every cascade outcome produces an audit event + a Spring application event
  • [ ] AUTH reissue 5xx retry exhaustion lands a NOTIF_FAILURE with last_failure_class='CASCADE_REISSUE_FAILED'
  • [ ] OQ-NOTIF-02 BLOQUANT explicitly referenced in deploy runbook (production GA needs ARTCI B5+B10)