You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

477 lines
14 KiB

/**
* Прохождение теста: контент для игры, проверка ответов, завершение попытки.
*/
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<string, string | string[] | undefined> | 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;
}