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