/** * Прохождение теста: контент для игры, проверка ответов, завершение попытки. */ import { RU } from '../messages/ru.js'; import { isTestAuthor } from '../config/devAuthor.js'; /** * @param {import('pg').Pool|import('pg').PoolClient} db * @param {string} testVersionId * @param {{ includeCorrect: boolean }} opts */ export async function loadQuestionsForVersion(db, testVersionId, opts) { const { rows: qrows } = await db.query( `SELECT id, text, question_order, has_multiple_answers FROM questions WHERE test_version_id = $1 ORDER BY question_order`, [testVersionId] ); const out = []; for (const row of qrows) { const { rows: orows } = await db.query( `SELECT id, text, is_correct, option_order FROM answer_options WHERE question_id = $1 ORDER BY option_order`, [row.id] ); const options = orows.map((o) => { const base = { id: o.id, text: o.text, optionOrder: o.option_order, }; if (opts.includeCorrect) { return { ...base, isCorrect: o.is_correct }; } return base; }); out.push({ id: row.id, text: row.text, questionOrder: row.question_order, hasMultipleAnswers: row.has_multiple_answers, options, }); } return out; } function sortUuidStrings(arr) { return [...new Set(arr)].map(String).sort(); } function sameSelection(selected, correctIds) { const a = sortUuidStrings(selected); const b = sortUuidStrings(correctIds); if (a.length !== b.length) { return false; } return a.every((x, i) => x === b[i]); } /** * @param {import('pg').Pool} pool * @param {string} userId * @param {string} testId */ export async function getEditorContent(pool, userId, testId) { const { rows: tr } = await pool.query( `SELECT t.id, t.title, t.description, t.passing_threshold, t.created_by FROM tests t WHERE t.id = $1`, [testId] ); if (!tr.length) { const e = new Error(RU.testNotFound); e.status = 404; throw e; } if (!isTestAuthor(tr[0].created_by, userId)) { const e = new Error(RU.forbidden); e.status = 403; throw e; } const { rows: tv } = await pool.query( `SELECT id FROM test_versions WHERE test_id = $1 AND is_active = true LIMIT 1`, [testId] ); if (!tv.length) { const e = new Error(RU.noActiveVersion); e.status = 400; throw e; } const versionId = tv[0].id; const questions = await loadQuestionsForVersion(pool, versionId, { includeCorrect: true, }); return { test: { id: tr[0].id, title: tr[0].title, description: tr[0].description, passingThreshold: tr[0].passing_threshold, }, activeVersionId: versionId, questions, }; } /** * @param {import('pg').Pool} pool * @param {string} userId * @param {string} testId * @param {string} attemptId */ export async function getPlayContent(pool, userId, testId, attemptId) { const { rows: arows } = await pool.query( `SELECT ta.id, ta.user_id, ta.status, ta.test_version_id, tv.test_id, t.title, t.passing_threshold FROM test_attempts ta INNER JOIN test_versions tv ON tv.id = ta.test_version_id INNER JOIN tests t ON t.id = tv.test_id WHERE ta.id = $1`, [attemptId] ); if (!arows.length) { const e = new Error(RU.attemptNotFound); e.status = 404; throw e; } const a = arows[0]; if (a.test_id !== testId) { const e = new Error(RU.attemptNotFound); e.status = 404; throw e; } if (a.user_id !== userId) { const e = new Error(RU.forbidden); e.status = 403; throw e; } if (a.status !== 'in_progress') { const e = new Error(RU.attemptNotInProgress); e.status = 400; throw e; } const questions = await loadQuestionsForVersion(pool, a.test_version_id, { includeCorrect: false, }); return { testTitle: a.title, passingThreshold: a.passing_threshold, attemptId: a.id, questions, }; } /** * @param {import('pg').Pool} pool * @param {string} userId * @param {string} testId * @param {string} attemptId * @param {Record | null | undefined} rawAnswers */ export async function submitAttempt(pool, userId, testId, attemptId, rawAnswers) { const answers = rawAnswers && typeof rawAnswers === 'object' ? rawAnswers : {}; const client = await pool.connect(); try { await client.query('BEGIN'); const { rows: arows } = await client.query( `SELECT id, user_id, status, test_version_id FROM test_attempts WHERE id = $1 FOR UPDATE`, [attemptId] ); if (!arows.length) { const e = new Error(RU.attemptNotFound); e.status = 404; throw e; } const a0 = arows[0]; const { rows: trows } = await client.query( `SELECT t.passing_threshold, tv.test_id FROM test_versions tv INNER JOIN tests t ON t.id = tv.test_id WHERE tv.id = $1`, [a0.test_version_id] ); if (!trows.length) { const e = new Error(RU.testNotFound); e.status = 404; throw e; } const link = trows[0]; const a = { test_id: link.test_id, user_id: a0.user_id, status: a0.status, test_version_id: a0.test_version_id, passing_threshold: link.passing_threshold, }; if (a.test_id !== testId) { const e = new Error(RU.attemptNotFound); e.status = 404; throw e; } if (a.user_id !== userId) { const e = new Error(RU.forbidden); e.status = 403; throw e; } if (a.status !== 'in_progress') { const e = new Error(RU.attemptNotInProgress); e.status = 400; throw e; } const versionId = a.test_version_id; const threshold = Number(a.passing_threshold) || 0; const { rows: qrows } = await client.query( `SELECT id, has_multiple_answers FROM questions WHERE test_version_id = $1`, [versionId] ); if (!qrows.length) { const e = new Error(RU.testHasNoQuestions); e.status = 400; throw e; } const { rows: allOpts } = await client.query( `SELECT a.id, a.question_id, a.is_correct FROM answer_options a INNER JOIN questions q ON q.id = a.question_id WHERE q.test_version_id = $1`, [versionId] ); const byQuestion = new Map(); for (const o of allOpts) { if (!byQuestion.has(o.question_id)) { byQuestion.set(o.question_id, { all: new Set(), correct: [] }); } const g = byQuestion.get(o.question_id); g.all.add(String(o.id)); if (o.is_correct) { g.correct.push(String(o.id)); } } let correctCount = 0; for (const q of qrows) { const qid = String(q.id); let selected = answers[qid] ?? answers[q.id]; if (selected == null) { selected = []; } else if (!Array.isArray(selected)) { selected = [String(selected)]; } else { selected = selected.map(String); } const g = byQuestion.get(q.id); if (!g) { continue; } for (const sid of selected) { if (!g.all.has(sid)) { const e = new Error(RU.invalidOptionForQuestion); e.status = 400; throw e; } } if (sameSelection(selected, g.correct)) { correctCount += 1; } } const total = qrows.length; const percent = (correctCount / total) * 100; const passed = percent + 1e-9 >= threshold; await client.query(`DELETE FROM user_answers WHERE attempt_id = $1`, [attemptId]); for (const q of qrows) { const qid = String(q.id); let selected = answers[qid] ?? answers[q.id] ?? []; if (!Array.isArray(selected)) { selected = [String(selected)]; } else { selected = selected.map(String); } await client.query( `INSERT INTO user_answers (attempt_id, question_id, selected_options) VALUES ($1, $2, $3::uuid[])`, [attemptId, q.id, selected] ); } await client.query( `UPDATE test_attempts SET status = 'completed', completed_at = CURRENT_TIMESTAMP, correct_count = $2, total_questions = $3, passed = $4 WHERE id = $1`, [attemptId, correctCount, total, passed] ); await client.query('COMMIT'); const base = { attemptId, correctCount, totalQuestions: total, percent: Math.round(percent * 10) / 10, passed, passingThreshold: threshold, }; const review = await buildReviewFromDb(pool, attemptId); return { ...base, review }; } catch (e) { await client.query('ROLLBACK'); throw e; } finally { client.release(); } } /** * Подробный разбор завершённой попытки (для API и ответа submit). * @param {import('pg').Pool|import('pg').PoolClient} pool * @param {string} attemptId */ export async function buildReviewFromDb(pool, attemptId) { const { rows: arows } = await pool.query( `SELECT ta.id, ta.status, ta.test_version_id, ta.user_id, ta.correct_count, ta.total_questions, ta.passed, ta.started_at, ta.completed_at, t.id AS test_id, t.title, t.passing_threshold, u.full_name AS attempter_name, u.login AS attempter_login FROM test_attempts ta INNER JOIN test_versions tv ON tv.id = ta.test_version_id INNER JOIN tests t ON t.id = tv.test_id INNER JOIN users u ON u.id = ta.user_id WHERE ta.id = $1`, [attemptId] ); if (!arows.length) { const e = new Error(RU.attemptNotFound); e.status = 404; throw e; } const a = arows[0]; if (a.status !== 'completed') { const e = new Error(RU.attemptNotCompleted); e.status = 400; throw e; } const questions = await loadQuestionsForVersion(pool, a.test_version_id, { includeCorrect: true, }); const { rows: uans } = await pool.query( `SELECT question_id, selected_options FROM user_answers WHERE attempt_id = $1`, [attemptId] ); const selByQ = new Map(); for (const r of uans) { selByQ.set(String(r.question_id), (r.selected_options || []).map(String)); } const threshold = Number(a.passing_threshold) || 0; const total = a.total_questions || questions.length; const percent = total > 0 ? Math.round(((a.correct_count || 0) / total) * 1000) / 10 : 0; const qOut = questions.map((q) => { const selected = sortUuidStrings(selByQ.get(String(q.id)) || []); const correctIdList = sortUuidStrings( q.options.filter((o) => o.isCorrect).map((o) => String(o.id)) ); const isUserCorrect = sameSelection(selected, correctIdList); const selectedSet = new Set(selected); return { id: q.id, text: q.text, hasMultipleAnswers: q.hasMultipleAnswers, isUserCorrect, options: q.options.map((o) => ({ id: o.id, text: o.text, isCorrect: o.isCorrect, selected: selectedSet.has(String(o.id)), })), }; }); return { attemptId: a.id, testId: a.test_id, testTitle: a.title, passingThreshold: threshold, correctCount: a.correct_count, totalQuestions: total, percent, passed: a.passed, startedAt: a.started_at, completedAt: a.completed_at, attempterUserId: a.user_id, attempterName: a.attempter_name, attempterLogin: a.attempter_login, questions: qOut, }; } /** * Разбор попытки: владелец попытки или автор теста. * @param {import('pg').Pool} pool * @param {string} currentUserId * @param {string} testId * @param {string} attemptId */ export async function getAttemptReviewForUser(pool, currentUserId, testId, attemptId) { const { rows } = await pool.query( `SELECT ta.user_id, t.created_by, tv.test_id FROM test_attempts ta INNER JOIN test_versions tv ON tv.id = ta.test_version_id INNER JOIN tests t ON t.id = tv.test_id WHERE ta.id = $1`, [attemptId] ); if (!rows.length) { const e = new Error(RU.attemptNotFound); e.status = 404; throw e; } const r0 = rows[0]; if (r0.test_id !== testId) { const e = new Error(RU.attemptNotFound); e.status = 404; throw e; } const isOwner = r0.user_id === currentUserId; const isAuthor = isTestAuthor(r0.created_by, currentUserId); if (!isOwner && !isAuthor) { const e = new Error(RU.forbidden); e.status = 403; throw e; } return buildReviewFromDb(pool, attemptId); } /** * Список всех попыток по цепочке (все версии) — только автор. * @param {import('pg').Pool} pool * @param {string} authorId * @param {string} testId */ export async function listTestAttemptsForAuthor(pool, authorId, testId) { const { rows: t } = await pool.query( `SELECT id, created_by FROM tests WHERE id = $1`, [testId] ); if (!t.length) { const e = new Error(RU.testNotFound); e.status = 404; throw e; } if (!isTestAuthor(t[0].created_by, authorId)) { const e = new Error(RU.forbidden); e.status = 403; throw e; } const { rows } = await pool.query( `SELECT ta.id, ta.user_id, ta.status, ta.attempt_number, ta.started_at, ta.completed_at, ta.correct_count, ta.total_questions, ta.passed, tv.version AS test_version, u.full_name AS attempter_name, u.login AS attempter_login FROM test_attempts ta INNER JOIN test_versions tv ON tv.id = ta.test_version_id INNER JOIN users u ON u.id = ta.user_id WHERE tv.test_id = $1 ORDER BY ta.started_at DESC NULLS LAST LIMIT 200`, [testId] ); return rows; }