Story OP-000: Foundation backend + frontend (module OP)¶
Module: operator-console
Slice: OP-S0 (Architecture §11 Tier 0)
Side: [BACKEND] + [OPERATOR]
Version target: V0.0.0 (additivement utilisable jusqu'en V1)
Priority: 1 (bloque toutes les autres stories OP)
Depends on: TENANT-S0 (Postgres + datasources), AUDIT-S0 (outbox + AuditEventBuilder), ING-001 (type ImportCommitted)
Can develop concurrently with: None (foundation)
Merge order: Before tout OP-NNN ≥ 001
Estimated complexity: M
PRD User Stories: N/A — Story technique fondatrice (V0 envelope)
Wireframe: N/A — backend + shell technique frontend uniquement
Objective¶
Poser une seule fois les fondations partagées par toutes les stories OP (back et front) afin d'éliminer 5 risques de dérive identifiés par le PO : schéma DB, RBAC + tenant/agency scoping, hooks frontend, audit event_type catalog, error envelope. Toute story OP-001+ consomme ces fondations sans les ré-inventer.
Backend Scope¶
Entities (créées par la migration de cette story)¶
campaigns(UUID id PK, tenant_id, agency_id, name VARCHAR(200), type VARCHAR(20) CHECK SCHEDULED|AD_HOC, status VARCHAR(30) CHECK BROUILLON|ENVOYÉE|CLÔTURÉE|ANNULÉE, created_by, created_at, sent_at, sent_by, closed_at, cancelled_at, cancelled_by, cohort_params JSONB)campaign_contracts(UUID id PK =correlation_idD16, tenant_id, campaign_id, contract_id, status VARCHAR(40) CHECK 11-états complets, exclusion fields, HP fields, link_id, send_attempt_count, UNIQUE(campaign_id, contract_id))operator_notes(UUID id, tenant_id, agency_id, entity_type CHECK CONTRACT|CAMPAIGN, entity_id, content TEXT, created_by, created_at, synced_at, device_local_id)
CHECK complets dès V0.0.0 (OP-ADR-02) pour éviter ALTER TABLE entre sous-versions.
Migrations¶
db/migration/tenant/V005__op_campaigns.sql — exactement le SQL de l'architecture §9.2 (les 3 CREATE TABLE + 6 INDEX, dont idx_cc_recovery WHERE status='INCLUS' et idx_notes_device_local WHERE device_local_id IS NOT NULL). Aucune FK DB cross-module (profil BYO).
Service Layer (fondations partagées)¶
OperatorModule.java:@ApplicationModule("operator")Spring Modulith.TenantContextResolver(réutilise le bean partagé) : extraittenant_id,agency_id,roledu JWT Keycloak (claimtenant_id,agency_idmapper,realm_access.roles).OperatorRbacFilter:HandlerInterceptorqui vérifieAuthorization: Bearervalide + role ∈ {agency_operator, supervisor, cabinet_admin, PLATFORM_ADMIN}. Rejette 401 / 403.AgencyScopeFilter: AOP aspect appliqué sur tous les repositories OP via@AgencyScoped. Pourrole == AGENCY_OPERATOR, injecteAND agency_id = :currentAgencyIddans la requête. Désactivé en V0.0.0 (bypass = pas de filtrage agence) ; activé via propertyop.agency-scope.enabled=trueen V0.0.2 (OP-010). Le code est en place dès V0.0.0 pour éviter une refonte du data layer.OperatorAuditPublisher: helper qui enveloppeAuditEventBuilderpour OP. Émet via outbox spring-modulith. Catalogue : 14 types d'événements OP (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). Cette story enregistre les 14 valeurs auprès duAuditEventTypeenum partagé (résout OQ-X14).ImportCommittedListener:@ApplicationModuleListener(ImportCommitted.class); corps no-op v0.0.0 (TODO marker// v0.0.1+ : invalidate ContractEligibleCache (D12)). Empêche les warnings Spring Modulith de listener manquant.CampaignDispatchRecovery: stub@Componentavec@Scheduled(fixedDelayString = "${op.recovery.interval-ms:300000}")vide. Le corps est implémenté en OP-004. Présent ici pour ne pas casser le wiring.OperatorExceptionHandler:@ControllerAdvicequi formate toutes les erreurs OP en{ "code": "OP_*", "message": "fr-FR", "details": {...} }(architecture §3.5). Liste initiale codes :OP_ROLE_FORBIDDEN,OP_CAMPAIGN_NOT_FOUND. Les autres sont ajoutés par les stories qui les introduisent.ContractEligibilityQueryAdapter: façade surContractQueryService.findEligible(tenantId, agencyId, windowDays)du module ING. Centralise la lecture cohort consommée par OP-001 (endpoint) ET OP-002 (création campagne).
API Endpoints¶
| Method | Path | Request | Response | Auth |
|---|---|---|---|---|
GET |
/api/operator/settings |
— | { renewalWindowDays, cimaLegalName, cimaLegalAddress, cimaLicenseNumber } (lit TenantConfigCache) |
Tous rôles OP |
Tous les autres endpoints OP sont introduits par les stories suivantes. Ce GET /settings est inclus ici parce qu'il valide la chaîne RBAC + TenantContextResolver + TenantConfigCache.
Validation Rules¶
- JWT signature HS256 vérifiée (clé publique Keycloak), expiry ≤ 5 min, claim
tenant_idobligatoire. roledoit appartenir à l'enum 5-rôles ; sinon 403OP_ROLE_FORBIDDEN.
Multi-Tenant Considerations¶
- Toutes les requêtes JdbcClient
WHERE tenant_id = :#{currentTenant.tenantId}. Aucune exception (rule #1critical-rules.md). - Profil SHARED (V0.0.0) : datasource
@Primary. Profil BYO (V0.0.4+) :TenantDataSourceResolverroute automatiquement — code OP identique. AgencyScopeFilterdésactivé V0.0.0, activé via property en V0.0.2 (OP-010). Le filtre litcurrentTenant.agencyId(); null pour cabinet_admin/supervisor.country_codeest lu viaTenantConfigCache.getCountryCode(tenantId)— la story ne consomme pas mais expose le hook pour les stories aval (NOTIF dispatch en OP-003 doit le passer en argument).
Audit & Logging¶
- 14 types OP enregistrés dans
AuditEventTypeenum AUDIT. MDCinitialisé parOperatorRbacFilter:tenant_id,user_id,role. Stories aval enrichissent aveccampaign_id,correlation_id.- Logs JSON via
logback.xml(préexistant).
Frontend Scope¶
Pages & Routes¶
- Aucune nouvelle page métier. Cette story étend
frontend-operator/src/App.tsx(router TanStack v1) pour exposer le layoutOperatorShellà toutes les futures routes/campagnes,/imports,/anomalies, etc. - Route
/settings(lecture seule, debug + check de bout-en-bout V0.0.0) : afficherenewalWindowDaysen texte simple. Cette route disparaîtra ou évoluera en V1 (US-OP-12).
Components¶
Composants foundation à créer sous frontend-operator/src/foundation/ :
apiClient.ts: instance Axios + interceptors (1) Keycloak Bearer auto, (2) refresh token, (3) offline detection (navigator.onLine + ECONNREFUSED → showOfflineBanner()), (4) error envelope mapping ({code, message, details}→ApiErrortypé).useOperatorPolling.ts: hook React Query générique(queryKey, fetcher, { intervalMs = 30000, suspendOnHidden = true }). Suspend audocument.visibilityState === 'hidden'. Réutilisé par OP-006/OP-009 pour le status polling.OperatorErrorBoundary.tsx: enveloppe toutOutlet. Affiche un fallback FR :« Une erreur est survenue. [Réessayer] ». Log Sentry.GlobalBanner.tsx: bandeau plein écran au-dessus de la TopBar, variantsoffline(noir),reconnecting(vert 2s),maintenance(rouge). Branché sur unuseOnlineStatushook (interne).Skeleton.tsx: primitive avec variantstext,row,card,kpi-tile. Shimmer 1.2s, respectprefers-reduced-motion. Réutilisée partout.sidebarRbac.ts: helpervisibleNavItems(role) → NavItem[]qui filtre selon §2 du design (visibilité par rôle). Importé parSidebar.tsxexistant.
UI States (route /settings minimale)¶
| State | Behavior | French Copy |
|---|---|---|
| Loading | Skeleton text 3 lignes |
— |
| Empty | Impossible (settings toujours seedés) | — |
| Error | Inline error sous le titre | « Impossible de charger les paramètres. [Réessayer] » |
| Offline / low network | GlobalBanner offline + valeurs en opacity: 0.6 |
« Connexion perdue. Les données affichées datent de {timestamp}. » |
| Success | Valeurs affichées en texte clair | — |
Responsive Behavior¶
- Desktop ≥ 1280 px : layout
OperatorShellcomplet (sidebar 240 px + contenu). - 1024–1279 px : identique.
- 768–1023 px : sidebar repliée en icônes.
- < 768 px : page d'accueil mobile « La console opérateur est optimisée pour ordinateur. Veuillez utiliser un écran ≥ 768 px. » (PRD §A.4 hors scope V1).
Connectivity Behavior¶
N/A — [OPERATOR] est online-first (PRD §C.4).
Interactions¶
Hook useOnlineStatus :
- détection window.addEventListener('online'|'offline'),
- heartbeat GET /api/health toutes les 30 s (créé par TENANT S0),
- à la reconnexion : bandeau vert 2 s « Connexion rétablie. Synchronisation… » + invalidation React Query.
Acceptance Criteria¶
AC-001: Migration des 3 tables OP appliquée et idempotente
Given une base tenant vide après TENANT S0
When la migration V005__op_campaigns.sql s'exécute
Then les tables campaigns, campaign_contracts, operator_notes existent avec les CHECK V1 complets, les 6 index sont créés, et une seconde exécution n'altère rien (Flyway state OK).
AC-002: AuditEventType enum admet les 14 types OP
Given le catalog AUDIT actif
When OperatorAuditPublisher émet un événement de chaque type OP
Then aucun n'est rejeté par AUDIT, et chaque événement est retrouvé dans audit_log avec tenant_id correct.
AC-003: GET /api/operator/settings respecte l'isolation tenant + RBAC
Given deux tenants A et B avec settings différents
When un agency_operator du tenant A appelle GET /settings avec son JWT
Then la réponse contient renewalWindowDays de A, et un appel avec un JWT non-OP (role non listé) retourne 403 OP_ROLE_FORBIDDEN.
AC-004: Frontend shell rend la route /settings sur tous les breakpoints
Given le frontend builded (Vite dev server)
When on charge /settings à 1280 / 1024 / 768 px
Then la page affiche la valeur reçue, la sidebar est visible (icônes seules à 768 px), et un kill-switch offline déclenche le GlobalBanner.
Compliance Rules¶
- CIMA 01-24 : aucun affichage client dans cette story (pas de mention CIMA rendue).
VÉRIFIÉ. - ARTCI / loi 2013-450 §B.1 (traçabilité) :
MDCinitialisé avecuser_id+tenant_id; chaque appel API est loggé.VÉRIFIÉ. - PRD V0 §8 (online-first) : pas de service worker, pas d'IndexedDB.
VÉRIFIÉ.
Standards & Conventions¶
docs/standards/critical-rules.md— règles #1 (tenant isolation), #3 (audit catalog).docs/standards/multi-tenant-model.md— §2 (5-role enum), §4 (DB profile taxonomy).docs/standards/java-spring-guidelines.md— Spring Modulith@ApplicationModule,JdbcClientparameterized.docs/standards/api-guidelines.md— namespace/api/operator/*, error envelope.docs/standards/react-typescript-guidelines.md— React 19.2 + TanStack Router + React Query + CSS Modules.docs/standards/design-system.md— Skeleton, GlobalBanner, tokens couleurs.docs/standards/connectivity-low-bandwidth.md— pattern polling 30s, suspend on hidden.
Testing Requirements¶
Unit Tests¶
OperatorAuditPublisherTest: chaque type OP produit un événement avecevent_typeexact,correlation_idpropagé,payloadnon-null.AgencyScopeFilterTest: propertyenabled=false→ aucune injection ;enabled=true+ role=AGENCY_OPERATOR → injection deagency_id.useOperatorPolling(Vitest) : refetch désactivé quanddocument.visibilityState='hidden'.
Integration Tests¶
OperatorModuleTest: Spring Modulith boundary OK (aucun import interdit).SettingsEndpointIT: isolation tenant prouvée (AC-003),@SpringBootTest+ Keycloak Testcontainer.OfflineDetectionTest(Playwright) :await context.setOffline(true)→GlobalBannervisible en < 2 s.
What QA Will Validate¶
- Migration appliquée sans erreur sur Postgres staging.
- Smoke
curl /settingsavec JWT operator → 200 ; JWT customer (out-of-scope) → 403. - Frontend
/settingsse charge en < 2 s ; pas de console error.
Out of Scope¶
- Toute endpoint campagne / contrat / anomalie / import → OP-001+.
- Édition de settings (
PATCH /settings) → V1 (US-OP-12). - SSE temps réel → V1 (OP arch §5.3).
- Drawer, InlineBanner, EmptyState, KpiTile, StatusPill, FilterBar, Pagination, DataTable extensions → créés par la première story aval qui les consomme.
- Service worker, IndexedDB, file de mutations offline → V2+.
AgencyScopeFilterACTIVÉ par défaut → OP-010 (V0.0.2).
Definition of Done¶
- [ ] Migration V005__op_campaigns.sql passe sur Postgres + idempotente
- [ ] 14 types AuditEventType OP enregistrés et acceptés par AUDIT API
- [ ]
OperatorRbacFilter,AgencyScopeFilter(désactivé),OperatorExceptionHandler,OperatorAuditPublisher,ContractEligibilityQueryAdaptermergés - [ ]
ImportCommittedListenerno-op câblé (aucun warning Spring Modulith) - [ ]
CampaignDispatchRecoverystub câblé (job vide démarre toutes les 5 min) - [ ]
GET /api/operator/settingsretourne 200 + isolation tenant (AC-003) - [ ] Frontend shell :
apiClient,useOperatorPolling,OperatorErrorBoundary,GlobalBanner,Skeleton,sidebarRbacmergés - [ ] Route
/settingsrend à 360 (page d'accueil mobile) / 768 / 1024 / 1280 / 1440 px - [ ] Bandeau offline visible < 2s après perte de connexion (Playwright)
- [ ] French micro-copy = exactement les chaînes spec design §7.1 et §8.3
- [ ] Audit log entries présents pour chaque appel
/settings(eventPLATFORM_ADMIN_QUERYseulement si rôle PLATFORM_ADMIN) - [ ] Tenant isolation prouvée par
SettingsEndpointIT(deux tenants, jamais de fuite) - [ ] Tests unitaires + intégration verts en CI
- [ ] Aucun TODO sans ticket ; le seul TODO accepté est
ImportCommittedListenerv0.0.1+