UI: фамилия с инициалами в шапке, подпись автора у тестов
- Хедер: отображать Фамилия И. О., полное ФИО в title. - Список тестов и карточка: «Автор: Вы» для своих, иначе «Автор: Фамилия И. О.». - API: в каталоге и summary/versions — created_by, author full_name (camelCase в JSON). Made-with: Cursor
This commit is contained in:
+423
-32
@@ -9,6 +9,28 @@ 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';
|
||||
import {
|
||||
getEditorContent,
|
||||
getPlayContent,
|
||||
submitAttempt,
|
||||
getAttemptReviewForUser,
|
||||
listTestAttemptsForAuthor,
|
||||
} from '../services/testAttemptService.js';
|
||||
import { extractTextFromFile } from '../services/documentExtractService.js';
|
||||
import { generationForImportDocument } from '../services/documentGenService.js';
|
||||
import { RU } from '../messages/ru.js';
|
||||
import { isTestAuthor } from '../config/devAuthor.js';
|
||||
import { ensureClinicUserIdForStaff } from '../services/assignmentUserService.js';
|
||||
import {
|
||||
queryTestsVisibleToUser,
|
||||
userHasTestAccess,
|
||||
} from '../services/testAccessService.js';
|
||||
import { isAssignmentFeatureEnabled } from '../config/featureFlags.js';
|
||||
import {
|
||||
generateFullTestByShape,
|
||||
generateOrRephraseQuestion,
|
||||
parseAndValidateShape,
|
||||
} from '../services/aiEditorService.js';
|
||||
|
||||
const router = express.Router();
|
||||
const upload = multer({
|
||||
@@ -28,35 +50,59 @@ function asyncHandler(fn) {
|
||||
};
|
||||
}
|
||||
|
||||
/** D.1 — раньше /:id, иначе id = "import" */
|
||||
/** D.1 + D.2 + D.3 (заглушка) — `POST` до маршрутов `/:id` */
|
||||
router.post(
|
||||
'/import/document',
|
||||
authenticate,
|
||||
upload.single('file'),
|
||||
(req, res, next) => {
|
||||
upload.single('file')(req, res, (err) => {
|
||||
if (err) {
|
||||
if (err.code === 'LIMIT_FILE_SIZE') {
|
||||
return res.status(413).json({ error: RU.fileTooLarge });
|
||||
}
|
||||
return next(err);
|
||||
}
|
||||
next();
|
||||
});
|
||||
},
|
||||
asyncHandler(async (req, res) => {
|
||||
if (!req.file) {
|
||||
return res.status(400).json({ error: 'file field required' });
|
||||
return res.status(400).json({ error: RU.fileFieldRequired });
|
||||
}
|
||||
const p = req.file.path;
|
||||
let size = 0;
|
||||
const { mimetype, originalname } = req.file;
|
||||
let size;
|
||||
let extractedText;
|
||||
try {
|
||||
const st = await fs.stat(p);
|
||||
size = st.size;
|
||||
await fs.unlink(p);
|
||||
} catch {
|
||||
extractedText = await extractTextFromFile(mimetype, p, originalname);
|
||||
} catch (e) {
|
||||
try {
|
||||
await fs.unlink(p);
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
return res.status(500).json({ error: 'upload failed' });
|
||||
if (e.status) {
|
||||
return res.status(e.status).json({ error: e.message });
|
||||
}
|
||||
console.error('import document:', e);
|
||||
return res.status(500).json({ error: RU.uploadFailed });
|
||||
}
|
||||
try {
|
||||
await fs.unlink(p);
|
||||
} catch {
|
||||
// D.5: временный файл удалён; при ошибке extract уже удалили выше
|
||||
}
|
||||
const generation = await generationForImportDocument(extractedText);
|
||||
res.json({
|
||||
received: true,
|
||||
originalName: req.file.originalname,
|
||||
originalName: originalname,
|
||||
mime: mimetype,
|
||||
size,
|
||||
message:
|
||||
'Файл принят; извлечение текста и генерация (D.2–D.3) — в следующем шаге',
|
||||
extractedText,
|
||||
textLength: extractedText.length,
|
||||
generation,
|
||||
});
|
||||
})
|
||||
);
|
||||
@@ -65,15 +111,19 @@ router.get(
|
||||
'/',
|
||||
authenticate,
|
||||
asyncHandler(async (req, res) => {
|
||||
const { rows } = await query(
|
||||
const { rows } = await queryTestsVisibleToUser(req.user.id);
|
||||
const { rows: hidden } = 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
|
||||
t.created_at, t.updated_at, tv.id AS active_version_id, tv.version,
|
||||
t.created_by, u.full_name AS author_full_name
|
||||
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`
|
||||
INNER JOIN users u ON u.id = t.created_by
|
||||
WHERE t.is_active = false AND t.created_by = $1
|
||||
ORDER BY t.updated_at DESC NULLS LAST, t.created_at DESC`,
|
||||
[req.user.id]
|
||||
);
|
||||
res.json({ tests: rows });
|
||||
res.json({ tests: rows, hiddenByYou: hidden });
|
||||
})
|
||||
);
|
||||
|
||||
@@ -83,7 +133,7 @@ router.post(
|
||||
asyncHandler(async (req, res) => {
|
||||
const { title, description } = req.body;
|
||||
if (!title || typeof title !== 'string') {
|
||||
return res.status(400).json({ error: 'title required' });
|
||||
return res.status(400).json({ error: RU.titleRequired });
|
||||
}
|
||||
const out = await createTestWithVersion(pool, req.user.id, {
|
||||
title,
|
||||
@@ -93,27 +143,172 @@ router.post(
|
||||
})
|
||||
);
|
||||
|
||||
/**
|
||||
* V.8: краткая карточка цепочки — одна строка (активная версия), без дублей.
|
||||
* Не-автор не видит скрытую с общего списка цепочку (кроме прямой ссылки автора).
|
||||
*/
|
||||
router.get(
|
||||
'/:id/summary',
|
||||
authenticate,
|
||||
asyncHandler(async (req, res) => {
|
||||
const testId = req.params.id;
|
||||
const { rows } = await query(
|
||||
`SELECT t.id, t.title, t.description, t.passing_threshold, t.is_active AS chain_active,
|
||||
t.created_by, t.created_at, t.updated_at, tv.id AS active_version_id, tv.version,
|
||||
u.full_name AS author_full_name
|
||||
FROM tests t
|
||||
LEFT JOIN test_versions tv ON tv.test_id = t.id AND tv.is_active = true
|
||||
LEFT JOIN users u ON u.id = t.created_by
|
||||
WHERE t.id = $1`,
|
||||
[testId]
|
||||
);
|
||||
if (!rows.length) {
|
||||
return res.status(404).json({ error: RU.testNotFound });
|
||||
}
|
||||
const row = rows[0];
|
||||
const isAuthor = isTestAuthor(row.created_by, req.user.id);
|
||||
if (row.chain_active === false && !isAuthor) {
|
||||
return res.status(404).json({ error: RU.testNotFound });
|
||||
}
|
||||
if (!isAuthor) {
|
||||
const acc = await userHasTestAccess(req.user.id, testId);
|
||||
if (!acc.ok) {
|
||||
return res.status(404).json({ error: RU.testNotFound });
|
||||
}
|
||||
}
|
||||
res.json({
|
||||
test: {
|
||||
id: row.id,
|
||||
title: row.title,
|
||||
description: row.description,
|
||||
passingThreshold: row.passing_threshold,
|
||||
chainActive: row.chain_active,
|
||||
activeVersionId: row.active_version_id,
|
||||
version: row.version,
|
||||
createdAt: row.created_at,
|
||||
updatedAt: row.updated_at,
|
||||
createdBy: row.created_by,
|
||||
authorFullName: row.author_full_name,
|
||||
},
|
||||
isAuthor,
|
||||
hasActiveVersion: row.active_version_id != null,
|
||||
});
|
||||
})
|
||||
);
|
||||
|
||||
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,
|
||||
]);
|
||||
const { rows: t } = await query(
|
||||
`SELECT t.id, t.title, t.created_by, t.is_active, t.created_at, t.updated_at, t.description,
|
||||
u.full_name AS author_full_name
|
||||
FROM tests t
|
||||
INNER JOIN users u ON u.id = t.created_by
|
||||
WHERE t.id = $1`,
|
||||
[testId]
|
||||
);
|
||||
if (!t.length) {
|
||||
return res.status(404).json({ error: 'Test not found' });
|
||||
return res.status(404).json({ error: RU.testNotFound });
|
||||
}
|
||||
if (t[0].created_by !== req.user.id) {
|
||||
return res.status(403).json({ error: 'Forbidden' });
|
||||
if (!isTestAuthor(t[0].created_by, req.user.id)) {
|
||||
return res.status(403).json({ error: RU.forbidden });
|
||||
}
|
||||
const testRow = t[0];
|
||||
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 });
|
||||
res.json({
|
||||
test: {
|
||||
id: testRow.id,
|
||||
title: testRow.title,
|
||||
description: testRow.description,
|
||||
chainActive: testRow.is_active,
|
||||
createdAt: testRow.created_at,
|
||||
updatedAt: testRow.updated_at,
|
||||
createdBy: testRow.created_by,
|
||||
authorFullName: testRow.author_full_name,
|
||||
},
|
||||
versions: rows,
|
||||
hasAttempts,
|
||||
});
|
||||
})
|
||||
);
|
||||
|
||||
router.get(
|
||||
'/:id/editor',
|
||||
authenticate,
|
||||
asyncHandler(async (req, res) => {
|
||||
const out = await getEditorContent(pool, req.user.id, req.params.id);
|
||||
res.json(out);
|
||||
})
|
||||
);
|
||||
|
||||
/**
|
||||
* ИИ: заполнить тест по текущей сетке (число вопросов = len(shape), варианты = optionsCount).
|
||||
* Только автор. Тело: { testTitle?, testDescription?, shape: [{ optionsCount, hasMultipleAnswers }] }
|
||||
*/
|
||||
router.post(
|
||||
'/:id/ai/generate-test',
|
||||
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: RU.testNotFound });
|
||||
}
|
||||
if (!isTestAuthor(t[0].created_by, req.user.id)) {
|
||||
return res.status(403).json({ error: RU.forbidden });
|
||||
}
|
||||
const b = req.body && typeof req.body === 'object' ? req.body : {};
|
||||
const shape = parseAndValidateShape(b.shape);
|
||||
const testTitle = typeof b.testTitle === 'string' ? b.testTitle : '';
|
||||
const testDescription = typeof b.testDescription === 'string' ? b.testDescription : '';
|
||||
const draft = await generateFullTestByShape(testTitle, testDescription, shape);
|
||||
res.json({ ok: true, draft });
|
||||
})
|
||||
);
|
||||
|
||||
/**
|
||||
* ИИ: один вопрос — пустой текст → сгенерировать вопрос и варианты; иначе переформулировать только текст.
|
||||
* Только автор. Тело: { testTitle?, testDescription?, questionText, optionsCount, hasMultipleAnswers }
|
||||
*/
|
||||
router.post(
|
||||
'/:id/ai/generate-question',
|
||||
authenticate,
|
||||
asyncHandler(async (req, res) => {
|
||||
const testId = req.params.id;
|
||||
const { rows: tr } = await query(
|
||||
`SELECT id, created_by FROM tests WHERE id = $1`,
|
||||
[testId]
|
||||
);
|
||||
if (!tr.length) {
|
||||
return res.status(404).json({ error: RU.testNotFound });
|
||||
}
|
||||
if (!isTestAuthor(tr[0].created_by, req.user.id)) {
|
||||
return res.status(403).json({ error: RU.forbidden });
|
||||
}
|
||||
const b = req.body && typeof req.body === 'object' ? req.body : {};
|
||||
const testTitle = typeof b.testTitle === 'string' ? b.testTitle : '';
|
||||
const testDescription = typeof b.testDescription === 'string' ? b.testDescription : '';
|
||||
const questionText = typeof b.questionText === 'string' ? b.questionText : '';
|
||||
const optionsCount = b.optionsCount;
|
||||
const hasMultipleAnswers = Boolean(b.hasMultipleAnswers);
|
||||
const out = await generateOrRephraseQuestion(
|
||||
testTitle,
|
||||
testDescription,
|
||||
questionText,
|
||||
optionsCount,
|
||||
hasMultipleAnswers
|
||||
);
|
||||
res.json({ ok: true, ...out });
|
||||
})
|
||||
);
|
||||
|
||||
@@ -128,17 +323,17 @@ router.post(
|
||||
[testId]
|
||||
);
|
||||
if (!t.length) {
|
||||
return res.status(404).json({ error: 'Test not found' });
|
||||
return res.status(404).json({ error: RU.testNotFound });
|
||||
}
|
||||
if (t[0].created_by !== req.user.id) {
|
||||
return res.status(403).json({ error: 'Forbidden' });
|
||||
if (!isTestAuthor(t[0].created_by, req.user.id)) {
|
||||
return res.status(403).json({ error: RU.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' });
|
||||
return res.status(404).json({ error: RU.versionNotFound });
|
||||
}
|
||||
const client = await pool.connect();
|
||||
try {
|
||||
@@ -170,17 +365,17 @@ router.patch(
|
||||
const { isActive, chainActive } = req.body;
|
||||
const chain = chainActive ?? isActive;
|
||||
if (typeof chain !== 'boolean') {
|
||||
return res.status(400).json({ error: 'chainActive (boolean) required' });
|
||||
return res.status(400).json({ error: RU.chainActiveRequired });
|
||||
}
|
||||
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' });
|
||||
return res.status(404).json({ error: RU.testNotFound });
|
||||
}
|
||||
if (t[0].created_by !== req.user.id) {
|
||||
return res.status(403).json({ error: 'Forbidden' });
|
||||
if (!isTestAuthor(t[0].created_by, req.user.id)) {
|
||||
return res.status(403).json({ error: RU.forbidden });
|
||||
}
|
||||
await query(
|
||||
`UPDATE tests SET is_active = $2, updated_at = CURRENT_TIMESTAMP WHERE id = $1`,
|
||||
@@ -190,6 +385,111 @@ router.patch(
|
||||
})
|
||||
);
|
||||
|
||||
/**
|
||||
* Пакетное назначение — `test_assignments` + `test_assignment_targets` (user).
|
||||
* Включение: `development` или `CLINIC_ASSIGNMENT_ENABLED=1`. Только автор. Тело: `userIds`, `staffIds` и/или одиночные `userId` / `staffId`.
|
||||
*/
|
||||
router.post(
|
||||
'/:id/assign',
|
||||
authenticate,
|
||||
asyncHandler(async (req, res) => {
|
||||
if (!isAssignmentFeatureEnabled()) {
|
||||
return res.status(404).json({ error: RU.notFound });
|
||||
}
|
||||
const testId = req.params.id;
|
||||
const b = req.body && typeof req.body === 'object' ? req.body : {};
|
||||
const userIds = new Set();
|
||||
|
||||
if (Array.isArray(b.userIds)) {
|
||||
for (const u of b.userIds) {
|
||||
if (typeof u === 'string' && u.trim()) {
|
||||
userIds.add(u.trim());
|
||||
}
|
||||
}
|
||||
}
|
||||
if (Array.isArray(b.staffIds)) {
|
||||
for (const s of b.staffIds) {
|
||||
const n = Number(s);
|
||||
if (Number.isFinite(n) && n >= 1) {
|
||||
userIds.add(await ensureClinicUserIdForStaff(pool, n));
|
||||
}
|
||||
}
|
||||
}
|
||||
if (userIds.size === 0) {
|
||||
if (b.staffId != null && b.userId) {
|
||||
return res.status(400).json({ error: RU.assignmentUserOrStaff });
|
||||
}
|
||||
if (b.staffId != null) {
|
||||
const sid = Number(b.staffId);
|
||||
if (Number.isNaN(sid)) {
|
||||
return res.status(400).json({ error: RU.assignmentUserRequired });
|
||||
}
|
||||
userIds.add(await ensureClinicUserIdForStaff(pool, sid));
|
||||
} else if (typeof b.userId === 'string' && b.userId.trim()) {
|
||||
userIds.add(b.userId.trim());
|
||||
}
|
||||
}
|
||||
if (userIds.size === 0) {
|
||||
return res.status(400).json({ error: RU.assignmentUserRequired });
|
||||
}
|
||||
|
||||
const { rows: t } = await query(
|
||||
`SELECT id, created_by FROM tests WHERE id = $1`,
|
||||
[testId]
|
||||
);
|
||||
if (!t.length) {
|
||||
return res.status(404).json({ error: RU.testNotFound });
|
||||
}
|
||||
if (!isTestAuthor(t[0].created_by, req.user.id)) {
|
||||
return res.status(403).json({ error: RU.forbidden });
|
||||
}
|
||||
const { rows: tv } = await query(
|
||||
`SELECT id FROM test_versions WHERE test_id = $1 AND is_active = true LIMIT 1`,
|
||||
[testId]
|
||||
);
|
||||
if (!tv.length) {
|
||||
return res.status(400).json({ error: RU.noActiveVersion });
|
||||
}
|
||||
for (const uid of userIds) {
|
||||
const { rows: u } = await query(
|
||||
`SELECT id FROM users WHERE id = $1 AND is_active = true`,
|
||||
[uid]
|
||||
);
|
||||
if (!u.length) {
|
||||
return res.status(400).json({ error: RU.userNotFound });
|
||||
}
|
||||
}
|
||||
const versionId = tv[0].id;
|
||||
const client = await pool.connect();
|
||||
let assignmentId;
|
||||
try {
|
||||
await client.query('BEGIN');
|
||||
const { rows: ins } = await client.query(
|
||||
`INSERT INTO test_assignments (test_version_id, assigned_by, max_attempts)
|
||||
VALUES ($1, $2, 5) RETURNING id`,
|
||||
[versionId, req.user.id]
|
||||
);
|
||||
assignmentId = ins[0].id;
|
||||
for (const uid of userIds) {
|
||||
await client.query(
|
||||
`INSERT INTO test_assignment_targets (assignment_id, target_type, target_id)
|
||||
VALUES ($1, 'user', $2)`,
|
||||
[assignmentId, uid]
|
||||
);
|
||||
}
|
||||
await client.query('COMMIT');
|
||||
} catch (e) {
|
||||
await client.query('ROLLBACK');
|
||||
throw e;
|
||||
} finally {
|
||||
client.release();
|
||||
}
|
||||
res
|
||||
.status(201)
|
||||
.json({ ok: true, assignmentId, count: userIds.size });
|
||||
})
|
||||
);
|
||||
|
||||
router.post(
|
||||
'/:id/draft',
|
||||
authenticate,
|
||||
@@ -204,6 +504,10 @@ router.post(
|
||||
authenticate,
|
||||
asyncHandler(async (req, res) => {
|
||||
const testId = req.params.id;
|
||||
const acc = await userHasTestAccess(req.user.id, testId);
|
||||
if (!acc.ok) {
|
||||
return res.status(404).json({ error: RU.testNotFound });
|
||||
}
|
||||
const { rows: tv } = await query(
|
||||
`SELECT tv.id AS test_version_id
|
||||
FROM test_versions tv
|
||||
@@ -211,7 +515,7 @@ router.post(
|
||||
[testId]
|
||||
);
|
||||
if (!tv.length) {
|
||||
return res.status(404).json({ error: 'No active version' });
|
||||
return res.status(404).json({ error: RU.noActiveVersion });
|
||||
}
|
||||
const testVersionId = tv[0].test_version_id;
|
||||
const { rows: mx } = await query(
|
||||
@@ -230,11 +534,98 @@ router.post(
|
||||
})
|
||||
);
|
||||
|
||||
/** Только автор: список попыток по цепочке (все версии) */
|
||||
router.get(
|
||||
'/:id/attempts',
|
||||
authenticate,
|
||||
asyncHandler(async (req, res) => {
|
||||
const rows = await listTestAttemptsForAuthor(pool, req.user.id, req.params.id);
|
||||
res.json({
|
||||
attempts: rows.map((r) => ({
|
||||
id: r.id,
|
||||
userId: r.user_id,
|
||||
status: r.status,
|
||||
attemptNumber: r.attempt_number,
|
||||
startedAt: r.started_at,
|
||||
completedAt: r.completed_at,
|
||||
correctCount: r.correct_count,
|
||||
totalQuestions: r.total_questions,
|
||||
passed: r.passed,
|
||||
testVersion: r.test_version,
|
||||
attempterName: r.attempter_name,
|
||||
attempterLogin: r.attempter_login,
|
||||
})),
|
||||
});
|
||||
})
|
||||
);
|
||||
|
||||
/** Разбор завершённой попытки: владелец или автор */
|
||||
router.get(
|
||||
'/:id/attempts/:aid/review',
|
||||
authenticate,
|
||||
asyncHandler(async (req, res) => {
|
||||
const review = await getAttemptReviewForUser(
|
||||
pool,
|
||||
req.user.id,
|
||||
req.params.id,
|
||||
req.params.aid
|
||||
);
|
||||
res.json(review);
|
||||
})
|
||||
);
|
||||
|
||||
router.get(
|
||||
'/:id/attempts/:aid/play',
|
||||
authenticate,
|
||||
asyncHandler(async (req, res) => {
|
||||
const out = await getPlayContent(pool, req.user.id, req.params.id, req.params.aid);
|
||||
res.json(out);
|
||||
})
|
||||
);
|
||||
|
||||
router.post(
|
||||
'/:id/attempts/:aid/submit',
|
||||
authenticate,
|
||||
asyncHandler(async (req, res) => {
|
||||
const out = await submitAttempt(
|
||||
pool,
|
||||
req.user.id,
|
||||
req.params.id,
|
||||
req.params.aid,
|
||||
req.body?.answers
|
||||
);
|
||||
res.json(out);
|
||||
})
|
||||
);
|
||||
|
||||
router.get(
|
||||
'/:id/chain-info',
|
||||
authenticate,
|
||||
asyncHandler(async (req, res) => {
|
||||
const testId = req.params.id;
|
||||
const acc = await userHasTestAccess(req.user.id, testId);
|
||||
if (acc.notFound) {
|
||||
return res.status(404).json({ error: RU.testNotFound });
|
||||
}
|
||||
if (!acc.ok) {
|
||||
return res.status(404).json({ error: RU.testNotFound });
|
||||
}
|
||||
const { rows: tr } = await query(
|
||||
`SELECT t.is_active AS chain_active FROM tests t WHERE t.id = $1`,
|
||||
[testId]
|
||||
);
|
||||
if (!tr.length) {
|
||||
return res.status(404).json({ error: RU.testNotFound });
|
||||
}
|
||||
if (tr[0].chain_active === false) {
|
||||
const { rows: auth } = await query(
|
||||
`SELECT created_by FROM tests WHERE id = $1`,
|
||||
[testId]
|
||||
);
|
||||
if (!isTestAuthor(auth[0].created_by, req.user.id)) {
|
||||
return res.status(404).json({ error: RU.testNotFound });
|
||||
}
|
||||
}
|
||||
const has = await hasAnyAttemptForTest(pool, testId);
|
||||
res.json({ testId, hasAnyAttempt: has });
|
||||
})
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
/**
|
||||
* Кто видит тест: автор цепочки и пользователи с назначением (target user = clinic user id).
|
||||
*/
|
||||
import { isTestAuthor } from '../config/devAuthor.js';
|
||||
import { query } from '../db/db.js';
|
||||
|
||||
/**
|
||||
* @param {string} userId
|
||||
* @param {string} testId
|
||||
* @returns {Promise<{ ok: boolean, isAuthor: boolean, notFound: boolean }>}
|
||||
*/
|
||||
export async function userHasTestAccess(userId, testId) {
|
||||
const { rows } = await query(
|
||||
`SELECT t.created_by FROM tests t WHERE t.id = $1`,
|
||||
[testId]
|
||||
);
|
||||
if (!rows.length) {
|
||||
return { ok: false, isAuthor: false, notFound: true };
|
||||
}
|
||||
if (isTestAuthor(rows[0].created_by, userId)) {
|
||||
return { ok: true, isAuthor: true, notFound: false };
|
||||
}
|
||||
const { rows: ar } = await query(
|
||||
`SELECT 1
|
||||
FROM test_assignments ta
|
||||
INNER JOIN test_versions tv_a ON tv_a.id = ta.test_version_id
|
||||
INNER JOIN test_assignment_targets tat ON tat.assignment_id = ta.id
|
||||
WHERE tv_a.test_id = $1
|
||||
AND tat.target_type = 'user'
|
||||
AND tat.target_id = $2
|
||||
LIMIT 1`,
|
||||
[testId, userId]
|
||||
);
|
||||
return { ok: ar.length > 0, isAuthor: false, notFound: false };
|
||||
}
|
||||
|
||||
/**
|
||||
* Список тестов в каталоге: только `is_active` цепочка + (автор OR назначен).
|
||||
*/
|
||||
export async function queryTestsVisibleToUser(userId) {
|
||||
return query(
|
||||
`SELECT DISTINCT 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,
|
||||
t.created_by, u.full_name AS author_full_name
|
||||
FROM tests t
|
||||
INNER JOIN test_versions tv ON tv.test_id = t.id AND tv.is_active = true
|
||||
INNER JOIN users u ON u.id = t.created_by
|
||||
WHERE t.is_active = true
|
||||
AND (
|
||||
t.created_by = $1
|
||||
OR EXISTS (
|
||||
SELECT 1
|
||||
FROM test_assignments ta
|
||||
INNER JOIN test_versions tv2 ON tv2.id = ta.test_version_id
|
||||
INNER JOIN test_assignment_targets tat ON tat.assignment_id = ta.id
|
||||
WHERE tv2.test_id = t.id
|
||||
AND tat.target_type = 'user'
|
||||
AND tat.target_id = $1
|
||||
)
|
||||
)
|
||||
ORDER BY t.updated_at DESC NULLS LAST, t.created_at DESC`,
|
||||
[userId]
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { Link, Outlet, useNavigate } from 'react-router-dom';
|
||||
import { api } from '../api';
|
||||
import { formatSurnameWithInitials } from '../utils/formatUserName';
|
||||
|
||||
export default function CabinetLayout() {
|
||||
const nav = useNavigate();
|
||||
const [user, setUser] = useState(null);
|
||||
/** Backend: `devUi` при NODE_ENV=development */
|
||||
const [devUi, setDevUi] = useState(false);
|
||||
/** Каталог HR + блок назначения (env CLINIC_ASSIGNMENT_ENABLED в prod) */
|
||||
const [assignmentUi, setAssignmentUi] = useState(false);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [err, setErr] = useState(null);
|
||||
|
||||
const loadMe = useCallback(async () => {
|
||||
setErr(null);
|
||||
try {
|
||||
const me = await api('/api/auth/me');
|
||||
setUser(me.user);
|
||||
setDevUi(!!me.devUi);
|
||||
setAssignmentUi(!!me.assignmentUi);
|
||||
} catch (e) {
|
||||
if (e.status === 401) {
|
||||
nav('/login', { replace: true });
|
||||
return;
|
||||
}
|
||||
setErr(e.message);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [nav]);
|
||||
|
||||
useEffect(() => {
|
||||
loadMe();
|
||||
}, [loadMe]);
|
||||
|
||||
async function logout() {
|
||||
try {
|
||||
await api('/api/auth/logout', { method: 'POST' });
|
||||
} catch {
|
||||
// ignore; still go to login
|
||||
}
|
||||
nav('/login');
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="cabinet-app cabinet-page--center">
|
||||
<p className="text-muted">Загрузка…</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (err) {
|
||||
return (
|
||||
<div className="cabinet-app cabinet-page--center">
|
||||
<p className="callout callout--error" role="alert">
|
||||
{err}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="cabinet-app">
|
||||
<header className="cabinet-header">
|
||||
<div className="cabinet-header__inner">
|
||||
<Link to="/tests" className="cabinet-brand">
|
||||
<span
|
||||
className="material-symbols-outlined cabinet-brand__icon"
|
||||
aria-hidden
|
||||
>
|
||||
school
|
||||
</span>
|
||||
<div>
|
||||
<div className="cabinet-brand__title">Система тестрования</div>
|
||||
<div className="cabinet-brand__subtitle">Портал</div>
|
||||
</div>
|
||||
</Link>
|
||||
<div className="cabinet-header__actions">
|
||||
{user && (
|
||||
<span
|
||||
className="cabinet-user"
|
||||
title={user.fullName ? `${user.fullName} · ${user.role}` : user.role}
|
||||
>
|
||||
{formatSurnameWithInitials(user.fullName) || user.login || '—'}
|
||||
<span className="cabinet-user__role"> · {user.role}</span>
|
||||
</span>
|
||||
)}
|
||||
<button type="button" className="btn btn-ghost" onClick={logout}>
|
||||
Выйти
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
<main className="cabinet-main">
|
||||
<Outlet context={{ user, devUi, assignmentUi, refreshUser: loadMe }} />
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
+1130
-94
File diff suppressed because it is too large
Load Diff
@@ -1,20 +1,27 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Link, useNavigate } from 'react-router-dom';
|
||||
import { Link, useNavigate, useOutletContext } from 'react-router-dom';
|
||||
import { api } from '../api';
|
||||
import { formatTestAuthorLabel } from '../utils/formatUserName';
|
||||
|
||||
export default function TestsList() {
|
||||
const { user, devUi } = useOutletContext() || {};
|
||||
const isEmployee = user?.role === 'employee';
|
||||
const canCreate = devUi || !isEmployee;
|
||||
const [tests, setTests] = useState([]);
|
||||
const [user, setUser] = useState(null);
|
||||
const [hiddenByYou, setHiddenByYou] = useState([]);
|
||||
const [err, setErr] = useState(null);
|
||||
const [title, setTitle] = useState('');
|
||||
const [passBusy, setPassBusy] = useState(null);
|
||||
const [passErr, setPassErr] = useState(null);
|
||||
const nav = useNavigate();
|
||||
|
||||
async function load() {
|
||||
setErr(null);
|
||||
setPassErr(null);
|
||||
try {
|
||||
const me = await api('/api/auth/me');
|
||||
setUser(me.user);
|
||||
const t = await api('/api/tests');
|
||||
setTests(t.tests || []);
|
||||
setHiddenByYou(t.hiddenByYou || []);
|
||||
} catch (e) {
|
||||
if (e.status === 401) {
|
||||
nav('/login');
|
||||
@@ -28,6 +35,22 @@ export default function TestsList() {
|
||||
load();
|
||||
}, [nav]);
|
||||
|
||||
async function startPass(testId) {
|
||||
setPassErr(null);
|
||||
setPassBusy(testId);
|
||||
try {
|
||||
const o = await api(`/api/tests/${testId}/attempts/start`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({}),
|
||||
});
|
||||
nav(`/tests/${testId}/attempt/${o.attempt.id}`);
|
||||
} catch (e) {
|
||||
setPassErr(e.message);
|
||||
} finally {
|
||||
setPassBusy(null);
|
||||
}
|
||||
}
|
||||
|
||||
async function createTest(e) {
|
||||
e.preventDefault();
|
||||
if (!title.trim()) {
|
||||
@@ -45,56 +68,126 @@ export default function TestsList() {
|
||||
}
|
||||
}
|
||||
|
||||
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}>
|
||||
Выйти
|
||||
<div>
|
||||
<h1 className="font-headline" style={{ fontSize: '1.5rem' }}>
|
||||
Тесты
|
||||
</h1>
|
||||
{err && (
|
||||
<p className="error-text" role="alert">
|
||||
{err}
|
||||
</p>
|
||||
)}
|
||||
{passErr && (
|
||||
<p className="error-text" role="alert">
|
||||
{passErr}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{canCreate && (
|
||||
<form onSubmit={createTest} className="create-row" aria-label="Создать тест">
|
||||
<input
|
||||
className="form-input"
|
||||
placeholder="Новый тест — название"
|
||||
value={title}
|
||||
onChange={(e) => setTitle(e.target.value)}
|
||||
/>
|
||||
<button type="submit" className="btn btn-ghost">
|
||||
Создать
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{err && <p style={{ color: 'coral' }}>{err}</p>}
|
||||
</form>
|
||||
)}
|
||||
|
||||
<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 }}>
|
||||
<ul className="list-stack" aria-label="Тесты в общем списке">
|
||||
{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 key={t.id} className="list-row list-row--split">
|
||||
<div className="list-row__main">
|
||||
<Link to={`/tests/${t.id}`} className="list-row__link">
|
||||
<span className="list-row__title">{t.title}</span>
|
||||
<span className="list-row__meta">
|
||||
{formatTestAuthorLabel(user, t.created_by, t.author_full_name)}
|
||||
<span className="list-row__meta-sep" aria-hidden>
|
||||
{' '}
|
||||
·{' '}
|
||||
</span>
|
||||
v{t.version} · активная {t.active_version_id?.slice(0, 8) ?? '—'}…
|
||||
</span>
|
||||
</Link>
|
||||
</div>
|
||||
<div className="list-row__side">
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-ghost"
|
||||
disabled={passBusy != null}
|
||||
onClick={() => startPass(t.id)}
|
||||
>
|
||||
{passBusy === t.id ? '…' : 'Пройти'}
|
||||
</button>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
{tests.length === 0 && <p>Нет тестов — создайте первый.</p>}
|
||||
{tests.length === 0 && hiddenByYou.length === 0 && (
|
||||
<p className="text-muted">
|
||||
{canCreate
|
||||
? 'Пусто. В списке — только тесты, которые вы ведёте, и назначенные вам. Создайте цепочку или дождитесь назначения.'
|
||||
: 'Пусто: вам пока ничего не назначено и нет цепочек, где вы автор.'}
|
||||
</p>
|
||||
)}
|
||||
{tests.length === 0 && hiddenByYou.length > 0 && (
|
||||
<p className="text-muted" style={{ marginBottom: '0.75rem' }}>
|
||||
В общем списке пусто — у вас есть скрытые тесты ниже; откройте карточку,
|
||||
чтобы снова включить отображение.
|
||||
</p>
|
||||
)}
|
||||
|
||||
{hiddenByYou.length > 0 && (
|
||||
<>
|
||||
<h2
|
||||
className="font-headline"
|
||||
style={{ fontSize: '1.1rem', marginTop: '1.5rem', marginBottom: '0.5rem' }}
|
||||
>
|
||||
Скрытые вами из списка
|
||||
</h2>
|
||||
<p className="text-muted" style={{ marginTop: 0, marginBottom: '0.75rem' }}>
|
||||
Эти цепочки не видны в блоке выше. Откройте карточку и внизу раздела
|
||||
«Публикация» нажмите «Снова показать в списке».
|
||||
</p>
|
||||
<ul className="list-stack" aria-label="Скрытые тесты автора">
|
||||
{hiddenByYou.map((t) => (
|
||||
<li
|
||||
key={t.id}
|
||||
className="list-row list-row--split"
|
||||
style={{ borderStyle: 'dashed', opacity: 0.95 }}
|
||||
>
|
||||
<div className="list-row__main">
|
||||
<Link to={`/tests/${t.id}`} className="list-row__link">
|
||||
<span className="list-row__title">{t.title}</span>
|
||||
<span className="list-row__meta">
|
||||
{formatTestAuthorLabel(user, t.created_by, t.author_full_name)}
|
||||
<span className="list-row__meta-sep" aria-hidden>
|
||||
{' '}
|
||||
·{' '}
|
||||
</span>
|
||||
v{t.version} · скрыт · {t.active_version_id?.slice(0, 8) ?? '—'}…
|
||||
</span>
|
||||
</Link>
|
||||
</div>
|
||||
<div className="list-row__side">
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-ghost"
|
||||
disabled={passBusy != null}
|
||||
onClick={() => startPass(t.id)}
|
||||
>
|
||||
{passBusy === t.id ? '…' : 'Пройти'}
|
||||
</button>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
/**
|
||||
* «Иванов Иван Иванович» → «Иванов И. И.»; «Иванов Иван» → «Иванов И.».
|
||||
* @param {string | null | undefined} fullName
|
||||
* @returns {string}
|
||||
*/
|
||||
export function formatSurnameWithInitials(fullName) {
|
||||
if (fullName == null || typeof fullName !== 'string') {
|
||||
return '';
|
||||
}
|
||||
const parts = fullName.trim().split(/\s+/).filter(Boolean);
|
||||
if (parts.length === 0) {
|
||||
return '';
|
||||
}
|
||||
if (parts.length === 1) {
|
||||
return parts[0];
|
||||
}
|
||||
const [surname, first, ...rest] = parts;
|
||||
const i1 = first.charAt(0).toLocaleUpperCase('ru-RU');
|
||||
if (parts.length === 2) {
|
||||
return `${surname} ${i1}.`;
|
||||
}
|
||||
const pat = rest.length ? rest[0] : '';
|
||||
const i2 = pat ? pat.charAt(0).toLocaleUpperCase('ru-RU') : '';
|
||||
return i2 ? `${surname} ${i1}. ${i2}.` : `${surname} ${i1}.`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Подпись автора в списке и карточке.
|
||||
* @param {{ id?: string } | null | undefined} user
|
||||
* @param {string | null | undefined} createdBy
|
||||
* @param {string | null | undefined} authorFullName
|
||||
*/
|
||||
export function formatTestAuthorLabel(user, createdBy, authorFullName) {
|
||||
if (user?.id != null && createdBy != null && String(user.id) === String(createdBy)) {
|
||||
return 'Автор: Вы';
|
||||
}
|
||||
const short = formatSurnameWithInitials(authorFullName);
|
||||
return short ? `Автор: ${short}` : 'Автор: —';
|
||||
}
|
||||
Reference in New Issue
Block a user