/** * V.4–V.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;