Files
gongxue-base/apps/api/src/features/catalog/navigation.ts
2026-07-12 19:26:57 +08:00

251 lines
8.8 KiB
TypeScript

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 = await 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 = await 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", access_rules as "accessRules", 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 = await 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", access_rules as "accessRules", 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 = await 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, access_rules as "accessRules", 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 = await 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.tenant_id = q.tenant_id
and v.question_id = q.id
and 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 };
}