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

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

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

Made-with: Cursor
This commit is contained in:
Константин Лебединский
2026-04-24 22:55:15 +05:00
parent a68331c86b
commit 0fe04d4d99
38 changed files with 3683 additions and 491 deletions
+23 -9
View File
@@ -2,6 +2,8 @@
* V.3 saveTestDraft, fork версии, контент вопросов.
*/
import { hasAnyAttemptForTest } from './testChainService.js';
import { RU } from '../messages/ru.js';
import { isTestAuthor } from '../config/devAuthor.js';
/**
* @param {import('pg').PoolClient} client
@@ -93,23 +95,25 @@ export async function replaceVersionContent(client, testVersionId, payload) {
export async function forkNewVersion(client, testId) {
const av = await getActiveVersionRow(client, testId);
if (!av) {
throw new Error('no active version');
throw new Error(RU.noActiveVersion);
}
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;
// Сначала снять is_active с цепочки: частичный уникальный индекс
// uq_test_versions_one_active_per_test — не более одной true на test_id.
await client.query(
`UPDATE test_versions SET is_active = false WHERE test_id = $1`,
[testId]
);
const { rows: nv } = await client.query(
`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;
}
@@ -126,13 +130,13 @@ export async function saveTestDraft(pool, authorId, testId, payload) {
[testId]
);
if (!tr.length) {
const e = new Error('Test not found');
const e = new Error(RU.testNotFound);
e.status = 404;
throw e;
}
const t = tr[0];
if (t.created_by !== authorId) {
const e = new Error('Forbidden');
if (!isTestAuthor(t.created_by, authorId)) {
const e = new Error(RU.forbidden);
e.status = 403;
throw e;
}
@@ -148,10 +152,20 @@ export async function saveTestDraft(pool, authorId, testId, payload) {
[testId, payload.title ?? null, payload.description ?? null]
);
}
if (payload.passingThreshold !== undefined && payload.passingThreshold !== null) {
const raw = Number(payload.passingThreshold);
if (Number.isFinite(raw)) {
const pt = Math.max(0, Math.min(100, Math.round(raw)));
await client.query(
`UPDATE tests SET passing_threshold = $2, updated_at = CURRENT_TIMESTAMP WHERE id = $1`,
[testId, pt]
);
}
}
const hasAttempts = await hasAnyAttemptForTest(client, testId);
let versionRow = await getActiveVersionRow(client, testId);
if (!versionRow) {
const e = new Error('No active version');
const e = new Error(RU.noActiveVersion);
e.status = 500;
throw e;
}