Aller au contenu

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_id selon D16).
  • Le tout en 1 transaction Spring @Transactional (REQUIRES_NEW).
  • Publie CampaignCreated via 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) ; pour agency_id par défaut V0.0.0 → "Cabinet" (fallback documenté).
  • Doublon même jour/agence : "...(n)" n=2,3,… (vérification par SELECT 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 → 400 OP_TYPE_NOT_SUPPORTED_V0.
  • Si contractCount == 0 → 400 OP_COHORT_EMPTY avec message FR : « Aucun contrat éligible dans la fenêtre de détection actuelle. »
  • Si tenant bloqué (TenantSuspended consommé) → 403 OP_TENANT_BLOCKED.

Multi-Tenant Considerations

  • tenant_id injecté 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_id ajouté 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) : amount lu en BIGINT XOF 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 de idx_cc_tenant_contract_status pour 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

  • CreateCampaignIT AC-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 /campaigns retourne 201, nom au format spec, contractCount > 0.
  • Audit audit_log reste 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/campaigns retourne 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_params JSONB 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