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