From fcd8fe7017011495db982c46231c84b5bb2d3ac2 Mon Sep 17 00:00:00 2001 From: Hermann_Kitio Date: Tue, 21 Apr 2026 03:48:45 +0300 Subject: [PATCH] =?UTF-8?q?feat(simulations):=20persistance=20session=20?= =?UTF-8?q?=E2=80=94=20autosave=20+=20sujet=5Fid=20+=20getById=20tol=C3=A8?= =?UTF-8?q?re=20rapport=3Dnull=20(FTD-21)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../__tests__/simulationController.test.ts | 313 +++++++++++++++--- src/controllers/simulationController.ts | 227 +++++++++++-- src/routes/simulations.ts | 70 ++++ 3 files changed, 533 insertions(+), 77 deletions(-) diff --git a/src/controllers/__tests__/simulationController.test.ts b/src/controllers/__tests__/simulationController.test.ts index 8142013..c8bdbe5 100644 --- a/src/controllers/__tests__/simulationController.test.ts +++ b/src/controllers/__tests__/simulationController.test.ts @@ -66,7 +66,7 @@ function mockProductionSelect(data: unknown, error: unknown = null) { } as any) } -/** Mock from('sujets').select(...).eq(...).eq(...).eq(...) pour create */ +/** Mock from('sujets').select(...).eq(...).eq(...).eq(...) pour create (liste filtrée) */ function mockSujets(rows: unknown[]) { vi.mocked(supabase.from).mockReturnValueOnce({ select: vi.fn(() => ({ @@ -79,6 +79,26 @@ function mockSujets(rows: unknown[]) { } as any) } +/** Mock from('sujets').select(...).eq(...).single() pour getById/updateSujet */ +function mockSujetById(data: unknown, error: unknown = null) { + vi.mocked(supabase.from).mockReturnValueOnce({ + select: vi.fn(() => ({ + eq: vi.fn(() => ({ + single: vi.fn(() => ({ data, error })), + })), + })), + } as any) +} + +/** Mock from('productions').update(...).eq(...) pour autosave/updateSujet */ +function mockUpdate(error: unknown = null) { + vi.mocked(supabase.from).mockReturnValueOnce({ + update: vi.fn(() => ({ + eq: vi.fn(() => ({ error })), + })), + } as any) +} + const MOCK_SUJET_EE_T1 = { id: 'sujet-1', consigne: 'Écrivez un texte argumentatif sur le télétravail.', @@ -117,8 +137,8 @@ describe('POST /simulations', () => { it('free + 4 simulations utilisées → création OK, pas d\'incrément (il a lieu après correction)', async () => { const profile = buildProfile({ plan: 'free', simulations_used: 4 }) mockAuth(profile) - mockInsert({ id: 'prod-1', tache: 'EE_T1', mode: 'entrainement', created_at: '2024-01-01T00:00:00Z' }) mockSujets([MOCK_SUJET_EE_T1]) + mockInsert({ id: 'prod-1', tache: 'EE_T1', mode: 'entrainement', created_at: '2024-01-01T00:00:00Z' }) const app = createApp() const res = await app.request('/simulations', { @@ -133,9 +153,10 @@ describe('POST /simulations', () => { expect(body.tache).toBe('EE_T1') expect(body.mode).toBe('entrainement') expect(body.sujet).toEqual(MOCK_SUJET_EE_T1) - // 3 appels from : profiles (auth) + productions (insert) + sujets (select) + // 3 appels from : profiles (auth) + sujets (select) + productions (insert) expect(vi.mocked(supabase.from).mock.calls).toHaveLength(3) - expect(vi.mocked(supabase.from).mock.calls[2][0]).toBe('sujets') + expect(vi.mocked(supabase.from).mock.calls[1][0]).toBe('sujets') + expect(vi.mocked(supabase.from).mock.calls[2][0]).toBe('productions') }) it('free + 5 simulations utilisées → QUOTA_REACHED', async () => { @@ -160,9 +181,8 @@ describe('POST /simulations', () => { it('standard + 999 simulations → création OK, simulations_used NON incrémenté', async () => { const profile = buildProfile({ plan: 'standard', simulations_used: 999 }) mockAuth(profile) - mockInsert({ id: 'prod-2', tache: 'EE_T2', mode: 'entrainement', created_at: '2024-01-01T00:00:00Z' }) mockSujets([MOCK_SUJET_EE_T2]) - // Pas de mockUpdate : standard n'a pas de limite + mockInsert({ id: 'prod-2', tache: 'EE_T2', mode: 'entrainement', created_at: '2024-01-01T00:00:00Z' }) const app = createApp() const res = await app.request('/simulations', { @@ -175,9 +195,10 @@ describe('POST /simulations', () => { const body = await res.json() expect(body.id).toBe('prod-2') expect(body.sujet).toEqual(MOCK_SUJET_EE_T2) - // 3 appels from : profiles (auth) + productions (insert) + sujets (select) — pas de update + // 3 appels from : profiles (auth) + sujets (select) + productions (insert) expect(vi.mocked(supabase.from).mock.calls).toHaveLength(3) - expect(vi.mocked(supabase.from).mock.calls[2][0]).toBe('sujets') + expect(vi.mocked(supabase.from).mock.calls[1][0]).toBe('sujets') + expect(vi.mocked(supabase.from).mock.calls[2][0]).toBe('productions') }) it('premium + EO_T2_LIVE → création OK, sujet: null (pas de lookup)', async () => { @@ -185,7 +206,6 @@ describe('POST /simulations', () => { mockAuth(profile) mockInsert({ id: 'prod-3', tache: 'EO_T2_LIVE', mode: 'entrainement', created_at: '2024-01-01T00:00:00Z' }) // Pas de mockSujets : EO_T2_LIVE skip la query sujets - // Pas de mockUpdate : premium n'a pas de limite const app = createApp() const res = await app.request('/simulations', { @@ -199,7 +219,7 @@ describe('POST /simulations', () => { expect(body.id).toBe('prod-3') expect(body.tache).toBe('EO_T2_LIVE') expect(body.sujet).toBeNull() - // 2 appels from : profiles (auth) + productions (insert) — pas de sujets, pas de update + // 2 appels from : profiles (auth) + productions (insert) — pas de sujets expect(vi.mocked(supabase.from).mock.calls).toHaveLength(2) }) @@ -240,8 +260,8 @@ describe('POST /simulations', () => { it('aucun sujet actif trouvé → création OK avec sujet: null (non bloquant)', async () => { const profile = buildProfile({ plan: 'standard', simulations_used: 10 }) mockAuth(profile) - mockInsert({ id: 'prod-4', tache: 'EE_T3', mode: 'entrainement', created_at: '2024-01-01T00:00:00Z' }) mockSujets([]) // table vide pour ce filtre + mockInsert({ id: 'prod-4', tache: 'EE_T3', mode: 'entrainement', created_at: '2024-01-01T00:00:00Z' }) const app = createApp() const res = await app.request('/simulations', { @@ -255,7 +275,7 @@ describe('POST /simulations', () => { expect(body.id).toBe('prod-4') expect(body.sujet).toBeNull() expect(vi.mocked(supabase.from).mock.calls).toHaveLength(3) - expect(vi.mocked(supabase.from).mock.calls[2][0]).toBe('sujets') + expect(vi.mocked(supabase.from).mock.calls[1][0]).toBe('sujets') }) it('pick aléatoire parmi plusieurs sujets actifs', async () => { @@ -266,8 +286,8 @@ describe('POST /simulations', () => { { ...MOCK_SUJET_EE_T1, id: 'sujet-c' }, ] mockAuth(profile) - mockInsert({ id: 'prod-5', tache: 'EE_T1', mode: 'entrainement', created_at: '2024-01-01T00:00:00Z' }) mockSujets(candidates) + mockInsert({ id: 'prod-5', tache: 'EE_T1', mode: 'entrainement', created_at: '2024-01-01T00:00:00Z' }) const app = createApp() const res = await app.request('/simulations', { @@ -304,7 +324,7 @@ describe('GET /simulations/:id', () => { exercices: ['exo 1'], } - it('OK : rapport trouvé et appartenant à l\'utilisateur → 200 avec simulation_id', async () => { + it('OK (avec sujet) : rapport trouvé, appartenant à l\'utilisateur → 200', async () => { const profile = buildProfile({ id: 'user-123', plan: 'standard' }) mockAuth(profile) mockProductionSelect({ @@ -312,11 +332,12 @@ describe('GET /simulations/:id', () => { user_id: 'user-123', tache: 'EE_T1', mode: 'entrainement', - score: 14, - nclc: 8, + contenu: 'Mon texte en cours.', + sujet_id: 'sujet-1', rapport: JSON.stringify(VALID_RAPPORT), created_at: '2024-01-01T00:00:00Z', }) + mockSujetById(MOCK_SUJET_EE_T1) const app = createApp() const res = await app.request('/simulations/prod-42', { @@ -329,14 +350,65 @@ describe('GET /simulations/:id', () => { expect(body.tache).toBe('EE_T1') expect(body.mode).toBe('entrainement') expect(body.created_at).toBe('2024-01-01T00:00:00Z') - expect(body.score).toBe(14) - expect(body.nclc).toBe(8) - expect(body.feedback_court).toBe('Bonne production générale.') - expect(body.criteres).toHaveLength(4) - expect(body.erreurs).toEqual(['erreur 1']) - expect(body.modele).toBe('Texte modèle.') - expect(body.idees).toEqual(['idée 1']) - expect(body.exercices).toEqual(['exo 1']) + expect(body.contenu).toBe('Mon texte en cours.') + expect(body.sujet).toEqual(MOCK_SUJET_EE_T1) + expect(body.rapport).toEqual(VALID_RAPPORT) + }) + + it('OK (sans sujet_id) : production sans sujet → sujet: null', async () => { + const profile = buildProfile({ id: 'user-123' }) + mockAuth(profile) + mockProductionSelect({ + id: 'prod-42', + user_id: 'user-123', + tache: 'EO_T2_LIVE', + mode: 'entrainement', + contenu: null, + sujet_id: null, + rapport: JSON.stringify(VALID_RAPPORT), + created_at: '2024-01-01T00:00:00Z', + }) + + const app = createApp() + const res = await app.request('/simulations/prod-42', { + headers: { Authorization: 'Bearer token' }, + }) + + expect(res.status).toBe(200) + const body = await res.json() + expect(body.sujet).toBeNull() + expect(body.contenu).toBeNull() + expect(body.rapport).toEqual(VALID_RAPPORT) + // Pas d'appel from('sujets') : sujet_id null + expect(vi.mocked(supabase.from).mock.calls).toHaveLength(2) + }) + + it('FTD-21 — simulation en cours : rapport=null retourné avec contenu + sujet (resume)', async () => { + const profile = buildProfile({ id: 'user-123' }) + mockAuth(profile) + mockProductionSelect({ + id: 'prod-42', + user_id: 'user-123', + tache: 'EE_T1', + mode: 'entrainement', + contenu: 'Brouillon en cours.', + sujet_id: 'sujet-1', + rapport: null, + created_at: '2024-01-01T00:00:00Z', + }) + mockSujetById(MOCK_SUJET_EE_T1) + + const app = createApp() + const res = await app.request('/simulations/prod-42', { + headers: { Authorization: 'Bearer token' }, + }) + + expect(res.status).toBe(200) + const body = await res.json() + expect(body.simulation_id).toBe('prod-42') + expect(body.contenu).toBe('Brouillon en cours.') + expect(body.sujet).toEqual(MOCK_SUJET_EE_T1) + expect(body.rapport).toBeNull() }) it('SIMULATION_NOT_FOUND : id inexistant → 404', async () => { @@ -363,8 +435,8 @@ describe('GET /simulations/:id', () => { user_id: 'another-user-456', tache: 'EE_T1', mode: 'entrainement', - score: 14, - nclc: 8, + contenu: null, + sujet_id: null, rapport: JSON.stringify(VALID_RAPPORT), created_at: '2024-01-01T00:00:00Z', }) @@ -379,29 +451,188 @@ describe('GET /simulations/:id', () => { expect(body.error).toBe(true) expect(body.code).toBe('AUTH_REQUIRED') }) +}) - it('REPORT_NOT_READY : rapport === null → 404', async () => { +describe('PATCH /simulations/:id/contenu — FTD-21 autosave', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('succès : contenu sauvegardé → 200 { ok: true }', async () => { const profile = buildProfile({ id: 'user-123' }) mockAuth(profile) - mockProductionSelect({ - id: 'prod-42', - user_id: 'user-123', - tache: 'EE_T1', - mode: 'entrainement', - score: null, - nclc: null, - rapport: null, - created_at: '2024-01-01T00:00:00Z', - }) + mockProductionSelect({ user_id: 'user-123', rapport: null }) + mockUpdate() const app = createApp() - const res = await app.request('/simulations/prod-42', { - headers: { Authorization: 'Bearer token' }, + const res = await app.request('/simulations/prod-42/contenu', { + method: 'PATCH', + headers: { Authorization: 'Bearer token', 'Content-Type': 'application/json' }, + body: JSON.stringify({ contenu: 'Mon brouillon.' }), + }) + + expect(res.status).toBe(200) + const body = await res.json() + expect(body.ok).toBe(true) + }) + + it('AUTH_REQUIRED : simulation appartient à un autre user → 401', async () => { + const profile = buildProfile({ id: 'user-123' }) + mockAuth(profile) + mockProductionSelect({ user_id: 'another-user', rapport: null }) + + const app = createApp() + const res = await app.request('/simulations/prod-42/contenu', { + method: 'PATCH', + headers: { Authorization: 'Bearer token', 'Content-Type': 'application/json' }, + body: JSON.stringify({ contenu: 'Texte.' }), + }) + + expect(res.status).toBe(401) + const body = await res.json() + expect(body.code).toBe('AUTH_REQUIRED') + }) + + it('SIMULATION_NOT_FOUND : id inexistant → 404', async () => { + const profile = buildProfile({ id: 'user-123' }) + mockAuth(profile) + mockProductionSelect(null, { message: 'No rows' }) + + const app = createApp() + const res = await app.request('/simulations/prod-42/contenu', { + method: 'PATCH', + headers: { Authorization: 'Bearer token', 'Content-Type': 'application/json' }, + body: JSON.stringify({ contenu: 'Texte.' }), }) expect(res.status).toBe(404) const body = await res.json() - expect(body.error).toBe(true) - expect(body.code).toBe('REPORT_NOT_READY') + expect(body.code).toBe('SIMULATION_NOT_FOUND') + }) + + it('VALIDATION_ERROR : contenu > 5000 caractères → 400', async () => { + const profile = buildProfile({ id: 'user-123' }) + mockAuth(profile) + // Pas besoin de mocks supabase : la validation bloque avant tout accès DB + + const app = createApp() + const res = await app.request('/simulations/prod-42/contenu', { + method: 'PATCH', + headers: { Authorization: 'Bearer token', 'Content-Type': 'application/json' }, + body: JSON.stringify({ contenu: 'a'.repeat(5001) }), + }) + + expect(res.status).toBe(400) + const body = await res.json() + expect(body.code).toBe('VALIDATION_ERROR') + }) + + it('VALIDATION_ERROR : simulation déjà corrigée → 400', async () => { + const profile = buildProfile({ id: 'user-123' }) + mockAuth(profile) + mockProductionSelect({ user_id: 'user-123', rapport: '{"score":14}' }) + + const app = createApp() + const res = await app.request('/simulations/prod-42/contenu', { + method: 'PATCH', + headers: { Authorization: 'Bearer token', 'Content-Type': 'application/json' }, + body: JSON.stringify({ contenu: 'Texte.' }), + }) + + expect(res.status).toBe(400) + const body = await res.json() + expect(body.code).toBe('VALIDATION_ERROR') + }) + + it('VALIDATION_ERROR : body.contenu manquant → 400', async () => { + const profile = buildProfile({ id: 'user-123' }) + mockAuth(profile) + + const app = createApp() + const res = await app.request('/simulations/prod-42/contenu', { + method: 'PATCH', + headers: { Authorization: 'Bearer token', 'Content-Type': 'application/json' }, + body: JSON.stringify({}), + }) + + expect(res.status).toBe(400) + const body = await res.json() + expect(body.code).toBe('VALIDATION_ERROR') + }) +}) + +describe('PATCH /simulations/:id/sujet — FTD-21 changement de sujet', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('succès : sujet mis à jour → 200 avec sujet complet', async () => { + const profile = buildProfile({ id: 'user-123' }) + mockAuth(profile) + mockSujetById(MOCK_SUJET_EE_T1) + mockProductionSelect({ user_id: 'user-123', rapport: null }) + mockUpdate() + + const app = createApp() + const res = await app.request('/simulations/prod-42/sujet', { + method: 'PATCH', + headers: { Authorization: 'Bearer token', 'Content-Type': 'application/json' }, + body: JSON.stringify({ sujet_id: 'sujet-1' }), + }) + + expect(res.status).toBe(200) + const body = await res.json() + expect(body.sujet).toEqual(MOCK_SUJET_EE_T1) + }) + + it('SUJET_NOT_FOUND : sujet_id inexistant → 404', async () => { + const profile = buildProfile({ id: 'user-123' }) + mockAuth(profile) + mockSujetById(null, { message: 'No rows' }) + + const app = createApp() + const res = await app.request('/simulations/prod-42/sujet', { + method: 'PATCH', + headers: { Authorization: 'Bearer token', 'Content-Type': 'application/json' }, + body: JSON.stringify({ sujet_id: 'does-not-exist' }), + }) + + expect(res.status).toBe(404) + const body = await res.json() + expect(body.code).toBe('SUJET_NOT_FOUND') + }) + + it('AUTH_REQUIRED : simulation appartient à un autre user → 401', async () => { + const profile = buildProfile({ id: 'user-123' }) + mockAuth(profile) + mockSujetById(MOCK_SUJET_EE_T1) + mockProductionSelect({ user_id: 'another-user', rapport: null }) + + const app = createApp() + const res = await app.request('/simulations/prod-42/sujet', { + method: 'PATCH', + headers: { Authorization: 'Bearer token', 'Content-Type': 'application/json' }, + body: JSON.stringify({ sujet_id: 'sujet-1' }), + }) + + expect(res.status).toBe(401) + const body = await res.json() + expect(body.code).toBe('AUTH_REQUIRED') + }) + + it('VALIDATION_ERROR : sujet_id manquant → 400', async () => { + const profile = buildProfile({ id: 'user-123' }) + mockAuth(profile) + + const app = createApp() + const res = await app.request('/simulations/prod-42/sujet', { + method: 'PATCH', + headers: { Authorization: 'Bearer token', 'Content-Type': 'application/json' }, + body: JSON.stringify({}), + }) + + expect(res.status).toBe(400) + const body = await res.json() + expect(body.code).toBe('VALIDATION_ERROR') }) }) diff --git a/src/controllers/simulationController.ts b/src/controllers/simulationController.ts index c3d4401..87c2c0b 100644 --- a/src/controllers/simulationController.ts +++ b/src/controllers/simulationController.ts @@ -75,29 +75,8 @@ export async function create( } } - // 2. Insérer dans productions - const { data, error } = await supabase - .from('productions') - .insert({ - user_id: profile.id, - tache: body.tache, - mode: body.mode, - contenu: body.contenu ?? null, - }) - .select('id, tache, mode, created_at') - .single() - - if (error || !data) { - return { - error: true, - code: 'INTERNAL_ERROR', - message: 'Une erreur est survenue. Veuillez réessayer dans quelques instants.', - status: 500, - } - } - - // 3. Fetch un sujet aléatoire (non bloquant — sujet: null si introuvable). - // TODO: migrer vers une RPC PostgreSQL si la table sujets dépasse quelques centaines de lignes. + // 2. Fetch un sujet aléatoire AVANT l'insert pour persister sujet_id en une seule requête. + // (non bloquant — sujet: null si introuvable). const sujetParams = mapTacheToSujetParams(body.tache) let sujet: SujetData | null = null if (sujetParams) { @@ -113,6 +92,28 @@ export async function create( } } + // 3. Insérer dans productions avec sujet_id (FTD-21 — persistance pour resume). + const { data, error } = await supabase + .from('productions') + .insert({ + user_id: profile.id, + tache: body.tache, + mode: body.mode, + contenu: body.contenu ?? null, + sujet_id: sujet?.id ?? null, + }) + .select('id, tache, mode, created_at') + .single() + + if (error || !data) { + return { + error: true, + code: 'INTERNAL_ERROR', + message: 'Une erreur est survenue. Veuillez réessayer dans quelques instants.', + status: 500, + } + } + return { data: { id: data.id, @@ -126,14 +127,22 @@ export async function create( // EERapport et EORapport ont la même structure depuis l'étape A — // on utilise EERapport comme représentation canonique du rapport parsé. -export type GetByIdResult = EERapport & { +// +// FTD-21 : rapport peut être null (simulation en cours, pas encore corrigée). +// Le frontend distingue : +// - rapport !== null → RapportPage affiche la correction +// - rapport === null → SimulationFlowProvider restaure la session (resume) +export interface GetByIdResult { simulation_id: string tache: Tache mode: Mode created_at: string + contenu: string | null + sujet: SujetData | null + rapport: EERapport | null } -type GetByIdError = { +type ControllerError = { error: true code: string message: string @@ -143,10 +152,10 @@ type GetByIdError = { export async function getById( id: string, profile: AuthProfile -): Promise<{ data: GetByIdResult } | GetByIdError> { +): Promise<{ data: GetByIdResult } | ControllerError> { const { data, error } = await supabase .from('productions') - .select('id, user_id, tache, mode, score, nclc, rapport, created_at') + .select('id, user_id, tache, mode, contenu, sujet_id, rapport, created_at') .eq('id', id) .single() @@ -168,24 +177,170 @@ export async function getById( } } - if (data.rapport === null) { - return { - error: true, - code: 'REPORT_NOT_READY', - message: "Le rapport n'est pas encore disponible pour cette simulation.", - status: 404, - } + // Charger le sujet si présent (FTD-21 — restore complet de la session). + let sujet: SujetData | null = null + if (data.sujet_id) { + const { data: sujetRow } = await supabase + .from('sujets') + .select('id, consigne, role, contexte, doc1_titre, doc1_texte, doc2_titre, doc2_texte') + .eq('id', data.sujet_id) + .single() + if (sujetRow) sujet = sujetRow as SujetData } - const rapport = JSON.parse(data.rapport) as EERapport + const rapport = data.rapport ? (JSON.parse(data.rapport) as EERapport) : null return { data: { - ...rapport, simulation_id: data.id, tache: data.tache as Tache, mode: data.mode as Mode, created_at: data.created_at, + contenu: data.contenu ?? null, + sujet, + rapport, }, } } + +/** + * FTD-21 — autosave du contenu d'une simulation en cours. + * Refuse si la simulation est déjà corrigée (rapport !== null). + */ +export async function autosaveContenu( + id: string, + userId: string, + contenu: string +): Promise<{ data: { ok: true } } | ControllerError> { + if (contenu.length > 5000) { + return { + error: true, + code: 'VALIDATION_ERROR', + message: 'Le texte ne doit pas dépasser 5 000 caractères.', + status: 400, + } + } + + const { data: prod, error } = await supabase + .from('productions') + .select('user_id, rapport') + .eq('id', id) + .single() + + if (error || !prod) { + return { + error: true, + code: 'SIMULATION_NOT_FOUND', + message: 'Simulation introuvable.', + status: 404, + } + } + + if (prod.user_id !== userId) { + return { + error: true, + code: 'AUTH_REQUIRED', + message: 'Cette simulation ne vous appartient pas.', + status: 401, + } + } + + if (prod.rapport !== null) { + return { + error: true, + code: 'VALIDATION_ERROR', + message: 'Cette simulation a déjà été corrigée.', + status: 400, + } + } + + const { error: updateError } = await supabase + .from('productions') + .update({ contenu }) + .eq('id', id) + + if (updateError) { + return { + error: true, + code: 'INTERNAL_ERROR', + message: 'Sauvegarde impossible. Réessayez dans quelques instants.', + status: 500, + } + } + + return { data: { ok: true } } +} + +/** + * FTD-21 — met à jour le sujet d'une simulation en cours. + * Vérifie que le sujet existe et que la simulation n'est pas corrigée. + */ +export async function updateSujet( + id: string, + userId: string, + sujetId: string +): Promise<{ data: { sujet: SujetData } } | ControllerError> { + const { data: sujetRow, error: sujetError } = await supabase + .from('sujets') + .select('id, consigne, role, contexte, doc1_titre, doc1_texte, doc2_titre, doc2_texte') + .eq('id', sujetId) + .single() + + if (sujetError || !sujetRow) { + return { + error: true, + code: 'SUJET_NOT_FOUND', + message: 'Sujet introuvable.', + status: 404, + } + } + + const { data: prod, error } = await supabase + .from('productions') + .select('user_id, rapport') + .eq('id', id) + .single() + + if (error || !prod) { + return { + error: true, + code: 'SIMULATION_NOT_FOUND', + message: 'Simulation introuvable.', + status: 404, + } + } + + if (prod.user_id !== userId) { + return { + error: true, + code: 'AUTH_REQUIRED', + message: 'Cette simulation ne vous appartient pas.', + status: 401, + } + } + + if (prod.rapport !== null) { + return { + error: true, + code: 'VALIDATION_ERROR', + message: 'Cette simulation a déjà été corrigée.', + status: 400, + } + } + + const { error: updateError } = await supabase + .from('productions') + .update({ sujet_id: sujetId }) + .eq('id', id) + + if (updateError) { + return { + error: true, + code: 'INTERNAL_ERROR', + message: 'Mise à jour impossible. Réessayez dans quelques instants.', + status: 500, + } + } + + return { data: { sujet: sujetRow as SujetData } } +} diff --git a/src/routes/simulations.ts b/src/routes/simulations.ts index 2202122..2f26b27 100644 --- a/src/routes/simulations.ts +++ b/src/routes/simulations.ts @@ -92,4 +92,74 @@ simulations.get('/:id', authMiddleware, async (c) => { return c.json(result.data, 200) }) +// FTD-21 — autosave du contenu d'une simulation en cours. +simulations.patch('/:id/contenu', authMiddleware, async (c) => { + const id = c.req.param('id')! + + let body: { contenu?: unknown } + try { + body = await c.req.json() + } catch { + return c.json( + { error: true, code: 'VALIDATION_ERROR', message: 'Corps de la requête invalide.' }, + 400 + ) + } + + if (typeof body.contenu !== 'string') { + return c.json( + { + error: true, + code: 'VALIDATION_ERROR', + message: 'Le champ `contenu` est requis et doit être une chaîne.', + }, + 400 + ) + } + + const profile = c.get('profile') + const result = await simulationController.autosaveContenu(id, profile.id, body.contenu) + + if ('error' in result) { + return c.json(result, result.status as 400 | 401 | 404 | 500) + } + + return c.json(result.data, 200) +}) + +// FTD-21 — met à jour le sujet d'une simulation en cours. +simulations.patch('/:id/sujet', authMiddleware, async (c) => { + const id = c.req.param('id')! + + let body: { sujet_id?: unknown } + try { + body = await c.req.json() + } catch { + return c.json( + { error: true, code: 'VALIDATION_ERROR', message: 'Corps de la requête invalide.' }, + 400 + ) + } + + if (!body.sujet_id || typeof body.sujet_id !== 'string') { + return c.json( + { + error: true, + code: 'VALIDATION_ERROR', + message: 'Le champ `sujet_id` est requis et doit être une chaîne.', + }, + 400 + ) + } + + const profile = c.get('profile') + const result = await simulationController.updateSujet(id, profile.id, body.sujet_id) + + if ('error' in result) { + return c.json(result, result.status as 400 | 401 | 404 | 500) + } + + return c.json(result.data, 200) +}) + export default simulations