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:
Константин Лебединский
2026-04-24 20:30:09 +05:00
parent 7fa6f98ee1
commit 5631d85238
37 changed files with 9687 additions and 59 deletions
+204
View File
@@ -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();
}
}