217 lines
8.0 KiB
JavaScript
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;
|