31a51956b6
Импорт 40 нечётных вариантов (v01, v03, ..., v79) в банк вопросов: - 400 questions с allow_html=1, source_type='экзамен 9', year=2025 - 540 options (single-choice) + correct_text (short_answer) - 40 tests (по 1 на вариант), title="Экзамен 9 — Вариант N" - exam9_variant_tests маппинг для назначения Назначение варианта как ДЗ на /exam9 (для учителей/админов): - Кнопка «Назначить как ДЗ» под заголовком варианта (только если test_id есть) - Модалка выбора классов + опциональный deadline - POST /api/assignments/bulk с test_id из exam9_variant_tests Поддержка HTML/SVG в вопросах банка через флаг questions.allow_html: - Миграция 003: ALTER TABLE questions ADD COLUMN allow_html - sessionController: SELECT возвращают allow_html и image - test-run.html: рендер q.text и opt.text как HTML при allow_html=1 - test-result.html: то же для explanation и opt.text - KaTeX: добавлены $...$ и $$...$$ delimiters в обеих страницах Бонус-фикс: bulkSchema требовал class_id (single), контроллер ждёт class_ids (array). Существующий вызов из classes.html был сломан; исправлено вместе. Команда: node backend/scripts/import-exam9.js (--all для всех 80)
607 lines
24 KiB
JavaScript
607 lines
24 KiB
JavaScript
const db = require('../db/db');
|
|
const { pushNotif } = require('../utils/notifications');
|
|
const { onTestFinished, updateDailyGoal, awardXP, updateChallenges } = require('./gamificationController');
|
|
const { COMBO_BONUSES } = require('../constants');
|
|
|
|
/* ── Prepared statements (avoid re-parsing on every request) ──────────── */
|
|
const stmts = {
|
|
/* stats: 7 queries → 2 via CTE + json_group_array */
|
|
statsMega: db.prepare(`
|
|
WITH base AS (
|
|
SELECT ts.id, ts.score, ts.total, ts.started_at, ts.finished_at,
|
|
s.slug AS subject_slug, s.name AS subject_name
|
|
FROM test_sessions ts
|
|
LEFT JOIN subjects s ON s.id = ts.subject_id
|
|
WHERE ts.user_id = @uid AND ts.status = 'completed'
|
|
),
|
|
hm AS (
|
|
SELECT date(ts.started_at) AS day, COUNT(*) AS cnt
|
|
FROM test_sessions ts
|
|
WHERE ts.user_id = @uid AND ts.started_at >= date('now', '-90 days')
|
|
GROUP BY day ORDER BY day
|
|
),
|
|
sd AS (
|
|
SELECT DISTINCT date(ts.started_at) AS d
|
|
FROM test_sessions ts WHERE ts.user_id = @uid
|
|
ORDER BY d DESC LIMIT 90
|
|
)
|
|
SELECT
|
|
(SELECT COALESCE(json_group_array(json_object(
|
|
'week', week, 'sessions', sessions, 'avg_pct', avg_pct)), '[]')
|
|
FROM (SELECT strftime('%Y-%W', finished_at) AS week,
|
|
COUNT(*) AS sessions,
|
|
AVG(CASE WHEN total>0 THEN score*100.0/total END) AS avg_pct
|
|
FROM base WHERE finished_at >= date('now', '-84 days')
|
|
GROUP BY week ORDER BY week)) AS weekly,
|
|
(SELECT COALESCE(json_group_array(json_object('day', day, 'count', cnt)), '[]')
|
|
FROM hm) AS heatmap,
|
|
(SELECT COALESCE(json_group_array(json_object(
|
|
'slug', subject_slug, 'name', subject_name,
|
|
'sessions', sessions, 'avg_pct', avg_pct,
|
|
'total_correct', total_correct, 'total_questions', total_questions)), '[]')
|
|
FROM (SELECT subject_slug, subject_name, COUNT(*) AS sessions,
|
|
AVG(CASE WHEN total>0 THEN score*100.0/total END) AS avg_pct,
|
|
SUM(score) AS total_correct, SUM(total) AS total_questions
|
|
FROM base GROUP BY subject_slug ORDER BY sessions DESC)) AS bySubject,
|
|
(SELECT COALESCE(json_group_array(json_object(
|
|
'id', id, 'score', score, 'total', total,
|
|
'finished_at', finished_at, 'subject_slug', subject_slug)), '[]')
|
|
FROM (SELECT id, score, total, finished_at, subject_slug
|
|
FROM base ORDER BY finished_at DESC LIMIT 20)) AS trend,
|
|
(SELECT json_object(
|
|
'sessions', COUNT(*),
|
|
'correct', COALESCE(SUM(score), 0),
|
|
'questions',COALESCE(SUM(total), 0),
|
|
'avg_pct', COALESCE(AVG(CASE WHEN total>0 THEN score*100.0/total END), 0))
|
|
FROM base) AS totals,
|
|
(SELECT COALESCE(json_group_array(d), '[]') FROM sd) AS streakDays
|
|
`),
|
|
courseProgress: db.prepare(`
|
|
SELECT c.id, c.title, c.cover_emoji, c.subject_slug,
|
|
COUNT(l.id) AS total_lessons, COUNT(lp.id) AS done_lessons
|
|
FROM courses c JOIN lessons l ON l.course_id = c.id AND l.is_published = 1
|
|
LEFT JOIN lesson_progress lp ON lp.lesson_id = l.id AND lp.user_id = @uid
|
|
WHERE c.is_published = 1 GROUP BY c.id HAVING done_lessons > 0
|
|
ORDER BY done_lessons * 1.0 / total_lessons DESC`),
|
|
sessionCount: db.prepare('SELECT COUNT(*) AS total FROM test_sessions WHERE user_id = ?'),
|
|
};
|
|
|
|
function shuffle(arr) {
|
|
for (let i = arr.length - 1; i > 0; i--) {
|
|
const j = Math.floor(Math.random() * (i + 1));
|
|
[arr[i], arr[j]] = [arr[j], arr[i]];
|
|
}
|
|
return arr;
|
|
}
|
|
|
|
const VALID_MODES = new Set(['exam', 'practice']);
|
|
|
|
/* ── POST /api/sessions ───────────────────────────────────────────────── */
|
|
function start(req, res, next) {
|
|
const { subject_slug, topic_id, test_id } = req.body;
|
|
const mode = req.body.mode || 'exam';
|
|
const count = Number(req.body.count) || 25;
|
|
|
|
if (!subject_slug) return res.status(400).json({ error: 'subject_slug is required' });
|
|
if (!VALID_MODES.has(mode)) return res.status(400).json({ error: 'mode must be exam or practice' });
|
|
if (!Number.isInteger(count) || count < 1 || count > 200)
|
|
return res.status(400).json({ error: 'count must be an integer between 1 and 200' });
|
|
|
|
const subject = db.prepare('SELECT id FROM subjects WHERE slug = ?').get(subject_slug);
|
|
if (!subject) return res.status(404).json({ error: 'Subject not found' });
|
|
|
|
let ids;
|
|
let testTimeLimitSec = null;
|
|
if (test_id) {
|
|
const tRow = db.prepare('SELECT time_limit FROM tests WHERE id = ?').get(Number(test_id));
|
|
if (tRow?.time_limit) testTimeLimitSec = tRow.time_limit * 60;
|
|
const tq = db.prepare(
|
|
'SELECT question_id AS id FROM test_questions WHERE test_id = ? ORDER BY order_index'
|
|
).all(Number(test_id));
|
|
if (!tq.length) return res.status(404).json({ error: 'Test has no questions' });
|
|
ids = shuffle(tq.map(r => r.id));
|
|
} else {
|
|
const rows = topic_id
|
|
? db.prepare('SELECT id FROM questions WHERE subject_id = ? AND topic_id = ?').all(subject.id, Number(topic_id))
|
|
: db.prepare('SELECT id FROM questions WHERE subject_id = ?').all(subject.id);
|
|
if (!rows.length) return res.status(404).json({ error: 'No questions found' });
|
|
ids = shuffle(rows.map(r => r.id)).slice(0, count);
|
|
}
|
|
|
|
const total = ids.length;
|
|
|
|
const createSession = db.transaction(() => {
|
|
const { lastInsertRowid: session_id } = db.prepare(
|
|
'INSERT INTO test_sessions (user_id, subject_id, mode, total) VALUES (?, ?, ?, ?)'
|
|
).run(req.user.id, subject.id, mode, total);
|
|
|
|
const insertSQ = db.prepare(
|
|
'INSERT INTO session_questions (session_id, question_id, order_index) VALUES (?, ?, ?)'
|
|
);
|
|
ids.forEach((qid, i) => insertSQ.run(session_id, qid, i));
|
|
return session_id;
|
|
});
|
|
|
|
try {
|
|
const session_id = createSession();
|
|
const questions = loadQuestionsForSession(ids);
|
|
res.status(201).json({ session_id, total, mode, questions, time_limit_sec: testTimeLimitSec });
|
|
} catch (err) {
|
|
next(err);
|
|
}
|
|
}
|
|
|
|
/* ── POST /api/sessions/:id/answer ───────────────────────────────────── */
|
|
function answer(req, res) {
|
|
const session_id = Number(req.params.id);
|
|
const { question_id, option_id, time_spent_sec, answer_text, chosen_options } = req.body;
|
|
|
|
const session = db.prepare(
|
|
'SELECT id, mode, status FROM test_sessions WHERE id = ? AND user_id = ?'
|
|
).get(session_id, req.user.id);
|
|
|
|
if (!session) return res.status(404).json({ error: 'Session not found' });
|
|
if (session.status !== 'in_progress')
|
|
return res.status(400).json({ error: 'Session already finished' });
|
|
|
|
// Verify question belongs to this session — prevents answering questions from other sessions
|
|
const sq = db.prepare(
|
|
'SELECT 1 FROM session_questions WHERE session_id = ? AND question_id = ?'
|
|
).get(session_id, question_id);
|
|
if (!sq) return res.status(400).json({ error: 'Question not in this session' });
|
|
|
|
const q = db.prepare('SELECT id, type, correct_text FROM questions WHERE id = ?').get(question_id);
|
|
if (!q) return res.status(400).json({ error: 'Invalid question' });
|
|
|
|
let isCorrect = 0;
|
|
let chosenOptionId = null;
|
|
let storedAnswerText = null;
|
|
|
|
if (q.type === 'short_answer') {
|
|
const userAns = String(answer_text || '').trim().toLowerCase().replace(/\s+/g, ' ');
|
|
const correct = String(q.correct_text || '').trim().toLowerCase().replace(/\s+/g, ' ');
|
|
isCorrect = userAns === correct ? 1 : 0;
|
|
storedAnswerText = String(answer_text || '').trim();
|
|
|
|
} else if (q.type === 'multi') {
|
|
const selected = Array.isArray(chosen_options) ? chosen_options.map(Number) : [];
|
|
const allOpts = db.prepare('SELECT id, is_correct FROM options WHERE question_id = ?').all(question_id);
|
|
const correctIds = allOpts.filter(o => o.is_correct).map(o => o.id);
|
|
const wrongIds = allOpts.filter(o => !o.is_correct).map(o => o.id);
|
|
isCorrect = (
|
|
correctIds.every(id => selected.includes(id)) &&
|
|
wrongIds.every(id => !selected.includes(id))
|
|
) ? 1 : 0;
|
|
storedAnswerText = JSON.stringify(selected);
|
|
|
|
} else if (q.type === 'matching') {
|
|
const pairs = (() => { try { return JSON.parse(answer_text || '{}'); } catch (e) { console.error('[answer] matching parse error:', e.message); return {}; } })();
|
|
const allOpts = db.prepare('SELECT id, match_pair FROM options WHERE question_id = ?').all(question_id);
|
|
isCorrect = allOpts.length > 0 && allOpts.every(opt => pairs[String(opt.id)] === opt.match_pair) ? 1 : 0;
|
|
storedAnswerText = answer_text;
|
|
|
|
} else {
|
|
// single / true_false
|
|
const opt = db.prepare(
|
|
'SELECT id, is_correct FROM options WHERE id = ? AND question_id = ?'
|
|
).get(option_id, question_id);
|
|
if (!opt) return res.status(400).json({ error: 'Invalid option' });
|
|
isCorrect = opt.is_correct;
|
|
chosenOptionId = opt.id;
|
|
}
|
|
|
|
db.prepare(`
|
|
INSERT INTO user_answers (session_id, question_id, chosen_option_id, answer_text, is_correct, time_spent_sec)
|
|
VALUES (?, ?, ?, ?, ?, ?)
|
|
ON CONFLICT(session_id, question_id)
|
|
DO UPDATE SET chosen_option_id = excluded.chosen_option_id,
|
|
answer_text = excluded.answer_text,
|
|
is_correct = excluded.is_correct,
|
|
time_spent_sec = excluded.time_spent_sec,
|
|
answered_at = datetime('now')
|
|
`).run(session_id, question_id, chosenOptionId, storedAnswerText, isCorrect, time_spent_sec ?? null);
|
|
|
|
const response = { is_correct: isCorrect === 1 };
|
|
|
|
if (session.mode === 'practice') {
|
|
if (q.type === 'short_answer') {
|
|
response.correct_text = q.correct_text;
|
|
} else {
|
|
response.correct_options = db.prepare(
|
|
'SELECT id, text FROM options WHERE question_id = ? AND is_correct = 1'
|
|
).all(question_id);
|
|
}
|
|
}
|
|
|
|
res.json(response);
|
|
}
|
|
|
|
/* ── POST /api/sessions/:id/finish ───────────────────────────────────── */
|
|
function finish(req, res) {
|
|
const session_id = Number(req.params.id);
|
|
|
|
const session = db.prepare(
|
|
'SELECT * FROM test_sessions WHERE id = ? AND user_id = ?'
|
|
).get(session_id, req.user.id);
|
|
|
|
if (!session) return res.status(404).json({ error: 'Session not found' });
|
|
|
|
// Atomically mark as completed — guard against concurrent finish() calls
|
|
let score;
|
|
try {
|
|
score = db.transaction(() => {
|
|
const { score: s } = db.prepare(
|
|
'SELECT COUNT(*) AS score FROM user_answers WHERE session_id = ? AND is_correct = 1'
|
|
).get(session_id);
|
|
const upd = db.prepare(
|
|
"UPDATE test_sessions SET status = 'completed', score = ?, finished_at = datetime('now') WHERE id = ? AND status = 'in_progress'"
|
|
).run(s, session_id);
|
|
if (upd.changes === 0) throw Object.assign(new Error('already_finished'), { code: 'ALREADY_FINISHED' });
|
|
return s;
|
|
})();
|
|
} catch (e) {
|
|
if (e.code === 'ALREADY_FINISHED') return res.status(400).json({ error: 'Session already finished' });
|
|
throw e;
|
|
}
|
|
|
|
// Compute max combo (consecutive correct answers in order)
|
|
let maxCombo = 0;
|
|
try {
|
|
const ansRows = db.prepare(
|
|
'SELECT is_correct FROM user_answers WHERE session_id = ? ORDER BY rowid'
|
|
).all(session_id);
|
|
let streak = 0;
|
|
for (const a of ansRows) {
|
|
streak = a.is_correct ? streak + 1 : 0;
|
|
if (streak > maxCombo) maxCombo = streak;
|
|
}
|
|
} catch (e) { console.error('[finish] combo calc:', e.message); }
|
|
|
|
// Gamification: award XP, update streak, check achievements
|
|
try {
|
|
const timeSec = Math.round((Date.now() - new Date(session.started_at).getTime()) / 1000);
|
|
// Check if linked to a test with time_limit
|
|
let testTimeLimitSec = null;
|
|
try {
|
|
const tl = db.prepare(`
|
|
SELECT t.time_limit FROM assignment_sessions ases
|
|
JOIN assignments a ON a.id = ases.assignment_id
|
|
JOIN tests t ON t.id = a.test_id
|
|
WHERE ases.session_id = ?
|
|
`).get(session_id);
|
|
if (tl?.time_limit) testTimeLimitSec = tl.time_limit * 60;
|
|
} catch (e) { console.error('[finish] time_limit fetch:', e.message); }
|
|
// Combo bonus XP (thresholds from constants)
|
|
let comboBonus = 0;
|
|
for (const [min, xp] of COMBO_BONUSES) {
|
|
if (maxCombo >= min) { comboBonus = xp; break; }
|
|
}
|
|
onTestFinished(req.user.id, score, session.total, timeSec, testTimeLimitSec);
|
|
if (comboBonus > 0) {
|
|
try { awardXP(req.user.id, comboBonus, `Комбо x${maxCombo}`); } catch (e) { console.error('[finish] comboBonus awardXP:', e.message); }
|
|
}
|
|
updateDailyGoal(req.user.id, 1, score * 10 + 50 + comboBonus);
|
|
// Update personal challenges
|
|
try {
|
|
const subj = db.prepare('SELECT s.slug FROM subjects s WHERE s.id = ?').get(session.subject_id);
|
|
const topicIds = db.prepare(`
|
|
SELECT DISTINCT q.topic_id FROM session_questions sq
|
|
JOIN questions q ON q.id = sq.question_id
|
|
WHERE sq.session_id = ? AND q.topic_id IS NOT NULL
|
|
`).all(session_id).map(r => r.topic_id);
|
|
const slug = subj ? subj.slug : null;
|
|
for (const tid of (topicIds.length ? topicIds : [null])) {
|
|
updateChallenges(req.user.id, score, session.total, slug, tid);
|
|
}
|
|
} catch (e) { console.error('[finish] updateChallenges:', e.message); }
|
|
} catch (e) { console.error('[finish] gamification:', e.message); }
|
|
|
|
// Notify teacher if session linked to a class assignment
|
|
try {
|
|
const link = db.prepare(`
|
|
SELECT a.title, COALESCE(c.teacher_id, a.created_by) AS teacher_id, u.name AS student_name
|
|
FROM assignment_sessions ass
|
|
JOIN assignments a ON a.id = ass.assignment_id
|
|
LEFT JOIN classes c ON c.id = a.class_id
|
|
JOIN users u ON u.id = ?
|
|
WHERE ass.session_id = ?
|
|
`).get(req.user.id, session_id);
|
|
if (link) {
|
|
const pct = Math.round((score / session.total) * 100);
|
|
pushNotif(link.teacher_id, 'session', `«${link.student_name}» сдал «${link.title}» — ${pct}%`, '/classes');
|
|
}
|
|
} catch (e) { console.error('[finish] pushNotif teacher:', e.message); }
|
|
|
|
res.json({
|
|
session_id,
|
|
score,
|
|
total: session.total,
|
|
percent: session.total ? Math.round((score / session.total) * 100) : 0,
|
|
time_sec: Math.round((Date.now() - new Date(session.started_at)) / 1000),
|
|
maxCombo,
|
|
review: buildReview(session_id),
|
|
});
|
|
}
|
|
|
|
/* ── GET /api/sessions/:id/result ────────────────────────────────────── */
|
|
function result(req, res) {
|
|
const session_id = Number(req.params.id);
|
|
|
|
const session = db.prepare(
|
|
'SELECT * FROM test_sessions WHERE id = ? AND user_id = ?'
|
|
).get(session_id, req.user.id);
|
|
|
|
if (!session) return res.status(404).json({ error: 'Session not found' });
|
|
if (session.status !== 'completed')
|
|
return res.status(400).json({ error: 'Session not finished yet' });
|
|
|
|
// Check if session is linked to an assignment using a test with show_answers setting
|
|
let show_answers = 1;
|
|
try {
|
|
const assnSess = db.prepare(`
|
|
SELECT t.show_answers FROM assignment_sessions ases
|
|
JOIN assignments a ON a.id = ases.assignment_id
|
|
JOIN tests t ON t.id = a.test_id
|
|
WHERE ases.session_id = ?
|
|
`).get(session_id);
|
|
if (assnSess) show_answers = assnSess.show_answers;
|
|
} catch {}
|
|
|
|
// Compute max combo for display
|
|
let maxCombo = 0;
|
|
try {
|
|
const ansRows = db.prepare(
|
|
'SELECT is_correct FROM user_answers WHERE session_id = ? ORDER BY rowid'
|
|
).all(session_id);
|
|
let streak = 0;
|
|
for (const a of ansRows) {
|
|
streak = a.is_correct ? streak + 1 : 0;
|
|
if (streak > maxCombo) maxCombo = streak;
|
|
}
|
|
} catch {}
|
|
|
|
res.json({
|
|
session_id,
|
|
score: session.score,
|
|
total: session.total,
|
|
percent: session.total ? Math.round((session.score / session.total) * 100) : 0,
|
|
show_answers,
|
|
maxCombo,
|
|
review: buildReview(session_id),
|
|
});
|
|
}
|
|
|
|
/* ── GET /api/sessions/history ───────────────────────────────────────── */
|
|
function history(req, res) {
|
|
const limit = Math.min(100, Math.max(1, Number(req.query.limit) || 20));
|
|
const cursor = Number(req.query.cursor) || 0;
|
|
|
|
// Cursor-based: if cursor provided, use it; otherwise fall back to offset pagination
|
|
if (cursor) {
|
|
const rows = db.prepare(`
|
|
SELECT ts.id, ts.mode, ts.score, ts.total, ts.status,
|
|
ts.started_at, ts.finished_at,
|
|
s.slug AS subject_slug, s.name AS subject_name
|
|
FROM test_sessions ts
|
|
LEFT JOIN subjects s ON s.id = ts.subject_id
|
|
WHERE ts.user_id = ? AND ts.id < ?
|
|
ORDER BY ts.id DESC
|
|
LIMIT ?
|
|
`).all(req.user.id, cursor, limit);
|
|
const nextCursor = rows.length === limit ? rows[rows.length - 1].id : null;
|
|
return res.json({ rows, nextCursor, limit });
|
|
}
|
|
|
|
// Offset-based (legacy)
|
|
const page = Math.max(1, Number(req.query.page) || 1);
|
|
const offset = (page - 1) * limit;
|
|
const { total } = db.prepare('SELECT COUNT(*) AS total FROM test_sessions WHERE user_id = ?').get(req.user.id);
|
|
const rows = db.prepare(`
|
|
SELECT ts.id, ts.mode, ts.score, ts.total, ts.status,
|
|
ts.started_at, ts.finished_at,
|
|
s.slug AS subject_slug, s.name AS subject_name
|
|
FROM test_sessions ts
|
|
LEFT JOIN subjects s ON s.id = ts.subject_id
|
|
WHERE ts.user_id = ?
|
|
ORDER BY ts.started_at DESC
|
|
LIMIT ? OFFSET ?
|
|
`).all(req.user.id, limit, offset);
|
|
res.json({ rows, total, page, limit });
|
|
}
|
|
|
|
/* ── helpers ─────────────────────────────────────────────────────────── */
|
|
function _placeholders(n) { return Array(n).fill('?').join(','); }
|
|
|
|
function loadQuestionsForSession(ids) {
|
|
if (!ids.length) return [];
|
|
const ph = _placeholders(ids.length);
|
|
|
|
const questions = db.prepare(
|
|
`SELECT id, text, type, difficulty, allow_html, image FROM questions WHERE id IN (${ph})`
|
|
).all(...ids);
|
|
|
|
const allOptions = db.prepare(
|
|
`SELECT question_id, id, text, match_pair FROM options WHERE question_id IN (${ph}) ORDER BY question_id, order_index`
|
|
).all(...ids);
|
|
|
|
const optsByQ = {};
|
|
for (const o of allOptions) (optsByQ[o.question_id] ??= []).push(o);
|
|
|
|
// Restore caller-expected order
|
|
const qMap = {};
|
|
for (const q of questions) qMap[q.id] = q;
|
|
|
|
return ids.map(id => {
|
|
const q = qMap[id];
|
|
if (!q) return null;
|
|
q.options = q.type !== 'short_answer' ? (optsByQ[id] || []) : [];
|
|
return q;
|
|
}).filter(Boolean);
|
|
}
|
|
|
|
function buildReview(session_id) {
|
|
const sqRows = db.prepare(
|
|
'SELECT question_id FROM session_questions WHERE session_id = ? ORDER BY order_index'
|
|
).all(session_id);
|
|
if (!sqRows.length) return [];
|
|
|
|
const ids = sqRows.map(r => r.question_id);
|
|
const ph = _placeholders(ids.length);
|
|
|
|
const questions = db.prepare(
|
|
`SELECT id, text, type, explanation, correct_text, allow_html, image FROM questions WHERE id IN (${ph})`
|
|
).all(...ids);
|
|
|
|
const answers = db.prepare(
|
|
`SELECT question_id, chosen_option_id, answer_text, is_correct FROM user_answers WHERE session_id = ? AND question_id IN (${ph})`
|
|
).all(session_id, ...ids);
|
|
|
|
const options = db.prepare(
|
|
`SELECT question_id, id, text, is_correct, match_pair FROM options WHERE question_id IN (${ph}) ORDER BY question_id, order_index`
|
|
).all(...ids);
|
|
|
|
const ansMap = {};
|
|
for (const a of answers) ansMap[a.question_id] = a;
|
|
|
|
const optsByQ = {};
|
|
for (const o of options) (optsByQ[o.question_id] ??= []).push(o);
|
|
|
|
const qMap = {};
|
|
for (const q of questions) qMap[q.id] = q;
|
|
|
|
return ids.map(id => {
|
|
const q = qMap[id];
|
|
const ua = ansMap[id];
|
|
return {
|
|
...q,
|
|
options: optsByQ[id] || [],
|
|
chosen_option_id: ua?.chosen_option_id ?? null,
|
|
answer_text: ua?.answer_text ?? null,
|
|
is_correct: ua ? ua.is_correct === 1 : null,
|
|
};
|
|
});
|
|
}
|
|
|
|
/* ── GET /api/sessions/weak-topics ───────────────────────────────────── */
|
|
function weakTopics(req, res) {
|
|
const rows = db.prepare(`
|
|
SELECT t.id AS topic_id,
|
|
t.name AS topic,
|
|
s.name AS subject_name,
|
|
s.slug AS subject_slug,
|
|
COUNT(ua.id) AS total,
|
|
SUM(CASE WHEN ua.is_correct = 0 THEN 1 ELSE 0 END) AS wrong,
|
|
ROUND(
|
|
CAST(SUM(CASE WHEN ua.is_correct = 0 THEN 1 ELSE 0 END) AS REAL)
|
|
/ COUNT(ua.id) * 100
|
|
, 0) AS error_pct
|
|
FROM user_answers ua
|
|
JOIN test_sessions ts ON ts.id = ua.session_id
|
|
JOIN questions q ON q.id = ua.question_id
|
|
JOIN topics t ON t.id = q.topic_id
|
|
JOIN subjects s ON s.id = q.subject_id
|
|
WHERE ts.user_id = ? AND ts.status = 'completed' AND q.topic_id IS NOT NULL
|
|
GROUP BY q.topic_id
|
|
HAVING total >= 2
|
|
ORDER BY error_pct DESC, wrong DESC
|
|
LIMIT 8
|
|
`).all(req.user.id);
|
|
|
|
res.json(rows);
|
|
}
|
|
|
|
/* ── GET /api/sessions/:id/questions ── resume existing session ─────── */
|
|
function getSessionQuestions(req, res) {
|
|
const session_id = Number(req.params.id);
|
|
const session = db.prepare(`
|
|
SELECT ts.id, ts.mode, ts.total, ts.status, ts.started_at, s.slug AS subject_slug
|
|
FROM test_sessions ts
|
|
LEFT JOIN subjects s ON s.id = ts.subject_id
|
|
WHERE ts.id = ? AND ts.user_id = ?
|
|
`).get(session_id, req.user.id);
|
|
if (!session) return res.status(404).json({ error: 'Session not found' });
|
|
if (session.status !== 'in_progress') return res.status(400).json({ error: 'Session already finished' });
|
|
|
|
const ids = db.prepare(
|
|
'SELECT question_id FROM session_questions WHERE session_id = ? ORDER BY order_index'
|
|
).all(session_id).map(r => r.question_id);
|
|
|
|
// Resolve time limit from linked test (if any)
|
|
let time_limit_sec = null;
|
|
try {
|
|
const tl = db.prepare(`
|
|
SELECT t.time_limit FROM assignment_sessions ases
|
|
JOIN assignments a ON a.id = ases.assignment_id
|
|
JOIN tests t ON t.id = a.test_id
|
|
WHERE ases.session_id = ? AND t.time_limit IS NOT NULL
|
|
`).get(session_id);
|
|
if (tl?.time_limit) time_limit_sec = tl.time_limit * 60;
|
|
} catch {}
|
|
|
|
const questions = loadQuestionsForSession(ids);
|
|
res.json({
|
|
session_id,
|
|
total: session.total,
|
|
mode: session.mode,
|
|
subject_slug: session.subject_slug,
|
|
questions,
|
|
time_limit_sec,
|
|
started_at: session.started_at,
|
|
});
|
|
}
|
|
|
|
/* ── GET /api/sessions/stats ── student dashboard charts ────────────── */
|
|
function stats(req, res) {
|
|
const uid = req.user.id;
|
|
|
|
// 2 queries instead of 7: mega-CTE returns all session data as JSON columns
|
|
const row = stmts.statsMega.get({ uid });
|
|
const weekly = JSON.parse(row.weekly);
|
|
const heatmap = JSON.parse(row.heatmap);
|
|
const bySubject = JSON.parse(row.bySubject);
|
|
const trend = JSON.parse(row.trend).reverse();
|
|
const totals = JSON.parse(row.totals);
|
|
const dayKeys = new Set(JSON.parse(row.streakDays));
|
|
const courseProgress = stmts.courseProgress.all({ uid });
|
|
|
|
// Streak calculation (JS side, uses pre-fetched day set)
|
|
let streak = 0;
|
|
const now = new Date();
|
|
for (let i = 0; i <= 90; i++) {
|
|
const d = new Date(now);
|
|
d.setDate(d.getDate() - i);
|
|
const key = d.toISOString().slice(0, 10);
|
|
if (dayKeys.has(key)) streak++;
|
|
else if (i > 0) break;
|
|
}
|
|
|
|
res.json({
|
|
weekly: weekly.map(r => ({ week: r.week, sessions: r.sessions, avgPct: Math.round(r.avg_pct || 0) })),
|
|
heatmap: heatmap.map(r => ({ day: r.day, count: r.count })),
|
|
bySubject: bySubject.map(r => ({
|
|
slug: r.slug, name: r.name, sessions: r.sessions,
|
|
avgPct: Math.round(r.avg_pct || 0),
|
|
correct: r.total_correct, questions: r.total_questions,
|
|
})),
|
|
trend: trend.map(r => ({
|
|
pct: r.total > 0 ? Math.round(r.score * 100 / r.total) : 0,
|
|
date: r.finished_at, subject: r.subject_slug,
|
|
})),
|
|
streak,
|
|
totals: {
|
|
sessions: totals.sessions || 0,
|
|
correct: totals.correct || 0,
|
|
questions:totals.questions|| 0,
|
|
avgPct: Math.round(totals.avg_pct || 0),
|
|
},
|
|
courseProgress: courseProgress.map(r => ({
|
|
id: r.id, title: r.title, emoji: r.cover_emoji,
|
|
subjectSlug: r.subject_slug,
|
|
done: r.done_lessons, total: r.total_lessons,
|
|
pct: r.total_lessons > 0 ? Math.round(r.done_lessons * 100 / r.total_lessons) : 0,
|
|
})),
|
|
});
|
|
}
|
|
|
|
module.exports = { start, answer, finish, result, history, weakTopics, getSessionQuestions, stats };
|