feat(entities): production + report — types, lib, api, tests floutage (Sprint 3 étape 13)
This commit is contained in:
parent
ca4291d7eb
commit
b31e8666a5
8 changed files with 301 additions and 0 deletions
|
|
@ -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
|
||||
|
|
|
|||
22
src/entities/production/api.ts
Normal file
22
src/entities/production/api.ts
Normal 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}`)
|
||||
}
|
||||
29
src/entities/production/lib.ts
Normal file
29
src/entities/production/lib.ts
Normal 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_')
|
||||
}
|
||||
35
src/entities/production/types.ts
Normal file
35
src/entities/production/types.ts
Normal 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
|
||||
}
|
||||
78
src/entities/report/__tests__/floutage.test.ts
Normal file
78
src/entities/report/__tests__/floutage.test.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
37
src/entities/report/api.ts
Normal file
37
src/entities/report/api.ts
Normal 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,
|
||||
})
|
||||
}
|
||||
34
src/entities/report/lib.ts
Normal file
34
src/entities/report/lib.ts
Normal 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])
|
||||
}
|
||||
62
src/entities/report/types.ts
Normal file
62
src/entities/report/types.ts
Normal 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'
|
||||
Loading…
Add table
Add a link
Reference in a new issue