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 } from './auth.js'; import { boolValue, intValue, jsonArrayValue, jsonObjectValue, nullableString, optionalStatus } from './utils.js'; 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, 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 ) returning id, tenant_id as "tenantId", question_bank_id as "questionBankId", subject_id as "subjectId", category_id as "categoryId", node_id as "nodeId", 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, questionBankId, subjectId, categoryId, nodeId, entryId, contentNodeId, primaryCollectionId, nullableString(body.legacyId), optionalString(body, 'type') || 'choice', optionalString(body, 'typeLabel') || null, intValue(body.difficulty, 1), 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( ` insert into public.question_versions ( tenant_id, question_id, version_no, content, options, correct_option_index, correct_option_indices, answer_text, explanation, sub_questions, code_lang, code_template, source_hash, created_by ) values ($1, $2, 1, $3, $4::jsonb, $5, $6::jsonb, $7, $8, $9::jsonb, $10, $11, $12, $13) returning id, version_no as "versionNo", content, options, correct_option_index as "correctOptionIndex", correct_option_indices as "correctOptionIndices", answer_text as "answerText", explanation, sub_questions as "subQuestions", code_lang as "codeLang", code_template as "codeTemplate", created_at as "createdAt" `, [ auth.tenantId, question.id, nullableString(body.content), jsonArrayValue(body.options), body.correctOptionIndex === undefined || body.correctOptionIndex === null ? null : intValue(body.correctOptionIndex, 0), jsonArrayValue(body.correctOptionIndices), nullableString(body.answerText), nullableString(body.explanation), jsonArrayValue(body.subQuestions), nullableString(body.codeLang), nullableString(body.codeTemplate), nullableString(body.sourceHash), auth.userId, ], ); await client.query( ` update public.questions set current_version_id = $3, updated_at = now() where tenant_id = $1 and id = $2 `, [auth.tenantId, question.id, versionResult.rows[0].id], ); return { ...question, currentVersion: versionResult.rows[0] }; }); return { item }; } export async function updateQuestionRoute(ctx: RequestContext) { const auth = await requireTenantContentEditor(ctx); const body = await readJsonBody(ctx); const questionId = requiredString(body, 'questionId'); 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 set question_bank_id = coalesce($3::uuid, question_bank_id), subject_id = coalesce($4::uuid, subject_id), category_id = coalesce($5::uuid, category_id), node_id = coalesce($6::uuid, node_id), 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", 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, questionBankId, subjectId, categoryId, nodeId, entryId, contentNodeId, primaryCollectionId, optionalString(body, 'type'), optionalString(body, 'typeLabel'), body.difficulty === undefined ? null : intValue(body.difficulty, 1), Object.hasOwn(body, 'tags'), 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; const latest = await client.query<{ version_no: number }>( 'select coalesce(max(version_no), 0) as version_no from public.question_versions where question_id = $1', [questionId], ); const nextVersionNo = Number(latest.rows[0]?.version_no || 0) + 1; const versionResult = await client.query( ` insert into public.question_versions ( tenant_id, question_id, version_no, content, options, correct_option_index, correct_option_indices, answer_text, explanation, sub_questions, code_lang, code_template, source_hash, created_by ) values ($1, $2, $3, $4, $5::jsonb, $6, $7::jsonb, $8, $9, $10::jsonb, $11, $12, $13, $14) returning id, version_no as "versionNo", content, options, correct_option_index as "correctOptionIndex", correct_option_indices as "correctOptionIndices", answer_text as "answerText", explanation, sub_questions as "subQuestions", code_lang as "codeLang", code_template as "codeTemplate", created_at as "createdAt" `, [ auth.tenantId, questionId, nextVersionNo, nullableString(body.content), jsonArrayValue(body.options), body.correctOptionIndex === undefined || body.correctOptionIndex === null ? null : intValue(body.correctOptionIndex, 0), jsonArrayValue(body.correctOptionIndices), nullableString(body.answerText), nullableString(body.explanation), jsonArrayValue(body.subQuestions), nullableString(body.codeLang), nullableString(body.codeTemplate), nullableString(body.sourceHash), auth.userId, ], ); await client.query( ` update public.questions set current_version_id = $3, updated_at = now() where tenant_id = $1 and id = $2 `, [auth.tenantId, questionId, versionResult.rows[0].id], ); return { ...question, currentVersion: versionResult.rows[0] }; }); return { item }; } export async function videosAdminRoute(ctx: RequestContext) { const auth = await requireTenantContentEditor(ctx); const subjectId = stringParam(ctx, 'subjectId'); const limit = intParam(ctx, 'limit', 100, 500); const items = await query( ` select id, legacy_id as "legacyId", title, description, video_url as "videoUrl", thumbnail_url as "thumbnailUrl", duration_seconds as "duration", knowledge_tags as "knowledgeTags", is_general as "isGeneral", subject_id as "subjectId", difficulty, sort_order as "order", is_active as "isActive", created_at as "createdAt", updated_at as "updatedAt" from public.video_explanations where tenant_id = $1 and ($2::uuid is null or subject_id = $2::uuid) order by sort_order asc, created_at desc limit $3 `, [auth.tenantId, subjectId || null, limit], ); return { items }; } export async function upsertVideoRoute(ctx: RequestContext) { const auth = await requireTenantContentEditor(ctx); const body = await readJsonBody(ctx); const id = nullableString(body.id); const title = requiredString(body, 'title'); const item = await queryOne( ` insert into public.video_explanations ( id, tenant_id, legacy_id, title, description, video_url, thumbnail_url, duration_seconds, knowledge_tags, is_general, subject_id, difficulty, sort_order, is_active ) values ( coalesce($1::uuid, gen_random_uuid()), $2, $3, $4, $5, $6, $7, $8, $9::jsonb, $10, $11::uuid, $12, $13, $14 ) on conflict (id) do update set title = excluded.title, description = excluded.description, video_url = excluded.video_url, thumbnail_url = excluded.thumbnail_url, duration_seconds = excluded.duration_seconds, knowledge_tags = excluded.knowledge_tags, is_general = excluded.is_general, subject_id = excluded.subject_id, difficulty = excluded.difficulty, sort_order = excluded.sort_order, is_active = excluded.is_active, updated_at = now() returning id, title, description, video_url as "videoUrl", thumbnail_url as "thumbnailUrl", duration_seconds as "duration", knowledge_tags as "knowledgeTags", is_general as "isGeneral", subject_id as "subjectId", difficulty, sort_order as "order", is_active as "isActive", updated_at as "updatedAt" `, [ id, auth.tenantId, nullableString(body.legacyId), title, nullableString(body.description), nullableString(body.videoUrl), nullableString(body.thumbnailUrl), body.duration === undefined ? null : intValue(body.duration, 0), jsonArrayValue(body.knowledgeTags), boolValue(body.isGeneral, false), nullableString(body.subjectId), body.difficulty === undefined ? null : intValue(body.difficulty, 1), intValue(body.order, 0), boolValue(body.isActive, true), ], ); return { item }; } export async function bindQuestionVideoRoute(ctx: RequestContext) { const auth = await requireTenantContentEditor(ctx); const body = await readJsonBody(ctx); const questionId = requiredString(body, 'questionId'); const videoId = requiredString(body, 'videoId'); const item = await transaction(async client => { const question = await client.query('select id from public.questions where tenant_id = $1 and id = $2 limit 1', [auth.tenantId, questionId]); if (!question.rows[0]) throw new HttpError(404, 'Question not found', 'QUESTION_NOT_FOUND'); const video = await client.query('select id from public.video_explanations where tenant_id = $1 and id = $2 limit 1', [auth.tenantId, videoId]); if (!video.rows[0]) throw new HttpError(404, 'Video not found', 'VIDEO_NOT_FOUND'); const result = await client.query( ` insert into public.question_videos ( tenant_id, question_id, video_id, legacy_id, video_type, sort_order ) values ($1, $2, $3, $4, $5, $6) returning id, question_id as "questionId", video_id as "videoId", video_type as "videoType", sort_order as "order", created_at as "createdAt" `, [ auth.tenantId, questionId, videoId, nullableString(body.legacyId), optionalString(body, 'videoType') || 'specific', intValue(body.order, 0), ], ); await client.query('update public.questions set has_video_explanation = true, updated_at = now() where tenant_id = $1 and id = $2', [ auth.tenantId, questionId, ]); return result.rows[0]; }); return { item }; } export async function scorelineSchoolsAdminRoute(ctx: RequestContext) { const auth = await requireTenantContentEditor(ctx); const regionId = stringParam(ctx, 'regionId'); const items = await query( ` select id, region_id as "regionId", name, short_name as "shortName", type, is_hot as "isHot", sort_order as "order", created_at as "createdAt", updated_at as "updatedAt" from public.scoreline_schools where tenant_id = $1 and ($2::uuid is null or region_id = $2::uuid) order by sort_order asc, name asc `, [auth.tenantId, regionId || null], ); return { items }; } export async function upsertScorelineSchoolRoute(ctx: RequestContext) { const auth = await requireTenantContentEditor(ctx); const body = await readJsonBody(ctx); const item = await queryOne( ` insert into public.scoreline_schools (id, tenant_id, region_id, legacy_id, name, short_name, type, is_hot, sort_order) values (coalesce($1::uuid, gen_random_uuid()), $2, $3::uuid, $4, $5, $6, $7, $8, $9) on conflict (id) do update set region_id = excluded.region_id, name = excluded.name, short_name = excluded.short_name, type = excluded.type, is_hot = excluded.is_hot, sort_order = excluded.sort_order, updated_at = now() returning id, region_id as "regionId", name, short_name as "shortName", type, is_hot as "isHot", sort_order as "order", updated_at as "updatedAt" `, [ nullableString(body.id), auth.tenantId, nullableString(body.regionId), nullableString(body.legacyId), requiredString(body, 'name'), nullableString(body.shortName), nullableString(body.type), boolValue(body.isHot, false), intValue(body.order, 0), ], ); return { item }; } export async function scorelineMajorsAdminRoute(ctx: RequestContext) { const auth = await requireTenantContentEditor(ctx); const schoolId = stringParam(ctx, 'schoolId'); const items = await query( ` select id, region_id as "regionId", school_id as "schoolId", name, sort_order as "order", has_restriction as "hasRestriction", restriction_desc as "restrictionDesc", created_at as "createdAt", updated_at as "updatedAt" from public.scoreline_majors where tenant_id = $1 and ($2::uuid is null or school_id = $2::uuid) order by sort_order asc, name asc `, [auth.tenantId, schoolId || null], ); return { items }; } export async function upsertScorelineMajorRoute(ctx: RequestContext) { const auth = await requireTenantContentEditor(ctx); const body = await readJsonBody(ctx); const item = await queryOne( ` insert into public.scoreline_majors ( id, tenant_id, region_id, school_id, legacy_id, name, sort_order, has_restriction, restriction_desc ) values (coalesce($1::uuid, gen_random_uuid()), $2, $3::uuid, $4::uuid, $5, $6, $7, $8, $9) on conflict (id) do update set region_id = excluded.region_id, school_id = excluded.school_id, name = excluded.name, sort_order = excluded.sort_order, has_restriction = excluded.has_restriction, restriction_desc = excluded.restriction_desc, updated_at = now() returning id, region_id as "regionId", school_id as "schoolId", name, sort_order as "order", has_restriction as "hasRestriction", restriction_desc as "restrictionDesc", updated_at as "updatedAt" `, [ nullableString(body.id), auth.tenantId, nullableString(body.regionId), requiredString(body, 'schoolId'), nullableString(body.legacyId), requiredString(body, 'name'), intValue(body.order, 0), boolValue(body.hasRestriction, false), nullableString(body.restrictionDesc), ], ); return { item }; } export async function scorelineFieldsAdminRoute(ctx: RequestContext) { const auth = await requireTenantContentEditor(ctx); const regionId = stringParam(ctx, 'regionId'); const items = await query( ` select id, region_id as "regionId", field_key as "fieldKey", field_name as "fieldName", field_type as "fieldType", unit, is_filter as "isFilter", is_required as "isRequired", is_visible as "isVisible", is_trend as "isTrend", options, placeholder, description, sort_order as "sortOrder" from public.scoreline_fields where tenant_id = $1 and ($2::uuid is null or region_id = $2::uuid) order by sort_order asc `, [auth.tenantId, regionId || null], ); return { items }; } export async function upsertScorelineFieldRoute(ctx: RequestContext) { const auth = await requireTenantContentEditor(ctx); const body = await readJsonBody(ctx); const item = await queryOne( ` insert into public.scoreline_fields ( id, tenant_id, region_id, legacy_id, field_key, field_name, field_type, unit, is_filter, is_required, is_visible, is_trend, options, placeholder, description, sort_order ) values ( coalesce($1::uuid, gen_random_uuid()), $2, $3::uuid, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13::jsonb, $14, $15, $16 ) on conflict (tenant_id, region_id, field_key) do update set field_name = excluded.field_name, field_type = excluded.field_type, unit = excluded.unit, is_filter = excluded.is_filter, is_required = excluded.is_required, is_visible = excluded.is_visible, is_trend = excluded.is_trend, options = excluded.options, placeholder = excluded.placeholder, description = excluded.description, sort_order = excluded.sort_order, updated_at = now() returning id, region_id as "regionId", field_key as "fieldKey", field_name as "fieldName", field_type as "fieldType", unit, is_filter as "isFilter", is_required as "isRequired", is_visible as "isVisible", is_trend as "isTrend", options, placeholder, description, sort_order as "sortOrder", updated_at as "updatedAt" `, [ nullableString(body.id), auth.tenantId, nullableString(body.regionId), nullableString(body.legacyId), requiredString(body, 'fieldKey'), requiredString(body, 'fieldName'), optionalString(body, 'fieldType') || 'number', nullableString(body.unit), boolValue(body.isFilter, false), boolValue(body.isRequired, false), boolValue(body.isVisible, true), boolValue(body.isTrend, false), jsonArrayValue(body.options), nullableString(body.placeholder), nullableString(body.description), intValue(body.sortOrder, 0), ], ); return { item }; } export async function scorelineRecordsAdminRoute(ctx: RequestContext) { const auth = await requireTenantContentEditor(ctx); const regionId = stringParam(ctx, 'regionId'); const limit = intParam(ctx, 'limit', 100, 500); const items = await query( ` select id, region_id as "regionId", school_id as "schoolId", major_id as "majorId", year, school_name as "schoolName", major_name as "majorName", field_values as "fieldValues", created_at as "createdAt", updated_at as "updatedAt" from public.scoreline_records where tenant_id = $1 and ($2::uuid is null or region_id = $2::uuid) order by year desc, school_name asc, major_name asc limit $3 `, [auth.tenantId, regionId || null, limit], ); return { items }; } export async function upsertScorelineRecordRoute(ctx: RequestContext) { const auth = await requireTenantContentEditor(ctx); const body = await readJsonBody(ctx); const item = await queryOne( ` insert into public.scoreline_records ( id, tenant_id, region_id, school_id, major_id, legacy_id, year, school_name, major_name, field_values ) values (coalesce($1::uuid, gen_random_uuid()), $2, $3::uuid, $4::uuid, $5::uuid, $6, $7, $8, $9, $10::jsonb) on conflict (id) do update set region_id = excluded.region_id, school_id = excluded.school_id, major_id = excluded.major_id, year = excluded.year, school_name = excluded.school_name, major_name = excluded.major_name, field_values = excluded.field_values, updated_at = now() returning id, region_id as "regionId", school_id as "schoolId", major_id as "majorId", year, school_name as "schoolName", major_name as "majorName", field_values as "fieldValues", updated_at as "updatedAt" `, [ nullableString(body.id), auth.tenantId, nullableString(body.regionId), nullableString(body.schoolId), nullableString(body.majorId), nullableString(body.legacyId), intValue(body.year, new Date().getFullYear()), nullableString(body.schoolName), nullableString(body.majorName), jsonObjectValue(body.fieldValues), ], ); return { item }; } export async function vocabularyUnitsAdminRoute(ctx: RequestContext) { const auth = await requireTenantContentEditor(ctx); const items = await query( ` select id, region_id as "regionId", name, description, word_count as "wordCount", sort_order as "order", is_active as "isActive", created_at as "createdAt", updated_at as "updatedAt" from public.vocabulary_units where tenant_id = $1 order by sort_order asc, created_at asc `, [auth.tenantId], ); return { items }; } export async function upsertVocabularyUnitRoute(ctx: RequestContext) { const auth = await requireTenantContentEditor(ctx); const body = await readJsonBody(ctx); const item = await queryOne( ` insert into public.vocabulary_units ( id, tenant_id, region_id, legacy_id, name, description, word_count, sort_order, is_active ) values (coalesce($1::uuid, gen_random_uuid()), $2, $3::uuid, $4, $5, $6, $7, $8, $9) on conflict (id) do update set region_id = excluded.region_id, name = excluded.name, description = excluded.description, word_count = excluded.word_count, sort_order = excluded.sort_order, is_active = excluded.is_active, updated_at = now() returning id, region_id as "regionId", name, description, word_count as "wordCount", sort_order as "order", is_active as "isActive", updated_at as "updatedAt" `, [ nullableString(body.id), auth.tenantId, nullableString(body.regionId), nullableString(body.legacyId), requiredString(body, 'name'), nullableString(body.description), body.wordCount === undefined ? null : intValue(body.wordCount, 0), intValue(body.order, 0), boolValue(body.isActive, true), ], ); return { item }; } export async function vocabularyWordsAdminRoute(ctx: RequestContext) { const auth = await requireTenantContentEditor(ctx); const unitId = stringParam(ctx, 'unitId'); const limit = intParam(ctx, 'limit', 500, 2000); const items = await query( ` select id, unit_id as "unitId", word, phonetic, meaning, example, example_translation as "exampleTranslation", difficulty, tags, sort_order as "order", is_active as "isActive", created_at as "createdAt", updated_at as "updatedAt" from public.vocabulary_words where tenant_id = $1 and ($2::uuid is null or unit_id = $2::uuid) order by sort_order asc, word asc limit $3 `, [auth.tenantId, unitId || null, limit], ); return { items }; } export async function upsertVocabularyWordRoute(ctx: RequestContext) { const auth = await requireTenantContentEditor(ctx); const body = await readJsonBody(ctx); const item = await queryOne( ` insert into public.vocabulary_words ( id, tenant_id, unit_id, legacy_id, word, phonetic, meaning, example, example_translation, difficulty, tags, sort_order, is_active ) values (coalesce($1::uuid, gen_random_uuid()), $2, $3::uuid, $4, $5, $6, $7, $8, $9, $10, $11::jsonb, $12, $13) on conflict (id) do update set unit_id = excluded.unit_id, word = excluded.word, phonetic = excluded.phonetic, meaning = excluded.meaning, example = excluded.example, example_translation = excluded.example_translation, difficulty = excluded.difficulty, tags = excluded.tags, sort_order = excluded.sort_order, is_active = excluded.is_active, updated_at = now() returning id, unit_id as "unitId", word, phonetic, meaning, example, example_translation as "exampleTranslation", difficulty, tags, sort_order as "order", is_active as "isActive", updated_at as "updatedAt" `, [ nullableString(body.id), auth.tenantId, nullableString(body.unitId), nullableString(body.legacyId), requiredString(body, 'word'), nullableString(body.phonetic), nullableString(body.meaning), nullableString(body.example), nullableString(body.exampleTranslation), body.difficulty === undefined ? null : intValue(body.difficulty, 1), jsonArrayValue(body.tags), intValue(body.order, 0), boolValue(body.isActive, true), ], ); return { item }; } export async function handbookSubjectsAdminRoute(ctx: RequestContext) { const auth = await requireTenantContentEditor(ctx); const items = await query( ` select id, region_id as "regionId", name, type, icon, color, description, sort_order as "order", is_active as "isActive", metadata from public.handbook_subjects where tenant_id = $1 order by sort_order asc, created_at asc `, [auth.tenantId], ); return { items }; } export async function upsertHandbookSubjectRoute(ctx: RequestContext) { const auth = await requireTenantContentEditor(ctx); const body = await readJsonBody(ctx); const item = await queryOne( ` insert into public.handbook_subjects ( id, tenant_id, region_id, legacy_id, name, type, icon, color, description, sort_order, is_active, metadata ) values (coalesce($1::uuid, gen_random_uuid()), $2, $3::uuid, $4, $5, $6, $7, $8, $9, $10, $11, $12::jsonb) on conflict (id) do update set region_id = excluded.region_id, name = excluded.name, type = excluded.type, icon = excluded.icon, color = excluded.color, description = excluded.description, sort_order = excluded.sort_order, is_active = excluded.is_active, metadata = excluded.metadata, updated_at = now() returning id, region_id as "regionId", name, type, icon, color, description, sort_order as "order", is_active as "isActive", metadata, updated_at as "updatedAt" `, [ nullableString(body.id), auth.tenantId, nullableString(body.regionId), nullableString(body.legacyId), requiredString(body, 'name'), nullableString(body.type), nullableString(body.icon), nullableString(body.color), nullableString(body.description), intValue(body.order, 0), boolValue(body.isActive, true), jsonObjectValue(body.metadata), ], ); return { item }; } export async function handbookChaptersAdminRoute(ctx: RequestContext) { const auth = await requireTenantContentEditor(ctx); const subjectId = stringParam(ctx, 'subjectId'); const items = await query( ` select id, subject_id as "subjectId", name, description, sort_order as "order", is_active as "isActive" from public.handbook_chapters where tenant_id = $1 and ($2::uuid is null or subject_id = $2::uuid) order by sort_order asc, created_at asc `, [auth.tenantId, subjectId || null], ); return { items }; } export async function upsertHandbookChapterRoute(ctx: RequestContext) { const auth = await requireTenantContentEditor(ctx); const body = await readJsonBody(ctx); const item = await queryOne( ` insert into public.handbook_chapters ( id, tenant_id, subject_id, legacy_id, name, description, sort_order, is_active ) values (coalesce($1::uuid, gen_random_uuid()), $2, $3::uuid, $4, $5, $6, $7, $8) on conflict (id) do update set subject_id = excluded.subject_id, name = excluded.name, description = excluded.description, sort_order = excluded.sort_order, is_active = excluded.is_active, updated_at = now() returning id, subject_id as "subjectId", name, description, sort_order as "order", is_active as "isActive", updated_at as "updatedAt" `, [ nullableString(body.id), auth.tenantId, requiredString(body, 'subjectId'), nullableString(body.legacyId), requiredString(body, 'name'), nullableString(body.description), intValue(body.order, 0), boolValue(body.isActive, true), ], ); return { item }; } export async function handbookEntriesAdminRoute(ctx: RequestContext) { const auth = await requireTenantContentEditor(ctx); const chapterId = stringParam(ctx, 'chapterId'); const items = await query( ` select id, chapter_id as "chapterId", title, summary, content, tags, sort_order as "order", is_active as "isActive" from public.handbook_entries where tenant_id = $1 and ($2::uuid is null or chapter_id = $2::uuid) order by sort_order asc, created_at asc `, [auth.tenantId, chapterId || null], ); return { items }; } export async function upsertHandbookEntryRoute(ctx: RequestContext) { const auth = await requireTenantContentEditor(ctx); const body = await readJsonBody(ctx); const item = await queryOne( ` insert into public.handbook_entries ( id, tenant_id, chapter_id, legacy_id, title, summary, content, tags, sort_order, is_active ) values (coalesce($1::uuid, gen_random_uuid()), $2, $3::uuid, $4, $5, $6, $7, $8::jsonb, $9, $10) on conflict (id) do update set chapter_id = excluded.chapter_id, title = excluded.title, summary = excluded.summary, content = excluded.content, tags = excluded.tags, sort_order = excluded.sort_order, is_active = excluded.is_active, updated_at = now() returning id, chapter_id as "chapterId", title, summary, content, tags, sort_order as "order", is_active as "isActive", updated_at as "updatedAt" `, [ nullableString(body.id), auth.tenantId, requiredString(body, 'chapterId'), nullableString(body.legacyId), requiredString(body, 'title'), nullableString(body.summary), nullableString(body.content), jsonArrayValue(body.tags), intValue(body.order, 0), boolValue(body.isActive, true), ], ); return { item }; }