feat(card1): версии тестов API, черновик, HR-login, import, UI

- V.1–V.3: saveTestDraft, fork при попытках; миграция 003 staff_id
- V.4–V.6: REST /api/tests, activate, PATCH, start attempt
- A: HR_DATABASE_URL + Werkzeug/bcrypt, JWT staffId, HR_AUTH
- D.1: multipart /api/tests/import/document
- Frontend: login, список тестов, экран версий/черновика/попытки
- ТЗ: V.10 назначения vs активная версия; журнал приёма

Made-with: Cursor
This commit is contained in:
Константин Лебединский
2026-04-24 20:30:09 +05:00
parent 7fa6f98ee1
commit 5631d85238
37 changed files with 9687 additions and 59 deletions
+27
View File
@@ -0,0 +1,27 @@
/**
* Read-only (по соглашению) пул к hr_bot_test для логина и справки по сотруднику.
*/
import pg from 'pg';
import { getHrPoolConfig } from './poolConfig.js';
const { Pool } = pg;
const cfg = getHrPoolConfig();
const pool = cfg ? new Pool(cfg) : null;
export function getHrPool() {
return pool;
}
/**
* @param {string} text
* @param {unknown[]} [params]
*/
export async function queryHr(text, params) {
if (!pool) {
throw new Error('HR database not configured (set HR_DATABASE_URL)');
}
return pool.query(text, params);
}
export default { getHrPool, queryHr };
+130
View File
@@ -0,0 +1,130 @@
-- Initial database schema for clinic tests application
-- Version: 1.0
-- Enable UUID extension
CREATE EXTENSION IF NOT EXISTS "uuid-ossp";
-- Departments table
CREATE TABLE IF NOT EXISTS departments (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
name VARCHAR(255) NOT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
-- User roles enum
CREATE TYPE user_role AS ENUM ('hr', 'manager', 'employee');
-- Users table
CREATE TABLE IF NOT EXISTS users (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
login VARCHAR(100) UNIQUE NOT NULL,
password_hash VARCHAR(255) NOT NULL,
full_name VARCHAR(255) NOT NULL,
role user_role NOT NULL DEFAULT 'employee',
department_id UUID REFERENCES departments(id),
is_active BOOLEAN DEFAULT true,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
-- Create index for login lookup
CREATE INDEX IF NOT EXISTS idx_users_login ON users(login);
CREATE INDEX IF NOT EXISTS idx_users_department ON users(department_id);
-- Tests table
CREATE TABLE IF NOT EXISTS tests (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
title VARCHAR(255) NOT NULL,
description TEXT,
passing_threshold INTEGER DEFAULT 70,
time_limit INTEGER,
allow_back BOOLEAN DEFAULT true,
is_active BOOLEAN DEFAULT true,
is_versioned BOOLEAN DEFAULT false,
created_by UUID REFERENCES users(id),
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
-- Test versions table
CREATE TABLE IF NOT EXISTS test_versions (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
test_id UUID REFERENCES tests(id) ON DELETE CASCADE,
version INTEGER NOT NULL DEFAULT 1,
is_active BOOLEAN DEFAULT false,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
UNIQUE(test_id, version)
);
-- Questions table
CREATE TABLE IF NOT EXISTS questions (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
test_version_id UUID REFERENCES test_versions(id) ON DELETE CASCADE,
text TEXT NOT NULL,
question_order INTEGER NOT NULL,
has_multiple_answers BOOLEAN DEFAULT false
);
-- Answer options table
CREATE TABLE IF NOT EXISTS answer_options (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
question_id UUID REFERENCES questions(id) ON DELETE CASCADE,
text TEXT NOT NULL,
is_correct BOOLEAN DEFAULT false,
option_order INTEGER NOT NULL
);
-- Test assignments table
CREATE TABLE IF NOT EXISTS test_assignments (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
test_version_id UUID REFERENCES test_versions(id) ON DELETE CASCADE,
assigned_by UUID REFERENCES users(id),
deadline DATE,
max_attempts INTEGER DEFAULT 1,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
-- Assignment targets table
CREATE TYPE target_type AS ENUM ('department', 'user');
CREATE TABLE IF NOT EXISTS test_assignment_targets (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
assignment_id UUID REFERENCES test_assignments(id) ON DELETE CASCADE,
target_type target_type NOT NULL,
target_id UUID NOT NULL
);
-- Test attempts table
CREATE TYPE attempt_status AS ENUM ('in_progress', 'completed', 'expired');
CREATE TABLE IF NOT EXISTS test_attempts (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
test_version_id UUID REFERENCES test_versions(id) ON DELETE CASCADE,
user_id UUID REFERENCES users(id),
attempt_number INTEGER NOT NULL DEFAULT 1,
status attempt_status DEFAULT 'in_progress',
started_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
completed_at TIMESTAMP,
correct_count INTEGER DEFAULT 0,
total_questions INTEGER DEFAULT 0,
passed BOOLEAN DEFAULT false,
UNIQUE(test_version_id, user_id, attempt_number)
);
-- User answers table
CREATE TABLE IF NOT EXISTS user_answers (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
attempt_id UUID REFERENCES test_attempts(id) ON DELETE CASCADE,
question_id UUID REFERENCES questions(id) ON DELETE CASCADE,
selected_options UUID[] DEFAULT '{}'
);
-- Settings table
CREATE TABLE IF NOT EXISTS settings (
key VARCHAR(100) PRIMARY KEY,
value TEXT
);
-- Insert default admin user (password: admin123)
-- This will be done via application code to properly hash the password
@@ -0,0 +1,7 @@
-- Связь пользователя клиник-теста с сотрудником HR (staff_members.id) — card1 A.x
ALTER TABLE users
ADD COLUMN IF NOT EXISTS staff_id INTEGER UNIQUE;
COMMENT ON COLUMN users.staff_id IS 'id из hr_bot_test.staff_members; без дублирования кадров в clinic_tests';
CREATE INDEX IF NOT EXISTS idx_users_staff_id ON users(staff_id) WHERE staff_id IS NOT NULL;
+23
View File
@@ -40,3 +40,26 @@ export function getPoolConfig(overrides = {}) {
...overrides,
};
}
/**
* Пул к БД `hr_bot_test` (сотрудники, users, RBAC) — отдельно от `clinic_tests`.
* Только `HR_DATABASE_URL` (без каскадного fallback на `DATABASE_URL` — путаница опасна).
* @param {import('pg').PoolConfig} [overrides]
* @returns {import('pg').PoolConfig | null}
*/
export function getHrPoolConfig(overrides = {}) {
const url = process.env.HR_DATABASE_URL?.trim();
if (!url) {
return null;
}
return {
connectionString: url,
max: parseInt(process.env.HR_DB_POOL_MAX || '5', 10),
idleTimeoutMillis: parseInt(process.env.DB_IDLE_TIMEOUT || '30000', 10),
connectionTimeoutMillis: parseInt(
process.env.DB_CONNECTION_TIMEOUT || '2000',
10
),
...overrides,
};
}