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.
243 lines
6.7 KiB
243 lines
6.7 KiB
/** |
|
* 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;
|
|
|