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 }; }