// Examens blancs : liste, démarrage d'une tentative, soumission, correction et analyse. import { NextResponse } from "next/server"; import { z } from "zod"; import { apiError, parseBody, requireEnrollment } from "@/lib/api.ts"; import { assertSameOrigin, requireUser } from "@/lib/auth/session.ts"; import { all, get, run } from "@/lib/db/index.ts"; import { gradeAnswer, type QuizQuestion } from "@/lib/learning/quiz.ts"; import { recordMasteryEvent } from "@/lib/learning/mastery.ts"; import { logActivity } from "@/lib/usage.ts"; export async function GET(req: Request) { try { const user = await requireUser(); const url = new URL(req.url); const course = url.searchParams.get("course")?.toUpperCase(); if (course !== "IMM1003" && course !== "IMM1033") return NextResponse.json({ error: "Cours requis." }, { status: 400 }); requireEnrollment(user.id, course); const exams = all<{ id: number; kind: string; title: string; description: string; duration_minutes: number; question_ids: string }>( "SELECT id, kind, title, description, duration_minutes, question_ids FROM mock_exams WHERE course_code = ? ORDER BY kind, id", course ); const attempts = all( `SELECT ea.id, ea.exam_id, ea.mode, ea.started_at, ea.finished_at, ea.score, ea.total FROM exam_attempts ea JOIN mock_exams me ON me.id = ea.exam_id WHERE ea.user_id = ? AND me.course_code = ? ORDER BY ea.id DESC LIMIT 50`, user.id, course ); return NextResponse.json({ exams: exams.map((e) => ({ ...e, questionCount: (JSON.parse(e.question_ids) as number[]).length, question_ids: undefined })), attempts, }); } catch (e) { return apiError(e); } } const startSchema = z.object({ action: z.literal("start"), examId: z.number().int().positive(), mode: z.enum(["timed", "practice"]), }); const submitSchema = z.object({ action: z.literal("submit"), attemptId: z.number().int().positive(), answers: z.record(z.string(), z.string().max(4000)), }); export async function POST(req: Request) { try { await assertSameOrigin(); const user = await requireUser(); const body = await parseBody(req, z.discriminatedUnion("action", [startSchema, submitSchema])); if (body.action === "start") { const exam = get<{ id: number; course_code: string; question_ids: string; duration_minutes: number; title: string }>( "SELECT id, course_code, question_ids, duration_minutes, title FROM mock_exams WHERE id = ?", body.examId ); if (!exam) return NextResponse.json({ error: "Examen introuvable." }, { status: 404 }); requireEnrollment(user.id, exam.course_code); const ids = JSON.parse(exam.question_ids) as number[]; const questions = ids.length ? all(`SELECT * FROM quiz_questions WHERE id IN (${ids.map(() => "?").join(",")})`, ...ids) : []; const ordered = ids.map((id) => questions.find((q) => q.id === id)).filter((q): q is QuizQuestion => !!q); const r = run( "INSERT INTO exam_attempts (user_id, exam_id, mode) VALUES (?, ?, ?)", user.id, exam.id, body.mode ); return NextResponse.json({ attemptId: Number(r.lastInsertRowid), durationMinutes: exam.duration_minutes, title: exam.title, questions: ordered.map((q) => ({ id: q.id, type: q.type, difficulty: q.difficulty, question: q.question, options: JSON.parse(q.options || "[]"), })), }); } // submit const attempt = get<{ id: number; user_id: number; exam_id: number; finished_at: string | null; mode: string; started_at: string }>( "SELECT * FROM exam_attempts WHERE id = ?", body.attemptId ); if (!attempt || attempt.user_id !== user.id) return NextResponse.json({ error: "Tentative introuvable." }, { status: 404 }); if (attempt.finished_at) return NextResponse.json({ error: "Tentative déjà soumise." }, { status: 409 }); const exam = get<{ course_code: string; question_ids: string; title: string }>( "SELECT course_code, question_ids, title FROM mock_exams WHERE id = ?", attempt.exam_id )!; const ids = JSON.parse(exam.question_ids) as number[]; const questions = all(`SELECT * FROM quiz_questions WHERE id IN (${ids.map(() => "?").join(",")})`, ...ids); let score = 0; const detail: Record = {}; const byConcept = new Map(); const byAxis = new Map(); for (const q of questions) { const ua = body.answers[String(q.id)] ?? ""; const correct = ua ? gradeAnswer(q, ua) : false; if (correct) score++; detail[String(q.id)] = { answer: ua, correct, expected: q.answer, explanation: q.explanation }; const c = q.concept_id ? get<{ name: string; axis: string }>("SELECT name, axis FROM concepts WHERE id = ?", q.concept_id) : null; const key = c?.name ?? "Divers"; const e = byConcept.get(key) ?? { name: key, correct: 0, total: 0, conceptId: q.concept_id }; e.total++; if (correct) e.correct++; byConcept.set(key, e); const axisKey = c?.axis ?? "connaissances"; const ax = byAxis.get(axisKey) ?? { correct: 0, total: 0 }; ax.total++; if (correct) ax.correct++; byAxis.set(axisKey, ax); if (q.concept_id) { recordMasteryEvent({ userId: user.id, conceptId: q.concept_id, kind: "exam", correct, difficulty: q.difficulty }); } if (!correct && ua) { run( `INSERT INTO error_notebook (user_id, course_code, concept_id, question, given_answer, correction, explanation, source) VALUES (?, ?, ?, ?, ?, ?, ?, 'exam')`, user.id, exam.course_code, q.concept_id, q.question, ua.slice(0, 2000), q.answer, q.explanation ); } } const weakest = [...byConcept.values()].filter((c) => c.total >= 1).sort((a, b) => a.correct / a.total - b.correct / b.total).slice(0, 3); const analysis = { byConcept: [...byConcept.values()], byAxis: [...byAxis.entries()].map(([axis, v]) => ({ axis, ...v })), weakest: weakest.map((w) => w.name), recommendation: weakest.length ? `Priorités de révision : ${weakest.map((w) => w.name).join(", ")}. Refaites un quiz ciblé sur chacune, puis retentez un examen thématique.` : "Excellente performance — passez au niveau supérieur avec le mode Défi du chat.", }; run( "UPDATE exam_attempts SET finished_at = datetime('now'), answers = ?, score = ?, total = ?, analysis = ? WHERE id = ?", JSON.stringify(detail), score, questions.length, JSON.stringify(analysis), attempt.id ); logActivity(user.id, "exam", exam.course_code, questions.length * 90, { attemptId: attempt.id, score, total: questions.length }); const previous = all<{ score: number; total: number; finished_at: string }>( "SELECT score, total, finished_at FROM exam_attempts WHERE user_id = ? AND exam_id = ? AND finished_at IS NOT NULL AND id != ? ORDER BY id DESC LIMIT 5", user.id, attempt.exam_id, attempt.id ); return NextResponse.json({ ok: true, score, total: questions.length, detail, analysis, previous }); } catch (e) { return apiError(e); } }