0fe04d4d99
- Сервисы: testAttemptService, testAccess, document import/gen/extract, LLM, assignment, aiEditor - Конфиг: devAuthor, featureFlags; messages/ru; интеграция V.9 (skip без БД) - API/роуты: app, auth, server; Dockerfile и env example - Фронт: TestAttempt, TestAttemptReview, AttemptReviewBlock, стили, правки App/api/login/vite - compose и README; смоук-тесты расширены Закрывает отсутствие модулей в origin после клона. Made-with: Cursor
478 lines
14 KiB
JavaScript
478 lines
14 KiB
JavaScript
/**
|
|
* Прохождение теста: контент для игры, проверка ответов, завершение попытки.
|
|
*/
|
|
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;
|
|
}
|