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