Story OP-002: Backend création campagne BROUILLON — POST /api/operator/campaigns¶
Module: operator-console Slice: Architecture §11 OP-001b (premier morceau backend OP-001) Side: [BACKEND] Version target: V0.0.0 Priority: 3 Depends on: OP-000 (migration, ContractEligibilityQueryAdapter, AuditPublisher), ING-001 (ContractQueryService.findEligible) Can develop concurrently with: OP-001, OP-015 Merge order: Before OP-003 (envoi a besoin de la création) Estimated complexity: M PRD User Stories: V0.0.0 envelope (création campagne fire-and-forget — composant création) Wireframe: N/A — backend only
Objective¶
Créer une campagne BROUILLON à partir de la cohort éligible : nom auto-généré (FR-OP-03), snapshot des paramètres cohort (cohort_params JSONB), N campaign_contracts INCLUS, déduplication (FR-OP-04). Étape 1 du flow fire-and-forget V0.0.0 — OP-003 enchaîne avec l'envoi.
Backend Scope¶
Entities¶
Lit et écrit campaigns et campaign_contracts (créées par OP-000).
Migrations¶
Aucune.
Service Layer¶
CampaignService:createDraft(tenantId, userId, agencyId): CampaignCreatedResult- Charge la cohort via
ContractEligibilityQueryAdapter.findEligible(tenantId, agencyId, windowDays). - Si 0 contrat → 400
OP_COHORT_EMPTY. - Génère le nom (
CampaignNameGenerator). - Pour chaque contrat : vérifie la déduplication (NOT EXISTS de FR-OP-04 / arch §6.1) — si conflit, exclut le contrat de l'insertion et le log.
- INSERT
campaigns(BROUILLON, type=SCHEDULED,cohort_params = {windowDays, generatedAt}). - INSERT N
campaign_contracts(status=INCLUS,id = correlation_idselon D16). - Le tout en 1 transaction Spring
@Transactional(REQUIRES_NEW). -
Publie
CampaignCreatedvia outbox spring-modulith (consommateur futur : OP-019/observabilité). -
CampaignNameGenerator(FR-OP-03) : - Format :
"Campagne {YYYY-MM-DD} - {nom_agence}"(date création tenant TZ = UTC+0 CI). - Agency name : lookup
TenantConfigCache.getAgencyName(agencyId); pouragency_idpar défaut V0.0.0 → "Cabinet" (fallback documenté). - Doublon même jour/agence :
"...(n)"n=2,3,… (vérification parSELECT COUNT WHERE name LIKE ... AND tenant_id=...). Pas de UNIQUE DB (forward-compat V2). -
Stocké, jamais modifiable après création.
-
CampaignRepository(JdbcClient) — méthodes : insertCampaign(...),insertCampaignContractsBatch(...),findNameCollisions(tenantId, agencyId, datePrefix).
API Endpoints¶
| Method | Path | Request | Response | Auth |
|---|---|---|---|---|
POST |
/api/operator/campaigns |
{ "type": "SCHEDULED" } (V0.0.0 ; AD_HOC reporté V1) |
201 { id, name, status: "BROUILLON", type, contractCount, createdAt, cohortParams } |
agency_operator, cabinet_admin |
Validation Rules¶
type∈ {SCHEDULED} en V0.0.0 ; AD_HOC → 400OP_TYPE_NOT_SUPPORTED_V0.- Si
contractCount == 0→ 400OP_COHORT_EMPTYavec message FR : « Aucun contrat éligible dans la fenêtre de détection actuelle. » - Si tenant bloqué (
TenantSuspendedconsommé) → 403OP_TENANT_BLOCKED.
Multi-Tenant Considerations¶
tenant_idinjecté depuis JWT, JAMAIS depuis le body.agency_id: V0.0.0 =TenantConfigCache.getDefaultAgencyId(tenantId)(toujours résolu) ; en V0.0.2 (OP-010) =currentTenant.agencyId()pour agency_operator.- Profils SHARED + BYO : mêmes requêtes, datasource résolue par TenantDataSourceResolver.
Audit & Logging¶
- Aucun audit dédié à la création BROUILLON (per AUDIT §B.1 — seule la validation envoie un audit, voir OP-003).
- MDC :
tenant_id,agency_id,campaign_idajouté après insert.
Acceptance Criteria¶
AC-001: POST /campaigns crée une BROUILLON avec nom correct (FR-OP-03)
Given un tenant avec agency_name="Agence Plateau" et 12 contrats éligibles
When POST /api/operator/campaigns body={"type":"SCHEDULED"}
Then la réponse 201 contient name="Campagne 2026-05-23 - Agence Plateau", status="BROUILLON", contractCount=12
And la table campaigns contient 1 row avec cohort_params={"windowDays":30,"generatedAt":"..."}
And la table campaign_contracts contient 12 rows INCLUS avec contract_id correct.
AC-002: Doublon de nom même jour/agence → suffixe (n) (FR-OP-03)
Given une campagne existe avec name="Campagne 2026-05-23 - Agence Plateau"
When POST /api/operator/campaigns est appelé le même jour pour la même agence
Then la nouvelle campagne a name="Campagne 2026-05-23 - Agence Plateau (2)"
And un 3ème appel produit "(3)".
AC-003: Déduplication FR-OP-04 — contrats déjà actifs exclus de l'insertion
Given un contrat X éligible (due_date dans la fenêtre)
And X est déjà dans une campagne BROUILLON existante
When POST /campaigns est appelé
Then la nouvelle campagne ne contient PAS X (campaign_contracts row absente)
And contractCount reflète l'absence.
AC-004: Cohort vide → 400 OP_COHORT_EMPTY
Given aucun contrat éligible
When POST /campaigns
Then 400 { code: "OP_COHORT_EMPTY", message: "Aucun contrat éligible dans la fenêtre de détection actuelle." }
And aucune campagne n'est créée.
Compliance Rules¶
- PRD V0 §4 ligne OP : fire-and-forget — création seule en V0.0.0.
VÉRIFIÉ. - FR-OP-03 nommage : stocké non modifiable.
VÉRIFIÉ. - FR-OP-04 déduplication : implémentée via NOT EXISTS de l'arch §6.1.
VÉRIFIÉ. - Critical rule #2 (money) :
amountlu enBIGINTXOF entier ; pas de manipulation.
Standards & Conventions¶
docs/standards/critical-rules.md— rules #1, #2.docs/standards/java-spring-guidelines.md—@Transactional(REQUIRES_NEW)pour la création atomique.docs/standards/api-guidelines.md— error envelope, 201 Created.docs/standards/database-guidelines.md— utilisation deidx_cc_tenant_contract_statuspour la dedup.
Testing Requirements¶
Unit Tests¶
CampaignNameGeneratorTest: format normal, doublon → (2), (3) ; agency name fallback.CampaignServiceTest: cohort vide → exception ; déduplication exclut bien les contrats déjà actifs.
Integration Tests¶
CreateCampaignITAC-001..AC-004 avec Postgres + ING fixture (12 contrats éligibles).- Isolation tenant : 2 tenants en parallèle ; aucune fuite de cohort.
- BYO profile : test avec datasource alternative (profil simulé).
What QA Will Validate¶
curl POST /campaignsretourne 201, nom au format spec, contractCount > 0.- Audit
audit_logreste vide (création BROUILLON non auditée — c'est la validation qui audit). - Transaction atomique : panique injectée entre INSERT campaigns et INSERT campaign_contracts → ROLLBACK propre.
Out of Scope¶
- Envoi
POST /send→ OP-003. - Création ad hoc avec sélecteur contrats → V1 (US-OP-03).
- Édition pré-envoi → OP-007 (V0.0.1).
- Audit
OP_CAMPAIGN_VALIDATED→ OP-003 (au moment du send). - Cohort planifiée
@Scheduled→ V1 (FR-OP-01).
Definition of Done¶
- [ ]
POST /api/operator/campaignsretourne 201 conforme - [ ] AC-001..AC-004 passent en intégration
- [ ] Nom FR-OP-03 stocké, non modifiable (pas d'UPDATE sur
name) - [ ] Déduplication FR-OP-04 testée (AC-003)
- [ ]
cohort_paramsJSONB contient{windowDays, generatedAt}exact - [ ] Tenant isolation prouvée
- [ ] BYO profile test passant
- [ ] Aucun audit event émis (vérifié vide)
- [ ] Transaction atomique vérifiée (rollback test)
- [ ] CampaignCreated publié dans l'outbox (vérifié, même sans consommateur V0.0.0)
- [ ] Unit + integration tests verts