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:
Константин Лебединский
2026-04-24 20:30:09 +05:00
parent 7fa6f98ee1
commit 5631d85238
37 changed files with 9687 additions and 59 deletions
+155
View File
@@ -0,0 +1,155 @@
/**
* A.1A.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;