Files
Learn_System/backend/src/routes/exam-prep.js
T

217 lines
8.0 KiB
JavaScript

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