forked from wangziqi/gongxue-base
926 lines
36 KiB
TypeScript
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 };
|
|
}
|