Aller au contenu

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_id D16, 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é) : extrait tenant_id, agency_id, role du JWT Keycloak (claim tenant_id, agency_id mapper, realm_access.roles).
  • OperatorRbacFilter : HandlerInterceptor qui vérifie Authorization: Bearer valide + role ∈ {agency_operator, supervisor, cabinet_admin, PLATFORM_ADMIN}. Rejette 401 / 403.
  • AgencyScopeFilter : AOP aspect appliqué sur tous les repositories OP via @AgencyScoped. Pour role == AGENCY_OPERATOR, injecte AND agency_id = :currentAgencyId dans la requête. Désactivé en V0.0.0 (bypass = pas de filtrage agence) ; activé via property op.agency-scope.enabled=true en 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 enveloppe AuditEventBuilder pour 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 du AuditEventType enum 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 @Component avec @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 : @ControllerAdvice qui 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 sur ContractQueryService.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_id obligatoire.
  • role doit appartenir à l'enum 5-rôles ; sinon 403 OP_ROLE_FORBIDDEN.

Multi-Tenant Considerations

  • Toutes les requêtes JdbcClient WHERE tenant_id = :#{currentTenant.tenantId}. Aucune exception (rule #1 critical-rules.md).
  • Profil SHARED (V0.0.0) : datasource @Primary. Profil BYO (V0.0.4+) : TenantDataSourceResolver route automatiquement — code OP identique.
  • AgencyScopeFilter désactivé V0.0.0, activé via property en V0.0.2 (OP-010). Le filtre lit currentTenant.agencyId() ; null pour cabinet_admin/supervisor.
  • country_code est lu via TenantConfigCache.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 AuditEventType enum AUDIT.
  • MDC initialisé par OperatorRbacFilter : tenant_id, user_id, role. Stories aval enrichissent avec campaign_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 layout OperatorShell à toutes les futures routes /campagnes, /imports, /anomalies, etc.
  • Route /settings (lecture seule, debug + check de bout-en-bout V0.0.0) : affiche renewalWindowDays en 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}ApiError typé).
  • useOperatorPolling.ts : hook React Query générique (queryKey, fetcher, { intervalMs = 30000, suspendOnHidden = true }). Suspend au document.visibilityState === 'hidden'. Réutilisé par OP-006/OP-009 pour le status polling.
  • OperatorErrorBoundary.tsx : enveloppe tout Outlet. Affiche un fallback FR : « Une erreur est survenue. [Réessayer] ». Log Sentry.
  • GlobalBanner.tsx : bandeau plein écran au-dessus de la TopBar, variants offline (noir), reconnecting (vert 2s), maintenance (rouge). Branché sur un useOnlineStatus hook (interne).
  • Skeleton.tsx : primitive avec variants text, row, card, kpi-tile. Shimmer 1.2s, respect prefers-reduced-motion. Réutilisée partout.
  • sidebarRbac.ts : helper visibleNavItems(role) → NavItem[] qui filtre selon §2 du design (visibilité par rôle). Importé par Sidebar.tsx existant.

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 OperatorShell complet (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é) : MDC initialisé avec user_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, JdbcClient parameterized.
  • 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 avec event_type exact, correlation_id propagé, payload non-null.
  • AgencyScopeFilterTest : property enabled=false → aucune injection ; enabled=true + role=AGENCY_OPERATOR → injection de agency_id.
  • useOperatorPolling (Vitest) : refetch désactivé quand document.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)GlobalBanner visible en < 2 s.

What QA Will Validate

  • Migration appliquée sans erreur sur Postgres staging.
  • Smoke curl /settings avec JWT operator → 200 ; JWT customer (out-of-scope) → 403.
  • Frontend /settings se 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+.
  • AgencyScopeFilter ACTIVÉ 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, ContractEligibilityQueryAdapter mergés
  • [ ] ImportCommittedListener no-op câblé (aucun warning Spring Modulith)
  • [ ] CampaignDispatchRecovery stub câblé (job vide démarre toutes les 5 min)
  • [ ] GET /api/operator/settings retourne 200 + isolation tenant (AC-003)
  • [ ] Frontend shell : apiClient, useOperatorPolling, OperatorErrorBoundary, GlobalBanner, Skeleton, sidebarRbac mergés
  • [ ] Route /settings rend à 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 (event PLATFORM_ADMIN_QUERY seulement 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 ImportCommittedListener v0.0.1+