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 });
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user