Files
Learn_System/backend/src/routes/exam-prep.js
T
Maxim Dolgolyov d5fbd0168e feat(permissions): +10 прав ролей с энфорсом (Доступ · роли)
Реестр (registry.js) пополнен правами, которыми раньше нельзя было управлять:
• Учитель: classroom.host (онлайн-уроки), livequiz.host (живые викторины),
  simbuilder.use (конструктор симуляций), flashcards.manage (общие колоды).
• Ученик: homework.submit (сдача ДЗ), materials.save («Мои материалы»),
  assistant.use (ИИ-ассистент), games.play (учебные игры),
  flashcards.access / exam.access (доступ к разделам).
Все default=1 → текущее поведение сохранено; админ может выключить по роли/классу/юзеру.

Энфорс на роутах: учительские — requirePermission (роуты уже teacher-only);
ученические на ОБЩИХ роутах (assistant/materials/games/flashcards/exam-prep) —
новый requirePermissionForStudents(key) (учитель/админ проходят всегда, проверка
только ученику — иначе isEnabled=false сломал бы учителя). PERM_DEFAULTS строится
из реестра → фолбэк до сидирования = enabled, никто не блокируется. Группы UI —
существующие (новых ярлыков нет). seedDefaults авто-сидит новые ключи на чтении.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-22 17:31:00 +03:00

1452 lines
59 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'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=год 20112024 и 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(/<svg[\s\S]*?<\/svg>/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;