feat(exam-prep F2): порт браузера вариантов + API /variants + POST /attempts + редирект /exam9

This commit is contained in:
Maxim Dolgolyov
2026-05-29 10:43:10 +03:00
parent 1b6865a491
commit 7c33d4ce11
6 changed files with 555 additions and 9 deletions
+130
View File
@@ -41,6 +41,43 @@ const SQL = {
JOIN exam_tasks t ON t.id = a.exam_task_id
WHERE a.user_id = ? AND t.exam_key = ?
`),
/* Variants list + per-user counts. One row per variant.
- total: tasks in variant
- solved: distinct tasks where user has ≥1 correct attempt
- viewed_sol: distinct tasks where user opened the solution */
listVariants: db.prepare(`
SELECT
t.variant,
COUNT(*) AS total,
COUNT(DISTINCT CASE WHEN a.is_correct = 1 THEN t.id END) AS solved,
COUNT(DISTINCT CASE WHEN a.solution_viewed = 1 THEN t.id END) AS viewed_sol
FROM exam_tasks t
LEFT JOIN exam_attempts a
ON a.exam_task_id = t.id AND a.user_id = ?
WHERE t.exam_key = ?
GROUP BY t.variant
ORDER BY t.variant
`),
getVariantTasks: db.prepare(`
SELECT
id, task_idx, task_type, text_html, figure_html, opts_json,
answer, solution_html, topic, subtopic
FROM exam_tasks
WHERE exam_key = ? AND variant = ?
ORDER BY task_idx
`),
/* For attempt save: confirm the task belongs to a known track + user */
getTaskExamKey: db.prepare(`SELECT exam_key FROM exam_tasks WHERE id = ?`),
insertAttempt: db.prepare(`
INSERT INTO exam_attempts
(user_id, exam_task_id, user_answer, is_correct, time_ms,
mode, session_id, hint_used, solution_viewed, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`),
};
/* ── GET /api/exam-prep/tracks ──
@@ -52,6 +89,7 @@ router.get('/tracks', (_req, res) => {
/* ── 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);
@@ -83,4 +121,96 @@ router.get('/:examKey/info', (req, res) => {
});
});
/* ── GET /api/exam-prep/:examKey/variants ──
List of variants with per-user progress aggregates. */
// @public-by-design: router-level authMiddleware (line 6) covers this route
router.get('/:examKey/variants', (req, res) => {
const { examKey } = req.params;
if (!SQL.getTrack.get(examKey)) return res.status(404).json({ error: 'Unknown exam track' });
const rows = SQL.listVariants.all(req.user.id, examKey);
const variants = rows.map(r => ({
n: r.variant,
label: `Вариант ${r.variant}`,
total: r.total,
solved: r.solved,
viewed_sol: r.viewed_sol,
}));
res.json({ variants });
});
/* ── GET /api/exam-prep/:examKey/variants/:n/tasks ──
All tasks of a specific variant. Includes answer + solution_html
(variants view = same UX as old /exam9: read condition + reveal solution).
In F3, the task-card will also use `answer` to auto-check user input. */
// @public-by-design: router-level authMiddleware (line 6) covers this route
router.get('/:examKey/variants/:n/tasks', (req, res) => {
const { examKey } = req.params;
const n = parseInt(req.params.n, 10);
if (!Number.isFinite(n) || n < 1) return res.status(400).json({ error: 'Bad variant number' });
const rows = SQL.getVariantTasks.all(examKey, n);
if (!rows.length) return res.status(404).json({ error: 'Variant not found or empty' });
const tasks = rows.map(r => ({
id: r.id,
idx: r.task_idx,
type: r.task_type,
text: r.text_html,
figure: r.figure_html,
opts: r.opts_json ? safeJson(r.opts_json) : null,
answer: r.answer,
solution: r.solution_html,
topic: r.topic,
subtopic: r.subtopic,
}));
res.json({ variant: n, tasks });
});
function safeJson(s) { try { return JSON.parse(s); } catch { return null; } }
/* ── POST /api/exam-prep/attempts ──
Save a single attempt. Used by the variants view (F2) for "solution viewed"
markers, and by the practice / mock views (F3+) for actual answer checking.
Body: {
exam_task_id (required, integer)
user_answer (string|null) — what the user typed/selected
is_correct (0|1|null) — null for solution-viewed-only events
time_ms (integer|null)
mode ('practice'|'variant'|'topic'|'mock')
session_id (integer|null)
hint_used (0|1)
solution_viewed (0|1)
}
The server does NOT recompute is_correct from `user_answer` here — that's
the client's responsibility (it has the `answer` from /tasks). Server only
validates ownership and records the event. */
router.post('/attempts', (req, res) => {
const b = req.body || {};
const taskId = Number(b.exam_task_id);
if (!Number.isFinite(taskId)) return res.status(400).json({ error: 'exam_task_id required' });
const task = SQL.getTaskExamKey.get(taskId);
if (!task) return res.status(404).json({ error: 'Task not found' });
const mode = String(b.mode || '');
if (!['practice', 'variant', 'topic', 'mock'].includes(mode)) {
return res.status(400).json({ error: 'invalid mode' });
}
const userAnswer = b.user_answer != null ? String(b.user_answer).slice(0, 500) : null;
const isCorrect = b.is_correct === 1 || b.is_correct === 0 ? b.is_correct : null;
const timeMs = Number.isFinite(b.time_ms) ? Math.max(0, Math.min(b.time_ms, 24 * 60 * 60 * 1000)) : null;
const sessionId = Number.isFinite(b.session_id) ? b.session_id : null;
const hintUsed = b.hint_used ? 1 : 0;
const solutionViewed = b.solution_viewed ? 1 : 0;
const result = SQL.insertAttempt.run(
req.user.id, taskId, userAnswer, isCorrect, timeMs,
mode, sessionId, hintUsed, solutionViewed, Date.now()
);
res.json({ id: Number(result.lastInsertRowid) });
});
module.exports = router;