Aller au contenu

Operator Console (OP) — Architecture · Part 1 (Domain & API)

Module: OP — operator-console Plane: Application Target version: V1 (MVP) — V0 envelope active Story prefix: OP-NNN Date: 2026-05-23 (review pass against prd-v0.md scope) Status: Draft — awaiting founder approval API namespace: /api/operator/... Frontend app: frontend/operator/ Spring module: com.altarys.papillon.pcs.operator

Part 1 of 2. Sections §0–§11 (domain, data, API, multi-tenant, connectivity, core logic, transactions, sequences, security, performance, migrations). Part 2 covers integration points, vertical slices, risks, test infrastructure, dev env, ADRs and open questions — see part2-integrations-build-ops.md.


0. V0 Envelope

Read docs/prd/prd-v0.md §4 (OP line) and §8 before starting any V0 story. This document remains the V1 contract. V0 carves down from this spec; each V0 sub-version is additive.

Sub-version Scope Story Notes
[V0.0.0] Campaign creation + fire-and-forget send UI. Read-only contract list. Simplified cohort (renewal_window_days). CampaignSendWorker + CampaignDispatchRecovery. No-op ImportCommitted listener. OP-001 No scheduling, no segmentation, no post-send feedback, no SSE.
[V0.0.1] Inline pre-send edit (cabinet_admin only). Read-only post-send status view (polling). OP-002
[V0.0.2] Multi-agency routing. Each agency_operator sees only their agency's contracts. Inline-edit RBAC lifted to agency_operator (PRD V0 §4 OP). OP-003 Requires TENANT-002 (agency_id on contract).
[V0.0.3] Post-send actions: view + revoke + cancel (admin only). Import-upload UI (delegates to ING). Anomalies + inline resolution. OP-004 Remind and mark-offplatform stay in V1 (PRD V0 §4 OP).
[V0.1.0] Observability hooks (ARTCI + ops). OP-005
[V1] Full V1 spec (dashboard, global search, export, user management, SSE, scheduled cohort, ad-hoc campaigns, reminders, standalone off-platform marking, cabinet settings). OP-006+

Locked v0.0.0 constraints: - Desktop-first, online-first, polling only (no SSE, no service worker) - Campaign trigger: operator on-demand only (OP-ADR-04); scheduled cron deferred to V1 - No post-send feedback, no revoke, no edit, no anomalies UI - Fan-out: CampaignSendWorker (async, outbox-backed) + CampaignDispatchRecovery (OP-ADR-01) - Campaign name: "Campagne {YYYY-MM-DD} - {nom_agence}" (FR-OP-03), stored, immutable


1. System Context

1.1 Position in the platform

OP is the application-plane pivot. Every other module feeds it (ING produces contracts), is orchestrated by it (NOTIF and AUTH are invoked at campaign send), or observes it (AUDIT records every operator action; PAY reports payment status).

graph TB
    subgraph "Application Plane"
        ING["ING\ningestion"]
        OP["OP\noperator-console\n(ce module)"]
        CP["CP\ncustomer-portal"]
        PAY["PAY\npayment"]
    end
    subgraph "Control Plane"
        AUTH["AUTH\nidentity"]
        NOTIF["NOTIF\nnotifications"]
        TENANT["TENANT\ntenant-admin"]
        AUDIT["AUDIT\naudit-log"]
    end
    subgraph "External"
        KC["Keycloak\n(operator auth)"]
        SMS["SMS Provider\n(via NOTIF)"]
        WA["WhatsApp Cloud API\n(via NOTIF)"]
    end
    subgraph "Frontend"
        OPUI["frontend/operator/\nReact 19.2 + CSS Modules"]
    end

    OPUI -->|"Keycloak JWT"| OP
    OPUI -->|"REST /api/operator/..."| OP
    OP -->|"ContractQueryService (in-process)"| ING
    OP -->|"NotificationDispatchService (in-process)"| NOTIF
    OP -->|"SignedLinkService (in-process)"| AUTH
    OP -->|"PaymentQueryService (in-process)"| PAY
    OP -->|"TenantConfigCache (in-process)"| TENANT
    OP -->|"AuditEventBuilder + outbox (in-process)"| AUDIT
    OP -->|"Keycloak Admin REST API (V1)"| KC
    NOTIF --> SMS
    NOTIF --> WA
    CP -->|"CP_LINK_OPENED Spring event"| OP
    PAY -->|"PAY_CONFIRMED / PAY_FAILED Spring event"| OP
    NOTIF -->|"NOTIF_SENT / NOTIF_FAILURE Spring event"| OP
    TENANT -->|"TenantSuspended / Reactivated / AgencyDisabled"| OP
    ING -->|"ImportCommitted (no-op v0.0.0)"| OP

1.2 V0 deployment topology

Single Spring Boot deployable (papillon-app). OP is a Spring Modulith module (com.altarys.papillon.pcs.operator). The frontend/operator/ app is a separate Vite build served as static files via Nginx/Coolify. The platform-admin surface (PLATFORM_ADMIN role) is a role-gated section inside frontend/operator/ (D20) — no separate app.

1.3 Event contracts

OP confirms D16 (OQ-X3 — OP leg resolved): correlation_id is minted by OP when a campaign_contract row is inserted. Value = campaign_contract.id (UUID). OP passes this correlation_id to SignedLinkService.createLink(...) and NotificationDispatchService.dispatch(...). Downstream modules (AUTH, NOTIF, CP, PAY) propagate it unchanged.

Standalone operator events (FIELD_CORRECTED, HORS_PLATEFORME_MARKED, etc.) carry no correlation_id (D16).

OP subscribes to:

Event Source Effect in OP
NOTIF_SENT NOTIF campaign_contract.status → NOTIF_LIVRÉ
NOTIF_FAILURE NOTIF campaign_contract.status → NOTIF_ÉCHOUÉ
CP_LINK_OPENED CP campaign_contract.status → LIEN_OUVERT (V1)
PAY_CONFIRMED PAY campaign_contract.status → PAYÉ
PAY_FAILED PAY campaign_contract.status → PAIEMENT_ÉCHOUÉ
AUTH_LINK_EXPIRED AUTH campaign_contract.status → LIEN_EXPIRÉ (V1)
TenantSuspended TENANT Blocks all campaign sends for the tenant
TenantReactivated TENANT Unblocks sends
AgencyDisabled TENANT Blocks sends for the agency
ImportCommitted ING No-op in v0.0.0; v0.0.1+ = cohort cache invalidation

OP emits (via the AUDIT outbox, 14 event types):

OP_CAMPAIGN_VALIDATED, CAMPAIGN_CANCELLED, CONTRACT_EXCLUDED, CONTRACT_ADDED_TO_CAMPAIGN, FIELD_CORRECTED, HORS_PLATEFORME_MARKED, NOTIFICATION_RESENT, ANOMALY_RESOLVED, OP_USER_CREATED, OP_USER_ROLE_CHANGED, OP_USER_DEACTIVATED, SETTINGS_CHANGED, DATA_EXPORTED, IMPORT_TRIGGERED.

Note: SETTINGS_CHANGED is emitted by OP to AUDIT when the operator calls the TENANT API to update renewal_detection_window_days. TENANT independently emits its own TenantSettingsChanged in parallel.


2. Data Model

2.1 Tables owned by OP (Tenant DB — D1)

erDiagram
    campaigns {
        uuid id PK
        uuid tenant_id "NOT NULL"
        uuid agency_id "NOT NULL (default agency v0.0.0)"
        varchar_200 name "NOT NULL, stocké, non modifiable"
        varchar_20 type "SCHEDULED | AD_HOC"
        varchar_30 status "BROUILLON | ENVOYÉE | CLÔTURÉE | ANNULÉE"
        uuid created_by "NOT NULL"
        timestamptz created_at "DEFAULT now()"
        timestamptz sent_at
        uuid sent_by
        timestamptz closed_at
        timestamptz cancelled_at
        uuid cancelled_by
        jsonb cohort_params "windowDays + generatedAt snapshot"
    }

    campaign_contracts {
        uuid id PK "= correlation_id (D16)"
        uuid tenant_id "NOT NULL"
        uuid campaign_id "NOT NULL"
        uuid contract_id "NOT NULL (logical FK vers ING)"
        varchar_40 status "INCLUS | EXCLU | NOTIF_EN_ATTENTE | NOTIF_LIVRÉ | NOTIF_ÉCHOUÉ | LIEN_OUVERT | PAYÉ | PAIEMENT_ÉCHOUÉ | RÉGLÉ_HORS_PLATEFORME | LIEN_EXPIRÉ | LIEN_RÉVOQUÉ"
        uuid excluded_by
        timestamptz excluded_at
        varchar_500 exclusion_reason
        varchar_20 hp_method "MOBILE_MONEY | CASH | CHEQUE | VIREMENT"
        date hp_date
        bigint hp_amount "XOF entier"
        varchar_200 hp_reference
        uuid hp_marked_by
        timestamptz hp_marked_at
        uuid link_id "logique → signed_links.id (AUTH, platform DB)"
        integer send_attempt_count "default 0"
    }

    operator_notes {
        uuid id PK
        uuid tenant_id "NOT NULL"
        uuid agency_id "NOT NULL"
        varchar_20 entity_type "CONTRACT | CAMPAIGN"
        uuid entity_id "NOT NULL"
        text content "NOT NULL"
        uuid created_by "NOT NULL"
        timestamptz created_at "NOT NULL"
        timestamptz synced_at
        varchar_100 device_local_id "dedup sync"
    }

    campaigns ||--o{ campaign_contracts : "contient N"
    campaign_contracts }o--|| contracts : "réf. logique (ING)"
    campaigns }o--|| agencies : "appartient à (TENANT)"

2.2 Cross-module logical references (no DB FK under BYO profile)

Column OP table Remote entity Owner DB
contract_id campaign_contracts contracts ING/TENANT Tenant
link_id campaign_contracts signed_links AUTH Platform
agency_id campaigns agencies TENANT Tenant
created_by / sent_by / cancelled_by campaigns users (Keycloak) AUTH/TENANT

Under the SHARED profile (v0.0.0), DB-level FKs can be enforced (same physical DB). Under BYO, they cannot (separate DBs). OP therefore declares every one of these references as an application-level check only — consistent with the datasource abstraction (D1).

2.3 Indexing strategy

CREATE INDEX idx_campaigns_tenant_agency_status
  ON campaigns (tenant_id, agency_id, status);
CREATE INDEX idx_campaigns_tenant_created
  ON campaigns (tenant_id, created_at DESC);

CREATE INDEX idx_cc_tenant_campaign
  ON campaign_contracts (tenant_id, campaign_id);
CREATE INDEX idx_cc_tenant_contract_status
  ON campaign_contracts (tenant_id, contract_id, status);
CREATE INDEX idx_cc_recovery
  ON campaign_contracts (tenant_id, campaign_id, status)
  WHERE status = 'INCLUS';

CREATE INDEX idx_notes_tenant_entity
  ON operator_notes (tenant_id, entity_type, entity_id);
CREATE UNIQUE INDEX idx_notes_device_local
  ON operator_notes (tenant_id, device_local_id)
  WHERE device_local_id IS NOT NULL;

The hottest query is the cohort deduplication check (FR-OP-04):

NOT EXISTS (
  SELECT 1 FROM campaign_contracts cc
  JOIN campaigns c ON c.id = cc.campaign_id
  WHERE cc.contract_id = :contractId
    AND c.tenant_id    = :tenantId
    AND c.status       NOT IN ('CLÔTURÉE','ANNULÉE')
    AND cc.status      NOT IN ('EXCLU','PAYÉ','RÉGLÉ_HORS_PLATEFORME','LIEN_EXPIRÉ','LIEN_RÉVOQUÉ')
)

Covered by idx_cc_tenant_contract_status (tenant_id, contract_id, status). Also requires a (tenant_id, due_date) index on contracts — declared in the TENANT/ING migration and to be verified at implementation time.

2.4 V2 forward-compat

  • No UNIQUE on campaigns.name without tenant_id.
  • cohort_params JSONB absorbs V2 fields (cron expression, A/B ratio) with no migration.
  • send_attempt_count supports V2 automatic retry.
  • Full CHECK constraints from v0.0.0 (OP-ADR-02) → no ALTER TABLE between V0 sub-versions.

3. API Design

3.1 Namespace and authentication

  • All endpoints: /api/operator/... (D5; namespace reserved in PROGRESS).
  • Auth: Authorization: Bearer <keycloak-access-token> (5-minute TTL, see AUTH arch).
  • Tenant: resolved from the JWT tenant_id claim (D3).
  • Agency: for agency_operator, the Spring middleware injects agency_id automatically.

3.2 RBAC middleware

Role Data scope Restrictions
agency_operator Own agency only Middleware adds AND agency_id = :currentAgencyId. v0.0.0–v0.0.1: read-only. v0.0.2+: inline pre-send edit allowed (PRD V0 §4 OP "branch operators can edit").
supervisor All agencies of the tenant Read-only (no write endpoints)
cabinet_admin All agencies of the tenant Full CRUD
PLATFORM_ADMIN Cross-tenant (D20) Platform-admin section only; emits PLATFORM_ADMIN_QUERY audit

3.3 Endpoints

Campaigns

Method Path V0 RBAC Description
GET /campaigns v0.0.0 All Paginated list (cursor D6, 25/page). Filters: status, agencyId, from, to, type.
POST /campaigns v0.0.0 operator, admin Create BROUILLON campaign. v0.0.0: cohort auto-selected.
GET /campaigns/{id} v0.0.0 All Detail + aggregated counters.
POST /campaigns/{id}/send v0.0.0 operator, admin Validate and send (BROUILLON → ENVOYÉE). Triggers CampaignSendWorker.
POST /campaigns/{id}/cancel v0.0.3 admin Cancel + revoke links.
GET /campaigns/{id}/status v0.0.1 All Polling endpoint: aggregated counters (delivered/paid/failed).
GET /campaigns/{id}/events V1 All SSE real-time stream.
GET /campaigns/{id}/contracts v0.0.1 All Campaign contracts (cursor, 50/page).
PATCH /campaigns/{id}/contracts/{ccId} v0.0.1 (admin) / v0.0.2 (operator) operator, admin Field correction or exclusion. Branch operator gains write access in v0.0.2 (PRD V0 §4 OP).
POST /campaigns/{id}/contracts/{ccId}/remind V1 operator, admin Reminder + link re-issue. (Removed from v0.0.3 — not listed in PRD V0 §4 OP v0.0.3; consistent with OP-013.)
POST /campaigns/{id}/contracts/{ccId}/mark-offplatform V1 operator, admin Off-platform marking. (Removed from v0.0.3 — not listed in PRD V0 §4 OP v0.0.3; consistent with OP-014.)
POST /campaigns/{id}/contracts V1 operator, admin Add a contract to an already-sent campaign.
GET /campaigns/{id}/export V1 admin, supervisor CSV/XLSX export (signed URL, 10-min TTL).

Contracts (read, via ING)

Method Path V0 Description
GET /contracts v0.0.0 Cohort-eligible contracts. Filters: eligible=true, agencyId.
GET /contracts/{id} V1 Individual contract sheet.
GET /contracts/search V1 Global search: name, phone (partial), reference.

Anomalies, Import, Dashboard, Settings, Users, Notes

Method Path V0 RBAC Description
GET /anomalies v0.0.3 All Open-anomalies queue.
PATCH /anomalies/{id} v0.0.3 operator, admin Resolution (delegates PATCH to ING).
POST /imports v0.0.3 operator, admin Trigger import (ING). Audit IMPORT_TRIGGERED.
GET /imports/{id} v0.0.3 All Import result.
GET /dashboard V1 All 4 aggregated widgets; agency-scoped for operator.
GET /settings v0.0.0 All Cabinet settings (from TenantConfigCache).
PATCH /settings V1 admin Updates renewal_window_days + CIMA fields (calls TENANT API).
GET /users V1 admin List org users from Keycloak.
POST /users V1 admin Create user (Keycloak Admin API).
PATCH /users/{id} V1 admin Change role/agency.
DELETE /users/{id} V1 admin Deactivate user.
POST /users/{id}/reset-password V1 admin Keycloak password-reset email.
POST /notes V1 All Batch-sync localStorage notes (idempotent via device_local_id).

3.4 Response shapes (v0.0.0)

POST /campaigns — 201 Created

{
  "id": "uuid",
  "name": "Campagne 2026-05-22 - Agence Plateau",
  "status": "BROUILLON",
  "type": "SCHEDULED",
  "contractCount": 12,
  "createdAt": "2026-05-22T10:30:00Z",
  "cohortParams": { "windowDays": 30, "generatedAt": "2026-05-22T10:30:00Z" }
}

POST /campaigns/{id}/send — 200 OK

{
  "id": "uuid",
  "status": "ENVOYÉE",
  "sentAt": "2026-05-22T10:31:00Z",
  "contractCount": 12,
  "dispatchQueued": true
}

GET /campaigns — 200 OK

{
  "data": [
    {
      "id": "uuid",
      "name": "Campagne 2026-05-22 - Agence Plateau",
      "status": "ENVOYÉE",
      "agencyName": "Agence Plateau",
      "contractCount": 12,
      "sentAt": "2026-05-22T10:31:00Z",
      "createdAt": "2026-05-22T10:30:00Z"
    }
  ],
  "nextCursor": "base64opaque",
  "hasMore": false
}

GET /campaigns/{id}/status — 200 OK

{
  "campaignId": "uuid",
  "total": 12,
  "inclus": 0,
  "notifEnAttente": 2,
  "notifLivre": 8,
  "notifEchoue": 1,
  "paye": 1,
  "paymentEchoue": 0,
  "horsePlateforme": 0,
  "lienExpire": 0,
  "exclu": 0,
  "lienRevoque": 0,
  "conversionPct": 8,
  "polledAt": "2026-05-22T10:35:00Z"
}

3.5 Error codes

HTTP Code Description
400 OP_CAMPAIGN_BLOCKING_ANOMALIES Unresolved blocking anomalies (FR-OP-02)
400 OP_CAMPAIGN_WRONG_STATUS Transition forbidden for the current status
400 OP_CONTRACT_ALREADY_ACTIVE Contract already in an active campaign (FR-OP-04)
403 OP_ROLE_FORBIDDEN RBAC check failed
404 OP_CAMPAIGN_NOT_FOUND Campaign not found or out of tenant scope
409 OP_DUPLICATE_CAMPAIGN_DATE Name collision (FR-OP-03 handles the increment)
503 OP_NOTIF_UNAVAILABLE NotificationDispatchService unavailable

All error responses: { "code": "...", "message": "...", "details": {} } (message in French — UI locale).


4. Multi-Tenant Strategy

4.1 Tenant resolution

  • Keycloak JWT: the tenant_id claim is extracted by TenantFilter (D3).
  • RequestContextHolder carries TenantContext { tenantId, agencyId?, role }.
  • agencyId is populated for agency_operator from the JWT agency_id claim (Keycloak mapper).
  • Every OP JdbcClient query: WHERE tenant_id = :#{currentTenant.tenantId}.

4.2 Agency-scoping middleware

if (currentTenant.role() == Role.AGENCY_OPERATOR) {
    queryContext.addFilter("agency_id", currentTenant.agencyId());
}

Applied to: campaign list, contract list, campaign_contracts, anomalies, dashboard. Not applied to: campaign creation (agency_id is injected from the context) and settings read.

4.3 SHARED vs BYO profiles

Profile campaigns / campaign_contracts / operator_notes Notes
SHARED (v0.0.0) Shared Postgres, tenant_id clause Same physical DB as all tenants
BYO (v0.0.4+) Tenant's own Postgres, same tables TenantDataSourceResolver routes to the tenant's DataSource

OP never uses @Qualifier("platformJdbcClient") for its own tables (D2). Every OP business query uses the @Primary datasource (= tenant datasource).

4.4 Platform-admin cross-tenant

PLATFORM_ADMIN JWT (D20): TenantContext.isAdminOverride() lifts the tenant_id filter and emits a PLATFORM_ADMIN_QUERY audit event (D3).

4.5 V2 forward-compat

  • No global UNIQUE on campaigns.name.
  • agency_id present from v0.0.0 (default agency) — v0.0.2 enables routing with no migration.
  • Settings live in TENANT → adding TENANT columns never touches OP tables.

5. Connectivity & Operator Minimum Offline

5.1 Online-first model (locked V0 constraint)

OP is desktop-first, online-first. Polling every 30 seconds on the campaign detail screen. No service worker, no IndexedDB, no client-side mutation queue.

Client-side states: - Online: full functionality. - Connection lost: banner « Connexion perdue. Certaines fonctions sont indisponibles. ». Mutation buttons disabled. Already-loaded data remains readable. - Reconnect: automatic re-poll. Buttons re-enabled. Pending notes sync (V1).

5.2 Polling strategy (v0.0.0–v0.0.5)

GET /api/operator/campaigns/{id}/status  ← every 30s while the detail screen is open

Polling is suspended when the tab loses focus (document.visibilityState) and resumed on focus.

5.3 SSE strategy (V1)

GET /api/operator/campaigns/{id}/events
Accept: text/event-stream
Authorization: Bearer <token>

Backend: Spring SseEmitter, 30s heartbeat, emitter registry filtered by (tenant_id, agency_id). Fallback: if SSE fails, drop to 30s polling. Reconnect with exponential back-off (1s, 2s, 4s, 8s, cap 60s).

5.4 V1 minimum offline (FR-OP-15)

  • Reads: already-loaded campaign and contract lists stay viewable (React Query cache).
  • Notes: written to localStorage as { entityType, entityId, content, localTimestamp, deviceLocalId, synced: false }. On reconnect: POST /operator/notes (batched, idempotent via deviceLocalId).
  • No service worker, no IndexedDB (locked architectural constraint).

5.5 Graceful degradation on dependency failure

Dependency OP behaviour
ING unavailable « Impossible de charger les contrats. [Réessayer] ». Campaign creation disabled.
NOTIF unavailable Send blocked with 503 OP_NOTIF_UNAVAILABLE. « Envoi impossible pour l'instant. »
AUTH unavailable Send blocked (createLink fails). Same UX as NOTIF.
PAY unavailable Polling returns stale payment statuses. Banner: « Statuts paiement temporairement indisponibles. »
AUDIT write fails Per D7: no business-action rollback. AUDIT degraded mode kicks in.
Keycloak unavailable JWT refresh fails → operator session ends (30-min timeout).

6. Core Domain Logic & Async Processing

6.1 Cohort selection & validation

FR-OP-04 rule: a contract can be active in at most one non-closed, non-cancelled campaign at a time. Enforced at campaign creation and at contract addition.

NOT EXISTS (
  SELECT 1 FROM campaign_contracts cc
  JOIN campaigns c ON c.id = cc.campaign_id
  WHERE cc.contract_id = :contractId
    AND c.tenant_id    = :tenantId
    AND c.status       NOT IN ('CLÔTURÉE','ANNULÉE')
    AND cc.status      NOT IN ('EXCLU','PAYÉ','RÉGLÉ_HORS_PLATEFORME','LIEN_EXPIRÉ','LIEN_RÉVOQUÉ')
)

Initial cohort (v0.0.0 — fire-and-forget, no scheduling):

  1. Load eligible contracts via ContractQueryService.findEligible(tenant, agencyId, windowDays).
  2. Criteria: status='ACTIF', due_date within [now, now+windowDays], NOT in an active campaign (FR-OP-04).
  3. Scope: if agency_operator, add AND agency_id = :currentAgency.
  4. Create a Campaign (BROUILLON) with cohort_params = { windowDays, generatedAt: now }.
  5. Create one campaign_contract per eligible contract, status=INCLUS.

Inline exclusion (v0.0.1 admin / v0.0.2 operator): PATCH /campaigns/{id}/contracts/{ccId} with body { "status": "EXCLU", "reason": "..." }.

6.2 Campaign lifecycle

BROUILLON → ENVOYÉE → CLÔTURÉE
         ↘           ↗
          ANNULÉE
  • BROUILLON: created, may be modified (v0.0.1) or deleted.
  • ENVOYÉE: send in flight or completed. Entering this state triggers CampaignSendWorker.
  • CLÔTURÉE: archived (v0.0.1+). No mutation allowed.
  • ANNULÉE: revokes every non-terminal AUTH link (v0.0.3+). No recovery.

6.3 CampaignSendWorker (async, outbox-backed)

Trigger: POST /campaigns/{id}/send validates the campaign then inserts a CAMPAIGN_SEND row in the outbox table (D7).

Spring Modulith worker: 1. Scans outbox WHERE event_type='CAMPAIGN_SEND' AND processed=false every 2 seconds. 2. For each row: - Load the campaign and its campaign_contracts (INCLUS only). - For each contract: a. Create a SignedLink via SignedLinkService.createLink(...) (correlated by campaign_contract.id). b. Dispatch a notification via NotificationDispatchService.dispatch(...). c. Insert OP_CAMPAIGN_VALIDATED + CONTRACT_ADDED_TO_CAMPAIGN audits into the outbox. - Update campaigns.status=ENVOYÉE, sent_at=now, sent_by=currentUser. - Mark outbox.processed=true.

Recovery: CampaignDispatchRecovery (periodic job) rescans ENVOYÉE campaigns whose contracts are still INCLUS (partial index idx_cc_recovery) and re-injects them.

NOTIF/AUTH degradation (D7): if createLink or dispatch fails → catch + log + send_attempt_count++continue. No campaign rollback. The operator will see NOTIF_ÉCHOUÉ at the next poll.

6.4 Inbound event handlers

Event Handler
NOTIF_SENT NotificationSentEventHandler: campaign_contracts.status → NOTIF_LIVRÉ
NOTIF_FAILURE NotificationFailureEventHandler: status → NOTIF_ÉCHOUÉ, send_attempt_count++
CP_LINK_OPENED LinkOpenedEventHandler: status → LIEN_OUVERT (V1)
PAY_CONFIRMED PaymentConfirmedEventHandler: status → PAYÉ, audit OP_PAYMENT_CONFIRMED
PAY_FAILED PaymentFailedEventHandler: status → PAIEMENT_ÉCHOUÉ
AUTH_LINK_EXPIRED LinkExpiredEventHandler: status → LIEN_EXPIRÉ (V1)
TenantSuspended TenantBlockedEventHandler: POST /send returns 403
TenantReactivated TenantUnblockedEventHandler: clears the flag
AgencyDisabled AgencyBlockedEventHandler: blocks sends for the agency
ImportCommitted No-op in v0.0.0; v0.0.1+: invalidates ContractEligibleCache (D12)

6.5 Tenant / agency blocking

Tenant block (payment suspension, compliance violation) is emitted by TENANT via a Spring event. OP stores the state in a TenantBlockedRegistry (cache + simple DB flag). Reconciliation: TENANT API call at startup.


7. Transaction Management & Failure Modes

7.1 Transaction boundaries

Every REST mutation = one Spring transaction delimited by @Transactional.

  • Campaign creation: insert campaigns + N × campaign_contracts + audit outbox = 1 TX.
  • Campaign send: update campaigns.status + insert CAMPAIGN_SEND outbox row = 1 short TX.
  • Contract exclusion / field fix: update campaign_contracts + audit outbox = 1 TX.
  • Anomaly resolution: delegated to ING over REST (HTTP — outside the OP TX).
  • Settings update: call to TENANT API (outside the TX), then audit outbox write (if OK) = 2 logical entities.

7.2 Failure modes

Event Behaviour
Cohort conflict (contract already active) 400 OP_CONTRACT_ALREADY_ACTIVE; TX rolled back.
NOTIF dispatch fails (outside OP TX) send_attempt_count++, audit error, client receives 200 + error detail. Retried by CampaignDispatchRecovery.
Audit outbox write fails (D7) TX proceeds (no rollback). AUDIT degraded mode. OPS alerted.
Agency/tenant scope violation 403 OP_ROLE_FORBIDDEN; TX rolled back.
Contract not found (ING unavailable) 503 ING_UNAVAILABLE returned. Campaign creation blocked.
Keycloak unavailable JWT refresh fails → operator session token expiry (30-min TTL). Redirect to login.

7.3 Distributed tracing

Every inter-module call carries an X-Correlation-ID header = campaign_contract.id (D16). Structured logs via SLF4J + MDC. Jaeger / OpenTelemetry traces ship upstream in v0.1.0.


8. Sequence Diagrams

8.1 Happy path: create + send a campaign (v0.0.0)

sequenceDiagram
    actor Operator
    participant UI as frontend/operator
    participant API as OP API
    participant TenantDB as Tenant DB
    participant AUTH_SVC as SignedLinkService (AUTH)
    participant NOTIF_SVC as NotificationDispatchService (NOTIF)
    participant Outbox as spring-modulith outbox
    participant PlatformDB as Platform DB

    Operator->>UI: Clique "Créer et envoyer"
    UI->>API: GET /operator/contracts?eligible=true
    API->>TenantDB: SELECT contracts WHERE due_date IN window AND pas de campagne active
    TenantDB-->>API: 12 contrats éligibles
    API-->>UI: { eligibleCount: 12 }

    Operator->>UI: Confirme dans la modale
    UI->>API: POST /operator/campaigns

    Note over API,TenantDB: TX0 — Création BROUILLON
    API->>TenantDB: INSERT campaigns (BROUILLON)
    API->>TenantDB: INSERT campaign_contracts ×12 (INCLUS, correlation_id=cc.id)
    API->>Outbox: INSERT event_publication (CampaignCreated)
    TenantDB-->>API: TX0 COMMIT
    API-->>UI: 201 { id, status: BROUILLON, contractCount: 12 }

    Operator->>UI: Confirme l'envoi
    UI->>API: POST /operator/campaigns/{id}/send

    Note over API,TenantDB: TX1 — Transition ENVOYÉE
    API->>TenantDB: UPDATE campaigns SET status=ENVOYÉE, sent_at=now()
    API->>Outbox: INSERT event_publication (CampaignValidated, outbox-backed)
    TenantDB-->>API: TX1 COMMIT
    API-->>UI: 200 { status: ENVOYÉE, dispatchQueued: true }

    Note over API: @ApplicationModuleListener déclenche AFTER_COMMIT

    loop CampaignSendWorker — pour chaque cc INCLUS
        Note over API,PlatformDB: TX_i par contrat
        API->>AUTH_SVC: createLink(contractId, ccId, tenantId, correlationId=cc.id)
        AUTH_SVC->>PlatformDB: INSERT signed_links
        AUTH_SVC-->>API: { linkId, token, expiresAt }
        API->>NOTIF_SVC: dispatch({ ccId, correlationId, phone, linkToken, ... })
        NOTIF_SVC->>PlatformDB: INSERT notification + delivery_attempt (PENDING)
        API->>TenantDB: UPDATE campaign_contracts SET status=NOTIF_EN_ATTENTE, link_id=linkId
        Note over API: TX_i COMMIT
    end

    Note over API: event_publication marqué DONE
    Note over NOTIF_SVC: @Async post-commit — soumission SMS au provider

8.2 JVM crash mid fan-out + CampaignDispatchRecovery

sequenceDiagram
    participant Worker as CampaignSendWorker
    participant Recovery as CampaignDispatchRecovery
    participant TenantDB as Tenant DB
    participant PlatformDB as Platform DB
    participant AUTH_SVC as SignedLinkService
    participant NOTIF_SVC as NotificationDispatchService

    Note over Worker: JVM crash après TX_3 committé (contrats 1-3 NOTIF_EN_ATTENTE, 4-12 INCLUS)

    Note over Recovery: Au redémarrage JVM
    Recovery->>PlatformDB: spring-modulith : event_publication WHERE status=PUBLISHED (retry)
    Note over Recovery: Listener CampaignSendWorker réexécuté

    Worker->>TenantDB: SELECT cc WHERE status=INCLUS AND campaign.status=ENVOYÉE
    Note over Worker: Trouve les contrats 4..12

    loop Contrats 4..12 (idempotence garantie)
        Worker->>AUTH_SVC: createLink(...) — UNIQUE(campaign_contract_id) → no-op si déjà créé
        Worker->>NOTIF_SVC: dispatch(...) — UNIQUE(campaign_contract_id) → no-op si déjà créé
        Worker->>TenantDB: UPDATE campaign_contracts SET status=NOTIF_EN_ATTENTE
    end

    Note over Recovery: Job périodique (toutes les 5 min) — filet de sécurité
    Recovery->>TenantDB: SELECT cc LEFT JOIN notifications n ON n.campaign_contract_id=cc.id\nWHERE cc.status='INCLUS' AND campaign.status='ENVOYÉE' AND n.id IS NULL
    Note over Recovery: Résultat vide → rien à faire
sequenceDiagram
    actor Operator
    participant UI as frontend/operator
    participant API as OP API
    participant AUTH_SVC as SignedLinkService (AUTH)
    participant NOTIF_SVC as NotificationDispatchService (NOTIF)
    participant TenantDB as Tenant DB

    Operator->>UI: Clique "Envoyer un rappel" sur un contrat
    UI->>API: POST /campaigns/{id}/contracts/{ccId}/remind

    API->>TenantDB: SELECT cc WHERE status IN (NOTIF_LIVRÉ, NOTIF_ÉCHOUÉ, LIEN_EXPIRÉ, PAIEMENT_ÉCHOUÉ)

    alt Lien expiré (LIEN_EXPIRÉ)
        API->>AUTH_SVC: reissueForCascade(originalLinkId, 72h)
        AUTH_SVC-->>API: { newLinkToken, expiresAt }
    else Lien encore valide
        API->>AUTH_SVC: getLink(ccId)
        AUTH_SVC-->>API: { existingLinkToken, expiresAt }
    end

    API->>NOTIF_SVC: dispatch({ ccId, correlationId, linkToken, ... })
    API->>TenantDB: UPDATE cc SET status=NOTIF_EN_ATTENTE, send_attempt_count+=1
    API->>TenantDB: (outbox) INSERT NOTIFICATION_RESENT audit event
    API-->>UI: 200 { status: NOTIF_EN_ATTENTE, newLinkExpiry }

8.4 Campaign cancellation (v0.0.3 — FR-OP-05)

sequenceDiagram
    actor Operator
    participant UI as frontend/operator
    participant API as OP API
    participant AUTH_SVC as SignedLinkService (AUTH)
    participant NOTIF_SVC as NotificationDispatchService (NOTIF)
    participant TenantDB as Tenant DB

    Operator->>UI: Clique "Annuler la campagne"
    UI->>API: GET /campaigns/{id}/status
    API-->>UI: { nonTerminalCount: 8 }
    UI->>Operator: Modale "8 lien(s) seront révoqués. Annuler quand même ?"
    Operator->>UI: Confirme
    UI->>API: POST /campaigns/{id}/cancel

    Note over API: Opérations synchrones avant le flip de statut
    API->>NOTIF_SVC: cancelPending(campaignId)
    NOTIF_SVC-->>API: { cancelledCount: 2 }
    API->>AUTH_SVC: revokeLinks(nonTerminalContractIds[])
    AUTH_SVC-->>API: { revokedCount: 8 }

    Note over API: Toutes révocations confirmées — flip statut
    API->>TenantDB: UPDATE campaigns SET status=ANNULÉE, cancelled_at, cancelled_by
    API->>TenantDB: UPDATE campaign_contracts SET status=LIEN_RÉVOQUÉ WHERE status NOT IN (terminal states)
    API->>TenantDB: (outbox) INSERT CAMPAIGN_CANCELLED
    API-->>UI: 200 { status: ANNULÉE }

9. Security

9.1 Authentication (Keycloak)

  • Every OP endpoint requires a valid Keycloak JWT (Authorization: Bearer).
  • Validation via Spring Security + Keycloak adapter: signature, expiry (5 min), tenant_id claim.
  • Refresh token handled by the frontend; 30 minutes of inactivity → automatic logout.
  • SSE (V1): JWT validated on connect; the client refreshes the token and re-opens the SSE connection.
  • Logout: session revoked on the Keycloak side; the frontend purges the JWT from sessionStorage.

9.2 RBAC enforcement

Two layers: 1. Endpoint level: Spring Security @PreAuthorize (e.g. export: supervisor OR cabinet_admin; user management: cabinet_admin only). 2. Data level: TenantFilter injects tenant_id. AgencyFilter injects agency_id for agency_operator.

cabinet_admin guard (OQ-OP-01 / OQ-06 in the PRD): OP enforces ≥ 1 active cabinet_admin per tenant at the service layer before any Keycloak Admin API deactivation call.

V0.0.2 note: at v0.0.2, agency_operator gains write access on PATCH /campaigns/{id}/contracts/{ccId} (inline pre-send edit) while remaining agency-scoped. The write middleware checks (role == AGENCY_OPERATOR && cc.agency_id == currentAgencyId) || role == CABINET_ADMIN.

9.3 Audit log

Every event listed in PRD §B.6 is written synchronously to the spring-modulith outbox inside the business transaction. Per D7, an outbox failure triggers the AUDIT degraded mode — the business action is not rolled back. Catalogue (14 OP types): see §1.3.

9.4 Data protection (XOF, PII) & minimisation

  • XOF: always stored as integer minor units (centime) — never floats. Display uses a space as the thousands separator.
  • DOB: shown as **/**/YYYY in list views, DD/MM/YYYY on the individual sheet (FR-OP-06).
  • Phone in list views: shown in clear in v0.0.0. [LEGAL-REVIEW] — partial masking +225 XX XX ** ** is contingent on the ARTCI decision (OQ-OP-02).
  • PII in audit: routed through PiiHasher (AUDIT named interface). OP never holds the raw DOB.
  • Retention: campaigns / contracts retained 2 years after settlement (CIMA §F.2 — see legal memo).

9.5 SQL injection prevention

All queries use named parameters via Spring JdbcClient. No string concatenation on user input.

9.6 CSRF protection

CSRF via Spring Security defaults (SameSite cookie + POST validation). The frontend sends the CSRF token in the X-CSRF-TOKEN header.

9.7 Export URLs (V1)

Signed links, 10-min TTL, single use, carrying the tenantId. Issued via SignedLinkService or as a presigned MinIO URL.

9.8 SSE connections (V1)

In-memory emitter registry, scoped by (tenant_id, agency_id). A fresh JWT is required on every reconnect.

9.9 Secrets

Variable Usage
KC_OP_CLIENT_SECRET Confidential Keycloak client for operator tokens
KC_ADMIN_CLIENT_ID / KC_ADMIN_CLIENT_SECRET Keycloak Admin client (V1 user management)

Never logged; never returned in API responses.

9.10 Compliance flags (inherited from the OP PRD)

Flag Status Note
TV-01: CIMA mentions légales handled by the platform PRÉLIMINAIRE [LEGAL-REVIEW] before production
TV-02: 72h link validity compatible with CIMA PRÉLIMINAIRE [LEGAL-REVIEW] before production
TV-03: WhatsApp + phone with no ARTCI restriction PRÉLIMINAIRE Founder-validated 2026-05-19
OQ-03: Phone partial masking in list view Open ARTCI decision before V1 (OQ-OP-02)

10. Performance & Bandwidth Budget

10.1 Desktop-first model

No strict payload budget (D23 caps apply to the customer portal only). OP targets desktop/tablet >= 1024 px on an office network (4G/fibre).

10.2 Targets

Metric Target Mechanism
Initial load ≤ 2 s Vite code-splitting; React Router lazy imports
Screen transitions ≤ 300 ms Client-side routing
Write actions ≤ 3 s Optimistic feedback where safe (exclusion, correction)
Real-time latency < 5 s (SSE V1); 30 s max (polling) SSE + polling fallback
Campaigns pagination 25 / page (D6) Opaque base64 cursor
Contracts pagination (detail) 50 / page Opaque base64 cursor

10.3 Caching strategy

  • TenantConfigCache (TENANT): Spring @Cacheable, 5-min TTL, key = tenantId.
  • Campaign list: no server-side cache. React Query on the client, staleTime 30 s.
  • Country profile: served by TenantConfigCache; OP reads renewal_window_days.

10.4 Query optimisation

Cohort eligibility query (FR-OP-04, the hottest one):

SELECT c.*
FROM contracts c
WHERE c.tenant_id = :tenantId
  AND c.due_date BETWEEN CURRENT_DATE
                     AND CURRENT_DATE + (:windowDays * INTERVAL '1 day')
  AND NOT EXISTS (
    SELECT 1 FROM campaign_contracts cc
    JOIN campaigns camp ON camp.id = cc.campaign_id
    WHERE cc.contract_id = c.id
      AND camp.tenant_id = :tenantId
      AND camp.status    NOT IN ('CLÔTURÉE','ANNULÉE')
      AND cc.status      NOT IN ('EXCLU','PAYÉ','RÉGLÉ_HORS_PLATEFORME',
                                  'LIEN_EXPIRÉ','LIEN_RÉVOQUÉ')
  )

Covered by idx_cc_tenant_contract_status. Requires (tenant_id, due_date) on contracts — to be verified during the TENANT/ING migration.

10.5 Dashboard aggregates (V1)

Four widgets = four aggregated SQL queries on demand (no materialised view in V1). A materialised view becomes a candidate in V2 above 10,000 contracts per tenant.


11. Migration Strategy

11.1 Flyway script location

db/migration/tenant/
  V005__op_campaigns.sql     ← campaigns + campaign_contracts + operator_notes + indexes

11.2 v0.0.0 migration (V005__op_campaigns.sql)

CREATE TABLE campaigns (
  id              UUID         PRIMARY KEY DEFAULT gen_random_uuid(),
  tenant_id       UUID         NOT NULL,
  agency_id       UUID         NOT NULL,
  name            VARCHAR(200) NOT NULL,
  type            VARCHAR(20)  NOT NULL DEFAULT 'SCHEDULED'
                    CHECK (type IN ('SCHEDULED','AD_HOC')),
  status          VARCHAR(30)  NOT NULL DEFAULT 'BROUILLON'
                    CHECK (status IN ('BROUILLON','ENVOYÉE','CLÔTURÉE','ANNULÉE')),
  created_by      UUID         NOT NULL,
  created_at      TIMESTAMPTZ  NOT NULL DEFAULT now(),
  sent_at         TIMESTAMPTZ,
  sent_by         UUID,
  closed_at       TIMESTAMPTZ,
  cancelled_at    TIMESTAMPTZ,
  cancelled_by    UUID,
  cohort_params   JSONB
);

CREATE INDEX idx_campaigns_tenant_agency_status ON campaigns (tenant_id, agency_id, status);
CREATE INDEX idx_campaigns_tenant_created       ON campaigns (tenant_id, created_at DESC);

CREATE TABLE campaign_contracts (
  id                  UUID        PRIMARY KEY DEFAULT gen_random_uuid(),
  tenant_id           UUID        NOT NULL,
  campaign_id         UUID        NOT NULL,
  contract_id         UUID        NOT NULL,
  status              VARCHAR(40) NOT NULL DEFAULT 'INCLUS'
                        CHECK (status IN (
                          'INCLUS','EXCLU',
                          'NOTIF_EN_ATTENTE','NOTIF_LIVRÉ','NOTIF_ÉCHOUÉ',
                          'LIEN_OUVERT','PAYÉ','PAIEMENT_ÉCHOUÉ',
                          'RÉGLÉ_HORS_PLATEFORME','LIEN_EXPIRÉ','LIEN_RÉVOQUÉ'
                        )),
  excluded_by         UUID,
  excluded_at         TIMESTAMPTZ,
  exclusion_reason    VARCHAR(500),
  hp_method           VARCHAR(20)
                        CHECK (hp_method IN ('MOBILE_MONEY','CASH','CHEQUE','VIREMENT')),
  hp_date             DATE,
  hp_amount           BIGINT,
  hp_reference        VARCHAR(200),
  hp_marked_by        UUID,
  hp_marked_at        TIMESTAMPTZ,
  link_id             UUID,
  send_attempt_count  INTEGER NOT NULL DEFAULT 0,
  UNIQUE (campaign_id, contract_id)
);

CREATE INDEX idx_cc_tenant_campaign         ON campaign_contracts (tenant_id, campaign_id);
CREATE INDEX idx_cc_tenant_contract_status  ON campaign_contracts (tenant_id, contract_id, status);
CREATE INDEX idx_cc_recovery                ON campaign_contracts (tenant_id, campaign_id, status)
  WHERE status = 'INCLUS';

CREATE TABLE operator_notes (
  id              UUID         PRIMARY KEY DEFAULT gen_random_uuid(),
  tenant_id       UUID         NOT NULL,
  agency_id       UUID         NOT NULL,
  entity_type     VARCHAR(20)  NOT NULL CHECK (entity_type IN ('CONTRACT','CAMPAIGN')),
  entity_id       UUID         NOT NULL,
  content         TEXT         NOT NULL,
  created_by      UUID         NOT NULL,
  created_at      TIMESTAMPTZ  NOT NULL,
  synced_at       TIMESTAMPTZ,
  device_local_id VARCHAR(100)
);

CREATE INDEX idx_notes_tenant_entity ON operator_notes (tenant_id, entity_type, entity_id);
CREATE UNIQUE INDEX idx_notes_device_local ON operator_notes (tenant_id, device_local_id)
  WHERE device_local_id IS NOT NULL;

11.3 Additive migrations (no breaking changes)

Migration Sub-version Change
No OP-specific migration v0.0.1, v0.0.2, v0.0.3 CHECK already covers every state; agency_id already present

Every FSM transition and the multi-agency routing (v0.0.2) work without an ALTER TABLE thanks to the full CHECK shipped from v0.0.0 (OP-ADR-02).

11.4 Reversibility

Migrations are purely additive in V0. Rollback = DROP TABLE campaigns CASCADE; DROP TABLE campaign_contracts CASCADE; DROP TABLE operator_notes CASCADE; — no other module references these tables via a DB FK.


Continues in part2-integrations-build-ops.md — §12 Integration Points through §21 Cross-Module Decisions Confirmed + Appendices A (ADRs) and B (Open Questions).