feat(simulations): persistance session — autosave + sujet_id + getById tolère rapport=null (FTD-21)
This commit is contained in:
parent
fc76fac981
commit
fcd8fe7017
3 changed files with 533 additions and 77 deletions
|
|
@ -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 () => {
|
||||
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',
|
||||
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({ 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')
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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 } }
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue