Files
TestingWebApp/backend/src/routes/tests.js
T
Константин Лебединский 5631d85238 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
2026-04-24 20:30:09 +05:00

244 lines
6.7 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* V.4V.6, D.1 — API тестов, версий, импорт файла
*/
import express from 'express';
import fs from 'fs/promises';
import os from 'os';
import multer from 'multer';
import pool, { query } from '../db/db.js';
import { authenticate } from '../middleware/auth.js';
import { hasAnyAttemptForTest } from '../services/testChainService.js';
import { saveTestDraft, createTestWithVersion } from '../services/testDraftService.js';
const router = express.Router();
const upload = multer({
dest: os.tmpdir(),
limits: { fileSize: 10 * 1024 * 1024 },
});
function asyncHandler(fn) {
return (req, res, next) => {
Promise.resolve(fn(req, res, next)).catch((err) => {
if (err.status) {
res.status(err.status).json({ error: err.message });
} else {
next(err);
}
});
};
}
/** D.1 — раньше /:id, иначе id = "import" */
router.post(
'/import/document',
authenticate,
upload.single('file'),
asyncHandler(async (req, res) => {
if (!req.file) {
return res.status(400).json({ error: 'file field required' });
}
const p = req.file.path;
let size = 0;
try {
const st = await fs.stat(p);
size = st.size;
await fs.unlink(p);
} catch {
try {
await fs.unlink(p);
} catch {
// ignore
}
return res.status(500).json({ error: 'upload failed' });
}
res.json({
received: true,
originalName: req.file.originalname,
size,
message:
'Файл принят; извлечение текста и генерация (D.2–D.3) — в следующем шаге',
});
})
);
router.get(
'/',
authenticate,
asyncHandler(async (req, res) => {
const { rows } = await query(
`SELECT t.id, t.title, t.description, t.is_active AS chain_active,
t.created_at, t.updated_at, tv.id AS active_version_id, tv.version
FROM tests t
INNER JOIN test_versions tv ON tv.test_id = t.id AND tv.is_active = true
WHERE t.is_active = true
ORDER BY t.updated_at DESC NULLS LAST, t.created_at DESC`
);
res.json({ tests: rows });
})
);
router.post(
'/',
authenticate,
asyncHandler(async (req, res) => {
const { title, description } = req.body;
if (!title || typeof title !== 'string') {
return res.status(400).json({ error: 'title required' });
}
const out = await createTestWithVersion(pool, req.user.id, {
title,
description,
});
res.status(201).json(out);
})
);
router.get(
'/:id/versions',
authenticate,
asyncHandler(async (req, res) => {
const testId = req.params.id;
const { rows: t } = await query(`SELECT id, created_by FROM tests WHERE id = $1`, [
testId,
]);
if (!t.length) {
return res.status(404).json({ error: 'Test not found' });
}
if (t[0].created_by !== req.user.id) {
return res.status(403).json({ error: 'Forbidden' });
}
const { rows } = await query(
`SELECT id, version, is_active, parent_id, created_at
FROM test_versions WHERE test_id = $1 ORDER BY version`,
[testId]
);
const hasAttempts = await hasAnyAttemptForTest(pool, testId);
res.json({ versions: rows, hasAttempts });
})
);
router.post(
'/:id/versions/:vid/activate',
authenticate,
asyncHandler(async (req, res) => {
const testId = req.params.id;
const versionId = req.params.vid;
const { rows: t } = await query(
`SELECT id, created_by FROM tests WHERE id = $1`,
[testId]
);
if (!t.length) {
return res.status(404).json({ error: 'Test not found' });
}
if (t[0].created_by !== req.user.id) {
return res.status(403).json({ error: 'Forbidden' });
}
const { rows: v } = await query(
`SELECT id FROM test_versions WHERE test_id = $1 AND id = $2`,
[testId, versionId]
);
if (!v.length) {
return res.status(404).json({ error: 'Version not found' });
}
const client = await pool.connect();
try {
await client.query('BEGIN');
await client.query(
`UPDATE test_versions SET is_active = false WHERE test_id = $1`,
[testId]
);
await client.query(
`UPDATE test_versions SET is_active = true WHERE id = $1`,
[versionId]
);
await client.query('COMMIT');
} catch (e) {
await client.query('ROLLBACK');
throw e;
} finally {
client.release();
}
res.json({ ok: true, activeVersionId: versionId });
})
);
router.patch(
'/:id',
authenticate,
asyncHandler(async (req, res) => {
const testId = req.params.id;
const { isActive, chainActive } = req.body;
const chain = chainActive ?? isActive;
if (typeof chain !== 'boolean') {
return res.status(400).json({ error: 'chainActive (boolean) required' });
}
const { rows: t } = await query(
`SELECT id, created_by FROM tests WHERE id = $1`,
[testId]
);
if (!t.length) {
return res.status(404).json({ error: 'Test not found' });
}
if (t[0].created_by !== req.user.id) {
return res.status(403).json({ error: 'Forbidden' });
}
await query(
`UPDATE tests SET is_active = $2, updated_at = CURRENT_TIMESTAMP WHERE id = $1`,
[testId, chain]
);
res.json({ id: testId, chainActive: chain });
})
);
router.post(
'/:id/draft',
authenticate,
asyncHandler(async (req, res) => {
const out = await saveTestDraft(pool, req.user.id, req.params.id, req.body);
res.json(out);
})
);
router.post(
'/:id/attempts/start',
authenticate,
asyncHandler(async (req, res) => {
const testId = req.params.id;
const { rows: tv } = await query(
`SELECT tv.id AS test_version_id
FROM test_versions tv
WHERE tv.test_id = $1 AND tv.is_active = true LIMIT 1`,
[testId]
);
if (!tv.length) {
return res.status(404).json({ error: 'No active version' });
}
const testVersionId = tv[0].test_version_id;
const { rows: mx } = await query(
`SELECT COALESCE(MAX(attempt_number), 0) AS n FROM test_attempts
WHERE test_version_id = $1 AND user_id = $2`,
[testVersionId, req.user.id]
);
const nextN = (mx[0].n || 0) + 1;
const { rows: a } = await query(
`INSERT INTO test_attempts (test_version_id, user_id, attempt_number, status)
VALUES ($1, $2, $3, 'in_progress')
RETURNING id, test_version_id, user_id, attempt_number, status, started_at`,
[testVersionId, req.user.id, nextN]
);
res.status(201).json({ attempt: a[0] });
})
);
router.get(
'/:id/chain-info',
authenticate,
asyncHandler(async (req, res) => {
const testId = req.params.id;
const has = await hasAnyAttemptForTest(pool, testId);
res.json({ testId, hasAnyAttempt: has });
})
);
export default router;