feat(exam-prep F2): порт браузера вариантов + API /variants + POST /attempts + редирект /exam9
This commit is contained in:
@@ -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;
|
||||
|
||||
@@ -366,9 +366,8 @@ app.get(['/exam-prep/:examKey/:view', '/exam-prep/:examKey/:view/*'], (req, res,
|
||||
sendExamPrep(res, file);
|
||||
});
|
||||
|
||||
// NOTE: /exam9 remains live (served by static middleware as exam9.html) until F2
|
||||
// ports the variant browser into /exam-prep/:examKey/variants. The 301 redirect
|
||||
// will be added at that point.
|
||||
// Legacy /exam9 → new variants browser (F2 port complete)
|
||||
app.get('/exam9', (_req, res) => res.redirect(301, '/exam-prep/math9/variants'));
|
||||
|
||||
// Serve HTML files without extension (/dashboard → dashboard.html)
|
||||
// In dev: disable cache so edits are always picked up immediately
|
||||
|
||||
Reference in New Issue
Block a user