feat: scaffold supabase multi-tenant backend

This commit is contained in:
Codex
2026-06-21 21:54:43 +08:00
commit c9c767c7bd
99 changed files with 36660 additions and 0 deletions

View File

@@ -0,0 +1,354 @@
import { randomUUID } from 'node:crypto';
import { HttpError, type RequestContext } from '../../core/http.js';
import { intParam, readJsonBody, requiredString, stringParam } from '../../core/request.js';
import { query, queryOne } from '../../core/db.js';
import { requireTenantContentEditor, type TenantContentAuth } from './auth.js';
import { boolValue, intValue, jsonObjectValue, nullableString } from './utils.js';
const ASSET_TYPES = ['pdf', 'video', 'image', 'audio', 'document', 'package', 'link', 'other'];
const STORAGE_PROVIDERS = ['external_url', 'supabase_storage', 'aliyun_oss', 'tencent_cos', 'qiniu_kodo', 'local_dev'];
const VISIBILITIES = ['public', 'tenant', 'members', 'svip', 'private'];
const ASSET_STATUSES = ['draft', 'active', 'archived'];
interface AssetRow {
id: string;
tenantId: string;
assetType: string;
storageProvider: string;
bucket: string | null;
objectKey: string | null;
title: string | null;
fileName: string | null;
cdnUrl: string | null;
previewUrl: string | null;
visibility: string;
status: string;
}
function choice(value: unknown, allowed: string[], fallback: string, label: string) {
const candidate = nullableString(value) || fallback;
if (!allowed.includes(candidate)) {
throw new HttpError(400, `Invalid ${label}: ${candidate}`, 'INVALID_FIELD_VALUE');
}
return candidate;
}
function nullableUuid(value: unknown) {
return nullableString(value);
}
function safeFileName(fileName: string) {
return fileName
.trim()
.replace(/[\\/:*?"<>|]+/g, '-')
.replace(/\s+/g, '-')
.slice(0, 160) || 'asset';
}
function placeholderSignedUrl(asset: AssetRow, expiresInSec: number) {
const expiresAt = new Date(Date.now() + expiresInSec * 1000).toISOString();
if (asset.cdnUrl) {
return {
provider: asset.storageProvider,
url: asset.cdnUrl,
expiresAt,
signatureMode: 'public-or-provider-managed',
};
}
return {
provider: asset.storageProvider,
url: `${asset.storageProvider}://${asset.bucket || 'default'}/${asset.objectKey || asset.id}?expiresAt=${encodeURIComponent(expiresAt)}`,
expiresAt,
signatureMode: 'local-placeholder',
};
}
async function assertOptionalReference(tenantId: string, table: string, id: string | null, code: string) {
if (!id) return;
const row = await queryOne<{ id: string }>(
`select id from public.${table} where tenant_id = $1 and id = $2 limit 1`,
[tenantId, id],
);
if (!row) {
throw new HttpError(400, `${table} reference is not in this tenant`, code);
}
}
async function recordAssetAudit(auth: TenantContentAuth, action: string, targetId: string | null, details: Record<string, unknown>) {
await query(
`
insert into public.audit_logs (tenant_id, actor_user_id, action, target_type, target_id, details)
values ($1, $2, $3, 'content_asset', $4, $5::jsonb)
`,
[auth.tenantId, auth.userId, action, targetId, JSON.stringify(details)],
);
}
export async function assetsAdminRoute(ctx: RequestContext) {
const auth = await requireTenantContentEditor(ctx);
const limit = intParam(ctx, 'limit', 100, 500);
const assetType = stringParam(ctx, 'assetType');
const status = stringParam(ctx, 'status');
const visibility = stringParam(ctx, 'visibility');
const regionId = stringParam(ctx, 'regionId');
const subjectId = stringParam(ctx, 'subjectId');
const categoryId = stringParam(ctx, 'categoryId');
const params: unknown[] = [auth.tenantId];
const filters = ['tenant_id = $1'];
if (assetType) {
params.push(assetType);
filters.push(`asset_type = $${params.length}`);
}
if (status) {
params.push(status);
filters.push(`status = $${params.length}`);
}
if (visibility) {
params.push(visibility);
filters.push(`visibility = $${params.length}`);
}
if (regionId) {
params.push(regionId);
filters.push(`region_id = $${params.length}`);
}
if (subjectId) {
params.push(subjectId);
filters.push(`subject_id = $${params.length}`);
}
if (categoryId) {
params.push(categoryId);
filters.push(`category_id = $${params.length}`);
}
params.push(limit);
const items = await query(
`
select id, legacy_id as "legacyId", asset_key as "assetKey",
asset_type as "assetType", storage_provider as "storageProvider",
bucket, object_key as "objectKey", title, category as "categoryLabel",
description, file_name as "fileName", cdn_url as "cdnUrl",
preview_url as "previewUrl", mime_type as "mimeType",
file_size_bytes as "fileSizeBytes", checksum_sha256 as "checksumSha256",
visibility, is_public as "isPublic", region_id as "regionId",
subject_id as "subjectId", category_id as "categoryId", node_id as "nodeId",
status, sort_order as "order", access_rules as "accessRules",
source, download_count as "downloadCount", metadata,
created_by as "createdBy", updated_by as "updatedBy",
created_at as "createdAt", updated_at as "updatedAt"
from public.content_assets
where ${filters.join(' and ')}
order by sort_order asc, created_at desc
limit $${params.length}
`,
params,
);
return { items };
}
export async function upsertAssetRoute(ctx: RequestContext) {
const auth = await requireTenantContentEditor(ctx);
const body = await readJsonBody(ctx);
const assetType = choice(body.assetType, ASSET_TYPES, 'document', 'assetType');
const storageProvider = choice(body.storageProvider, STORAGE_PROVIDERS, nullableString(body.cdnUrl) ? 'external_url' : 'local_dev', 'storageProvider');
const visibility = choice(body.visibility, VISIBILITIES, boolValue(body.isPublic, false) ? 'public' : 'tenant', 'visibility');
const status = choice(body.status, ASSET_STATUSES, 'active', 'status');
const bucket = nullableString(body.bucket);
const objectKey = nullableString(body.objectKey);
const cdnUrl = nullableString(body.cdnUrl);
const title = requiredString(body, 'title');
const regionId = nullableUuid(body.regionId);
const subjectId = nullableUuid(body.subjectId);
const categoryId = nullableUuid(body.categoryId);
const nodeId = nullableUuid(body.nodeId);
if (status === 'active' && !cdnUrl && !objectKey) {
throw new HttpError(400, 'Active asset requires cdnUrl or objectKey', 'ASSET_LOCATION_REQUIRED');
}
await assertOptionalReference(auth.tenantId, 'regions', regionId, 'REGION_NOT_FOUND');
await assertOptionalReference(auth.tenantId, 'subjects', subjectId, 'SUBJECT_NOT_FOUND');
await assertOptionalReference(auth.tenantId, 'categories', categoryId, 'CATEGORY_NOT_FOUND');
await assertOptionalReference(auth.tenantId, 'module_nodes', nodeId, 'NODE_NOT_FOUND');
const item = await queryOne(
`
insert into public.content_assets (
id, tenant_id, legacy_id, asset_key, asset_type, storage_provider,
bucket, object_key, title, category, description, file_name, cdn_url,
preview_url, mime_type, file_size_bytes, checksum_sha256, visibility,
is_public, region_id, subject_id, category_id, node_id, status,
sort_order, access_rules, metadata, created_by, updated_by, source
)
values (
coalesce($1::uuid, gen_random_uuid()), $2, $3, $4, $5, $6,
$7, $8, $9, $10, $11, $12, $13,
$14, $15, $16, $17, $18,
$19, $20::uuid, $21::uuid, $22::uuid, $23::uuid, $24,
$25, $26::jsonb, $27::jsonb, $28, $28, $29
)
on conflict (id)
do update set legacy_id = excluded.legacy_id,
asset_key = excluded.asset_key,
asset_type = excluded.asset_type,
storage_provider = excluded.storage_provider,
bucket = excluded.bucket,
object_key = excluded.object_key,
title = excluded.title,
category = excluded.category,
description = excluded.description,
file_name = excluded.file_name,
cdn_url = excluded.cdn_url,
preview_url = excluded.preview_url,
mime_type = excluded.mime_type,
file_size_bytes = excluded.file_size_bytes,
checksum_sha256 = excluded.checksum_sha256,
visibility = excluded.visibility,
is_public = excluded.is_public,
region_id = excluded.region_id,
subject_id = excluded.subject_id,
category_id = excluded.category_id,
node_id = excluded.node_id,
status = excluded.status,
sort_order = excluded.sort_order,
access_rules = excluded.access_rules,
metadata = excluded.metadata,
updated_by = excluded.updated_by,
source = excluded.source,
updated_at = now()
where public.content_assets.tenant_id = excluded.tenant_id
returning id, legacy_id as "legacyId", asset_key as "assetKey",
asset_type as "assetType", storage_provider as "storageProvider",
bucket, object_key as "objectKey", title, category as "categoryLabel",
description, file_name as "fileName", cdn_url as "cdnUrl",
preview_url as "previewUrl", mime_type as "mimeType",
file_size_bytes as "fileSizeBytes", checksum_sha256 as "checksumSha256",
visibility, is_public as "isPublic", region_id as "regionId",
subject_id as "subjectId", category_id as "categoryId", node_id as "nodeId",
status, sort_order as "order", access_rules as "accessRules",
source, download_count as "downloadCount", metadata,
created_by as "createdBy", updated_by as "updatedBy",
created_at as "createdAt", updated_at as "updatedAt"
`,
[
nullableString(body.id),
auth.tenantId,
nullableString(body.legacyId),
nullableString(body.assetKey),
assetType,
storageProvider,
bucket,
objectKey,
title,
nullableString(body.categoryLabel) || nullableString(body.category),
nullableString(body.description),
nullableString(body.fileName),
cdnUrl,
nullableString(body.previewUrl),
nullableString(body.mimeType),
body.fileSizeBytes === undefined ? null : intValue(body.fileSizeBytes, 0),
nullableString(body.checksumSha256),
visibility,
visibility === 'public',
regionId,
subjectId,
categoryId,
nodeId,
status,
intValue(body.order, 0),
jsonObjectValue(body.accessRules),
jsonObjectValue(body.metadata),
auth.userId,
nullableString(body.source) || 'manual',
],
);
if (!item) {
throw new HttpError(404, 'Asset not found in this tenant', 'ASSET_NOT_FOUND');
}
await recordAssetAudit(auth, 'content.asset.upserted', String((item as { id?: string } | null)?.id || ''), {
title,
assetType,
visibility,
status,
});
return { item };
}
export async function signAssetUploadRoute(ctx: RequestContext) {
const auth = await requireTenantContentEditor(ctx);
const body = await readJsonBody(ctx);
const fileName = requiredString(body, 'fileName');
const assetType = choice(body.assetType, ASSET_TYPES, 'document', 'assetType');
const storageProvider = choice(body.storageProvider, STORAGE_PROVIDERS, 'local_dev', 'storageProvider');
const bucket = nullableString(body.bucket) || 'tenant-assets';
const objectKey =
nullableString(body.objectKey) ||
`${auth.tenantId}/${assetType}/${Date.now()}-${randomUUID()}-${safeFileName(fileName)}`;
const expiresInSec = Math.min(Math.max(intValue(body.expiresInSec, 900), 60), 3600);
const expiresAt = new Date(Date.now() + expiresInSec * 1000).toISOString();
return {
upload: {
provider: storageProvider,
bucket,
objectKey,
method: 'PUT',
url: `${storageProvider}://${bucket}/${objectKey}?expiresAt=${encodeURIComponent(expiresAt)}`,
headers: {
'content-type': nullableString(body.mimeType) || 'application/octet-stream',
},
expiresAt,
signatureMode: 'local-placeholder',
},
assetDraft: {
assetType,
storageProvider,
bucket,
objectKey,
fileName,
mimeType: nullableString(body.mimeType),
fileSizeBytes: body.fileSizeBytes === undefined ? null : intValue(body.fileSizeBytes, 0),
checksumSha256: nullableString(body.checksumSha256),
},
};
}
export async function signAssetDownloadAdminRoute(ctx: RequestContext) {
const auth = await requireTenantContentEditor(ctx);
const body = await readJsonBody(ctx);
const assetId = requiredString(body, 'assetId');
const expiresInSec = Math.min(Math.max(intValue(body.expiresInSec, 900), 60), 86_400);
const asset = await queryOne<AssetRow>(
`
select id, tenant_id as "tenantId", asset_type as "assetType",
storage_provider as "storageProvider", bucket, object_key as "objectKey",
title, file_name as "fileName", cdn_url as "cdnUrl",
preview_url as "previewUrl", visibility, status
from public.content_assets
where tenant_id = $1 and id = $2
limit 1
`,
[auth.tenantId, assetId],
);
if (!asset) {
throw new HttpError(404, 'Asset not found', 'ASSET_NOT_FOUND');
}
await query(
'update public.content_assets set download_count = download_count + 1, updated_at = now() where tenant_id = $1 and id = $2',
[auth.tenantId, assetId],
);
return {
item: asset,
download: placeholderSignedUrl(asset, expiresInSec),
};
}

View File

@@ -0,0 +1,43 @@
import { HttpError, type RequestContext } from '../../core/http.js';
import { queryOne } from '../../core/db.js';
import { tenantIdFrom, userIdFrom } from '../../core/request.js';
const CONTENT_ROLES = new Set(['tenant_owner', 'tenant_admin', 'tenant_operator', 'teacher']);
export interface TenantContentAuth {
tenantId: string;
userId: string;
role: string;
permissions: Record<string, unknown>;
}
export async function requireTenantContentEditor(ctx: RequestContext): Promise<TenantContentAuth> {
const tenantId = tenantIdFrom(ctx);
const userId = userIdFrom(ctx);
const membership = await queryOne<{ role: string; permissions: Record<string, unknown> }>(
`
select role, permissions
from public.tenant_memberships
where tenant_id = $1
and user_id = $2
and status = 'active'
and role = any($3::text[])
order by case role
when 'tenant_owner' then 1
when 'tenant_admin' then 2
when 'tenant_operator' then 3
when 'teacher' then 4
else 9
end
limit 1
`,
[tenantId, userId, Array.from(CONTENT_ROLES)],
);
if (!membership) {
throw new HttpError(403, 'Tenant content editor access is required', 'TENANT_CONTENT_EDITOR_REQUIRED');
}
return { tenantId, userId, role: membership.role, permissions: membership.permissions || {} };
}

View File

@@ -0,0 +1,951 @@
import { createHash } from 'node:crypto';
import type pg from 'pg';
import { HttpError, type RequestContext } from '../../core/http.js';
import { intParam, readJsonBody, requiredString, stringParam } from '../../core/request.js';
import { query, queryOne, transaction } from '../../core/db.js';
import { requireTenantContentEditor, type TenantContentAuth } from './auth.js';
import { boolValue, intValue, nullableString } from './utils.js';
type JsonObject = Record<string, unknown>;
interface ImportIssue {
rowNo: number;
severity: 'error' | 'warning';
code: string;
fieldPath: string | null;
message: string;
details?: JsonObject;
}
interface NormalizedQuestion {
legacyId: string | null;
type: string;
typeLabel: string | null;
content: string;
options: unknown[];
correctOptionIndex: number | null;
correctOptionIndices: number[];
answerText: string | null;
explanation: string | null;
difficulty: number;
tags: string[];
mediaUrl: string | null;
subQuestions: unknown[];
codeLang: string | null;
codeTemplate: string | null;
sourceHash: string;
}
interface PreviewResult {
job: {
id: string;
status: string;
totalCount: number;
validCount: number;
errorCount: number;
warningCount: number;
};
items: Array<{
rowNo: number;
status: 'valid' | 'invalid';
externalId: string | null;
normalized: NormalizedQuestion | null;
issues: ImportIssue[];
}>;
issues: ImportIssue[];
}
const OBJECTIVE_TYPES = new Set(['choice', 'multi', 'judge', 'image']);
const READING_TYPE = 'reading';
const KNOWN_TYPES = new Set([
...OBJECTIVE_TYPES,
READING_TYPE,
'text',
'terms',
'short_answer',
'composition',
'discuss',
'translation',
'case_analysis',
'brief_analysis',
'calculation',
'analysis_design',
'combination',
'solution',
]);
function objectValue(value: unknown): JsonObject {
return value && typeof value === 'object' && !Array.isArray(value) ? value as JsonObject : {};
}
function stringValue(value: unknown) {
return typeof value === 'string' && value.trim() ? value.trim() : '';
}
function stringArrayValue(value: unknown) {
if (!Array.isArray(value)) return [];
return value.map(item => String(item).trim()).filter(Boolean);
}
function numberArrayValue(value: unknown) {
if (!Array.isArray(value)) return [];
return value
.map(item => Number(item))
.filter(Number.isFinite)
.map(item => Math.trunc(item));
}
function parseQuestionItems(body: JsonObject) {
const source = body.items ?? body.questions ?? body.payload;
let parsed: unknown = source;
if (typeof source === 'string') {
try {
parsed = JSON.parse(source);
} catch {
throw new HttpError(400, 'payload must be a valid JSON array string', 'INVALID_IMPORT_PAYLOAD');
}
}
if (!Array.isArray(parsed)) {
throw new HttpError(400, 'items/questions/payload must be a JSON array', 'INVALID_IMPORT_PAYLOAD');
}
if (parsed.length === 0) {
throw new HttpError(400, 'Import payload must contain at least one item', 'EMPTY_IMPORT_PAYLOAD');
}
if (parsed.length > 2000) {
throw new HttpError(400, 'A single import job can contain at most 2000 items', 'IMPORT_TOO_LARGE');
}
return parsed;
}
function contentHash(value: unknown) {
return createHash('sha256').update(JSON.stringify(value)).digest('hex');
}
function normalizeDifficulty(value: unknown, issues: ImportIssue[], rowNo: number) {
if (value === undefined || value === null || value === '') return 1;
const parsed = Number(value);
if (!Number.isFinite(parsed)) {
issues.push({
rowNo,
severity: 'error',
code: 'INVALID_DIFFICULTY',
fieldPath: 'difficulty',
message: 'difficulty must be a number from 1 to 5',
});
return 1;
}
const difficulty = Math.trunc(parsed);
if (difficulty < 1 || difficulty > 5) {
issues.push({
rowNo,
severity: 'warning',
code: 'DIFFICULTY_OUT_OF_RANGE',
fieldPath: 'difficulty',
message: 'difficulty is outside the recommended 1-5 range and was clamped',
details: { original: value },
});
}
return Math.min(5, Math.max(1, difficulty));
}
function normalizeOptions(value: unknown, issues: ImportIssue[], rowNo: number, fieldPath = 'options') {
if (!Array.isArray(value)) return [];
const normalized = value.filter(item => {
if (typeof item === 'string') return item.trim() !== '';
return item && typeof item === 'object';
});
if (normalized.length !== value.length) {
issues.push({
rowNo,
severity: 'warning',
code: 'EMPTY_OPTIONS_REMOVED',
fieldPath,
message: 'empty options were removed during normalization',
});
}
return normalized;
}
function normalizeSubQuestions(value: unknown, issues: ImportIssue[], rowNo: number) {
if (!Array.isArray(value)) return [];
return value.map((raw, index) => {
const sub = objectValue(raw);
const fieldPrefix = `sub_questions[${index}]`;
const type = stringValue(sub.type) || 'choice';
const content = stringValue(sub.content);
const options = normalizeOptions(sub.options, issues, rowNo, `${fieldPrefix}.options`);
const correctOptionIndices = numberArrayValue(sub.correctOptionIndices ?? sub.correct_option_indices);
const answerText = stringValue(sub.answerText ?? sub.answer_text);
if (!content) {
issues.push({
rowNo,
severity: 'error',
code: 'SUB_QUESTION_CONTENT_REQUIRED',
fieldPath: `${fieldPrefix}.content`,
message: 'sub question content is required',
});
}
if (OBJECTIVE_TYPES.has(type)) {
validateObjectiveAnswer(options, correctOptionIndices, issues, rowNo, fieldPrefix);
} else if (!answerText) {
issues.push({
rowNo,
severity: 'error',
code: 'SUB_QUESTION_ANSWER_REQUIRED',
fieldPath: `${fieldPrefix}.answerText`,
message: 'subjective sub question requires answerText',
});
}
return {
type,
typeLabel: stringValue(sub.typeLabel ?? sub.type_label) || null,
content,
options,
correctOptionIndices,
answerText: answerText || null,
explanation: stringValue(sub.explanation) || null,
};
});
}
function validateObjectiveAnswer(
options: unknown[],
correctOptionIndices: number[],
issues: ImportIssue[],
rowNo: number,
fieldPrefix = '',
) {
const prefix = fieldPrefix ? `${fieldPrefix}.` : '';
if (options.length < 2) {
issues.push({
rowNo,
severity: 'error',
code: 'OPTIONS_REQUIRED',
fieldPath: `${prefix}options`,
message: 'objective question requires at least two options',
});
}
if (correctOptionIndices.length === 0) {
issues.push({
rowNo,
severity: 'error',
code: 'CORRECT_OPTION_REQUIRED',
fieldPath: `${prefix}correctOptionIndices`,
message: 'objective question requires correctOptionIndices',
});
}
for (const index of correctOptionIndices) {
if (index < 0 || index >= options.length) {
issues.push({
rowNo,
severity: 'error',
code: 'CORRECT_OPTION_OUT_OF_RANGE',
fieldPath: `${prefix}correctOptionIndices`,
message: 'correct option index is outside the options range',
details: { index, optionsCount: options.length },
});
}
}
}
function normalizeQuestion(raw: unknown, rowNo: number) {
const issues: ImportIssue[] = [];
const source = objectValue(raw);
if (!source || Object.keys(source).length === 0) {
issues.push({
rowNo,
severity: 'error',
code: 'ROW_OBJECT_REQUIRED',
fieldPath: null,
message: 'each import row must be an object',
});
return { normalized: null, issues };
}
const subQuestions = normalizeSubQuestions(source.sub_questions ?? source.subQuestions, issues, rowNo);
let type = stringValue(source.type) || (subQuestions.length ? READING_TYPE : 'choice');
if (subQuestions.length) type = READING_TYPE;
if (!KNOWN_TYPES.has(type)) {
issues.push({
rowNo,
severity: 'warning',
code: 'UNKNOWN_QUESTION_TYPE',
fieldPath: 'type',
message: 'unknown question type was kept for compatibility',
details: { type },
});
}
const content = stringValue(source.content);
if (!content) {
issues.push({
rowNo,
severity: 'error',
code: 'CONTENT_REQUIRED',
fieldPath: 'content',
message: 'question content is required',
});
}
const options = normalizeOptions(source.options, issues, rowNo);
const legacyCorrectIndex = source.correctOptionIndex ?? source.correct_option_index;
const correctOptionIndices = numberArrayValue(source.correctOptionIndices ?? source.correct_option_indices);
if (correctOptionIndices.length === 0 && legacyCorrectIndex !== undefined && legacyCorrectIndex !== null && legacyCorrectIndex !== '') {
const parsed = Number(legacyCorrectIndex);
if (Number.isFinite(parsed)) correctOptionIndices.push(Math.trunc(parsed));
}
const answerText = stringValue(source.answerText ?? source.answer_text);
if (OBJECTIVE_TYPES.has(type)) {
validateObjectiveAnswer(options, correctOptionIndices, issues, rowNo);
} else if (type === READING_TYPE) {
if (subQuestions.length === 0) {
issues.push({
rowNo,
severity: 'error',
code: 'SUB_QUESTIONS_REQUIRED',
fieldPath: 'sub_questions',
message: 'reading question requires sub_questions',
});
}
} else if (!answerText) {
issues.push({
rowNo,
severity: 'error',
code: 'ANSWER_TEXT_REQUIRED',
fieldPath: 'answerText',
message: 'subjective question requires answerText',
});
}
const normalizedWithoutHash = {
legacyId: stringValue(source.legacyId ?? source.legacy_id ?? source.externalId ?? source.external_id) || null,
type,
typeLabel: stringValue(source.typeLabel ?? source.type_label) || null,
content,
options,
correctOptionIndex: correctOptionIndices.length === 1 ? correctOptionIndices[0] : null,
correctOptionIndices,
answerText: answerText || null,
explanation: stringValue(source.explanation) || null,
difficulty: normalizeDifficulty(source.difficulty, issues, rowNo),
tags: stringArrayValue(source.tags),
mediaUrl: stringValue(source.mediaUrl ?? source.media_url) || null,
subQuestions,
codeLang: stringValue(source.codeLang ?? source.code_lang) || null,
codeTemplate: stringValue(source.codeTemplate ?? source.code_template) || null,
};
const normalized: NormalizedQuestion = {
...normalizedWithoutHash,
sourceHash: contentHash(normalizedWithoutHash),
};
return { normalized, issues };
}
async function assertTargetReferences(client: pg.PoolClient, auth: TenantContentAuth, body: JsonObject) {
const subjectId = requiredString(body, 'subjectId');
const categoryId = requiredString(body, 'categoryId');
const nodeId = nullableString(body.nodeId);
const questionBankId = nullableString(body.questionBankId);
const regionId = nullableString(body.regionId);
const subject = await client.query(
'select id, region_id from public.subjects where tenant_id = $1 and id = $2 limit 1',
[auth.tenantId, subjectId],
);
if (!subject.rows[0]) {
throw new HttpError(400, 'subjectId is not in this tenant', 'SUBJECT_NOT_FOUND');
}
const category = await client.query(
'select id from public.categories where tenant_id = $1 and id = $2 and subject_id = $3 limit 1',
[auth.tenantId, categoryId, subjectId],
);
if (!category.rows[0]) {
throw new HttpError(400, 'categoryId is not in this tenant or subject', 'CATEGORY_NOT_FOUND');
}
if (nodeId) {
const node = await client.query(
'select id from public.module_nodes where tenant_id = $1 and id = $2 limit 1',
[auth.tenantId, nodeId],
);
if (!node.rows[0]) {
throw new HttpError(400, 'nodeId is not in this tenant', 'NODE_NOT_FOUND');
}
}
if (questionBankId) {
const questionBank = await client.query(
'select id from public.question_banks where tenant_id = $1 and id = $2 limit 1',
[auth.tenantId, questionBankId],
);
if (!questionBank.rows[0]) {
throw new HttpError(400, 'questionBankId is not in this tenant', 'QUESTION_BANK_NOT_FOUND');
}
}
if (regionId) {
const region = await client.query(
'select id from public.regions where tenant_id = $1 and id = $2 limit 1',
[auth.tenantId, regionId],
);
if (!region.rows[0]) {
throw new HttpError(400, 'regionId is not in this tenant', 'REGION_NOT_FOUND');
}
}
return { subjectId, categoryId, nodeId, questionBankId, regionId };
}
async function createQuestionPreviewJob(auth: TenantContentAuth, body: JsonObject): Promise<PreviewResult> {
const rawItems = parseQuestionItems(body);
const sourceFormat = stringValue(body.sourceFormat) || 'json';
const sourceName = stringValue(body.sourceName) || null;
if (sourceFormat !== 'json') {
throw new HttpError(400, 'Only json sourceFormat is supported by the synchronous API for now', 'IMPORT_FORMAT_NOT_SUPPORTED');
}
return transaction(async client => {
const target = await assertTargetReferences(client, auth, body);
const normalizedItems = rawItems.map((raw, index) => {
const rowNo = index + 1;
const { normalized, issues } = normalizeQuestion(raw, rowNo);
return {
rowNo,
status: issues.some(issue => issue.severity === 'error') ? 'invalid' as const : 'valid' as const,
externalId: normalized?.legacyId || null,
source: raw,
normalized,
issues,
};
});
const issues = normalizedItems.flatMap(item => item.issues);
const errorCount = issues.filter(issue => issue.severity === 'error').length;
const warningCount = issues.filter(issue => issue.severity === 'warning').length;
const validCount = normalizedItems.filter(item => item.status === 'valid').length;
const rawPayload = JSON.stringify(rawItems);
const normalizedPayload = JSON.stringify(normalizedItems.map(item => item.normalized).filter(Boolean));
const jobResult = await client.query(
`
insert into public.content_import_jobs (
tenant_id, created_by, import_type, source_format, status,
source_name, source_hash, target_region_id, target_subject_id,
target_category_id, target_node_id, target_question_bank_id,
dry_run, total_count, valid_count, error_count, warning_count,
summary, raw_payload, normalized_payload
)
values (
$1, $2, 'questions', $3, 'preview',
$4, $5, $6::uuid, $7::uuid,
$8::uuid, $9::uuid, $10::uuid,
true, $11, $12, $13, $14,
$15::jsonb, $16::jsonb, $17::jsonb
)
returning id, status, total_count as "totalCount", valid_count as "validCount",
error_count as "errorCount", warning_count as "warningCount"
`,
[
auth.tenantId,
auth.userId,
sourceFormat,
sourceName,
contentHash(rawItems),
target.regionId,
target.subjectId,
target.categoryId,
target.nodeId,
target.questionBankId,
rawItems.length,
validCount,
errorCount,
warningCount,
JSON.stringify({ target, generatedAt: new Date().toISOString() }),
rawPayload,
normalizedPayload,
],
);
const job = jobResult.rows[0];
const responseItems: PreviewResult['items'] = [];
for (const item of normalizedItems) {
const itemResult = await client.query(
`
insert into public.content_import_items (
tenant_id, job_id, row_no, external_id, status, target_type,
source_payload, normalized_payload, content_hash, issues_count
)
values ($1, $2, $3, $4, $5, 'question', $6::jsonb, $7::jsonb, $8, $9)
returning id
`,
[
auth.tenantId,
job.id,
item.rowNo,
item.externalId,
item.status,
JSON.stringify(item.source),
JSON.stringify(item.normalized || {}),
item.normalized?.sourceHash || contentHash(item.source),
item.issues.length,
],
);
const itemId = itemResult.rows[0].id;
for (const issue of item.issues) {
await client.query(
`
insert into public.content_import_issues (
tenant_id, job_id, item_id, row_no, severity, code,
field_path, message, details
)
values ($1, $2, $3, $4, $5, $6, $7, $8, $9::jsonb)
`,
[
auth.tenantId,
job.id,
itemId,
issue.rowNo,
issue.severity,
issue.code,
issue.fieldPath,
issue.message,
JSON.stringify(issue.details || {}),
],
);
}
responseItems.push({
rowNo: item.rowNo,
status: item.status,
externalId: item.externalId,
normalized: item.normalized,
issues: item.issues,
});
}
await client.query(
`
insert into public.audit_logs (tenant_id, actor_user_id, action, target_type, target_id, details)
values ($1, $2, 'content.import.previewed', 'content_import_job', $3, $4::jsonb)
`,
[auth.tenantId, auth.userId, job.id, JSON.stringify({ importType: 'questions', total: rawItems.length, errorCount, warningCount })],
);
return {
job,
items: responseItems,
issues,
};
});
}
async function loadPreviewJob(client: pg.PoolClient, auth: TenantContentAuth, jobId: string) {
const result = await client.query<{
id: string;
status: string;
total_count: number;
valid_count: number;
error_count: number;
warning_count: number;
target_region_id: string | null;
target_subject_id: string;
target_category_id: string;
target_node_id: string | null;
target_question_bank_id: string | null;
}>(
`
select id, status, total_count, valid_count, error_count, warning_count,
target_region_id, target_subject_id, target_category_id,
target_node_id, target_question_bank_id
from public.content_import_jobs
where tenant_id = $1 and id = $2 and import_type = 'questions'
limit 1
for update
`,
[auth.tenantId, jobId],
);
const job = result.rows[0];
if (!job) {
throw new HttpError(404, 'Import job not found', 'IMPORT_JOB_NOT_FOUND');
}
if (['importing', 'failed'].includes(job.status)) {
throw new HttpError(409, `Import job is ${job.status}`, 'IMPORT_JOB_NOT_READY');
}
return job;
}
async function currentVersionHash(client: pg.PoolClient, questionId: string) {
const result = await client.query<{ source_hash: string | null }>(
`
select v.source_hash
from public.questions q
join public.question_versions v on v.id = q.current_version_id
where q.id = $1
limit 1
`,
[questionId],
);
return result.rows[0]?.source_hash || null;
}
async function importOneQuestion(
client: pg.PoolClient,
auth: TenantContentAuth,
job: {
id: string;
target_subject_id: string;
target_category_id: string;
target_node_id: string | null;
target_question_bank_id: string | null;
},
item: {
id: string;
row_no: number;
normalized_payload: NormalizedQuestion;
},
) {
const normalized = item.normalized_payload;
const legacyId = normalized.legacyId || `content-import:${job.id}:${item.row_no}`;
const existing = await client.query<{ id: string }>(
'select id from public.questions where tenant_id = $1 and legacy_id = $2 limit 1 for update',
[auth.tenantId, legacyId],
);
const existingQuestion = existing.rows[0];
let questionId = existingQuestion?.id || '';
if (!existingQuestion) {
const inserted = await client.query<{ id: string }>(
`
insert into public.questions (
tenant_id, question_bank_id, subject_id, category_id, node_id,
legacy_id, type, type_label, difficulty, tags, media_url, status
)
values ($1, $2::uuid, $3::uuid, $4::uuid, $5::uuid, $6, $7, $8, $9, $10::jsonb, $11, 'published')
returning id
`,
[
auth.tenantId,
job.target_question_bank_id,
job.target_subject_id,
job.target_category_id,
job.target_node_id,
legacyId,
normalized.type,
normalized.typeLabel,
normalized.difficulty,
JSON.stringify(normalized.tags),
normalized.mediaUrl,
],
);
questionId = inserted.rows[0].id;
} else {
await client.query(
`
update public.questions
set question_bank_id = $3::uuid,
subject_id = $4::uuid,
category_id = $5::uuid,
node_id = $6::uuid,
type = $7,
type_label = $8,
difficulty = $9,
tags = $10::jsonb,
media_url = $11,
status = 'published',
updated_at = now()
where tenant_id = $1 and id = $2
`,
[
auth.tenantId,
questionId,
job.target_question_bank_id,
job.target_subject_id,
job.target_category_id,
job.target_node_id,
normalized.type,
normalized.typeLabel,
normalized.difficulty,
JSON.stringify(normalized.tags),
normalized.mediaUrl,
],
);
}
const previousHash = existingQuestion ? await currentVersionHash(client, questionId) : null;
if (previousHash && previousHash === normalized.sourceHash) {
await client.query(
`
update public.content_import_items
set status = 'skipped', target_id = $3, updated_at = now()
where tenant_id = $1 and id = $2
`,
[auth.tenantId, item.id, questionId],
);
return 'skipped' as const;
}
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 version = await client.query<{ id: string }>(
`
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
`,
[
auth.tenantId,
questionId,
nextVersionNo,
normalized.content,
JSON.stringify(normalized.options),
normalized.correctOptionIndex,
JSON.stringify(normalized.correctOptionIndices),
normalized.answerText,
normalized.explanation,
JSON.stringify(normalized.subQuestions),
normalized.codeLang,
normalized.codeTemplate,
normalized.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, version.rows[0].id],
);
const status = existingQuestion ? 'updated' : 'inserted';
await client.query(
`
update public.content_import_items
set status = $3, target_id = $4, updated_at = now()
where tenant_id = $1 and id = $2
`,
[auth.tenantId, item.id, status, questionId],
);
return status as 'inserted' | 'updated';
}
export async function previewQuestionsImportRoute(ctx: RequestContext) {
const auth = await requireTenantContentEditor(ctx);
const body = await readJsonBody(ctx);
return createQuestionPreviewJob(auth, body);
}
export async function importQuestionsRoute(ctx: RequestContext) {
const auth = await requireTenantContentEditor(ctx);
const body = await readJsonBody(ctx);
const allowPartial = boolValue(body.allowPartial, false);
const jobId = nullableString(body.previewJobId) || nullableString(body.jobId);
const createdPreview = jobId ? null : await createQuestionPreviewJob(auth, body);
const finalJobId = jobId || createdPreview?.job.id || '';
const result = await transaction(async client => {
const job = await loadPreviewJob(client, auth, finalJobId);
if (job.status === 'completed' || job.status === 'completed_with_errors') {
return {
jobId: job.id,
status: job.status,
idempotent: true,
insertedCount: 0,
updatedCount: 0,
skippedCount: 0,
};
}
if (job.error_count > 0 && !allowPartial) {
await client.query(
`
update public.content_import_jobs
set status = 'rejected', error_message = 'Preview contains validation errors', updated_at = now()
where tenant_id = $1 and id = $2
`,
[auth.tenantId, job.id],
);
throw new HttpError(409, 'Preview contains validation errors. Fix issues or set allowPartial=true.', 'IMPORT_HAS_ERRORS');
}
await client.query(
`
update public.content_import_jobs
set status = 'importing', dry_run = false, started_at = coalesce(started_at, now()), updated_at = now()
where tenant_id = $1 and id = $2
`,
[auth.tenantId, job.id],
);
const itemResult = await client.query<{
id: string;
row_no: number;
normalized_payload: NormalizedQuestion;
}>(
`
select id, row_no, normalized_payload
from public.content_import_items
where tenant_id = $1 and job_id = $2 and status = 'valid'
order by row_no asc
for update
`,
[auth.tenantId, job.id],
);
let insertedCount = 0;
let updatedCount = 0;
let skippedCount = 0;
for (const item of itemResult.rows) {
const status = await importOneQuestion(client, auth, job, item);
if (status === 'inserted') insertedCount += 1;
if (status === 'updated') updatedCount += 1;
if (status === 'skipped') skippedCount += 1;
}
const finalStatus = job.error_count > 0 ? 'completed_with_errors' : 'completed';
await client.query(
`
update public.content_import_jobs
set status = $3,
inserted_count = $4,
updated_count = $5,
skipped_count = $6,
summary = coalesce(summary, '{}'::jsonb) || $7::jsonb,
finished_at = now(),
updated_at = now()
where tenant_id = $1 and id = $2
`,
[
auth.tenantId,
job.id,
finalStatus,
insertedCount,
updatedCount,
skippedCount,
JSON.stringify({ insertedCount, updatedCount, skippedCount, importedAt: new Date().toISOString() }),
],
);
await client.query(
`
insert into public.audit_logs (tenant_id, actor_user_id, action, target_type, target_id, details)
values ($1, $2, 'content.import.questions.completed', 'content_import_job', $3, $4::jsonb)
`,
[auth.tenantId, auth.userId, job.id, JSON.stringify({ insertedCount, updatedCount, skippedCount, allowPartial })],
);
return {
jobId: job.id,
status: finalStatus,
insertedCount,
updatedCount,
skippedCount,
errorCount: job.error_count,
warningCount: job.warning_count,
};
});
return { item: result, preview: createdPreview };
}
export async function importJobsRoute(ctx: RequestContext) {
const auth = await requireTenantContentEditor(ctx);
const limit = intParam(ctx, 'limit', 50, 200);
const importType = stringParam(ctx, 'importType');
const status = stringParam(ctx, 'status');
const params: unknown[] = [auth.tenantId];
const filters = ['tenant_id = $1'];
if (importType) {
params.push(importType);
filters.push(`import_type = $${params.length}`);
}
if (status) {
params.push(status);
filters.push(`status = $${params.length}`);
}
params.push(limit);
const items = await query(
`
select id, import_type as "importType", source_format as "sourceFormat",
status, source_name as "sourceName", source_hash as "sourceHash",
target_region_id as "targetRegionId", target_subject_id as "targetSubjectId",
target_category_id as "targetCategoryId", target_node_id as "targetNodeId",
target_question_bank_id as "targetQuestionBankId",
dry_run as "dryRun", total_count as "totalCount",
valid_count as "validCount", error_count as "errorCount",
warning_count as "warningCount", inserted_count as "insertedCount",
updated_count as "updatedCount", skipped_count as "skippedCount",
summary, error_message as "errorMessage",
started_at as "startedAt", finished_at as "finishedAt",
created_by as "createdBy", created_at as "createdAt", updated_at as "updatedAt"
from public.content_import_jobs
where ${filters.join(' and ')}
order by created_at desc
limit $${params.length}
`,
params,
);
return { items };
}
export async function importIssuesRoute(ctx: RequestContext) {
const auth = await requireTenantContentEditor(ctx);
const jobId = requiredString({ jobId: stringParam(ctx, 'jobId') }, 'jobId');
const limit = intParam(ctx, 'limit', 500, 2000);
const job = await queryOne<{ id: string }>(
'select id from public.content_import_jobs where tenant_id = $1 and id = $2 limit 1',
[auth.tenantId, jobId],
);
if (!job) {
throw new HttpError(404, 'Import job not found', 'IMPORT_JOB_NOT_FOUND');
}
const items = await query(
`
select i.id, i.row_no as "rowNo", i.severity, i.code,
i.field_path as "fieldPath", i.message, i.details,
item.external_id as "externalId", item.status as "itemStatus",
i.created_at as "createdAt"
from public.content_import_issues i
left join public.content_import_items item on item.id = i.item_id
where i.tenant_id = $1 and i.job_id = $2
order by i.row_no asc nulls last, case i.severity when 'error' then 1 else 2 end, i.created_at asc
limit $3
`,
[auth.tenantId, jobId, limit],
);
return { items };
}

View File

@@ -0,0 +1,72 @@
import type { RouteDefinition } from '../../core/router.js';
import {
assetsAdminRoute,
signAssetDownloadAdminRoute,
signAssetUploadRoute,
upsertAssetRoute,
} from './assets.js';
import {
importIssuesRoute,
importJobsRoute,
importQuestionsRoute,
previewQuestionsImportRoute,
} from './imports.js';
import {
bindQuestionVideoRoute,
createQuestionRoute,
handbookChaptersAdminRoute,
handbookEntriesAdminRoute,
handbookSubjectsAdminRoute,
scorelineFieldsAdminRoute,
scorelineMajorsAdminRoute,
scorelineRecordsAdminRoute,
scorelineSchoolsAdminRoute,
updateQuestionRoute,
upsertHandbookChapterRoute,
upsertHandbookEntryRoute,
upsertHandbookSubjectRoute,
upsertScorelineFieldRoute,
upsertScorelineMajorRoute,
upsertScorelineRecordRoute,
upsertScorelineSchoolRoute,
upsertVideoRoute,
upsertVocabularyUnitRoute,
upsertVocabularyWordRoute,
videosAdminRoute,
vocabularyUnitsAdminRoute,
vocabularyWordsAdminRoute,
} from './routes.js';
export const tenantContentRoutes: RouteDefinition[] = [
['POST', '/api/tenant-content/questions', createQuestionRoute],
['PATCH', '/api/tenant-content/questions', updateQuestionRoute],
['GET', '/api/tenant-content/assets', assetsAdminRoute],
['PUT', '/api/tenant-content/assets', upsertAssetRoute],
['POST', '/api/tenant-content/assets/sign-upload', signAssetUploadRoute],
['POST', '/api/tenant-content/assets/sign-download', signAssetDownloadAdminRoute],
['POST', '/api/tenant-content/imports/preview/questions', previewQuestionsImportRoute],
['POST', '/api/tenant-content/imports/questions', importQuestionsRoute],
['GET', '/api/tenant-content/imports', importJobsRoute],
['GET', '/api/tenant-content/imports/issues', importIssuesRoute],
['GET', '/api/tenant-content/videos', videosAdminRoute],
['PUT', '/api/tenant-content/videos', upsertVideoRoute],
['POST', '/api/tenant-content/question-videos', bindQuestionVideoRoute],
['GET', '/api/tenant-content/scoreline/schools', scorelineSchoolsAdminRoute],
['PUT', '/api/tenant-content/scoreline/schools', upsertScorelineSchoolRoute],
['GET', '/api/tenant-content/scoreline/majors', scorelineMajorsAdminRoute],
['PUT', '/api/tenant-content/scoreline/majors', upsertScorelineMajorRoute],
['GET', '/api/tenant-content/scoreline/fields', scorelineFieldsAdminRoute],
['PUT', '/api/tenant-content/scoreline/fields', upsertScorelineFieldRoute],
['GET', '/api/tenant-content/scoreline/records', scorelineRecordsAdminRoute],
['PUT', '/api/tenant-content/scoreline/records', upsertScorelineRecordRoute],
['GET', '/api/tenant-content/vocabulary-units', vocabularyUnitsAdminRoute],
['PUT', '/api/tenant-content/vocabulary-units', upsertVocabularyUnitRoute],
['GET', '/api/tenant-content/vocabulary-words', vocabularyWordsAdminRoute],
['PUT', '/api/tenant-content/vocabulary-words', upsertVocabularyWordRoute],
['GET', '/api/tenant-content/handbook-subjects', handbookSubjectsAdminRoute],
['PUT', '/api/tenant-content/handbook-subjects', upsertHandbookSubjectRoute],
['GET', '/api/tenant-content/handbook-chapters', handbookChaptersAdminRoute],
['PUT', '/api/tenant-content/handbook-chapters', upsertHandbookChapterRoute],
['GET', '/api/tenant-content/handbook-entries', handbookEntriesAdminRoute],
['PUT', '/api/tenant-content/handbook-entries', upsertHandbookEntryRoute],
];

View File

@@ -0,0 +1,820 @@
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'];
export async function createQuestionRoute(ctx: RequestContext) {
const auth = await requireTenantContentEditor(ctx);
const body = await readJsonBody(ctx);
const item = await transaction(async client => {
const questionResult = await client.query(
`
insert into public.questions (
tenant_id, question_bank_id, subject_id, category_id, node_id,
legacy_id, type, type_label, difficulty, tags, media_url, status
)
values ($1, $2::uuid, $3::uuid, $4::uuid, $5::uuid, $6, $7, $8, $9, $10::jsonb, $11, $12)
returning id, tenant_id as "tenantId", question_bank_id as "questionBankId",
subject_id as "subjectId", category_id as "categoryId", node_id as "nodeId",
type, type_label as "typeLabel", difficulty, tags, media_url as "mediaUrl",
status, created_at as "createdAt", updated_at as "updatedAt"
`,
[
auth.tenantId,
nullableString(body.questionBankId),
nullableString(body.subjectId),
nullableString(body.categoryId),
nullableString(body.nodeId),
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'),
],
);
const question = questionResult.rows[0];
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 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),
type = coalesce(nullif($7, ''), type),
type_label = coalesce(nullif($8, ''), type_label),
difficulty = coalesce($9, difficulty),
tags = case when $10::boolean then $11::jsonb else tags end,
media_url = coalesce(nullif($12, ''), media_url),
status = coalesce(nullif($13, ''), status),
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",
type, type_label as "typeLabel", difficulty, tags, media_url as "mediaUrl",
status, current_version_id as "currentVersionId", updated_at as "updatedAt"
`,
[
auth.tenantId,
questionId,
nullableString(body.questionBankId),
nullableString(body.subjectId),
nullableString(body.categoryId),
nullableString(body.nodeId),
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') : '',
],
);
const question = questionResult.rows[0];
if (!question) throw new HttpError(404, 'Question not found', 'QUESTION_NOT_FOUND');
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 };
}

View File

@@ -0,0 +1,32 @@
import { HttpError } from '../../core/http.js';
export type JsonObject = Record<string, unknown>;
export function jsonObjectValue(value: unknown) {
return JSON.stringify(value && typeof value === 'object' && !Array.isArray(value) ? value : {});
}
export function jsonArrayValue(value: unknown) {
return JSON.stringify(Array.isArray(value) ? value : []);
}
export function boolValue(value: unknown, fallback: boolean) {
return typeof value === 'boolean' ? value : fallback;
}
export function intValue(value: unknown, fallback: number) {
const numberValue = Number(value ?? fallback);
return Number.isFinite(numberValue) ? Math.trunc(numberValue) : fallback;
}
export function nullableString(value: unknown) {
return typeof value === 'string' && value.trim() ? value.trim() : null;
}
export function optionalStatus(value: unknown, allowed: string[], fallback: string) {
const candidate = nullableString(value) || fallback;
if (!allowed.includes(candidate)) {
throw new HttpError(400, `Invalid status: ${candidate}`, 'INVALID_STATUS');
}
return candidate;
}