'use strict'; const router = require('express').Router(); const db = require('../db/db'); const { authMiddleware } = require('../middleware/auth'); router.use(authMiddleware); /* ── Statements (prepared once) ────────────────────────────────── */ const SQL = { listTracks: db.prepare(` SELECT exam_key, title, subject_slug, grade, duration_min, tasks_per_variant, variants_count, intro_html, sort_order FROM exam_tracks WHERE enabled = 1 ORDER BY sort_order, exam_key `), getTrack: db.prepare(` SELECT exam_key, title, subject_slug, grade, duration_min, tasks_per_variant, variants_count, scoring_json, intro_html FROM exam_tracks WHERE exam_key = ? AND enabled = 1 `), countTasks: db.prepare(` SELECT COUNT(*) AS total, COALESCE(SUM(CASE WHEN task_type = 'mc' THEN 1 ELSE 0 END), 0) AS mc, COALESCE(SUM(CASE WHEN task_type = 'open' THEN 1 ELSE 0 END), 0) AS open, COALESCE(SUM(CASE WHEN task_type = 'long' THEN 1 ELSE 0 END), 0) AS long FROM exam_tasks WHERE exam_key = ? `), /* For each (user, task) — was the most-recent attempt correct? A task counts as «solved» iff at least one historical attempt is_correct = 1. */ userProgress: db.prepare(` SELECT COUNT(DISTINCT exam_task_id) AS tasks_attempted, COUNT(DISTINCT CASE WHEN is_correct = 1 THEN exam_task_id END) AS tasks_solved, COUNT(*) AS total_attempts, COALESCE(SUM(CASE WHEN is_correct = 1 THEN 1 ELSE 0 END), 0) AS correct_attempts FROM exam_attempts a JOIN exam_tasks t ON t.id = a.exam_task_id WHERE a.user_id = ? AND t.exam_key = ? `), /* Variants list + per-user counts. One row per variant. - total: tasks in variant - solved: distinct tasks where user has ≥1 correct attempt - viewed_sol: distinct tasks where user opened the solution */ listVariants: db.prepare(` SELECT t.variant, COUNT(*) AS total, COUNT(DISTINCT CASE WHEN a.is_correct = 1 THEN t.id END) AS solved, COUNT(DISTINCT CASE WHEN a.solution_viewed = 1 THEN t.id END) AS viewed_sol FROM exam_tasks t LEFT JOIN exam_attempts a ON a.exam_task_id = t.id AND a.user_id = ? WHERE t.exam_key = ? GROUP BY t.variant ORDER BY t.variant `), getVariantTasks: db.prepare(` SELECT id, task_idx, task_type, text_html, figure_html, opts_json, answer, solution_html, topic, subtopic FROM exam_tasks WHERE exam_key = ? AND variant = ? ORDER BY task_idx `), /* For attempt save: confirm the task belongs to a known track + user */ getTaskExamKey: db.prepare(`SELECT exam_key FROM exam_tasks WHERE id = ?`), insertAttempt: db.prepare(` INSERT INTO exam_attempts (user_id, exam_task_id, user_answer, is_correct, time_ms, mode, session_id, hint_used, solution_viewed, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) `), }; /* ── GET /api/exam-prep/tracks ── Public list of enabled exam tracks (for a future landing page). */ router.get('/tracks', (_req, res) => { const tracks = SQL.listTracks.all(); res.json({ tracks }); }); /* ── GET /api/exam-prep/:examKey/info ── Track metadata + global counts + this user's aggregate progress. */ // @public-by-design: router-level authMiddleware (line 6) covers this route router.get('/:examKey/info', (req, res) => { const { examKey } = req.params; const track = SQL.getTrack.get(examKey); if (!track) return res.status(404).json({ error: 'Unknown exam track' }); const counts = SQL.countTasks.get(examKey); const progress = SQL.userProgress.get(req.user.id, examKey); // Parse scoring grid (JSON in DB). Tolerant of malformed values. let scoring = null; if (track.scoring_json) { try { scoring = JSON.parse(track.scoring_json); } catch { scoring = null; } } res.json({ track: { exam_key: track.exam_key, title: track.title, subject: track.subject_slug, grade: track.grade, duration_min: track.duration_min, tasks_per_variant: track.tasks_per_variant, variants_count: track.variants_count, intro_html: track.intro_html, scoring, }, counts, progress, }); }); /* ── GET /api/exam-prep/:examKey/variants ── List of variants with per-user progress aggregates. */ // @public-by-design: router-level authMiddleware (line 6) covers this route router.get('/:examKey/variants', (req, res) => { const { examKey } = req.params; if (!SQL.getTrack.get(examKey)) return res.status(404).json({ error: 'Unknown exam track' }); const rows = SQL.listVariants.all(req.user.id, examKey); const variants = rows.map(r => ({ n: r.variant, label: `Вариант ${r.variant}`, total: r.total, solved: r.solved, viewed_sol: r.viewed_sol, })); res.json({ variants }); }); /* ── GET /api/exam-prep/:examKey/variants/:n/tasks ── All tasks of a specific variant. Includes answer + solution_html (variants view = same UX as old /exam9: read condition + reveal solution). In F3, the task-card will also use `answer` to auto-check user input. */ // @public-by-design: router-level authMiddleware (line 6) covers this route router.get('/:examKey/variants/:n/tasks', (req, res) => { const { examKey } = req.params; const n = parseInt(req.params.n, 10); if (!Number.isFinite(n) || n < 1) return res.status(400).json({ error: 'Bad variant number' }); const rows = SQL.getVariantTasks.all(examKey, n); if (!rows.length) return res.status(404).json({ error: 'Variant not found or empty' }); const tasks = rows.map(r => ({ id: r.id, idx: r.task_idx, type: r.task_type, text: r.text_html, figure: r.figure_html, opts: r.opts_json ? safeJson(r.opts_json) : null, answer: r.answer, solution: r.solution_html, topic: r.topic, subtopic: r.subtopic, })); res.json({ variant: n, tasks }); }); function safeJson(s) { try { return JSON.parse(s); } catch { return null; } } /* ── POST /api/exam-prep/attempts ── Save a single attempt. Used by the variants view (F2) for "solution viewed" markers, and by the practice / mock views (F3+) for actual answer checking. Body: { exam_task_id (required, integer) user_answer (string|null) — what the user typed/selected is_correct (0|1|null) — null for solution-viewed-only events time_ms (integer|null) mode ('practice'|'variant'|'topic'|'mock') session_id (integer|null) hint_used (0|1) solution_viewed (0|1) } The server does NOT recompute is_correct from `user_answer` here — that's the client's responsibility (it has the `answer` from /tasks). Server only validates ownership and records the event. */ router.post('/attempts', (req, res) => { const b = req.body || {}; const taskId = Number(b.exam_task_id); if (!Number.isFinite(taskId)) return res.status(400).json({ error: 'exam_task_id required' }); const task = SQL.getTaskExamKey.get(taskId); if (!task) return res.status(404).json({ error: 'Task not found' }); const mode = String(b.mode || ''); if (!['practice', 'variant', 'topic', 'mock'].includes(mode)) { return res.status(400).json({ error: 'invalid mode' }); } const userAnswer = b.user_answer != null ? String(b.user_answer).slice(0, 500) : null; const isCorrect = b.is_correct === 1 || b.is_correct === 0 ? b.is_correct : null; const timeMs = Number.isFinite(b.time_ms) ? Math.max(0, Math.min(b.time_ms, 24 * 60 * 60 * 1000)) : null; const sessionId = Number.isFinite(b.session_id) ? b.session_id : null; const hintUsed = b.hint_used ? 1 : 0; const solutionViewed = b.solution_viewed ? 1 : 0; const result = SQL.insertAttempt.run( req.user.id, taskId, userAnswer, isCorrect, timeMs, mode, sessionId, hintUsed, solutionViewed, Date.now() ); res.json({ id: Number(result.lastInsertRowid) }); }); module.exports = router;