forked from wangziqi/gongxue-base
feat: scaffold supabase multi-tenant backend
This commit is contained in:
354
apps/api/src/features/tenant-content/assets.ts
Normal file
354
apps/api/src/features/tenant-content/assets.ts
Normal 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),
|
||||
};
|
||||
}
|
||||
43
apps/api/src/features/tenant-content/auth.ts
Normal file
43
apps/api/src/features/tenant-content/auth.ts
Normal 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 || {} };
|
||||
}
|
||||
951
apps/api/src/features/tenant-content/imports.ts
Normal file
951
apps/api/src/features/tenant-content/imports.ts
Normal 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 };
|
||||
}
|
||||
72
apps/api/src/features/tenant-content/index.ts
Normal file
72
apps/api/src/features/tenant-content/index.ts
Normal 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],
|
||||
];
|
||||
820
apps/api/src/features/tenant-content/routes.ts
Normal file
820
apps/api/src/features/tenant-content/routes.ts
Normal 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 };
|
||||
}
|
||||
32
apps/api/src/features/tenant-content/utils.ts
Normal file
32
apps/api/src/features/tenant-content/utils.ts
Normal 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;
|
||||
}
|
||||
Reference in New Issue
Block a user