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 thenotificationtable + 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 nocampaign_idso 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— createsdelivery_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 (operatorrole per architecture §7); rejects bodytenant_id≠ JWTtenant_idclaim with 403tenant_mismatch.NotificationService.dispatch(DispatchRequest, Jwt):- Validate per §3.2 rules.
- Lookup
tenant.country_codeviaTenantConfigCache(shared bean from TENANT). Ifcountry_codehas no seededcountry_profile, reject 400tenant_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 → 409natural_key_conflictwithexisting_notification_id. - Insert
notificationrow withstatus='PENDING',correlation_idfrom request body,customer_consented=true(validation gate below).agency_id = tenant.default_agency_idwhen present (NULL otherwise — v0.0.0 acceptable per §4.3). - Insert
delivery_attempt(channel='SMS', status='SUBMITTING', attempt_index=1). - Publish outbox event
NOTIF_SENDINGwith 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 viaPiiHasher(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 propagation—correlation_idplaced 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 400unknown_message_typeotherwise.customer_consented MUST be true— ARTCI mitigation #2; reject 400missing_variablefield=customer_consentedif false/missing.customer_phone_e164MUST match^\+225[0-9]{10}$(CI seed; regex from country profile) — reject 400phone_invalid_e164.amount_minor_xofMUST be integer > 0 (XOF minor units, 0 decimals per NFR-08 — never floats; critical-rule #2).iterationMUST be 1 in v0.0.0; values > 1 are rejected (v0.1.0 will lift this).tenant_idin body MUST equal JWTtenant_idclaim → 403tenant_mismatch.tenant.country_codeMUST have a seededcountry_profilerow → 400tenant_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 → 400missing_variable.
Multi-Tenant Considerations¶
- Every read + write on
notificationanddelivery_attemptis scoped bytenant_id. Critical-rule #1 — non-negotiable. tenant_idis cross-checked between JWT claim and request body (per architecture §4.1).- Both V1 DB profiles supported: NOTIF tables live on
platformDataSourceregardless ofSHARED/BYO(architecture §4.2 / TV-05 / NOTIF-ADR-01). A BYO-DB tenant still has its NOTIF rows on the central platform DB. country_codesnapshot at enqueue fromTenantConfigCache— country-profile resolver provider lookup happens here even though v0.0.0 seeds onlyCI(NOTIF-ADR-05).
Audit & Logging¶
- Emit
NOTIF_SENDINGper audit-log §B.3 NOTIF row contract in the SAME transaction as thenotificationinsert (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 thenotificationrow. correlation_idfrom 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 /dispatchp95 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=trueis hard-validated server-side at dispatch (architecture §0 / PRD V0 envelope §"Verrouillé"). Status:VÉRIFIÉfor the mitigation itself;INCERTAIN — avocat requisfor 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_SENDINGemission 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 versioningv1, 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.mddocs/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 viaGET /{id}(returns 404, not 403).DispatchTenantMismatchTest— JWT.tenant_id ≠ body.tenant_id → 403.DispatchAuditPayloadPiiHashedTest— outboxNOTIF_SENDINGevent payload hashash_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_key24h+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_idfield — v0.0.0 dispatches are ad-hoc (architecture §1.2 In row 1).
Definition of Done¶
- [ ]
POST /api/notifications/dispatchandGET /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.sqlapplied cleanly - [ ] All 4 ACs pass manually (curl per architecture §14.2)
- [ ] Unit + integration tests written and passing
- [ ] Tenant isolation verified (
tenant_idfilter on every query; JWT vs body cross-check; tests assert no info leak across tenants) - [ ] Both V1 DB profiles tested — NOTIF tables on
platformDataSourceregardless of tenant profile (NOTIF-ADR-01) - [ ]
NOTIF_SENDINGaudit event present in outbox for every accepted dispatch; PII fields hashed viaPiiHasher - [ ]
correlation_idpropagated end-to-end (MDC + log lines + outbox payload) - [ ]
POST /dispatchp95 ≤ 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)