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_CHANGEDis emitted by OP to AUDIT when the operator calls the TENANT API to updaterenewal_detection_window_days. TENANT independently emits its ownTenantSettingsChangedin 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
UNIQUEoncampaigns.namewithouttenant_id. cohort_params JSONBabsorbs V2 fields (cron expression, A/B ratio) with no migration.send_attempt_countsupports V2 automatic retry.- Full CHECK constraints from v0.0.0 (OP-ADR-02) → no
ALTER TABLEbetween 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_idclaim (D3). - Agency: for
agency_operator, the Spring middleware injectsagency_idautomatically.
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_idclaim is extracted byTenantFilter(D3). RequestContextHoldercarriesTenantContext { tenantId, agencyId?, role }.agencyIdis populated foragency_operatorfrom the JWTagency_idclaim (Keycloak mapper).- Every OP
JdbcClientquery: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
UNIQUEoncampaigns.name. agency_idpresent 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)¶
Polling is suspended when the tab loses focus (document.visibilityState) and resumed on focus.
5.3 SSE strategy (V1)¶
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
localStorageas{ entityType, entityId, content, localTimestamp, deviceLocalId, synced: false }. On reconnect:POST /operator/notes(batched, idempotent viadeviceLocalId). - 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):
- Load eligible contracts via
ContractQueryService.findEligible(tenant, agencyId, windowDays). - Criteria:
status='ACTIF',due_datewithin[now, now+windowDays], NOT in an active campaign (FR-OP-04). - Scope: if
agency_operator, addAND agency_id = :currentAgency. - Create a
Campaign(BROUILLON) withcohort_params = { windowDays, generatedAt: now }. - Create one
campaign_contractper 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: 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+ insertCAMPAIGN_SENDoutbox 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
8.3 Link re-issue / reminder (V1 — FR-OP-09)¶
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_idclaim. - 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
**/**/YYYYin list views,DD/MM/YYYYon 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,
staleTime30 s. - Country profile: served by
TenantConfigCache; OP readsrenewal_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).