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.

218 lines
7.0 KiB

/**
* 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();
}
}