Files
gongxue-base/apps/api/src/features/tenant-content/routes.ts

926 lines
36 KiB
TypeScript

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