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

@@ -110,6 +110,8 @@ export async function assetsRoute(ctx: RequestContext) {
const regionId = stringParam(ctx, 'regionId');
const subjectId = stringParam(ctx, 'subjectId');
const categoryId = stringParam(ctx, 'categoryId');
const entryId = stringParam(ctx, 'entryId');
const contentNodeId = stringParam(ctx, 'contentNodeId');
const includeLocked = stringParam(ctx, 'includeLocked') === 'true';
const userPresent = hasUserContext(ctx);
@@ -134,6 +136,14 @@ export async function assetsRoute(ctx: RequestContext) {
params.push(categoryId);
filters.push(`(category_id = $${params.length} or category_id is null)`);
}
if (entryId) {
params.push(entryId);
filters.push(`(entry_id = $${params.length} or entry_id is null)`);
}
if (contentNodeId) {
params.push(contentNodeId);
filters.push(`(content_node_id = $${params.length} or content_node_id is null)`);
}
params.push(limit);
const items = await query(
@@ -144,6 +154,7 @@ export async function assetsRoute(ctx: RequestContext) {
mime_type as "mimeType", file_size_bytes as "fileSizeBytes",
visibility, region_id as "regionId", subject_id as "subjectId",
category_id as "categoryId", node_id as "nodeId",
entry_id as "entryId", content_node_id as "contentNodeId",
sort_order as "order", metadata, created_at as "createdAt",
updated_at as "updatedAt"
from public.content_assets

View File

@@ -1,5 +1,12 @@
import type { RouteDefinition } from '../../core/router.js';
import { assetDownloadRoute, assetsRoute } from './assets.js';
import {
collectionQuestionsRoute,
contentEntriesRoute,
contentNodesRoute,
practiceBlueprintsRoute,
questionCollectionsRoute,
} from './navigation.js';
import {
announcementsRoute,
bannersRoute,
@@ -31,6 +38,11 @@ export const catalogRoutes: RouteDefinition[] = [
['GET', '/api/catalog/subjects', subjectsRoute],
['GET', '/api/catalog/categories', categoriesRoute],
['GET', '/api/catalog/questions', questionsRoute],
['GET', '/api/catalog/content-entries', contentEntriesRoute],
['GET', '/api/catalog/content-nodes', contentNodesRoute],
['GET', '/api/catalog/question-collections', questionCollectionsRoute],
['GET', '/api/catalog/question-collections/questions', collectionQuestionsRoute],
['GET', '/api/catalog/practice-blueprints', practiceBlueprintsRoute],
['GET', '/api/catalog/assets', assetsRoute],
['GET', '/api/catalog/assets/download', assetDownloadRoute],
['GET', '/api/catalog/vocabulary-units', vocabularyUnitsRoute],

View File

@@ -0,0 +1,247 @@
import { HttpError, type RequestContext } from '../../core/http.js';
import { intParam, stringParam, tenantIdFrom } from '../../core/request.js';
import { query, queryOne } from '../../core/db.js';
function optionalUuidParam(ctx: RequestContext, name: string) {
const value = stringParam(ctx, name);
return value || null;
}
export async function contentEntriesRoute(ctx: RequestContext) {
const tenantId = tenantIdFrom(ctx);
const regionId = optionalUuidParam(ctx, 'regionId');
const entryType = stringParam(ctx, 'entryType');
const includeHidden = stringParam(ctx, 'includeHidden') === 'true';
const params: unknown[] = [tenantId];
const filters = ['tenant_id = $1', 'is_active = true'];
if (!includeHidden) filters.push(`visibility <> 'hidden'`);
if (regionId) {
params.push(regionId);
filters.push(`(region_id = $${params.length} or region_id is null)`);
}
if (entryType) {
params.push(entryType);
filters.push(`entry_type = $${params.length}`);
}
const items = await query(
`
select id, region_id as "regionId", legacy_id as "legacyId",
entry_key as "entryKey", name, entry_type as "entryType",
icon, route, description, visibility, access_rules as "accessRules",
layout_config as "layoutConfig", sort_order as "order",
is_active as "isActive", created_at as "createdAt",
updated_at as "updatedAt"
from public.content_entries
where ${filters.join(' and ')}
order by sort_order asc, created_at asc
`,
params,
);
return { items };
}
export async function contentNodesRoute(ctx: RequestContext) {
const tenantId = tenantIdFrom(ctx);
const entryId = stringParam(ctx, 'entryId');
if (!entryId) {
throw new HttpError(400, 'entryId is required', 'REQUIRED_FIELD');
}
const parentIdParam = ctx.url.searchParams.get('parentId');
const parentId = parentIdParam === 'root' ? null : parentIdParam;
const mode = stringParam(ctx, 'mode') || 'children';
const includeInactive = stringParam(ctx, 'includeInactive') === 'true';
const markerType = stringParam(ctx, 'markerType');
const params: unknown[] = [tenantId, entryId];
const filters = ['tenant_id = $1', 'entry_id = $2'];
if (!includeInactive) filters.push('is_active = true');
if (parentIdParam !== null) {
if (parentId) {
params.push(parentId);
filters.push(`parent_id = $${params.length}`);
} else {
filters.push('parent_id is null');
}
}
if (markerType) {
params.push(markerType);
filters.push(`marker_type = $${params.length}`);
}
const orderClause = mode === 'flat'
? 'coalesce(path::text, name) asc, sort_order asc'
: 'sort_order asc, created_at asc';
const items = await query(
`
select id, entry_id as "entryId", region_id as "regionId",
parent_id as "parentId", legacy_id as "legacyId",
node_key as "nodeKey", name, node_type as "nodeType",
marker_type as "markerType", marker_config as "markerConfig",
path::text as path, depth, sort_order as "order",
is_active as "isActive", is_selectable as "isSelectable",
is_leaf as "isLeaf", metadata,
created_at as "createdAt", updated_at as "updatedAt"
from public.content_nodes
where ${filters.join(' and ')}
order by ${orderClause}
`,
params,
);
return { items };
}
export async function questionCollectionsRoute(ctx: RequestContext) {
const tenantId = tenantIdFrom(ctx);
const regionId = optionalUuidParam(ctx, 'regionId');
const entryId = optionalUuidParam(ctx, 'entryId');
const nodeId = optionalUuidParam(ctx, 'nodeId');
const collectionType = stringParam(ctx, 'collectionType');
const limit = intParam(ctx, 'limit', 100, 500);
const params: unknown[] = [tenantId];
const filters = ['tenant_id = $1', `status = 'active'`];
if (regionId) {
params.push(regionId);
filters.push(`(region_id = $${params.length} or region_id is null)`);
}
if (entryId) {
params.push(entryId);
filters.push(`entry_id = $${params.length}`);
}
if (nodeId) {
params.push(nodeId);
filters.push(`node_id = $${params.length}`);
}
if (collectionType) {
params.push(collectionType);
filters.push(`collection_type = $${params.length}`);
}
params.push(limit);
const items = await query(
`
select id, region_id as "regionId", entry_id as "entryId",
node_id as "nodeId", subject_id as "subjectId",
category_id as "categoryId", question_bank_id as "questionBankId",
legacy_id as "legacyId", name, collection_type as "collectionType",
source_type as "sourceType", filters, question_count as "questionCount",
total_score as "totalScore", duration_minutes as "durationMinutes",
status, sort_order as "order", metadata,
created_at as "createdAt", updated_at as "updatedAt"
from public.question_collections
where ${filters.join(' and ')}
order by sort_order asc, created_at asc
limit $${params.length}
`,
params,
);
return { items };
}
export async function practiceBlueprintsRoute(ctx: RequestContext) {
const tenantId = tenantIdFrom(ctx);
const entryId = optionalUuidParam(ctx, 'entryId');
const nodeId = optionalUuidParam(ctx, 'nodeId');
const collectionId = optionalUuidParam(ctx, 'collectionId');
const mode = stringParam(ctx, 'mode');
const limit = intParam(ctx, 'limit', 100, 500);
const params: unknown[] = [tenantId];
const filters = ['tenant_id = $1', `status = 'active'`];
if (entryId) {
params.push(entryId);
filters.push(`entry_id = $${params.length}`);
}
if (nodeId) {
params.push(nodeId);
filters.push(`node_id = $${params.length}`);
}
if (collectionId) {
params.push(collectionId);
filters.push(`collection_id = $${params.length}`);
}
if (mode) {
params.push(mode);
filters.push(`mode = $${params.length}`);
}
params.push(limit);
const items = await query(
`
select id, region_id as "regionId", entry_id as "entryId",
node_id as "nodeId", collection_id as "collectionId",
legacy_id as "legacyId", name, mode,
assembly_type as "assemblyType", question_limit as "questionLimit",
duration_minutes as "durationMinutes", total_score as "totalScore",
pass_score as "passScore", sections, rules, status,
sort_order as "order", created_at as "createdAt",
updated_at as "updatedAt"
from public.practice_blueprints
where ${filters.join(' and ')}
order by sort_order asc, created_at asc
limit $${params.length}
`,
params,
);
return { items };
}
export async function collectionQuestionsRoute(ctx: RequestContext) {
const tenantId = tenantIdFrom(ctx);
const collectionId = stringParam(ctx, 'collectionId');
if (!collectionId) {
throw new HttpError(400, 'collectionId is required', 'REQUIRED_FIELD');
}
const limit = intParam(ctx, 'limit', 200, 1000);
const collection = await queryOne<{ id: string }>(
`
select id
from public.question_collections
where tenant_id = $1 and id = $2 and status = 'active'
limit 1
`,
[tenantId, collectionId],
);
if (!collection) {
throw new HttpError(404, 'Question collection not found', 'QUESTION_COLLECTION_NOT_FOUND');
}
const items = await query(
`
select q.id, q.legacy_id as "legacyId", q.entry_id as "entryId",
q.content_node_id as "contentNodeId",
q.primary_collection_id as "primaryCollectionId",
q.subject_id as "subjectId", q.category_id as "categoryId",
q.node_id as "nodeId", q.type, q.type_label as "typeLabel",
q.difficulty, q.tags, q.exam_markers as "examMarkers",
q.media_url as "mediaUrl", q.has_video_explanation as "hasVideoExplanation",
ci.section_key as "sectionKey", ci.score, ci.sort_order as "order",
v.id as "versionId", v.content, v.options,
v.correct_option_index as "correctOptionIndex",
v.correct_option_indices as "correctOptionIndices",
v.answer_text as "answerText", v.explanation, v.sub_questions as "subQuestions",
v.code_lang as "codeLang", v.code_template as "codeTemplate",
q.created_at as "createdAt", q.updated_at as "updatedAt"
from public.question_collection_items ci
join public.questions q on q.id = ci.question_id and q.tenant_id = ci.tenant_id
left join public.question_versions v on v.id = q.current_version_id
where ci.tenant_id = $1
and ci.collection_id = $2
and q.status = 'published'
order by ci.section_key asc nulls first, ci.sort_order asc, q.created_at asc
limit $3
`,
[tenantId, collectionId, limit],
);
return { items };
}

View File

@@ -239,6 +239,9 @@ export async function questionsRoute(ctx: RequestContext) {
const subjectId = ctx.url.searchParams.get('subjectId');
const categoryId = ctx.url.searchParams.get('categoryId');
const nodeId = ctx.url.searchParams.get('nodeId');
const entryId = ctx.url.searchParams.get('entryId');
const contentNodeId = ctx.url.searchParams.get('contentNodeId');
const collectionId = ctx.url.searchParams.get('collectionId');
const limit = intParam(ctx, 'limit', 200, 500);
const params: unknown[] = [tenantId];
@@ -255,13 +258,37 @@ export async function questionsRoute(ctx: RequestContext) {
params.push(nodeId);
filters.push(`q.node_id = $${params.length}`);
}
if (entryId) {
params.push(entryId);
filters.push(`q.entry_id = $${params.length}`);
}
if (contentNodeId) {
params.push(contentNodeId);
filters.push(`q.content_node_id = $${params.length}`);
}
if (collectionId) {
params.push(collectionId);
filters.push(`(
q.primary_collection_id = $${params.length}
or exists (
select 1
from public.question_collection_items qci
where qci.tenant_id = q.tenant_id
and qci.question_id = q.id
and qci.collection_id = $${params.length}
)
)`);
}
params.push(limit);
const items = await query(
`
select q.id, q.legacy_id as "legacyId", q.subject_id as "subjectId",
q.category_id as "categoryId", q.node_id as "nodeId",
select q.id, q.legacy_id as "legacyId", q.entry_id as "entryId",
q.content_node_id as "contentNodeId",
q.primary_collection_id as "primaryCollectionId",
q.subject_id as "subjectId", q.category_id as "categoryId", q.node_id as "nodeId",
q.type, q.type_label as "typeLabel", q.difficulty, q.tags,
q.exam_markers as "examMarkers",
q.media_url as "mediaUrl", q.has_video_explanation as "hasVideoExplanation",
v.id as "versionId", v.content, v.options,
v.correct_option_index as "correctOptionIndex",

View File

@@ -20,6 +20,35 @@ interface QuestionAnswerRow {
answer_text: string | null;
}
interface PracticeBlueprintRow {
id: string;
entry_id: string | null;
node_id: string | null;
collection_id: string | null;
mode: string;
assembly_type: string;
question_limit: number | null;
duration_minutes: number | null;
total_score: string | number | null;
sections: unknown;
rules: Record<string, unknown>;
}
interface PracticeAssembly {
mode: string;
targetType: string | null;
targetId: string | null;
blueprintId: string | null;
collectionId: string | null;
entryId: string | null;
contentNodeId: string | null;
questionLimit: number;
durationMinutes: number | null;
totalScore: number | null;
sections: unknown[];
rules: Record<string, unknown>;
}
function normalizeStringArray(value: unknown): string[] {
if (!Array.isArray(value)) return [];
return value.map(item => String(item)).filter(item => item !== '');
@@ -62,23 +91,312 @@ function judgeAnswer(row: QuestionAnswerRow, selectedOptions: string[], answerTe
return null;
}
function optionalBodyString(body: Record<string, unknown>, key: string) {
const value = body[key];
return typeof value === 'string' && value.trim() ? value.trim() : null;
}
function jsonArray(value: unknown): unknown[] {
return Array.isArray(value) ? value : [];
}
function positiveInt(value: unknown, fallback: number, max = 500) {
const parsed = Number(value ?? fallback);
if (!Number.isFinite(parsed) || parsed <= 0) return fallback;
return Math.min(Math.trunc(parsed), max);
}
function modeFrom(value: string) {
if (['sequential', 'random', 'mock_exam', 'paper', 'wrong_review', 'favorite_review', 'chapter'].includes(value)) return value;
return 'chapter';
}
async function loadPracticeBlueprint(tenantId: string, blueprintId: string) {
const row = await queryOne<PracticeBlueprintRow>(
`
select id, entry_id, node_id, collection_id, mode, assembly_type,
question_limit, duration_minutes, total_score, sections, rules
from public.practice_blueprints
where tenant_id = $1 and id = $2 and status = 'active'
limit 1
`,
[tenantId, blueprintId],
);
if (!row) {
throw new HttpError(404, 'Practice blueprint not found', 'PRACTICE_BLUEPRINT_NOT_FOUND');
}
return row;
}
async function buildPracticeAssembly(tenantId: string, body: Record<string, unknown>): Promise<PracticeAssembly> {
const blueprintId = optionalBodyString(body, 'blueprintId');
const targetType = optionalString(body, 'targetType') || null;
const targetId = optionalString(body, 'targetId') || null;
const inputMode = modeFrom(optionalString(body, 'mode') || 'chapter');
if (!blueprintId) {
return {
mode: inputMode,
targetType,
targetId,
blueprintId: null,
collectionId: optionalBodyString(body, 'collectionId'),
entryId: optionalBodyString(body, 'entryId'),
contentNodeId: optionalBodyString(body, 'contentNodeId') || (targetType === 'content_node' ? targetId : null),
questionLimit: positiveInt(body.questionLimit, 100),
durationMinutes: body.durationMinutes === undefined ? null : positiveInt(body.durationMinutes, 60, 24 * 60),
totalScore: body.totalScore === undefined || body.totalScore === null ? null : Number(body.totalScore),
sections: jsonArray(body.sections),
rules: body.rules && typeof body.rules === 'object' && !Array.isArray(body.rules) ? body.rules as Record<string, unknown> : {},
};
}
const blueprint = await loadPracticeBlueprint(tenantId, blueprintId);
return {
mode: modeFrom(blueprint.mode),
targetType: targetType || 'blueprint',
targetId: targetId || blueprint.id,
blueprintId,
collectionId: optionalBodyString(body, 'collectionId') || blueprint.collection_id,
entryId: optionalBodyString(body, 'entryId') || blueprint.entry_id,
contentNodeId: optionalBodyString(body, 'contentNodeId') || blueprint.node_id,
questionLimit: positiveInt(body.questionLimit ?? blueprint.question_limit, 100),
durationMinutes: body.durationMinutes === undefined
? blueprint.duration_minutes
: positiveInt(body.durationMinutes, blueprint.duration_minutes || 60, 24 * 60),
totalScore: body.totalScore === undefined || body.totalScore === null
? (blueprint.total_score === null ? null : Number(blueprint.total_score))
: Number(body.totalScore),
sections: jsonArray(body.sections).length ? jsonArray(body.sections) : jsonArray(blueprint.sections),
rules: {
...(blueprint.rules || {}),
...(body.rules && typeof body.rules === 'object' && !Array.isArray(body.rules) ? body.rules as Record<string, unknown> : {}),
},
};
}
async function collectFromCollection(
tenantId: string,
collectionId: string,
limit: number,
randomize: boolean,
typeFilter?: string | null,
sectionKey?: string | null,
excludeIds: string[] = [],
) {
const params: unknown[] = [tenantId, collectionId];
const filters = ['ci.tenant_id = $1', 'ci.collection_id = $2', `q.status = 'published'`];
if (typeFilter) {
params.push(typeFilter);
filters.push(`q.type = $${params.length}`);
}
if (sectionKey) {
params.push(sectionKey);
filters.push(`ci.section_key = $${params.length}`);
}
if (excludeIds.length) {
params.push(excludeIds);
filters.push(`q.id <> all($${params.length}::uuid[])`);
}
params.push(limit);
const rows = await query<{ id: string }>(
`
select q.id
from public.question_collection_items ci
join public.questions q on q.id = ci.question_id and q.tenant_id = ci.tenant_id
where ${filters.join(' and ')}
order by ${randomize ? 'random()' : 'ci.sort_order asc, q.created_at asc'}
limit $${params.length}
`,
params,
);
return rows.map(row => row.id);
}
async function collectFromContentNode(
tenantId: string,
contentNodeId: string,
limit: number,
randomize: boolean,
typeFilter?: string | null,
excludeIds: string[] = [],
) {
const params: unknown[] = [tenantId, contentNodeId];
const filters = ['q.tenant_id = $1', `q.status = 'published'`];
if (typeFilter) {
params.push(typeFilter);
filters.push(`q.type = $${params.length}`);
}
if (excludeIds.length) {
params.push(excludeIds);
filters.push(`q.id <> all($${params.length}::uuid[])`);
}
params.push(limit);
const rows = await query<{ id: string }>(
`
with target_node as (
select path
from public.content_nodes
where tenant_id = $1 and id = $2 and is_active = true
limit 1
)
select q.id
from public.questions q
left join public.content_nodes n on n.id = q.content_node_id and n.tenant_id = q.tenant_id
where ${filters.join(' and ')}
and exists (select 1 from target_node)
and (
q.content_node_id = $2
or n.path <@ (select path from target_node)
)
order by ${randomize ? 'random()' : 'q.created_at asc'}
limit $${params.length}
`,
params,
);
return rows.map(row => row.id);
}
async function collectFromLegacyTarget(
tenantId: string,
targetType: string | null,
targetId: string | null,
limit: number,
randomize: boolean,
) {
if (!targetType || !targetId) return [];
const columnByTarget: Record<string, string> = {
subject: 'subject_id',
category: 'category_id',
node: 'node_id',
question_bank: 'question_bank_id',
};
const column = columnByTarget[targetType];
if (!column) return [];
const rows = await query<{ id: string }>(
`
select id
from public.questions
where tenant_id = $1 and status = 'published' and ${column} = $2
order by ${randomize ? 'random()' : 'created_at asc'}
limit $3
`,
[tenantId, targetId, limit],
);
return rows.map(row => row.id);
}
function sectionType(section: unknown) {
if (!section || typeof section !== 'object' || Array.isArray(section)) return null;
const object = section as Record<string, unknown>;
const value = object.questionType ?? object.type;
return typeof value === 'string' && value.trim() ? value.trim() : null;
}
function sectionKey(section: unknown) {
if (!section || typeof section !== 'object' || Array.isArray(section)) return null;
const object = section as Record<string, unknown>;
const value = object.sectionKey ?? object.key;
return typeof value === 'string' && value.trim() ? value.trim() : null;
}
function sectionLimit(section: unknown, fallback: number) {
if (!section || typeof section !== 'object' || Array.isArray(section)) return fallback;
const object = section as Record<string, unknown>;
return positiveInt(object.questionCount ?? object.limit, fallback);
}
async function assembleQuestionIds(tenantId: string, assembly: PracticeAssembly) {
const randomize = assembly.mode === 'random' || assembly.mode === 'mock_exam' || assembly.rules.randomize === true;
const questionIds: string[] = [];
const pushUnique = (ids: string[]) => {
for (const id of ids) {
if (!questionIds.includes(id)) questionIds.push(id);
}
};
if (assembly.sections.length && (assembly.collectionId || assembly.contentNodeId)) {
for (const section of assembly.sections) {
const limit = sectionLimit(section, assembly.questionLimit);
const type = sectionType(section);
const key = sectionKey(section);
const ids = assembly.collectionId
? await collectFromCollection(tenantId, assembly.collectionId, limit, randomize, type, key, questionIds)
: await collectFromContentNode(tenantId, assembly.contentNodeId || '', limit, randomize, type, questionIds);
pushUnique(ids);
}
return questionIds.slice(0, assembly.questionLimit);
}
if (assembly.collectionId) {
return collectFromCollection(tenantId, assembly.collectionId, assembly.questionLimit, randomize);
}
if (assembly.contentNodeId) {
return collectFromContentNode(tenantId, assembly.contentNodeId, assembly.questionLimit, randomize);
}
return collectFromLegacyTarget(tenantId, assembly.targetType, assembly.targetId, assembly.questionLimit, randomize);
}
export async function createPracticeSessionRoute(ctx: RequestContext) {
const body = await readJsonBody(ctx);
const tenantId = tenantIdFrom(ctx);
const userId = userIdFrom(ctx, body);
const mode = optionalString(body, 'mode') || 'chapter';
const targetType = optionalString(body, 'targetType') || null;
const targetId = optionalString(body, 'targetId') || null;
const assembly = await buildPracticeAssembly(tenantId, body);
const questionIds = await assembleQuestionIds(tenantId, assembly);
if ((assembly.blueprintId || assembly.collectionId || assembly.contentNodeId) && questionIds.length === 0) {
throw new HttpError(409, 'No published questions are available for this practice target', 'NO_PRACTICE_QUESTIONS');
}
const item = await queryOne(
`
insert into public.practice_sessions (tenant_id, user_id, mode, target_type, target_id, metadata)
values ($1, $2, $3, $4, $5, $6::jsonb)
insert into public.practice_sessions (
tenant_id, user_id, mode, target_type, target_id,
blueprint_id, collection_id, entry_id, content_node_id,
question_ids, question_count, duration_minutes, total_score,
expires_at, metadata
)
values (
$1, $2, $3, $4, $5,
$6::uuid, $7::uuid, $8::uuid, $9::uuid,
$10::jsonb, $11, $12, $13,
case when $12::integer is null then null else now() + make_interval(mins => $12::integer) end,
$14::jsonb
)
returning id, tenant_id as "tenantId", user_id as "userId", mode,
target_type as "targetType", target_id as "targetId",
started_at as "startedAt", finished_at as "finishedAt", metadata
blueprint_id as "blueprintId", collection_id as "collectionId",
entry_id as "entryId", content_node_id as "contentNodeId",
question_ids as "questionIds", question_count as "questionCount",
duration_minutes as "durationMinutes", total_score as "totalScore",
expires_at as "expiresAt", started_at as "startedAt",
finished_at as "finishedAt", metadata
`,
[tenantId, userId, mode, targetType, targetId, JSON.stringify(body.metadata || {})],
[
tenantId,
userId,
assembly.mode,
assembly.targetType,
assembly.targetId,
assembly.blueprintId,
assembly.collectionId,
assembly.entryId,
assembly.contentNodeId,
JSON.stringify(questionIds),
questionIds.length,
assembly.durationMinutes,
assembly.totalScore,
JSON.stringify({
...(body.metadata && typeof body.metadata === 'object' && !Array.isArray(body.metadata) ? body.metadata : {}),
assembly: {
sections: assembly.sections,
rules: assembly.rules,
},
}),
],
);
return { item };

View File

@@ -94,6 +94,8 @@ export async function assetsAdminRoute(ctx: RequestContext) {
const regionId = stringParam(ctx, 'regionId');
const subjectId = stringParam(ctx, 'subjectId');
const categoryId = stringParam(ctx, 'categoryId');
const entryId = stringParam(ctx, 'entryId');
const contentNodeId = stringParam(ctx, 'contentNodeId');
const params: unknown[] = [auth.tenantId];
const filters = ['tenant_id = $1'];
@@ -121,6 +123,14 @@ export async function assetsAdminRoute(ctx: RequestContext) {
params.push(categoryId);
filters.push(`category_id = $${params.length}`);
}
if (entryId) {
params.push(entryId);
filters.push(`entry_id = $${params.length}`);
}
if (contentNodeId) {
params.push(contentNodeId);
filters.push(`content_node_id = $${params.length}`);
}
params.push(limit);
const items = await query(
@@ -133,6 +143,7 @@ export async function assetsAdminRoute(ctx: RequestContext) {
file_size_bytes as "fileSizeBytes", checksum_sha256 as "checksumSha256",
visibility, is_public as "isPublic", region_id as "regionId",
subject_id as "subjectId", category_id as "categoryId", node_id as "nodeId",
entry_id as "entryId", content_node_id as "contentNodeId",
status, sort_order as "order", access_rules as "accessRules",
source, download_count as "downloadCount", metadata,
created_by as "createdBy", updated_by as "updatedBy",
@@ -164,6 +175,8 @@ export async function upsertAssetRoute(ctx: RequestContext) {
const subjectId = nullableUuid(body.subjectId);
const categoryId = nullableUuid(body.categoryId);
const nodeId = nullableUuid(body.nodeId);
const entryId = nullableUuid(body.entryId);
const contentNodeId = nullableUuid(body.contentNodeId);
if (status === 'active' && !cdnUrl && !objectKey) {
throw new HttpError(400, 'Active asset requires cdnUrl or objectKey', 'ASSET_LOCATION_REQUIRED');
@@ -173,6 +186,8 @@ export async function upsertAssetRoute(ctx: RequestContext) {
await assertOptionalReference(auth.tenantId, 'subjects', subjectId, 'SUBJECT_NOT_FOUND');
await assertOptionalReference(auth.tenantId, 'categories', categoryId, 'CATEGORY_NOT_FOUND');
await assertOptionalReference(auth.tenantId, 'module_nodes', nodeId, 'NODE_NOT_FOUND');
await assertOptionalReference(auth.tenantId, 'content_entries', entryId, 'ENTRY_NOT_FOUND');
await assertOptionalReference(auth.tenantId, 'content_nodes', contentNodeId, 'CONTENT_NODE_NOT_FOUND');
const item = await queryOne(
`
@@ -180,15 +195,17 @@ export async function upsertAssetRoute(ctx: RequestContext) {
id, tenant_id, legacy_id, asset_key, asset_type, storage_provider,
bucket, object_key, title, category, description, file_name, cdn_url,
preview_url, mime_type, file_size_bytes, checksum_sha256, visibility,
is_public, region_id, subject_id, category_id, node_id, status,
is_public, region_id, subject_id, category_id, node_id, entry_id,
content_node_id, status,
sort_order, access_rules, metadata, created_by, updated_by, source
)
values (
coalesce($1::uuid, gen_random_uuid()), $2, $3, $4, $5, $6,
$7, $8, $9, $10, $11, $12, $13,
$14, $15, $16, $17, $18,
$19, $20::uuid, $21::uuid, $22::uuid, $23::uuid, $24,
$25, $26::jsonb, $27::jsonb, $28, $28, $29
$19, $20::uuid, $21::uuid, $22::uuid, $23::uuid, $24::uuid,
$25::uuid, $26,
$27, $28::jsonb, $29::jsonb, $30, $30, $31
)
on conflict (id)
do update set legacy_id = excluded.legacy_id,
@@ -212,6 +229,8 @@ export async function upsertAssetRoute(ctx: RequestContext) {
subject_id = excluded.subject_id,
category_id = excluded.category_id,
node_id = excluded.node_id,
entry_id = excluded.entry_id,
content_node_id = excluded.content_node_id,
status = excluded.status,
sort_order = excluded.sort_order,
access_rules = excluded.access_rules,
@@ -228,6 +247,7 @@ export async function upsertAssetRoute(ctx: RequestContext) {
file_size_bytes as "fileSizeBytes", checksum_sha256 as "checksumSha256",
visibility, is_public as "isPublic", region_id as "regionId",
subject_id as "subjectId", category_id as "categoryId", node_id as "nodeId",
entry_id as "entryId", content_node_id as "contentNodeId",
status, sort_order as "order", access_rules as "accessRules",
source, download_count as "downloadCount", metadata,
created_by as "createdBy", updated_by as "updatedBy",
@@ -257,6 +277,8 @@ export async function upsertAssetRoute(ctx: RequestContext) {
subjectId,
categoryId,
nodeId,
entryId,
contentNodeId,
status,
intValue(body.order, 0),
jsonObjectValue(body.accessRules),

View File

@@ -33,6 +33,7 @@ interface NormalizedQuestion {
subQuestions: unknown[];
codeLang: string | null;
codeTemplate: string | null;
examMarkers: JsonObject;
sourceHash: string;
}
@@ -345,6 +346,7 @@ function normalizeQuestion(raw: unknown, rowNo: number) {
subQuestions,
codeLang: stringValue(source.codeLang ?? source.code_lang) || null,
codeTemplate: stringValue(source.codeTemplate ?? source.code_template) || null,
examMarkers: objectValue(source.examMarkers ?? source.exam_markers),
};
const normalized: NormalizedQuestion = {
@@ -361,6 +363,9 @@ async function assertTargetReferences(client: pg.PoolClient, auth: TenantContent
const nodeId = nullableString(body.nodeId);
const questionBankId = nullableString(body.questionBankId);
const regionId = nullableString(body.regionId);
const entryId = nullableString(body.entryId);
const contentNodeId = nullableString(body.contentNodeId);
const collectionId = nullableString(body.collectionId) || nullableString(body.primaryCollectionId);
const subject = await client.query(
'select id, region_id from public.subjects where tenant_id = $1 and id = $2 limit 1',
@@ -408,7 +413,37 @@ async function assertTargetReferences(client: pg.PoolClient, auth: TenantContent
}
}
return { subjectId, categoryId, nodeId, questionBankId, regionId };
if (entryId) {
const entry = await client.query(
'select id from public.content_entries where tenant_id = $1 and id = $2 limit 1',
[auth.tenantId, entryId],
);
if (!entry.rows[0]) {
throw new HttpError(400, 'entryId is not in this tenant', 'ENTRY_NOT_FOUND');
}
}
if (contentNodeId) {
const contentNode = await client.query(
'select id from public.content_nodes where tenant_id = $1 and id = $2 limit 1',
[auth.tenantId, contentNodeId],
);
if (!contentNode.rows[0]) {
throw new HttpError(400, 'contentNodeId is not in this tenant', 'CONTENT_NODE_NOT_FOUND');
}
}
if (collectionId) {
const collection = await client.query(
'select id from public.question_collections where tenant_id = $1 and id = $2 limit 1',
[auth.tenantId, collectionId],
);
if (!collection.rows[0]) {
throw new HttpError(400, 'collectionId is not in this tenant', 'QUESTION_COLLECTION_NOT_FOUND');
}
}
return { subjectId, categoryId, nodeId, questionBankId, regionId, entryId, contentNodeId, collectionId };
}
async function createQuestionPreviewJob(auth: TenantContentAuth, body: JsonObject): Promise<PreviewResult> {
@@ -448,6 +483,7 @@ async function createQuestionPreviewJob(auth: TenantContentAuth, body: JsonObjec
tenant_id, created_by, import_type, source_format, status,
source_name, source_hash, target_region_id, target_subject_id,
target_category_id, target_node_id, target_question_bank_id,
target_entry_id, target_content_node_id, target_collection_id,
dry_run, total_count, valid_count, error_count, warning_count,
summary, raw_payload, normalized_payload
)
@@ -455,8 +491,9 @@ async function createQuestionPreviewJob(auth: TenantContentAuth, body: JsonObjec
$1, $2, 'questions', $3, 'preview',
$4, $5, $6::uuid, $7::uuid,
$8::uuid, $9::uuid, $10::uuid,
true, $11, $12, $13, $14,
$15::jsonb, $16::jsonb, $17::jsonb
$11::uuid, $12::uuid, $13::uuid,
true, $14, $15, $16, $17,
$18::jsonb, $19::jsonb, $20::jsonb
)
returning id, status, total_count as "totalCount", valid_count as "validCount",
error_count as "errorCount", warning_count as "warningCount"
@@ -472,6 +509,9 @@ async function createQuestionPreviewJob(auth: TenantContentAuth, body: JsonObjec
target.categoryId,
target.nodeId,
target.questionBankId,
target.entryId,
target.contentNodeId,
target.collectionId,
rawItems.length,
validCount,
errorCount,
@@ -570,11 +610,15 @@ async function loadPreviewJob(client: pg.PoolClient, auth: TenantContentAuth, jo
target_category_id: string;
target_node_id: string | null;
target_question_bank_id: string | null;
target_entry_id: string | null;
target_content_node_id: string | null;
target_collection_id: string | null;
}>(
`
select id, status, total_count, valid_count, error_count, warning_count,
target_region_id, target_subject_id, target_category_id,
target_node_id, target_question_bank_id
target_node_id, target_question_bank_id,
target_entry_id, target_content_node_id, target_collection_id
from public.content_import_jobs
where tenant_id = $1 and id = $2 and import_type = 'questions'
limit 1
@@ -616,6 +660,9 @@ async function importOneQuestion(
target_category_id: string;
target_node_id: string | null;
target_question_bank_id: string | null;
target_entry_id: string | null;
target_content_node_id: string | null;
target_collection_id: string | null;
},
item: {
id: string;
@@ -637,9 +684,16 @@ async function importOneQuestion(
`
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, 'published',
$15::jsonb
)
values ($1, $2::uuid, $3::uuid, $4::uuid, $5::uuid, $6, $7, $8, $9, $10::jsonb, $11, 'published')
returning id
`,
[
@@ -648,12 +702,16 @@ async function importOneQuestion(
job.target_subject_id,
job.target_category_id,
job.target_node_id,
job.target_entry_id,
job.target_content_node_id,
job.target_collection_id,
legacyId,
normalized.type,
normalized.typeLabel,
normalized.difficulty,
JSON.stringify(normalized.tags),
normalized.mediaUrl,
JSON.stringify(normalized.examMarkers),
],
);
questionId = inserted.rows[0].id;
@@ -665,11 +723,15 @@ async function importOneQuestion(
subject_id = $4::uuid,
category_id = $5::uuid,
node_id = $6::uuid,
type = $7,
type_label = $8,
difficulty = $9,
tags = $10::jsonb,
media_url = $11,
entry_id = $7::uuid,
content_node_id = $8::uuid,
primary_collection_id = $9::uuid,
type = $10,
type_label = $11,
difficulty = $12,
tags = $13::jsonb,
media_url = $14,
exam_markers = $15::jsonb,
status = 'published',
updated_at = now()
where tenant_id = $1 and id = $2
@@ -681,15 +743,46 @@ async function importOneQuestion(
job.target_subject_id,
job.target_category_id,
job.target_node_id,
job.target_entry_id,
job.target_content_node_id,
job.target_collection_id,
normalized.type,
normalized.typeLabel,
normalized.difficulty,
JSON.stringify(normalized.tags),
normalized.mediaUrl,
JSON.stringify(normalized.examMarkers),
],
);
}
if (job.target_collection_id) {
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
`,
[auth.tenantId, job.target_collection_id, 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
`,
[auth.tenantId, job.target_collection_id],
);
}
const previousHash = existingQuestion ? await currentVersionHash(client, questionId) : null;
if (previousHash && previousHash === normalized.sourceHash) {
await client.query(

View File

@@ -11,6 +11,17 @@ import {
importQuestionsRoute,
previewQuestionsImportRoute,
} from './imports.js';
import {
contentEntriesAdminRoute,
contentNodesAdminRoute,
practiceBlueprintsAdminRoute,
questionCollectionsAdminRoute,
replaceQuestionCollectionItemsRoute,
upsertContentEntryRoute,
upsertContentNodeRoute,
upsertPracticeBlueprintRoute,
upsertQuestionCollectionRoute,
} from './navigation.js';
import {
bindQuestionVideoRoute,
createQuestionRoute,
@@ -38,6 +49,15 @@ import {
} from './routes.js';
export const tenantContentRoutes: RouteDefinition[] = [
['GET', '/api/tenant-content/content-entries', contentEntriesAdminRoute],
['PUT', '/api/tenant-content/content-entries', upsertContentEntryRoute],
['GET', '/api/tenant-content/content-nodes', contentNodesAdminRoute],
['PUT', '/api/tenant-content/content-nodes', upsertContentNodeRoute],
['GET', '/api/tenant-content/question-collections', questionCollectionsAdminRoute],
['PUT', '/api/tenant-content/question-collections', upsertQuestionCollectionRoute],
['PUT', '/api/tenant-content/question-collections/items', replaceQuestionCollectionItemsRoute],
['GET', '/api/tenant-content/practice-blueprints', practiceBlueprintsAdminRoute],
['PUT', '/api/tenant-content/practice-blueprints', upsertPracticeBlueprintRoute],
['POST', '/api/tenant-content/questions', createQuestionRoute],
['PATCH', '/api/tenant-content/questions', updateQuestionRoute],
['GET', '/api/tenant-content/assets', assetsAdminRoute],

View File

@@ -0,0 +1,754 @@
import { randomUUID } from 'node:crypto';
import type pg from 'pg';
import { HttpError, type RequestContext } from '../../core/http.js';
import { intParam, optionalString, readJsonBody, requiredString, stringParam } from '../../core/request.js';
import { query, queryOne, transaction } from '../../core/db.js';
import { requireTenantContentEditor, type TenantContentAuth } from './auth.js';
import { boolValue, intValue, jsonArrayValue, jsonObjectValue, nullableString } from './utils.js';
const ENTRY_TYPES = ['question_practice', 'vocabulary', 'handbook', 'scoreline', 'resource', 'ai_report', 'custom'];
const ENTRY_VISIBILITIES = ['public', 'members', 'svip', 'hidden'];
const NODE_TYPES = ['category', 'subject', 'chapter', 'paper', 'school', 'major', 'exam_target', 'resource_group', 'custom'];
const MARKER_TYPES = ['school', 'major', 'subject', 'exam_track', 'course_package', 'sales_intent', 'custom'];
const COLLECTION_TYPES = ['dynamic', 'manual', 'paper', 'chapter', 'mock_exam'];
const COLLECTION_SOURCE_TYPES = ['filters', 'manual_questions', 'node_descendants', 'category', 'subject', 'question_bank'];
const PRACTICE_MODES = ['sequential', 'random', 'mock_exam', 'paper', 'wrong_review', 'favorite_review'];
const ASSEMBLY_TYPES = ['collection', 'node_descendants', 'manual', 'filters'];
const STATUSES = ['draft', 'active', 'archived'];
type JsonObject = Record<string, unknown>;
function choice(value: unknown, allowed: string[], fallback: string, label: string) {
const candidate = nullableString(value) || fallback;
if (!allowed.includes(candidate)) {
throw new HttpError(400, `Invalid ${label}: ${candidate}`, 'INVALID_FIELD_VALUE');
}
return candidate;
}
function optionalChoice(value: unknown, allowed: string[], label: string) {
const candidate = nullableString(value);
if (!candidate) return null;
if (!allowed.includes(candidate)) {
throw new HttpError(400, `Invalid ${label}: ${candidate}`, 'INVALID_FIELD_VALUE');
}
return candidate;
}
function arrayFrom(value: unknown) {
return Array.isArray(value) ? value : [];
}
function uuidArray(value: unknown) {
return arrayFrom(value)
.map(item => (typeof item === 'string' ? item.trim() : ''))
.filter(Boolean);
}
function ltreeLabel(id: string) {
return `n_${id.replace(/-/g, '')}`;
}
async function assertOptionalTenantReference(
client: pg.PoolClient,
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 recordNavigationAudit(auth: TenantContentAuth, action: string, targetType: string, targetId: string | null, details: JsonObject) {
await query(
`
insert into public.audit_logs (tenant_id, actor_user_id, action, target_type, target_id, details)
values ($1, $2, $3, $4, $5, $6::jsonb)
`,
[auth.tenantId, auth.userId, action, targetType, targetId, JSON.stringify(details)],
);
}
export async function contentEntriesAdminRoute(ctx: RequestContext) {
const auth = await requireTenantContentEditor(ctx);
const regionId = stringParam(ctx, 'regionId');
const entryType = stringParam(ctx, 'entryType');
const params: unknown[] = [auth.tenantId];
const filters = ['tenant_id = $1'];
if (regionId) {
params.push(regionId);
filters.push(`region_id = $${params.length}`);
}
if (entryType) {
params.push(entryType);
filters.push(`entry_type = $${params.length}`);
}
const items = await query(
`
select id, region_id as "regionId", legacy_id as "legacyId",
entry_key as "entryKey", name, entry_type as "entryType",
icon, route, description, visibility, access_rules as "accessRules",
layout_config as "layoutConfig", sort_order as "order",
is_active as "isActive", created_by as "createdBy",
updated_by as "updatedBy", created_at as "createdAt",
updated_at as "updatedAt"
from public.content_entries
where ${filters.join(' and ')}
order by sort_order asc, created_at asc
`,
params,
);
return { items };
}
export async function upsertContentEntryRoute(ctx: RequestContext) {
const auth = await requireTenantContentEditor(ctx);
const body = await readJsonBody(ctx);
const name = requiredString(body, 'name');
const entryKey = optionalString(body, 'entryKey') || optionalString(body, 'key') || nullableString(body.id) || randomUUID();
const regionId = nullableString(body.regionId);
const entryType = choice(body.entryType, ENTRY_TYPES, 'question_practice', 'entryType');
const visibility = choice(body.visibility, ENTRY_VISIBILITIES, 'public', 'visibility');
const item = await transaction(async client => {
await assertOptionalTenantReference(client, auth.tenantId, 'regions', regionId, 'REGION_NOT_FOUND');
const result = await client.query(
`
insert into public.content_entries (
id, tenant_id, region_id, legacy_id, entry_key, name, entry_type,
icon, route, description, visibility, access_rules, layout_config,
sort_order, is_active, created_by, updated_by
)
values (
coalesce($1::uuid, gen_random_uuid()), $2, $3::uuid, $4, $5, $6, $7,
$8, $9, $10, $11, $12::jsonb, $13::jsonb,
$14, $15, $16, $16
)
on conflict (tenant_id, entry_key)
do update set region_id = excluded.region_id,
legacy_id = excluded.legacy_id,
name = excluded.name,
entry_type = excluded.entry_type,
icon = excluded.icon,
route = excluded.route,
description = excluded.description,
visibility = excluded.visibility,
access_rules = excluded.access_rules,
layout_config = excluded.layout_config,
sort_order = excluded.sort_order,
is_active = excluded.is_active,
updated_by = excluded.updated_by,
updated_at = now()
returning id, region_id as "regionId", legacy_id as "legacyId",
entry_key as "entryKey", name, entry_type as "entryType",
icon, route, description, visibility, access_rules as "accessRules",
layout_config as "layoutConfig", sort_order as "order",
is_active as "isActive", created_at as "createdAt",
updated_at as "updatedAt"
`,
[
nullableString(body.id),
auth.tenantId,
regionId,
nullableString(body.legacyId),
entryKey,
name,
entryType,
nullableString(body.icon),
nullableString(body.route),
nullableString(body.description),
visibility,
jsonObjectValue(body.accessRules),
jsonObjectValue(body.layoutConfig),
intValue(body.order, 0),
boolValue(body.isActive, true),
auth.userId,
],
);
return result.rows[0];
});
await recordNavigationAudit(auth, 'content.entry.upserted', 'content_entry', String(item.id), { name, entryKey, entryType });
return { item };
}
async function buildNodePath(client: pg.PoolClient, tenantId: string, entryId: string, nodeId: string, parentId: string | null) {
if (!parentId) return { path: ltreeLabel(nodeId), depth: 0, parentPath: null as string | null };
const parent = await client.query<{ path: string; depth: number }>(
`
select path::text as path, depth
from public.content_nodes
where tenant_id = $1 and entry_id = $2 and id = $3
limit 1
`,
[tenantId, entryId, parentId],
);
if (!parent.rows[0]) {
throw new HttpError(400, 'parentId is not in this entry', 'PARENT_NODE_NOT_FOUND');
}
return {
path: `${parent.rows[0].path}.${ltreeLabel(nodeId)}`,
depth: Number(parent.rows[0].depth || 0) + 1,
parentPath: parent.rows[0].path,
};
}
export async function contentNodesAdminRoute(ctx: RequestContext) {
const auth = await requireTenantContentEditor(ctx);
const entryId = stringParam(ctx, 'entryId');
const parentParam = ctx.url.searchParams.get('parentId');
const includeInactive = stringParam(ctx, 'includeInactive') === 'true';
const markerType = stringParam(ctx, 'markerType');
const mode = stringParam(ctx, 'mode') || 'children';
const params: unknown[] = [auth.tenantId];
const filters = ['tenant_id = $1'];
if (entryId) {
params.push(entryId);
filters.push(`entry_id = $${params.length}`);
}
if (parentParam !== null) {
if (parentParam === 'root') {
filters.push('parent_id is null');
} else {
params.push(parentParam);
filters.push(`parent_id = $${params.length}`);
}
}
if (!includeInactive) filters.push('is_active = true');
if (markerType) {
params.push(markerType);
filters.push(`marker_type = $${params.length}`);
}
const orderClause = mode === 'flat' ? 'coalesce(path::text, name) asc, sort_order asc' : 'sort_order asc, created_at asc';
const items = await query(
`
select id, entry_id as "entryId", region_id as "regionId",
parent_id as "parentId", legacy_id as "legacyId",
node_key as "nodeKey", name, node_type as "nodeType",
marker_type as "markerType", marker_config as "markerConfig",
path::text as path, depth, sort_order as "order",
is_active as "isActive", is_selectable as "isSelectable",
is_leaf as "isLeaf", metadata, created_by as "createdBy",
updated_by as "updatedBy", created_at as "createdAt",
updated_at as "updatedAt"
from public.content_nodes
where ${filters.join(' and ')}
order by ${orderClause}
`,
params,
);
return { items };
}
export async function upsertContentNodeRoute(ctx: RequestContext) {
const auth = await requireTenantContentEditor(ctx);
const body = await readJsonBody(ctx);
let nodeId = nullableString(body.id) || randomUUID();
const entryId = requiredString(body, 'entryId');
const parentId = nullableString(body.parentId);
const name = requiredString(body, 'name');
const nodeKey = nullableString(body.nodeKey) || nullableString(body.key);
const nodeType = choice(body.nodeType, NODE_TYPES, 'category', 'nodeType');
const markerType = optionalChoice(body.markerType, MARKER_TYPES, 'markerType');
const regionId = nullableString(body.regionId);
const item = await transaction(async client => {
await assertOptionalTenantReference(client, auth.tenantId, 'content_entries', entryId, 'ENTRY_NOT_FOUND');
await assertOptionalTenantReference(client, auth.tenantId, 'regions', regionId, 'REGION_NOT_FOUND');
if (!nullableString(body.id) && nodeKey) {
const keyedNode = await client.query<{ id: string }>(
`
select id
from public.content_nodes
where tenant_id = $1 and entry_id = $2 and node_key = $3
limit 1
for update
`,
[auth.tenantId, entryId, nodeKey],
);
nodeId = keyedNode.rows[0]?.id || nodeId;
}
const existing = await client.query<{ path: string | null; parent_id: string | null }>(
'select path::text as path, parent_id from public.content_nodes where tenant_id = $1 and id = $2 limit 1 for update',
[auth.tenantId, nodeId],
);
const previousPath = existing.rows[0]?.path || null;
const { path, depth } = await buildNodePath(client, auth.tenantId, entryId, nodeId, parentId);
const result = await client.query(
`
insert into public.content_nodes (
id, tenant_id, entry_id, region_id, parent_id, legacy_id, node_key,
name, node_type, marker_type, marker_config, path, depth, sort_order,
is_active, is_selectable, is_leaf, metadata, created_by, updated_by
)
values (
$1, $2, $3, $4::uuid, $5::uuid, $6, $7,
$8, $9, $10, $11::jsonb, $12::ltree, $13, $14,
$15, $16, $17, $18::jsonb, $19, $19
)
on conflict (id)
do update set entry_id = excluded.entry_id,
region_id = excluded.region_id,
parent_id = excluded.parent_id,
legacy_id = excluded.legacy_id,
node_key = excluded.node_key,
name = excluded.name,
node_type = excluded.node_type,
marker_type = excluded.marker_type,
marker_config = excluded.marker_config,
path = excluded.path,
depth = excluded.depth,
sort_order = excluded.sort_order,
is_active = excluded.is_active,
is_selectable = excluded.is_selectable,
is_leaf = excluded.is_leaf,
metadata = excluded.metadata,
updated_by = excluded.updated_by,
updated_at = now()
where public.content_nodes.tenant_id = excluded.tenant_id
returning id, entry_id as "entryId", region_id as "regionId",
parent_id as "parentId", legacy_id as "legacyId",
node_key as "nodeKey", name, node_type as "nodeType",
marker_type as "markerType", marker_config as "markerConfig",
path::text as path, depth, sort_order as "order",
is_active as "isActive", is_selectable as "isSelectable",
is_leaf as "isLeaf", metadata, created_at as "createdAt",
updated_at as "updatedAt"
`,
[
nodeId,
auth.tenantId,
entryId,
regionId,
parentId,
nullableString(body.legacyId),
nodeKey,
name,
nodeType,
markerType,
jsonObjectValue(body.markerConfig),
path,
depth,
intValue(body.order, 0),
boolValue(body.isActive, true),
boolValue(body.isSelectable, true),
boolValue(body.isLeaf, false),
jsonObjectValue(body.metadata),
auth.userId,
],
);
if (parentId) {
await client.query(
'update public.content_nodes set is_leaf = false, updated_at = now() where tenant_id = $1 and id = $2',
[auth.tenantId, parentId],
);
}
if (previousPath && previousPath !== path) {
await client.query(
`
update public.content_nodes
set path = $3::ltree || subpath(path, nlevel($2::ltree)),
depth = nlevel($3::ltree || subpath(path, nlevel($2::ltree))) - 1,
updated_at = now()
where tenant_id = $1
and id <> $4
and path <@ $2::ltree
`,
[auth.tenantId, previousPath, path, nodeId],
);
}
return result.rows[0];
});
await recordNavigationAudit(auth, 'content.node.upserted', 'content_node', String(item.id), { name, nodeType, markerType });
return { item };
}
export async function questionCollectionsAdminRoute(ctx: RequestContext) {
const auth = await requireTenantContentEditor(ctx);
const nodeId = stringParam(ctx, 'nodeId');
const entryId = stringParam(ctx, 'entryId');
const status = stringParam(ctx, 'status');
const limit = intParam(ctx, 'limit', 100, 500);
const params: unknown[] = [auth.tenantId];
const filters = ['tenant_id = $1'];
if (nodeId) {
params.push(nodeId);
filters.push(`node_id = $${params.length}`);
}
if (entryId) {
params.push(entryId);
filters.push(`entry_id = $${params.length}`);
}
if (status) {
params.push(status);
filters.push(`status = $${params.length}`);
}
params.push(limit);
const items = await query(
`
select id, region_id as "regionId", entry_id as "entryId",
node_id as "nodeId", subject_id as "subjectId",
category_id as "categoryId", question_bank_id as "questionBankId",
legacy_id as "legacyId", name, collection_type as "collectionType",
source_type as "sourceType", filters, question_count as "questionCount",
total_score as "totalScore", duration_minutes as "durationMinutes",
status, sort_order as "order", metadata,
created_by as "createdBy", updated_by as "updatedBy",
created_at as "createdAt", updated_at as "updatedAt"
from public.question_collections
where ${filters.join(' and ')}
order by sort_order asc, created_at asc
limit $${params.length}
`,
params,
);
return { items };
}
async function assertCollectionReferences(client: pg.PoolClient, auth: TenantContentAuth, body: JsonObject) {
const regionId = nullableString(body.regionId);
const entryId = nullableString(body.entryId);
const nodeId = nullableString(body.nodeId);
const subjectId = nullableString(body.subjectId);
const categoryId = nullableString(body.categoryId);
const questionBankId = nullableString(body.questionBankId);
await assertOptionalTenantReference(client, auth.tenantId, 'regions', regionId, 'REGION_NOT_FOUND');
await assertOptionalTenantReference(client, auth.tenantId, 'content_entries', entryId, 'ENTRY_NOT_FOUND');
await assertOptionalTenantReference(client, auth.tenantId, 'content_nodes', nodeId, 'CONTENT_NODE_NOT_FOUND');
await assertOptionalTenantReference(client, auth.tenantId, 'subjects', subjectId, 'SUBJECT_NOT_FOUND');
await assertOptionalTenantReference(client, auth.tenantId, 'categories', categoryId, 'CATEGORY_NOT_FOUND');
await assertOptionalTenantReference(client, auth.tenantId, 'question_banks', questionBankId, 'QUESTION_BANK_NOT_FOUND');
return { regionId, entryId, nodeId, subjectId, categoryId, questionBankId };
}
async function replaceCollectionItems(
client: pg.PoolClient,
auth: TenantContentAuth,
collectionId: string,
questions: unknown[],
) {
await client.query('delete from public.question_collection_items where tenant_id = $1 and collection_id = $2', [auth.tenantId, collectionId]);
let order = 0;
for (const raw of questions) {
const item = typeof raw === 'string' ? { questionId: raw } : raw && typeof raw === 'object' ? raw as JsonObject : {};
const questionId = nullableString(item.questionId ?? item.id);
if (!questionId) continue;
const question = await client.query<{ id: string }>(
'select id from public.questions where tenant_id = $1 and id = $2 limit 1',
[auth.tenantId, questionId],
);
if (!question.rows[0]) {
throw new HttpError(400, `Question ${questionId} is not in this tenant`, 'QUESTION_NOT_FOUND');
}
await client.query(
`
insert into public.question_collection_items (
tenant_id, collection_id, question_id, section_key, sort_order, score, required, metadata
)
values ($1, $2, $3, $4, $5, $6, $7, $8::jsonb)
on conflict (tenant_id, collection_id, question_id)
do update set section_key = excluded.section_key,
sort_order = excluded.sort_order,
score = excluded.score,
required = excluded.required,
metadata = excluded.metadata,
updated_at = now()
`,
[
auth.tenantId,
collectionId,
questionId,
nullableString(item.sectionKey),
item.order === undefined ? order : intValue(item.order, order),
item.score === undefined || item.score === null ? null : Number(item.score),
boolValue(item.required, true),
jsonObjectValue(item.metadata),
],
);
order += 1;
}
const count = await client.query<{ count: string }>(
'select count(*)::text as count from public.question_collection_items where tenant_id = $1 and collection_id = $2',
[auth.tenantId, collectionId],
);
const questionCount = Number(count.rows[0]?.count || 0);
await client.query(
'update public.question_collections set question_count = $3, updated_at = now() where tenant_id = $1 and id = $2',
[auth.tenantId, collectionId, questionCount],
);
return questionCount;
}
export async function upsertQuestionCollectionRoute(ctx: RequestContext) {
const auth = await requireTenantContentEditor(ctx);
const body = await readJsonBody(ctx);
const name = requiredString(body, 'name');
const collectionType = choice(body.collectionType, COLLECTION_TYPES, 'dynamic', 'collectionType');
const sourceType = choice(body.sourceType, COLLECTION_SOURCE_TYPES, collectionType === 'manual' ? 'manual_questions' : 'filters', 'sourceType');
const status = choice(body.status, STATUSES, 'active', 'status');
const item = await transaction(async client => {
const refs = await assertCollectionReferences(client, auth, body);
const result = await client.query(
`
insert into public.question_collections (
id, tenant_id, region_id, entry_id, node_id, subject_id,
category_id, question_bank_id, legacy_id, name, collection_type,
source_type, filters, question_count, total_score, duration_minutes,
status, sort_order, metadata, created_by, updated_by
)
values (
coalesce($1::uuid, gen_random_uuid()), $2, $3::uuid, $4::uuid, $5::uuid, $6::uuid,
$7::uuid, $8::uuid, $9, $10, $11,
$12, $13::jsonb, $14, $15, $16,
$17, $18, $19::jsonb, $20, $20
)
on conflict (id)
do update set region_id = excluded.region_id,
entry_id = excluded.entry_id,
node_id = excluded.node_id,
subject_id = excluded.subject_id,
category_id = excluded.category_id,
question_bank_id = excluded.question_bank_id,
legacy_id = excluded.legacy_id,
name = excluded.name,
collection_type = excluded.collection_type,
source_type = excluded.source_type,
filters = excluded.filters,
question_count = excluded.question_count,
total_score = excluded.total_score,
duration_minutes = excluded.duration_minutes,
status = excluded.status,
sort_order = excluded.sort_order,
metadata = excluded.metadata,
updated_by = excluded.updated_by,
updated_at = now()
where public.question_collections.tenant_id = excluded.tenant_id
returning id, region_id as "regionId", entry_id as "entryId",
node_id as "nodeId", subject_id as "subjectId",
category_id as "categoryId", question_bank_id as "questionBankId",
legacy_id as "legacyId", name, collection_type as "collectionType",
source_type as "sourceType", filters, question_count as "questionCount",
total_score as "totalScore", duration_minutes as "durationMinutes",
status, sort_order as "order", metadata,
created_at as "createdAt", updated_at as "updatedAt"
`,
[
nullableString(body.id),
auth.tenantId,
refs.regionId,
refs.entryId,
refs.nodeId,
refs.subjectId,
refs.categoryId,
refs.questionBankId,
nullableString(body.legacyId),
name,
collectionType,
sourceType,
jsonObjectValue(body.filters),
body.questionCount === undefined ? 0 : intValue(body.questionCount, 0),
body.totalScore === undefined || body.totalScore === null ? null : Number(body.totalScore),
body.durationMinutes === undefined ? null : intValue(body.durationMinutes, 0),
status,
intValue(body.order, 0),
jsonObjectValue(body.metadata),
auth.userId,
],
);
let collection = result.rows[0];
if (Array.isArray(body.questions) || Array.isArray(body.questionIds)) {
const questionCount = await replaceCollectionItems(client, auth, collection.id, Array.isArray(body.questions) ? body.questions : uuidArray(body.questionIds));
collection = { ...collection, questionCount };
}
return collection;
});
await recordNavigationAudit(auth, 'content.collection.upserted', 'question_collection', String(item.id), { name, collectionType, sourceType });
return { item };
}
export async function replaceQuestionCollectionItemsRoute(ctx: RequestContext) {
const auth = await requireTenantContentEditor(ctx);
const body = await readJsonBody(ctx);
const collectionId = requiredString(body, 'collectionId');
const questions = Array.isArray(body.questions) ? body.questions : uuidArray(body.questionIds);
const item = await transaction(async client => {
await assertOptionalTenantReference(client, auth.tenantId, 'question_collections', collectionId, 'QUESTION_COLLECTION_NOT_FOUND');
const questionCount = await replaceCollectionItems(client, auth, collectionId, questions);
return { collectionId, questionCount };
});
await recordNavigationAudit(auth, 'content.collection.items_replaced', 'question_collection', collectionId, { questionCount: item.questionCount });
return { item };
}
export async function practiceBlueprintsAdminRoute(ctx: RequestContext) {
const auth = await requireTenantContentEditor(ctx);
const nodeId = stringParam(ctx, 'nodeId');
const collectionId = stringParam(ctx, 'collectionId');
const mode = stringParam(ctx, 'mode');
const limit = intParam(ctx, 'limit', 100, 500);
const params: unknown[] = [auth.tenantId];
const filters = ['tenant_id = $1'];
if (nodeId) {
params.push(nodeId);
filters.push(`node_id = $${params.length}`);
}
if (collectionId) {
params.push(collectionId);
filters.push(`collection_id = $${params.length}`);
}
if (mode) {
params.push(mode);
filters.push(`mode = $${params.length}`);
}
params.push(limit);
const items = await query(
`
select id, region_id as "regionId", entry_id as "entryId",
node_id as "nodeId", collection_id as "collectionId",
legacy_id as "legacyId", name, mode,
assembly_type as "assemblyType", question_limit as "questionLimit",
duration_minutes as "durationMinutes", total_score as "totalScore",
pass_score as "passScore", sections, rules, status,
sort_order as "order", created_by as "createdBy",
updated_by as "updatedBy", created_at as "createdAt",
updated_at as "updatedAt"
from public.practice_blueprints
where ${filters.join(' and ')}
order by sort_order asc, created_at asc
limit $${params.length}
`,
params,
);
return { items };
}
export async function upsertPracticeBlueprintRoute(ctx: RequestContext) {
const auth = await requireTenantContentEditor(ctx);
const body = await readJsonBody(ctx);
const name = requiredString(body, 'name');
const mode = choice(body.mode, PRACTICE_MODES, 'sequential', 'mode');
const assemblyType = choice(body.assemblyType, ASSEMBLY_TYPES, 'collection', 'assemblyType');
const status = choice(body.status, STATUSES, 'active', 'status');
const item = await transaction(async client => {
const regionId = nullableString(body.regionId);
const entryId = nullableString(body.entryId);
const nodeId = nullableString(body.nodeId);
const collectionId = nullableString(body.collectionId);
await assertOptionalTenantReference(client, auth.tenantId, 'regions', regionId, 'REGION_NOT_FOUND');
await assertOptionalTenantReference(client, auth.tenantId, 'content_entries', entryId, 'ENTRY_NOT_FOUND');
await assertOptionalTenantReference(client, auth.tenantId, 'content_nodes', nodeId, 'CONTENT_NODE_NOT_FOUND');
await assertOptionalTenantReference(client, auth.tenantId, 'question_collections', collectionId, 'QUESTION_COLLECTION_NOT_FOUND');
const result = await client.query(
`
insert into public.practice_blueprints (
id, tenant_id, region_id, entry_id, node_id, collection_id,
legacy_id, name, mode, assembly_type, question_limit,
duration_minutes, total_score, pass_score, sections, rules,
status, sort_order, created_by, updated_by
)
values (
coalesce($1::uuid, gen_random_uuid()), $2, $3::uuid, $4::uuid, $5::uuid, $6::uuid,
$7, $8, $9, $10, $11,
$12, $13, $14, $15::jsonb, $16::jsonb,
$17, $18, $19, $19
)
on conflict (id)
do update set region_id = excluded.region_id,
entry_id = excluded.entry_id,
node_id = excluded.node_id,
collection_id = excluded.collection_id,
legacy_id = excluded.legacy_id,
name = excluded.name,
mode = excluded.mode,
assembly_type = excluded.assembly_type,
question_limit = excluded.question_limit,
duration_minutes = excluded.duration_minutes,
total_score = excluded.total_score,
pass_score = excluded.pass_score,
sections = excluded.sections,
rules = excluded.rules,
status = excluded.status,
sort_order = excluded.sort_order,
updated_by = excluded.updated_by,
updated_at = now()
where public.practice_blueprints.tenant_id = excluded.tenant_id
returning id, region_id as "regionId", entry_id as "entryId",
node_id as "nodeId", collection_id as "collectionId",
legacy_id as "legacyId", name, mode,
assembly_type as "assemblyType", question_limit as "questionLimit",
duration_minutes as "durationMinutes", total_score as "totalScore",
pass_score as "passScore", sections, rules, status,
sort_order as "order", created_at as "createdAt",
updated_at as "updatedAt"
`,
[
nullableString(body.id),
auth.tenantId,
regionId,
entryId,
nodeId,
collectionId,
nullableString(body.legacyId),
name,
mode,
assemblyType,
body.questionLimit === undefined ? null : intValue(body.questionLimit, 0),
body.durationMinutes === undefined ? null : intValue(body.durationMinutes, 0),
body.totalScore === undefined || body.totalScore === null ? null : Number(body.totalScore),
body.passScore === undefined || body.passScore === null ? null : Number(body.passScore),
jsonArrayValue(body.sections),
jsonObjectValue(body.rules),
status,
intValue(body.order, 0),
auth.userId,
],
);
return result.rows[0];
});
await recordNavigationAudit(auth, 'content.practice_blueprint.upserted', 'practice_blueprint', String(item.id), { name, mode });
return { item };
}

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;