Aller au contenu

Story NOTIF-001a: Dispatch endpoint + DB persistence + idempotency dual-guard

Module: notifications Slice: NOTIF-001 sub-slice a (of NOTIF-001 in architecture §11) Side: [BACKEND] Version target: V0.0.0 Sub-version: v0.0.0 (architecture-backed) Priority: 2 Depends on: NOTIF-S0; AUDIT-002 (catalog extension for NOTIF_SUPPRESSED_OPT_OUT — see architecture §11 R1) Can develop concurrently with: NOTIF-001d Merge order: Before NOTIF-001b, NOTIF-001c, NOTIF-002, NOTIF-003, NOTIF-004 Estimated complexity: M PRD User Stories: NOTIF-US-01 (AC-01.1, AC-01.2, AC-01.3, AC-01.4) Wireframe: N/A — backend only


Objective

Ship the POST /api/notifications/dispatch endpoint end-to-end up to (but not including) the provider submit: validate the payload, persist the notification row and an initial delivery_attempt row, enforce the idempotency dual-guard (Idempotency-Key + natural-key), and emit the NOTIF_SENDING audit outbox event in the same transaction. The notification is left in PENDING status — the actual SMS provider call ships in NOTIF-001b. This slice is independently verifiable from the DB and the audit outbox table.


Backend Scope

Entities

notification (v0.0.0 columns — full set per architecture §2.1): - notification_id UUIDv7 PK - tenant_id UUID NOT NULL, agency_id UUID NULL (NOT NULL from v0.0.2 — write tenant.default_agency_id when available per architecture §4.3) - country_code CHAR(2) NOT NULL — snapshotted from tenant at enqueue - customer_id UUID, contract_id UUID, message_type TEXT (RENEWAL_REMINDER_INITIAL), iteration SMALLINT - idempotency_key UUID NOT NULL - signed_link_id UUID, signed_link_url TEXT - cascade_signed_link_id UUID NULL, cascade_signed_link_url TEXT NULL - customer_phone_e164 TEXT, customer_first_name TEXT, tenant_name_snapshot TEXT - due_date DATE, amount_minor_xof BIGINT - correlation_id UUID NOT NULL (from OP per PROGRESS.md D16; NOTIF never originates it) - customer_consented BOOL NOT NULL — ARTCI mitigation #2 carried from ingestion - enqueued_at TIMESTAMPTZ NOT NULL - sms_sent_at, customer_clicked_at, wa_scheduled_for, wa_dispatched_at TIMESTAMPTZ NULL - status TEXT (declared TEXT not enum — see architecture §2.3) - last_failure_class TEXT NULL - terminal_event_published_at TIMESTAMPTZ NULL — guards exactly-once terminal Spring event

delivery_attempt (skeleton — full lifecycle in NOTIF-001b): - attempt_id UUIDv7 PK, notification_id FK, tenant_id (denormalized) - channel TEXT (SMS initial row inserted here) - provider_id, provider_message_id, attempt_index SMALLINT - status TEXT (SUBMITTING on insert) - submitted_at, last_status_at TIMESTAMPTZ, status_timeline JSONB

Migrations

  • V0_0_0__notif_create_notification.sql — creates the notification table + indexes per architecture §2.2:
  • UNIQUE (tenant_id, idempotency_key)
  • UNIQUE (tenant_id, customer_id, message_type, iteration) (natural-key guard; note v0.0.0 uses no campaign_id so this differs from the V1 PRD natural key — see architecture §3.1)
  • (status, wa_scheduled_for) WHERE status = 'AWAITING_CASCADE' (partial; populated by NOTIF-003 — index exists Day 1)
  • (tenant_id, customer_id, enqueued_at DESC)
  • (correlation_id)
  • V0_0_0__notif_create_delivery_attempt.sql — creates delivery_attempt + indexes:
  • UNIQUE (provider_id, provider_message_id)
  • UNIQUE (notification_id, attempt_index)
  • V0_0_0__notif_register_outbox_producer.sql — no-op DDL declaring NOTIF as outbox producer for spring-modulith-events-jdbc.

Service Layer

  • DispatchController (POST /api/notifications/dispatch) — JWT-scoped (operator role per architecture §7); rejects body tenant_id ≠ JWT tenant_id claim with 403 tenant_mismatch.
  • NotificationService.dispatch(DispatchRequest, Jwt):
  • Validate per §3.2 rules.
  • Lookup tenant.country_code via TenantConfigCache (shared bean from TENANT). If country_code has no seeded country_profile, reject 400 tenant_country_unsupported.
  • Idempotency-Key replay check: SELECT notification WHERE tenant_id=? AND idempotency_key=?. If found AND row younger than 24h → return 200 with existing {notification_id, status}.
  • Natural-key conflict check: SELECT notification WHERE (tenant_id, customer_id, message_type, iteration) = (...) AND enqueued_at > now() - INTERVAL '60 seconds' AND idempotency_key != ?. If found → 409 natural_key_conflict with existing_notification_id.
  • Insert notification row with status='PENDING', correlation_id from request body, customer_consented=true (validation gate below). agency_id = tenant.default_agency_id when present (NULL otherwise — v0.0.0 acceptable per §4.3).
  • Insert delivery_attempt(channel='SMS', status='SUBMITTING', attempt_index=1).
  • Publish outbox event NOTIF_SENDING with mandatory fields per audit-log §B.3 NOTIF row: {tenant_id, agency_id, country_code, customer_id, contract_id, signed_link_id, correlation_id, channel='SMS', provider_id, attempt_index=1, result:'INFO'}. PII pseudonymized via PiiHasher (AUDIT-owned bean, audit-log AC-06).
  • Return 202 {notification_id, status:'PENDING'}.
  • GET /api/notifications/{id} — single-resource read, scoped by tenant. Returns 404 (not 403) when out-of-tenant — pure absence, no info leak.
  • MDC propagationcorrelation_id placed in MDC for the request thread; included in every log line and every async/outbox event downstream (architecture §5 row 7 + NOTIF-ADR-07).

API Endpoints

Method Path Request Response Auth
POST /api/notifications/dispatch Per §3.2 v0.0.0 contract (full payload below) 202 {notification_id, status:'PENDING'} / 200 replay / 409 / 400 / 403 Keycloak JWT, operator role
GET /api/notifications/{id} path param 200 {notification_id, status, last_status_at, last_failure_class} / 404 Keycloak JWT, operator role

Request body (v0.0.0):

{
  "tenant_id": "uuid", "customer_id": "uuid", "contract_id": "uuid",
  "message_type": "RENEWAL_REMINDER_INITIAL", "iteration": 1,
  "customer_phone_e164": "+22507XXXXXXXX", "customer_first_name": "Aïssatou",
  "due_date": "2026-06-15", "amount_minor_xof": 15000,
  "signed_link_id": "uuid", "signed_link_url": "https://app.papillon.ci/portail/abc",
  "customer_consented": true, "correlation_id": "uuid", "idempotency_key": "uuid"
}

Validation Rules

  • message_type ∈ {'RENEWAL_REMINDER_INITIAL'} — single value in v0.0.0; reject 400 unknown_message_type otherwise.
  • customer_consented MUST be true — ARTCI mitigation #2; reject 400 missing_variable field=customer_consented if false/missing.
  • customer_phone_e164 MUST match ^\+225[0-9]{10}$ (CI seed; regex from country profile) — reject 400 phone_invalid_e164.
  • amount_minor_xof MUST be integer > 0 (XOF minor units, 0 decimals per NFR-08 — never floats; critical-rule #2).
  • iteration MUST be 1 in v0.0.0; values > 1 are rejected (v0.1.0 will lift this).
  • tenant_id in body MUST equal JWT tenant_id claim → 403 tenant_mismatch.
  • tenant.country_code MUST have a seeded country_profile row → 400 tenant_country_unsupported.
  • All other required fields (customer_id, contract_id, signed_link_id, signed_link_url, correlation_id, idempotency_key, due_date, customer_first_name) MUST be non-null/non-empty → 400 missing_variable.

Multi-Tenant Considerations

  • Every read + write on notification and delivery_attempt is scoped by tenant_id. Critical-rule #1 — non-negotiable.
  • tenant_id is cross-checked between JWT claim and request body (per architecture §4.1).
  • Both V1 DB profiles supported: NOTIF tables live on platformDataSource regardless of SHARED / BYO (architecture §4.2 / TV-05 / NOTIF-ADR-01). A BYO-DB tenant still has its NOTIF rows on the central platform DB.
  • country_code snapshot at enqueue from TenantConfigCache — country-profile resolver provider lookup happens here even though v0.0.0 seeds only CI (NOTIF-ADR-05).

Audit & Logging

  • Emit NOTIF_SENDING per audit-log §B.3 NOTIF row contract in the SAME transaction as the notification insert (spring-modulith-events-jdbc outbox; D4/D7). No extra round-trip — outbox insert is a local Postgres write.
  • PII (phone, customer_first_name) MUST be hashed via PiiHasher (AUDIT bean) before the AUDIT payload is built; raw PII stays only on the notification row.
  • correlation_id from request body MUST appear in every audit event and every log line (MDC).
  • Audit producer latency budget per AC-09.4: outbox writes are off the critical path; POST /dispatch p95 stays ≤ 200 ms (NFR-01).

Frontend Scope

N/A — backend only.


Acceptance Criteria

AC-01.1: Single dispatch contract
Given a producer call to `POST /api/notifications/dispatch` with `{tenant_id, customer_id, contract_id, message_type, iteration, signed_link_id, signed_link_url, customer_phone_e164, customer_first_name, due_date, amount_minor_xof, customer_consented:true, correlation_id, idempotency_key}` matching the JWT tenant
When the request is well-formed
Then NOTIF returns HTTP 202 with `{notification_id, status: "PENDING"}`
And a `notification` row is persisted with `status='PENDING'`, `country_code` snapshotted from tenant, `correlation_id` echoed
And a `delivery_attempt` row is persisted with `channel='SMS'`, `status='SUBMITTING'`, `attempt_index=1`
And a `NOTIF_SENDING` event is in the outbox table with PII-hashed phone + customer_first_name and `correlation_id` propagated

AC-01.2: Idempotency-Key wins (24h window)
Given a `notification` row exists for `(tenant_id, idempotency_key)` younger than 24h
When the same `idempotency_key` is replayed (any body)
Then NOTIF returns HTTP 200 with the EXISTING `{notification_id, status:<current>}`
And no duplicate `notification` row is created
And no additional `NOTIF_SENDING` event is published

AC-01.3: Natural-key conflict guard (60s window)
Given a notification was just enqueued for `(tenant_id, customer_id, message_type='RENEWAL_REMINDER_INITIAL', iteration=1)`
When a second request arrives within 60 seconds with a DIFFERENT `idempotency_key` but the same `(customer_id, message_type, iteration)`
Then NOTIF rejects with HTTP 409 `{reason:"natural_key_conflict", existing_notification_id:"<uuid>"}`
And no new `notification` row is created

AC-01.4: Mandatory-field + consent gate
Given a `POST /dispatch` request with `customer_consented=false` OR a missing required field per validation rules above
When the request is processed
Then NOTIF rejects with HTTP 400 `{reason:"missing_variable"|"unknown_message_type"|"phone_invalid_e164"|"tenant_country_unsupported", field:"..."}`
And no `notification` row is persisted, no audit event emitted
And the response p95 stays under 200 ms

Compliance Rules

  • ARTCI mitigation #2 (consent flag carried from ingestion)customer_consented=true is hard-validated server-side at dispatch (architecture §0 / PRD V0 envelope §"Verrouillé"). Status: VÉRIFIÉ for the mitigation itself; INCERTAIN — avocat requis for the legitimate-interest basis still pending OQ-NOTIF-02 (handled by feature flag at NOTIF-003, not here). This story is the SMS-only path — no cross-border WA flow yet, so the OQ-NOTIF-02 BLOQUANT does not block this slice.
  • ARTCI mitigation #4 (audit-log per send)NOTIF_SENDING emission is non-negotiable. Critical-rule #3.
  • CIMA Reg. 01-24 — no direct article applies to this slice; the underlying renewal is governed by CIMA but the dispatch primitive carries the broker's signed link, not a CIMA artifact.
  • Law n°2013-450 (data protection) — PII pseudonymization in audit payloads is the AC-09.5 / audit-log AC-06 obligation, served by PiiHasher.

Standards & Conventions

  • docs/standards/critical-rules.md (full — especially rules #1 tenant isolation, #2 XOF integer, #3 audit completeness)
  • docs/standards/multi-tenant-model.md (tenant resolution + DB-profile rules)
  • docs/standards/compliance-discipline.md (status discipline applied to OQ-NOTIF-02)
  • docs/standards/api-guidelines.md (path versioning v1, error envelope, idempotency)
  • docs/standards/database-guidelines.md (UUIDv7, partial indexes, JdbcClient access pattern)
  • docs/standards/multi-datasource-patterns.md (platformDataSource is the target — TV-05)
  • docs/standards/java-spring-guidelines.md (JdbcClient, MDC propagation, spring-modulith-events-jdbc)
  • docs/standards/tech-stack.md
  • docs/standards/git-workflow.md

Testing Requirements

Unit Tests

  • DispatchRequestValidatorTest — every field rule (consent=false, bad phone regex, non-CI tenant, missing fields, amount<=0, iteration!=1, unknown message_type).
  • NotificationServiceIdempotencyTest — same idempotency_key returns existing row; expired (>24h) idempotency_key creates new row.
  • NotificationServiceNaturalKeyTest — natural-key conflict within 60s → 409; outside 60s → new row OK.

Integration Tests

  • DispatchEndpointE2ETest (using NotifTestContainersConfig) — full HTTP request → 202 + DB row + outbox row asserted.
  • DispatchTenantIsolationTest — operator JWT from tenant A cannot read notification of tenant B via GET /{id} (returns 404, not 403).
  • DispatchTenantMismatchTest — JWT.tenant_id ≠ body.tenant_id → 403.
  • DispatchAuditPayloadPiiHashedTest — outbox NOTIF_SENDING event payload has hash_v1(phone) not cleartext.

What QA Will Validate

  • p95 latency under 200 ms over 1000 requests (NFR-01).
  • Outbox row count == accepted-dispatch count (no silent drops).
  • Replay of the same idempotency_key 24h+1m later creates a NEW row (verifies the 24h boundary).
  • Curl smoke test from architecture §14.2 returns 202 + DB row + outbox row.

Out of Scope

  • Actual SMS provider submit — NOTIF-001b.
  • Suppression pre-check — NOTIF-001c (this story always inserts the SMS attempt without a suppression lookup; correctness comes from the merge order: NOTIF-001c lands before any real SMS goes out in NOTIF-001b).
  • Country-profile-driven provider strategy wiring — NOTIF-001d.
  • Status webhook / inbound STOP — NOTIF-001b + NOTIF-002.
  • WhatsApp cascade — NOTIF-003.
  • Crash recovery — NOTIF-004.
  • Quiet-hours gate, tenant daily cap, kill switch — deferred to V1 / v0.1.0 per architecture §0 "Not in v0.0.0".
  • Manual retry, operator timeline UI — V1.
  • campaign_id field — v0.0.0 dispatches are ad-hoc (architecture §1.2 In row 1).

Definition of Done

  • [ ] POST /api/notifications/dispatch and GET /api/notifications/{id} implemented and returning correct responses
  • [ ] Flyway migrations V0_0_0__notif_create_notification.sql, V0_0_0__notif_create_delivery_attempt.sql, V0_0_0__notif_register_outbox_producer.sql applied cleanly
  • [ ] All 4 ACs pass manually (curl per architecture §14.2)
  • [ ] Unit + integration tests written and passing
  • [ ] Tenant isolation verified (tenant_id filter on every query; JWT vs body cross-check; tests assert no info leak across tenants)
  • [ ] Both V1 DB profiles tested — NOTIF tables on platformDataSource regardless of tenant profile (NOTIF-ADR-01)
  • [ ] NOTIF_SENDING audit event present in outbox for every accepted dispatch; PII fields hashed via PiiHasher
  • [ ] correlation_id propagated end-to-end (MDC + log lines + outbox payload)
  • [ ] POST /dispatch p95 ≤ 200 ms under 50 RPS (NFR-01)
  • [ ] No silent failures on validation errors (every reject produces a structured 4xx)
  • [ ] XOF amount stored as integer minor units (critical-rule #2)