feat(entities): production + report — types, lib, api, tests floutage (Sprint 3 étape 13)

This commit is contained in:
Hermann_Kitio 2026-04-19 03:37:41 +03:00
parent ca4291d7eb
commit b31e8666a5
8 changed files with 301 additions and 0 deletions

View file

@ -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

View file

@ -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<Production> {
return apiFetch<Production>('/simulations', { method: 'POST', body: payload })
}
/** Récupère une simulation existante. Endpoint : `GET /simulations/:id`. */
export function getSimulation(id: string): Promise<Production> {
return apiFetch<Production>(`/simulations/${id}`)
}

View file

@ -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<Tache, string> = {
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_')
}

View file

@ -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
}

View file

@ -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)
})
})

View file

@ -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<Report> {
return apiFetch<Report>('/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<Report> {
return apiFetch<Report>('/corrections/eo', {
method: 'POST',
body: payload,
timeoutMs: CORRECTION_TIMEOUT_MS,
})
}

View file

@ -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<BlurableSection, 'detailed_report' | 'tips'> = {
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])
}

View file

@ -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'