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 };