'use strict'; const router = require('express').Router(); const db = require('../db/db'); const { authMiddleware, requireRole, requirePermissionForStudents } = require('../middleware/auth'); const access = require('../services/contentAccess'); router.use(authMiddleware); // Ролевой доступ к подготовке к экзаменам: ученик без права exam.access закрыт; // учитель/админ проходят всегда. Видимость конкретных модулей — в «Доступ · контент». router.use(requirePermissionForStudents('exam.access')); /* Гейт доступа: любой маршрут с :examKey проверяется по allowlist. Админ/учитель проходят всегда; ученик — только при наличии правила. */ router.param('examKey', (req, res, next, examKey) => { if (!access.canAccessExam(req.user, examKey)) { return res.status(403).json({ error: 'Нет доступа к этому экзамен-модулю' }); } next(); }); /* ── Mock/variant picker: какие variant считаются «пробниками» ────── ctmath: год-пачки (variant=год 2011–2024 и 0) — это тематический ПУЛ для тренажёра по темам, а НЕ чистые 30-задачные варианты (у части до 114 задач). Чистые варианты-пробники нумеруются 3-значно (101, 102, …), а год-пачки — 4-значными годами (≥2011) и 0, поэтому фильтр — ДИАПАЗОН [101;1999], а не просто порог (год 2024 > 101 и иначе бы прошёл!). В пикере пробников, mock/start и просмотре вариантов показываем только чистые. Тренажёр по темам отбирает по subtopic и этот фильтр НЕ использует — пул задач не теряется. Для остальных треков (math9: варианты 1..80) диапазона нет — показываются все. */ const MOCK_VARIANT_RANGE = { ctmath: [101, 1999] }; const isMockVariant = (examKey, v) => { const r = MOCK_VARIANT_RANGE[examKey]; return r ? (v >= r[0] && v <= r[1]) : (v >= 1); }; // Границы вариантов для практики/тренажёра: тот же фильтр, что у пикера, чтобы // год-пачки (variant=год≥2011) и variant=0 НЕ попадали в выборку задач и не // дублировали выверенные варианты-пробники. Для треков без диапазона — всё, кроме 0. const MV_LO = ek => (MOCK_VARIANT_RANGE[ek] ? MOCK_VARIANT_RANGE[ek][0] : 1); const MV_HI = ek => (MOCK_VARIANT_RANGE[ek] ? MOCK_VARIANT_RANGE[ek][1] : 1e15); /* Человекочитаемая подпись варианта (номер в БД остаётся техническим, напр. 101). Для ctmath варианты-пробники именуются по источнику; при добавлении новых вариантов (104+) — дописывать сюда. Иначе fallback «Вариант N». */ const VARIANT_LABEL = { ctmath: { 101: 'РТ-2024/25 · этап I', 102: 'РТ-2024/25 · этап II', 103: 'РТ-2024/25 · этап III', 104: 'РТ-2023/24 · этап I', 105: 'РТ-2023/24 · этап II', 106: 'РТ-2023/24 · этап III', 107: 'РТ-2022/23 · этап I', 108: 'РТ-2022/23 · этап II', 109: 'РТ-2022/23 · этап III', 110: 'ЦТ-2014', 111: 'ЦЭ-2024', 112: 'ЦТ-2015', 113: 'ЦТ-2016', 114: 'ЦТ-2018', 115: 'ЦТ-2019', 116: 'ЦТ-2020', 117: 'ЦТ-2021', 118: 'ЦТ-2017', 119: 'ЦТ-2013', 120: 'ЦТ-2012', 121: 'ЦТ-2011', }, }; const examVariantLabel = (examKey, v) => VARIANT_LABEL[examKey]?.[v] || `Вариант ${v}`; /* ── 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, textbook_slug, textbook_paragraph FROM exam_tasks WHERE exam_key = ? AND variant = ? ORDER BY task_idx `), /* Practice batch — RANDOM strategy. Excludes long tasks (no auto-check) by default to keep UX consistent. NOTE: kept for backwards compatibility / fallback. The primary random path goes through pickRandomByDifficulty() below (difficulty-ordered with optional topic exclusions). */ practiceRandom: db.prepare(` SELECT id, task_idx, variant, task_type, text_html, figure_html, opts_json, answer, solution_html, topic, subtopic, difficulty, textbook_slug, textbook_paragraph FROM exam_tasks WHERE exam_key = ? AND variant BETWEEN ? AND ? AND task_type IN ('mc','open') ORDER BY RANDOM() LIMIT ? `), /* All subtopic slugs known for an exam — used to validate ?exclude= so a bogus slug doesn't silently drop the filter. */ listSubtopicSlugs: db.prepare(` SELECT slug FROM exam_topics WHERE exam_key = ? AND parent_slug IS NOT NULL `), /* Topic reference map: subtopic_slug → textbook chapter / paragraph. Loaded once and cached so we can stamp `topic_ref` onto every task shape without an extra JOIN per query. */ listTopicRefs: db.prepare(` SELECT slug, title, textbook_slug, textbook_paragraph FROM exam_topics WHERE exam_key = ? AND textbook_slug IS NOT NULL `), /* Practice batch — UNSOLVED strategy. Excludes tasks the user has already solved correctly at least once. */ practiceUnsolved: db.prepare(` SELECT t.id, t.task_idx, t.variant, t.task_type, t.text_html, t.figure_html, t.opts_json, t.answer, t.solution_html, t.topic, t.subtopic, t.difficulty, t.textbook_slug, t.textbook_paragraph FROM exam_tasks t WHERE t.exam_key = ? AND t.variant BETWEEN ? AND ? AND t.task_type IN ('mc','open') AND NOT EXISTS ( SELECT 1 FROM exam_attempts a WHERE a.exam_task_id = t.id AND a.user_id = ? AND a.is_correct = 1 ) ORDER BY RANDOM() LIMIT ? `), /* 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 (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) `), /* ── Mock sessions ──────────────────────────────────────────── */ getTasksByVariant: db.prepare(` SELECT id FROM exam_tasks WHERE exam_key = ? AND variant = ? ORDER BY task_idx `), getRandomTaskIds: db.prepare(` SELECT id FROM exam_tasks WHERE exam_key = ? AND task_type IN ('mc','open') ORDER BY RANDOM() LIMIT ? `), insertMockSession: db.prepare(` INSERT INTO exam_mock_sessions (user_id, exam_key, variant, source, task_ids_json, started_at, duration_planned_min, total_tasks, status) VALUES (?, ?, ?, ?, ?, ?, ?, ?, 'active') `), getMockSession: db.prepare(` SELECT id, user_id, exam_key, variant, source, task_ids_json, started_at, finished_at, duration_planned_min, score, total_correct, total_tasks, status FROM exam_mock_sessions WHERE id = ? `), getTracksScoring: db.prepare(`SELECT duration_min, scoring_json FROM exam_tracks WHERE exam_key = ?`), getTasksByIds: (ids) => { if (!ids.length) return []; const ph = ids.map(() => '?').join(','); return db.prepare(` SELECT id, task_idx, variant, task_type, text_html, figure_html, opts_json, answer, solution_html, topic, subtopic, textbook_slug, textbook_paragraph FROM exam_tasks WHERE id IN (${ph}) `).all(...ids); }, /* Latest user-answer per task in a mock session (one row per task). We upsert by deleting prior rows for (user, task, session, mode='mock'). */ deleteMockAnswer: db.prepare(` DELETE FROM exam_attempts WHERE user_id = ? AND exam_task_id = ? AND session_id = ? AND mode = 'mock' `), getMockAnswers: db.prepare(` SELECT exam_task_id, user_answer, is_correct FROM exam_attempts WHERE user_id = ? AND session_id = ? AND mode = 'mock' `), updateMockAttemptCorrectness: db.prepare(` UPDATE exam_attempts SET is_correct = ? WHERE user_id = ? AND exam_task_id = ? AND session_id = ? AND mode = 'mock' `), finalizeMockSession: db.prepare(` UPDATE exam_mock_sessions SET status = 'finished', finished_at = ?, score = ?, total_correct = ? WHERE id = ? `), /* ── Dashboard aggregates (F4) ──────────────────────────────── */ /* Distinct calendar dates (UTC) on which the user had ≥1 correct attempt for tasks of a specific track, last 60 days. We compute streak from this in JS. */ streakDays: db.prepare(` SELECT DISTINCT DATE(a.created_at / 1000, 'unixepoch') AS day FROM exam_attempts a JOIN exam_tasks t ON t.id = a.exam_task_id WHERE a.user_id = ? AND t.exam_key = ? AND a.is_correct = 1 AND a.created_at >= ? ORDER BY day DESC `), /* Accuracy over a recent time window. */ accuracyWindow: db.prepare(` SELECT COUNT(*) AS attempts, COALESCE(SUM(CASE WHEN a.is_correct = 1 THEN 1 ELSE 0 END), 0) AS correct FROM exam_attempts a JOIN exam_tasks t ON t.id = a.exam_task_id WHERE a.user_id = ? AND t.exam_key = ? AND a.is_correct IS NOT NULL AND a.created_at >= ? `), /* Recent attempts (last N) with task metadata. */ recentAttempts: db.prepare(` SELECT a.id, a.exam_task_id, a.is_correct, a.solution_viewed, a.mode, a.user_answer, a.created_at, t.variant, t.task_idx, t.task_type, t.text_html FROM exam_attempts a JOIN exam_tasks t ON t.id = a.exam_task_id WHERE a.user_id = ? AND t.exam_key = ? ORDER BY a.created_at DESC LIMIT ? `), /* Activity heatmap — attempts per calendar day, last 28 days. */ activityHeatmap: db.prepare(` SELECT DATE(a.created_at / 1000, 'unixepoch') AS day, COUNT(*) AS attempts, COALESCE(SUM(CASE WHEN a.is_correct = 1 THEN 1 ELSE 0 END), 0) AS correct FROM exam_attempts a JOIN exam_tasks t ON t.id = a.exam_task_id WHERE a.user_id = ? AND t.exam_key = ? AND a.created_at >= ? GROUP BY day ORDER BY day `), /* Recent mock sessions for "your last attempts" card on dashboard. */ recentMocks: db.prepare(` SELECT id, variant, source, started_at, finished_at, status, score, total_correct, total_tasks FROM exam_mock_sessions WHERE user_id = ? AND exam_key = ? ORDER BY started_at DESC LIMIT ? `), /* ── Study plan (F10) ───────────────────────────────────────── */ getPlan: db.prepare(` SELECT exam_date, daily_target, weak_focus, created_at, updated_at FROM exam_user_plan WHERE user_id = ? AND exam_key = ? `), upsertPlan: db.prepare(` INSERT INTO exam_user_plan (user_id, exam_key, exam_date, daily_target, weak_focus, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?) ON CONFLICT(user_id, exam_key) DO UPDATE SET exam_date = excluded.exam_date, daily_target = excluded.daily_target, weak_focus = excluded.weak_focus, updated_at = excluded.updated_at `), deletePlan: db.prepare(`DELETE FROM exam_user_plan WHERE user_id = ? AND exam_key = ?`), /* Tasks solved today (distinct, mc+open+long); used by "today" progress. */ tasksSolvedToday: db.prepare(` SELECT COUNT(DISTINCT a.exam_task_id) AS solved FROM exam_attempts a JOIN exam_tasks t ON t.id = a.exam_task_id WHERE a.user_id = ? AND t.exam_key = ? AND a.is_correct = 1 AND DATE(a.created_at / 1000, 'unixepoch') = DATE('now') `), /* ── Topics (F7) ────────────────────────────────────────────── */ listTopicsWithCounts: db.prepare(` SELECT tp.slug, tp.parent_slug, tp.title, tp.sort_order, tp.textbook_slug, tp.textbook_paragraph, COALESCE(stat.total, 0) AS total, COALESCE(stat.attempted, 0) AS attempted, COALESCE(stat.solved, 0) AS solved, COALESCE(stat.attempts, 0) AS attempts, COALESCE(stat.correct, 0) AS correct FROM exam_topics tp LEFT JOIN ( SELECT t.subtopic, COUNT(DISTINCT t.id) AS total, COUNT(DISTINCT a.exam_task_id) AS attempted, COUNT(DISTINCT CASE WHEN a.is_correct = 1 THEN a.exam_task_id END) AS solved, COUNT(a.id) AS attempts, COALESCE(SUM(CASE WHEN a.is_correct = 1 THEN 1 ELSE 0 END), 0) AS correct FROM exam_tasks t LEFT JOIN exam_attempts a ON a.exam_task_id = t.id AND a.user_id = ? WHERE t.exam_key = ? AND t.variant BETWEEN ? AND ? AND t.subtopic IS NOT NULL GROUP BY t.subtopic ) stat ON stat.subtopic = tp.slug WHERE tp.exam_key = ? ORDER BY tp.sort_order `), /* Pick tasks for a topic practice session. Excludes long tasks (no auto-check) and tasks the user has already solved correctly, when ?exclude_solved=1. */ topicTasksUnsolved: db.prepare(` SELECT id, task_idx, variant, task_type, text_html, figure_html, opts_json, answer, solution_html, topic, subtopic, difficulty, textbook_slug, textbook_paragraph FROM exam_tasks t WHERE t.exam_key = ? AND t.variant BETWEEN ? AND ? AND t.subtopic = ? AND t.task_type IN ('mc','open') AND NOT EXISTS ( SELECT 1 FROM exam_attempts a WHERE a.exam_task_id = t.id AND a.user_id = ? AND a.is_correct = 1 ) ORDER BY RANDOM() LIMIT ? `), topicTasksAny: db.prepare(` SELECT id, task_idx, variant, task_type, text_html, figure_html, opts_json, answer, solution_html, topic, subtopic, difficulty, textbook_slug, textbook_paragraph FROM exam_tasks t WHERE t.exam_key = ? AND t.variant BETWEEN ? AND ? AND t.subtopic = ? AND t.task_type IN ('mc','open') ORDER BY RANDOM() LIMIT ? `), getTopicMeta: db.prepare(` SELECT slug, parent_slug, title, description FROM exam_topics WHERE exam_key = ? AND slug = ? `), /* ── Weak topics (F8) ──────────────────────────────────────── Subtopics where the user has ≥3 attempts and accuracy < 60%. Sorted by (accuracy ASC, attempts DESC). */ weakTopics: db.prepare(` SELECT tp.slug, tp.title, tp.parent_slug, COUNT(a.id) AS attempts, COALESCE(SUM(CASE WHEN a.is_correct = 1 THEN 1 ELSE 0 END), 0) AS correct, COUNT(DISTINCT t.id) AS total_tasks, COUNT(DISTINCT CASE WHEN a.is_correct = 1 THEN a.exam_task_id END) AS solved_tasks FROM exam_topics tp JOIN exam_tasks t ON t.subtopic = tp.slug AND t.exam_key = tp.exam_key JOIN exam_attempts a ON a.exam_task_id = t.id AND a.user_id = ? AND a.is_correct IS NOT NULL WHERE tp.exam_key = ? AND tp.parent_slug IS NOT NULL GROUP BY tp.slug HAVING attempts >= 3 AND (CAST(correct AS REAL) / attempts) < 0.6 ORDER BY (CAST(correct AS REAL) / attempts) ASC, attempts DESC LIMIT 3 `), weakTopicSlugs: db.prepare(` SELECT tp.slug FROM exam_topics tp JOIN exam_tasks t ON t.subtopic = tp.slug AND t.exam_key = tp.exam_key JOIN exam_attempts a ON a.exam_task_id = t.id AND a.user_id = ? AND a.is_correct IS NOT NULL WHERE tp.exam_key = ? AND tp.parent_slug IS NOT NULL GROUP BY tp.slug HAVING COUNT(a.id) >= 3 AND (CAST(SUM(CASE WHEN a.is_correct = 1 THEN 1 ELSE 0 END) AS REAL) / COUNT(a.id)) < 0.6 ORDER BY (CAST(SUM(CASE WHEN a.is_correct = 1 THEN 1 ELSE 0 END) AS REAL) / COUNT(a.id)) ASC, COUNT(a.id) DESC LIMIT 3 `), /* Practice batch — WEAK strategy: pick random tasks from top-3 weak subtopics, skipping tasks the user has already solved correctly. */ weakBatchTasks: (examKey, slugs, userId, count) => { if (!slugs.length) return []; const ph = slugs.map(() => '?').join(','); return db.prepare(` SELECT id, task_idx, variant, task_type, text_html, figure_html, opts_json, answer, solution_html, topic, subtopic, difficulty, textbook_slug, textbook_paragraph FROM exam_tasks t WHERE t.exam_key = ? AND t.variant BETWEEN ? AND ? AND t.subtopic IN (${ph}) AND t.task_type IN ('mc','open') AND NOT EXISTS ( SELECT 1 FROM exam_attempts a WHERE a.exam_task_id = t.id AND a.user_id = ? AND a.is_correct = 1 ) ORDER BY RANDOM() LIMIT ? `).all(examKey, MV_LO(examKey), MV_HI(examKey), ...slugs, userId, count); }, }; /* ── 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() .filter(t => access.canAccessExam(req.user, t.exam_key)); res.json({ tracks }); }); /* ── Админ: управление экзамен-модулями (вкл/выкл) ── Отдельные пути (без :examKey, чтобы не задеть гейт content_access). */ router.get('/admin/tracks', requireRole('admin'), (_req, res) => { const tracks = db.prepare(` SELECT exam_key, title, subject_slug, grade, enabled, variants_count, sort_order, (SELECT COUNT(*) FROM exam_tasks t WHERE t.exam_key = exam_tracks.exam_key) AS task_count FROM exam_tracks ORDER BY sort_order, exam_key `).all(); res.json({ tracks }); }); router.patch('/admin/track', requireRole('admin'), (req, res) => { const key = String(req.body && req.body.exam_key || '').trim(); if (!key) return res.status(400).json({ error: 'exam_key required' }); if (!db.prepare('SELECT 1 FROM exam_tracks WHERE exam_key = ?').get(key)) return res.status(404).json({ error: 'Unknown exam track' }); const enabled = req.body && req.body.enabled ? 1 : 0; db.prepare('UPDATE exam_tracks SET enabled = ? WHERE exam_key = ?').run(enabled, key); res.json({ ok: true, exam_key: key, enabled }); }); /* ── 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).filter(r => isMockVariant(examKey, r.variant)); const variants = rows.map(r => ({ n: r.variant, label: examVariantLabel(examKey, 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' }); if (!isMockVariant(examKey, n)) return res.status(404).json({ error: 'Variant not found or empty' }); const rows = SQL.getVariantTasks.all(examKey, n); if (!rows.length) return res.status(404).json({ error: 'Variant not found or empty' }); const refMap = getTopicRefMap(examKey); const tasks = rows.map(r => { let topic_ref = null; if (r.textbook_slug) { const paraTitle = getParaTitle(r.textbook_slug, r.textbook_paragraph); const fallbackTitle = r.subtopic ? (refMap.get(r.subtopic)?.title || null) : null; topic_ref = { title: paraTitle || fallbackTitle, slug: r.textbook_slug, paragraph: r.textbook_paragraph ?? null, }; } else if (r.subtopic) { topic_ref = refMap.get(r.subtopic) || null; } return { 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, topic_ref, }; }); res.json({ variant: n, tasks }); }); function safeJson(s) { try { return JSON.parse(s); } catch { return null; } } /* ── Topic-ref cache ───────────────────────────────────────────── subtopic_slug → { title, textbook_slug, textbook_paragraph }. Loaded lazily per examKey; invalidated never (migrations are the only writer, and a server restart picks up new mappings). */ const _topicRefCache = new Map(); function getTopicRefMap(examKey) { let map = _topicRefCache.get(examKey); if (map) return map; const rows = SQL.listTopicRefs.all(examKey); map = new Map(); for (const r of rows) { map.set(r.slug, { title: r.title, slug: r.textbook_slug, paragraph: r.textbook_paragraph ?? null, }); } _topicRefCache.set(examKey, map); return map; } function shapeTask(r, refMap) { // Prefer task-level textbook over subtopic-level fallback. let topic_ref = null; if (r.textbook_slug) { // Build title from para map, fall back to subtopic title via refMap. const fallbackTitle = (refMap && r.subtopic) ? (refMap.get(r.subtopic)?.title || null) : null; const paraTitle = getParaTitle(r.textbook_slug, r.textbook_paragraph); topic_ref = { title: paraTitle || fallbackTitle, slug: r.textbook_slug, paragraph: r.textbook_paragraph ?? null, }; } else if (refMap && r.subtopic) { topic_ref = refMap.get(r.subtopic) || null; } return { id: r.id, idx: r.task_idx, variant: r.variant ?? null, 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 ?? null, subtopic: r.subtopic ?? null, difficulty: r.difficulty ?? null, topic_ref: topic_ref || null, }; } /* ── Para-title cache ───────────────────────────────────────────── chapter_slug + paragraph_int → title string from g9_textbook_sections.json. Built once on first call; covers all books 5-9. */ let _paraCache = null; function _buildParaCache() { const cache = new Map(); try { const sections = require('../../scripts/data/g9_textbook_sections'); for (const s of sections) { if (!s.para_id || !s.title) continue; const m = String(s.para_id).match(/^p(\d+)$/); if (!m) continue; const n = parseInt(m[1], 10); cache.set(`${s.chapter_slug}:${n}`, s.title); } } catch { /* if file missing, silently skip */ } return cache; } function getParaTitle(chapterSlug, paraNum) { if (!chapterSlug || paraNum == null) return null; if (!_paraCache) _paraCache = _buildParaCache(); return _paraCache.get(`${chapterSlug}:${paraNum}`) || null; } /* ── Difficulty distribution for "Random" practice ─────────────── Returns an array [n1,n2,n3,n4,n5] of how many tasks of each difficulty level (1..5) to pick, summing to N. Position 1 of the batch is always difficulty 1 (so n1 >= 1 whenever N >= 1). */ function distributeByDifficulty(N) { if (N <= 0) return [0, 0, 0, 0, 0]; if (N <= 5) { // 1 task per level, then drop hardest first if N<5. // Order [4,3,1,2] keeps slot 0 (difficulty 1) until last so the first // task is always easiest. const c = [1, 1, 1, 1, 1]; let drop = 5 - N; const off = [4, 3, 1, 2]; for (let i = 0; drop > 0 && i < off.length; i++) { c[off[i]] = 0; drop--; } return c; } // Weighted: 10% / 20% / 30% / 20% / 20%, min 1 per level so first slot = d1. const w = [0.10, 0.20, 0.30, 0.20, 0.20]; const c = w.map(p => Math.max(1, Math.round(N * p))); let total = c.reduce((a, b) => a + b, 0); // adjust: trim from level 5 then 4, top-up at level 3 while (total > N) { const idx = c[4] > 1 ? 4 : (c[3] > 1 ? 3 : c.indexOf(Math.max(...c))); c[idx]--; total--; } while (total < N) { c[2]++; total++; } return c; } /* Pick a difficulty-ordered batch with optional subtopic exclusions. Excludes long tasks (no auto-check). Tasks come back already sorted by difficulty ascending — the response order is the play order. */ function pickRandomByDifficulty(examKey, count, excludeSlugs) { const dist = distributeByDifficulty(count); const exParams = excludeSlugs.length ? excludeSlugs : null; const exClause = exParams ? `AND (subtopic IS NULL OR subtopic NOT IN (${exParams.map(() => '?').join(',')}))` : ''; const mvLo = MV_LO(examKey), mvHi = MV_HI(examKey); const COLS = `id, task_idx, variant, task_type, text_html, figure_html, opts_json, answer, solution_html, topic, subtopic, difficulty, textbook_slug, textbook_paragraph`; const out = []; const seen = new Set(); for (let d = 1; d <= 5; d++) { const limit = dist[d - 1]; if (limit === 0) continue; const sql = ` SELECT ${COLS} FROM exam_tasks WHERE exam_key = ? AND variant BETWEEN ? AND ? AND task_type IN ('mc','open') AND difficulty = ? ${exClause} ORDER BY RANDOM() LIMIT ?`; const args = exParams ? [examKey, mvLo, mvHi, d, ...exParams, limit] : [examKey, mvLo, mvHi, d, limit]; for (const r of db.prepare(sql).all(...args)) { if (!seen.has(r.id)) { seen.add(r.id); out.push(r); } } } // Backfill to `count` from any difficulty — covers tracks whose tasks don't // span all 5 difficulty levels (otherwise empty levels would shrink the batch). if (out.length < count) { const ids = [...seen]; const notIn = ids.length ? `AND id NOT IN (${ids.map(() => '?').join(',')})` : ''; const sql = ` SELECT ${COLS} FROM exam_tasks WHERE exam_key = ? AND variant BETWEEN ? AND ? AND task_type IN ('mc','open') ${exClause} ${notIn} ORDER BY RANDOM() LIMIT ?`; const args = [examKey, mvLo, mvHi, ...(exParams || []), ...ids, count - out.length]; out.push(...db.prepare(sql).all(...args)); } out.sort((a, b) => (a.difficulty || 0) - (b.difficulty || 0)); return out; } /* Parse + validate ?exclude=slug1,slug2 against known subtopics for examKey. */ function parseExcludeParam(examKey, raw) { if (!raw) return []; const requested = String(raw).split(',').map(s => s.trim()).filter(Boolean); if (!requested.length) return []; const valid = new Set(SQL.listSubtopicSlugs.all(examKey).map(r => r.slug)); return requested.filter(s => valid.has(s)); } /* ── GET /api/exam-prep/:examKey/practice/next ── Returns up to ?count=N tasks for the practice trainer. ?strategy=random — any mc/open task, fresh random sample =unsolved — only tasks the user has not yet solved correctly Long tasks are excluded (no auto-check available). */ // @public-by-design: router-level authMiddleware (line 6) covers this route router.get('/:examKey/practice/next', (req, res) => { const { examKey } = req.params; if (!SQL.getTrack.get(examKey)) return res.status(404).json({ error: 'Unknown exam track' }); let count = Number(req.query.count) || 10; count = Math.max(1, Math.min(count, 30)); const strategyRaw = req.query.strategy; let strategy = ['unsolved', 'weak', 'random'].includes(strategyRaw) ? strategyRaw : 'random'; let rows; let weakSlugs = null; const excludeSlugs = parseExcludeParam(examKey, req.query.exclude); if (strategy === 'weak') { weakSlugs = SQL.weakTopicSlugs.all(req.user.id, examKey).map(r => r.slug); if (weakSlugs.length === 0) { // No weak topics yet → fall back to unsolved so the button still works rows = SQL.practiceUnsolved.all(examKey, MV_LO(examKey), MV_HI(examKey), req.user.id, count); strategy = 'unsolved-fallback'; } else { rows = SQL.weakBatchTasks(examKey, weakSlugs, req.user.id, count); if (!rows.length) { // weak topics exist but all unsolved tasks exhausted → fallback to any from those topics rows = SQL.weakBatchTasks(examKey, weakSlugs, -1 /* never matches */, count); } } } else if (strategy === 'unsolved') { rows = SQL.practiceUnsolved.all(examKey, MV_LO(examKey), MV_HI(examKey), req.user.id, count); if (!rows.length) rows = SQL.practiceRandom.all(examKey, MV_LO(examKey), MV_HI(examKey), count); } else { // Random: difficulty-ordered batch, position 1 = difficulty 1. rows = pickRandomByDifficulty(examKey, count, excludeSlugs); // Fallback if exclusions wiped the pool — drop them and retry. if (!rows.length && excludeSlugs.length) { rows = pickRandomByDifficulty(examKey, count, []); } if (!rows.length) rows = SQL.practiceRandom.all(examKey, MV_LO(examKey), MV_HI(examKey), count); } const refMap = getTopicRefMap(examKey); res.json({ strategy, weak_slugs: weakSlugs, excluded: excludeSlugs, session_id: Date.now(), tasks: rows.map(r => shapeTask(r, refMap)), }); }); /* ── 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() ); // Variant-clear coin bonus: when the user finishes a variant in // 'variant' mode with every task correctly answered, give them a // one-shot 30-coin tip. Dedup key includes the variant number, so a // user who later re-solves a different variant gets paid again. if (mode === 'variant' && isCorrect === 1) { try { const taskMeta = db.prepare('SELECT variant FROM exam_tasks WHERE id = ?').get(taskId); if (taskMeta?.variant) { const stats = db.prepare(` SELECT (SELECT COUNT(*) FROM exam_tasks WHERE exam_key='math9' AND variant=?) AS total, (SELECT COUNT(DISTINCT t.id) FROM exam_attempts a JOIN exam_tasks t ON t.id = a.exam_task_id WHERE a.user_id = ? AND t.variant = ? AND a.is_correct = 1) AS solved `).get(taskMeta.variant, req.user.id, taskMeta.variant); if (stats.total > 0 && stats.solved >= stats.total) { const { awardCoinsOnce } = require('../controllers/gamification/service'); awardCoinsOnce(req.user.id, 30, `variant_clear:${taskMeta.variant}`, 'forever'); } } } catch (e) { /* don't break the response on bonus failure */ } } res.json({ id: Number(result.lastInsertRowid) }); }); /* ────────────────────────────────────────────────────────────────── Dashboard (F4) ────────────────────────────────────────────────────────────────── */ /* Compute streak: consecutive calendar days ending today (or yesterday — a one-day grace prevents the streak from being lost overnight) on which the user had ≥1 correct attempt. Returns an integer >= 0. `days` — array of date strings 'YYYY-MM-DD' sorted descending. */ function computeStreak(days) { if (!days.length) return 0; const set = new Set(days); const dayMs = 86400000; const today = new Date(); const todayStr = toIsoDate(today); const yesterdayStr = toIsoDate(new Date(today.getTime() - dayMs)); // Anchor: start from today if today's correct exists, else yesterday (grace). // If neither — streak is 0. let cursor; if (set.has(todayStr)) cursor = new Date(today); else if (set.has(yesterdayStr)) cursor = new Date(today.getTime() - dayMs); else return 0; let streak = 0; while (set.has(toIsoDate(cursor))) { streak++; cursor = new Date(cursor.getTime() - dayMs); } return streak; } function toIsoDate(d) { const y = d.getUTCFullYear(); const m = String(d.getUTCMonth() + 1).padStart(2, '0'); const day = String(d.getUTCDate()).padStart(2, '0'); return `${y}-${m}-${day}`; } /* ── GET /api/exam-prep/:examKey/dashboard ── Live aggregates for the dashboard view: streak, recent attempts, accuracy over the last 7 days, 28-day activity heatmap, recent mocks. */ // @public-by-design: router-level authMiddleware (line 6) covers this route router.get('/:examKey/dashboard', (req, res) => { const { examKey } = req.params; if (!SQL.getTrack.get(examKey)) return res.status(404).json({ error: 'Unknown exam track' }); const now = Date.now(); const dayMs = 86400000; const sixtyDaysAgo = now - 60 * dayMs; const sevenDaysAgo = now - 7 * dayMs; const twentyEightDaysAgo = now - 28 * dayMs; const dayRows = SQL.streakDays.all(req.user.id, examKey, sixtyDaysAgo); const streak = computeStreak(dayRows.map(r => r.day)); const acc7 = SQL.accuracyWindow.get(req.user.id, examKey, sevenDaysAgo); const recent = SQL.recentAttempts.all(req.user.id, examKey, 8); const heat = SQL.activityHeatmap.all(req.user.id, examKey, twentyEightDaysAgo); const mocks = SQL.recentMocks.all(req.user.id, examKey, 3); const weak = SQL.weakTopics.all(req.user.id, examKey); res.json({ streak, accuracy_7d: { attempts: acc7.attempts, correct: acc7.correct, pct: acc7.attempts ? Math.round((acc7.correct / acc7.attempts) * 100) : null, }, weak_topics: weak.map(w => ({ slug: w.slug, title: w.title, parent: w.parent_slug, attempts: w.attempts, correct: w.correct, accuracy: Math.round((w.correct / w.attempts) * 100), total_tasks: w.total_tasks, solved_tasks: w.solved_tasks, })), recent_attempts: recent.map(r => ({ task_id: r.exam_task_id, variant: r.variant, task_idx: r.task_idx, task_type: r.task_type, is_correct: r.is_correct, solution_viewed: r.solution_viewed, mode: r.mode, user_answer: r.user_answer, preview: stripPreview(r.text_html), created_at: r.created_at, })), heatmap: heat.map(h => ({ day: h.day, attempts: h.attempts, correct: h.correct })), recent_mocks: mocks.map(m => ({ id: m.id, variant: m.variant, source: m.source, started_at: m.started_at, finished_at: m.finished_at, status: m.status, score: m.score, total_correct: m.total_correct, total_tasks: m.total_tasks, })), }); }); /* Lightweight preview: strip HTML, normalize LaTeX to readable unicode, collapse whitespace, truncate. Used in dashboard "recent attempts" rows where rendering KaTeX would be overkill but raw `$\dfrac{7}{9}$` is ugly. Matches the spirit of tag-exam-tasks.js#stripText. */ function stripPreview(html) { let text = String(html || '') .replace(//gi, '') .replace(/<[^>]+>/g, ' '); // Common LaTeX → unicode/plain. Order matters: handle fractions/roots // before stripping bare backslash commands. text = text .replace(/\\d?frac\s*\{([^{}]+)\}\s*\{([^{}]+)\}/g, '$1/$2') .replace(/\\sqrt\s*\{([^{}]+)\}/g, '√($1)') .replace(/\\cdot/g, '·') .replace(/\\times/g, '×') .replace(/\\pm/g, '±') .replace(/\\mp/g, '∓') .replace(/\\leq?/g, '≤') .replace(/\\geq?/g, '≥') .replace(/\\neq/g, '≠') .replace(/\\approx/g, '≈') .replace(/\\infty/g, '∞') .replace(/\\pi/g, 'π') .replace(/\\alpha/g, 'α').replace(/\\beta/g, 'β').replace(/\\gamma/g, 'γ') .replace(/\\(?:left|right)/g, '') .replace(/\\[a-zA-Z]+\s*/g, ' ') // any remaining LaTeX command .replace(/[{}]/g, '') // leftover braces .replace(/\$+/g, '') // math delimiters .replace(/&[a-z]+;/gi, ' ') // html entities .replace(/\s+/g, ' ') .trim(); return text.length > 100 ? text.slice(0, 100) + '…' : text; } /* ────────────────────────────────────────────────────────────────── Topics (F7) — list + per-topic practice batch ────────────────────────────────────────────────────────────────── */ /* ── GET /api/exam-prep/:examKey/topics ── Returns sections (parents) with children + counts and user accuracy. */ // @public-by-design: router-level authMiddleware (line 6) covers this route router.get('/:examKey/topics', (req, res) => { const { examKey } = req.params; if (!SQL.getTrack.get(examKey)) return res.status(404).json({ error: 'Unknown exam track' }); const rows = SQL.listTopicsWithCounts.all(req.user.id, examKey, MV_LO(examKey), MV_HI(examKey), examKey); // Build {sections: [{slug,title,subtopics:[...]}]} const byParent = new Map(); const sections = []; for (const r of rows) { if (r.parent_slug == null) { const section = { slug: r.slug, title: r.title, subtopics: [], total: 0, solved: 0 }; sections.push(section); byParent.set(r.slug, section); } } for (const r of rows) { if (r.parent_slug != null) { const sec = byParent.get(r.parent_slug); if (!sec) continue; const accuracy = r.attempts > 0 ? Math.round((r.correct / r.attempts) * 100) : null; const solvedPct = r.total > 0 ? Math.round((r.solved / r.total) * 100) : 0; sec.subtopics.push({ slug: r.slug, title: r.title, total: r.total, solved: r.solved, attempted: r.attempted, attempts: r.attempts, correct: r.correct, textbook_slug: r.textbook_slug ?? null, textbook_paragraph: r.textbook_paragraph ?? null, accuracy, solved_pct: solvedPct, }); sec.total += r.total; sec.solved += r.solved; } } res.json({ sections }); }); /* ── GET /api/exam-prep/:examKey/topics/:slug/tasks ── Returns a batch of tasks for topic practice. Query: ?count=10 (5-30) ?exclude_solved=1 (default 1) */ // @public-by-design: router-level authMiddleware (line 6) covers this route router.get('/:examKey/topics/:slug/tasks', (req, res) => { const { examKey, slug } = req.params; const meta = SQL.getTopicMeta.get(examKey, slug); if (!meta) return res.status(404).json({ error: 'Topic not found' }); if (meta.parent_slug == null) { return res.status(400).json({ error: 'Cannot fetch tasks for a section — pick a specific subtopic' }); } let count = Number(req.query.count) || 10; count = Math.max(5, Math.min(count, 30)); const excludeSolved = req.query.exclude_solved !== '0'; let rows; if (excludeSolved) { rows = SQL.topicTasksUnsolved.all(examKey, MV_LO(examKey), MV_HI(examKey), slug, req.user.id, count); if (!rows.length) rows = SQL.topicTasksAny.all(examKey, MV_LO(examKey), MV_HI(examKey), slug, count); } else { rows = SQL.topicTasksAny.all(examKey, MV_LO(examKey), MV_HI(examKey), slug, count); } const refMap = getTopicRefMap(examKey); res.json({ topic: { slug: meta.slug, title: meta.title, parent: meta.parent_slug }, session_id: Date.now(), tasks: rows.map(r => shapeTask(r, refMap)), }); }); /* ────────────────────────────────────────────────────────────────── Study plan (F10) — by exam date ────────────────────────────────────────────────────────────────── */ function isIsoDate(s) { return typeof s === 'string' && /^\d{4}-\d{2}-\d{2}$/.test(s); } function daysBetween(yyyymmdd) { // Calendar days from today to yyyymmdd (UTC). Negative if in past. const [y, m, d] = yyyymmdd.split('-').map(Number); const target = Date.UTC(y, m - 1, d); const today = new Date(); const todayStart = Date.UTC(today.getUTCFullYear(), today.getUTCMonth(), today.getUTCDate()); return Math.round((target - todayStart) / 86400000); } function buildPlanPayload(row, userId, examKey) { if (!row) return { plan: null }; const counts = SQL.countTasks.get(examKey); const progress = SQL.userProgress.get(userId, examKey); const todayRow = SQL.tasksSolvedToday.get(userId, examKey); const daysLeft = daysBetween(row.exam_date); const tasksLeft = Math.max(0, counts.total - progress.tasks_solved); // Auto daily target if user hasn't customized: ceil(left / days) let dailyTarget = row.daily_target; if (!dailyTarget && daysLeft > 0) { dailyTarget = Math.max(5, Math.min(50, Math.ceil(tasksLeft / daysLeft))); } return { plan: { exam_date: row.exam_date, daily_target: dailyTarget, weak_focus: !!row.weak_focus, created_at: row.created_at, updated_at: row.updated_at, days_left: daysLeft, tasks_left: tasksLeft, today: { solved: todayRow.solved, target: dailyTarget, }, }, }; } /* ── GET /api/exam-prep/:examKey/plan ── Returns { plan: null } if user has no plan for this track. */ // @public-by-design: router-level authMiddleware (line 6) covers this route router.get('/:examKey/plan', (req, res) => { const { examKey } = req.params; if (!SQL.getTrack.get(examKey)) return res.status(404).json({ error: 'Unknown exam track' }); const row = SQL.getPlan.get(req.user.id, examKey); res.json(buildPlanPayload(row, req.user.id, examKey)); }); /* ── PUT /api/exam-prep/:examKey/plan ── Body: { exam_date: 'YYYY-MM-DD', daily_target?: int 5-50, weak_focus?: 0|1 } Upserts the plan. exam_date is required; the other fields are optional. */ // @public-by-design: router-level authMiddleware (line 6) covers this route router.put('/:examKey/plan', (req, res) => { const { examKey } = req.params; if (!SQL.getTrack.get(examKey)) return res.status(404).json({ error: 'Unknown exam track' }); const examDate = req.body?.exam_date; if (!isIsoDate(examDate)) return res.status(400).json({ error: 'exam_date must be YYYY-MM-DD' }); let dailyTarget = req.body?.daily_target; if (dailyTarget != null) { dailyTarget = Number(dailyTarget); if (!Number.isInteger(dailyTarget) || dailyTarget < 5 || dailyTarget > 50) { return res.status(400).json({ error: 'daily_target must be integer 5-50' }); } } else { dailyTarget = 10; // sensible default; client can override } const weakFocus = req.body?.weak_focus ? 1 : 0; const existing = SQL.getPlan.get(req.user.id, examKey); const now = Date.now(); SQL.upsertPlan.run( req.user.id, examKey, examDate, dailyTarget, weakFocus, existing ? existing.created_at : now, now ); const row = SQL.getPlan.get(req.user.id, examKey); res.json(buildPlanPayload(row, req.user.id, examKey)); }); /* ── DELETE /api/exam-prep/:examKey/plan ── Remove the user's plan for this track. */ // @public-by-design: router-level authMiddleware (line 6) covers this route router.delete('/:examKey/plan', (req, res) => { const { examKey } = req.params; if (!SQL.getTrack.get(examKey)) return res.status(404).json({ error: 'Unknown exam track' }); SQL.deletePlan.run(req.user.id, examKey); res.json({ ok: true }); }); /* ────────────────────────────────────────────────────────────────── Mock exam (F9) ────────────────────────────────────────────────────────────────── */ /* Convert {correct} count into a final score per the track's scoring grid. Grid is JSON like [{correct:30,score:10},{correct:27,score:9},...]. Sorted descending; we return the score for the largest threshold met. */ function scoreFromGrid(correctCount, scoringJson) { if (!scoringJson) return null; let grid; try { grid = JSON.parse(scoringJson); } catch { return null; } if (!Array.isArray(grid) || !grid.length) return null; grid = grid.slice().sort((a, b) => b.correct - a.correct); for (const entry of grid) { if (correctCount >= entry.correct) return entry.score; } return 0; } /* ── POST /api/exam-prep/:examKey/mock/start ── Body: { source: 'variant'|'random', variant?: number, count?: number } Creates an active mock session and returns its id. */ router.post('/:examKey/mock/start', (req, res) => { const { examKey } = req.params; const track = SQL.getTrack.get(examKey); if (!track) return res.status(404).json({ error: 'Unknown exam track' }); const source = req.body?.source; let taskIds = []; let variant = null; if (source === 'variant') { variant = Number(req.body?.variant); if (!Number.isInteger(variant) || !isMockVariant(examKey, variant)) { return res.status(400).json({ error: 'Variant number required' }); } const rows = SQL.getTasksByVariant.all(examKey, variant); if (!rows.length) return res.status(404).json({ error: 'Variant not found or empty' }); taskIds = rows.map(r => r.id); } else if (source === 'random') { let count = Number(req.body?.count) || track.tasks_per_variant; count = Math.max(5, Math.min(count, 30)); const rows = SQL.getRandomTaskIds.all(examKey, count); taskIds = rows.map(r => r.id); } else { return res.status(400).json({ error: 'source must be variant|random' }); } const r = SQL.insertMockSession.run( req.user.id, examKey, variant, source, JSON.stringify(taskIds), Date.now(), track.duration_min, taskIds.length ); res.json({ id: Number(r.lastInsertRowid), task_count: taskIds.length, duration_min: track.duration_min, }); }); /* ── GET /api/exam-prep/mock/:id ── Returns mock-session state + tasks. - status='active' : tasks WITHOUT answer/solution; includes user_answers map - status='finished' : tasks WITH answer/solution; includes is_correct per task */ // @public-by-design: router-level authMiddleware (line 6) covers this route router.get('/mock/:id', (req, res) => { const id = Number(req.params.id); const sess = SQL.getMockSession.get(id); if (!sess) return res.status(404).json({ error: 'Mock session not found' }); if (sess.user_id !== req.user.id) return res.status(403).json({ error: 'Forbidden' }); let ids = []; try { ids = JSON.parse(sess.task_ids_json) || []; } catch {} const tasksRaw = SQL.getTasksByIds(ids); const taskMap = new Map(tasksRaw.map(t => [t.id, t])); const tasks = ids.map(i => taskMap.get(i)).filter(Boolean); const answers = SQL.getMockAnswers.all(req.user.id, id); const answerByTask = new Map(answers.map(a => [a.exam_task_id, a])); const isActive = sess.status === 'active'; const out = tasks.map((t, idx) => { const ua = answerByTask.get(t.id); return { id: t.id, idx: idx + 1, variant: t.variant, type: t.task_type, text: t.text_html, figure: t.figure_html, opts: t.opts_json ? safeJson(t.opts_json) : null, // Hide answer/solution while active answer: isActive ? null : t.answer, solution: isActive ? null : t.solution_html, // User's stored answer + correctness (post-finish only) user_answer: ua?.user_answer ?? null, is_correct: !isActive ? (ua?.is_correct ?? null) : null, }; }); res.json({ session: { id: sess.id, exam_key: sess.exam_key, variant: sess.variant, variant_label: sess.variant != null ? examVariantLabel(sess.exam_key, sess.variant) : null, source: sess.source, status: sess.status, started_at: sess.started_at, finished_at: sess.finished_at, duration_planned_min: sess.duration_planned_min, score: sess.score, total_correct: sess.total_correct, total_tasks: sess.total_tasks, }, tasks: out, }); }); /* ── POST /api/exam-prep/mock/:id/answer ── Body: { exam_task_id, user_answer } Upserts the user's draft answer (no correctness check while active). */ // @public-by-design: router-level authMiddleware (line 6) covers this route router.post('/mock/:id/answer', (req, res) => { const id = Number(req.params.id); const sess = SQL.getMockSession.get(id); if (!sess) return res.status(404).json({ error: 'Mock session not found' }); if (sess.user_id !== req.user.id) return res.status(403).json({ error: 'Forbidden' }); if (sess.status !== 'active') return res.status(409).json({ error: 'Session finished' }); const taskId = Number(req.body?.exam_task_id); if (!Number.isFinite(taskId)) return res.status(400).json({ error: 'exam_task_id required' }); let ids = []; try { ids = JSON.parse(sess.task_ids_json) || []; } catch {} if (!ids.includes(taskId)) return res.status(400).json({ error: 'Task not in this session' }); const userAnswer = req.body?.user_answer != null ? String(req.body.user_answer).slice(0, 500) : null; const timeMs = Number.isFinite(req.body?.time_ms) ? Math.max(0, Math.min(req.body.time_ms, 24 * 3600 * 1000)) : null; /* Upsert: delete any prior mock-attempt for this task, then insert fresh */ db.transaction(() => { SQL.deleteMockAnswer.run(req.user.id, taskId, id); SQL.insertAttempt.run( req.user.id, taskId, userAnswer, null /* is_correct unknown */, timeMs, 'mock', id, 0, 0, Date.now() ); })(); res.json({ ok: true }); }); /* ── POST /api/exam-prep/mock/:id/finish ── Computes correctness for every stored answer + final score. */ // @public-by-design: router-level authMiddleware (line 6) covers this route router.post('/mock/:id/finish', (req, res) => { const id = Number(req.params.id); const sess = SQL.getMockSession.get(id); if (!sess) return res.status(404).json({ error: 'Mock session not found' }); if (sess.user_id !== req.user.id) return res.status(403).json({ error: 'Forbidden' }); if (sess.status === 'finished') return res.json({ ok: true, already: true }); // Pull tasks + answers, compute correctness per task using same logic as client. let ids = []; try { ids = JSON.parse(sess.task_ids_json) || []; } catch {} const tasks = SQL.getTasksByIds(ids); const taskMap = new Map(tasks.map(t => [t.id, t])); const answers = SQL.getMockAnswers.all(req.user.id, id); let totalCorrect = 0; db.transaction(() => { for (const a of answers) { const t = taskMap.get(a.exam_task_id); if (!t || !t.answer) continue; const correct = checkAnswerServer(a.user_answer, t.answer) ? 1 : 0; if (correct) totalCorrect++; SQL.updateMockAttemptCorrectness.run(correct, req.user.id, a.exam_task_id, id); } })(); const track = SQL.getTracksScoring.get(sess.exam_key); const score = track ? scoreFromGrid(totalCorrect, track.scoring_json) : null; SQL.finalizeMockSession.run(Date.now(), score, totalCorrect, id); res.json({ ok: true, total_correct: totalCorrect, total_tasks: sess.total_tasks, score, }); }); /* ────────────────────────────────────────────────────────────────── Server-side answer checker — mirror of frontend EP.answer.check. Kept here (intentional code duplication) so the server is the source of truth for mock-exam scoring and can never be bypassed by a tampered client. ────────────────────────────────────────────────────────────────── */ const EPS = 1e-6; function srvToNumber(s) { if (s == null) return NaN; let t = String(s).trim().replace(/\$/g, '').replace(/\s+/g, '').replace(',', '.'); const f = t.match(/^(-?\d+(?:\.\d+)?)\s*\/\s*(-?\d+(?:\.\d+)?)$/); if (f) { const num = Number(f[1]); const den = Number(f[2]); if (!Number.isFinite(num) || !Number.isFinite(den) || den === 0) return NaN; return num / den; } const n = Number(t); return Number.isFinite(n) ? n : NaN; } function srvToPair(s) { if (s == null) return null; const t = String(s).trim().replace(/\$/g, '').replace(/\s+и\s+/g, ';'); const parts = t.split(/[;,]/).map(p => p.trim()).filter(Boolean); if (parts.length !== 2) return null; const a = srvToNumber(parts[0]); const b = srvToNumber(parts[1]); if (Number.isNaN(a) || Number.isNaN(b)) return null; return a <= b ? [a, b] : [b, a]; } function checkAnswerServer(userInput, canonical) { if (userInput == null || canonical == null) return false; const c = String(canonical).trim(); if (/^[а-д]$/.test(c)) { return String(userInput).trim().toLowerCase() === c.toLowerCase(); } if (/^[^;]+;[^;]+$/.test(c)) { const cp = srvToPair(c); const up = srvToPair(userInput); if (!cp || !up) return false; return Math.abs(cp[0] - up[0]) < EPS && Math.abs(cp[1] - up[1]) < EPS; } const cn = srvToNumber(c); const un = srvToNumber(userInput); if (Number.isNaN(cn) || Number.isNaN(un)) return false; return Math.abs(cn - un) < EPS; } module.exports = router;