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

/**
* 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;