Story OP-003: Backend envoi campagne + CampaignSendWorker¶
Module: operator-console Slice: Architecture §11 OP-001b (second morceau backend) + §6.3 Side: [BACKEND] Version target: V0.0.0 Priority: 4 Depends on: OP-002 (création BROUILLON), AUTH-001 (SignedLinkService.createLink), NOTIF-001 (NotificationDispatchService.dispatch) Can develop concurrently with: aucune (chemin critique V0.0.0) Merge order: Before OP-004 (recovery dépend du worker), Before OP-005 (frontend a besoin du send) Estimated complexity: M PRD User Stories: V0.0.0 envelope (envoi fire-and-forget) Wireframe: N/A — backend only
Objective¶
Faire passer une campagne BROUILLON → ENVOYÉE, déclencher le fan-out asynchrone par contrat (signed link + notification), produire l'audit OP_CAMPAIGN_VALIDATED. Implémente l'OP-ADR-01 (outbox + worker + idempotence via UNIQUE).
Backend Scope¶
Entities¶
Lit/écrit campaigns, campaign_contracts (créées en OP-000). Insère dans event_publication (spring-modulith outbox, existante TENANT S0).
Migrations¶
Aucune.
Service Layer¶
CampaignSendController:POST /api/operator/campaigns/{id}/send(RBAC : agency_operator, cabinet_admin).- Vérifie : campagne existe (sinon 404
OP_CAMPAIGN_NOT_FOUND), statut == BROUILLON (sinon 400OP_CAMPAIGN_WRONG_STATUS), tenant non bloqué (sinon 403OP_TENANT_BLOCKED). - Garde anomalies bloquantes : non implémentée en V0.0.0 (introduite OP-007 V0.0.1). V0.0.0 = pas de garde, envoie tout.
-
Service-level →
CampaignSenderService.send(campaignId, userId). -
CampaignSenderService.send(...)(TX0 — courte) : - UPDATE
campaignsSETstatus='ENVOYÉE', sent_at=now(), sent_by=:userId. - Publie
CampaignValidated {campaignId, tenantId, contractCount}via spring-modulith outbox. -
Publie audit
OP_CAMPAIGN_VALIDATEDviaOperatorAuditPublisher(aveccorrelation_id = campaignIdau niveau campagne). -
CampaignSendWorker(@ApplicationModuleListener(CampaignValidated.class), outbox-backed) : - Pour chaque
campaign_contracten statut INCLUS de la campagne :- TX_i (un par contrat, REQUIRES_NEW) :
a.
SignedLinkService.createLink(contractId, campaignContractId, tenantId, correlationId=cc.id, ttlHours=72). b.NotificationDispatchService.dispatch(DispatchRequest{ campaignContractId, correlationId=cc.id, tenantId, customerPhone, linkToken, templateVars={nomClient, montant, dateEcheance, nomCabinet, typeContrat, numeroContrat}, countryCode }). c. UPDATEcampaign_contractsSETstatus='NOTIF_EN_ATTENTE', link_id=:linkId, send_attempt_count=send_attempt_count+1. d. AuditCONTRACT_ADDED_TO_CAMPAIGNvia outbox (correlation_id = cc.id).
- TX_i (un par contrat, REQUIRES_NEW) :
a.
- Idempotent via :
notifications.UNIQUE(campaign_contract_id)(NOTIF) → no-op silencieux si conflit (OQ-OP-04 confirmé).signed_linksUNIQUE équivalent (AUTH-001).
- Échec dispatch / createLink : catch, log,
send_attempt_count++, continue (D7) ; pas de rollback campagne. Le contrat reste INCLUS → repris parCampaignDispatchRecovery(OP-004).
API Endpoints¶
| Method | Path | Request | Response | Auth |
|---|---|---|---|---|
POST |
/api/operator/campaigns/{id}/send |
{} |
200 { id, status: "ENVOYÉE", sentAt, contractCount, dispatchQueued: true } |
agency_operator, cabinet_admin |
Validation Rules¶
- Path
{id}UUID valide ; sinon 400. - Statut courant ∈ {BROUILLON} sinon 400
OP_CAMPAIGN_WRONG_STATUS.
Multi-Tenant Considerations¶
- Toutes les requêtes scoped
tenant_iddu JWT. tenant_idpropagé dansDispatchRequestà NOTIF (provider sélectionné parcountry_codedu tenant).- Le worker tourne dans le contexte transactionnel du listener spring-modulith ;
TenantContextHolderest réhydraté depuis l'event payload (tenantId).
Audit & Logging¶
OP_CAMPAIGN_VALIDATED(correlation_id = campaignId, payload ={campaignId, contractCount, agencyId}) — émis lors du TX0 dansCampaignSenderService.CONTRACT_ADDED_TO_CAMPAIGN× N (correlation_id = cc.id) — émis dans chaque TX_i par le worker.- MDC enrichi avec
campaign_id,correlation_id(cc.id) au sein de chaque TX_i.
Acceptance Criteria¶
AC-001: POST /send transitionne BROUILLON → ENVOYÉE et déclenche le fan-out
Given une campagne BROUILLON avec 12 campaign_contracts INCLUS
When POST /api/operator/campaigns/{id}/send
Then la réponse 200 contient status="ENVOYÉE", dispatchQueued=true
And campaigns.status='ENVOYÉE', sent_at!=null, sent_by=opérateur courant
And après convergence du worker, tous les 12 campaign_contracts sont en NOTIF_EN_ATTENTE
And 12 audit events CONTRACT_ADDED_TO_CAMPAIGN + 1 OP_CAMPAIGN_VALIDATED sont en audit_log.
AC-002: Idempotence du worker (relecture event ou re-trigger)
Given une campagne déjà partiellement dispatchée (3 sur 12 NOTIF_EN_ATTENTE, 9 INCLUS)
When le worker réexécute (event redelivery)
Then les 9 contrats restants passent en NOTIF_EN_ATTENTE
And aucun doublon dans `notifications` (vérifié par count = 12, pas 15)
And les 3 déjà dispatchés ne reçoivent pas une 2ème notification.
AC-003: Statut interdit → 400 OP_CAMPAIGN_WRONG_STATUS
Given une campagne ENVOYÉE ou CLÔTURÉE ou ANNULÉE
When POST /send
Then 400 { code: "OP_CAMPAIGN_WRONG_STATUS" } et la campagne n'est pas touchée.
AC-004: Échec NOTIF / AUTH n'annule pas la campagne (D7 dégradé)
Given NOTIF.dispatch retourne une exception 503 pour 2 contrats sur 12
When le worker traite tous les 12
Then 10 contrats sont NOTIF_EN_ATTENTE, 2 restent INCLUS avec send_attempt_count=1
And campaigns.status reste ENVOYÉE (pas de rollback)
And l'audit log contient les erreurs (event_type="OP_DISPATCH_ERROR" via mode dégradé).
Compliance Rules¶
- PRD V0 §4 ligne OP : fire-and-forget envoi via domain events + outbox (D4).
VÉRIFIÉ. - OP-ADR-01 : outbox + TX_i par contrat + idempotence UNIQUE.
VÉRIFIÉ. - D16 correlation_id :
cc.idpropagé à AUTH et NOTIF.VÉRIFIÉ. - OP-ADR-05 : campagne validée n'est pas rollback en cas d'échec NOTIF/AUTH.
VÉRIFIÉ.
Standards & Conventions¶
docs/standards/critical-rules.md— règles #1, #3 (audit).docs/standards/java-spring-guidelines.md— Spring Modulith@ApplicationModuleListener, outbox pattern.docs/standards/multi-tenant-model.md—country_coderesolution pour NOTIF.docs/standards/api-guidelines.md— error codes 400/403/404.
Testing Requirements¶
Unit Tests¶
CampaignSenderServiceTest: mock SignedLinkService + NotificationDispatchService ; vérifie l'ordre TX0 → publish → worker.CampaignSendWorkerTest: idempotence (2 invocations consécutives produisent 1 notification).- Échec dispatch →
send_attempt_countincrémenté, contrat reste INCLUS.
Integration Tests¶
SendCampaignITAC-001..AC-004 avec Postgres + WireMock (NOTIF) + Keycloak Testcontainer.FanOutCrashRecoveryIT(extrait de §13.4 arch) : injecter crash JVM mi-fan-out, vérifier que reload du worker reprend correctement (préparation OP-004).- Test du blocage tenant :
TenantSuspendedreçu, /send → 403.
What QA Will Validate¶
- Flow smoke : créer (OP-002) → envoyer → vérifier statut polling endpoint (sera ajouté en OP-006) ou via DB.
- Audit log contient les 14 OP types correctement formatés.
- Aucun dispatch hors-TX (
@Asyncpost-commit géré côté NOTIF).
Out of Scope¶
- Polling endpoint
GET /status→ OP-006. - Recovery worker périodique → OP-004 (le câblage est déjà en place via stub OP-000).
- Annulation campagne → OP-012.
- Garde anomalies bloquantes (FR-OP-02) → OP-007.
- SSE temps réel → V1.
Definition of Done¶
- [ ]
POST /api/operator/campaigns/{id}/sendretourne 200 ; transitionne BROUILLON → ENVOYÉE - [ ]
CampaignSendWorkerconsommeCampaignValidatedvia outbox spring-modulith - [ ] Fan-out TX_i par contrat (REQUIRES_NEW) implémenté
- [ ] AC-001..AC-004 passent en intégration
- [ ] Idempotence prouvée (AC-002)
- [ ] Échec NOTIF n'annule pas la campagne (AC-004) ;
send_attempt_countincrémenté - [ ] Audit
OP_CAMPAIGN_VALIDATED+ N ×CONTRACT_ADDED_TO_CAMPAIGNémis aveccorrelation_idcorrect - [ ]
link_idpeuplé sur chaque cc après dispatch - [ ] MDC enrichi
campaign_id,correlation_id(vérifié dans les logs) - [ ] Tenant isolation prouvée
- [ ] Tests unit + intégration verts