From b31e8666a59a47344c61d8da32456e992c8a511b Mon Sep 17 00:00:00 2001 From: Hermann_Kitio Date: Sun, 19 Apr 2026 03:37:41 +0300 Subject: [PATCH] =?UTF-8?q?feat(entities):=20production=20+=20report=20?= =?UTF-8?q?=E2=80=94=20types,=20lib,=20api,=20tests=20floutage=20(Sprint?= =?UTF-8?q?=203=20=C3=A9tape=2013)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/ARCHITECTURE.md | 4 + src/entities/production/api.ts | 22 ++++++ src/entities/production/lib.ts | 29 +++++++ src/entities/production/types.ts | 35 +++++++++ .../report/__tests__/floutage.test.ts | 78 +++++++++++++++++++ src/entities/report/api.ts | 37 +++++++++ src/entities/report/lib.ts | 34 ++++++++ src/entities/report/types.ts | 62 +++++++++++++++ 8 files changed, 301 insertions(+) create mode 100644 src/entities/production/api.ts create mode 100644 src/entities/production/lib.ts create mode 100644 src/entities/production/types.ts create mode 100644 src/entities/report/__tests__/floutage.test.ts create mode 100644 src/entities/report/api.ts create mode 100644 src/entities/report/lib.ts create mode 100644 src/entities/report/types.ts diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 5f40f2b..62a68ec 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -197,6 +197,10 @@ shared/ ne doit RIEN importer des autres dossiers Cette hiérarchie garantit que la logique métier (`entities/`) ne dépend jamais de l'UI (`features/`), et que les briques réutilisables (`shared/`) restent portables. +**Exception documentée — cross-entity report → user :** +`entities/report/lib.ts` importe `hasAccess` et `Plan` depuis `entities/user/lib`. +Justification : la logique de floutage des rapports dépend intrinsèquement des permissions utilisateur. Utiliser `hasAccess()` est obligatoire (Règle D) ; le déplacer vers `shared/` briserait la cohésion du domaine `user`. Cette exception est volontaire et ne doit pas être généralisée à d'autres paires d'entities. + --- ## 4. Flux de données diff --git a/src/entities/production/api.ts b/src/entities/production/api.ts new file mode 100644 index 0000000..1036abc --- /dev/null +++ b/src/entities/production/api.ts @@ -0,0 +1,22 @@ +/** + * Appels API du domaine `production`. + * + * Toutes les requêtes passent par `apiFetch` (Règle J / Règle F). + * - `POST /simulations` : timeout 5s (défaut), retry désactivé (POST non-idempotent). + * - `GET /simulations/:id` : timeout 5s, retry activé (GET idempotent). + * + * Erreurs notables : `QUOTA_REACHED` (Free 5/5), `PLAN_INSUFFICIENT` (exam_mode). + */ + +import { apiFetch } from '@/shared/lib/api-client' +import type { CreateSimulationPayload, Production } from './types' + +/** Crée une nouvelle simulation. Endpoint : `POST /simulations` (HTTP 201). */ +export function createSimulation(payload: CreateSimulationPayload): Promise { + return apiFetch('/simulations', { method: 'POST', body: payload }) +} + +/** Récupère une simulation existante. Endpoint : `GET /simulations/:id`. */ +export function getSimulation(id: string): Promise { + return apiFetch(`/simulations/${id}`) +} diff --git a/src/entities/production/lib.ts b/src/entities/production/lib.ts new file mode 100644 index 0000000..bf44c17 --- /dev/null +++ b/src/entities/production/lib.ts @@ -0,0 +1,29 @@ +/** + * Helpers purs du domaine `production`. + * Aucune logique de plan en dur — les règles d'accès passent par entities/user/lib. + */ + +import type { Tache } from './types' + +const TACHE_LABELS: Record = { + EE_T1: 'Expression Écrite — Tâche 1', + EE_T2: 'Expression Écrite — Tâche 2', + EE_T3: 'Expression Écrite — Tâche 3', + EO_T1: 'Expression Orale — Tâche 1', + EO_T3: 'Expression Orale — Tâche 3', +} + +/** Libellé long d'une tâche, à afficher dans l'UI. */ +export function formatTache(tache: Tache): string { + return TACHE_LABELS[tache] +} + +/** Vrai si la tâche est une tâche d'Expression Écrite. */ +export function isEcrit(tache: Tache): boolean { + return tache.startsWith('EE_') +} + +/** Vrai si la tâche est une tâche d'Expression Orale (T1 ou T3 — hors T2 Live). */ +export function isOral(tache: Tache): boolean { + return tache.startsWith('EO_') +} diff --git a/src/entities/production/types.ts b/src/entities/production/types.ts new file mode 100644 index 0000000..863c038 --- /dev/null +++ b/src/entities/production/types.ts @@ -0,0 +1,35 @@ +/** + * Types publics du domaine `production`. + * + * Une `Production` correspond à une simulation créée via `POST /simulations`. + * Le backend renvoie directement l'objet métier (pas d'enveloppe — cf. ARCHITECTURE.md §5). + * + * EO_T2 (T2 Live) est exclu de ce domaine : il passe par WebSocket et est géré + * dans `features/t2-live` (Sprint 6). + * + * SEC-05 : les payloads de correction contiennent du texte utilisateur brut. + * Ne jamais les injecter comme HTML — passer par react-markdown dans les composants. + */ + +/** Identifiants des tâches disponibles en mode simulation (hors T2 Live). */ +export type Tache = 'EE_T1' | 'EE_T2' | 'EE_T3' | 'EO_T1' | 'EO_T3' + +/** Mode de la simulation — examen uniquement accessible au plan Premium. */ +export type Mode = 'entrainement' | 'examen' + +/** + * Réponse du backend pour `POST /simulations` (HTTP 201) et `GET /simulations/:id`. + * Format confirmé par l'audit backend 2026-04-17 (cf. ARCHITECTURE.md §5). + */ +export interface Production { + id: string + tache: Tache + mode: Mode + created_at: string +} + +/** Corps de la requête `POST /simulations`. */ +export interface CreateSimulationPayload { + tache: Tache + mode: Mode +} diff --git a/src/entities/report/__tests__/floutage.test.ts b/src/entities/report/__tests__/floutage.test.ts new file mode 100644 index 0000000..fdd3dcf --- /dev/null +++ b/src/entities/report/__tests__/floutage.test.ts @@ -0,0 +1,78 @@ +/** + * Tests de la logique de floutage — isSectionVisible(). + * + * Couverture : 5 sections floutables × 3 plans = 15 tests. + * Source de vérité : PLANS_TARIFAIRES.md §2. + * + * detailed_report : criteres, erreurs → false pour Free, true pour Standard+ + * tips : modele, idees, exercices → false pour Free, true pour Standard+ + */ + +import { describe, it, expect } from 'vitest' +import { isSectionVisible } from '../lib' + +describe('isSectionVisible — plan free', () => { + it('criteres : non visible (detailed_report = false)', () => { + expect(isSectionVisible('free', 'criteres')).toBe(false) + }) + + it('erreurs : non visible (detailed_report = false)', () => { + expect(isSectionVisible('free', 'erreurs')).toBe(false) + }) + + it('modele : non visible (tips = false)', () => { + expect(isSectionVisible('free', 'modele')).toBe(false) + }) + + it('idees : non visible (tips = false)', () => { + expect(isSectionVisible('free', 'idees')).toBe(false) + }) + + it('exercices : non visible (tips = false)', () => { + expect(isSectionVisible('free', 'exercices')).toBe(false) + }) +}) + +describe('isSectionVisible — plan standard', () => { + it('criteres : visible (detailed_report = true)', () => { + expect(isSectionVisible('standard', 'criteres')).toBe(true) + }) + + it('erreurs : visible (detailed_report = true)', () => { + expect(isSectionVisible('standard', 'erreurs')).toBe(true) + }) + + it('modele : visible (tips = true)', () => { + expect(isSectionVisible('standard', 'modele')).toBe(true) + }) + + it('idees : visible (tips = true)', () => { + expect(isSectionVisible('standard', 'idees')).toBe(true) + }) + + it('exercices : visible (tips = true)', () => { + expect(isSectionVisible('standard', 'exercices')).toBe(true) + }) +}) + +describe('isSectionVisible — plan premium', () => { + it('criteres : visible (detailed_report = true)', () => { + expect(isSectionVisible('premium', 'criteres')).toBe(true) + }) + + it('erreurs : visible (detailed_report = true)', () => { + expect(isSectionVisible('premium', 'erreurs')).toBe(true) + }) + + it('modele : visible (tips = true)', () => { + expect(isSectionVisible('premium', 'modele')).toBe(true) + }) + + it('idees : visible (tips = true)', () => { + expect(isSectionVisible('premium', 'idees')).toBe(true) + }) + + it('exercices : visible (tips = true)', () => { + expect(isSectionVisible('premium', 'exercices')).toBe(true) + }) +}) diff --git a/src/entities/report/api.ts b/src/entities/report/api.ts new file mode 100644 index 0000000..187a51e --- /dev/null +++ b/src/entities/report/api.ts @@ -0,0 +1,37 @@ +/** + * Appels API du domaine `report`. + * + * Toutes les requêtes passent par `apiFetch` (Règle J / Règle F). + * Timeout 30 s : DeepSeek (EE) et Gemini (EO) peuvent mettre jusqu'à 20-25 s. + * Retry désactivé : POST non-idempotent — une double soumission créerait + * deux corrections facturées sur le quota Free. + * + * Erreurs notables : SIMULATION_NOT_FOUND (404), AUTH_REQUIRED (401), + * QUOTA_REACHED (403 — côté simulation, pas correction). + */ + +import { apiFetch } from '@/shared/lib/api-client' +import type { CorrectEePayload, CorrectEoPayload, Report } from './types' + +const CORRECTION_TIMEOUT_MS = 30_000 + +/** Soumet une production écrite pour correction. Endpoint : `POST /corrections/ee`. */ +export function correctEe(payload: CorrectEePayload): Promise { + return apiFetch('/corrections/ee', { + method: 'POST', + body: payload, + timeoutMs: CORRECTION_TIMEOUT_MS, + }) +} + +/** + * Soumet une production orale pour correction. Endpoint : `POST /corrections/eo`. + * audio_url : URL pré-signée après upload Supabase Storage (implémenté Sprint 4). + */ +export function correctEo(payload: CorrectEoPayload): Promise { + return apiFetch('/corrections/eo', { + method: 'POST', + body: payload, + timeoutMs: CORRECTION_TIMEOUT_MS, + }) +} diff --git a/src/entities/report/lib.ts b/src/entities/report/lib.ts new file mode 100644 index 0000000..6cc1195 --- /dev/null +++ b/src/entities/report/lib.ts @@ -0,0 +1,34 @@ +/** + * Logique de floutage du domaine `report`. + * + * Import cross-entity volontaire et documenté : + * entities/report → entities/user/lib (hasAccess, Plan) + * Justification : la logique de floutage dépend intrinsèquement des permissions + * utilisateur. Exception validée — cf. ARCHITECTURE.md §3 et étape 13c. + * + * Règle D : aucun `if (plan === 'xxx')` — tout passe par hasAccess(). + * Règle H : cette logique vit ici, jamais dans les composants features/. + */ + +import { hasAccess } from '@/entities/user/lib' +import type { Plan } from '@/entities/user/lib' +import type { BlurableSection } from './types' + +const SECTION_FEATURE: Record = { + criteres: 'detailed_report', + erreurs: 'detailed_report', + modele: 'tips', + idees: 'tips', + exercices: 'tips', +} + +/** + * Indique si une section du rapport est visible pour un plan donné. + * + * @example + * isSectionVisible('free', 'criteres') // false + * isSectionVisible('standard', 'modele') // true + */ +export function isSectionVisible(plan: Plan, section: BlurableSection): boolean { + return hasAccess(plan, SECTION_FEATURE[section]) +} diff --git a/src/entities/report/types.ts b/src/entities/report/types.ts new file mode 100644 index 0000000..19c4465 --- /dev/null +++ b/src/entities/report/types.ts @@ -0,0 +1,62 @@ +/** + * Types publics du domaine `report`. + * + * Un `Report` est retourné par le backend après correction d'une production + * (DeepSeek pour EE, Gemini pour EO). Le backend renvoie directement le payload + * métier — pas d'enveloppe (cf. ARCHITECTURE.md §5). + * + * Visibilité par section selon le plan (cf. PLANS_TARIFAIRES.md §2) : + * - score, nclc, feedback_court → toujours visibles (tous plans) + * - criteres, erreurs → detailed_report (Standard+) + * - modele, idees, exercices → tips (Standard+) + * + * La logique de floutage vit dans lib.ts via isSectionVisible() — jamais dans + * les composants (Règle H). Voir aussi SEC-05 : ces champs contiennent du texte + * IA potentiellement malveillant — les rendre via react-markdown, jamais via + * dangerouslySetInnerHTML. + */ + +export interface Critere { + nom: string + note: number + commentaire: string +} + +export interface Exercice { + titre: string + contenu: string +} + +/** + * Rapport de correction renvoyé par `POST /corrections/ee` ou `POST /corrections/eo`. + * Timeout 30 s (DeepSeek/Gemini — cf. api.ts). + */ +export interface Report { + simulation_id: string + score: number // /20 + nclc: number // estimation NCLC, ex. 7.5 + feedback_court: string // 2-3 lignes — toujours visible + criteres: Critere[] // feature : detailed_report + erreurs: string[] // feature : detailed_report + modele: string // feature : tips — 1re phrase visible pour Free + idees: string[] // feature : tips — 1er item visible pour Free + exercices: Exercice[] // feature : tips — titre visible pour Free +} + +/** Corps de `POST /corrections/ee`. */ +export interface CorrectEePayload { + simulation_id: string + texte: string +} + +/** + * Corps de `POST /corrections/eo`. + * audio_url : URL pré-signée après upload vers Supabase Storage (Sprint 4). + */ +export interface CorrectEoPayload { + simulation_id: string + audio_url: string +} + +/** Sections du rapport dont la visibilité dépend du plan. */ +export type BlurableSection = 'criteres' | 'erreurs' | 'modele' | 'idees' | 'exercices'