Browse Source

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
dev
Константин Лебединский 2 weeks ago
parent
commit
5631d85238
  1. 6
      .gitignore
  2. 6
      backend/.env.example
  3. 15
      backend/.eslintrc.json
  4. 7
      backend/.prettierrc
  5. 2978
      backend/package-lock.json
  6. 9
      backend/package.json
  7. 45
      backend/src/app.js
  8. 7
      backend/src/config/authConstants.js
  9. 27
      backend/src/db/hrPool.js
  10. 130
      backend/src/db/migrations/001_initial.sql
  11. 7
      backend/src/db/migrations/003_users_staff_id_hr_link.sql
  12. 23
      backend/src/db/poolConfig.js
  13. 52
      backend/src/index.js
  14. 170
      backend/src/middleware/auth.js
  15. 155
      backend/src/routes/auth.js
  16. 243
      backend/src/routes/tests.js
  17. 8
      backend/src/server.js
  18. 204
      backend/src/services/testDraftService.js
  19. 94
      backend/src/utils/auth.js
  20. 21
      backend/src/utils/hrRoleMap.js
  21. 74
      backend/src/utils/werkzeugPassword.js
  22. 31
      backend/src/utils/werkzeugPassword.test.js
  23. 7
      docs/revision_task/TESTING_JOURNAL.md
  24. 2
      docs/revision_task/card1.md
  25. 1
      docs/revision_task/task.md
  26. 29
      frontend/.eslintrc.json
  27. 7
      frontend/.prettierrc
  28. 13
      frontend/index.html
  29. 4937
      frontend/package-lock.json
  30. 29
      frontend/package.json
  31. 19
      frontend/src/App.jsx
  32. 26
      frontend/src/api.js
  33. 9
      frontend/src/main.jsx
  34. 63
      frontend/src/pages/Login.jsx
  35. 177
      frontend/src/pages/TestDetail.jsx
  36. 100
      frontend/src/pages/TestsList.jsx
  37. 15
      frontend/vite.config.js

6
.gitignore vendored

@ -1,5 +1,11 @@
# General # General
.DS_Store .DS_Store
node_modules/
dist/
.env
.env.local
backend/.env
frontend/.env
__MACOSX/ __MACOSX/
# Thumbnails and Metadata # Thumbnails and Metadata

6
backend/.env.example

@ -20,3 +20,9 @@
# DB_PASSWORD=dev_password # DB_PASSWORD=dev_password
DATABASE_URL=postgresql://hr_bot_user:hrbot123@localhost:5432/clinic_tests DATABASE_URL=postgresql://hr_bot_user:hrbot123@localhost:5432/clinic_tests
JWT_SECRET=change_me_in_production
# A.1: HR login (Werkzeug password, staff by web_login)
# HR_AUTH=1
# HR_DATABASE_URL=postgresql://hr_bot_user:hrbot123@localhost:5432/hr_bot_test

15
backend/.eslintrc.json

@ -0,0 +1,15 @@
{
"env": {
"es2022": true,
"node": true
},
"parserOptions": {
"ecmaVersion": "latest",
"sourceType": "module"
},
"extends": ["eslint:recommended"],
"rules": {
"no-unused-vars": ["error", { "argsIgnorePattern": "^_" }],
"no-console": "warn"
}
}

7
backend/.prettierrc

@ -0,0 +1,7 @@
{
"semi": true,
"singleQuote": true,
"tabWidth": 2,
"trailingComma": "es5",
"printWidth": 100
}

2978
backend/package-lock.json generated

File diff suppressed because it is too large Load Diff

9
backend/package.json

@ -2,12 +2,12 @@
"name": "clinic-tests-backend", "name": "clinic-tests-backend",
"version": "1.0.0", "version": "1.0.0",
"description": "Backend for Clinic Tests application", "description": "Backend for Clinic Tests application",
"main": "src/index.js", "main": "src/server.js",
"type": "module", "type": "module",
"scripts": { "scripts": {
"start": "node src/index.js", "start": "node src/server.js",
"dev": "node --watch src/index.js", "dev": "node --watch src/server.js",
"test": "node --test src/services/testChainService.test.js", "test": "node --test 'src/**/*.test.js'",
"migrate": "node src/db/migrate.js", "migrate": "node src/db/migrate.js",
"lint": "eslint src/", "lint": "eslint src/",
"lint:fix": "eslint src/ --fix", "lint:fix": "eslint src/ --fix",
@ -23,6 +23,7 @@
"dotenv": "^16.4.5", "dotenv": "^16.4.5",
"express": "^4.21.0", "express": "^4.21.0",
"jsonwebtoken": "^9.0.2", "jsonwebtoken": "^9.0.2",
"multer": "^1.4.5-lts.1",
"pg": "^8.12.0" "pg": "^8.12.0"
}, },
"devDependencies": { "devDependencies": {

45
backend/src/app.js

@ -0,0 +1,45 @@
import express from 'express';
import cors from 'cors';
import cookieParser from 'cookie-parser';
import dotenv from 'dotenv';
import authRoutes from './routes/auth.js';
import testsRoutes from './routes/tests.js';
dotenv.config();
export function createApp() {
const app = express();
const corsOrigins =
process.env.NODE_ENV === 'production'
? process.env.FRONTEND_URL
? [process.env.FRONTEND_URL]
: []
: ['http://localhost:5173', 'http://localhost:3000'];
app.use(
cors({
origin: corsOrigins.length ? corsOrigins : true,
credentials: true,
})
);
app.use(express.json());
app.use(cookieParser());
app.use('/api/auth', authRoutes);
app.use('/api/tests', testsRoutes);
app.get('/api/health', (req, res) => {
res.json({
status: 'ok',
timestamp: new Date().toISOString(),
message: 'Server is running',
});
});
app.use((err, req, res, _next) => {
console.error('Error:', err);
res.status(err.status || 500).json({
error: err.message || 'Internal Server Error',
});
});
app.use((req, res) => {
res.status(404).json({ error: 'Not found' });
});
return app;
}

7
backend/src/config/authConstants.js

@ -0,0 +1,7 @@
/** Пароль-заглушка: вход только через HR, локальный compare не пройдёт. */
export const HR_MANAGED_PASSWORD_PLACEHOLDER = '$HR$MANAGED$NO_LOCAL$';
/** HR login enabled (1 = Werkzeug + upsert user по staff_id) */
export function isHrAuthEnabled() {
return process.env.HR_AUTH === '1' || process.env.HR_AUTH === 'true';
}

27
backend/src/db/hrPool.js

@ -0,0 +1,27 @@
/**
* Read-only (по соглашению) пул к hr_bot_test для логина и справки по сотруднику.
*/
import pg from 'pg';
import { getHrPoolConfig } from './poolConfig.js';
const { Pool } = pg;
const cfg = getHrPoolConfig();
const pool = cfg ? new Pool(cfg) : null;
export function getHrPool() {
return pool;
}
/**
* @param {string} text
* @param {unknown[]} [params]
*/
export async function queryHr(text, params) {
if (!pool) {
throw new Error('HR database not configured (set HR_DATABASE_URL)');
}
return pool.query(text, params);
}
export default { getHrPool, queryHr };

130
backend/src/db/migrations/001_initial.sql

@ -0,0 +1,130 @@
-- Initial database schema for clinic tests application
-- Version: 1.0
-- Enable UUID extension
CREATE EXTENSION IF NOT EXISTS "uuid-ossp";
-- Departments table
CREATE TABLE IF NOT EXISTS departments (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
name VARCHAR(255) NOT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
-- User roles enum
CREATE TYPE user_role AS ENUM ('hr', 'manager', 'employee');
-- Users table
CREATE TABLE IF NOT EXISTS users (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
login VARCHAR(100) UNIQUE NOT NULL,
password_hash VARCHAR(255) NOT NULL,
full_name VARCHAR(255) NOT NULL,
role user_role NOT NULL DEFAULT 'employee',
department_id UUID REFERENCES departments(id),
is_active BOOLEAN DEFAULT true,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
-- Create index for login lookup
CREATE INDEX IF NOT EXISTS idx_users_login ON users(login);
CREATE INDEX IF NOT EXISTS idx_users_department ON users(department_id);
-- Tests table
CREATE TABLE IF NOT EXISTS tests (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
title VARCHAR(255) NOT NULL,
description TEXT,
passing_threshold INTEGER DEFAULT 70,
time_limit INTEGER,
allow_back BOOLEAN DEFAULT true,
is_active BOOLEAN DEFAULT true,
is_versioned BOOLEAN DEFAULT false,
created_by UUID REFERENCES users(id),
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
-- Test versions table
CREATE TABLE IF NOT EXISTS test_versions (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
test_id UUID REFERENCES tests(id) ON DELETE CASCADE,
version INTEGER NOT NULL DEFAULT 1,
is_active BOOLEAN DEFAULT false,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
UNIQUE(test_id, version)
);
-- Questions table
CREATE TABLE IF NOT EXISTS questions (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
test_version_id UUID REFERENCES test_versions(id) ON DELETE CASCADE,
text TEXT NOT NULL,
question_order INTEGER NOT NULL,
has_multiple_answers BOOLEAN DEFAULT false
);
-- Answer options table
CREATE TABLE IF NOT EXISTS answer_options (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
question_id UUID REFERENCES questions(id) ON DELETE CASCADE,
text TEXT NOT NULL,
is_correct BOOLEAN DEFAULT false,
option_order INTEGER NOT NULL
);
-- Test assignments table
CREATE TABLE IF NOT EXISTS test_assignments (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
test_version_id UUID REFERENCES test_versions(id) ON DELETE CASCADE,
assigned_by UUID REFERENCES users(id),
deadline DATE,
max_attempts INTEGER DEFAULT 1,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
-- Assignment targets table
CREATE TYPE target_type AS ENUM ('department', 'user');
CREATE TABLE IF NOT EXISTS test_assignment_targets (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
assignment_id UUID REFERENCES test_assignments(id) ON DELETE CASCADE,
target_type target_type NOT NULL,
target_id UUID NOT NULL
);
-- Test attempts table
CREATE TYPE attempt_status AS ENUM ('in_progress', 'completed', 'expired');
CREATE TABLE IF NOT EXISTS test_attempts (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
test_version_id UUID REFERENCES test_versions(id) ON DELETE CASCADE,
user_id UUID REFERENCES users(id),
attempt_number INTEGER NOT NULL DEFAULT 1,
status attempt_status DEFAULT 'in_progress',
started_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
completed_at TIMESTAMP,
correct_count INTEGER DEFAULT 0,
total_questions INTEGER DEFAULT 0,
passed BOOLEAN DEFAULT false,
UNIQUE(test_version_id, user_id, attempt_number)
);
-- User answers table
CREATE TABLE IF NOT EXISTS user_answers (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
attempt_id UUID REFERENCES test_attempts(id) ON DELETE CASCADE,
question_id UUID REFERENCES questions(id) ON DELETE CASCADE,
selected_options UUID[] DEFAULT '{}'
);
-- Settings table
CREATE TABLE IF NOT EXISTS settings (
key VARCHAR(100) PRIMARY KEY,
value TEXT
);
-- Insert default admin user (password: admin123)
-- This will be done via application code to properly hash the password

7
backend/src/db/migrations/003_users_staff_id_hr_link.sql

@ -0,0 +1,7 @@
-- Связь пользователя клиник-теста с сотрудником HR (staff_members.id) — card1 A.x
ALTER TABLE users
ADD COLUMN IF NOT EXISTS staff_id INTEGER UNIQUE;
COMMENT ON COLUMN users.staff_id IS 'id из hr_bot_test.staff_members; без дублирования кадров в clinic_tests';
CREATE INDEX IF NOT EXISTS idx_users_staff_id ON users(staff_id) WHERE staff_id IS NOT NULL;

23
backend/src/db/poolConfig.js

@ -40,3 +40,26 @@ export function getPoolConfig(overrides = {}) {
...overrides, ...overrides,
}; };
} }
/**
* Пул к БД `hr_bot_test` (сотрудники, users, RBAC) отдельно от `clinic_tests`.
* Только `HR_DATABASE_URL` (без каскадного fallback на `DATABASE_URL` путаница опасна).
* @param {import('pg').PoolConfig} [overrides]
* @returns {import('pg').PoolConfig | null}
*/
export function getHrPoolConfig(overrides = {}) {
const url = process.env.HR_DATABASE_URL?.trim();
if (!url) {
return null;
}
return {
connectionString: url,
max: parseInt(process.env.HR_DB_POOL_MAX || '5', 10),
idleTimeoutMillis: parseInt(process.env.DB_IDLE_TIMEOUT || '30000', 10),
connectionTimeoutMillis: parseInt(
process.env.DB_CONNECTION_TIMEOUT || '2000',
10
),
...overrides,
};
}

52
backend/src/index.js

@ -1,52 +0,0 @@
import express from 'express';
import cors from 'cors';
import cookieParser from 'cookie-parser';
import dotenv from 'dotenv';
import authRoutes from './routes/auth.js';
dotenv.config();
const app = express();
const PORT = process.env.PORT || 3001;
// Middleware
app.use(cors({
origin: process.env.NODE_ENV === 'production'
? process.env.FRONTEND_URL
: ['http://localhost:5173', 'http://localhost:3000'],
credentials: true,
}));
app.use(express.json());
app.use(cookieParser());
// Mount auth routes
app.use('/api/auth', authRoutes);
// Health check route
app.get('/api/health', (req, res) => {
res.json({
status: 'ok',
timestamp: new Date().toISOString(),
message: 'Server is running',
});
});
// Error handling middleware
app.use((err, req, res, _next) => {
console.error('Error:', err);
res.status(err.status || 500).json({
error: err.message || 'Internal Server Error',
});
});
// 404 handler
app.use((req, res) => {
res.status(404).json({ error: 'Not found' });
});
// Start server
app.listen(PORT, () => {
console.log(`Server is running on port ${PORT}`);
console.log(`Environment: ${process.env.NODE_ENV || 'development'}`);
});

170
backend/src/middleware/auth.js

@ -0,0 +1,170 @@
/**
* Authorization Middleware
* JWT authentication and role-based access control
*/
import { verifyToken } from '../utils/auth.js';
import { query } from '../db/db.js';
/**
* Extract token from cookie
* @param {Object} req - Express request object
* @returns {string|null} Token from cookie
*/
function getTokenFromCookie(req) {
return req.cookies?.token || null;
}
/**
* Middleware to authenticate JWT token
* Adds user data to req.user
*/
export async function authenticate(req, res, next) {
try {
const token = getTokenFromCookie(req);
if (!token) {
return res.status(401).json({ error: 'Authentication required' });
}
const decoded = verifyToken(token);
if (!decoded) {
return res.status(401).json({ error: 'Invalid or expired token' });
}
const result = await query(
'SELECT id, login, full_name, role, department_id, staff_id FROM users WHERE id = $1',
[decoded.userId]
);
if (result.rows.length === 0) {
return res.status(401).json({ error: 'User not found' });
}
const user = result.rows[0];
const staffId = user.staff_id ?? decoded.staffId;
req.user = {
id: user.id,
login: user.login,
fullName: user.full_name,
role: user.role,
departmentId: user.department_id,
};
if (staffId != null) {
req.user.staffId = staffId;
}
next();
} catch (error) {
console.error('Auth middleware error:', error);
return res.status(500).json({ error: 'Authentication error' });
}
}
/**
* Middleware factory to require specific roles
* @param {string|string[]} roles - Required role(s)
* @returns {Function} Express middleware
*/
export function requireRole(roles) {
const allowedRoles = Array.isArray(roles) ? roles : [roles];
return (req, res, next) => {
if (!req.user) {
return res.status(401).json({ error: 'Authentication required' });
}
if (!allowedRoles.includes(req.user.role)) {
return res.status(403).json({ error: 'Insufficient permissions' });
}
next();
};
}
/**
* Middleware to require specific department access
* For managers to access their department's data
* @param {number} departmentId - Required department ID
* @returns {Function} Express middleware
*/
export function requireDepartment(departmentId) {
return (req, res, next) => {
if (!req.user) {
return res.status(401).json({ error: 'Authentication required' });
}
// Admins can access all departments
if (req.user.role === 'admin') {
return next();
}
// Managers can only access their department
if (req.user.role === 'manager' && req.user.departmentId !== departmentId) {
return res.status(403).json({ error: 'Access denied to this department' });
}
next();
};
}
/**
* Optional authentication middleware
* Attaches user to request if token is valid, but doesn't require it
*/
export async function optionalAuth(req, res, next) {
try {
const token = getTokenFromCookie(req);
if (!token) {
req.user = null;
return next();
}
const decoded = verifyToken(token);
if (!decoded) {
req.user = null;
return next();
}
const result = await query(
'SELECT id, login, full_name, role, department_id, staff_id FROM users WHERE id = $1',
[decoded.userId]
);
if (result.rows.length === 0) {
req.user = null;
return next();
}
const user = result.rows[0];
const staffId = user.staff_id ?? decoded.staffId;
req.user = {
id: user.id,
login: user.login,
fullName: user.full_name,
role: user.role,
departmentId: user.department_id,
};
if (staffId != null) {
req.user.staffId = staffId;
}
next();
} catch (error) {
// Don't block request on auth errors
req.user = null;
next();
}
}
export default {
authenticate,
requireRole,
requireDepartment,
optionalAuth,
};

155
backend/src/routes/auth.js

@ -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;

243
backend/src/routes/tests.js

@ -0,0 +1,243 @@
/**
* V.4V.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;

8
backend/src/server.js

@ -0,0 +1,8 @@
import { createApp } from './app.js';
const app = createApp();
const PORT = process.env.PORT || 3001;
app.listen(PORT, () => {
console.log(`Server is running on port ${PORT}`);
console.log(`Environment: ${process.env.NODE_ENV || 'development'}`);
});

204
backend/src/services/testDraftService.js

@ -0,0 +1,204 @@
/**
* V.3 saveTestDraft, fork версии, контент вопросов.
*/
import { hasAnyAttemptForTest } from './testChainService.js';
/**
* @param {import('pg').PoolClient} client
* @param {string} testId
*/
export async function getActiveVersionRow(client, testId) {
const { rows } = await client.query(
`SELECT * FROM test_versions WHERE test_id = $1 AND is_active = true LIMIT 1`,
[testId]
);
return rows[0] || null;
}
/**
* @param {import('pg').PoolClient} client
* @param {string} fromVersionId
* @param {string} toVersionId
*/
export async function copyQuestionTree(client, fromVersionId, toVersionId) {
const { rows: questions } = await client.query(
`SELECT * FROM questions WHERE test_version_id = $1 ORDER BY question_order`,
[fromVersionId]
);
for (const q of questions) {
const { rows: insQ } = await client.query(
`INSERT INTO questions (test_version_id, text, question_order, has_multiple_answers)
VALUES ($1, $2, $3, $4) RETURNING id`,
[toVersionId, q.text, q.question_order, q.has_multiple_answers]
);
const nqid = insQ[0].id;
const { rows: options } = await client.query(
`SELECT * FROM answer_options WHERE question_id = $1 ORDER BY option_order`,
[q.id]
);
for (const o of options) {
await client.query(
`INSERT INTO answer_options (question_id, text, is_correct, option_order)
VALUES ($1, $2, $3, $4)`,
[nqid, o.text, o.is_correct, o.option_order]
);
}
}
}
/**
* @param {import('pg').PoolClient} client
* @param {string} testVersionId
* @param {{ questions?: Array<{ text: string, question_order?: number, hasMultipleAnswers?: boolean, options?: Array<{ text: string, isCorrect?: boolean, option_order?: number }> }> }} payload
*/
export async function replaceVersionContent(client, testVersionId, payload) {
await client.query(
`DELETE FROM answer_options WHERE question_id IN
(SELECT id FROM questions WHERE test_version_id = $1)`,
[testVersionId]
);
await client.query(`DELETE FROM questions WHERE test_version_id = $1`, [
testVersionId,
]);
const questions = payload.questions || [];
for (let i = 0; i < questions.length; i++) {
const q = questions[i];
const { rows: insQ } = await client.query(
`INSERT INTO questions (test_version_id, text, question_order, has_multiple_answers)
VALUES ($1, $2, $3, $4) RETURNING id`,
[
testVersionId,
q.text,
q.question_order ?? i + 1,
q.hasMultipleAnswers || false,
]
);
const qid = insQ[0].id;
const opts = q.options || [];
for (let j = 0; j < opts.length; j++) {
const o = opts[j];
await client.query(
`INSERT INTO answer_options (question_id, text, is_correct, option_order)
VALUES ($1, $2, $3, $4)`,
[qid, o.text, !!o.isCorrect, o.option_order ?? j + 1]
);
}
}
}
/**
* @param {import('pg').PoolClient} client
* @param {string} testId
*/
export async function forkNewVersion(client, testId) {
const av = await getActiveVersionRow(client, testId);
if (!av) {
throw new Error('no active version');
}
const { rows: mx } = await client.query(
`SELECT COALESCE(MAX(version), 0) AS v FROM test_versions WHERE test_id = $1`,
[testId]
);
const nextV = (mx[0].v || 0) + 1;
const { rows: nv } = await client.query(
`INSERT INTO test_versions (test_id, version, is_active, parent_id)
VALUES ($1, $2, true, $3) RETURNING *`,
[testId, nextV, av.id]
);
const newRow = nv[0];
await client.query(
`UPDATE test_versions SET is_active = false WHERE test_id = $1 AND id <> $2`,
[testId, newRow.id]
);
await copyQuestionTree(client, av.id, newRow.id);
return newRow;
}
/**
* @param {import('pg').Pool} pool
* @param {string} authorId
* @param {string} testId
* @param {{ title?: string, description?: string, questions?: Array<unknown> }} payload
*/
export async function saveTestDraft(pool, authorId, testId, payload) {
const { rows: tr } = await pool.query(
`SELECT id, created_by FROM tests WHERE id = $1`,
[testId]
);
if (!tr.length) {
const e = new Error('Test not found');
e.status = 404;
throw e;
}
const t = tr[0];
if (t.created_by !== authorId) {
const e = new Error('Forbidden');
e.status = 403;
throw e;
}
const client = await pool.connect();
let forked = false;
try {
await client.query('BEGIN');
if (payload.title != null || payload.description != null) {
await client.query(
`UPDATE tests SET title = COALESCE($2, title), description = COALESCE($3, description),
updated_at = CURRENT_TIMESTAMP WHERE id = $1`,
[testId, payload.title ?? null, payload.description ?? null]
);
}
const hasAttempts = await hasAnyAttemptForTest(client, testId);
let versionRow = await getActiveVersionRow(client, testId);
if (!versionRow) {
const e = new Error('No active version');
e.status = 500;
throw e;
}
if (hasAttempts && payload.questions !== undefined) {
versionRow = await forkNewVersion(client, testId);
forked = true;
}
if (payload.questions) {
await replaceVersionContent(client, versionRow.id, payload);
}
await client.query('COMMIT');
return { testId, versionId: versionRow.id, forked };
} catch (e) {
await client.query('ROLLBACK');
throw e;
} finally {
client.release();
}
}
/**
* Создать пустой тест (цепочка) с одной версией 1.
* @param {import('pg').Pool} pool
* @param {string} authorId
* @param {{ title: string, description?: string }} meta
*/
export async function createTestWithVersion(pool, authorId, meta) {
const client = await pool.connect();
try {
await client.query('BEGIN');
const { rows: t } = await client.query(
`INSERT INTO tests (title, description, created_by, is_active, is_versioned)
VALUES ($1, $2, $3, true, true) RETURNING id`,
[meta.title, meta.description || null, authorId]
);
const testId = t[0].id;
const { rows: v } = await client.query(
`INSERT INTO test_versions (test_id, version, is_active, parent_id)
VALUES ($1, 1, true, NULL) RETURNING id`,
[testId]
);
await client.query('COMMIT');
return { testId, versionId: v[0].id };
} catch (e) {
await client.query('ROLLBACK');
throw e;
} finally {
client.release();
}
}

94
backend/src/utils/auth.js

@ -0,0 +1,94 @@
/**
* Authentication Utilities
* Password hashing and JWT token management
*/
import bcrypt from 'bcrypt';
import jwt from 'jsonwebtoken';
import dotenv from 'dotenv';
import { checkWerkzeugPassword } from './werkzeugPassword.js';
import { HR_MANAGED_PASSWORD_PLACEHOLDER } from '../config/authConstants.js';
dotenv.config();
const JWT_SECRET = process.env.JWT_SECRET;
const JWT_EXPIRES_IN = process.env.JWT_EXPIRES_IN || '7d';
// Salt rounds for bcrypt
const SALT_ROUNDS = 10;
/**
* Hash a password using bcrypt
* @param {string} password - Plain text password
* @returns {Promise<string>} Hashed password
*/
export async function hashPassword(password) {
return bcrypt.hash(password, SALT_ROUNDS);
}
/**
* Compare a plain text password with a hashed password
* @param {string} password - Plain text password
* @param {string} hash - Hashed password
* @returns {Promise<boolean>} True if passwords match
*/
export async function comparePassword(password, hash) {
if (!hash) {
return false;
}
if (hash === HR_MANAGED_PASSWORD_PLACEHOLDER) {
return false;
}
if (hash.startsWith('scrypt:') || hash.startsWith('pbkdf2:')) {
return checkWerkzeugPassword(hash, password);
}
if (hash.startsWith('$2')) {
return bcrypt.compare(password, hash);
}
return checkWerkzeugPassword(hash, password);
}
/**
* @param {string} userId - clinic_tests.users.id
* @param {string} role
* @param {string|null|undefined} departmentId
* @param {{ staffId?: number } | null} [meta]
* @returns {string}
*/
export function generateToken(userId, role, departmentId = null, meta = null) {
const payload = {
userId,
role,
};
if (departmentId !== null && departmentId !== undefined) {
payload.departmentId = departmentId;
}
if (meta?.staffId != null) {
payload.staffId = meta.staffId;
}
return jwt.sign(payload, JWT_SECRET, {
expiresIn: JWT_EXPIRES_IN,
});
}
/**
* Verify and decode a JWT token
* @param {string} token - JWT token
* @returns {Object|null} Decoded token payload or null if invalid
*/
export function verifyToken(token) {
try {
return jwt.verify(token, JWT_SECRET);
} catch (error) {
return null;
}
}
export default {
hashPassword,
comparePassword,
generateToken,
verifyToken,
};

21
backend/src/utils/hrRoleMap.js

@ -0,0 +1,21 @@
/**
* Сопоставление role из hr_bot_test.users (varchar) с ролью в модуле тестов.
* A.5 MVP уточняется при подключении staff_role_assignments.
* @param {string|null|undefined} hrRole
* @returns {'hr' | 'manager' | 'employee'}
*/
export function mapHrRoleToApp(hrRole) {
const r = String(hrRole || '')
.toLowerCase()
.trim();
if (!r) {
return 'employee';
}
if (r === 'admin' || r.includes('hr') || r.includes('дире')) {
return 'hr';
}
if (r.includes('manager') || r.includes('рук') || r.includes('завед')) {
return 'manager';
}
return 'employee';
}

74
backend/src/utils/werkzeugPassword.js

@ -0,0 +1,74 @@
/**
* Проверка хеша в формате Werkzeug 3 (scrypt: / pbkdf2:).
* @see https://github.com/pallets/werkzeug/blob/main/src/werkzeug/security.py
*/
import crypto from 'crypto';
/**
* @param {string} pwhash
* @param {string} password
* @returns {boolean}
*/
function hashInternal(method, salt, password) {
const methodParts = method.split(':');
const kind = methodParts[0];
const saltBytes = Buffer.from(salt, 'utf8');
const passwordBytes = Buffer.from(password, 'utf8');
if (kind === 'scrypt') {
const n = methodParts[1] ? parseInt(methodParts[1], 10) : 2 ** 15;
const r = methodParts[2] ? parseInt(methodParts[2], 10) : 8;
const p = methodParts[3] ? parseInt(methodParts[3], 10) : 1;
const maxmem = 132 * n * r * p;
return crypto
.scryptSync(passwordBytes, saltBytes, 64, { N: n, r, p, maxmem })
.toString('hex');
}
if (kind === 'pbkdf2') {
const hashName = methodParts[1] || 'sha256';
const iterStr = methodParts[2];
if (!iterStr) {
throw new Error('pbkdf2: missing iterations');
}
const iterations = parseInt(iterStr, 10);
return crypto
.pbkdf2Sync(passwordBytes, saltBytes, iterations, 32, hashName)
.toString('hex');
}
throw new Error(`Invalid hash method: ${kind}`);
}
/**
* @param {string} pwhash
* @param {string} password
* @returns {boolean}
*/
export function checkWerkzeugPassword(pwhash, password) {
if (!pwhash || pwhash.length < 3) {
return false;
}
const parts = pwhash.split('$');
if (parts.length < 3) {
return false;
}
const hashval = parts.pop();
const salt = parts.pop();
const method = parts.join('$');
if (!method || !salt || !hashval) {
return false;
}
let computed;
try {
computed = hashInternal(method, salt, password);
} catch {
return false;
}
const a = Buffer.from(computed, 'hex');
const b = Buffer.from(hashval, 'hex');
if (a.length !== b.length) {
return false;
}
return crypto.timingSafeEqual(a, b);
}

31
backend/src/utils/werkzeugPassword.test.js

@ -0,0 +1,31 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import crypto from 'crypto';
import { checkWerkzeugPassword } from './werkzeugPassword.js';
test('pbkdf2:sha256 self-consistency', () => {
const salt = 'AbCdEfGhIjKlMnOp';
const iterations = 1000;
const password = 'secret';
const hashval = crypto
.pbkdf2Sync(password, salt, iterations, 32, 'sha256')
.toString('hex');
const pwhash = `pbkdf2:sha256:${iterations}$${salt}$${hashval}`;
assert.equal(checkWerkzeugPassword(pwhash, 'secret'), true);
assert.equal(checkWerkzeugPassword(pwhash, 'wrong'), false);
});
test('scrypt self-consistency', () => {
const salt = 'AbCdEfGhIjKlMnOp';
const n = 32768;
const r = 8;
const p = 1;
const maxmem = 132 * n * r * p;
const method = `scrypt:${n}:${r}:${p}`;
const password = 'x';
const h = crypto
.scryptSync(password, salt, 64, { N: n, r, p, maxmem })
.toString('hex');
const pwhash = `${method}$${salt}$${h}`;
assert.equal(checkWerkzeugPassword(pwhash, 'x'), true);
});

7
docs/revision_task/TESTING_JOURNAL.md

@ -24,8 +24,9 @@
|---|----------------|--------|------| |---|----------------|--------|------|
| A1 | В проекте есть миграция базы: связь версий «родитель» (`parent_id`) и правило «только одна активная версия на тест» | [x] `002_…sql` | 2026-04-24 | | A1 | В проекте есть миграция базы: связь версий «родитель» (`parent_id`) и правило «только одна активная версия на тест» | [x] `002_…sql` | 2026-04-24 |
| A2 | Линтер (`npm run lint`): **0 errors**; остаются **warnings** `no-console` в существующих файлах | готово (errors) | 2026-04-24 | | A2 | Линтер (`npm run lint`): **0 errors**; остаются **warnings** `no-console` в существующих файлах | готово (errors) | 2026-04-24 |
| A3 | Автотесты: функция «есть ли уже хотя бы одна попытка по этому тесту» (`npm test`) | [x] готово | 2026-04-23 | | A3 | `npm test` в `backend/`: hasAny + проверка Werkzeug-совместимых хешей (`src/**/*.test.js`) | [x] готово | 2026-04-25 |
| A4 | Запрос «здоров ли сервер» по адресу `/api/health` при запущенном backend | [x] `{"status":"ok"}` | 2026-04-24 | | A4 | Запрос «здоров ли сервер» по адресу `/api/health` при запущенном backend | [x] `{"status":"ok"}` | 2026-04-24 |
| A5 | Реализация card1: API тестов/версий, черновик, HR-login (опц.), D.1 upload, UI списка/версий/черновика (в `dev`) | [x] код | 2026-04-25 |
**Техническая заметка:** реализация `hasAnyAttemptForTest` в `backend/src/services/testChainService.js`, тесты в `testChainService.test.js`. **Техническая заметка:** реализация `hasAnyAttemptForTest` в `backend/src/services/testChainService.js`, тесты в `testChainService.test.js`.
@ -65,9 +66,11 @@
| Спринт | Тема простыми словами | Раздел A | Раздел B | | Спринт | Тема простыми словами | Раздел A | Раздел B |
|--------|------------------------|----------|----------| |--------|------------------------|----------|----------|
| 1 | Версии, история прогонов | в работе | 2 шага (S1-00, S1-01) | | 1 | Версии, история прогонов | приём (код в dev) | 2 + очередь S1-02+ |
| 2 | *(по мере появления)* | | | | 2 | *(по мере появления)* | | |
--- ---
*Связанные файлы: [sprint-01-testing.md](sprint-01-testing.md) (черновик чек-листа), [card1.md](card1.md) (задачи).* *Связанные файлы: [sprint-01-testing.md](sprint-01-testing.md) (черновик чек-листа), [card1.md](card1.md) (задачи).*
**Очередь ручного приёма card1 (шаги по одному в чате, затем в таблицу B):** S1-02 — миграции и старт; S1-03 — сценарий тест (черновик / попытка / смена активной); S1-04 — (при `HR_AUTH=1`) вход HR. Первый шаг после внедрения: см. **одно** задание в чате от ассистента.

2
docs/revision_task/card1.md

@ -38,7 +38,7 @@
| V.7 | UI автора: номер/метка версии, предупреждение при «после первой попытки», экран **история версий**, кнопка **сменить активную** (с confirm) | Смоук `sprint-01-testing.md` | | V.7 | UI автора: номер/метка версии, предупреждение при «после первой попытки», экран **история версий**, кнопка **сменить активную** (с confirm) | Смоук `sprint-01-testing.md` |
| V.8 | UI списки сотрудника/автора: **один** ряд на цепочку, без дублей версий | — | | V.8 | UI списки сотрудника/автора: **один** ряд на цепочку, без дублей версий | — |
| V.9 | Интеграционные тесты API + регресс «разбор старой попытки» по старым `question_id` | — | | V.9 | Интеграционные тесты API + регресс «разбор старой попытки» по старым `question_id` | — |
| V.10 | *Продукт (зафиксировать в коде/доке):* при **новой** версии, что делаем с `test_assignments` — остаются на старом `test_version_id` / подтягиваем на новый / оба сценария | Решение в `task.md` или ADR one-liner | | V.10 | *Продукт:* при новой версии `test_assignments` **не** переносим на новый `test_version_id`; старт попытки — по **активной** версии (см. [task.md §2.6](task.md)) | Зафиксировано в ТЗ |
--- ---

1
docs/revision_task/task.md

@ -76,6 +76,7 @@ AI-помощник меняет это кардинально:
- Во **всех бизнес-процессах** модуля тестирования сотрудник идентифицируется по **`staff_members.id`**. В `clinic_tests` хранятся **ссылки** на этот идентификатор; кадровые данные и структура подразделений — из HR, без дублирования «второго реестра людей». - Во **всех бизнес-процессах** модуля тестирования сотрудник идентифицируется по **`staff_members.id`**. В `clinic_tests` хранятся **ссылки** на этот идентификатор; кадровые данные и структура подразделений — из HR, без дублирования «второго реестра людей».
- Поле **`telegram_id`** у сотрудника **не используется** в логике модуля (вход, назначения, фильтры, права) — только как **справочная** информация при необходимости. - Поле **`telegram_id`** у сотрудника **не используется** в логике модуля (вход, назначения, фильтры, права) — только как **справочная** информация при необходимости.
- **Разграничение прав** в целевом виде — через **существующую** в клинике систему (роли, permissions, привязки к сотруднику); модуль **не** строит параллельную полную копию RBAC. Допустимы временные упрощения до согласования API с HR. - **Разграничение прав** в целевом виде — через **существующую** в клинике систему (роли, permissions, привязки к сотруднику); модуль **не** строит параллельную полную копию RBAC. Допустимы временные упрощения до согласования API с HR.
- **Назначения и новые версии (V.10 / card1):** запись `test_assignments` **не** перепривязываем автоматически к новой `test_version_id` при форке версии. **Старт попытки** (V.4) фиксирует **активную** версию на момент «Старт», а не версию из строки назначения. Авто-обновление `test_assignments` при смене активной версии **не** делаем.
Детализация: [card1.md](card1.md) (вступление), [README](../../README.md#данные-сотрудники-интеграция-с-hr). Детализация: [card1.md](card1.md) (вступление), [README](../../README.md#данные-сотрудники-интеграция-с-hr).

29
frontend/.eslintrc.json

@ -0,0 +1,29 @@
{
"env": {
"browser": true,
"es2022": true
},
"parserOptions": {
"ecmaVersion": "latest",
"sourceType": "module",
"ecmaFeatures": {
"jsx": true
}
},
"extends": [
"eslint:recommended",
"plugin:react/recommended",
"plugin:react-hooks/recommended"
],
"plugins": ["react", "react-hooks"],
"settings": {
"react": {
"version": "detect"
}
},
"rules": {
"no-unused-vars": ["error", { "argsIgnorePattern": "^_" }],
"react/react-in-jsx-scope": "off",
"react/prop-types": "warn"
}
}

7
frontend/.prettierrc

@ -0,0 +1,7 @@
{
"semi": true,
"singleQuote": true,
"tabWidth": 2,
"trailingComma": "es5",
"printWidth": 100
}

13
frontend/index.html

@ -0,0 +1,13 @@
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Клинические Тесты</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.jsx"></script>
</body>
</html>

4937
frontend/package-lock.json generated

File diff suppressed because it is too large Load Diff

29
frontend/package.json

@ -0,0 +1,29 @@
{
"name": "clinic-tests-frontend",
"private": true,
"version": "1.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview",
"lint": "eslint src/",
"lint:fix": "eslint src/ --fix",
"format": "prettier --write src/"
},
"dependencies": {
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-router-dom": "^6.26.0"
},
"devDependencies": {
"@types/react": "^18.3.3",
"@types/react-dom": "^18.3.0",
"@vitejs/plugin-react": "^4.3.1",
"eslint": "^8.57.0",
"eslint-plugin-react": "^7.34.4",
"eslint-plugin-react-hooks": "^4.6.2",
"prettier": "^3.3.3",
"vite": "^5.4.0"
}
}

19
frontend/src/App.jsx

@ -0,0 +1,19 @@
import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom';
import Login from './pages/Login';
import TestsList from './pages/TestsList';
import TestDetail from './pages/TestDetail';
function App() {
return (
<BrowserRouter>
<Routes>
<Route path="/login" element={<Login />} />
<Route path="/tests" element={<TestsList />} />
<Route path="/tests/:id" element={<TestDetail />} />
<Route path="/" element={<Navigate to="/tests" replace />} />
</Routes>
</BrowserRouter>
);
}
export default App;

26
frontend/src/api.js

@ -0,0 +1,26 @@
const base = '';
export async function api(path, opts = {}) {
const r = await fetch(`${base}${path}`, {
credentials: 'include',
headers: {
'Content-Type': 'application/json',
...(opts.headers || {}),
},
...opts,
});
const text = await r.text();
let data;
try {
data = text ? JSON.parse(text) : {};
} catch {
data = { raw: text };
}
if (!r.ok) {
const err = new Error(data.error || r.statusText);
err.status = r.status;
err.data = data;
throw err;
}
return data;
}

9
frontend/src/main.jsx

@ -0,0 +1,9 @@
import React from 'react';
import ReactDOM from 'react-dom/client';
import App from './App.jsx';
ReactDOM.createRoot(document.getElementById('root')).render(
<React.StrictMode>
<App />
</React.StrictMode>
);

63
frontend/src/pages/Login.jsx

@ -0,0 +1,63 @@
import { useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { api } from '../api';
export default function Login() {
const [login, setLogin] = useState('');
const [password, setPassword] = useState('');
const [err, setErr] = useState(null);
const nav = useNavigate();
async function onSubmit(e) {
e.preventDefault();
setErr(null);
try {
await api('/api/auth/login', {
method: 'POST',
body: JSON.stringify({ login, password }),
});
nav('/tests');
} catch (e) {
setErr(e.message);
}
}
return (
<div style={{ maxWidth: 360, margin: '48px auto', fontFamily: 'system-ui' }}>
<h1>Вход</h1>
<p style={{ color: '#666', fontSize: 14 }}>
Локальный пользователь из <code>clinic_tests</code> (если HR_AUTH не
включён).
</p>
<form onSubmit={onSubmit}>
<div style={{ marginBottom: 12 }}>
<label>
Логин
<br />
<input
value={login}
onChange={(e) => setLogin(e.target.value)}
style={{ width: '100%', padding: 8 }}
autoComplete="username"
/>
</label>
</div>
<div style={{ marginBottom: 12 }}>
<label>
Пароль
<br />
<input
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
style={{ width: '100%', padding: 8 }}
autoComplete="current-password"
/>
</label>
</div>
{err && <p style={{ color: 'coral' }}>{err}</p>}
<button type="submit">Войти</button>
</form>
</div>
);
}

177
frontend/src/pages/TestDetail.jsx

@ -0,0 +1,177 @@
import { useEffect, useState } from 'react';
import { useNavigate, useParams, Link } from 'react-router-dom';
import { api } from '../api';
export default function TestDetail() {
const { id } = useParams();
const nav = useNavigate();
const [data, setData] = useState(null);
const [chain, setChain] = useState(null);
const [err, setErr] = useState(null);
const [qText, setQText] = useState('Пример вопроса?');
const [draftStatus, setDraftStatus] = useState('');
async function load() {
setErr(null);
try {
const v = await api(`/api/tests/${id}/versions`);
const c = await api(`/api/tests/${id}/chain-info`);
setData(v);
setChain(c);
} catch (e) {
if (e.status === 401) {
nav('/login');
return;
}
setErr(e.message);
}
}
useEffect(() => {
load();
}, [id, nav]);
async function saveDraft() {
setDraftStatus('…');
try {
const out = await api(`/api/tests/${id}/draft`, {
method: 'POST',
body: JSON.stringify({
title: 'Обновлённый заголовок (через черновик)',
questions: [
{
text: qText,
question_order: 1,
hasMultipleAnswers: false,
options: [
{ text: 'Верно', isCorrect: true, option_order: 1 },
{ text: 'Неверно 1', isCorrect: false, option_order: 2 },
{ text: 'Неверно 2', isCorrect: false, option_order: 3 },
],
},
],
}),
});
setDraftStatus(
out.forked
? 'Создана новая версия (вилка) и применён черновик'
: 'Черновик применён на месте'
);
load();
} catch (e) {
setDraftStatus(e.message);
}
}
async function startAttempt() {
try {
const o = await api(`/api/tests/${id}/attempts/start`, {
method: 'POST',
body: JSON.stringify({}),
});
setDraftStatus(`Попытка стартовала: ${o.attempt.id}`);
load();
} catch (e) {
setDraftStatus(e.message);
}
}
async function activateVersion(vid) {
if (!window.confirm('Сделать эту версию активной?')) {
return;
}
try {
await api(`/api/tests/${id}/versions/${vid}/activate`, {
method: 'POST',
body: JSON.stringify({}),
});
load();
} catch (e) {
setErr(e.message);
}
}
if (err) {
return <p style={{ color: 'coral' }}>{err}</p>;
}
if (!data) {
return <p>Загрузка</p>;
}
return (
<div style={{ maxWidth: 900, margin: '24px auto', fontFamily: 'system-ui' }}>
<p>
<Link to="/tests"> к списку</Link>
</p>
{chain?.hasAnyAttempt && (
<div
style={{
background: '#fff3cd',
border: '1px solid #ffc107',
padding: 12,
marginBottom: 16,
}}
>
По этой цепочке уже есть попытка. Сохранение черновика с вопросами
создаст <strong>новую версию</strong> (V.1V.3).
</div>
)}
<h2>Версии</h2>
<table
style={{ borderCollapse: 'collapse', width: '100%' }}
cellPadding={8}
>
<thead>
<tr>
<th>ver</th>
<th>id</th>
<th>active</th>
<th />
</tr>
</thead>
<tbody>
{data.versions.map((r) => (
<tr key={r.id} style={{ borderTop: '1px solid #ddd' }}>
<td>{r.version}</td>
<td style={{ fontSize: 12, wordBreak: 'break-all' }}>{r.id}</td>
<td>{r.is_active ? 'да' : 'нет'}</td>
<td>
{!r.is_active && (
<button type="button" onClick={() => activateVersion(r.id)}>
сделать активной
</button>
)}
</td>
</tr>
))}
</tbody>
</table>
{data.hasAttempts && (
<p style={{ color: '#666', fontSize: 14 }}>hasAttempts: да</p>
)}
<h3>Черновик (V.3)</h3>
<label>
Текст вопроса
<br />
<input
value={qText}
onChange={(e) => setQText(e.target.value)}
style={{ width: '100%', padding: 8, marginTop: 4 }}
/>
</label>
<div style={{ marginTop: 8 }}>
<button type="button" onClick={saveDraft}>
Сохранить черновик
</button>
<span style={{ marginLeft: 12, color: '#666' }}>{draftStatus}</span>
</div>
<h3 style={{ marginTop: 24 }}>Прохождение (V.4)</h3>
<button type="button" onClick={startAttempt}>
Старт попытки
</button>
</div>
);
}

100
frontend/src/pages/TestsList.jsx

@ -0,0 +1,100 @@
import { useEffect, useState } from 'react';
import { Link, useNavigate } from 'react-router-dom';
import { api } from '../api';
export default function TestsList() {
const [tests, setTests] = useState([]);
const [user, setUser] = useState(null);
const [err, setErr] = useState(null);
const [title, setTitle] = useState('');
const nav = useNavigate();
async function load() {
try {
const me = await api('/api/auth/me');
setUser(me.user);
const t = await api('/api/tests');
setTests(t.tests || []);
} catch (e) {
if (e.status === 401) {
nav('/login');
return;
}
setErr(e.message);
}
}
useEffect(() => {
load();
}, [nav]);
async function createTest(e) {
e.preventDefault();
if (!title.trim()) {
return;
}
try {
const o = await api('/api/tests', {
method: 'POST',
body: JSON.stringify({ title: title.trim() }),
});
setTitle('');
nav(`/tests/${o.testId}`);
} catch (e) {
setErr(e.message);
}
}
async function logout() {
await api('/api/auth/logout', { method: 'POST' });
nav('/login');
}
return (
<div style={{ maxWidth: 800, margin: '24px auto', fontFamily: 'system-ui' }}>
<div style={{ display: 'flex', justifyContent: 'space-between' }}>
<h1>Тесты</h1>
<div>
{user && (
<span style={{ marginRight: 12 }}>
{user.fullName} ({user.role})
</span>
)}
<button type="button" onClick={logout}>
Выйти
</button>
</div>
</div>
{err && <p style={{ color: 'coral' }}>{err}</p>}
<form onSubmit={createTest} style={{ margin: '20px 0' }}>
<input
placeholder="Новый тест — название"
value={title}
onChange={(e) => setTitle(e.target.value)}
style={{ padding: 8, width: 320 }}
/>
<button type="submit" style={{ marginLeft: 8 }}>
Создать
</button>
</form>
<ul style={{ listStyle: 'none', padding: 0 }}>
{tests.map((t) => (
<li
key={t.id}
style={{ border: '1px solid #ddd', marginBottom: 8, padding: 12 }}
>
<Link to={`/tests/${t.id}`}>
{t.title}
</Link>
<span style={{ color: '#888', fontSize: 13, marginLeft: 8 }}>
v{t.version} · активная версия {t.active_version_id?.slice(0, 8)}
</span>
</li>
))}
</ul>
{tests.length === 0 && <p>Нет тестов создайте первый.</p>}
</div>
);
}

15
frontend/vite.config.js

@ -0,0 +1,15 @@
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
export default defineConfig({
plugins: [react()],
server: {
port: 5173,
proxy: {
'/api': {
target: 'http://localhost:3001',
changeOrigin: true,
},
},
},
});
Loading…
Cancel
Save