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
219 lines
7.0 KiB
JavaScript
219 lines
7.0 KiB
JavaScript
/**
|
|
* V.3 saveTestDraft, fork версии, контент вопросов.
|
|
*/
|
|
import { hasAnyAttemptForTest } from './testChainService.js';
|
|
import { RU } from '../messages/ru.js';
|
|
import { isTestAuthor } from '../config/devAuthor.js';
|
|
|
|
/**
|
|
* @param {import('pg').PoolClient} client
|
|
* @param {string} testId
|
|
*/
|
|
export async function getActiveVersionRow(client, testId) {
|
|
const { rows } = await client.query(
|
|
`SELECT * FROM test_versions WHERE test_id = $1 AND is_active = true LIMIT 1`,
|
|
[testId]
|
|
);
|
|
return rows[0] || null;
|
|
}
|
|
|
|
/**
|
|
* @param {import('pg').PoolClient} client
|
|
* @param {string} fromVersionId
|
|
* @param {string} toVersionId
|
|
*/
|
|
export async function copyQuestionTree(client, fromVersionId, toVersionId) {
|
|
const { rows: questions } = await client.query(
|
|
`SELECT * FROM questions WHERE test_version_id = $1 ORDER BY question_order`,
|
|
[fromVersionId]
|
|
);
|
|
for (const q of questions) {
|
|
const { rows: insQ } = await client.query(
|
|
`INSERT INTO questions (test_version_id, text, question_order, has_multiple_answers)
|
|
VALUES ($1, $2, $3, $4) RETURNING id`,
|
|
[toVersionId, q.text, q.question_order, q.has_multiple_answers]
|
|
);
|
|
const nqid = insQ[0].id;
|
|
const { rows: options } = await client.query(
|
|
`SELECT * FROM answer_options WHERE question_id = $1 ORDER BY option_order`,
|
|
[q.id]
|
|
);
|
|
for (const o of options) {
|
|
await client.query(
|
|
`INSERT INTO answer_options (question_id, text, is_correct, option_order)
|
|
VALUES ($1, $2, $3, $4)`,
|
|
[nqid, o.text, o.is_correct, o.option_order]
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* @param {import('pg').PoolClient} client
|
|
* @param {string} testVersionId
|
|
* @param {{ questions?: Array<{ text: string, question_order?: number, hasMultipleAnswers?: boolean, options?: Array<{ text: string, isCorrect?: boolean, option_order?: number }> }> }} payload
|
|
*/
|
|
export async function replaceVersionContent(client, testVersionId, payload) {
|
|
await client.query(
|
|
`DELETE FROM answer_options WHERE question_id IN
|
|
(SELECT id FROM questions WHERE test_version_id = $1)`,
|
|
[testVersionId]
|
|
);
|
|
await client.query(`DELETE FROM questions WHERE test_version_id = $1`, [
|
|
testVersionId,
|
|
]);
|
|
const questions = payload.questions || [];
|
|
for (let i = 0; i < questions.length; i++) {
|
|
const q = questions[i];
|
|
const { rows: insQ } = await client.query(
|
|
`INSERT INTO questions (test_version_id, text, question_order, has_multiple_answers)
|
|
VALUES ($1, $2, $3, $4) RETURNING id`,
|
|
[
|
|
testVersionId,
|
|
q.text,
|
|
q.question_order ?? i + 1,
|
|
q.hasMultipleAnswers || false,
|
|
]
|
|
);
|
|
const qid = insQ[0].id;
|
|
const opts = q.options || [];
|
|
for (let j = 0; j < opts.length; j++) {
|
|
const o = opts[j];
|
|
await client.query(
|
|
`INSERT INTO answer_options (question_id, text, is_correct, option_order)
|
|
VALUES ($1, $2, $3, $4)`,
|
|
[qid, o.text, !!o.isCorrect, o.option_order ?? j + 1]
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* @param {import('pg').PoolClient} client
|
|
* @param {string} testId
|
|
*/
|
|
export async function forkNewVersion(client, testId) {
|
|
const av = await getActiveVersionRow(client, testId);
|
|
if (!av) {
|
|
throw new Error(RU.noActiveVersion);
|
|
}
|
|
const { rows: mx } = await client.query(
|
|
`SELECT COALESCE(MAX(version), 0) AS v FROM test_versions WHERE test_id = $1`,
|
|
[testId]
|
|
);
|
|
const nextV = (mx[0].v || 0) + 1;
|
|
// Сначала снять is_active с цепочки: частичный уникальный индекс
|
|
// uq_test_versions_one_active_per_test — не более одной true на test_id.
|
|
await client.query(
|
|
`UPDATE test_versions SET is_active = false WHERE test_id = $1`,
|
|
[testId]
|
|
);
|
|
const { rows: nv } = await client.query(
|
|
`INSERT INTO test_versions (test_id, version, is_active, parent_id)
|
|
VALUES ($1, $2, true, $3) RETURNING *`,
|
|
[testId, nextV, av.id]
|
|
);
|
|
const newRow = nv[0];
|
|
await copyQuestionTree(client, av.id, newRow.id);
|
|
return newRow;
|
|
}
|
|
|
|
/**
|
|
* @param {import('pg').Pool} pool
|
|
* @param {string} authorId
|
|
* @param {string} testId
|
|
* @param {{ title?: string, description?: string, questions?: Array<unknown> }} payload
|
|
*/
|
|
export async function saveTestDraft(pool, authorId, testId, payload) {
|
|
const { rows: tr } = await pool.query(
|
|
`SELECT id, created_by FROM tests WHERE id = $1`,
|
|
[testId]
|
|
);
|
|
if (!tr.length) {
|
|
const e = new Error(RU.testNotFound);
|
|
e.status = 404;
|
|
throw e;
|
|
}
|
|
const t = tr[0];
|
|
if (!isTestAuthor(t.created_by, authorId)) {
|
|
const e = new Error(RU.forbidden);
|
|
e.status = 403;
|
|
throw e;
|
|
}
|
|
|
|
const client = await pool.connect();
|
|
let forked = false;
|
|
try {
|
|
await client.query('BEGIN');
|
|
if (payload.title != null || payload.description != null) {
|
|
await client.query(
|
|
`UPDATE tests SET title = COALESCE($2, title), description = COALESCE($3, description),
|
|
updated_at = CURRENT_TIMESTAMP WHERE id = $1`,
|
|
[testId, payload.title ?? null, payload.description ?? null]
|
|
);
|
|
}
|
|
if (payload.passingThreshold !== undefined && payload.passingThreshold !== null) {
|
|
const raw = Number(payload.passingThreshold);
|
|
if (Number.isFinite(raw)) {
|
|
const pt = Math.max(0, Math.min(100, Math.round(raw)));
|
|
await client.query(
|
|
`UPDATE tests SET passing_threshold = $2, updated_at = CURRENT_TIMESTAMP WHERE id = $1`,
|
|
[testId, pt]
|
|
);
|
|
}
|
|
}
|
|
const hasAttempts = await hasAnyAttemptForTest(client, testId);
|
|
let versionRow = await getActiveVersionRow(client, testId);
|
|
if (!versionRow) {
|
|
const e = new Error(RU.noActiveVersion);
|
|
e.status = 500;
|
|
throw e;
|
|
}
|
|
if (hasAttempts && payload.questions !== undefined) {
|
|
versionRow = await forkNewVersion(client, testId);
|
|
forked = true;
|
|
}
|
|
if (payload.questions) {
|
|
await replaceVersionContent(client, versionRow.id, payload);
|
|
}
|
|
await client.query('COMMIT');
|
|
return { testId, versionId: versionRow.id, forked };
|
|
} catch (e) {
|
|
await client.query('ROLLBACK');
|
|
throw e;
|
|
} finally {
|
|
client.release();
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Создать пустой тест (цепочка) с одной версией 1.
|
|
* @param {import('pg').Pool} pool
|
|
* @param {string} authorId
|
|
* @param {{ title: string, description?: string }} meta
|
|
*/
|
|
export async function createTestWithVersion(pool, authorId, meta) {
|
|
const client = await pool.connect();
|
|
try {
|
|
await client.query('BEGIN');
|
|
const { rows: t } = await client.query(
|
|
`INSERT INTO tests (title, description, created_by, is_active, is_versioned)
|
|
VALUES ($1, $2, $3, true, true) RETURNING id`,
|
|
[meta.title, meta.description || null, authorId]
|
|
);
|
|
const testId = t[0].id;
|
|
const { rows: v } = await client.query(
|
|
`INSERT INTO test_versions (test_id, version, is_active, parent_id)
|
|
VALUES ($1, 1, true, NULL) RETURNING id`,
|
|
[testId]
|
|
);
|
|
await client.query('COMMIT');
|
|
return { testId, versionId: v[0].id };
|
|
} catch (e) {
|
|
await client.query('ROLLBACK');
|
|
throw e;
|
|
} finally {
|
|
client.release();
|
|
}
|
|
}
|