feat(card1): версии тестов API, черновик, HR-login, import, UI
- V.1–V.3: saveTestDraft, fork при попытках; миграция 003 staff_id - V.4–V.6: REST /api/tests, activate, PATCH, start attempt - A: HR_DATABASE_URL + Werkzeug/bcrypt, JWT staffId, HR_AUTH - D.1: multipart /api/tests/import/document - Frontend: login, список тестов, экран версий/черновика/попытки - ТЗ: V.10 назначения vs активная версия; журнал приёма Made-with: Cursor
This commit is contained in:
@@ -0,0 +1,204 @@
|
||||
/**
|
||||
* V.3 saveTestDraft, fork версии, контент вопросов.
|
||||
*/
|
||||
import { hasAnyAttemptForTest } from './testChainService.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('no active version');
|
||||
}
|
||||
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;
|
||||
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 client.query(
|
||||
`UPDATE test_versions SET is_active = false WHERE test_id = $1 AND id <> $2`,
|
||||
[testId, newRow.id]
|
||||
);
|
||||
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('Test not found');
|
||||
e.status = 404;
|
||||
throw e;
|
||||
}
|
||||
const t = tr[0];
|
||||
if (t.created_by !== authorId) {
|
||||
const e = new Error('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]
|
||||
);
|
||||
}
|
||||
const hasAttempts = await hasAnyAttemptForTest(client, testId);
|
||||
let versionRow = await getActiveVersionRow(client, testId);
|
||||
if (!versionRow) {
|
||||
const e = new Error('No active version');
|
||||
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();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user