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:
@@ -0,0 +1,155 @@
|
||||
/**
|
||||
* A.1–A.4: локальный bcrypt (dev) и HR (HR_AUTH=1 + Werkzeug + staff_id)
|
||||
*/
|
||||
import express from 'express';
|
||||
import { query } from '../db/db.js';
|
||||
import { comparePassword, generateToken } from '../utils/auth.js';
|
||||
import { authenticate } from '../middleware/auth.js';
|
||||
import { queryHr, getHrPool } from '../db/hrPool.js';
|
||||
import { mapHrRoleToApp } from '../utils/hrRoleMap.js';
|
||||
import {
|
||||
isHrAuthEnabled,
|
||||
HR_MANAGED_PASSWORD_PLACEHOLDER,
|
||||
} from '../config/authConstants.js';
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
router.post('/login', async (req, res) => {
|
||||
try {
|
||||
const { login, password } = req.body;
|
||||
if (!login || !password) {
|
||||
return res.status(400).json({ error: 'Login and password are required' });
|
||||
}
|
||||
|
||||
if (isHrAuthEnabled()) {
|
||||
if (!getHrPool()) {
|
||||
return res.status(500).json({ error: 'HR_DATABASE_URL is not set' });
|
||||
}
|
||||
const u = await queryHr(
|
||||
`SELECT id, username, password_hash, role
|
||||
FROM users
|
||||
WHERE LOWER(TRIM(username)) = LOWER(TRIM($1))`,
|
||||
[login]
|
||||
);
|
||||
if (u.rows.length === 0 || !u.rows[0].password_hash) {
|
||||
return res.status(401).json({ error: 'Invalid credentials' });
|
||||
}
|
||||
const row = u.rows[0];
|
||||
const ok = await comparePassword(password, row.password_hash);
|
||||
if (!ok) {
|
||||
return res.status(401).json({ error: 'Invalid credentials' });
|
||||
}
|
||||
const s = await queryHr(
|
||||
`SELECT id, fio FROM staff_members
|
||||
WHERE LOWER(TRIM(COALESCE(web_login, ''))) = LOWER(TRIM($1))`,
|
||||
[login]
|
||||
);
|
||||
if (s.rows.length === 0) {
|
||||
return res
|
||||
.status(403)
|
||||
.json({ error: 'No staff link for this login (web_login)' });
|
||||
}
|
||||
const staffId = s.rows[0].id;
|
||||
const fio = s.rows[0].fio || login;
|
||||
const appRole = mapHrRoleToApp(row.role);
|
||||
const up = await query(
|
||||
`INSERT INTO users (login, password_hash, full_name, role, department_id, is_active, staff_id)
|
||||
VALUES ($1, $2, $3, $4, null, true, $5)
|
||||
ON CONFLICT (staff_id) DO UPDATE SET
|
||||
login = EXCLUDED.login,
|
||||
full_name = EXCLUDED.full_name,
|
||||
role = EXCLUDED.role,
|
||||
password_hash = EXCLUDED.password_hash
|
||||
RETURNING id, login, full_name, role, department_id, staff_id`,
|
||||
[login, HR_MANAGED_PASSWORD_PLACEHOLDER, fio, appRole, staffId]
|
||||
);
|
||||
const uu = up.rows[0];
|
||||
const token = generateToken(
|
||||
uu.id,
|
||||
uu.role,
|
||||
uu.department_id,
|
||||
{ staffId: uu.staff_id }
|
||||
);
|
||||
res.cookie('token', token, {
|
||||
httpOnly: true,
|
||||
secure: process.env.NODE_ENV === 'production',
|
||||
sameSite: 'strict',
|
||||
maxAge: 7 * 24 * 60 * 60 * 1000,
|
||||
});
|
||||
return res.json({
|
||||
user: {
|
||||
id: uu.id,
|
||||
login: uu.login,
|
||||
fullName: uu.full_name,
|
||||
role: uu.role,
|
||||
departmentId: uu.department_id,
|
||||
staffId: uu.staff_id,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
const result = await query(
|
||||
'SELECT id, login, password_hash, full_name, role, department_id FROM users WHERE login = $1 AND is_active = true',
|
||||
[login]
|
||||
);
|
||||
if (result.rows.length === 0) {
|
||||
return res.status(401).json({ error: 'Invalid credentials' });
|
||||
}
|
||||
const user = result.rows[0];
|
||||
if (user.password_hash === HR_MANAGED_PASSWORD_PLACEHOLDER) {
|
||||
return res.status(401).json({ error: 'Use HR login' });
|
||||
}
|
||||
const isValidPassword = await comparePassword(password, user.password_hash);
|
||||
if (!isValidPassword) {
|
||||
return res.status(401).json({ error: 'Invalid credentials' });
|
||||
}
|
||||
const token = generateToken(user.id, user.role, user.department_id);
|
||||
res.cookie('token', token, {
|
||||
httpOnly: true,
|
||||
secure: process.env.NODE_ENV === 'production',
|
||||
sameSite: 'strict',
|
||||
maxAge: 7 * 24 * 60 * 60 * 1000,
|
||||
});
|
||||
return res.json({
|
||||
user: {
|
||||
id: user.id,
|
||||
login: user.login,
|
||||
fullName: user.full_name,
|
||||
role: user.role,
|
||||
departmentId: user.department_id,
|
||||
staffId: null,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
if (error.message?.includes('HR database not configured')) {
|
||||
return res.status(500).json({ error: 'HR database not configured' });
|
||||
}
|
||||
console.error('Login error:', error);
|
||||
return res.status(500).json({ error: 'Login failed' });
|
||||
}
|
||||
});
|
||||
|
||||
router.post('/logout', (req, res) => {
|
||||
try {
|
||||
res.clearCookie('token', {
|
||||
httpOnly: true,
|
||||
secure: process.env.NODE_ENV === 'production',
|
||||
sameSite: 'strict',
|
||||
});
|
||||
res.json({ message: 'Logged out successfully' });
|
||||
} catch (error) {
|
||||
console.error('Logout error:', error);
|
||||
res.status(500).json({ error: 'Logout failed' });
|
||||
}
|
||||
});
|
||||
|
||||
router.get('/me', authenticate, async (req, res) => {
|
||||
try {
|
||||
res.json({ user: req.user });
|
||||
} catch (error) {
|
||||
console.error('Get current user error:', error);
|
||||
res.status(500).json({ error: 'Failed to get user data' });
|
||||
}
|
||||
});
|
||||
|
||||
export default router;
|
||||
@@ -0,0 +1,243 @@
|
||||
/**
|
||||
* 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;
|
||||
Reference in New Issue
Block a user