feat: add content navigation practice assembly

This commit is contained in:
Codex
2026-06-21 23:04:39 +08:00
parent efce192844
commit c1159f8745
18 changed files with 2358 additions and 74 deletions

View File

@@ -6,29 +6,103 @@ import { boolValue, intValue, jsonArrayValue, jsonObjectValue, nullableString, o
const QUESTION_STATUSES = ['draft', 'published', 'archived'];
async function assertOptionalReference(
client: { query: (sql: string, params?: unknown[]) => Promise<{ rows: unknown[] }> },
tenantId: string,
table: string,
id: string | null,
code: string,
) {
if (!id) return;
const row = await client.query(`select id from public.${table} where tenant_id = $1 and id = $2 limit 1`, [tenantId, id]);
if (!row.rows[0]) {
throw new HttpError(400, `${table} reference is not in this tenant`, code);
}
}
async function syncPrimaryCollectionItem(
client: { query: (sql: string, params?: unknown[]) => Promise<{ rows: unknown[] }> },
tenantId: string,
collectionId: string | null,
questionId: string,
) {
if (!collectionId) return;
await client.query(
`
insert into public.question_collection_items (tenant_id, collection_id, question_id, sort_order)
values (
$1, $2, $3,
coalesce((select max(sort_order) + 1 from public.question_collection_items where tenant_id = $1 and collection_id = $2), 0)
)
on conflict (tenant_id, collection_id, question_id) do nothing
`,
[tenantId, collectionId, questionId],
);
await client.query(
`
update public.question_collections
set question_count = (
select count(*)
from public.question_collection_items
where tenant_id = $1 and collection_id = $2
),
updated_at = now()
where tenant_id = $1 and id = $2
`,
[tenantId, collectionId],
);
}
export async function createQuestionRoute(ctx: RequestContext) {
const auth = await requireTenantContentEditor(ctx);
const body = await readJsonBody(ctx);
const item = await transaction(async client => {
const questionBankId = nullableString(body.questionBankId);
const subjectId = nullableString(body.subjectId);
const categoryId = nullableString(body.categoryId);
const nodeId = nullableString(body.nodeId);
const entryId = nullableString(body.entryId);
const contentNodeId = nullableString(body.contentNodeId);
const primaryCollectionId = nullableString(body.primaryCollectionId) || nullableString(body.collectionId);
await assertOptionalReference(client, auth.tenantId, 'question_banks', questionBankId, 'QUESTION_BANK_NOT_FOUND');
await assertOptionalReference(client, auth.tenantId, 'subjects', subjectId, 'SUBJECT_NOT_FOUND');
await assertOptionalReference(client, auth.tenantId, 'categories', categoryId, 'CATEGORY_NOT_FOUND');
await assertOptionalReference(client, auth.tenantId, 'module_nodes', nodeId, 'NODE_NOT_FOUND');
await assertOptionalReference(client, auth.tenantId, 'content_entries', entryId, 'ENTRY_NOT_FOUND');
await assertOptionalReference(client, auth.tenantId, 'content_nodes', contentNodeId, 'CONTENT_NODE_NOT_FOUND');
await assertOptionalReference(client, auth.tenantId, 'question_collections', primaryCollectionId, 'QUESTION_COLLECTION_NOT_FOUND');
const questionResult = await client.query(
`
insert into public.questions (
tenant_id, question_bank_id, subject_id, category_id, node_id,
legacy_id, type, type_label, difficulty, tags, media_url, status
entry_id, content_node_id, primary_collection_id, legacy_id, type,
type_label, difficulty, tags, media_url, status, exam_markers
)
values (
$1, $2::uuid, $3::uuid, $4::uuid, $5::uuid,
$6::uuid, $7::uuid, $8::uuid, $9, $10,
$11, $12, $13::jsonb, $14, $15, $16::jsonb
)
values ($1, $2::uuid, $3::uuid, $4::uuid, $5::uuid, $6, $7, $8, $9, $10::jsonb, $11, $12)
returning id, tenant_id as "tenantId", question_bank_id as "questionBankId",
subject_id as "subjectId", category_id as "categoryId", node_id as "nodeId",
type, type_label as "typeLabel", difficulty, tags, media_url as "mediaUrl",
status, created_at as "createdAt", updated_at as "updatedAt"
entry_id as "entryId", content_node_id as "contentNodeId",
primary_collection_id as "primaryCollectionId", type,
type_label as "typeLabel", difficulty, tags, media_url as "mediaUrl",
status, exam_markers as "examMarkers", created_at as "createdAt",
updated_at as "updatedAt"
`,
[
auth.tenantId,
nullableString(body.questionBankId),
nullableString(body.subjectId),
nullableString(body.categoryId),
nullableString(body.nodeId),
questionBankId,
subjectId,
categoryId,
nodeId,
entryId,
contentNodeId,
primaryCollectionId,
nullableString(body.legacyId),
optionalString(body, 'type') || 'choice',
optionalString(body, 'typeLabel') || null,
@@ -36,9 +110,11 @@ export async function createQuestionRoute(ctx: RequestContext) {
jsonArrayValue(body.tags),
nullableString(body.mediaUrl),
optionalStatus(body.status, QUESTION_STATUSES, 'published'),
jsonObjectValue(body.examMarkers),
],
);
const question = questionResult.rows[0];
await syncPrimaryCollectionItem(client, auth.tenantId, primaryCollectionId, question.id);
const versionResult = await client.query(
`
@@ -93,6 +169,22 @@ export async function updateQuestionRoute(ctx: RequestContext) {
const createVersion = body.createVersion === true;
const item = await transaction(async client => {
const questionBankId = nullableString(body.questionBankId);
const subjectId = nullableString(body.subjectId);
const categoryId = nullableString(body.categoryId);
const nodeId = nullableString(body.nodeId);
const entryId = nullableString(body.entryId);
const contentNodeId = nullableString(body.contentNodeId);
const primaryCollectionId = nullableString(body.primaryCollectionId) || nullableString(body.collectionId);
await assertOptionalReference(client, auth.tenantId, 'question_banks', questionBankId, 'QUESTION_BANK_NOT_FOUND');
await assertOptionalReference(client, auth.tenantId, 'subjects', subjectId, 'SUBJECT_NOT_FOUND');
await assertOptionalReference(client, auth.tenantId, 'categories', categoryId, 'CATEGORY_NOT_FOUND');
await assertOptionalReference(client, auth.tenantId, 'module_nodes', nodeId, 'NODE_NOT_FOUND');
await assertOptionalReference(client, auth.tenantId, 'content_entries', entryId, 'ENTRY_NOT_FOUND');
await assertOptionalReference(client, auth.tenantId, 'content_nodes', contentNodeId, 'CONTENT_NODE_NOT_FOUND');
await assertOptionalReference(client, auth.tenantId, 'question_collections', primaryCollectionId, 'QUESTION_COLLECTION_NOT_FOUND');
const questionResult = await client.query(
`
update public.questions
@@ -100,26 +192,36 @@ export async function updateQuestionRoute(ctx: RequestContext) {
subject_id = coalesce($4::uuid, subject_id),
category_id = coalesce($5::uuid, category_id),
node_id = coalesce($6::uuid, node_id),
type = coalesce(nullif($7, ''), type),
type_label = coalesce(nullif($8, ''), type_label),
difficulty = coalesce($9, difficulty),
tags = case when $10::boolean then $11::jsonb else tags end,
media_url = coalesce(nullif($12, ''), media_url),
status = coalesce(nullif($13, ''), status),
entry_id = coalesce($7::uuid, entry_id),
content_node_id = coalesce($8::uuid, content_node_id),
primary_collection_id = coalesce($9::uuid, primary_collection_id),
type = coalesce(nullif($10, ''), type),
type_label = coalesce(nullif($11, ''), type_label),
difficulty = coalesce($12, difficulty),
tags = case when $13::boolean then $14::jsonb else tags end,
media_url = coalesce(nullif($15, ''), media_url),
status = coalesce(nullif($16, ''), status),
exam_markers = case when $17::boolean then $18::jsonb else exam_markers end,
updated_at = now()
where tenant_id = $1 and id = $2
returning id, tenant_id as "tenantId", question_bank_id as "questionBankId",
subject_id as "subjectId", category_id as "categoryId", node_id as "nodeId",
type, type_label as "typeLabel", difficulty, tags, media_url as "mediaUrl",
status, current_version_id as "currentVersionId", updated_at as "updatedAt"
entry_id as "entryId", content_node_id as "contentNodeId",
primary_collection_id as "primaryCollectionId", type,
type_label as "typeLabel", difficulty, tags, media_url as "mediaUrl",
status, exam_markers as "examMarkers",
current_version_id as "currentVersionId", updated_at as "updatedAt"
`,
[
auth.tenantId,
questionId,
nullableString(body.questionBankId),
nullableString(body.subjectId),
nullableString(body.categoryId),
nullableString(body.nodeId),
questionBankId,
subjectId,
categoryId,
nodeId,
entryId,
contentNodeId,
primaryCollectionId,
optionalString(body, 'type'),
optionalString(body, 'typeLabel'),
body.difficulty === undefined ? null : intValue(body.difficulty, 1),
@@ -127,11 +229,14 @@ export async function updateQuestionRoute(ctx: RequestContext) {
jsonArrayValue(body.tags),
nullableString(body.mediaUrl),
body.status ? optionalStatus(body.status, QUESTION_STATUSES, 'published') : '',
Object.hasOwn(body, 'examMarkers'),
jsonObjectValue(body.examMarkers),
],
);
const question = questionResult.rows[0];
if (!question) throw new HttpError(404, 'Question not found', 'QUESTION_NOT_FOUND');
await syncPrimaryCollectionItem(client, auth.tenantId, question.primaryCollectionId || null, question.id);
if (!createVersion) return question;