Browse Source
- 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: Cursordev
37 changed files with 9687 additions and 59 deletions
@ -0,0 +1,15 @@ |
|||||||
|
{ |
||||||
|
"env": { |
||||||
|
"es2022": true, |
||||||
|
"node": true |
||||||
|
}, |
||||||
|
"parserOptions": { |
||||||
|
"ecmaVersion": "latest", |
||||||
|
"sourceType": "module" |
||||||
|
}, |
||||||
|
"extends": ["eslint:recommended"], |
||||||
|
"rules": { |
||||||
|
"no-unused-vars": ["error", { "argsIgnorePattern": "^_" }], |
||||||
|
"no-console": "warn" |
||||||
|
} |
||||||
|
} |
||||||
@ -0,0 +1,7 @@ |
|||||||
|
{ |
||||||
|
"semi": true, |
||||||
|
"singleQuote": true, |
||||||
|
"tabWidth": 2, |
||||||
|
"trailingComma": "es5", |
||||||
|
"printWidth": 100 |
||||||
|
} |
||||||
File diff suppressed because it is too large
Load Diff
@ -0,0 +1,45 @@ |
|||||||
|
import express from 'express'; |
||||||
|
import cors from 'cors'; |
||||||
|
import cookieParser from 'cookie-parser'; |
||||||
|
import dotenv from 'dotenv'; |
||||||
|
import authRoutes from './routes/auth.js'; |
||||||
|
import testsRoutes from './routes/tests.js'; |
||||||
|
|
||||||
|
dotenv.config(); |
||||||
|
|
||||||
|
export function createApp() { |
||||||
|
const app = express(); |
||||||
|
const corsOrigins = |
||||||
|
process.env.NODE_ENV === 'production' |
||||||
|
? process.env.FRONTEND_URL |
||||||
|
? [process.env.FRONTEND_URL] |
||||||
|
: [] |
||||||
|
: ['http://localhost:5173', 'http://localhost:3000']; |
||||||
|
app.use( |
||||||
|
cors({ |
||||||
|
origin: corsOrigins.length ? corsOrigins : true, |
||||||
|
credentials: true, |
||||||
|
}) |
||||||
|
); |
||||||
|
app.use(express.json()); |
||||||
|
app.use(cookieParser()); |
||||||
|
app.use('/api/auth', authRoutes); |
||||||
|
app.use('/api/tests', testsRoutes); |
||||||
|
app.get('/api/health', (req, res) => { |
||||||
|
res.json({ |
||||||
|
status: 'ok', |
||||||
|
timestamp: new Date().toISOString(), |
||||||
|
message: 'Server is running', |
||||||
|
}); |
||||||
|
}); |
||||||
|
app.use((err, req, res, _next) => { |
||||||
|
console.error('Error:', err); |
||||||
|
res.status(err.status || 500).json({ |
||||||
|
error: err.message || 'Internal Server Error', |
||||||
|
}); |
||||||
|
}); |
||||||
|
app.use((req, res) => { |
||||||
|
res.status(404).json({ error: 'Not found' }); |
||||||
|
}); |
||||||
|
return app; |
||||||
|
} |
||||||
@ -0,0 +1,7 @@ |
|||||||
|
/** Пароль-заглушка: вход только через HR, локальный compare не пройдёт. */ |
||||||
|
export const HR_MANAGED_PASSWORD_PLACEHOLDER = '$HR$MANAGED$NO_LOCAL$'; |
||||||
|
|
||||||
|
/** HR login enabled (1 = Werkzeug + upsert user по staff_id) */ |
||||||
|
export function isHrAuthEnabled() { |
||||||
|
return process.env.HR_AUTH === '1' || process.env.HR_AUTH === 'true'; |
||||||
|
} |
||||||
@ -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 }; |
||||||
@ -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; |
||||||
@ -1,52 +0,0 @@ |
|||||||
import express from 'express'; |
|
||||||
import cors from 'cors'; |
|
||||||
import cookieParser from 'cookie-parser'; |
|
||||||
import dotenv from 'dotenv'; |
|
||||||
|
|
||||||
import authRoutes from './routes/auth.js'; |
|
||||||
|
|
||||||
dotenv.config(); |
|
||||||
|
|
||||||
const app = express(); |
|
||||||
const PORT = process.env.PORT || 3001; |
|
||||||
|
|
||||||
// Middleware
|
|
||||||
app.use(cors({ |
|
||||||
origin: process.env.NODE_ENV === 'production' |
|
||||||
? process.env.FRONTEND_URL |
|
||||||
: ['http://localhost:5173', 'http://localhost:3000'], |
|
||||||
credentials: true, |
|
||||||
})); |
|
||||||
app.use(express.json()); |
|
||||||
app.use(cookieParser()); |
|
||||||
|
|
||||||
// Mount auth routes
|
|
||||||
app.use('/api/auth', authRoutes); |
|
||||||
|
|
||||||
// Health check route
|
|
||||||
app.get('/api/health', (req, res) => { |
|
||||||
res.json({ |
|
||||||
status: 'ok', |
|
||||||
timestamp: new Date().toISOString(), |
|
||||||
message: 'Server is running', |
|
||||||
}); |
|
||||||
}); |
|
||||||
|
|
||||||
// Error handling middleware
|
|
||||||
app.use((err, req, res, _next) => { |
|
||||||
console.error('Error:', err); |
|
||||||
res.status(err.status || 500).json({ |
|
||||||
error: err.message || 'Internal Server Error', |
|
||||||
}); |
|
||||||
}); |
|
||||||
|
|
||||||
// 404 handler
|
|
||||||
app.use((req, res) => { |
|
||||||
res.status(404).json({ error: 'Not found' }); |
|
||||||
}); |
|
||||||
|
|
||||||
// Start server
|
|
||||||
app.listen(PORT, () => { |
|
||||||
console.log(`Server is running on port ${PORT}`); |
|
||||||
console.log(`Environment: ${process.env.NODE_ENV || 'development'}`); |
|
||||||
}); |
|
||||||
@ -0,0 +1,170 @@ |
|||||||
|
/** |
||||||
|
* Authorization Middleware |
||||||
|
* JWT authentication and role-based access control |
||||||
|
*/ |
||||||
|
|
||||||
|
import { verifyToken } from '../utils/auth.js'; |
||||||
|
import { query } from '../db/db.js'; |
||||||
|
|
||||||
|
/** |
||||||
|
* Extract token from cookie |
||||||
|
* @param {Object} req - Express request object |
||||||
|
* @returns {string|null} Token from cookie |
||||||
|
*/ |
||||||
|
function getTokenFromCookie(req) { |
||||||
|
return req.cookies?.token || null; |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Middleware to authenticate JWT token |
||||||
|
* Adds user data to req.user |
||||||
|
*/ |
||||||
|
export async function authenticate(req, res, next) { |
||||||
|
try { |
||||||
|
const token = getTokenFromCookie(req); |
||||||
|
|
||||||
|
if (!token) { |
||||||
|
return res.status(401).json({ error: 'Authentication required' }); |
||||||
|
} |
||||||
|
|
||||||
|
const decoded = verifyToken(token); |
||||||
|
|
||||||
|
if (!decoded) { |
||||||
|
return res.status(401).json({ error: 'Invalid or expired token' }); |
||||||
|
} |
||||||
|
|
||||||
|
const result = await query( |
||||||
|
'SELECT id, login, full_name, role, department_id, staff_id FROM users WHERE id = $1', |
||||||
|
[decoded.userId] |
||||||
|
); |
||||||
|
|
||||||
|
if (result.rows.length === 0) { |
||||||
|
return res.status(401).json({ error: 'User not found' }); |
||||||
|
} |
||||||
|
|
||||||
|
const user = result.rows[0]; |
||||||
|
const staffId = user.staff_id ?? decoded.staffId; |
||||||
|
|
||||||
|
req.user = { |
||||||
|
id: user.id, |
||||||
|
login: user.login, |
||||||
|
fullName: user.full_name, |
||||||
|
role: user.role, |
||||||
|
departmentId: user.department_id, |
||||||
|
}; |
||||||
|
if (staffId != null) { |
||||||
|
req.user.staffId = staffId; |
||||||
|
} |
||||||
|
|
||||||
|
next(); |
||||||
|
} catch (error) { |
||||||
|
console.error('Auth middleware error:', error); |
||||||
|
return res.status(500).json({ error: 'Authentication error' }); |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Middleware factory to require specific roles |
||||||
|
* @param {string|string[]} roles - Required role(s) |
||||||
|
* @returns {Function} Express middleware |
||||||
|
*/ |
||||||
|
export function requireRole(roles) { |
||||||
|
const allowedRoles = Array.isArray(roles) ? roles : [roles]; |
||||||
|
|
||||||
|
return (req, res, next) => { |
||||||
|
if (!req.user) { |
||||||
|
return res.status(401).json({ error: 'Authentication required' }); |
||||||
|
} |
||||||
|
|
||||||
|
if (!allowedRoles.includes(req.user.role)) { |
||||||
|
return res.status(403).json({ error: 'Insufficient permissions' }); |
||||||
|
} |
||||||
|
|
||||||
|
next(); |
||||||
|
}; |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Middleware to require specific department access |
||||||
|
* For managers to access their department's data |
||||||
|
* @param {number} departmentId - Required department ID |
||||||
|
* @returns {Function} Express middleware |
||||||
|
*/ |
||||||
|
export function requireDepartment(departmentId) { |
||||||
|
return (req, res, next) => { |
||||||
|
if (!req.user) { |
||||||
|
return res.status(401).json({ error: 'Authentication required' }); |
||||||
|
} |
||||||
|
|
||||||
|
// Admins can access all departments
|
||||||
|
if (req.user.role === 'admin') { |
||||||
|
return next(); |
||||||
|
} |
||||||
|
|
||||||
|
// Managers can only access their department
|
||||||
|
if (req.user.role === 'manager' && req.user.departmentId !== departmentId) { |
||||||
|
return res.status(403).json({ error: 'Access denied to this department' }); |
||||||
|
} |
||||||
|
|
||||||
|
next(); |
||||||
|
}; |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Optional authentication middleware |
||||||
|
* Attaches user to request if token is valid, but doesn't require it |
||||||
|
*/ |
||||||
|
export async function optionalAuth(req, res, next) { |
||||||
|
try { |
||||||
|
const token = getTokenFromCookie(req); |
||||||
|
|
||||||
|
if (!token) { |
||||||
|
req.user = null; |
||||||
|
return next(); |
||||||
|
} |
||||||
|
|
||||||
|
const decoded = verifyToken(token); |
||||||
|
|
||||||
|
if (!decoded) { |
||||||
|
req.user = null; |
||||||
|
return next(); |
||||||
|
} |
||||||
|
|
||||||
|
const result = await query( |
||||||
|
'SELECT id, login, full_name, role, department_id, staff_id FROM users WHERE id = $1', |
||||||
|
[decoded.userId] |
||||||
|
); |
||||||
|
|
||||||
|
if (result.rows.length === 0) { |
||||||
|
req.user = null; |
||||||
|
return next(); |
||||||
|
} |
||||||
|
|
||||||
|
const user = result.rows[0]; |
||||||
|
const staffId = user.staff_id ?? decoded.staffId; |
||||||
|
|
||||||
|
req.user = { |
||||||
|
id: user.id, |
||||||
|
login: user.login, |
||||||
|
fullName: user.full_name, |
||||||
|
role: user.role, |
||||||
|
departmentId: user.department_id, |
||||||
|
}; |
||||||
|
if (staffId != null) { |
||||||
|
req.user.staffId = staffId; |
||||||
|
} |
||||||
|
|
||||||
|
next(); |
||||||
|
} catch (error) { |
||||||
|
// Don't block request on auth errors
|
||||||
|
req.user = null; |
||||||
|
next(); |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
export default { |
||||||
|
authenticate, |
||||||
|
requireRole, |
||||||
|
requireDepartment, |
||||||
|
optionalAuth, |
||||||
|
}; |
||||||
@ -0,0 +1,155 @@ |
|||||||
|
/** |
||||||
|
* A.1–A.4: локальный bcrypt (dev) и HR (HR_AUTH=1 + Werkzeug + staff_id) |
||||||
|
*/ |
||||||
|
import express from 'express'; |
||||||
|
import { query } from '../db/db.js'; |
||||||
|
import { comparePassword, generateToken } from '../utils/auth.js'; |
||||||
|
import { authenticate } from '../middleware/auth.js'; |
||||||
|
import { queryHr, getHrPool } from '../db/hrPool.js'; |
||||||
|
import { mapHrRoleToApp } from '../utils/hrRoleMap.js'; |
||||||
|
import { |
||||||
|
isHrAuthEnabled, |
||||||
|
HR_MANAGED_PASSWORD_PLACEHOLDER, |
||||||
|
} from '../config/authConstants.js'; |
||||||
|
|
||||||
|
const router = express.Router(); |
||||||
|
|
||||||
|
router.post('/login', async (req, res) => { |
||||||
|
try { |
||||||
|
const { login, password } = req.body; |
||||||
|
if (!login || !password) { |
||||||
|
return res.status(400).json({ error: 'Login and password are required' }); |
||||||
|
} |
||||||
|
|
||||||
|
if (isHrAuthEnabled()) { |
||||||
|
if (!getHrPool()) { |
||||||
|
return res.status(500).json({ error: 'HR_DATABASE_URL is not set' }); |
||||||
|
} |
||||||
|
const u = await queryHr( |
||||||
|
`SELECT id, username, password_hash, role
|
||||||
|
FROM users |
||||||
|
WHERE LOWER(TRIM(username)) = LOWER(TRIM($1))`,
|
||||||
|
[login] |
||||||
|
); |
||||||
|
if (u.rows.length === 0 || !u.rows[0].password_hash) { |
||||||
|
return res.status(401).json({ error: 'Invalid credentials' }); |
||||||
|
} |
||||||
|
const row = u.rows[0]; |
||||||
|
const ok = await comparePassword(password, row.password_hash); |
||||||
|
if (!ok) { |
||||||
|
return res.status(401).json({ error: 'Invalid credentials' }); |
||||||
|
} |
||||||
|
const s = await queryHr( |
||||||
|
`SELECT id, fio FROM staff_members
|
||||||
|
WHERE LOWER(TRIM(COALESCE(web_login, ''))) = LOWER(TRIM($1))`,
|
||||||
|
[login] |
||||||
|
); |
||||||
|
if (s.rows.length === 0) { |
||||||
|
return res |
||||||
|
.status(403) |
||||||
|
.json({ error: 'No staff link for this login (web_login)' }); |
||||||
|
} |
||||||
|
const staffId = s.rows[0].id; |
||||||
|
const fio = s.rows[0].fio || login; |
||||||
|
const appRole = mapHrRoleToApp(row.role); |
||||||
|
const up = await query( |
||||||
|
`INSERT INTO users (login, password_hash, full_name, role, department_id, is_active, staff_id)
|
||||||
|
VALUES ($1, $2, $3, $4, null, true, $5) |
||||||
|
ON CONFLICT (staff_id) DO UPDATE SET |
||||||
|
login = EXCLUDED.login, |
||||||
|
full_name = EXCLUDED.full_name, |
||||||
|
role = EXCLUDED.role, |
||||||
|
password_hash = EXCLUDED.password_hash |
||||||
|
RETURNING id, login, full_name, role, department_id, staff_id`,
|
||||||
|
[login, HR_MANAGED_PASSWORD_PLACEHOLDER, fio, appRole, staffId] |
||||||
|
); |
||||||
|
const uu = up.rows[0]; |
||||||
|
const token = generateToken( |
||||||
|
uu.id, |
||||||
|
uu.role, |
||||||
|
uu.department_id, |
||||||
|
{ staffId: uu.staff_id } |
||||||
|
); |
||||||
|
res.cookie('token', token, { |
||||||
|
httpOnly: true, |
||||||
|
secure: process.env.NODE_ENV === 'production', |
||||||
|
sameSite: 'strict', |
||||||
|
maxAge: 7 * 24 * 60 * 60 * 1000, |
||||||
|
}); |
||||||
|
return res.json({ |
||||||
|
user: { |
||||||
|
id: uu.id, |
||||||
|
login: uu.login, |
||||||
|
fullName: uu.full_name, |
||||||
|
role: uu.role, |
||||||
|
departmentId: uu.department_id, |
||||||
|
staffId: uu.staff_id, |
||||||
|
}, |
||||||
|
}); |
||||||
|
} |
||||||
|
|
||||||
|
const result = await query( |
||||||
|
'SELECT id, login, password_hash, full_name, role, department_id FROM users WHERE login = $1 AND is_active = true', |
||||||
|
[login] |
||||||
|
); |
||||||
|
if (result.rows.length === 0) { |
||||||
|
return res.status(401).json({ error: 'Invalid credentials' }); |
||||||
|
} |
||||||
|
const user = result.rows[0]; |
||||||
|
if (user.password_hash === HR_MANAGED_PASSWORD_PLACEHOLDER) { |
||||||
|
return res.status(401).json({ error: 'Use HR login' }); |
||||||
|
} |
||||||
|
const isValidPassword = await comparePassword(password, user.password_hash); |
||||||
|
if (!isValidPassword) { |
||||||
|
return res.status(401).json({ error: 'Invalid credentials' }); |
||||||
|
} |
||||||
|
const token = generateToken(user.id, user.role, user.department_id); |
||||||
|
res.cookie('token', token, { |
||||||
|
httpOnly: true, |
||||||
|
secure: process.env.NODE_ENV === 'production', |
||||||
|
sameSite: 'strict', |
||||||
|
maxAge: 7 * 24 * 60 * 60 * 1000, |
||||||
|
}); |
||||||
|
return res.json({ |
||||||
|
user: { |
||||||
|
id: user.id, |
||||||
|
login: user.login, |
||||||
|
fullName: user.full_name, |
||||||
|
role: user.role, |
||||||
|
departmentId: user.department_id, |
||||||
|
staffId: null, |
||||||
|
}, |
||||||
|
}); |
||||||
|
} catch (error) { |
||||||
|
if (error.message?.includes('HR database not configured')) { |
||||||
|
return res.status(500).json({ error: 'HR database not configured' }); |
||||||
|
} |
||||||
|
console.error('Login error:', error); |
||||||
|
return res.status(500).json({ error: 'Login failed' }); |
||||||
|
} |
||||||
|
}); |
||||||
|
|
||||||
|
router.post('/logout', (req, res) => { |
||||||
|
try { |
||||||
|
res.clearCookie('token', { |
||||||
|
httpOnly: true, |
||||||
|
secure: process.env.NODE_ENV === 'production', |
||||||
|
sameSite: 'strict', |
||||||
|
}); |
||||||
|
res.json({ message: 'Logged out successfully' }); |
||||||
|
} catch (error) { |
||||||
|
console.error('Logout error:', error); |
||||||
|
res.status(500).json({ error: 'Logout failed' }); |
||||||
|
} |
||||||
|
}); |
||||||
|
|
||||||
|
router.get('/me', authenticate, async (req, res) => { |
||||||
|
try { |
||||||
|
res.json({ user: req.user }); |
||||||
|
} catch (error) { |
||||||
|
console.error('Get current user error:', error); |
||||||
|
res.status(500).json({ error: 'Failed to get user data' }); |
||||||
|
} |
||||||
|
}); |
||||||
|
|
||||||
|
export default router; |
||||||
@ -0,0 +1,243 @@ |
|||||||
|
/** |
||||||
|
* V.4–V.6, D.1 — API тестов, версий, импорт файла |
||||||
|
*/ |
||||||
|
import express from 'express'; |
||||||
|
import fs from 'fs/promises'; |
||||||
|
import os from 'os'; |
||||||
|
import multer from 'multer'; |
||||||
|
import pool, { query } from '../db/db.js'; |
||||||
|
import { authenticate } from '../middleware/auth.js'; |
||||||
|
import { hasAnyAttemptForTest } from '../services/testChainService.js'; |
||||||
|
import { saveTestDraft, createTestWithVersion } from '../services/testDraftService.js'; |
||||||
|
|
||||||
|
const router = express.Router(); |
||||||
|
const upload = multer({ |
||||||
|
dest: os.tmpdir(), |
||||||
|
limits: { fileSize: 10 * 1024 * 1024 }, |
||||||
|
}); |
||||||
|
|
||||||
|
function asyncHandler(fn) { |
||||||
|
return (req, res, next) => { |
||||||
|
Promise.resolve(fn(req, res, next)).catch((err) => { |
||||||
|
if (err.status) { |
||||||
|
res.status(err.status).json({ error: err.message }); |
||||||
|
} else { |
||||||
|
next(err); |
||||||
|
} |
||||||
|
}); |
||||||
|
}; |
||||||
|
} |
||||||
|
|
||||||
|
/** D.1 — раньше /:id, иначе id = "import" */ |
||||||
|
router.post( |
||||||
|
'/import/document', |
||||||
|
authenticate, |
||||||
|
upload.single('file'), |
||||||
|
asyncHandler(async (req, res) => { |
||||||
|
if (!req.file) { |
||||||
|
return res.status(400).json({ error: 'file field required' }); |
||||||
|
} |
||||||
|
const p = req.file.path; |
||||||
|
let size = 0; |
||||||
|
try { |
||||||
|
const st = await fs.stat(p); |
||||||
|
size = st.size; |
||||||
|
await fs.unlink(p); |
||||||
|
} catch { |
||||||
|
try { |
||||||
|
await fs.unlink(p); |
||||||
|
} catch { |
||||||
|
// ignore
|
||||||
|
} |
||||||
|
return res.status(500).json({ error: 'upload failed' }); |
||||||
|
} |
||||||
|
res.json({ |
||||||
|
received: true, |
||||||
|
originalName: req.file.originalname, |
||||||
|
size, |
||||||
|
message: |
||||||
|
'Файл принят; извлечение текста и генерация (D.2–D.3) — в следующем шаге', |
||||||
|
}); |
||||||
|
}) |
||||||
|
); |
||||||
|
|
||||||
|
router.get( |
||||||
|
'/', |
||||||
|
authenticate, |
||||||
|
asyncHandler(async (req, res) => { |
||||||
|
const { rows } = await query( |
||||||
|
`SELECT t.id, t.title, t.description, t.is_active AS chain_active,
|
||||||
|
t.created_at, t.updated_at, tv.id AS active_version_id, tv.version |
||||||
|
FROM tests t |
||||||
|
INNER JOIN test_versions tv ON tv.test_id = t.id AND tv.is_active = true |
||||||
|
WHERE t.is_active = true |
||||||
|
ORDER BY t.updated_at DESC NULLS LAST, t.created_at DESC` |
||||||
|
); |
||||||
|
res.json({ tests: rows }); |
||||||
|
}) |
||||||
|
); |
||||||
|
|
||||||
|
router.post( |
||||||
|
'/', |
||||||
|
authenticate, |
||||||
|
asyncHandler(async (req, res) => { |
||||||
|
const { title, description } = req.body; |
||||||
|
if (!title || typeof title !== 'string') { |
||||||
|
return res.status(400).json({ error: 'title required' }); |
||||||
|
} |
||||||
|
const out = await createTestWithVersion(pool, req.user.id, { |
||||||
|
title, |
||||||
|
description, |
||||||
|
}); |
||||||
|
res.status(201).json(out); |
||||||
|
}) |
||||||
|
); |
||||||
|
|
||||||
|
router.get( |
||||||
|
'/:id/versions', |
||||||
|
authenticate, |
||||||
|
asyncHandler(async (req, res) => { |
||||||
|
const testId = req.params.id; |
||||||
|
const { rows: t } = await query(`SELECT id, created_by FROM tests WHERE id = $1`, [ |
||||||
|
testId, |
||||||
|
]); |
||||||
|
if (!t.length) { |
||||||
|
return res.status(404).json({ error: 'Test not found' }); |
||||||
|
} |
||||||
|
if (t[0].created_by !== req.user.id) { |
||||||
|
return res.status(403).json({ error: 'Forbidden' }); |
||||||
|
} |
||||||
|
const { rows } = await query( |
||||||
|
`SELECT id, version, is_active, parent_id, created_at
|
||||||
|
FROM test_versions WHERE test_id = $1 ORDER BY version`,
|
||||||
|
[testId] |
||||||
|
); |
||||||
|
const hasAttempts = await hasAnyAttemptForTest(pool, testId); |
||||||
|
res.json({ versions: rows, hasAttempts }); |
||||||
|
}) |
||||||
|
); |
||||||
|
|
||||||
|
router.post( |
||||||
|
'/:id/versions/:vid/activate', |
||||||
|
authenticate, |
||||||
|
asyncHandler(async (req, res) => { |
||||||
|
const testId = req.params.id; |
||||||
|
const versionId = req.params.vid; |
||||||
|
const { rows: t } = await query( |
||||||
|
`SELECT id, created_by FROM tests WHERE id = $1`, |
||||||
|
[testId] |
||||||
|
); |
||||||
|
if (!t.length) { |
||||||
|
return res.status(404).json({ error: 'Test not found' }); |
||||||
|
} |
||||||
|
if (t[0].created_by !== req.user.id) { |
||||||
|
return res.status(403).json({ error: 'Forbidden' }); |
||||||
|
} |
||||||
|
const { rows: v } = await query( |
||||||
|
`SELECT id FROM test_versions WHERE test_id = $1 AND id = $2`, |
||||||
|
[testId, versionId] |
||||||
|
); |
||||||
|
if (!v.length) { |
||||||
|
return res.status(404).json({ error: 'Version not found' }); |
||||||
|
} |
||||||
|
const client = await pool.connect(); |
||||||
|
try { |
||||||
|
await client.query('BEGIN'); |
||||||
|
await client.query( |
||||||
|
`UPDATE test_versions SET is_active = false WHERE test_id = $1`, |
||||||
|
[testId] |
||||||
|
); |
||||||
|
await client.query( |
||||||
|
`UPDATE test_versions SET is_active = true WHERE id = $1`, |
||||||
|
[versionId] |
||||||
|
); |
||||||
|
await client.query('COMMIT'); |
||||||
|
} catch (e) { |
||||||
|
await client.query('ROLLBACK'); |
||||||
|
throw e; |
||||||
|
} finally { |
||||||
|
client.release(); |
||||||
|
} |
||||||
|
res.json({ ok: true, activeVersionId: versionId }); |
||||||
|
}) |
||||||
|
); |
||||||
|
|
||||||
|
router.patch( |
||||||
|
'/:id', |
||||||
|
authenticate, |
||||||
|
asyncHandler(async (req, res) => { |
||||||
|
const testId = req.params.id; |
||||||
|
const { isActive, chainActive } = req.body; |
||||||
|
const chain = chainActive ?? isActive; |
||||||
|
if (typeof chain !== 'boolean') { |
||||||
|
return res.status(400).json({ error: 'chainActive (boolean) required' }); |
||||||
|
} |
||||||
|
const { rows: t } = await query( |
||||||
|
`SELECT id, created_by FROM tests WHERE id = $1`, |
||||||
|
[testId] |
||||||
|
); |
||||||
|
if (!t.length) { |
||||||
|
return res.status(404).json({ error: 'Test not found' }); |
||||||
|
} |
||||||
|
if (t[0].created_by !== req.user.id) { |
||||||
|
return res.status(403).json({ error: 'Forbidden' }); |
||||||
|
} |
||||||
|
await query( |
||||||
|
`UPDATE tests SET is_active = $2, updated_at = CURRENT_TIMESTAMP WHERE id = $1`, |
||||||
|
[testId, chain] |
||||||
|
); |
||||||
|
res.json({ id: testId, chainActive: chain }); |
||||||
|
}) |
||||||
|
); |
||||||
|
|
||||||
|
router.post( |
||||||
|
'/:id/draft', |
||||||
|
authenticate, |
||||||
|
asyncHandler(async (req, res) => { |
||||||
|
const out = await saveTestDraft(pool, req.user.id, req.params.id, req.body); |
||||||
|
res.json(out); |
||||||
|
}) |
||||||
|
); |
||||||
|
|
||||||
|
router.post( |
||||||
|
'/:id/attempts/start', |
||||||
|
authenticate, |
||||||
|
asyncHandler(async (req, res) => { |
||||||
|
const testId = req.params.id; |
||||||
|
const { rows: tv } = await query( |
||||||
|
`SELECT tv.id AS test_version_id
|
||||||
|
FROM test_versions tv |
||||||
|
WHERE tv.test_id = $1 AND tv.is_active = true LIMIT 1`,
|
||||||
|
[testId] |
||||||
|
); |
||||||
|
if (!tv.length) { |
||||||
|
return res.status(404).json({ error: 'No active version' }); |
||||||
|
} |
||||||
|
const testVersionId = tv[0].test_version_id; |
||||||
|
const { rows: mx } = await query( |
||||||
|
`SELECT COALESCE(MAX(attempt_number), 0) AS n FROM test_attempts
|
||||||
|
WHERE test_version_id = $1 AND user_id = $2`,
|
||||||
|
[testVersionId, req.user.id] |
||||||
|
); |
||||||
|
const nextN = (mx[0].n || 0) + 1; |
||||||
|
const { rows: a } = await query( |
||||||
|
`INSERT INTO test_attempts (test_version_id, user_id, attempt_number, status)
|
||||||
|
VALUES ($1, $2, $3, 'in_progress') |
||||||
|
RETURNING id, test_version_id, user_id, attempt_number, status, started_at`,
|
||||||
|
[testVersionId, req.user.id, nextN] |
||||||
|
); |
||||||
|
res.status(201).json({ attempt: a[0] }); |
||||||
|
}) |
||||||
|
); |
||||||
|
|
||||||
|
router.get( |
||||||
|
'/:id/chain-info', |
||||||
|
authenticate, |
||||||
|
asyncHandler(async (req, res) => { |
||||||
|
const testId = req.params.id; |
||||||
|
const has = await hasAnyAttemptForTest(pool, testId); |
||||||
|
res.json({ testId, hasAnyAttempt: has }); |
||||||
|
}) |
||||||
|
); |
||||||
|
|
||||||
|
export default router; |
||||||
@ -0,0 +1,8 @@ |
|||||||
|
import { createApp } from './app.js'; |
||||||
|
|
||||||
|
const app = createApp(); |
||||||
|
const PORT = process.env.PORT || 3001; |
||||||
|
app.listen(PORT, () => { |
||||||
|
console.log(`Server is running on port ${PORT}`); |
||||||
|
console.log(`Environment: ${process.env.NODE_ENV || 'development'}`); |
||||||
|
}); |
||||||
@ -0,0 +1,204 @@ |
|||||||
|
/** |
||||||
|
* V.3 saveTestDraft, fork версии, контент вопросов. |
||||||
|
*/ |
||||||
|
import { hasAnyAttemptForTest } from './testChainService.js'; |
||||||
|
|
||||||
|
/** |
||||||
|
* @param {import('pg').PoolClient} client |
||||||
|
* @param {string} testId |
||||||
|
*/ |
||||||
|
export async function getActiveVersionRow(client, testId) { |
||||||
|
const { rows } = await client.query( |
||||||
|
`SELECT * FROM test_versions WHERE test_id = $1 AND is_active = true LIMIT 1`, |
||||||
|
[testId] |
||||||
|
); |
||||||
|
return rows[0] || null; |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* @param {import('pg').PoolClient} client |
||||||
|
* @param {string} fromVersionId |
||||||
|
* @param {string} toVersionId |
||||||
|
*/ |
||||||
|
export async function copyQuestionTree(client, fromVersionId, toVersionId) { |
||||||
|
const { rows: questions } = await client.query( |
||||||
|
`SELECT * FROM questions WHERE test_version_id = $1 ORDER BY question_order`, |
||||||
|
[fromVersionId] |
||||||
|
); |
||||||
|
for (const q of questions) { |
||||||
|
const { rows: insQ } = await client.query( |
||||||
|
`INSERT INTO questions (test_version_id, text, question_order, has_multiple_answers)
|
||||||
|
VALUES ($1, $2, $3, $4) RETURNING id`,
|
||||||
|
[toVersionId, q.text, q.question_order, q.has_multiple_answers] |
||||||
|
); |
||||||
|
const nqid = insQ[0].id; |
||||||
|
const { rows: options } = await client.query( |
||||||
|
`SELECT * FROM answer_options WHERE question_id = $1 ORDER BY option_order`, |
||||||
|
[q.id] |
||||||
|
); |
||||||
|
for (const o of options) { |
||||||
|
await client.query( |
||||||
|
`INSERT INTO answer_options (question_id, text, is_correct, option_order)
|
||||||
|
VALUES ($1, $2, $3, $4)`,
|
||||||
|
[nqid, o.text, o.is_correct, o.option_order] |
||||||
|
); |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* @param {import('pg').PoolClient} client |
||||||
|
* @param {string} testVersionId |
||||||
|
* @param {{ questions?: Array<{ text: string, question_order?: number, hasMultipleAnswers?: boolean, options?: Array<{ text: string, isCorrect?: boolean, option_order?: number }> }> }} payload |
||||||
|
*/ |
||||||
|
export async function replaceVersionContent(client, testVersionId, payload) { |
||||||
|
await client.query( |
||||||
|
`DELETE FROM answer_options WHERE question_id IN
|
||||||
|
(SELECT id FROM questions WHERE test_version_id = $1)`,
|
||||||
|
[testVersionId] |
||||||
|
); |
||||||
|
await client.query(`DELETE FROM questions WHERE test_version_id = $1`, [ |
||||||
|
testVersionId, |
||||||
|
]); |
||||||
|
const questions = payload.questions || []; |
||||||
|
for (let i = 0; i < questions.length; i++) { |
||||||
|
const q = questions[i]; |
||||||
|
const { rows: insQ } = await client.query( |
||||||
|
`INSERT INTO questions (test_version_id, text, question_order, has_multiple_answers)
|
||||||
|
VALUES ($1, $2, $3, $4) RETURNING id`,
|
||||||
|
[ |
||||||
|
testVersionId, |
||||||
|
q.text, |
||||||
|
q.question_order ?? i + 1, |
||||||
|
q.hasMultipleAnswers || false, |
||||||
|
] |
||||||
|
); |
||||||
|
const qid = insQ[0].id; |
||||||
|
const opts = q.options || []; |
||||||
|
for (let j = 0; j < opts.length; j++) { |
||||||
|
const o = opts[j]; |
||||||
|
await client.query( |
||||||
|
`INSERT INTO answer_options (question_id, text, is_correct, option_order)
|
||||||
|
VALUES ($1, $2, $3, $4)`,
|
||||||
|
[qid, o.text, !!o.isCorrect, o.option_order ?? j + 1] |
||||||
|
); |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* @param {import('pg').PoolClient} client |
||||||
|
* @param {string} testId |
||||||
|
*/ |
||||||
|
export async function forkNewVersion(client, testId) { |
||||||
|
const av = await getActiveVersionRow(client, testId); |
||||||
|
if (!av) { |
||||||
|
throw new Error('no active version'); |
||||||
|
} |
||||||
|
const { rows: mx } = await client.query( |
||||||
|
`SELECT COALESCE(MAX(version), 0) AS v FROM test_versions WHERE test_id = $1`, |
||||||
|
[testId] |
||||||
|
); |
||||||
|
const nextV = (mx[0].v || 0) + 1; |
||||||
|
const { rows: nv } = await client.query( |
||||||
|
`INSERT INTO test_versions (test_id, version, is_active, parent_id)
|
||||||
|
VALUES ($1, $2, true, $3) RETURNING *`,
|
||||||
|
[testId, nextV, av.id] |
||||||
|
); |
||||||
|
const newRow = nv[0]; |
||||||
|
await client.query( |
||||||
|
`UPDATE test_versions SET is_active = false WHERE test_id = $1 AND id <> $2`, |
||||||
|
[testId, newRow.id] |
||||||
|
); |
||||||
|
await copyQuestionTree(client, av.id, newRow.id); |
||||||
|
return newRow; |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* @param {import('pg').Pool} pool |
||||||
|
* @param {string} authorId |
||||||
|
* @param {string} testId |
||||||
|
* @param {{ title?: string, description?: string, questions?: Array<unknown> }} payload |
||||||
|
*/ |
||||||
|
export async function saveTestDraft(pool, authorId, testId, payload) { |
||||||
|
const { rows: tr } = await pool.query( |
||||||
|
`SELECT id, created_by FROM tests WHERE id = $1`, |
||||||
|
[testId] |
||||||
|
); |
||||||
|
if (!tr.length) { |
||||||
|
const e = new Error('Test not found'); |
||||||
|
e.status = 404; |
||||||
|
throw e; |
||||||
|
} |
||||||
|
const t = tr[0]; |
||||||
|
if (t.created_by !== authorId) { |
||||||
|
const e = new Error('Forbidden'); |
||||||
|
e.status = 403; |
||||||
|
throw e; |
||||||
|
} |
||||||
|
|
||||||
|
const client = await pool.connect(); |
||||||
|
let forked = false; |
||||||
|
try { |
||||||
|
await client.query('BEGIN'); |
||||||
|
if (payload.title != null || payload.description != null) { |
||||||
|
await client.query( |
||||||
|
`UPDATE tests SET title = COALESCE($2, title), description = COALESCE($3, description),
|
||||||
|
updated_at = CURRENT_TIMESTAMP WHERE id = $1`,
|
||||||
|
[testId, payload.title ?? null, payload.description ?? null] |
||||||
|
); |
||||||
|
} |
||||||
|
const hasAttempts = await hasAnyAttemptForTest(client, testId); |
||||||
|
let versionRow = await getActiveVersionRow(client, testId); |
||||||
|
if (!versionRow) { |
||||||
|
const e = new Error('No active version'); |
||||||
|
e.status = 500; |
||||||
|
throw e; |
||||||
|
} |
||||||
|
if (hasAttempts && payload.questions !== undefined) { |
||||||
|
versionRow = await forkNewVersion(client, testId); |
||||||
|
forked = true; |
||||||
|
} |
||||||
|
if (payload.questions) { |
||||||
|
await replaceVersionContent(client, versionRow.id, payload); |
||||||
|
} |
||||||
|
await client.query('COMMIT'); |
||||||
|
return { testId, versionId: versionRow.id, forked }; |
||||||
|
} catch (e) { |
||||||
|
await client.query('ROLLBACK'); |
||||||
|
throw e; |
||||||
|
} finally { |
||||||
|
client.release(); |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Создать пустой тест (цепочка) с одной версией 1. |
||||||
|
* @param {import('pg').Pool} pool |
||||||
|
* @param {string} authorId |
||||||
|
* @param {{ title: string, description?: string }} meta |
||||||
|
*/ |
||||||
|
export async function createTestWithVersion(pool, authorId, meta) { |
||||||
|
const client = await pool.connect(); |
||||||
|
try { |
||||||
|
await client.query('BEGIN'); |
||||||
|
const { rows: t } = await client.query( |
||||||
|
`INSERT INTO tests (title, description, created_by, is_active, is_versioned)
|
||||||
|
VALUES ($1, $2, $3, true, true) RETURNING id`,
|
||||||
|
[meta.title, meta.description || null, authorId] |
||||||
|
); |
||||||
|
const testId = t[0].id; |
||||||
|
const { rows: v } = await client.query( |
||||||
|
`INSERT INTO test_versions (test_id, version, is_active, parent_id)
|
||||||
|
VALUES ($1, 1, true, NULL) RETURNING id`,
|
||||||
|
[testId] |
||||||
|
); |
||||||
|
await client.query('COMMIT'); |
||||||
|
return { testId, versionId: v[0].id }; |
||||||
|
} catch (e) { |
||||||
|
await client.query('ROLLBACK'); |
||||||
|
throw e; |
||||||
|
} finally { |
||||||
|
client.release(); |
||||||
|
} |
||||||
|
} |
||||||
@ -0,0 +1,94 @@ |
|||||||
|
/** |
||||||
|
* Authentication Utilities |
||||||
|
* Password hashing and JWT token management |
||||||
|
*/ |
||||||
|
|
||||||
|
import bcrypt from 'bcrypt'; |
||||||
|
import jwt from 'jsonwebtoken'; |
||||||
|
import dotenv from 'dotenv'; |
||||||
|
import { checkWerkzeugPassword } from './werkzeugPassword.js'; |
||||||
|
import { HR_MANAGED_PASSWORD_PLACEHOLDER } from '../config/authConstants.js'; |
||||||
|
|
||||||
|
dotenv.config(); |
||||||
|
|
||||||
|
const JWT_SECRET = process.env.JWT_SECRET; |
||||||
|
const JWT_EXPIRES_IN = process.env.JWT_EXPIRES_IN || '7d'; |
||||||
|
|
||||||
|
// Salt rounds for bcrypt
|
||||||
|
const SALT_ROUNDS = 10; |
||||||
|
|
||||||
|
/** |
||||||
|
* Hash a password using bcrypt |
||||||
|
* @param {string} password - Plain text password |
||||||
|
* @returns {Promise<string>} Hashed password |
||||||
|
*/ |
||||||
|
export async function hashPassword(password) { |
||||||
|
return bcrypt.hash(password, SALT_ROUNDS); |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Compare a plain text password with a hashed password |
||||||
|
* @param {string} password - Plain text password |
||||||
|
* @param {string} hash - Hashed password |
||||||
|
* @returns {Promise<boolean>} True if passwords match |
||||||
|
*/ |
||||||
|
export async function comparePassword(password, hash) { |
||||||
|
if (!hash) { |
||||||
|
return false; |
||||||
|
} |
||||||
|
if (hash === HR_MANAGED_PASSWORD_PLACEHOLDER) { |
||||||
|
return false; |
||||||
|
} |
||||||
|
if (hash.startsWith('scrypt:') || hash.startsWith('pbkdf2:')) { |
||||||
|
return checkWerkzeugPassword(hash, password); |
||||||
|
} |
||||||
|
if (hash.startsWith('$2')) { |
||||||
|
return bcrypt.compare(password, hash); |
||||||
|
} |
||||||
|
return checkWerkzeugPassword(hash, password); |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* @param {string} userId - clinic_tests.users.id |
||||||
|
* @param {string} role |
||||||
|
* @param {string|null|undefined} departmentId |
||||||
|
* @param {{ staffId?: number } | null} [meta] |
||||||
|
* @returns {string} |
||||||
|
*/ |
||||||
|
export function generateToken(userId, role, departmentId = null, meta = null) { |
||||||
|
const payload = { |
||||||
|
userId, |
||||||
|
role, |
||||||
|
}; |
||||||
|
|
||||||
|
if (departmentId !== null && departmentId !== undefined) { |
||||||
|
payload.departmentId = departmentId; |
||||||
|
} |
||||||
|
if (meta?.staffId != null) { |
||||||
|
payload.staffId = meta.staffId; |
||||||
|
} |
||||||
|
|
||||||
|
return jwt.sign(payload, JWT_SECRET, { |
||||||
|
expiresIn: JWT_EXPIRES_IN, |
||||||
|
}); |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Verify and decode a JWT token |
||||||
|
* @param {string} token - JWT token |
||||||
|
* @returns {Object|null} Decoded token payload or null if invalid |
||||||
|
*/ |
||||||
|
export function verifyToken(token) { |
||||||
|
try { |
||||||
|
return jwt.verify(token, JWT_SECRET); |
||||||
|
} catch (error) { |
||||||
|
return null; |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
export default { |
||||||
|
hashPassword, |
||||||
|
comparePassword, |
||||||
|
generateToken, |
||||||
|
verifyToken, |
||||||
|
}; |
||||||
@ -0,0 +1,21 @@ |
|||||||
|
/** |
||||||
|
* Сопоставление role из hr_bot_test.users (varchar) с ролью в модуле тестов. |
||||||
|
* A.5 MVP — уточняется при подключении staff_role_assignments. |
||||||
|
* @param {string|null|undefined} hrRole |
||||||
|
* @returns {'hr' | 'manager' | 'employee'} |
||||||
|
*/ |
||||||
|
export function mapHrRoleToApp(hrRole) { |
||||||
|
const r = String(hrRole || '') |
||||||
|
.toLowerCase() |
||||||
|
.trim(); |
||||||
|
if (!r) { |
||||||
|
return 'employee'; |
||||||
|
} |
||||||
|
if (r === 'admin' || r.includes('hr') || r.includes('дире')) { |
||||||
|
return 'hr'; |
||||||
|
} |
||||||
|
if (r.includes('manager') || r.includes('рук') || r.includes('завед')) { |
||||||
|
return 'manager'; |
||||||
|
} |
||||||
|
return 'employee'; |
||||||
|
} |
||||||
@ -0,0 +1,74 @@ |
|||||||
|
/** |
||||||
|
* Проверка хеша в формате Werkzeug 3 (scrypt:… / pbkdf2:…). |
||||||
|
* @see https://github.com/pallets/werkzeug/blob/main/src/werkzeug/security.py
|
||||||
|
*/ |
||||||
|
import crypto from 'crypto'; |
||||||
|
|
||||||
|
/** |
||||||
|
* @param {string} pwhash |
||||||
|
* @param {string} password |
||||||
|
* @returns {boolean} |
||||||
|
*/ |
||||||
|
function hashInternal(method, salt, password) { |
||||||
|
const methodParts = method.split(':'); |
||||||
|
const kind = methodParts[0]; |
||||||
|
const saltBytes = Buffer.from(salt, 'utf8'); |
||||||
|
const passwordBytes = Buffer.from(password, 'utf8'); |
||||||
|
|
||||||
|
if (kind === 'scrypt') { |
||||||
|
const n = methodParts[1] ? parseInt(methodParts[1], 10) : 2 ** 15; |
||||||
|
const r = methodParts[2] ? parseInt(methodParts[2], 10) : 8; |
||||||
|
const p = methodParts[3] ? parseInt(methodParts[3], 10) : 1; |
||||||
|
const maxmem = 132 * n * r * p; |
||||||
|
return crypto |
||||||
|
.scryptSync(passwordBytes, saltBytes, 64, { N: n, r, p, maxmem }) |
||||||
|
.toString('hex'); |
||||||
|
} |
||||||
|
|
||||||
|
if (kind === 'pbkdf2') { |
||||||
|
const hashName = methodParts[1] || 'sha256'; |
||||||
|
const iterStr = methodParts[2]; |
||||||
|
if (!iterStr) { |
||||||
|
throw new Error('pbkdf2: missing iterations'); |
||||||
|
} |
||||||
|
const iterations = parseInt(iterStr, 10); |
||||||
|
return crypto |
||||||
|
.pbkdf2Sync(passwordBytes, saltBytes, iterations, 32, hashName) |
||||||
|
.toString('hex'); |
||||||
|
} |
||||||
|
|
||||||
|
throw new Error(`Invalid hash method: ${kind}`); |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* @param {string} pwhash |
||||||
|
* @param {string} password |
||||||
|
* @returns {boolean} |
||||||
|
*/ |
||||||
|
export function checkWerkzeugPassword(pwhash, password) { |
||||||
|
if (!pwhash || pwhash.length < 3) { |
||||||
|
return false; |
||||||
|
} |
||||||
|
const parts = pwhash.split('$'); |
||||||
|
if (parts.length < 3) { |
||||||
|
return false; |
||||||
|
} |
||||||
|
const hashval = parts.pop(); |
||||||
|
const salt = parts.pop(); |
||||||
|
const method = parts.join('$'); |
||||||
|
if (!method || !salt || !hashval) { |
||||||
|
return false; |
||||||
|
} |
||||||
|
let computed; |
||||||
|
try { |
||||||
|
computed = hashInternal(method, salt, password); |
||||||
|
} catch { |
||||||
|
return false; |
||||||
|
} |
||||||
|
const a = Buffer.from(computed, 'hex'); |
||||||
|
const b = Buffer.from(hashval, 'hex'); |
||||||
|
if (a.length !== b.length) { |
||||||
|
return false; |
||||||
|
} |
||||||
|
return crypto.timingSafeEqual(a, b); |
||||||
|
} |
||||||
@ -0,0 +1,31 @@ |
|||||||
|
import { test } from 'node:test'; |
||||||
|
import assert from 'node:assert/strict'; |
||||||
|
import crypto from 'crypto'; |
||||||
|
import { checkWerkzeugPassword } from './werkzeugPassword.js'; |
||||||
|
|
||||||
|
test('pbkdf2:sha256 self-consistency', () => { |
||||||
|
const salt = 'AbCdEfGhIjKlMnOp'; |
||||||
|
const iterations = 1000; |
||||||
|
const password = 'secret'; |
||||||
|
const hashval = crypto |
||||||
|
.pbkdf2Sync(password, salt, iterations, 32, 'sha256') |
||||||
|
.toString('hex'); |
||||||
|
const pwhash = `pbkdf2:sha256:${iterations}$${salt}$${hashval}`; |
||||||
|
assert.equal(checkWerkzeugPassword(pwhash, 'secret'), true); |
||||||
|
assert.equal(checkWerkzeugPassword(pwhash, 'wrong'), false); |
||||||
|
}); |
||||||
|
|
||||||
|
test('scrypt self-consistency', () => { |
||||||
|
const salt = 'AbCdEfGhIjKlMnOp'; |
||||||
|
const n = 32768; |
||||||
|
const r = 8; |
||||||
|
const p = 1; |
||||||
|
const maxmem = 132 * n * r * p; |
||||||
|
const method = `scrypt:${n}:${r}:${p}`; |
||||||
|
const password = 'x'; |
||||||
|
const h = crypto |
||||||
|
.scryptSync(password, salt, 64, { N: n, r, p, maxmem }) |
||||||
|
.toString('hex'); |
||||||
|
const pwhash = `${method}$${salt}$${h}`; |
||||||
|
assert.equal(checkWerkzeugPassword(pwhash, 'x'), true); |
||||||
|
}); |
||||||
@ -0,0 +1,29 @@ |
|||||||
|
{ |
||||||
|
"env": { |
||||||
|
"browser": true, |
||||||
|
"es2022": true |
||||||
|
}, |
||||||
|
"parserOptions": { |
||||||
|
"ecmaVersion": "latest", |
||||||
|
"sourceType": "module", |
||||||
|
"ecmaFeatures": { |
||||||
|
"jsx": true |
||||||
|
} |
||||||
|
}, |
||||||
|
"extends": [ |
||||||
|
"eslint:recommended", |
||||||
|
"plugin:react/recommended", |
||||||
|
"plugin:react-hooks/recommended" |
||||||
|
], |
||||||
|
"plugins": ["react", "react-hooks"], |
||||||
|
"settings": { |
||||||
|
"react": { |
||||||
|
"version": "detect" |
||||||
|
} |
||||||
|
}, |
||||||
|
"rules": { |
||||||
|
"no-unused-vars": ["error", { "argsIgnorePattern": "^_" }], |
||||||
|
"react/react-in-jsx-scope": "off", |
||||||
|
"react/prop-types": "warn" |
||||||
|
} |
||||||
|
} |
||||||
@ -0,0 +1,7 @@ |
|||||||
|
{ |
||||||
|
"semi": true, |
||||||
|
"singleQuote": true, |
||||||
|
"tabWidth": 2, |
||||||
|
"trailingComma": "es5", |
||||||
|
"printWidth": 100 |
||||||
|
} |
||||||
@ -0,0 +1,13 @@ |
|||||||
|
<!DOCTYPE html> |
||||||
|
<html lang="ru"> |
||||||
|
<head> |
||||||
|
<meta charset="UTF-8" /> |
||||||
|
<link rel="icon" type="image/svg+xml" href="/vite.svg" /> |
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> |
||||||
|
<title>Клинические Тесты</title> |
||||||
|
</head> |
||||||
|
<body> |
||||||
|
<div id="root"></div> |
||||||
|
<script type="module" src="/src/main.jsx"></script> |
||||||
|
</body> |
||||||
|
</html> |
||||||
File diff suppressed because it is too large
Load Diff
@ -0,0 +1,29 @@ |
|||||||
|
{ |
||||||
|
"name": "clinic-tests-frontend", |
||||||
|
"private": true, |
||||||
|
"version": "1.0.0", |
||||||
|
"type": "module", |
||||||
|
"scripts": { |
||||||
|
"dev": "vite", |
||||||
|
"build": "vite build", |
||||||
|
"preview": "vite preview", |
||||||
|
"lint": "eslint src/", |
||||||
|
"lint:fix": "eslint src/ --fix", |
||||||
|
"format": "prettier --write src/" |
||||||
|
}, |
||||||
|
"dependencies": { |
||||||
|
"react": "^18.3.1", |
||||||
|
"react-dom": "^18.3.1", |
||||||
|
"react-router-dom": "^6.26.0" |
||||||
|
}, |
||||||
|
"devDependencies": { |
||||||
|
"@types/react": "^18.3.3", |
||||||
|
"@types/react-dom": "^18.3.0", |
||||||
|
"@vitejs/plugin-react": "^4.3.1", |
||||||
|
"eslint": "^8.57.0", |
||||||
|
"eslint-plugin-react": "^7.34.4", |
||||||
|
"eslint-plugin-react-hooks": "^4.6.2", |
||||||
|
"prettier": "^3.3.3", |
||||||
|
"vite": "^5.4.0" |
||||||
|
} |
||||||
|
} |
||||||
@ -0,0 +1,19 @@ |
|||||||
|
import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom'; |
||||||
|
import Login from './pages/Login'; |
||||||
|
import TestsList from './pages/TestsList'; |
||||||
|
import TestDetail from './pages/TestDetail'; |
||||||
|
|
||||||
|
function App() { |
||||||
|
return ( |
||||||
|
<BrowserRouter> |
||||||
|
<Routes> |
||||||
|
<Route path="/login" element={<Login />} /> |
||||||
|
<Route path="/tests" element={<TestsList />} /> |
||||||
|
<Route path="/tests/:id" element={<TestDetail />} /> |
||||||
|
<Route path="/" element={<Navigate to="/tests" replace />} /> |
||||||
|
</Routes> |
||||||
|
</BrowserRouter> |
||||||
|
); |
||||||
|
} |
||||||
|
|
||||||
|
export default App; |
||||||
@ -0,0 +1,26 @@ |
|||||||
|
const base = ''; |
||||||
|
|
||||||
|
export async function api(path, opts = {}) { |
||||||
|
const r = await fetch(`${base}${path}`, { |
||||||
|
credentials: 'include', |
||||||
|
headers: { |
||||||
|
'Content-Type': 'application/json', |
||||||
|
...(opts.headers || {}), |
||||||
|
}, |
||||||
|
...opts, |
||||||
|
}); |
||||||
|
const text = await r.text(); |
||||||
|
let data; |
||||||
|
try { |
||||||
|
data = text ? JSON.parse(text) : {}; |
||||||
|
} catch { |
||||||
|
data = { raw: text }; |
||||||
|
} |
||||||
|
if (!r.ok) { |
||||||
|
const err = new Error(data.error || r.statusText); |
||||||
|
err.status = r.status; |
||||||
|
err.data = data; |
||||||
|
throw err; |
||||||
|
} |
||||||
|
return data; |
||||||
|
} |
||||||
@ -0,0 +1,9 @@ |
|||||||
|
import React from 'react'; |
||||||
|
import ReactDOM from 'react-dom/client'; |
||||||
|
import App from './App.jsx'; |
||||||
|
|
||||||
|
ReactDOM.createRoot(document.getElementById('root')).render( |
||||||
|
<React.StrictMode> |
||||||
|
<App /> |
||||||
|
</React.StrictMode> |
||||||
|
); |
||||||
@ -0,0 +1,63 @@ |
|||||||
|
import { useState } from 'react'; |
||||||
|
import { useNavigate } from 'react-router-dom'; |
||||||
|
import { api } from '../api'; |
||||||
|
|
||||||
|
export default function Login() { |
||||||
|
const [login, setLogin] = useState(''); |
||||||
|
const [password, setPassword] = useState(''); |
||||||
|
const [err, setErr] = useState(null); |
||||||
|
const nav = useNavigate(); |
||||||
|
|
||||||
|
async function onSubmit(e) { |
||||||
|
e.preventDefault(); |
||||||
|
setErr(null); |
||||||
|
try { |
||||||
|
await api('/api/auth/login', { |
||||||
|
method: 'POST', |
||||||
|
body: JSON.stringify({ login, password }), |
||||||
|
}); |
||||||
|
nav('/tests'); |
||||||
|
} catch (e) { |
||||||
|
setErr(e.message); |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
return ( |
||||||
|
<div style={{ maxWidth: 360, margin: '48px auto', fontFamily: 'system-ui' }}> |
||||||
|
<h1>Вход</h1> |
||||||
|
<p style={{ color: '#666', fontSize: 14 }}> |
||||||
|
Локальный пользователь из <code>clinic_tests</code> (если HR_AUTH не |
||||||
|
включён). |
||||||
|
</p> |
||||||
|
<form onSubmit={onSubmit}> |
||||||
|
<div style={{ marginBottom: 12 }}> |
||||||
|
<label> |
||||||
|
Логин |
||||||
|
<br /> |
||||||
|
<input |
||||||
|
value={login} |
||||||
|
onChange={(e) => setLogin(e.target.value)} |
||||||
|
style={{ width: '100%', padding: 8 }} |
||||||
|
autoComplete="username" |
||||||
|
/> |
||||||
|
</label> |
||||||
|
</div> |
||||||
|
<div style={{ marginBottom: 12 }}> |
||||||
|
<label> |
||||||
|
Пароль |
||||||
|
<br /> |
||||||
|
<input |
||||||
|
type="password" |
||||||
|
value={password} |
||||||
|
onChange={(e) => setPassword(e.target.value)} |
||||||
|
style={{ width: '100%', padding: 8 }} |
||||||
|
autoComplete="current-password" |
||||||
|
/> |
||||||
|
</label> |
||||||
|
</div> |
||||||
|
{err && <p style={{ color: 'coral' }}>{err}</p>} |
||||||
|
<button type="submit">Войти</button> |
||||||
|
</form> |
||||||
|
</div> |
||||||
|
); |
||||||
|
} |
||||||
@ -0,0 +1,177 @@ |
|||||||
|
import { useEffect, useState } from 'react'; |
||||||
|
import { useNavigate, useParams, Link } from 'react-router-dom'; |
||||||
|
import { api } from '../api'; |
||||||
|
|
||||||
|
export default function TestDetail() { |
||||||
|
const { id } = useParams(); |
||||||
|
const nav = useNavigate(); |
||||||
|
const [data, setData] = useState(null); |
||||||
|
const [chain, setChain] = useState(null); |
||||||
|
const [err, setErr] = useState(null); |
||||||
|
const [qText, setQText] = useState('Пример вопроса?'); |
||||||
|
const [draftStatus, setDraftStatus] = useState(''); |
||||||
|
|
||||||
|
async function load() { |
||||||
|
setErr(null); |
||||||
|
try { |
||||||
|
const v = await api(`/api/tests/${id}/versions`); |
||||||
|
const c = await api(`/api/tests/${id}/chain-info`); |
||||||
|
setData(v); |
||||||
|
setChain(c); |
||||||
|
} catch (e) { |
||||||
|
if (e.status === 401) { |
||||||
|
nav('/login'); |
||||||
|
return; |
||||||
|
} |
||||||
|
setErr(e.message); |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
useEffect(() => { |
||||||
|
load(); |
||||||
|
}, [id, nav]); |
||||||
|
|
||||||
|
async function saveDraft() { |
||||||
|
setDraftStatus('…'); |
||||||
|
try { |
||||||
|
const out = await api(`/api/tests/${id}/draft`, { |
||||||
|
method: 'POST', |
||||||
|
body: JSON.stringify({ |
||||||
|
title: 'Обновлённый заголовок (через черновик)', |
||||||
|
questions: [ |
||||||
|
{ |
||||||
|
text: qText, |
||||||
|
question_order: 1, |
||||||
|
hasMultipleAnswers: false, |
||||||
|
options: [ |
||||||
|
{ text: 'Верно', isCorrect: true, option_order: 1 }, |
||||||
|
{ text: 'Неверно 1', isCorrect: false, option_order: 2 }, |
||||||
|
{ text: 'Неверно 2', isCorrect: false, option_order: 3 }, |
||||||
|
], |
||||||
|
}, |
||||||
|
], |
||||||
|
}), |
||||||
|
}); |
||||||
|
setDraftStatus( |
||||||
|
out.forked |
||||||
|
? 'Создана новая версия (вилка) и применён черновик' |
||||||
|
: 'Черновик применён на месте' |
||||||
|
); |
||||||
|
load(); |
||||||
|
} catch (e) { |
||||||
|
setDraftStatus(e.message); |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
async function startAttempt() { |
||||||
|
try { |
||||||
|
const o = await api(`/api/tests/${id}/attempts/start`, { |
||||||
|
method: 'POST', |
||||||
|
body: JSON.stringify({}), |
||||||
|
}); |
||||||
|
setDraftStatus(`Попытка стартовала: ${o.attempt.id}`); |
||||||
|
load(); |
||||||
|
} catch (e) { |
||||||
|
setDraftStatus(e.message); |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
async function activateVersion(vid) { |
||||||
|
if (!window.confirm('Сделать эту версию активной?')) { |
||||||
|
return; |
||||||
|
} |
||||||
|
try { |
||||||
|
await api(`/api/tests/${id}/versions/${vid}/activate`, { |
||||||
|
method: 'POST', |
||||||
|
body: JSON.stringify({}), |
||||||
|
}); |
||||||
|
load(); |
||||||
|
} catch (e) { |
||||||
|
setErr(e.message); |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
if (err) { |
||||||
|
return <p style={{ color: 'coral' }}>{err}</p>; |
||||||
|
} |
||||||
|
if (!data) { |
||||||
|
return <p>Загрузка…</p>; |
||||||
|
} |
||||||
|
|
||||||
|
return ( |
||||||
|
<div style={{ maxWidth: 900, margin: '24px auto', fontFamily: 'system-ui' }}> |
||||||
|
<p> |
||||||
|
<Link to="/tests">← к списку</Link> |
||||||
|
</p> |
||||||
|
{chain?.hasAnyAttempt && ( |
||||||
|
<div |
||||||
|
style={{ |
||||||
|
background: '#fff3cd', |
||||||
|
border: '1px solid #ffc107', |
||||||
|
padding: 12, |
||||||
|
marginBottom: 16, |
||||||
|
}} |
||||||
|
> |
||||||
|
По этой цепочке уже есть попытка. Сохранение черновика с вопросами |
||||||
|
создаст <strong>новую версию</strong> (V.1–V.3). |
||||||
|
</div> |
||||||
|
)} |
||||||
|
|
||||||
|
<h2>Версии</h2> |
||||||
|
<table |
||||||
|
style={{ borderCollapse: 'collapse', width: '100%' }} |
||||||
|
cellPadding={8} |
||||||
|
> |
||||||
|
<thead> |
||||||
|
<tr> |
||||||
|
<th>ver</th> |
||||||
|
<th>id</th> |
||||||
|
<th>active</th> |
||||||
|
<th /> |
||||||
|
</tr> |
||||||
|
</thead> |
||||||
|
<tbody> |
||||||
|
{data.versions.map((r) => ( |
||||||
|
<tr key={r.id} style={{ borderTop: '1px solid #ddd' }}> |
||||||
|
<td>{r.version}</td> |
||||||
|
<td style={{ fontSize: 12, wordBreak: 'break-all' }}>{r.id}</td> |
||||||
|
<td>{r.is_active ? 'да' : 'нет'}</td> |
||||||
|
<td> |
||||||
|
{!r.is_active && ( |
||||||
|
<button type="button" onClick={() => activateVersion(r.id)}> |
||||||
|
сделать активной |
||||||
|
</button> |
||||||
|
)} |
||||||
|
</td> |
||||||
|
</tr> |
||||||
|
))} |
||||||
|
</tbody> |
||||||
|
</table> |
||||||
|
{data.hasAttempts && ( |
||||||
|
<p style={{ color: '#666', fontSize: 14 }}>hasAttempts: да</p> |
||||||
|
)} |
||||||
|
|
||||||
|
<h3>Черновик (V.3)</h3> |
||||||
|
<label> |
||||||
|
Текст вопроса |
||||||
|
<br /> |
||||||
|
<input |
||||||
|
value={qText} |
||||||
|
onChange={(e) => setQText(e.target.value)} |
||||||
|
style={{ width: '100%', padding: 8, marginTop: 4 }} |
||||||
|
/> |
||||||
|
</label> |
||||||
|
<div style={{ marginTop: 8 }}> |
||||||
|
<button type="button" onClick={saveDraft}> |
||||||
|
Сохранить черновик |
||||||
|
</button> |
||||||
|
<span style={{ marginLeft: 12, color: '#666' }}>{draftStatus}</span> |
||||||
|
</div> |
||||||
|
|
||||||
|
<h3 style={{ marginTop: 24 }}>Прохождение (V.4)</h3> |
||||||
|
<button type="button" onClick={startAttempt}> |
||||||
|
Старт попытки |
||||||
|
</button> |
||||||
|
</div> |
||||||
|
); |
||||||
|
} |
||||||
@ -0,0 +1,100 @@ |
|||||||
|
import { useEffect, useState } from 'react'; |
||||||
|
import { Link, useNavigate } from 'react-router-dom'; |
||||||
|
import { api } from '../api'; |
||||||
|
|
||||||
|
export default function TestsList() { |
||||||
|
const [tests, setTests] = useState([]); |
||||||
|
const [user, setUser] = useState(null); |
||||||
|
const [err, setErr] = useState(null); |
||||||
|
const [title, setTitle] = useState(''); |
||||||
|
const nav = useNavigate(); |
||||||
|
|
||||||
|
async function load() { |
||||||
|
try { |
||||||
|
const me = await api('/api/auth/me'); |
||||||
|
setUser(me.user); |
||||||
|
const t = await api('/api/tests'); |
||||||
|
setTests(t.tests || []); |
||||||
|
} catch (e) { |
||||||
|
if (e.status === 401) { |
||||||
|
nav('/login'); |
||||||
|
return; |
||||||
|
} |
||||||
|
setErr(e.message); |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
useEffect(() => { |
||||||
|
load(); |
||||||
|
}, [nav]); |
||||||
|
|
||||||
|
async function createTest(e) { |
||||||
|
e.preventDefault(); |
||||||
|
if (!title.trim()) { |
||||||
|
return; |
||||||
|
} |
||||||
|
try { |
||||||
|
const o = await api('/api/tests', { |
||||||
|
method: 'POST', |
||||||
|
body: JSON.stringify({ title: title.trim() }), |
||||||
|
}); |
||||||
|
setTitle(''); |
||||||
|
nav(`/tests/${o.testId}`); |
||||||
|
} catch (e) { |
||||||
|
setErr(e.message); |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
async function logout() { |
||||||
|
await api('/api/auth/logout', { method: 'POST' }); |
||||||
|
nav('/login'); |
||||||
|
} |
||||||
|
|
||||||
|
return ( |
||||||
|
<div style={{ maxWidth: 800, margin: '24px auto', fontFamily: 'system-ui' }}> |
||||||
|
<div style={{ display: 'flex', justifyContent: 'space-between' }}> |
||||||
|
<h1>Тесты</h1> |
||||||
|
<div> |
||||||
|
{user && ( |
||||||
|
<span style={{ marginRight: 12 }}> |
||||||
|
{user.fullName} ({user.role}) |
||||||
|
</span> |
||||||
|
)} |
||||||
|
<button type="button" onClick={logout}> |
||||||
|
Выйти |
||||||
|
</button> |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
{err && <p style={{ color: 'coral' }}>{err}</p>} |
||||||
|
|
||||||
|
<form onSubmit={createTest} style={{ margin: '20px 0' }}> |
||||||
|
<input |
||||||
|
placeholder="Новый тест — название" |
||||||
|
value={title} |
||||||
|
onChange={(e) => setTitle(e.target.value)} |
||||||
|
style={{ padding: 8, width: 320 }} |
||||||
|
/> |
||||||
|
<button type="submit" style={{ marginLeft: 8 }}> |
||||||
|
Создать |
||||||
|
</button> |
||||||
|
</form> |
||||||
|
|
||||||
|
<ul style={{ listStyle: 'none', padding: 0 }}> |
||||||
|
{tests.map((t) => ( |
||||||
|
<li |
||||||
|
key={t.id} |
||||||
|
style={{ border: '1px solid #ddd', marginBottom: 8, padding: 12 }} |
||||||
|
> |
||||||
|
<Link to={`/tests/${t.id}`}> |
||||||
|
{t.title} |
||||||
|
</Link> |
||||||
|
<span style={{ color: '#888', fontSize: 13, marginLeft: 8 }}> |
||||||
|
v{t.version} · активная версия {t.active_version_id?.slice(0, 8)}… |
||||||
|
</span> |
||||||
|
</li> |
||||||
|
))} |
||||||
|
</ul> |
||||||
|
{tests.length === 0 && <p>Нет тестов — создайте первый.</p>} |
||||||
|
</div> |
||||||
|
); |
||||||
|
} |
||||||
Loading…
Reference in new issue