Aller au contenu

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 400 OP_CAMPAIGN_WRONG_STATUS), tenant non bloqué (sinon 403 OP_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 campaigns SET status='ENVOYÉE', sent_at=now(), sent_by=:userId.
  • Publie CampaignValidated {campaignId, tenantId, contractCount} via spring-modulith outbox.
  • Publie audit OP_CAMPAIGN_VALIDATED via OperatorAuditPublisher (avec correlation_id = campaignId au niveau campagne).

  • CampaignSendWorker (@ApplicationModuleListener(CampaignValidated.class), outbox-backed) :

  • Pour chaque campaign_contract en 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. UPDATE campaign_contracts SET status='NOTIF_EN_ATTENTE', link_id=:linkId, send_attempt_count=send_attempt_count+1. d. Audit CONTRACT_ADDED_TO_CAMPAIGN via outbox (correlation_id = cc.id).
  • Idempotent via :
    • notifications.UNIQUE(campaign_contract_id) (NOTIF) → no-op silencieux si conflit (OQ-OP-04 confirmé).
    • signed_links UNIQUE équivalent (AUTH-001).
  • Échec dispatch / createLink : catch, log, send_attempt_count++, continue (D7) ; pas de rollback campagne. Le contrat reste INCLUS → repris par CampaignDispatchRecovery (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_id du JWT.
  • tenant_id propagé dans DispatchRequest à NOTIF (provider sélectionné par country_code du tenant).
  • Le worker tourne dans le contexte transactionnel du listener spring-modulith ; TenantContextHolder est réhydraté depuis l'event payload (tenantId).

Audit & Logging

  • OP_CAMPAIGN_VALIDATED (correlation_id = campaignId, payload = {campaignId, contractCount, agencyId}) — émis lors du TX0 dans CampaignSenderService.
  • 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.id propagé à 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.mdcountry_code resolution 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_count incrémenté, contrat reste INCLUS.

Integration Tests

  • SendCampaignIT AC-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 : TenantSuspended reç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 (@Async post-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}/send retourne 200 ; transitionne BROUILLON → ENVOYÉE
  • [ ] CampaignSendWorker consomme CampaignValidated via 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_count incrémenté
  • [ ] Audit OP_CAMPAIGN_VALIDATED + N × CONTRACT_ADDED_TO_CAMPAIGN émis avec correlation_id correct
  • [ ] link_id peuplé 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