Browse Source

feat: полный бэк и фронт (попытки, разбор, импорт, ИИ, назначения)

- Сервисы: testAttemptService, testAccess, document import/gen/extract, LLM, assignment, aiEditor
- Конфиг: devAuthor, featureFlags; messages/ru; интеграция V.9 (skip без БД)
- API/роуты: app, auth, server; Dockerfile и env example
- Фронт: TestAttempt, TestAttemptReview, AttemptReviewBlock, стили, правки App/api/login/vite
- compose и README; смоук-тесты расширены

Закрывает отсутствие модулей в origin после клона.

Made-with: Cursor
dev
Константин Лебединский 2 weeks ago
parent
commit
0fe04d4d99
  1. 2
      README.md
  2. 16
      backend/.env.example
  3. 2
      backend/Dockerfile
  4. 1032
      backend/package-lock.json
  5. 10
      backend/package.json
  6. 26
      backend/src/apiSmoke.test.js
  7. 11
      backend/src/app.js
  8. 6
      backend/src/config/devAuthor.js
  9. 18
      backend/src/config/featureFlags.js
  10. 234
      backend/src/integration/v9card1.test.js
  11. 41
      backend/src/messages/ru.js
  12. 17
      backend/src/middleware/auth.js
  13. 65
      backend/src/routes/auth.js
  14. 2
      backend/src/server.js
  15. 197
      backend/src/services/aiEditorService.js
  16. 20
      backend/src/services/aiEditorService.test.js
  17. 125
      backend/src/services/assignmentDirectoryService.js
  18. 64
      backend/src/services/assignmentUserService.js
  19. 66
      backend/src/services/documentExtractService.js
  20. 33
      backend/src/services/documentExtractService.test.js
  21. 176
      backend/src/services/documentGenService.js
  22. 63
      backend/src/services/documentGenService.test.js
  23. 98
      backend/src/services/llmClient.js
  24. 477
      backend/src/services/testAttemptService.js
  25. 32
      backend/src/services/testDraftService.js
  26. 8
      backend/src/utils/auth.js
  27. 11
      docker-compose.dev.yml
  28. 13
      frontend/index.html
  29. 3
      frontend/nginx-default.conf
  30. 14
      frontend/src/App.jsx
  31. 12
      frontend/src/api.js
  32. 112
      frontend/src/components/AttemptReviewBlock.jsx
  33. 1
      frontend/src/main.jsx
  34. 84
      frontend/src/pages/Login.jsx
  35. 215
      frontend/src/pages/TestAttempt.jsx
  36. 79
      frontend/src/pages/TestAttemptReview.jsx
  37. 785
      frontend/src/styles/cabinet-theme.css
  38. 2
      frontend/vite.config.js

2
README.md

@ -129,7 +129,7 @@
4. Миграции: из каталога `backend/`: `npm run migrate`, затем `npm start` (и фронт из `frontend/``npm run dev`). 4. Миграции: из каталога `backend/`: `npm run migrate`, затем `npm start` (и фронт из `frontend/``npm run dev`).
**Docker (UI + API + общий Postgres):** поднять `Postgres_TG_Bots` (сеть `hr_postgres_dev_net`), создать БД `clinic_tests`, затем из корня `TestingWebApp`: **Docker (UI + API + общий Postgres):** поднять `Postgres_TG_Bots` (сеть `hr_postgres_dev_net`), создать БД `clinic_tests`, затем из корня `TestingWebApp`:
`docker compose -f docker-compose.dev.yml up --build` — интерфейс **http://localhost:8080** (Nginx проксирует `/api` в backend), API с хоста **http://localhost:3002** (внутри сети контейнера `3001`; см. [docker-compose.dev.yml](docker-compose.dev.yml), миграции в entrypoint). В БД `clinic_tests` для локального логина нужен активный `users` с bcrypt-паролем, либо включите `HR_AUTH=1` + `HR_DATABASE_URL` в compose/`.env` (см. `backend/.env.example`). `docker compose -f docker-compose.dev.yml up --build` — интерфейс **http://localhost:8080** (Nginx проксирует `/api` в backend), API с хоста **http://localhost:3002** (внутри сети контейнера `3107`; см. [docker-compose.dev.yml](docker-compose.dev.yml), миграции в entrypoint). В БД `clinic_tests` для локального логина нужен активный `users` с bcrypt-паролем, либо включите `HR_AUTH=1` + `HR_DATABASE_URL` в compose/`.env` (см. `backend/.env.example`).
`docker compose -f docker-compose.dev.yml down` — остановка. `docker compose -f docker-compose.dev.yml down` — остановка.

16
backend/.env.example

@ -24,6 +24,20 @@ DATABASE_URL=postgresql://hr_bot_user:hrbot123@localhost:5432/clinic_tests
JWT_SECRET=change_me_in_production JWT_SECRET=change_me_in_production
# A.1: HR login (Werkzeug password, staff by web_login) # A.1: HR login (Werkzeug password, staff by web_login = username в public.users)
# В Docker (docker-compose.dev.yml) по умолчанию HR_AUTH=1 и HR_DATABASE_URL на hr_bot_test.
# HR_AUTH=1 # HR_AUTH=1
# HR_DATABASE_URL=postgresql://hr_bot_user:hrbot123@localhost:5432/hr_bot_test # HR_DATABASE_URL=postgresql://hr_bot_user:hrbot123@localhost:5432/hr_bot_test
# V.8: API/UI назначения (POST /api/tests/:id/assign, каталог в карточке). В NODE_ENV=development
# включено без этого флага. В production: CLINIC_ASSIGNMENT_ENABLED=1
# CLINIC_ASSIGNMENT_ENABLED=1
# D.3 — генерация черновика из импорта (POST /api/tests/import/document), OpenAI-совместимый API
# DEEPSEEK_API_KEY= → по умолчанию https://api.deepseek.com/v1, модель deepseek-chat
# OPENAI_API_KEY= → https://api.openai.com/v1, модель gpt-4o-mini (если нет ключа DeepSeek)
# LLM_BASE_URL= → переопределить (без /chat/completions)
# LLM_MODEL=
# LLM_NO_JSON=1 → убрать response_format, если API не принимает json_object
# DEEPSEEK_API_KEY=
# OPENAI_API_KEY=

2
backend/Dockerfile

@ -3,6 +3,6 @@ WORKDIR /app
COPY package.json package-lock.json* ./ COPY package.json package-lock.json* ./
RUN npm ci RUN npm ci
COPY . . COPY . .
EXPOSE 3001 EXPOSE 3107
RUN chmod +x docker-entrypoint.sh RUN chmod +x docker-entrypoint.sh
ENTRYPOINT ["./docker-entrypoint.sh"] ENTRYPOINT ["./docker-entrypoint.sh"]

1032
backend/package-lock.json generated

File diff suppressed because it is too large Load Diff

10
backend/package.json

@ -6,8 +6,9 @@
"type": "module", "type": "module",
"scripts": { "scripts": {
"start": "node src/server.js", "start": "node src/server.js",
"dev": "node --watch src/server.js", "dev": "NODE_ENV=development node --watch src/server.js",
"test": "node --test 'src/**/*.test.js'", "test": "node --test 'src/**/*.test.js'",
"test:integration": "CLINIC_TESTS_INTEGRATION=1 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",
@ -17,17 +18,20 @@
"author": "", "author": "",
"license": "ISC", "license": "ISC",
"dependencies": { "dependencies": {
"bcrypt": "^5.1.1", "bcryptjs": "^3.0.3",
"cookie-parser": "^1.4.7", "cookie-parser": "^1.4.7",
"cors": "^2.8.5", "cors": "^2.8.5",
"dotenv": "^16.4.5", "dotenv": "^16.4.5",
"express": "^4.21.0", "express": "^4.21.0",
"jsonwebtoken": "^9.0.2", "jsonwebtoken": "^9.0.2",
"mammoth": "^1.12.0",
"multer": "^1.4.5-lts.1", "multer": "^1.4.5-lts.1",
"pdf-parse": "^2.4.5",
"pg": "^8.12.0" "pg": "^8.12.0"
}, },
"devDependencies": { "devDependencies": {
"eslint": "^8.57.0", "eslint": "^8.57.0",
"prettier": "^3.3.3" "prettier": "^3.3.3",
"supertest": "^7.2.2"
} }
} }

26
backend/src/apiSmoke.test.js

@ -0,0 +1,26 @@
/**
* V.9 минимальные проверки HTTP без БД: health и 401 на защищённых маршрутах.
* Интеграции с Postgres см. отдельные сценарии / ручной журнал.
*/
import { test } from 'node:test';
import assert from 'node:assert/strict';
import request from 'supertest';
import { createApp } from './app.js';
import { RU } from './messages/ru.js';
const app = createApp();
test('GET /api/health — 200 и status ok', async () => {
const res = await request(app).get('/api/health').expect(200);
assert.equal(res.body.status, 'ok');
});
test('GET /api/tests без cookie — 401', async () => {
const res = await request(app).get('/api/tests').expect(401);
assert.equal(res.body.error, RU.authRequired);
});
test('GET /api/__no_route__ — 404 на русском', async () => {
const res = await request(app).get('/api/__no_route__').expect(404);
assert.equal(res.body.error, RU.notFound);
});

11
backend/src/app.js

@ -4,6 +4,7 @@ import cookieParser from 'cookie-parser';
import dotenv from 'dotenv'; import dotenv from 'dotenv';
import authRoutes from './routes/auth.js'; import authRoutes from './routes/auth.js';
import testsRoutes from './routes/tests.js'; import testsRoutes from './routes/tests.js';
import { RU } from './messages/ru.js';
dotenv.config(); dotenv.config();
@ -14,7 +15,11 @@ export function createApp() {
? process.env.FRONTEND_URL ? process.env.FRONTEND_URL
? [process.env.FRONTEND_URL] ? [process.env.FRONTEND_URL]
: [] : []
: ['http://localhost:5173', 'http://localhost:3000']; : [
'http://localhost:5173',
'http://localhost:3000',
'http://localhost:8080',
];
app.use( app.use(
cors({ cors({
origin: corsOrigins.length ? corsOrigins : true, origin: corsOrigins.length ? corsOrigins : true,
@ -35,11 +40,11 @@ export function createApp() {
app.use((err, req, res, _next) => { app.use((err, req, res, _next) => {
console.error('Error:', err); console.error('Error:', err);
res.status(err.status || 500).json({ res.status(err.status || 500).json({
error: err.message || 'Internal Server Error', error: err.message || RU.internal,
}); });
}); });
app.use((req, res) => { app.use((req, res) => {
res.status(404).json({ error: 'Not found' }); res.status(404).json({ error: RU.notFound });
}); });
return app; return app;
} }

6
backend/src/config/devAuthor.js

@ -0,0 +1,6 @@
/**
* Правка цепочки теста (черновик, версии, публикация, редактор) только создатель (`tests.created_by`).
*/
export function isTestAuthor(createdBy, userId) {
return createdBy === userId;
}

18
backend/src/config/featureFlags.js

@ -0,0 +1,18 @@
/**
* Флаги продуктовых фич (env). В development ряд вещей включён по умолчанию.
*/
/** API и UI: назначение тестов сотрудникам (каталог HR + POST /tests/:id/assign). */
export function isAssignmentFeatureEnabled() {
if (process.env.NODE_ENV === 'development') {
return true;
}
const v = (process.env.CLINIC_ASSIGNMENT_ENABLED || '').toLowerCase();
if (v === '1' || v === 'true' || v === 'yes') {
return true;
}
if (v === '0' || v === 'false' || v === 'no') {
return false;
}
return false;
}

234
backend/src/integration/v9card1.test.js

@ -0,0 +1,234 @@
/**
* Card1 V.9: интеграция с реальной `clinic_tests` старая попытка остаётся
* на снимке версии и старых `question_id` после форка (новая версия).
*
* Запуск: `CLINIC_TESTS_INTEGRATION=1` и применённые миграции (`npm run migrate`),
* `DATABASE_URL` (или DB_*) к той же базе. Без флага тесты помечаются skip.
*/
import { test } from 'node:test';
import assert from 'node:assert/strict';
import pg from 'pg';
import bcrypt from 'bcryptjs';
import { getPoolConfig } from '../db/poolConfig.js';
import { saveTestDraft, createTestWithVersion } from '../services/testDraftService.js';
const { Pool } = pg;
/** `CLINIC_TESTS_INTEGRATION=1` и успешный `SELECT 1` (без БД — skip, не fail). */
let runDb = false;
if (process.env.CLINIC_TESTS_INTEGRATION === '1') {
const probe = new Pool({
...getPoolConfig(),
connectionTimeoutMillis: 2000,
});
try {
await probe.query('SELECT 1');
runDb = true;
} catch {
runDb = false;
} finally {
await probe.end();
}
}
const qPayload = (label) => ({
title: 'V9 ' + label,
questions: [
{
text: `Q ${label}`,
question_order: 1,
hasMultipleAnswers: false,
options: [
{ text: 'yes', isCorrect: true, option_order: 1 },
{ text: 'no', isCorrect: false, option_order: 2 },
],
},
],
});
/**
* @param {import('pg').Pool} pool
* @param {string} testId
* @param {string} [exceptUserId]
*/
async function purgeTestChain(pool, testId, exceptUserId) {
await pool.query(
`DELETE FROM user_answers WHERE attempt_id IN (
SELECT id FROM test_attempts WHERE test_version_id IN (
SELECT id FROM test_versions WHERE test_id = $1
)
)`,
[testId]
);
await pool.query(
`DELETE FROM test_attempts WHERE test_version_id IN (
SELECT id FROM test_versions WHERE test_id = $1
)`,
[testId]
);
await pool.query(
`DELETE FROM answer_options WHERE question_id IN (
SELECT id FROM questions WHERE test_version_id IN (
SELECT id FROM test_versions WHERE test_id = $1
)
)`,
[testId]
);
await pool.query(
`DELETE FROM questions WHERE test_version_id IN (
SELECT id FROM test_versions WHERE test_id = $1
)`,
[testId]
);
await pool.query(`DELETE FROM test_versions WHERE test_id = $1`, [testId]);
await pool.query(`DELETE FROM tests WHERE id = $1`, [testId]);
if (exceptUserId) {
await pool.query(`DELETE FROM users WHERE id = $1`, [exceptUserId]);
}
}
test(
'V.9: без попыток два saveTestDraft — одна строка test_versions (редактирование на месте)',
{ skip: !runDb },
async () => {
const pool = new Pool(getPoolConfig());
const suffix = `${Date.now()}-${Math.random().toString(16).slice(2)}`;
let userId;
let testId;
try {
const { rows: u } = await pool.query(
`INSERT INTO users (login, password_hash, full_name, role, is_active)
VALUES ($1, $2, 'V9 in-place', 'hr', true) RETURNING id`,
[`v9p-${suffix}`, bcrypt.hashSync('x', 4)]
);
userId = u[0].id;
const c = await createTestWithVersion(pool, userId, { title: 'V9P' });
testId = c.testId;
const { rows: v0 } = await pool.query(
`SELECT id FROM test_versions WHERE test_id = $1 AND is_active = true`,
[testId]
);
const vid0 = v0[0].id;
await saveTestDraft(pool, userId, testId, qPayload('A'));
const { rows: c1 } = await pool.query(
`SELECT count(*)::int AS n FROM test_versions WHERE test_id = $1`,
[testId]
);
assert.equal(c1[0].n, 1, 'должна остаться одна версия');
const { rows: v1 } = await pool.query(
`SELECT id FROM test_versions WHERE test_id = $1 AND is_active = true`,
[testId]
);
assert.equal(
v1[0].id,
vid0,
'id активной версии не меняется при нуле попыток'
);
await saveTestDraft(pool, userId, testId, qPayload('B'));
const { rows: c2 } = await pool.query(
`SELECT count(*)::int AS n FROM test_versions WHERE test_id = $1`,
[testId]
);
assert.equal(c2[0].n, 1);
const { rows: v2 } = await pool.query(
`SELECT id FROM test_versions WHERE test_id = $1 AND is_active = true`,
[testId]
);
assert.equal(v2[0].id, vid0);
} finally {
if (userId && testId) {
await purgeTestChain(pool, testId, userId);
}
await pool.end();
}
}
);
test(
'V.9: после попытки форк — попытка и user_answers остаются на старых version_id / question_id',
{ skip: !runDb },
async () => {
const pool = new Pool(getPoolConfig());
const suffix = `${Date.now()}-${Math.random().toString(16).slice(2)}`;
let userId;
let testId;
let v1Id;
let q1Id;
let opt1Id;
let attemptId;
try {
const { rows: u } = await pool.query(
`INSERT INTO users (login, password_hash, full_name, role, is_active)
VALUES ($1, $2, 'V9 fork', 'hr', true) RETURNING id`,
[`v9f-${suffix}`, bcrypt.hashSync('x', 4)]
);
userId = u[0].id;
const c = await createTestWithVersion(pool, userId, { title: 'V9F' });
testId = c.testId;
await saveTestDraft(pool, userId, testId, qPayload('pre'));
const { rows: tv0 } = await pool.query(
`SELECT id FROM test_versions WHERE test_id = $1 AND is_active = true`,
[testId]
);
v1Id = tv0[0].id;
const { rows: qu } = await pool.query(
`SELECT id FROM questions WHERE test_version_id = $1 LIMIT 1`,
[v1Id]
);
q1Id = qu[0].id;
const { rows: op } = await pool.query(
`SELECT id FROM answer_options WHERE question_id = $1 AND is_correct = true LIMIT 1`,
[q1Id]
);
opt1Id = op[0].id;
const { rows: at } = await pool.query(
`INSERT INTO test_attempts (test_version_id, user_id, attempt_number, status, correct_count, total_questions, passed)
VALUES ($1, $2, 1, 'completed', 1, 1, true) RETURNING id`,
[v1Id, userId]
);
attemptId = at[0].id;
await pool.query(
`INSERT INTO user_answers (attempt_id, question_id, selected_options) VALUES ($1, $2, $3::uuid[])`,
[attemptId, q1Id, [opt1Id]]
);
const out = await saveTestDraft(pool, userId, testId, qPayload('post-fork'));
assert.equal(out.forked, true, 'должна создаться новая версия после попытки');
const { rows: att } = await pool.query(
`SELECT test_version_id FROM test_attempts WHERE id = $1`,
[attemptId]
);
assert.equal(
att[0].test_version_id,
v1Id,
'попытка остаётся на версии, с которой проходили'
);
const { rows: ua } = await pool.query(
`SELECT question_id, selected_options FROM user_answers WHERE attempt_id = $1`,
[attemptId]
);
assert.equal(ua[0].question_id, q1Id);
assert.equal(ua[0].selected_options[0], opt1Id);
const { rows: qExists } = await pool.query(
`SELECT 1 FROM questions WHERE id = $1 AND test_version_id = $2`,
[q1Id, v1Id]
);
assert.equal(qExists.length, 1, 'старый вопрос остаётся в старой версии');
const { rows: active } = await pool.query(
`SELECT id FROM test_versions WHERE test_id = $1 AND is_active = true`,
[testId]
);
assert.notEqual(active[0].id, v1Id, 'новая версия — активна');
} finally {
if (userId && testId) {
await purgeTestChain(pool, testId, userId);
}
await pool.end();
}
}
);

41
backend/src/messages/ru.js

@ -0,0 +1,41 @@
/** Тексты ответов API для пользователей (русский). */
export const RU = {
loginAndPasswordRequired: 'Укажите логин и пароль.',
invalidCredentials: 'Неверный логин или пароль.',
useHrLogin: 'Войдите через учётную запись кадровой системы (тот же логин, что в HR).',
hrDatabaseUrlMissing:
'База кадровой системы не настроена: задайте HR_DATABASE_URL на backend.',
hrDatabaseNotConfigured: 'База кадровой системы не настроена.',
noStaffForLogin:
'К учётной записи не привязан сотрудник: в HR в карточке сотрудника должно совпадать поле веб-логина (web_login) с логином входа, как в кабинете сотрудника.',
loggedOut: 'Вы вышли из системы.',
logoutFailed: 'Не удалось выйти. Повторите попытку.',
userDataFailed: 'Не удалось загрузить данные пользователя.',
loginFailed: 'Ошибка входа. Повторите попытку.',
authRequired: 'Требуется вход в систему.',
tokenInvalid: 'Сессия истекла или недействительна. Войдите снова.',
userNotFound: 'Пользователь не найден.',
authError: 'Ошибка проверки доступа.',
insufficientPermissions: 'Недостаточно прав.',
departmentAccessDenied: 'Нет доступа к этому подразделению.',
notFound: 'Не найдено.',
fileFieldRequired: 'Прикрепите файл к полю file.',
uploadFailed: 'Не удалось принять файл.',
titleRequired: 'Укажите название.',
assignmentUserRequired: 'Передайте userId (UUID) или staffId (число, сотрудник из HR).',
assignmentUserOrStaff: 'Укажите только userId, или только staffId — не оба сразу.',
testNotFound: 'Тест не найден.',
forbidden: 'Доступ запрещён.',
versionNotFound: 'Версия не найдена.',
chainActiveRequired: 'Передайте chainActive: true/false в теле запроса.',
noActiveVersion: 'Нет активной версии теста.',
internal: 'Внутренняя ошибка сервера.',
fileTooLarge: 'Файл слишком большой (максимум 10 МБ).',
unsupportedFileType:
'Неподдерживаемый формат. Допустимы: PDF, DOCX, TXT, MD.',
attemptNotFound: 'Попытка не найдена.',
attemptNotInProgress: 'Попытка уже завершена или просрочена.',
attemptNotCompleted: 'Попытка ещё не завершена — подробный разбор доступен после отправки ответов.',
testHasNoQuestions: 'В активной версии нет вопросов. Добавьте вопросы и сохраните черновик.',
invalidOptionForQuestion: 'Выбран вариант ответа, не относящийся к вопросу.',
};

17
backend/src/middleware/auth.js

@ -5,6 +5,7 @@
import { verifyToken } from '../utils/auth.js'; import { verifyToken } from '../utils/auth.js';
import { query } from '../db/db.js'; import { query } from '../db/db.js';
import { RU } from '../messages/ru.js';
/** /**
* Extract token from cookie * Extract token from cookie
@ -24,13 +25,13 @@ export async function authenticate(req, res, next) {
const token = getTokenFromCookie(req); const token = getTokenFromCookie(req);
if (!token) { if (!token) {
return res.status(401).json({ error: 'Authentication required' }); return res.status(401).json({ error: RU.authRequired });
} }
const decoded = verifyToken(token); const decoded = verifyToken(token);
if (!decoded) { if (!decoded) {
return res.status(401).json({ error: 'Invalid or expired token' }); return res.status(401).json({ error: RU.tokenInvalid });
} }
const result = await query( const result = await query(
@ -39,7 +40,7 @@ export async function authenticate(req, res, next) {
); );
if (result.rows.length === 0) { if (result.rows.length === 0) {
return res.status(401).json({ error: 'User not found' }); return res.status(401).json({ error: RU.userNotFound });
} }
const user = result.rows[0]; const user = result.rows[0];
@ -59,7 +60,7 @@ export async function authenticate(req, res, next) {
next(); next();
} catch (error) { } catch (error) {
console.error('Auth middleware error:', error); console.error('Auth middleware error:', error);
return res.status(500).json({ error: 'Authentication error' }); return res.status(500).json({ error: RU.authError });
} }
} }
@ -73,11 +74,11 @@ export function requireRole(roles) {
return (req, res, next) => { return (req, res, next) => {
if (!req.user) { if (!req.user) {
return res.status(401).json({ error: 'Authentication required' }); return res.status(401).json({ error: RU.authRequired });
} }
if (!allowedRoles.includes(req.user.role)) { if (!allowedRoles.includes(req.user.role)) {
return res.status(403).json({ error: 'Insufficient permissions' }); return res.status(403).json({ error: RU.insufficientPermissions });
} }
next(); next();
@ -93,7 +94,7 @@ export function requireRole(roles) {
export function requireDepartment(departmentId) { export function requireDepartment(departmentId) {
return (req, res, next) => { return (req, res, next) => {
if (!req.user) { if (!req.user) {
return res.status(401).json({ error: 'Authentication required' }); return res.status(401).json({ error: RU.authRequired });
} }
// Admins can access all departments // Admins can access all departments
@ -103,7 +104,7 @@ export function requireDepartment(departmentId) {
// Managers can only access their department // Managers can only access their department
if (req.user.role === 'manager' && req.user.departmentId !== departmentId) { if (req.user.role === 'manager' && req.user.departmentId !== departmentId) {
return res.status(403).json({ error: 'Access denied to this department' }); return res.status(403).json({ error: RU.departmentAccessDenied });
} }
next(); next();

65
backend/src/routes/auth.js

@ -11,6 +11,12 @@ import {
isHrAuthEnabled, isHrAuthEnabled,
HR_MANAGED_PASSWORD_PLACEHOLDER, HR_MANAGED_PASSWORD_PLACEHOLDER,
} from '../config/authConstants.js'; } from '../config/authConstants.js';
import { RU } from '../messages/ru.js';
import {
getAssignmentDirectory,
getHrDepartmentNames,
} from '../services/assignmentDirectoryService.js';
import { isAssignmentFeatureEnabled } from '../config/featureFlags.js';
const router = express.Router(); const router = express.Router();
@ -18,12 +24,12 @@ router.post('/login', async (req, res) => {
try { try {
const { login, password } = req.body; const { login, password } = req.body;
if (!login || !password) { if (!login || !password) {
return res.status(400).json({ error: 'Login and password are required' }); return res.status(400).json({ error: RU.loginAndPasswordRequired });
} }
if (isHrAuthEnabled()) { if (isHrAuthEnabled()) {
if (!getHrPool()) { if (!getHrPool()) {
return res.status(500).json({ error: 'HR_DATABASE_URL is not set' }); return res.status(500).json({ error: RU.hrDatabaseUrlMissing });
} }
const u = await queryHr( const u = await queryHr(
`SELECT id, username, password_hash, role `SELECT id, username, password_hash, role
@ -32,12 +38,12 @@ router.post('/login', async (req, res) => {
[login] [login]
); );
if (u.rows.length === 0 || !u.rows[0].password_hash) { if (u.rows.length === 0 || !u.rows[0].password_hash) {
return res.status(401).json({ error: 'Invalid credentials' }); return res.status(401).json({ error: RU.invalidCredentials });
} }
const row = u.rows[0]; const row = u.rows[0];
const ok = await comparePassword(password, row.password_hash); const ok = await comparePassword(password, row.password_hash);
if (!ok) { if (!ok) {
return res.status(401).json({ error: 'Invalid credentials' }); return res.status(401).json({ error: RU.invalidCredentials });
} }
const s = await queryHr( const s = await queryHr(
`SELECT id, fio FROM staff_members `SELECT id, fio FROM staff_members
@ -45,9 +51,7 @@ router.post('/login', async (req, res) => {
[login] [login]
); );
if (s.rows.length === 0) { if (s.rows.length === 0) {
return res return res.status(403).json({ error: RU.noStaffForLogin });
.status(403)
.json({ error: 'No staff link for this login (web_login)' });
} }
const staffId = s.rows[0].id; const staffId = s.rows[0].id;
const fio = s.rows[0].fio || login; const fio = s.rows[0].fio || login;
@ -93,15 +97,15 @@ router.post('/login', async (req, res) => {
[login] [login]
); );
if (result.rows.length === 0) { if (result.rows.length === 0) {
return res.status(401).json({ error: 'Invalid credentials' }); return res.status(401).json({ error: RU.invalidCredentials });
} }
const user = result.rows[0]; const user = result.rows[0];
if (user.password_hash === HR_MANAGED_PASSWORD_PLACEHOLDER) { if (user.password_hash === HR_MANAGED_PASSWORD_PLACEHOLDER) {
return res.status(401).json({ error: 'Use HR login' }); return res.status(401).json({ error: RU.useHrLogin });
} }
const isValidPassword = await comparePassword(password, user.password_hash); const isValidPassword = await comparePassword(password, user.password_hash);
if (!isValidPassword) { if (!isValidPassword) {
return res.status(401).json({ error: 'Invalid credentials' }); return res.status(401).json({ error: RU.invalidCredentials });
} }
const token = generateToken(user.id, user.role, user.department_id); const token = generateToken(user.id, user.role, user.department_id);
res.cookie('token', token, { res.cookie('token', token, {
@ -122,10 +126,10 @@ router.post('/login', async (req, res) => {
}); });
} catch (error) { } catch (error) {
if (error.message?.includes('HR database not configured')) { if (error.message?.includes('HR database not configured')) {
return res.status(500).json({ error: 'HR database not configured' }); return res.status(500).json({ error: RU.hrDatabaseNotConfigured });
} }
console.error('Login error:', error); console.error('Login error:', error);
return res.status(500).json({ error: 'Login failed' }); return res.status(500).json({ error: RU.loginFailed });
} }
}); });
@ -136,19 +140,48 @@ router.post('/logout', (req, res) => {
secure: process.env.NODE_ENV === 'production', secure: process.env.NODE_ENV === 'production',
sameSite: 'strict', sameSite: 'strict',
}); });
res.json({ message: 'Logged out successfully' }); res.json({ message: RU.loggedOut });
} catch (error) { } catch (error) {
console.error('Logout error:', error); console.error('Logout error:', error);
res.status(500).json({ error: 'Logout failed' }); res.status(500).json({ error: RU.logoutFailed });
} }
}); });
router.get('/me', authenticate, async (req, res) => { router.get('/me', authenticate, async (req, res) => {
try { try {
res.json({ user: req.user }); const devUi = process.env.NODE_ENV === 'development';
const assignmentUi = isAssignmentFeatureEnabled();
res.json({ user: req.user, devUi, assignmentUi });
} catch (error) { } catch (error) {
console.error('Get current user error:', error); console.error('Get current user error:', error);
res.status(500).json({ error: 'Failed to get user data' }); res.status(500).json({ error: RU.userDataFailed });
}
});
/**
* Каталог сотрудников для назначения: HR (все) + отделы + поиск. Как `POST .../assign`: см. `isAssignmentFeatureEnabled()`.
* Query: q, department (имя отдела или __all__), clinic=all|with|without
*/
router.get('/dev/assignment-directory', authenticate, async (req, res) => {
if (!isAssignmentFeatureEnabled()) {
return res.status(404).json({ error: RU.notFound });
}
try {
const q = typeof req.query.q === 'string' ? req.query.q : '';
const department = typeof req.query.department === 'string' ? req.query.department : '';
const c = req.query.clinic;
const clinicFilter =
c === 'with' || c === 'without' ? c : 'all';
const { people, source } = await getAssignmentDirectory({
q,
department,
clinicFilter,
});
const departments = await getHrDepartmentNames();
res.json({ people, source, departments });
} catch (error) {
console.error('dev assignment directory:', error);
res.status(500).json({ error: RU.userDataFailed });
} }
}); });

2
backend/src/server.js

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

197
backend/src/services/aiEditorService.js

@ -0,0 +1,197 @@
/**
* Генерация теста/вопроса в редакторе: строгая сетка (число вопросов и вариантов) из UI.
*/
import { getLlmConfig, chatCompletionTextContent } from './llmClient.js';
import {
parseJsonFromLlmText,
validateAndNormalizeDraft,
} from './documentGenService.js';
/**
* @param {unknown} s
* @returns {{ optionsCount: number, hasMultipleAnswers: boolean }[]}
*/
export function parseAndValidateShape(s) {
if (!Array.isArray(s) || s.length === 0) {
const e = new Error('Передайте непустой массив shape: [{ optionsCount, hasMultipleAnswers }, ...].');
e.status = 400;
throw e;
}
if (s.length > 40) {
const e = new Error('Не более 40 вопросов за раз.');
e.status = 400;
throw e;
}
return s.map((row, i) => {
if (!row || typeof row !== 'object') {
const e = new Error(`shape[${i}]: ожидается объект.`);
e.status = 400;
throw e;
}
const n = Math.floor(Number((/** @type {any} */ (row)).optionsCount));
const hasMultipleAnswers = Boolean((/** @type {any} */ (row)).hasMultipleAnswers);
if (!Number.isFinite(n) || n < 2 || n > 12) {
const e = new Error(`shape[${i}]: optionsCount от 2 до 12.`);
e.status = 400;
throw e;
}
return { optionsCount: n, hasMultipleAnswers };
});
}
/**
* @param {any} o parsed draft
* @param {Array<{ optionsCount: number, hasMultipleAnswers: boolean }>} shape
*/
export function assertDraftMatchesShape(o, shape) {
if (!o?.questions || !Array.isArray(o.questions)) {
const e = new Error('В ответе нет questions.');
e.code = 'llm_shape';
throw e;
}
if (o.questions.length !== shape.length) {
const e = new Error(
`Ожидалось вопросов: ${shape.length}, в ответе: ${o.questions.length}.`
);
e.code = 'llm_shape';
throw e;
}
for (let i = 0; i < shape.length; i++) {
const q = o.questions[i];
const sh = shape[i];
if (!q?.options || !Array.isArray(q.options)) {
const e = new Error(`Вопрос ${i + 1}: нет options.`);
e.code = 'llm_shape';
throw e;
}
if (q.options.length !== sh.optionsCount) {
const e = new Error(
`Вопрос ${i + 1}: ожидалось вариантов ${sh.optionsCount}, в ответе: ${q.options.length}.`
);
e.code = 'llm_shape';
throw e;
}
if (Boolean(q.hasMultipleAnswers) !== sh.hasMultipleAnswers) {
const e = new Error(
`Вопрос ${i + 1}: hasMultipleAnswers должен быть ${sh.hasMultipleAnswers}.`
);
e.code = 'llm_shape';
throw e;
}
}
}
/**
* @param {string} testTitle
* @param {string} testDescription
* @param {Array<{ optionsCount: number, hasMultipleAnswers: boolean }>} shape
*/
export async function generateFullTestByShape(testTitle, testDescription, shape) {
const cfg = getLlmConfig();
if (!cfg) {
const e = new Error('Задайте DEEPSEEK_API_KEY или OPENAI_API_KEY на сервере.');
/** @type {any} */ (e).status = 503;
throw e;
}
const title = (testTitle || '').trim() || 'Тест';
const desc = (testDescription || '').trim();
const lines = shape.map(
(sh, i) =>
`Вопрос ${i + 1}: ровно ${sh.optionsCount} вариантов ответа; ${
sh.hasMultipleAnswers
? 'несколько вариантов помечены как верные (hasMultipleAnswers: true).'
: 'ровно один верный вариант (hasMultipleAnswers: false).'
}`
);
const system =
'Ты составитель учебных тестов. Отвечай ТОЛЬКО одним JSON-объектом на русском. Схема: {"title": string, "description": string (может быть пустой строкой), "questions": array}. Каждый вопрос: {"text", "hasMultipleAnswers", "options": [{ "text", "isCorrect" }]}.';
const user = `Составь тест по теме.
Название (можно уточнить, но смысл сохранить): ${title}
Краткое описание / контекст темы: ${desc || 'не указано; придумай согласованную тему с названием.'}
Соблюди СТРОГО число вопросов и вариантов (не больше и не меньше):
${lines.join('\n')}
Правила: варианты осмысленные, по теме; отметь isCorrect согласно hasMultipleAnswers; для одного правильного ровна одна true.`;
const raw = await chatCompletionTextContent(cfg, system, user, 0.35);
const parsed = parseJsonFromLlmText(raw);
const draft = validateAndNormalizeDraft(parsed);
assertDraftMatchesShape({ questions: draft.questions }, shape);
return {
title: draft.title,
description: draft.description,
questions: draft.questions,
};
}
/**
* Пустой вопрос сгенерировать формулировки; непустой переформулировать только текст вопроса.
* @param {string} testTitle
* @param {string} testDescription
* @param {string} questionText
* @param {number} optionsCount
* @param {boolean} hasMultipleAnswers
*/
export async function generateOrRephraseQuestion(
testTitle,
testDescription,
questionText,
optionsCount,
hasMultipleAnswers
) {
const cfg = getLlmConfig();
if (!cfg) {
const e = new Error('Задайте DEEPSEEK_API_KEY или OPENAI_API_KEY на сервере.');
/** @type {any} */ (e).status = 503;
throw e;
}
const n = Math.floor(Number(optionsCount));
if (!Number.isFinite(n) || n < 2 || n > 12) {
const e = new Error('optionsCount: от 2 до 12.');
e.status = 400;
throw e;
}
const topic = `${(testTitle || '').trim() || 'Тест'}. ${(testDescription || '').trim()}`.trim();
const qt = (questionText || '').trim();
if (qt) {
const system =
'Ты редактор учебных материалов. Отвечай ТОЛЬКО JSON: {"text": string} — чёткая формулировка вопроса на русском, 1–3 полных предложения в зависимости от сложности исходного черновика, без вариантов ответа.';
const user = `Тема теста: ${topic}\n\nИсходный черновик вопроса (улучши формулировку, не меняй смысл без нужды):\n${qt}`;
const raw = await chatCompletionTextContent(cfg, system, user, 0.3);
const parsed = parseJsonFromLlmText(raw);
const text = String((/** @type {any} */ (parsed)).text ?? '').trim();
if (!text) {
const e = new Error('Пустой text в ответе модели.');
e.code = 'llm_shape';
throw e;
}
return { mode: 'rephrase', text };
}
const system =
'Ты составитель тестов. Отвечай ТОЛЬКО JSON: {"text", "hasMultipleAnswers", "options": [{ "text", "isCorrect" }]}. Все на русском.';
const user = `Тема теста: ${topic}
Сформулируй ОДИН вопрос по этой теме с ровно ${n} вариантами ответа. hasMultipleAnswers = ${
hasMultipleAnswers
? 'true (несколько верных, минимум 2 isCorrect: true, остальные false).'
: 'false (ровно один isCorrect: true).'
}`;
const raw = await chatCompletionTextContent(cfg, system, user, 0.35);
const parsed = parseJsonFromLlmText(raw);
const shape = [{ optionsCount: n, hasMultipleAnswers: Boolean(hasMultipleAnswers) }];
assertDraftMatchesShape({ questions: [parsed] }, shape);
const draft = validateAndNormalizeDraft({
title: 'временно',
questions: [parsed],
});
return {
mode: 'full',
text: draft.questions[0].text,
hasMultipleAnswers: draft.questions[0].hasMultipleAnswers,
options: draft.questions[0].options,
};
}

20
backend/src/services/aiEditorService.test.js

@ -0,0 +1,20 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { parseAndValidateShape } from './aiEditorService.js';
test('parseAndValidateShape: валидный ввод', () => {
const s = parseAndValidateShape([
{ optionsCount: 3, hasMultipleAnswers: false },
{ optionsCount: 2, hasMultipleAnswers: true },
]);
assert.equal(s.length, 2);
assert.equal(s[0].optionsCount, 3);
assert.equal(s[1].hasMultipleAnswers, true);
});
test('parseAndValidateShape: пусто — ошибка', () => {
assert.throws(
() => parseAndValidateShape([]),
/Передайте/
);
});

125
backend/src/services/assignmentDirectoryService.js

@ -0,0 +1,125 @@
/**
* Каталог для назначения: HR (staff_members + отделы) + учётки clinic_tests по staff_id.
* Две БД данные сливаем в Node.
*/
import { getHrPool, queryHr } from '../db/hrPool.js';
import pool from '../db/db.js';
/**
* @param {{ q?: string, department?: string, clinicFilter?: 'all' | 'with' | 'without' }} p
*/
export async function getAssignmentDirectory(p) {
const { rows: clinicByStaff } = await pool.query(
`SELECT id, staff_id, login, full_name
FROM users
WHERE is_active = true AND staff_id IS NOT NULL`
);
const byStaff = new Map();
for (const r of clinicByStaff) {
byStaff.set(r.staff_id, { clinicUserId: r.id, login: r.login, fullName: r.full_name });
}
if (!getHrPool()) {
const { rows } = await pool.query(
`SELECT u.id, u.staff_id, u.full_name AS fio, u.login AS "webLogin"
FROM users u WHERE u.is_active = true ORDER BY u.full_name NULLS LAST, u.login`
);
let people = rows.map((r) => ({
staffId: r.staff_id,
fio: r.fio || r.webLogin,
webLogin: r.webLogin,
departments: '',
clinicUserId: r.id,
}));
const qx = (p.q || '').trim().toLowerCase();
if (qx) {
people = people.filter(
(x) =>
(x.fio && x.fio.toLowerCase().includes(qx)) ||
(x.webLogin && x.webLogin.toLowerCase().includes(qx)) ||
(x.clinicUserId && x.clinicUserId.toLowerCase().includes(qx))
);
}
return { people, source: 'clinic' };
}
const q = (p.q || '').trim();
const dept = (p.department || '').trim();
const clinicFilter = p.clinicFilter || 'all';
const { rows: staffRows } = await queryHr(
`SELECT sm.id AS staff_id, sm.fio, sm.web_login
FROM staff_members sm`,
[]
);
if (!staffRows.length) {
return { people: [], source: 'hr' };
}
const { rows: edRows } = await queryHr(
`SELECT staff_id, department FROM employees_departments
WHERE department IS NOT NULL AND trim(department) <> ''`,
[]
);
const deptsByStaff = new Map();
for (const r of edRows) {
if (!deptsByStaff.has(r.staff_id)) {
deptsByStaff.set(r.staff_id, new Set());
}
deptsByStaff.get(r.staff_id).add(r.department);
}
let people = staffRows.map((r) => {
const dset = deptsByStaff.get(r.staff_id);
const departments = dset
? [...dset].sort((a, b) => a.localeCompare(b, 'ru')).join(', ')
: '';
const cu = byStaff.get(r.staff_id) || null;
return {
staffId: r.staff_id,
fio: r.fio || '—',
webLogin: r.web_login,
departments,
clinicUserId: cu ? cu.clinicUserId : null,
};
});
if (q) {
const low = q.toLowerCase();
people = people.filter(
(x) =>
(x.fio && x.fio.toLowerCase().includes(low)) ||
(x.webLogin && x.webLogin.toLowerCase().includes(low))
);
}
if (dept && dept !== '__all__') {
people = people.filter((x) => {
const s = deptsByStaff.get(x.staffId);
return s && s.has(dept);
});
}
if (clinicFilter === 'with') {
people = people.filter((x) => x.clinicUserId != null);
} else if (clinicFilter === 'without') {
people = people.filter((x) => x.clinicUserId == null);
}
people.sort((a, b) => (a.fio || '').localeCompare(b.fio || '', 'ru'));
return { people, source: 'hr' };
}
/**
* @returns {Promise<string[]>}
*/
export async function getHrDepartmentNames() {
if (!getHrPool()) {
return [];
}
const { rows } = await queryHr(
`SELECT DISTINCT TRIM(department) AS d
FROM employees_departments
WHERE department IS NOT NULL AND TRIM(department) <> ''
ORDER BY 1`
);
return rows.map((r) => r.d).filter(Boolean);
}

64
backend/src/services/assignmentUserService.js

@ -0,0 +1,64 @@
/**
* Создать/найти запись `clinic_tests.users` по staff_id (HR), чтобы назначить target_id = uuid.
*/
import { queryHr, getHrPool } from '../db/hrPool.js';
import { HR_MANAGED_PASSWORD_PLACEHOLDER } from '../config/authConstants.js';
import { RU } from '../messages/ru.js';
/**
* @param {import('pg').Pool} pool
* @param {number} staffId
* @returns {Promise<string>} uuid в clinic_tests.users
*/
export async function ensureClinicUserIdForStaff(pool, staffId) {
const n = Math.floor(Number(staffId));
if (!Number.isFinite(n) || n < 1) {
const e = new Error(RU.assignmentUserRequired);
e.status = 400;
throw e;
}
const { rows: ex } = await pool.query(
`SELECT id FROM users WHERE staff_id = $1 AND is_active = true LIMIT 1`,
[n]
);
if (ex.length) {
return ex[0].id;
}
if (!getHrPool()) {
const e = new Error('Нет HR БД: нельзя завести учётку по staff_id.');
e.status = 400;
throw e;
}
const { rows: st } = await queryHr(
`SELECT id, fio, web_login FROM staff_members WHERE id = $1`,
[n]
);
if (!st.length) {
const e = new Error('Сотрудник не найден в HR.');
e.status = 400;
throw e;
}
const fio = st[0].fio || `staff #${n}`;
const rawLogin = (st[0].web_login && String(st[0].web_login).trim()) || null;
let login = rawLogin;
if (!login) {
login = `staff_${n}@clinic.local`;
}
const { rows: taken } = await pool.query(
`SELECT 1 FROM users WHERE LOWER(TRIM(login)) = LOWER(TRIM($1)) AND (staff_id IS NULL OR staff_id <> $2) LIMIT 1`,
[login, n]
);
if (taken.length) {
login = `staff_${n}@clinic.local`;
}
const ins = await pool.query(
`INSERT INTO users (login, password_hash, full_name, role, department_id, is_active, staff_id)
VALUES ($1, $2, $3, 'employee', null, true, $4)
ON CONFLICT (staff_id) DO UPDATE SET
full_name = EXCLUDED.full_name,
is_active = true
RETURNING id`,
[login, HR_MANAGED_PASSWORD_PLACEHOLDER, fio, n]
);
return ins.rows[0].id;
}

66
backend/src/services/documentExtractService.js

@ -0,0 +1,66 @@
/**
* D.2 извлечение текста из PDF, DOCX, TXT (см. card1.md).
*/
import { readFile } from 'fs/promises';
import { createRequire } from 'node:module';
import mammoth from 'mammoth';
import { RU } from '../messages/ru.js';
const require = createRequire(import.meta.url);
const pdfParse = require('pdf-parse');
/** @param {string} mime @param {string} [originalName] */
export function resolveDocumentKind(mime, originalName = '') {
const m = (mime || '').toLowerCase();
const n = originalName.toLowerCase();
if (m === 'application/pdf' || n.endsWith('.pdf')) {
return 'pdf';
}
if (
m ===
'application/vnd.openxmlformats-officedocument.wordprocessingml.document' ||
n.endsWith('.docx')
) {
return 'docx';
}
if (m === 'text/plain' || m === 'text/markdown' || n.endsWith('.txt') || n.endsWith('.md')) {
return 'text';
}
return null;
}
/**
* @param {string} mimetype
* @param {string} filePath
* @param {string} [originalName]
* @returns {Promise<string>} извлечённый плоский текст
*/
export async function extractTextFromFile(mimetype, filePath, originalName) {
const kind = resolveDocumentKind(mimetype, originalName);
if (!kind) {
const e = new Error(RU.unsupportedFileType);
e.status = 400;
throw e;
}
const buf = await readFile(filePath);
return extractTextFromBuffer(kind, buf);
}
/**
* @param {'pdf'|'docx'|'text'} kind
* @param {Buffer} buffer
*/
export async function extractTextFromBuffer(kind, buffer) {
if (kind === 'text') {
return buffer.toString('utf8');
}
if (kind === 'docx') {
const { value } = await mammoth.extractRawText({ buffer });
return (value || '').replace(/\r\n/g, '\n').trim();
}
if (kind === 'pdf') {
const data = await pdfParse(buffer);
return ((data && data.text) || '').replace(/\r\n/g, '\n').trim();
}
return '';
}

33
backend/src/services/documentExtractService.test.js

@ -0,0 +1,33 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import {
extractTextFromBuffer,
resolveDocumentKind,
} from './documentExtractService.js';
test('resolveDocumentKind: PDF по MIME и по имени', () => {
assert.equal(resolveDocumentKind('application/pdf'), 'pdf');
assert.equal(resolveDocumentKind('', 'X.PDF'), 'pdf');
assert.equal(resolveDocumentKind('application/octet-stream', 'a.pdf'), 'pdf');
});
test('resolveDocumentKind: docx, txt, неизвестно', () => {
assert.equal(
resolveDocumentKind(
'application/vnd.openxmlformats-officedocument.wordprocessingml.document'
),
'docx'
);
assert.equal(resolveDocumentKind('text/plain', 'x.txt'), 'text');
assert.equal(resolveDocumentKind('', 'readme.md'), 'text');
assert.equal(resolveDocumentKind('image/png'), null);
assert.equal(resolveDocumentKind('application/octet-stream', 'a.exe'), null);
});
test('extractTextFromBuffer: text UTF-8', async () => {
const t = await extractTextFromBuffer(
'text',
Buffer.from('Проверка D.2', 'utf8')
);
assert.equal(t, 'Проверка D.2');
});

176
backend/src/services/documentGenService.js

@ -0,0 +1,176 @@
/**
* D.3 генерация структуры теста из извлечённого текста (OpenAI-совместимый Chat Completions).
* Ключ: DEEPSEEK_API_KEY (по умолчанию api.deepseek.com) или OPENAI_API_KEY. Опц.: LLM_BASE_URL, LLM_MODEL.
*/
import { getLlmConfig, chatCompletionTextContent } from './llmClient.js';
const MAX_EXTRACT_CHARS = 14000;
/**
* @param {string} text
* @returns {string}
*/
export function parseJsonFromLlmText(text) {
if (typeof text !== 'string' || !text.trim()) {
const e = new Error('Пустой ответ модели.');
e.code = 'llm_empty';
throw e;
}
let t = text.trim();
const fence = /^```(?:json)?\s*([\s\S]*?)```$/m.exec(t);
if (fence) {
t = fence[1].trim();
}
let parsed;
try {
parsed = JSON.parse(t);
} catch (err) {
const e = new Error('Ответ модели не является корректным JSON.');
e.code = 'llm_json_parse';
throw e;
}
return parsed;
}
/**
* @param {unknown} o
* @returns {{ title: string, description: string | null, questions: Array<{ text: string, hasMultipleAnswers: boolean, options: Array<{ text: string, isCorrect: boolean }> }> }}
*/
export function validateAndNormalizeDraft(o) {
if (!o || typeof o !== 'object') {
const e = new Error('JSON не содержит объекта с данными.');
e.code = 'llm_shape';
throw e;
}
const title = String((/** @type {any} */ (o)).title ?? '').trim();
if (!title) {
const e = new Error('В ответе нет поля title.');
e.code = 'llm_shape';
throw e;
}
const desc = (/** @type {any} */ (o)).description;
const description =
desc != null && String(desc).trim() ? String(desc).trim() : null;
const rawQs = (/** @type {any} */ (o)).questions;
if (!Array.isArray(rawQs) || rawQs.length === 0) {
const e = new Error('В ответе нет вопросов (questions).');
e.code = 'llm_shape';
throw e;
}
if (rawQs.length > 40) {
const e = new Error('Слишком много вопросов в ответе (макс. 40).');
e.code = 'llm_shape';
throw e;
}
const questions = rawQs.map((q, i) => {
if (!q || typeof q !== 'object') {
const e = new Error(`Вопрос ${i + 1}: неверный формат.`);
e.code = 'llm_shape';
throw e;
}
const text = String((/** @type {any} */ (q)).text ?? '').trim();
if (!text) {
const e = new Error(`Вопрос ${i + 1}: пустой текст.`);
e.code = 'llm_shape';
throw e;
}
const hasMultipleAnswers = Boolean(
(/** @type {any} */ (q)).hasMultipleAnswers
);
const rawOpts = (/** @type {any} */ (q)).options;
if (!Array.isArray(rawOpts) || rawOpts.length < 2) {
const e = new Error(`Вопрос ${i + 1}: нужны минимум 2 варианта ответа.`);
e.code = 'llm_shape';
throw e;
}
if (rawOpts.length > 12) {
const e = new Error(`Вопрос ${i + 1}: слишком много вариантов (макс. 12).`);
e.code = 'llm_shape';
throw e;
}
const options = rawOpts.map((op, j) => {
if (!op || typeof op !== 'object') {
const e = new Error(
`Вопрос ${i + 1}, вариант ${j + 1}: неверный формат.`
);
e.code = 'llm_shape';
throw e;
}
return {
text: String((/** @type {any} */ (op)).text ?? '').trim() || `Вариант ${j + 1}`,
isCorrect: Boolean((/** @type {any} */ (op)).isCorrect),
};
});
const correctN = options.filter((x) => x.isCorrect).length;
if (correctN === 0) {
const e = new Error(
`Вопрос ${i + 1}: отметьте минимум один правильный вариант.`
);
e.code = 'llm_shape';
throw e;
}
if (!hasMultipleAnswers && correctN > 1) {
const e = new Error(
`Вопрос ${i + 1}: с одним правильным ответом должен быть один вариант isCorrect, либо укажите hasMultipleAnswers: true.`
);
e.code = 'llm_shape';
throw e;
}
return { text, hasMultipleAnswers, options };
});
return { title, description, questions };
}
/**
* D.1/D.2/D.3 ответ для POST /import/document (клиент не получает сырые ключи).
* @param {string} extractedText
*/
export async function generationForImportDocument(extractedText) {
const text = (extractedText || '').trim();
if (!text) {
return {
available: false,
message: 'Нет извлечённого текста — нечего передавать в модель.',
};
}
const cfg = getLlmConfig();
if (!cfg) {
return {
available: false,
message:
'Автогенерация выключена: задайте DEEPSEEK_API_KEY или OPENAI_API_KEY (см. backend/.env.example). Ниже — превью текста; можно вставить в черновик вручную.',
textPreview: text.slice(0, 4000),
};
}
const slice =
text.length > MAX_EXTRACT_CHARS
? `${text.slice(0, MAX_EXTRACT_CHARS)}\n\n[…фрагмент обрезан для API]`
: text;
try {
const system =
'Ты помощник для составления тестов. Отвечай ТОЛЬКО одним JSON-объектом без пояснений. Схема: {"title": string, "description"?: string, "questions": array}. Каждый вопрос: {"text", "hasMultipleAnswers": boolean, "options": [{"text", "isCorrect": boolean}, ...]}. Минимум 2 варианта. Для одиночного выбора ровно один isCorrect: true. Текст и формулировки — на русском, по содержанию входного материала.';
const user =
'Составь тест с вопросами с одним или несколькими правильными ответами на основе текста:\n\n' + slice;
const raw = await chatCompletionTextContent(cfg, system, user, 0.25);
const parsed = parseJsonFromLlmText(raw);
const draft = validateAndNormalizeDraft(parsed);
return {
available: true,
message: `Сгенерировано: «${draft.title}», вопросов: ${draft.questions.length}. Нажмите «Применить сгенерированный черновик» ниже.`,
draft: {
title: draft.title,
description: draft.description,
questions: draft.questions,
},
};
} catch (e) {
const msg = e instanceof Error ? e.message : String(e);
const code = e instanceof Error && 'code' in e ? (/** @type {any} */ (e)).code : 'llm_error';
return {
available: false,
message: `Генерация не удалась: ${msg}`,
errorCode: code,
textPreview: text.slice(0, 4000),
};
}
}

63
backend/src/services/documentGenService.test.js

@ -0,0 +1,63 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import {
parseJsonFromLlmText,
validateAndNormalizeDraft,
} from './documentGenService.js';
test('parseJsonFromLlmText: чистый JSON', () => {
const o = parseJsonFromLlmText('{"title":"T","questions":[{"text":"Q","options":[{"text":"a","isCorrect":true},{"text":"b","isCorrect":false}]}]}');
assert.equal(o.title, 'T');
assert.equal(o.questions.length, 1);
});
test('parseJsonFromLlmText: JSON в markdown-заборе', () => {
const raw = '```json\n{"title":"X","questions":[{"text":"1","options":[{"text":"+","isCorrect":true},{"text":"-","isCorrect":false}]}]}\n```';
const o = parseJsonFromLlmText(raw);
assert.equal(o.title, 'X');
});
test('parseJsonFromLlmText: невалидный JSON — ошибка', () => {
assert.throws(
() => parseJsonFromLlmText('not json'),
/JSON/i
);
});
test('validateAndNormalizeDraft: валидный черновик', () => {
const d = validateAndNormalizeDraft({
title: ' Экзамен ',
description: ' оп ',
questions: [
{
text: '2+2?',
hasMultipleAnswers: false,
options: [
{ text: '4', isCorrect: true },
{ text: '5', isCorrect: false },
],
},
],
});
assert.equal(d.title, 'Экзамен');
assert.equal(d.description, 'оп');
assert.equal(d.questions[0].options.length, 2);
});
test('validateAndNormalizeDraft: нет title', () => {
assert.throws(
() =>
validateAndNormalizeDraft({
questions: [
{
text: 'Q',
options: [
{ text: 'a', isCorrect: true },
{ text: 'b', isCorrect: false },
],
},
],
}),
/title/i
);
});

98
backend/src/services/llmClient.js

@ -0,0 +1,98 @@
/**
* OpenAI-совместимый Chat Completions. Общий для импорта и редактора.
*/
/**
* @returns {null | { provider: string, apiKey: string, baseUrl: string, model: string }}
*/
export function getLlmConfig() {
if (process.env.DEEPSEEK_API_KEY) {
return {
provider: 'deepseek',
apiKey: process.env.DEEPSEEK_API_KEY,
baseUrl: (process.env.LLM_BASE_URL || 'https://api.deepseek.com/v1').replace(
/\/+$/,
''
),
model: process.env.LLM_MODEL || 'deepseek-chat',
};
}
if (process.env.OPENAI_API_KEY) {
return {
provider: 'openai',
apiKey: process.env.OPENAI_API_KEY,
baseUrl: (process.env.LLM_BASE_URL || 'https://api.openai.com/v1').replace(
/\/+$/,
''
),
model: process.env.LLM_MODEL || 'gpt-4o-mini',
};
}
return null;
}
/**
* @param {{ baseUrl: string, apiKey: string, model: string }} cfg
* @param {string} system
* @param {string} user
* @param {number} [temperature]
* @returns {Promise<string>} raw assistant message
*/
export async function chatCompletionTextContent(cfg, system, user, temperature = 0.25) {
const url = `${cfg.baseUrl}/chat/completions`;
const body = {
model: cfg.model,
messages: [
{ role: 'system', content: system },
{ role: 'user', content: user },
],
temperature,
};
if (process.env.LLM_NO_JSON !== '1') {
body.response_format = { type: 'json_object' };
}
const ac = new AbortController();
const t = setTimeout(() => ac.abort(), 120000);
let res;
try {
res = await fetch(url, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${cfg.apiKey}`,
},
body: JSON.stringify(body),
signal: ac.signal,
});
} catch (e) {
if (e.name === 'AbortError') {
const err = new Error('Превышен таймаут ожидания ответа LLM (120 с).');
err.code = 'llm_timeout';
throw err;
}
const err = new Error(
e instanceof Error ? e.message : 'Сбой сети при обращении к LLM'
);
err.code = 'llm_network';
throw err;
} finally {
clearTimeout(t);
}
if (!res.ok) {
const errText = await res.text();
const err = new Error(
`LLM ${res.status}: ${errText.replace(/\s+/g, ' ').slice(0, 280)}`
);
err.code = 'llm_http';
err.status = res.status;
throw err;
}
const data = await res.json();
const content = data?.choices?.[0]?.message?.content;
if (typeof content !== 'string' || !content.trim()) {
const e = new Error('Пустой content в ответе API.');
e.code = 'llm_empty';
throw e;
}
return content;
}

477
backend/src/services/testAttemptService.js

@ -0,0 +1,477 @@
/**
* Прохождение теста: контент для игры, проверка ответов, завершение попытки.
*/
import { RU } from '../messages/ru.js';
import { isTestAuthor } from '../config/devAuthor.js';
/**
* @param {import('pg').Pool|import('pg').PoolClient} db
* @param {string} testVersionId
* @param {{ includeCorrect: boolean }} opts
*/
export async function loadQuestionsForVersion(db, testVersionId, opts) {
const { rows: qrows } = await db.query(
`SELECT id, text, question_order, has_multiple_answers
FROM questions
WHERE test_version_id = $1
ORDER BY question_order`,
[testVersionId]
);
const out = [];
for (const row of qrows) {
const { rows: orows } = await db.query(
`SELECT id, text, is_correct, option_order
FROM answer_options
WHERE question_id = $1
ORDER BY option_order`,
[row.id]
);
const options = orows.map((o) => {
const base = {
id: o.id,
text: o.text,
optionOrder: o.option_order,
};
if (opts.includeCorrect) {
return { ...base, isCorrect: o.is_correct };
}
return base;
});
out.push({
id: row.id,
text: row.text,
questionOrder: row.question_order,
hasMultipleAnswers: row.has_multiple_answers,
options,
});
}
return out;
}
function sortUuidStrings(arr) {
return [...new Set(arr)].map(String).sort();
}
function sameSelection(selected, correctIds) {
const a = sortUuidStrings(selected);
const b = sortUuidStrings(correctIds);
if (a.length !== b.length) {
return false;
}
return a.every((x, i) => x === b[i]);
}
/**
* @param {import('pg').Pool} pool
* @param {string} userId
* @param {string} testId
*/
export async function getEditorContent(pool, userId, testId) {
const { rows: tr } = await pool.query(
`SELECT t.id, t.title, t.description, t.passing_threshold, t.created_by
FROM tests t WHERE t.id = $1`,
[testId]
);
if (!tr.length) {
const e = new Error(RU.testNotFound);
e.status = 404;
throw e;
}
if (!isTestAuthor(tr[0].created_by, userId)) {
const e = new Error(RU.forbidden);
e.status = 403;
throw e;
}
const { rows: tv } = await pool.query(
`SELECT id FROM test_versions WHERE test_id = $1 AND is_active = true LIMIT 1`,
[testId]
);
if (!tv.length) {
const e = new Error(RU.noActiveVersion);
e.status = 400;
throw e;
}
const versionId = tv[0].id;
const questions = await loadQuestionsForVersion(pool, versionId, {
includeCorrect: true,
});
return {
test: {
id: tr[0].id,
title: tr[0].title,
description: tr[0].description,
passingThreshold: tr[0].passing_threshold,
},
activeVersionId: versionId,
questions,
};
}
/**
* @param {import('pg').Pool} pool
* @param {string} userId
* @param {string} testId
* @param {string} attemptId
*/
export async function getPlayContent(pool, userId, testId, attemptId) {
const { rows: arows } = await pool.query(
`SELECT ta.id, ta.user_id, ta.status, ta.test_version_id, tv.test_id, t.title, t.passing_threshold
FROM test_attempts ta
INNER JOIN test_versions tv ON tv.id = ta.test_version_id
INNER JOIN tests t ON t.id = tv.test_id
WHERE ta.id = $1`,
[attemptId]
);
if (!arows.length) {
const e = new Error(RU.attemptNotFound);
e.status = 404;
throw e;
}
const a = arows[0];
if (a.test_id !== testId) {
const e = new Error(RU.attemptNotFound);
e.status = 404;
throw e;
}
if (a.user_id !== userId) {
const e = new Error(RU.forbidden);
e.status = 403;
throw e;
}
if (a.status !== 'in_progress') {
const e = new Error(RU.attemptNotInProgress);
e.status = 400;
throw e;
}
const questions = await loadQuestionsForVersion(pool, a.test_version_id, {
includeCorrect: false,
});
return {
testTitle: a.title,
passingThreshold: a.passing_threshold,
attemptId: a.id,
questions,
};
}
/**
* @param {import('pg').Pool} pool
* @param {string} userId
* @param {string} testId
* @param {string} attemptId
* @param {Record<string, string | string[] | undefined> | null | undefined} rawAnswers
*/
export async function submitAttempt(pool, userId, testId, attemptId, rawAnswers) {
const answers = rawAnswers && typeof rawAnswers === 'object' ? rawAnswers : {};
const client = await pool.connect();
try {
await client.query('BEGIN');
const { rows: arows } = await client.query(
`SELECT id, user_id, status, test_version_id
FROM test_attempts
WHERE id = $1
FOR UPDATE`,
[attemptId]
);
if (!arows.length) {
const e = new Error(RU.attemptNotFound);
e.status = 404;
throw e;
}
const a0 = arows[0];
const { rows: trows } = await client.query(
`SELECT t.passing_threshold, tv.test_id
FROM test_versions tv
INNER JOIN tests t ON t.id = tv.test_id
WHERE tv.id = $1`,
[a0.test_version_id]
);
if (!trows.length) {
const e = new Error(RU.testNotFound);
e.status = 404;
throw e;
}
const link = trows[0];
const a = {
test_id: link.test_id,
user_id: a0.user_id,
status: a0.status,
test_version_id: a0.test_version_id,
passing_threshold: link.passing_threshold,
};
if (a.test_id !== testId) {
const e = new Error(RU.attemptNotFound);
e.status = 404;
throw e;
}
if (a.user_id !== userId) {
const e = new Error(RU.forbidden);
e.status = 403;
throw e;
}
if (a.status !== 'in_progress') {
const e = new Error(RU.attemptNotInProgress);
e.status = 400;
throw e;
}
const versionId = a.test_version_id;
const threshold = Number(a.passing_threshold) || 0;
const { rows: qrows } = await client.query(
`SELECT id, has_multiple_answers
FROM questions
WHERE test_version_id = $1`,
[versionId]
);
if (!qrows.length) {
const e = new Error(RU.testHasNoQuestions);
e.status = 400;
throw e;
}
const { rows: allOpts } = await client.query(
`SELECT a.id, a.question_id, a.is_correct
FROM answer_options a
INNER JOIN questions q ON q.id = a.question_id
WHERE q.test_version_id = $1`,
[versionId]
);
const byQuestion = new Map();
for (const o of allOpts) {
if (!byQuestion.has(o.question_id)) {
byQuestion.set(o.question_id, { all: new Set(), correct: [] });
}
const g = byQuestion.get(o.question_id);
g.all.add(String(o.id));
if (o.is_correct) {
g.correct.push(String(o.id));
}
}
let correctCount = 0;
for (const q of qrows) {
const qid = String(q.id);
let selected = answers[qid] ?? answers[q.id];
if (selected == null) {
selected = [];
} else if (!Array.isArray(selected)) {
selected = [String(selected)];
} else {
selected = selected.map(String);
}
const g = byQuestion.get(q.id);
if (!g) {
continue;
}
for (const sid of selected) {
if (!g.all.has(sid)) {
const e = new Error(RU.invalidOptionForQuestion);
e.status = 400;
throw e;
}
}
if (sameSelection(selected, g.correct)) {
correctCount += 1;
}
}
const total = qrows.length;
const percent = (correctCount / total) * 100;
const passed = percent + 1e-9 >= threshold;
await client.query(`DELETE FROM user_answers WHERE attempt_id = $1`, [attemptId]);
for (const q of qrows) {
const qid = String(q.id);
let selected = answers[qid] ?? answers[q.id] ?? [];
if (!Array.isArray(selected)) {
selected = [String(selected)];
} else {
selected = selected.map(String);
}
await client.query(
`INSERT INTO user_answers (attempt_id, question_id, selected_options)
VALUES ($1, $2, $3::uuid[])`,
[attemptId, q.id, selected]
);
}
await client.query(
`UPDATE test_attempts
SET status = 'completed', completed_at = CURRENT_TIMESTAMP,
correct_count = $2, total_questions = $3, passed = $4
WHERE id = $1`,
[attemptId, correctCount, total, passed]
);
await client.query('COMMIT');
const base = {
attemptId,
correctCount,
totalQuestions: total,
percent: Math.round(percent * 10) / 10,
passed,
passingThreshold: threshold,
};
const review = await buildReviewFromDb(pool, attemptId);
return { ...base, review };
} catch (e) {
await client.query('ROLLBACK');
throw e;
} finally {
client.release();
}
}
/**
* Подробный разбор завершённой попытки (для API и ответа submit).
* @param {import('pg').Pool|import('pg').PoolClient} pool
* @param {string} attemptId
*/
export async function buildReviewFromDb(pool, attemptId) {
const { rows: arows } = await pool.query(
`SELECT ta.id, ta.status, ta.test_version_id, ta.user_id, ta.correct_count, ta.total_questions,
ta.passed, ta.started_at, ta.completed_at,
t.id AS test_id, t.title, t.passing_threshold,
u.full_name AS attempter_name, u.login AS attempter_login
FROM test_attempts ta
INNER JOIN test_versions tv ON tv.id = ta.test_version_id
INNER JOIN tests t ON t.id = tv.test_id
INNER JOIN users u ON u.id = ta.user_id
WHERE ta.id = $1`,
[attemptId]
);
if (!arows.length) {
const e = new Error(RU.attemptNotFound);
e.status = 404;
throw e;
}
const a = arows[0];
if (a.status !== 'completed') {
const e = new Error(RU.attemptNotCompleted);
e.status = 400;
throw e;
}
const questions = await loadQuestionsForVersion(pool, a.test_version_id, {
includeCorrect: true,
});
const { rows: uans } = await pool.query(
`SELECT question_id, selected_options FROM user_answers WHERE attempt_id = $1`,
[attemptId]
);
const selByQ = new Map();
for (const r of uans) {
selByQ.set(String(r.question_id), (r.selected_options || []).map(String));
}
const threshold = Number(a.passing_threshold) || 0;
const total = a.total_questions || questions.length;
const percent =
total > 0
? Math.round(((a.correct_count || 0) / total) * 1000) / 10
: 0;
const qOut = questions.map((q) => {
const selected = sortUuidStrings(selByQ.get(String(q.id)) || []);
const correctIdList = sortUuidStrings(
q.options.filter((o) => o.isCorrect).map((o) => String(o.id))
);
const isUserCorrect = sameSelection(selected, correctIdList);
const selectedSet = new Set(selected);
return {
id: q.id,
text: q.text,
hasMultipleAnswers: q.hasMultipleAnswers,
isUserCorrect,
options: q.options.map((o) => ({
id: o.id,
text: o.text,
isCorrect: o.isCorrect,
selected: selectedSet.has(String(o.id)),
})),
};
});
return {
attemptId: a.id,
testId: a.test_id,
testTitle: a.title,
passingThreshold: threshold,
correctCount: a.correct_count,
totalQuestions: total,
percent,
passed: a.passed,
startedAt: a.started_at,
completedAt: a.completed_at,
attempterUserId: a.user_id,
attempterName: a.attempter_name,
attempterLogin: a.attempter_login,
questions: qOut,
};
}
/**
* Разбор попытки: владелец попытки или автор теста.
* @param {import('pg').Pool} pool
* @param {string} currentUserId
* @param {string} testId
* @param {string} attemptId
*/
export async function getAttemptReviewForUser(pool, currentUserId, testId, attemptId) {
const { rows } = await pool.query(
`SELECT ta.user_id, t.created_by, tv.test_id
FROM test_attempts ta
INNER JOIN test_versions tv ON tv.id = ta.test_version_id
INNER JOIN tests t ON t.id = tv.test_id
WHERE ta.id = $1`,
[attemptId]
);
if (!rows.length) {
const e = new Error(RU.attemptNotFound);
e.status = 404;
throw e;
}
const r0 = rows[0];
if (r0.test_id !== testId) {
const e = new Error(RU.attemptNotFound);
e.status = 404;
throw e;
}
const isOwner = r0.user_id === currentUserId;
const isAuthor = isTestAuthor(r0.created_by, currentUserId);
if (!isOwner && !isAuthor) {
const e = new Error(RU.forbidden);
e.status = 403;
throw e;
}
return buildReviewFromDb(pool, attemptId);
}
/**
* Список всех попыток по цепочке (все версии) только автор.
* @param {import('pg').Pool} pool
* @param {string} authorId
* @param {string} testId
*/
export async function listTestAttemptsForAuthor(pool, authorId, testId) {
const { rows: t } = await pool.query(
`SELECT id, created_by FROM tests WHERE id = $1`,
[testId]
);
if (!t.length) {
const e = new Error(RU.testNotFound);
e.status = 404;
throw e;
}
if (!isTestAuthor(t[0].created_by, authorId)) {
const e = new Error(RU.forbidden);
e.status = 403;
throw e;
}
const { rows } = await pool.query(
`SELECT ta.id, ta.user_id, ta.status, ta.attempt_number, ta.started_at, ta.completed_at,
ta.correct_count, ta.total_questions, ta.passed, tv.version AS test_version,
u.full_name AS attempter_name, u.login AS attempter_login
FROM test_attempts ta
INNER JOIN test_versions tv ON tv.id = ta.test_version_id
INNER JOIN users u ON u.id = ta.user_id
WHERE tv.test_id = $1
ORDER BY ta.started_at DESC NULLS LAST
LIMIT 200`,
[testId]
);
return rows;
}

32
backend/src/services/testDraftService.js

@ -2,6 +2,8 @@
* V.3 saveTestDraft, fork версии, контент вопросов. * V.3 saveTestDraft, fork версии, контент вопросов.
*/ */
import { hasAnyAttemptForTest } from './testChainService.js'; import { hasAnyAttemptForTest } from './testChainService.js';
import { RU } from '../messages/ru.js';
import { isTestAuthor } from '../config/devAuthor.js';
/** /**
* @param {import('pg').PoolClient} client * @param {import('pg').PoolClient} client
@ -93,23 +95,25 @@ export async function replaceVersionContent(client, testVersionId, payload) {
export async function forkNewVersion(client, testId) { export async function forkNewVersion(client, testId) {
const av = await getActiveVersionRow(client, testId); const av = await getActiveVersionRow(client, testId);
if (!av) { if (!av) {
throw new Error('no active version'); throw new Error(RU.noActiveVersion);
} }
const { rows: mx } = await client.query( const { rows: mx } = await client.query(
`SELECT COALESCE(MAX(version), 0) AS v FROM test_versions WHERE test_id = $1`, `SELECT COALESCE(MAX(version), 0) AS v FROM test_versions WHERE test_id = $1`,
[testId] [testId]
); );
const nextV = (mx[0].v || 0) + 1; const nextV = (mx[0].v || 0) + 1;
// Сначала снять is_active с цепочки: частичный уникальный индекс
// uq_test_versions_one_active_per_test — не более одной true на test_id.
await client.query(
`UPDATE test_versions SET is_active = false WHERE test_id = $1`,
[testId]
);
const { rows: nv } = await client.query( const { rows: nv } = await client.query(
`INSERT INTO test_versions (test_id, version, is_active, parent_id) `INSERT INTO test_versions (test_id, version, is_active, parent_id)
VALUES ($1, $2, true, $3) RETURNING *`, VALUES ($1, $2, true, $3) RETURNING *`,
[testId, nextV, av.id] [testId, nextV, av.id]
); );
const newRow = nv[0]; 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); await copyQuestionTree(client, av.id, newRow.id);
return newRow; return newRow;
} }
@ -126,13 +130,13 @@ export async function saveTestDraft(pool, authorId, testId, payload) {
[testId] [testId]
); );
if (!tr.length) { if (!tr.length) {
const e = new Error('Test not found'); const e = new Error(RU.testNotFound);
e.status = 404; e.status = 404;
throw e; throw e;
} }
const t = tr[0]; const t = tr[0];
if (t.created_by !== authorId) { if (!isTestAuthor(t.created_by, authorId)) {
const e = new Error('Forbidden'); const e = new Error(RU.forbidden);
e.status = 403; e.status = 403;
throw e; throw e;
} }
@ -148,10 +152,20 @@ export async function saveTestDraft(pool, authorId, testId, payload) {
[testId, payload.title ?? null, payload.description ?? null] [testId, payload.title ?? null, payload.description ?? null]
); );
} }
if (payload.passingThreshold !== undefined && payload.passingThreshold !== null) {
const raw = Number(payload.passingThreshold);
if (Number.isFinite(raw)) {
const pt = Math.max(0, Math.min(100, Math.round(raw)));
await client.query(
`UPDATE tests SET passing_threshold = $2, updated_at = CURRENT_TIMESTAMP WHERE id = $1`,
[testId, pt]
);
}
}
const hasAttempts = await hasAnyAttemptForTest(client, testId); const hasAttempts = await hasAnyAttemptForTest(client, testId);
let versionRow = await getActiveVersionRow(client, testId); let versionRow = await getActiveVersionRow(client, testId);
if (!versionRow) { if (!versionRow) {
const e = new Error('No active version'); const e = new Error(RU.noActiveVersion);
e.status = 500; e.status = 500;
throw e; throw e;
} }

8
backend/src/utils/auth.js

@ -3,7 +3,7 @@
* Password hashing and JWT token management * Password hashing and JWT token management
*/ */
import bcrypt from 'bcrypt'; import { hash, compare } from 'bcryptjs';
import jwt from 'jsonwebtoken'; import jwt from 'jsonwebtoken';
import dotenv from 'dotenv'; import dotenv from 'dotenv';
import { checkWerkzeugPassword } from './werkzeugPassword.js'; import { checkWerkzeugPassword } from './werkzeugPassword.js';
@ -14,7 +14,7 @@ dotenv.config();
const JWT_SECRET = process.env.JWT_SECRET; const JWT_SECRET = process.env.JWT_SECRET;
const JWT_EXPIRES_IN = process.env.JWT_EXPIRES_IN || '7d'; const JWT_EXPIRES_IN = process.env.JWT_EXPIRES_IN || '7d';
// Salt rounds for bcrypt // Salt rounds (bcryptjs — тот же формат $2*, без нативной сборки — проще Docker/ARM/musl)
const SALT_ROUNDS = 10; const SALT_ROUNDS = 10;
/** /**
@ -23,7 +23,7 @@ const SALT_ROUNDS = 10;
* @returns {Promise<string>} Hashed password * @returns {Promise<string>} Hashed password
*/ */
export async function hashPassword(password) { export async function hashPassword(password) {
return bcrypt.hash(password, SALT_ROUNDS); return hash(password, SALT_ROUNDS);
} }
/** /**
@ -43,7 +43,7 @@ export async function comparePassword(password, hash) {
return checkWerkzeugPassword(hash, password); return checkWerkzeugPassword(hash, password);
} }
if (hash.startsWith('$2')) { if (hash.startsWith('$2')) {
return bcrypt.compare(password, hash); return compare(password, hash);
} }
return checkWerkzeugPassword(hash, password); return checkWerkzeugPassword(hash, password);
} }

11
docker-compose.dev.yml

@ -14,15 +14,22 @@ services:
context: ./backend context: ./backend
dockerfile: Dockerfile dockerfile: Dockerfile
container_name: testing_webapp_backend container_name: testing_webapp_backend
# LLM и прочие секреты из хоста (не копируются в образ — см. .dockerignore)
env_file:
- ./backend/.env
environment: environment:
DATABASE_URL: postgresql://hr_bot_user:hrbot123@hr_postgres_dev:5432/clinic_tests DATABASE_URL: postgresql://hr_bot_user:hrbot123@hr_postgres_dev:5432/clinic_tests
JWT_SECRET: ${JWT_SECRET:-testing_webapp_jwt_dev} JWT_SECRET: ${JWT_SECRET:-testing_webapp_jwt_dev}
# development: httpOnly-cookie без Secure (иначе на http://localhost:8080 логин не сработает) # development: httpOnly-cookie без Secure (иначе на http://localhost:8080 логин не сработает)
NODE_ENV: development NODE_ENV: development
FRONTEND_URL: http://localhost:8080 FRONTEND_URL: http://localhost:8080
# На хосте 3002, если 3001 занят локальным dev-сервером # Вход теми же учётками, что в HR: проверка пароля в hr_bot_test + привязка сотрудника по web_login.
# Без HR_AUTH / HR_DATABASE_URL логин ищется только в clinic_tests.users (локальные dev-учётки).
HR_AUTH: ${HR_AUTH:-1}
HR_DATABASE_URL: postgresql://hr_bot_user:hrbot123@hr_postgres_dev:5432/hr_bot_test
# На хосте 3002, если 3107 занят локальным dev-сервером
ports: ports:
- "3002:3001" - "3002:3107"
networks: networks:
- app - app
- postgres - postgres

13
frontend/index.html

@ -4,7 +4,18 @@
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" /> <link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Клинические Тесты</title> <meta name="color-scheme" content="light" />
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link
href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600&family=Manrope:wght@600;700;800&display=swap"
rel="stylesheet"
/>
<link
href="https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined:opsz,wght,FILL,GRAD@20..48,100..700,0..1,-50..200&display=swap"
rel="stylesheet"
/>
<title>Система тестрования</title>
</head> </head>
<body> <body>
<div id="root"></div> <div id="root"></div>

3
frontend/nginx-default.conf

@ -6,8 +6,9 @@ server {
location / { location / {
try_files $uri $uri/ /index.html; try_files $uri $uri/ /index.html;
} }
client_max_body_size 10m;
location /api/ { location /api/ {
proxy_pass http://testing-backend:3001; proxy_pass http://testing-backend:3107;
proxy_http_version 1.1; proxy_http_version 1.1;
proxy_set_header Host $host; proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Real-IP $remote_addr;

14
frontend/src/App.jsx

@ -1,15 +1,25 @@
import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom'; import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom';
import CabinetLayout from './components/CabinetLayout';
import Login from './pages/Login'; import Login from './pages/Login';
import TestsList from './pages/TestsList'; import TestsList from './pages/TestsList';
import TestDetail from './pages/TestDetail'; import TestDetail from './pages/TestDetail';
import TestAttempt from './pages/TestAttempt';
import TestAttemptReview from './pages/TestAttemptReview';
function App() { function App() {
return ( return (
<BrowserRouter> <BrowserRouter>
<Routes> <Routes>
<Route path="/login" element={<Login />} /> <Route path="/login" element={<Login />} />
<Route path="/tests" element={<TestsList />} /> <Route element={<CabinetLayout />}>
<Route path="/tests/:id" element={<TestDetail />} /> <Route path="/tests" element={<TestsList />} />
<Route path="/tests/:id/attempt/:attemptId" element={<TestAttempt />} />
<Route
path="/tests/:id/attempts/:attemptId/review"
element={<TestAttemptReview />}
/>
<Route path="/tests/:id" element={<TestDetail />} />
</Route>
<Route path="/" element={<Navigate to="/tests" replace />} /> <Route path="/" element={<Navigate to="/tests" replace />} />
</Routes> </Routes>
</BrowserRouter> </BrowserRouter>

12
frontend/src/api.js

@ -1,12 +1,16 @@
const base = ''; const base = '';
export async function api(path, opts = {}) { export async function api(path, opts = {}) {
const isFormData =
typeof FormData !== 'undefined' && opts.body instanceof FormData;
const r = await fetch(`${base}${path}`, { const r = await fetch(`${base}${path}`, {
credentials: 'include', credentials: 'include',
headers: { headers: isFormData
'Content-Type': 'application/json', ? { ...(opts.headers || {}) }
...(opts.headers || {}), : {
}, 'Content-Type': 'application/json',
...(opts.headers || {}),
},
...opts, ...opts,
}); });
const text = await r.text(); const text = await r.text();

112
frontend/src/components/AttemptReviewBlock.jsx

@ -0,0 +1,112 @@
import { Link } from 'react-router-dom';
/**
* @param {{ review: {
* testTitle?: string,
* attempterName?: string,
* attempterLogin?: string,
* startedAt?: string,
* completedAt?: string,
* correctCount: number,
* totalQuestions: number,
* percent: number,
* passed: boolean,
* passingThreshold: number,
* questions: Array<{
* id: string,
* text: string,
* isUserCorrect: boolean,
* options: Array<{ id: string, text: string, isCorrect: boolean, selected: boolean }>
* }>
* }, showAttempter?: boolean, backLink: { to: string, label: string } }} p
*/
export default function AttemptReviewBlock({ review, showAttempter, backLink }) {
if (!review?.questions?.length) {
return null;
}
return (
<div className="attempt-review" style={{ maxWidth: 640 }}>
{showAttempter && (review.attempterName || review.attempterLogin) && (
<p className="text-muted" style={{ marginTop: 0, fontSize: 14 }}>
Участник: {review.attempterName || '—'}{' '}
{review.attempterLogin && (
<span className="code-inline" style={{ fontSize: 12 }}>
{review.attempterLogin}
</span>
)}
</p>
)}
{review.completedAt && (
<p className="text-muted" style={{ marginTop: 0, fontSize: 13 }}>
Завершено: {new Date(review.completedAt).toLocaleString('ru-RU')}
</p>
)}
<ol style={{ paddingLeft: '1.1rem', marginTop: '0.75rem' }}>
{review.questions.map((q, i) => (
<li
key={q.id}
style={{
marginBottom: '1.1rem',
borderLeft: '3px solid',
borderColor: q.isUserCorrect
? 'color-mix(in srgb, var(--primary) 50%, var(--outline-variant))'
: 'color-mix(in srgb, var(--error, #c00) 45%, var(--outline-variant))',
paddingLeft: 10,
}}
>
<p style={{ marginTop: 0, marginBottom: 8, fontWeight: 600 }}>{i + 1}. {q.text}</p>
<p
className={q.isUserCorrect ? 'text-muted' : 'error-text'}
style={{ margin: '0 0 6px', fontSize: 13 }}
>
{q.isUserCorrect ? 'Верно' : 'Ошибка'}
</p>
<ul style={{ listStyle: 'none', padding: 0, margin: 0, fontSize: 14 }}>
{q.options.map((o) => {
const mark =
o.selected && o.isCorrect
? '✓ верно'
: o.selected && !o.isCorrect
? '✗ выбрано'
: !o.selected && o.isCorrect
? '— правильный вариант'
: '';
return (
<li
key={o.id}
style={{
marginBottom: 4,
opacity: o.selected || o.isCorrect ? 1 : 0.7,
}}
>
<span
style={
o.isCorrect
? { fontWeight: 600, color: 'var(--primary, #007168)' }
: o.selected
? {}
: { color: 'var(--secondary)' }
}
>
{o.text}
</span>
{mark && (
<span className="text-muted" style={{ marginLeft: 8, fontSize: 12 }}>
{mark}
</span>
)}
</li>
);
})}
</ul>
</li>
))}
</ol>
{backLink && (
<p className="link-back" style={{ marginTop: '1rem' }}>
<Link to={backLink.to}>{backLink.label}</Link>
</p>
)}
</div>
);
}

1
frontend/src/main.jsx

@ -1,6 +1,7 @@
import React from 'react'; import React from 'react';
import ReactDOM from 'react-dom/client'; import ReactDOM from 'react-dom/client';
import App from './App.jsx'; import App from './App.jsx';
import './styles/cabinet-theme.css';
ReactDOM.createRoot(document.getElementById('root')).render( ReactDOM.createRoot(document.getElementById('root')).render(
<React.StrictMode> <React.StrictMode>

84
frontend/src/pages/Login.jsx

@ -23,41 +23,59 @@ export default function Login() {
} }
return ( return (
<div style={{ maxWidth: 360, margin: '48px auto', fontFamily: 'system-ui' }}> <div className="login-page">
<h1>Вход</h1> <div className="login-shell">
<p style={{ color: '#666', fontSize: 14 }}> <div className="login-logo">
Локальный пользователь из <code>clinic_tests</code> (если HR_AUTH не <div className="login-logo__frame" aria-hidden>
включён). <span className="material-symbols-outlined">school</span>
</p> </div>
<form onSubmit={onSubmit}> <h1 className="font-headline">Система тестрования</h1>
<div style={{ marginBottom: 12 }}> <p className="login-subtitle">Войдите в систему</p>
<label>
Логин
<br />
<input
value={login}
onChange={(e) => setLogin(e.target.value)}
style={{ width: '100%', padding: 8 }}
autoComplete="username"
/>
</label>
</div> </div>
<div style={{ marginBottom: 12 }}>
<label> {err && (
Пароль <div className="callout callout--error" style={{ marginBottom: '1rem' }}>
<br /> {err}
<input </div>
type="password" )}
value={password}
onChange={(e) => setPassword(e.target.value)} <div className="login-card">
style={{ width: '100%', padding: 8 }} <form onSubmit={onSubmit} noValidate>
autoComplete="current-password" <div className="form-field">
/> <label className="form-label" htmlFor="login-username">
</label> Логин
</label>
<input
id="login-username"
className="form-input"
value={login}
onChange={(e) => setLogin(e.target.value)}
autoComplete="username"
/>
</div>
<div className="form-field">
<label className="form-label" htmlFor="login-password">
Пароль
</label>
<input
id="login-password"
className="form-input"
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
autoComplete="current-password"
/>
</div>
<button type="submit" className="btn btn-primary">
Войти
</button>
</form>
<p className="text-muted" style={{ marginTop: '1.25rem', marginBottom: 0 }}>
Локальный пользователь в <code className="code-inline">clinic_tests</code> (если
отключён вход через персонал HR).
</p>
</div> </div>
{err && <p style={{ color: 'coral' }}>{err}</p>} </div>
<button type="submit">Войти</button>
</form>
</div> </div>
); );
} }

215
frontend/src/pages/TestAttempt.jsx

@ -0,0 +1,215 @@
import { useEffect, useState } from 'react';
import { Link, useNavigate, useParams } from 'react-router-dom';
import AttemptReviewBlock from '../components/AttemptReviewBlock';
import { api } from '../api';
export default function TestAttempt() {
const { id: testId, attemptId } = useParams();
const nav = useNavigate();
const [play, setPlay] = useState(null);
const [err, setErr] = useState(null);
const [submitErr, setSubmitErr] = useState(null);
const [loading, setLoading] = useState(true);
const [sending, setSending] = useState(false);
/** @type {Record<string, string[]>} */
const [selections, setSelections] = useState({});
const [result, setResult] = useState(null);
useEffect(() => {
let cancelled = false;
(async () => {
setErr(null);
setLoading(true);
try {
const data = await api(`/api/tests/${testId}/attempts/${attemptId}/play`);
if (!cancelled) {
setPlay(data);
}
} catch (e) {
if (e.status === 401) {
nav('/login');
return;
}
if (!cancelled) {
setErr(e.message);
}
} finally {
if (!cancelled) {
setLoading(false);
}
}
})();
return () => {
cancelled = true;
};
}, [testId, attemptId, nav]);
function toggleOption(questionId, optionId, hasMultiple) {
setSelections((prev) => {
const key = String(questionId);
const cur = prev[key] || [];
const id = String(optionId);
if (hasMultiple) {
if (cur.includes(id)) {
return { ...prev, [key]: cur.filter((x) => x !== id) };
}
return { ...prev, [key]: [...cur, id] };
}
return { ...prev, [key]: [id] };
});
}
function isSelected(questionId, optionId) {
const s = selections[String(questionId)] || [];
return s.includes(String(optionId));
}
async function onSubmit() {
setSubmitErr(null);
setSending(true);
try {
const out = await api(`/api/tests/${testId}/attempts/${attemptId}/submit`, {
method: 'POST',
body: JSON.stringify({ answers: selections }),
});
setResult(out);
} catch (e) {
setSubmitErr(e.message);
} finally {
setSending(false);
}
}
if (loading) {
return <p className="text-muted">Загрузка вопросов</p>;
}
if (err) {
return (
<div>
<p className="error-text">{err}</p>
<p>
<Link to={`/tests/${testId}`}> к карточке теста</Link>
</p>
</div>
);
}
if (result) {
return (
<div>
<p className="link-back">
<Link to={`/tests/${testId}`}> к карточке теста</Link>
</p>
<h1 className="font-headline" style={{ fontSize: '1.35rem' }}>
Результат
</h1>
<p>
Правильно: <strong>{result.correctCount}</strong> из {result.totalQuestions} (
{result.percent}%). Порог: {result.passingThreshold}%.
</p>
<p className={result.passed ? 'text-muted' : 'error-text'}>
{result.passed
? 'Тест пройден по порогу.'
: 'Порог не достигнут — при необходимости начните новую попытку на карточке теста.'}
</p>
{result.review && (
<>
<h2
className="font-headline"
style={{ fontSize: '1.1rem', marginTop: '1.25rem', marginBottom: '0.5rem' }}
>
Разбор
</h2>
<AttemptReviewBlock review={result.review} showAttempter={false} />
{result.attemptId && (
<p className="text-muted" style={{ fontSize: 14, marginTop: '0.75rem' }}>
<Link to={`/tests/${testId}/attempts/${result.attemptId}/review`}>
Полная страница разбора
</Link>
</p>
)}
</>
)}
<div className="inline-actions" style={{ marginTop: '1rem' }}>
<Link to={`/tests/${testId}`} className="btn btn-ghost">
К настройкам теста
</Link>
</div>
</div>
);
}
if (!play?.questions?.length) {
return (
<div>
<p className="error-text">В этой версии нет вопросов. Добавьте вопросы в черновике.</p>
<Link to={`/tests/${testId}`}> к карточке</Link>
</div>
);
}
return (
<div>
<p className="link-back">
<Link to={`/tests/${testId}`}> к карточке теста</Link>
</p>
<h1 className="font-headline" style={{ fontSize: '1.35rem', marginTop: 0 }}>
{play.testTitle}
</h1>
<p className="text-muted" style={{ marginTop: 0 }}>
Отметьте ответы и нажмите «Завершить». Порог для зачёта: {play.passingThreshold}%.
</p>
<ol style={{ paddingLeft: '1.25rem', maxWidth: 640 }}>
{play.questions.map((q) => (
<li key={q.id} style={{ marginBottom: '1.5rem' }}>
<p style={{ marginTop: 0, marginBottom: '0.5rem' }}>{q.text}</p>
<ul style={{ listStyle: 'none', padding: 0, margin: 0 }}>
{q.options.map((o) => {
const inputType = q.hasMultipleAnswers ? 'checkbox' : 'radio';
const name = `q-${q.id}`;
return (
<li key={o.id} style={{ marginBottom: 6 }}>
<label
style={{
display: 'flex',
alignItems: 'flex-start',
gap: 8,
cursor: 'pointer',
}}
>
<input
type={inputType}
name={q.hasMultipleAnswers ? undefined : name}
checked={isSelected(q.id, o.id)}
onChange={() => toggleOption(q.id, o.id, q.hasMultipleAnswers)}
style={{ marginTop: 3 }}
aria-label={o.text}
/>
<span>{o.text}</span>
</label>
</li>
);
})}
</ul>
</li>
))}
</ol>
{submitErr && (
<p className="error-text" role="alert">
{submitErr}
</p>
)}
<div className="inline-actions" style={{ marginTop: '0.5rem' }}>
<button
type="button"
className="btn btn-ghost"
onClick={onSubmit}
disabled={sending}
>
{sending ? 'Отправка…' : 'Завершить тест'}
</button>
</div>
</div>
);
}

79
frontend/src/pages/TestAttemptReview.jsx

@ -0,0 +1,79 @@
import { useEffect, useState } from 'react';
import { Link, useNavigate, useParams } from 'react-router-dom';
import { api } from '../api';
import AttemptReviewBlock from '../components/AttemptReviewBlock';
export default function TestAttemptReview() {
const { id: testId, attemptId } = useParams();
const nav = useNavigate();
const [review, setReview] = useState(null);
const [err, setErr] = useState(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
let cancelled = false;
(async () => {
setErr(null);
setLoading(true);
try {
const data = await api(`/api/tests/${testId}/attempts/${attemptId}/review`);
if (!cancelled) {
setReview(data);
}
} catch (e) {
if (e.status === 401) {
nav('/login');
return;
}
if (!cancelled) {
setErr(e.message);
}
} finally {
if (!cancelled) {
setLoading(false);
}
}
})();
return () => {
cancelled = true;
};
}, [testId, attemptId, nav]);
if (loading) {
return <p className="text-muted">Загрузка разбора</p>;
}
if (err) {
return (
<div>
<p className="error-text">{err}</p>
<p>
<Link to={`/tests/${testId}`}> к карточке теста</Link>
</p>
</div>
);
}
if (!review) {
return null;
}
return (
<div>
<p className="link-back">
<Link to={`/tests/${testId}`}> к карточке теста</Link>
</p>
<h1 className="font-headline" style={{ fontSize: '1.35rem' }}>
Разбор попытки: {review.testTitle}
</h1>
<p>
Правильно: <strong>{review.correctCount}</strong> из {review.totalQuestions} ({review.percent}
%). Порог: {review.passingThreshold}%.{' '}
{review.passed ? (
<span className="text-muted">Зачёт.</span>
) : (
<span className="error-text">Незачёт.</span>
)}
</p>
<AttemptReviewBlock review={review} showAttempter />
</div>
);
}

785
frontend/src/styles/cabinet-theme.css

@ -0,0 +1,785 @@
/* Match: HR_TG_Bot/tgFlaskForm .../cabinet/tailwind_config.js + cabinet/login.html */
:root {
--surface: #ffffff;
--surface-container-low: #f3f8f9;
--surface-container: #eaf3f5;
--surface-container-high: #dfeef1;
--on-surface: #0d1b1d;
--on-surface-variant: #3d5357;
--primary: #007168;
--on-primary: #ffffff;
--primary-container: #56f1e0;
--on-primary-container: #00574f;
--secondary: #506965;
--secondary-container: #cce8e3;
--on-secondary-container: #3d5653;
--error: #af3d3b;
--outline-variant: #b9bc94;
--outline: #80835f;
--shadow-card: 0 8px 40px rgba(0, 0, 0, 0.08);
--radius-card: 2rem;
--max-content: 42rem; /* max-w-2xl */
color-scheme: light;
}
*,
*::before,
*::after {
box-sizing: border-box;
}
html {
color-scheme: light;
}
body {
margin: 0;
min-height: 100dvh;
font-family: 'Inter', system-ui, -apple-system, sans-serif;
background: var(--surface-container-low);
color: var(--on-surface);
-webkit-tap-highlight-color: transparent;
line-height: 1.45;
}
#root {
min-height: 100dvh;
}
.font-headline,
h1,
h2,
h3 {
font-family: 'Manrope', 'Inter', sans-serif;
font-weight: 700;
letter-spacing: -0.02em;
line-height: 1.2;
color: var(--on-surface);
}
h1 {
font-size: 1.5rem;
}
h2 {
font-size: 1.25rem;
margin: 0 0 0.75rem;
}
h3 {
font-size: 1.1rem;
margin: 1.25rem 0 0.5rem;
}
a {
color: var(--primary);
text-decoration: none;
font-weight: 500;
transition: color 0.15s ease;
}
a:hover {
color: var(--on-primary-container);
text-decoration: underline;
}
code,
.code-inline {
display: inline-block;
background: var(--secondary-container);
color: var(--on-primary-container);
padding: 1px 7px;
border-radius: 5px;
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
font-size: 0.8rem;
font-weight: 500;
}
.text-muted,
.text-secondary {
color: var(--secondary);
font-size: 0.875rem;
}
.material-symbols-outlined {
font-family: 'Material Symbols Outlined', sans-serif;
font-weight: normal;
font-style: normal;
line-height: 1;
letter-spacing: normal;
text-transform: none;
display: inline-block;
white-space: nowrap;
word-wrap: normal;
font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 24;
direction: ltr;
-webkit-font-feature-settings: 'liga';
font-feature-settings: 'liga';
-webkit-font-smoothing: antialiased;
}
/* --- Login (cabinet/login.html) --- */
.login-page {
min-height: 100dvh;
display: flex;
align-items: center;
justify-content: center;
padding: 1.5rem;
background: #ffffff;
}
.login-shell {
width: 100%;
max-width: 24rem; /* max-w-sm */
transition: max-width 0.2s ease-out;
}
.login-logo {
text-align: center;
margin-bottom: 2rem;
}
.login-logo__frame {
width: 4rem;
height: 4rem;
border-radius: 1.5rem;
background: #fff;
display: flex;
align-items: center;
justify-content: center;
margin: 0 auto 1rem;
box-shadow: 0 4px 24px rgba(0, 0, 0, 0.08);
border: 1px solid color-mix(in srgb, var(--outline-variant) 25%, transparent);
overflow: hidden;
}
.login-logo__frame .material-symbols-outlined {
font-size: 2.25rem;
color: var(--primary);
}
.login-page h1 {
font-size: 1.5rem;
font-weight: 800;
color: var(--primary);
margin: 0 0 0.25rem;
}
.login-subtitle {
color: var(--secondary);
font-size: 0.875rem;
margin: 0;
}
.login-card {
background: #fff;
border-radius: var(--radius-card);
box-shadow: var(--shadow-card);
padding: 1.5rem;
}
/* Form controls */
.form-label {
display: block;
font-size: 0.9rem;
font-weight: 500;
color: var(--on-surface);
margin-bottom: 0.35rem;
}
.form-input {
width: 100%;
padding: 11px 13px;
border: 1.5px solid var(--outline-variant);
border-radius: 0.75rem;
font-size: 15px;
font-family: inherit;
outline: none;
background: var(--surface-container-low);
color: var(--on-surface);
transition: border-color 0.15s, box-shadow 0.15s, background 0.15s;
}
.form-input::placeholder {
color: var(--on-surface-variant);
opacity: 0.7;
}
.form-input:focus {
border-color: var(--primary);
box-shadow: 0 0 0 3px rgba(0, 113, 104, 0.12);
background: #fff;
}
.form-field {
margin-bottom: 0.75rem;
}
/* Buttons */
.btn {
font-family: inherit;
font-size: 0.9375rem;
font-weight: 600;
padding: 0.55rem 1.1rem;
border-radius: 0.75rem;
border: 1.5px solid transparent;
cursor: pointer;
transition: background 0.15s, color 0.15s, border-color 0.15s, box-shadow 0.15s;
}
.btn-primary {
background: var(--primary);
color: var(--on-primary);
width: 100%;
padding-top: 0.65rem;
padding-bottom: 0.65rem;
margin-top: 0.5rem;
}
.btn-primary:hover {
background: #00645b;
filter: none;
}
.btn-primary:active {
transform: scale(0.99);
}
.btn-ghost {
background: transparent;
color: var(--primary);
border-color: color-mix(in srgb, var(--outline-variant) 50%, transparent);
}
.btn-ghost:hover {
background: var(--surface-container);
border-color: var(--primary);
}
.btn-ghost:disabled,
.btn-primary:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.btn--sm {
font-size: 0.8rem;
padding: 0.35rem 0.6rem;
border-radius: 0.5rem;
}
/* --- App shell (cabinet/base) --- */
.cabinet-app {
min-height: 100dvh;
display: flex;
flex-direction: column;
background: var(--surface);
}
.cabinet-page--center {
display: flex;
align-items: center;
justify-content: center;
min-height: 100dvh;
padding: 1.5rem;
}
.cabinet-header {
position: sticky;
top: 0;
z-index: 20;
background: color-mix(in srgb, var(--surface) 88%, transparent);
backdrop-filter: blur(10px);
-webkit-backdrop-filter: blur(10px);
border-bottom: 1px solid color-mix(in srgb, var(--outline-variant) 35%, transparent);
}
.cabinet-header__inner {
max-width: var(--max-content);
margin: 0 auto;
padding: 0.75rem 1.25rem;
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
}
.cabinet-brand {
display: flex;
align-items: center;
gap: 0.65rem;
color: var(--on-surface);
text-decoration: none;
min-width: 0;
}
.cabinet-brand:hover {
text-decoration: none;
color: var(--on-surface);
}
.cabinet-brand__icon {
font-size: 1.75rem;
color: var(--primary);
background: var(--surface-container-low);
border-radius: 0.75rem;
padding: 0.35rem;
border: 1px solid color-mix(in srgb, var(--outline-variant) 30%, transparent);
flex-shrink: 0;
}
.cabinet-brand__title {
font-family: 'Manrope', 'Inter', sans-serif;
font-weight: 800;
font-size: 1rem;
line-height: 1.2;
letter-spacing: -0.02em;
}
.cabinet-brand__subtitle {
font-size: 0.7rem;
color: var(--secondary);
text-transform: uppercase;
letter-spacing: 0.04em;
font-weight: 600;
}
.cabinet-header__actions {
display: flex;
align-items: center;
gap: 0.75rem;
flex-shrink: 0;
}
.cabinet-user {
font-size: 0.8rem;
color: var(--on-surface-variant);
text-align: right;
max-width: 12rem;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
display: none;
}
@media (min-width: 480px) {
.cabinet-user {
display: inline;
}
}
.cabinet-user__role {
color: var(--secondary);
font-weight: 500;
}
.cabinet-main {
flex: 1;
max-width: var(--max-content);
width: 100%;
margin: 0 auto;
padding: 1.25rem 1.25rem 2.5rem;
}
/* Cards & lists */
.surface-card {
background: var(--surface);
border: 1px solid color-mix(in srgb, var(--outline-variant) 30%, transparent);
border-radius: 1rem;
padding: 1rem 1.1rem;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.04);
}
.list-stack {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.list-row {
display: block;
border: 1px solid color-mix(in srgb, var(--outline-variant) 30%, transparent);
border-radius: 1rem;
padding: 0.9rem 1rem;
background: var(--surface);
transition: border-color 0.15s, box-shadow 0.15s;
}
.list-row:hover {
border-color: color-mix(in srgb, var(--primary) 35%, var(--outline-variant));
box-shadow: 0 2px 12px rgba(0, 113, 104, 0.08);
}
.list-row a {
text-decoration: none;
color: var(--on-surface);
font-weight: 600;
}
.list-row a:hover {
color: var(--primary);
text-decoration: none;
}
.list-row__meta {
color: var(--secondary);
font-size: 0.8rem;
display: block;
margin-top: 0.25rem;
}
/* Вся плитка — одна ссылка */
.list-row--action {
padding: 0;
overflow: hidden;
}
.list-row--action .list-row__link {
display: block;
padding: 0.9rem 1rem;
text-decoration: none;
color: inherit;
outline-offset: 2px;
border-radius: inherit;
transition: background 0.12s ease;
}
.list-row--action .list-row__link:hover {
background: color-mix(in srgb, var(--primary) 6%, transparent);
text-decoration: none;
}
.list-row--action .list-row__title {
display: block;
color: var(--on-surface);
font-weight: 600;
}
/* Список: слева ссылка на карточку, справа «Пройти» */
.list-row--split {
display: flex;
flex-direction: row;
align-items: stretch;
padding: 0;
overflow: hidden;
gap: 0;
}
.list-row--split .list-row__main {
flex: 1;
min-width: 0;
}
.list-row--split .list-row__link {
display: block;
padding: 0.9rem 1rem;
text-decoration: none;
color: inherit;
outline-offset: 2px;
border-radius: 0.85rem 0 0 0.85rem;
transition: background 0.12s ease;
}
.list-row--split .list-row__link:hover {
background: color-mix(in srgb, var(--primary) 6%, transparent);
text-decoration: none;
}
.list-row--split .list-row__side {
display: flex;
align-items: center;
padding: 0.5rem 0.9rem 0.5rem 0;
flex-shrink: 0;
border-left: 1px solid color-mix(in srgb, var(--outline-variant) 35%, transparent);
}
.list-row--split .list-row__title {
display: block;
color: var(--on-surface);
font-weight: 600;
}
@media (max-width: 520px) {
.list-row--split {
flex-wrap: wrap;
}
.list-row--split .list-row__link {
border-radius: 0.85rem 0.85rem 0 0;
}
.list-row--split .list-row__side {
width: 100%;
justify-content: flex-end;
border-left: none;
border-top: 1px solid color-mix(in srgb, var(--outline-variant) 35%, transparent);
padding: 0.5rem 0.9rem 0.75rem;
}
}
/* Карточка теста: визуальные блоки + сворачивание (удобно на узком экране) */
.test-detail-page {
max-width: var(--max-content, 42rem);
margin: 0 auto;
padding-bottom: 1.5rem;
}
.cabinet-brick {
margin-bottom: 1.1rem;
}
.cabinet-brick--hero {
padding: 0.1rem 0 0.2rem;
border-bottom: 1px solid color-mix(in srgb, var(--outline-variant) 45%, transparent);
margin-bottom: 1.25rem;
}
.cabinet-disclosure {
border: 1px solid color-mix(in srgb, var(--outline-variant) 30%, transparent);
border-radius: 1rem;
background: var(--surface);
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.04);
}
.cabinet-disclosure__summary {
cursor: pointer;
list-style: none;
user-select: none;
padding: 0.85rem 1rem 0.75rem;
font-size: 1.05rem;
border-radius: 1rem 1rem 0 0;
min-height: 2.75rem;
display: flex;
align-items: center;
}
.cabinet-disclosure__summary::-webkit-details-marker {
display: none;
}
.cabinet-disclosure__summary::after {
content: 'expand_more';
font-family: 'Material Symbols Outlined', sans-serif;
margin-left: auto;
font-size: 1.25rem;
opacity: 0.55;
font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 24;
transition: transform 0.2s ease;
}
.cabinet-disclosure[open] .cabinet-disclosure__summary::after {
transform: rotate(180deg);
}
.cabinet-disclosure__body {
padding: 0 1rem 1.05rem;
border-top: 1px solid color-mix(in srgb, var(--outline-variant) 35%, transparent);
}
.cabinet-disclosure[open] .cabinet-disclosure__summary {
border-bottom: 1px solid color-mix(in srgb, var(--outline-variant) 25%, transparent);
}
/* Назначение: поиск + список */
.assign-toolbar {
display: flex;
flex-direction: column;
gap: 0.5rem;
margin-bottom: 0.65rem;
}
@media (min-width: 520px) {
.assign-toolbar {
flex-direction: row;
flex-wrap: wrap;
align-items: center;
}
.assign-toolbar .form-input {
flex: 1 1 160px;
min-width: 0;
}
}
.assign-toolbar__search {
flex: 1 1 200px;
}
.assign-list {
max-height: min(50vh, 22rem);
overflow: auto;
border: 1px solid color-mix(in srgb, var(--outline-variant) 30%, transparent);
border-radius: 0.75rem;
background: var(--surface-container-low);
-webkit-overflow-scrolling: touch;
}
.assign-row {
display: flex;
gap: 0.5rem;
padding: 0.65rem 0.75rem;
border-bottom: 1px solid color-mix(in srgb, var(--outline-variant) 30%, transparent);
cursor: pointer;
align-items: flex-start;
}
.assign-row:last-child {
border-bottom: none;
}
.assign-row--selected,
.assign-row:hover {
background: color-mix(in srgb, var(--primary) 8%, transparent);
}
.assign-row__text {
display: flex;
flex-direction: column;
gap: 0.2rem;
min-width: 0;
flex: 1;
}
.assign-row__fio {
font-weight: 600;
color: var(--on-surface);
font-size: 0.95rem;
word-break: break-word;
}
.assign-row__login {
font-size: 0.8rem;
color: var(--secondary);
font-family: ui-monospace, Menlo, monospace;
}
.assign-row__meta {
font-size: 0.8rem;
color: var(--secondary);
line-height: 1.35;
}
.create-row {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
margin: 1.25rem 0;
align-items: center;
}
.create-row .form-input {
flex: 1 1 12rem;
min-width: 0;
}
.create-row .btn {
width: auto;
flex: 0 0 auto;
}
.callout {
border-radius: 1rem;
padding: 0.75rem 1rem;
font-size: 0.9rem;
font-weight: 500;
margin: 0 0 1rem;
}
.callout--warning {
background: #fffbeb;
border: 1px solid #fde68a;
color: #92400e;
}
.callout--error {
background: #fff5f5;
border: 1px solid #fecaca;
color: #991b1b;
}
.callout--success {
background: #ecfdf5;
border: 1px solid #a7f3d0;
color: #047857;
}
.error-text {
color: var(--error);
font-size: 0.9rem;
margin: 0.5rem 0 0;
}
.link-back {
display: inline-flex;
align-items: center;
gap: 0.25rem;
font-size: 0.9rem;
font-weight: 500;
margin: 0 0 1rem;
}
/* Table (detail) */
.table-cabinet {
width: 100%;
border-collapse: separate;
border-spacing: 0;
font-size: 0.875rem;
}
.table-cabinet th,
.table-cabinet td {
padding: 0.5rem 0.6rem;
text-align: left;
border-bottom: 1px solid color-mix(in srgb, var(--outline-variant) 40%, transparent);
vertical-align: top;
}
.table-cabinet th {
color: var(--secondary);
font-weight: 600;
font-size: 0.75rem;
text-transform: uppercase;
letter-spacing: 0.03em;
}
.table-cabinet tr:last-child td {
border-bottom: none;
}
.table-cabinet .mono {
font-size: 0.75rem;
word-break: break-all;
color: var(--on-surface-variant);
}
.draft-block {
margin-top: 1.25rem;
padding: 1rem;
background: var(--surface-container);
border-radius: 1rem;
border: 1px solid color-mix(in srgb, var(--outline-variant) 35%, transparent);
}
.draft-block .form-input {
margin-top: 0.25rem;
}
.muted {
color: var(--secondary);
font-size: 0.875rem;
}
.inline-actions {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.75rem;
margin-top: 0.5rem;
}
.inline-actions .btn {
width: auto;
}

2
frontend/vite.config.js

@ -7,7 +7,7 @@ export default defineConfig({
port: 5173, port: 5173,
proxy: { proxy: {
'/api': { '/api': {
target: 'http://localhost:3001', target: 'http://localhost:3107',
changeOrigin: true, changeOrigin: true,
}, },
}, },

Loading…
Cancel
Save