feat: add content navigation practice assembly

This commit is contained in:
Codex
2026-06-21 23:04:39 +08:00
parent efce192844
commit c1159f8745
18 changed files with 2358 additions and 74 deletions

View File

@@ -13,8 +13,8 @@
- Supabase/PostgreSQL 多租户数据库 schema、RLS、索引、触发器。
- `apps/api` 独立业务 API后续供 H5、Taro 小程序、管理后台统一调用。
- 租户后台能力:品牌、域名、公开设置、支付账户、登录配置、私密密钥掩码、活动内容、激活码、优惠券、成员权限、审计日志。
- 租户内容能力:题目录入/更新、视频绑定、分数线、单词、知识手册、资料资源台账、题目 JSON 批量导入。
- 学生端能力:题库目录、刷题 session、答题、错题本、收藏夹、背单词进度、个人中心、分数线、题目视频、订单、权益、激活码兑换、资料下载。
- 租户内容能力:可配置题库入口、任意深度分类树、考试意向标记、题目集合、顺序/随机/全真模拟蓝图、题目录入/更新、视频绑定、分数线、单词、知识手册、资料资源台账、题目 JSON 批量导入。
- 学生端能力:题库入口、分类树、题目集合、顺序/随机/模考 session 组卷快照、答题、错题本、收藏夹、背单词进度、个人中心、分数线、题目视频、订单、权益、激活码兑换、资料下载。
- 平台后台能力租户管理、SaaS 套餐、订阅、账单、服务费收款、用量记录。
- 销售/代理/CRM 增长链路:邀请码、扫码/分享事件、首绑客资保护、销售统计、团队关系、CRM 配置和队列。
- PocketBase schema/数据导入器雏形和导入后校验脚本。
@@ -114,17 +114,17 @@ npm run test:api
```text
apps/api/src/features/
auth/ 短信登录、迁移期 session、OAuth 占位
catalog/ 学生端目录、题库、资料、商城只读接口
catalog/ 学生端目录、内容入口、分类树、题目集合、资料、商城只读接口
commerce/ 订单、支付确认、激活码、权益
health/ 健康检查
learning/ 答题、错题、收藏、学习进度
learning/ 练习 session 组卷、答题、错题、收藏、学习进度
platform-admin/ 平台方租户、SaaS 套餐、订阅、账单、用量
profile/ 学生个人中心
referral/ 销售/代理客资追踪、CRM 队列
scoreline/ 分数线
tenant/ 租户解析
tenant-admin/ 租户后台配置、成员权限、活动和审计
tenant-content/ 租户内容维护、资源管理、批量导入
tenant-content/ 租户内容导航、题库维护、资源管理、批量导入
video/ 题目视频讲解
```
@@ -141,6 +141,7 @@ apps/api/src/features/
- 租户公开配置不能存放密钥。
- 商户密钥、短信密钥、OAuth app secret 等必须进入 `app_private.tenant_secrets`,或后续生产 KMS/Vault。
- 资料、PDF、视频等资源必须先进入 `content_assets` 台账,再由 API 校验权限并下发签名 URL。
- 题库入口和分类使用 `content_entries/content_nodes`;题目列表和练习规则使用 `question_collections/practice_blueprints`,前端不要再把旧树字段当成唯一业务结构。
- 批量导入必须先写 `content_import_jobs/items/issues`,保留原始 payload、规范化 payload、逐行问题和审计记录。
- 支付 webhook 必须先设计幂等键和验签流程,再进入生产使用。
@@ -150,7 +151,8 @@ apps/api/src/features/
```text
npm run supabase:reset
npm run check:refactor
npm run check:api
npm run test:api
```
结果:通过。

View File

@@ -110,6 +110,8 @@ export async function assetsRoute(ctx: RequestContext) {
const regionId = stringParam(ctx, 'regionId');
const subjectId = stringParam(ctx, 'subjectId');
const categoryId = stringParam(ctx, 'categoryId');
const entryId = stringParam(ctx, 'entryId');
const contentNodeId = stringParam(ctx, 'contentNodeId');
const includeLocked = stringParam(ctx, 'includeLocked') === 'true';
const userPresent = hasUserContext(ctx);
@@ -134,6 +136,14 @@ export async function assetsRoute(ctx: RequestContext) {
params.push(categoryId);
filters.push(`(category_id = $${params.length} or category_id is null)`);
}
if (entryId) {
params.push(entryId);
filters.push(`(entry_id = $${params.length} or entry_id is null)`);
}
if (contentNodeId) {
params.push(contentNodeId);
filters.push(`(content_node_id = $${params.length} or content_node_id is null)`);
}
params.push(limit);
const items = await query(
@@ -144,6 +154,7 @@ export async function assetsRoute(ctx: RequestContext) {
mime_type as "mimeType", file_size_bytes as "fileSizeBytes",
visibility, region_id as "regionId", subject_id as "subjectId",
category_id as "categoryId", node_id as "nodeId",
entry_id as "entryId", content_node_id as "contentNodeId",
sort_order as "order", metadata, created_at as "createdAt",
updated_at as "updatedAt"
from public.content_assets

View File

@@ -1,5 +1,12 @@
import type { RouteDefinition } from '../../core/router.js';
import { assetDownloadRoute, assetsRoute } from './assets.js';
import {
collectionQuestionsRoute,
contentEntriesRoute,
contentNodesRoute,
practiceBlueprintsRoute,
questionCollectionsRoute,
} from './navigation.js';
import {
announcementsRoute,
bannersRoute,
@@ -31,6 +38,11 @@ export const catalogRoutes: RouteDefinition[] = [
['GET', '/api/catalog/subjects', subjectsRoute],
['GET', '/api/catalog/categories', categoriesRoute],
['GET', '/api/catalog/questions', questionsRoute],
['GET', '/api/catalog/content-entries', contentEntriesRoute],
['GET', '/api/catalog/content-nodes', contentNodesRoute],
['GET', '/api/catalog/question-collections', questionCollectionsRoute],
['GET', '/api/catalog/question-collections/questions', collectionQuestionsRoute],
['GET', '/api/catalog/practice-blueprints', practiceBlueprintsRoute],
['GET', '/api/catalog/assets', assetsRoute],
['GET', '/api/catalog/assets/download', assetDownloadRoute],
['GET', '/api/catalog/vocabulary-units', vocabularyUnitsRoute],

View File

@@ -0,0 +1,247 @@
import { HttpError, type RequestContext } from '../../core/http.js';
import { intParam, stringParam, tenantIdFrom } from '../../core/request.js';
import { query, queryOne } from '../../core/db.js';
function optionalUuidParam(ctx: RequestContext, name: string) {
const value = stringParam(ctx, name);
return value || null;
}
export async function contentEntriesRoute(ctx: RequestContext) {
const tenantId = tenantIdFrom(ctx);
const regionId = optionalUuidParam(ctx, 'regionId');
const entryType = stringParam(ctx, 'entryType');
const includeHidden = stringParam(ctx, 'includeHidden') === 'true';
const params: unknown[] = [tenantId];
const filters = ['tenant_id = $1', 'is_active = true'];
if (!includeHidden) filters.push(`visibility <> 'hidden'`);
if (regionId) {
params.push(regionId);
filters.push(`(region_id = $${params.length} or region_id is null)`);
}
if (entryType) {
params.push(entryType);
filters.push(`entry_type = $${params.length}`);
}
const items = await query(
`
select id, region_id as "regionId", legacy_id as "legacyId",
entry_key as "entryKey", name, entry_type as "entryType",
icon, route, description, visibility, access_rules as "accessRules",
layout_config as "layoutConfig", sort_order as "order",
is_active as "isActive", created_at as "createdAt",
updated_at as "updatedAt"
from public.content_entries
where ${filters.join(' and ')}
order by sort_order asc, created_at asc
`,
params,
);
return { items };
}
export async function contentNodesRoute(ctx: RequestContext) {
const tenantId = tenantIdFrom(ctx);
const entryId = stringParam(ctx, 'entryId');
if (!entryId) {
throw new HttpError(400, 'entryId is required', 'REQUIRED_FIELD');
}
const parentIdParam = ctx.url.searchParams.get('parentId');
const parentId = parentIdParam === 'root' ? null : parentIdParam;
const mode = stringParam(ctx, 'mode') || 'children';
const includeInactive = stringParam(ctx, 'includeInactive') === 'true';
const markerType = stringParam(ctx, 'markerType');
const params: unknown[] = [tenantId, entryId];
const filters = ['tenant_id = $1', 'entry_id = $2'];
if (!includeInactive) filters.push('is_active = true');
if (parentIdParam !== null) {
if (parentId) {
params.push(parentId);
filters.push(`parent_id = $${params.length}`);
} else {
filters.push('parent_id is null');
}
}
if (markerType) {
params.push(markerType);
filters.push(`marker_type = $${params.length}`);
}
const orderClause = mode === 'flat'
? 'coalesce(path::text, name) asc, sort_order asc'
: 'sort_order asc, created_at asc';
const items = await query(
`
select id, entry_id as "entryId", region_id as "regionId",
parent_id as "parentId", legacy_id as "legacyId",
node_key as "nodeKey", name, node_type as "nodeType",
marker_type as "markerType", marker_config as "markerConfig",
path::text as path, depth, sort_order as "order",
is_active as "isActive", is_selectable as "isSelectable",
is_leaf as "isLeaf", metadata,
created_at as "createdAt", updated_at as "updatedAt"
from public.content_nodes
where ${filters.join(' and ')}
order by ${orderClause}
`,
params,
);
return { items };
}
export async function questionCollectionsRoute(ctx: RequestContext) {
const tenantId = tenantIdFrom(ctx);
const regionId = optionalUuidParam(ctx, 'regionId');
const entryId = optionalUuidParam(ctx, 'entryId');
const nodeId = optionalUuidParam(ctx, 'nodeId');
const collectionType = stringParam(ctx, 'collectionType');
const limit = intParam(ctx, 'limit', 100, 500);
const params: unknown[] = [tenantId];
const filters = ['tenant_id = $1', `status = 'active'`];
if (regionId) {
params.push(regionId);
filters.push(`(region_id = $${params.length} or region_id is null)`);
}
if (entryId) {
params.push(entryId);
filters.push(`entry_id = $${params.length}`);
}
if (nodeId) {
params.push(nodeId);
filters.push(`node_id = $${params.length}`);
}
if (collectionType) {
params.push(collectionType);
filters.push(`collection_type = $${params.length}`);
}
params.push(limit);
const items = await query(
`
select id, region_id as "regionId", entry_id as "entryId",
node_id as "nodeId", subject_id as "subjectId",
category_id as "categoryId", question_bank_id as "questionBankId",
legacy_id as "legacyId", name, collection_type as "collectionType",
source_type as "sourceType", filters, question_count as "questionCount",
total_score as "totalScore", duration_minutes as "durationMinutes",
status, sort_order as "order", metadata,
created_at as "createdAt", updated_at as "updatedAt"
from public.question_collections
where ${filters.join(' and ')}
order by sort_order asc, created_at asc
limit $${params.length}
`,
params,
);
return { items };
}
export async function practiceBlueprintsRoute(ctx: RequestContext) {
const tenantId = tenantIdFrom(ctx);
const entryId = optionalUuidParam(ctx, 'entryId');
const nodeId = optionalUuidParam(ctx, 'nodeId');
const collectionId = optionalUuidParam(ctx, 'collectionId');
const mode = stringParam(ctx, 'mode');
const limit = intParam(ctx, 'limit', 100, 500);
const params: unknown[] = [tenantId];
const filters = ['tenant_id = $1', `status = 'active'`];
if (entryId) {
params.push(entryId);
filters.push(`entry_id = $${params.length}`);
}
if (nodeId) {
params.push(nodeId);
filters.push(`node_id = $${params.length}`);
}
if (collectionId) {
params.push(collectionId);
filters.push(`collection_id = $${params.length}`);
}
if (mode) {
params.push(mode);
filters.push(`mode = $${params.length}`);
}
params.push(limit);
const items = await query(
`
select id, region_id as "regionId", entry_id as "entryId",
node_id as "nodeId", collection_id as "collectionId",
legacy_id as "legacyId", name, mode,
assembly_type as "assemblyType", question_limit as "questionLimit",
duration_minutes as "durationMinutes", total_score as "totalScore",
pass_score as "passScore", sections, rules, status,
sort_order as "order", created_at as "createdAt",
updated_at as "updatedAt"
from public.practice_blueprints
where ${filters.join(' and ')}
order by sort_order asc, created_at asc
limit $${params.length}
`,
params,
);
return { items };
}
export async function collectionQuestionsRoute(ctx: RequestContext) {
const tenantId = tenantIdFrom(ctx);
const collectionId = stringParam(ctx, 'collectionId');
if (!collectionId) {
throw new HttpError(400, 'collectionId is required', 'REQUIRED_FIELD');
}
const limit = intParam(ctx, 'limit', 200, 1000);
const collection = await queryOne<{ id: string }>(
`
select id
from public.question_collections
where tenant_id = $1 and id = $2 and status = 'active'
limit 1
`,
[tenantId, collectionId],
);
if (!collection) {
throw new HttpError(404, 'Question collection not found', 'QUESTION_COLLECTION_NOT_FOUND');
}
const items = await query(
`
select q.id, q.legacy_id as "legacyId", q.entry_id as "entryId",
q.content_node_id as "contentNodeId",
q.primary_collection_id as "primaryCollectionId",
q.subject_id as "subjectId", q.category_id as "categoryId",
q.node_id as "nodeId", q.type, q.type_label as "typeLabel",
q.difficulty, q.tags, q.exam_markers as "examMarkers",
q.media_url as "mediaUrl", q.has_video_explanation as "hasVideoExplanation",
ci.section_key as "sectionKey", ci.score, ci.sort_order as "order",
v.id as "versionId", v.content, v.options,
v.correct_option_index as "correctOptionIndex",
v.correct_option_indices as "correctOptionIndices",
v.answer_text as "answerText", v.explanation, v.sub_questions as "subQuestions",
v.code_lang as "codeLang", v.code_template as "codeTemplate",
q.created_at as "createdAt", q.updated_at as "updatedAt"
from public.question_collection_items ci
join public.questions q on q.id = ci.question_id and q.tenant_id = ci.tenant_id
left join public.question_versions v on v.id = q.current_version_id
where ci.tenant_id = $1
and ci.collection_id = $2
and q.status = 'published'
order by ci.section_key asc nulls first, ci.sort_order asc, q.created_at asc
limit $3
`,
[tenantId, collectionId, limit],
);
return { items };
}

View File

@@ -239,6 +239,9 @@ export async function questionsRoute(ctx: RequestContext) {
const subjectId = ctx.url.searchParams.get('subjectId');
const categoryId = ctx.url.searchParams.get('categoryId');
const nodeId = ctx.url.searchParams.get('nodeId');
const entryId = ctx.url.searchParams.get('entryId');
const contentNodeId = ctx.url.searchParams.get('contentNodeId');
const collectionId = ctx.url.searchParams.get('collectionId');
const limit = intParam(ctx, 'limit', 200, 500);
const params: unknown[] = [tenantId];
@@ -255,13 +258,37 @@ export async function questionsRoute(ctx: RequestContext) {
params.push(nodeId);
filters.push(`q.node_id = $${params.length}`);
}
if (entryId) {
params.push(entryId);
filters.push(`q.entry_id = $${params.length}`);
}
if (contentNodeId) {
params.push(contentNodeId);
filters.push(`q.content_node_id = $${params.length}`);
}
if (collectionId) {
params.push(collectionId);
filters.push(`(
q.primary_collection_id = $${params.length}
or exists (
select 1
from public.question_collection_items qci
where qci.tenant_id = q.tenant_id
and qci.question_id = q.id
and qci.collection_id = $${params.length}
)
)`);
}
params.push(limit);
const items = await query(
`
select q.id, q.legacy_id as "legacyId", q.subject_id as "subjectId",
q.category_id as "categoryId", q.node_id as "nodeId",
select q.id, q.legacy_id as "legacyId", q.entry_id as "entryId",
q.content_node_id as "contentNodeId",
q.primary_collection_id as "primaryCollectionId",
q.subject_id as "subjectId", q.category_id as "categoryId", q.node_id as "nodeId",
q.type, q.type_label as "typeLabel", q.difficulty, q.tags,
q.exam_markers as "examMarkers",
q.media_url as "mediaUrl", q.has_video_explanation as "hasVideoExplanation",
v.id as "versionId", v.content, v.options,
v.correct_option_index as "correctOptionIndex",

View File

@@ -20,6 +20,35 @@ interface QuestionAnswerRow {
answer_text: string | null;
}
interface PracticeBlueprintRow {
id: string;
entry_id: string | null;
node_id: string | null;
collection_id: string | null;
mode: string;
assembly_type: string;
question_limit: number | null;
duration_minutes: number | null;
total_score: string | number | null;
sections: unknown;
rules: Record<string, unknown>;
}
interface PracticeAssembly {
mode: string;
targetType: string | null;
targetId: string | null;
blueprintId: string | null;
collectionId: string | null;
entryId: string | null;
contentNodeId: string | null;
questionLimit: number;
durationMinutes: number | null;
totalScore: number | null;
sections: unknown[];
rules: Record<string, unknown>;
}
function normalizeStringArray(value: unknown): string[] {
if (!Array.isArray(value)) return [];
return value.map(item => String(item)).filter(item => item !== '');
@@ -62,23 +91,312 @@ function judgeAnswer(row: QuestionAnswerRow, selectedOptions: string[], answerTe
return null;
}
function optionalBodyString(body: Record<string, unknown>, key: string) {
const value = body[key];
return typeof value === 'string' && value.trim() ? value.trim() : null;
}
function jsonArray(value: unknown): unknown[] {
return Array.isArray(value) ? value : [];
}
function positiveInt(value: unknown, fallback: number, max = 500) {
const parsed = Number(value ?? fallback);
if (!Number.isFinite(parsed) || parsed <= 0) return fallback;
return Math.min(Math.trunc(parsed), max);
}
function modeFrom(value: string) {
if (['sequential', 'random', 'mock_exam', 'paper', 'wrong_review', 'favorite_review', 'chapter'].includes(value)) return value;
return 'chapter';
}
async function loadPracticeBlueprint(tenantId: string, blueprintId: string) {
const row = await queryOne<PracticeBlueprintRow>(
`
select id, entry_id, node_id, collection_id, mode, assembly_type,
question_limit, duration_minutes, total_score, sections, rules
from public.practice_blueprints
where tenant_id = $1 and id = $2 and status = 'active'
limit 1
`,
[tenantId, blueprintId],
);
if (!row) {
throw new HttpError(404, 'Practice blueprint not found', 'PRACTICE_BLUEPRINT_NOT_FOUND');
}
return row;
}
async function buildPracticeAssembly(tenantId: string, body: Record<string, unknown>): Promise<PracticeAssembly> {
const blueprintId = optionalBodyString(body, 'blueprintId');
const targetType = optionalString(body, 'targetType') || null;
const targetId = optionalString(body, 'targetId') || null;
const inputMode = modeFrom(optionalString(body, 'mode') || 'chapter');
if (!blueprintId) {
return {
mode: inputMode,
targetType,
targetId,
blueprintId: null,
collectionId: optionalBodyString(body, 'collectionId'),
entryId: optionalBodyString(body, 'entryId'),
contentNodeId: optionalBodyString(body, 'contentNodeId') || (targetType === 'content_node' ? targetId : null),
questionLimit: positiveInt(body.questionLimit, 100),
durationMinutes: body.durationMinutes === undefined ? null : positiveInt(body.durationMinutes, 60, 24 * 60),
totalScore: body.totalScore === undefined || body.totalScore === null ? null : Number(body.totalScore),
sections: jsonArray(body.sections),
rules: body.rules && typeof body.rules === 'object' && !Array.isArray(body.rules) ? body.rules as Record<string, unknown> : {},
};
}
const blueprint = await loadPracticeBlueprint(tenantId, blueprintId);
return {
mode: modeFrom(blueprint.mode),
targetType: targetType || 'blueprint',
targetId: targetId || blueprint.id,
blueprintId,
collectionId: optionalBodyString(body, 'collectionId') || blueprint.collection_id,
entryId: optionalBodyString(body, 'entryId') || blueprint.entry_id,
contentNodeId: optionalBodyString(body, 'contentNodeId') || blueprint.node_id,
questionLimit: positiveInt(body.questionLimit ?? blueprint.question_limit, 100),
durationMinutes: body.durationMinutes === undefined
? blueprint.duration_minutes
: positiveInt(body.durationMinutes, blueprint.duration_minutes || 60, 24 * 60),
totalScore: body.totalScore === undefined || body.totalScore === null
? (blueprint.total_score === null ? null : Number(blueprint.total_score))
: Number(body.totalScore),
sections: jsonArray(body.sections).length ? jsonArray(body.sections) : jsonArray(blueprint.sections),
rules: {
...(blueprint.rules || {}),
...(body.rules && typeof body.rules === 'object' && !Array.isArray(body.rules) ? body.rules as Record<string, unknown> : {}),
},
};
}
async function collectFromCollection(
tenantId: string,
collectionId: string,
limit: number,
randomize: boolean,
typeFilter?: string | null,
sectionKey?: string | null,
excludeIds: string[] = [],
) {
const params: unknown[] = [tenantId, collectionId];
const filters = ['ci.tenant_id = $1', 'ci.collection_id = $2', `q.status = 'published'`];
if (typeFilter) {
params.push(typeFilter);
filters.push(`q.type = $${params.length}`);
}
if (sectionKey) {
params.push(sectionKey);
filters.push(`ci.section_key = $${params.length}`);
}
if (excludeIds.length) {
params.push(excludeIds);
filters.push(`q.id <> all($${params.length}::uuid[])`);
}
params.push(limit);
const rows = await query<{ id: string }>(
`
select q.id
from public.question_collection_items ci
join public.questions q on q.id = ci.question_id and q.tenant_id = ci.tenant_id
where ${filters.join(' and ')}
order by ${randomize ? 'random()' : 'ci.sort_order asc, q.created_at asc'}
limit $${params.length}
`,
params,
);
return rows.map(row => row.id);
}
async function collectFromContentNode(
tenantId: string,
contentNodeId: string,
limit: number,
randomize: boolean,
typeFilter?: string | null,
excludeIds: string[] = [],
) {
const params: unknown[] = [tenantId, contentNodeId];
const filters = ['q.tenant_id = $1', `q.status = 'published'`];
if (typeFilter) {
params.push(typeFilter);
filters.push(`q.type = $${params.length}`);
}
if (excludeIds.length) {
params.push(excludeIds);
filters.push(`q.id <> all($${params.length}::uuid[])`);
}
params.push(limit);
const rows = await query<{ id: string }>(
`
with target_node as (
select path
from public.content_nodes
where tenant_id = $1 and id = $2 and is_active = true
limit 1
)
select q.id
from public.questions q
left join public.content_nodes n on n.id = q.content_node_id and n.tenant_id = q.tenant_id
where ${filters.join(' and ')}
and exists (select 1 from target_node)
and (
q.content_node_id = $2
or n.path <@ (select path from target_node)
)
order by ${randomize ? 'random()' : 'q.created_at asc'}
limit $${params.length}
`,
params,
);
return rows.map(row => row.id);
}
async function collectFromLegacyTarget(
tenantId: string,
targetType: string | null,
targetId: string | null,
limit: number,
randomize: boolean,
) {
if (!targetType || !targetId) return [];
const columnByTarget: Record<string, string> = {
subject: 'subject_id',
category: 'category_id',
node: 'node_id',
question_bank: 'question_bank_id',
};
const column = columnByTarget[targetType];
if (!column) return [];
const rows = await query<{ id: string }>(
`
select id
from public.questions
where tenant_id = $1 and status = 'published' and ${column} = $2
order by ${randomize ? 'random()' : 'created_at asc'}
limit $3
`,
[tenantId, targetId, limit],
);
return rows.map(row => row.id);
}
function sectionType(section: unknown) {
if (!section || typeof section !== 'object' || Array.isArray(section)) return null;
const object = section as Record<string, unknown>;
const value = object.questionType ?? object.type;
return typeof value === 'string' && value.trim() ? value.trim() : null;
}
function sectionKey(section: unknown) {
if (!section || typeof section !== 'object' || Array.isArray(section)) return null;
const object = section as Record<string, unknown>;
const value = object.sectionKey ?? object.key;
return typeof value === 'string' && value.trim() ? value.trim() : null;
}
function sectionLimit(section: unknown, fallback: number) {
if (!section || typeof section !== 'object' || Array.isArray(section)) return fallback;
const object = section as Record<string, unknown>;
return positiveInt(object.questionCount ?? object.limit, fallback);
}
async function assembleQuestionIds(tenantId: string, assembly: PracticeAssembly) {
const randomize = assembly.mode === 'random' || assembly.mode === 'mock_exam' || assembly.rules.randomize === true;
const questionIds: string[] = [];
const pushUnique = (ids: string[]) => {
for (const id of ids) {
if (!questionIds.includes(id)) questionIds.push(id);
}
};
if (assembly.sections.length && (assembly.collectionId || assembly.contentNodeId)) {
for (const section of assembly.sections) {
const limit = sectionLimit(section, assembly.questionLimit);
const type = sectionType(section);
const key = sectionKey(section);
const ids = assembly.collectionId
? await collectFromCollection(tenantId, assembly.collectionId, limit, randomize, type, key, questionIds)
: await collectFromContentNode(tenantId, assembly.contentNodeId || '', limit, randomize, type, questionIds);
pushUnique(ids);
}
return questionIds.slice(0, assembly.questionLimit);
}
if (assembly.collectionId) {
return collectFromCollection(tenantId, assembly.collectionId, assembly.questionLimit, randomize);
}
if (assembly.contentNodeId) {
return collectFromContentNode(tenantId, assembly.contentNodeId, assembly.questionLimit, randomize);
}
return collectFromLegacyTarget(tenantId, assembly.targetType, assembly.targetId, assembly.questionLimit, randomize);
}
export async function createPracticeSessionRoute(ctx: RequestContext) {
const body = await readJsonBody(ctx);
const tenantId = tenantIdFrom(ctx);
const userId = userIdFrom(ctx, body);
const mode = optionalString(body, 'mode') || 'chapter';
const targetType = optionalString(body, 'targetType') || null;
const targetId = optionalString(body, 'targetId') || null;
const assembly = await buildPracticeAssembly(tenantId, body);
const questionIds = await assembleQuestionIds(tenantId, assembly);
if ((assembly.blueprintId || assembly.collectionId || assembly.contentNodeId) && questionIds.length === 0) {
throw new HttpError(409, 'No published questions are available for this practice target', 'NO_PRACTICE_QUESTIONS');
}
const item = await queryOne(
`
insert into public.practice_sessions (tenant_id, user_id, mode, target_type, target_id, metadata)
values ($1, $2, $3, $4, $5, $6::jsonb)
insert into public.practice_sessions (
tenant_id, user_id, mode, target_type, target_id,
blueprint_id, collection_id, entry_id, content_node_id,
question_ids, question_count, duration_minutes, total_score,
expires_at, metadata
)
values (
$1, $2, $3, $4, $5,
$6::uuid, $7::uuid, $8::uuid, $9::uuid,
$10::jsonb, $11, $12, $13,
case when $12::integer is null then null else now() + make_interval(mins => $12::integer) end,
$14::jsonb
)
returning id, tenant_id as "tenantId", user_id as "userId", mode,
target_type as "targetType", target_id as "targetId",
started_at as "startedAt", finished_at as "finishedAt", metadata
blueprint_id as "blueprintId", collection_id as "collectionId",
entry_id as "entryId", content_node_id as "contentNodeId",
question_ids as "questionIds", question_count as "questionCount",
duration_minutes as "durationMinutes", total_score as "totalScore",
expires_at as "expiresAt", started_at as "startedAt",
finished_at as "finishedAt", metadata
`,
[tenantId, userId, mode, targetType, targetId, JSON.stringify(body.metadata || {})],
[
tenantId,
userId,
assembly.mode,
assembly.targetType,
assembly.targetId,
assembly.blueprintId,
assembly.collectionId,
assembly.entryId,
assembly.contentNodeId,
JSON.stringify(questionIds),
questionIds.length,
assembly.durationMinutes,
assembly.totalScore,
JSON.stringify({
...(body.metadata && typeof body.metadata === 'object' && !Array.isArray(body.metadata) ? body.metadata : {}),
assembly: {
sections: assembly.sections,
rules: assembly.rules,
},
}),
],
);
return { item };

View File

@@ -94,6 +94,8 @@ export async function assetsAdminRoute(ctx: RequestContext) {
const regionId = stringParam(ctx, 'regionId');
const subjectId = stringParam(ctx, 'subjectId');
const categoryId = stringParam(ctx, 'categoryId');
const entryId = stringParam(ctx, 'entryId');
const contentNodeId = stringParam(ctx, 'contentNodeId');
const params: unknown[] = [auth.tenantId];
const filters = ['tenant_id = $1'];
@@ -121,6 +123,14 @@ export async function assetsAdminRoute(ctx: RequestContext) {
params.push(categoryId);
filters.push(`category_id = $${params.length}`);
}
if (entryId) {
params.push(entryId);
filters.push(`entry_id = $${params.length}`);
}
if (contentNodeId) {
params.push(contentNodeId);
filters.push(`content_node_id = $${params.length}`);
}
params.push(limit);
const items = await query(
@@ -133,6 +143,7 @@ export async function assetsAdminRoute(ctx: RequestContext) {
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",
entry_id as "entryId", content_node_id as "contentNodeId",
status, sort_order as "order", access_rules as "accessRules",
source, download_count as "downloadCount", metadata,
created_by as "createdBy", updated_by as "updatedBy",
@@ -164,6 +175,8 @@ export async function upsertAssetRoute(ctx: RequestContext) {
const subjectId = nullableUuid(body.subjectId);
const categoryId = nullableUuid(body.categoryId);
const nodeId = nullableUuid(body.nodeId);
const entryId = nullableUuid(body.entryId);
const contentNodeId = nullableUuid(body.contentNodeId);
if (status === 'active' && !cdnUrl && !objectKey) {
throw new HttpError(400, 'Active asset requires cdnUrl or objectKey', 'ASSET_LOCATION_REQUIRED');
@@ -173,6 +186,8 @@ export async function upsertAssetRoute(ctx: RequestContext) {
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');
await assertOptionalReference(auth.tenantId, 'content_entries', entryId, 'ENTRY_NOT_FOUND');
await assertOptionalReference(auth.tenantId, 'content_nodes', contentNodeId, 'CONTENT_NODE_NOT_FOUND');
const item = await queryOne(
`
@@ -180,15 +195,17 @@ export async function upsertAssetRoute(ctx: RequestContext) {
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,
is_public, region_id, subject_id, category_id, node_id, entry_id,
content_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
$19, $20::uuid, $21::uuid, $22::uuid, $23::uuid, $24::uuid,
$25::uuid, $26,
$27, $28::jsonb, $29::jsonb, $30, $30, $31
)
on conflict (id)
do update set legacy_id = excluded.legacy_id,
@@ -212,6 +229,8 @@ export async function upsertAssetRoute(ctx: RequestContext) {
subject_id = excluded.subject_id,
category_id = excluded.category_id,
node_id = excluded.node_id,
entry_id = excluded.entry_id,
content_node_id = excluded.content_node_id,
status = excluded.status,
sort_order = excluded.sort_order,
access_rules = excluded.access_rules,
@@ -228,6 +247,7 @@ export async function upsertAssetRoute(ctx: RequestContext) {
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",
entry_id as "entryId", content_node_id as "contentNodeId",
status, sort_order as "order", access_rules as "accessRules",
source, download_count as "downloadCount", metadata,
created_by as "createdBy", updated_by as "updatedBy",
@@ -257,6 +277,8 @@ export async function upsertAssetRoute(ctx: RequestContext) {
subjectId,
categoryId,
nodeId,
entryId,
contentNodeId,
status,
intValue(body.order, 0),
jsonObjectValue(body.accessRules),

View File

@@ -33,6 +33,7 @@ interface NormalizedQuestion {
subQuestions: unknown[];
codeLang: string | null;
codeTemplate: string | null;
examMarkers: JsonObject;
sourceHash: string;
}
@@ -345,6 +346,7 @@ function normalizeQuestion(raw: unknown, rowNo: number) {
subQuestions,
codeLang: stringValue(source.codeLang ?? source.code_lang) || null,
codeTemplate: stringValue(source.codeTemplate ?? source.code_template) || null,
examMarkers: objectValue(source.examMarkers ?? source.exam_markers),
};
const normalized: NormalizedQuestion = {
@@ -361,6 +363,9 @@ async function assertTargetReferences(client: pg.PoolClient, auth: TenantContent
const nodeId = nullableString(body.nodeId);
const questionBankId = nullableString(body.questionBankId);
const regionId = nullableString(body.regionId);
const entryId = nullableString(body.entryId);
const contentNodeId = nullableString(body.contentNodeId);
const collectionId = nullableString(body.collectionId) || nullableString(body.primaryCollectionId);
const subject = await client.query(
'select id, region_id from public.subjects where tenant_id = $1 and id = $2 limit 1',
@@ -408,7 +413,37 @@ async function assertTargetReferences(client: pg.PoolClient, auth: TenantContent
}
}
return { subjectId, categoryId, nodeId, questionBankId, regionId };
if (entryId) {
const entry = await client.query(
'select id from public.content_entries where tenant_id = $1 and id = $2 limit 1',
[auth.tenantId, entryId],
);
if (!entry.rows[0]) {
throw new HttpError(400, 'entryId is not in this tenant', 'ENTRY_NOT_FOUND');
}
}
if (contentNodeId) {
const contentNode = await client.query(
'select id from public.content_nodes where tenant_id = $1 and id = $2 limit 1',
[auth.tenantId, contentNodeId],
);
if (!contentNode.rows[0]) {
throw new HttpError(400, 'contentNodeId is not in this tenant', 'CONTENT_NODE_NOT_FOUND');
}
}
if (collectionId) {
const collection = await client.query(
'select id from public.question_collections where tenant_id = $1 and id = $2 limit 1',
[auth.tenantId, collectionId],
);
if (!collection.rows[0]) {
throw new HttpError(400, 'collectionId is not in this tenant', 'QUESTION_COLLECTION_NOT_FOUND');
}
}
return { subjectId, categoryId, nodeId, questionBankId, regionId, entryId, contentNodeId, collectionId };
}
async function createQuestionPreviewJob(auth: TenantContentAuth, body: JsonObject): Promise<PreviewResult> {
@@ -448,6 +483,7 @@ async function createQuestionPreviewJob(auth: TenantContentAuth, body: JsonObjec
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,
target_entry_id, target_content_node_id, target_collection_id,
dry_run, total_count, valid_count, error_count, warning_count,
summary, raw_payload, normalized_payload
)
@@ -455,8 +491,9 @@ async function createQuestionPreviewJob(auth: TenantContentAuth, body: JsonObjec
$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
$11::uuid, $12::uuid, $13::uuid,
true, $14, $15, $16, $17,
$18::jsonb, $19::jsonb, $20::jsonb
)
returning id, status, total_count as "totalCount", valid_count as "validCount",
error_count as "errorCount", warning_count as "warningCount"
@@ -472,6 +509,9 @@ async function createQuestionPreviewJob(auth: TenantContentAuth, body: JsonObjec
target.categoryId,
target.nodeId,
target.questionBankId,
target.entryId,
target.contentNodeId,
target.collectionId,
rawItems.length,
validCount,
errorCount,
@@ -570,11 +610,15 @@ async function loadPreviewJob(client: pg.PoolClient, auth: TenantContentAuth, jo
target_category_id: string;
target_node_id: string | null;
target_question_bank_id: string | null;
target_entry_id: string | null;
target_content_node_id: string | null;
target_collection_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
target_node_id, target_question_bank_id,
target_entry_id, target_content_node_id, target_collection_id
from public.content_import_jobs
where tenant_id = $1 and id = $2 and import_type = 'questions'
limit 1
@@ -616,6 +660,9 @@ async function importOneQuestion(
target_category_id: string;
target_node_id: string | null;
target_question_bank_id: string | null;
target_entry_id: string | null;
target_content_node_id: string | null;
target_collection_id: string | null;
},
item: {
id: string;
@@ -637,9 +684,16 @@ async function importOneQuestion(
`
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
entry_id, content_node_id, primary_collection_id,
legacy_id, type, type_label, difficulty, tags, media_url, status,
exam_markers
)
values (
$1, $2::uuid, $3::uuid, $4::uuid, $5::uuid,
$6::uuid, $7::uuid, $8::uuid,
$9, $10, $11, $12, $13::jsonb, $14, 'published',
$15::jsonb
)
values ($1, $2::uuid, $3::uuid, $4::uuid, $5::uuid, $6, $7, $8, $9, $10::jsonb, $11, 'published')
returning id
`,
[
@@ -648,12 +702,16 @@ async function importOneQuestion(
job.target_subject_id,
job.target_category_id,
job.target_node_id,
job.target_entry_id,
job.target_content_node_id,
job.target_collection_id,
legacyId,
normalized.type,
normalized.typeLabel,
normalized.difficulty,
JSON.stringify(normalized.tags),
normalized.mediaUrl,
JSON.stringify(normalized.examMarkers),
],
);
questionId = inserted.rows[0].id;
@@ -665,11 +723,15 @@ async function importOneQuestion(
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,
entry_id = $7::uuid,
content_node_id = $8::uuid,
primary_collection_id = $9::uuid,
type = $10,
type_label = $11,
difficulty = $12,
tags = $13::jsonb,
media_url = $14,
exam_markers = $15::jsonb,
status = 'published',
updated_at = now()
where tenant_id = $1 and id = $2
@@ -681,15 +743,46 @@ async function importOneQuestion(
job.target_subject_id,
job.target_category_id,
job.target_node_id,
job.target_entry_id,
job.target_content_node_id,
job.target_collection_id,
normalized.type,
normalized.typeLabel,
normalized.difficulty,
JSON.stringify(normalized.tags),
normalized.mediaUrl,
JSON.stringify(normalized.examMarkers),
],
);
}
if (job.target_collection_id) {
await client.query(
`
insert into public.question_collection_items (tenant_id, collection_id, question_id, sort_order)
values (
$1, $2, $3,
coalesce((select max(sort_order) + 1 from public.question_collection_items where tenant_id = $1 and collection_id = $2), 0)
)
on conflict (tenant_id, collection_id, question_id) do nothing
`,
[auth.tenantId, job.target_collection_id, questionId],
);
await client.query(
`
update public.question_collections
set question_count = (
select count(*)
from public.question_collection_items
where tenant_id = $1 and collection_id = $2
),
updated_at = now()
where tenant_id = $1 and id = $2
`,
[auth.tenantId, job.target_collection_id],
);
}
const previousHash = existingQuestion ? await currentVersionHash(client, questionId) : null;
if (previousHash && previousHash === normalized.sourceHash) {
await client.query(

View File

@@ -11,6 +11,17 @@ import {
importQuestionsRoute,
previewQuestionsImportRoute,
} from './imports.js';
import {
contentEntriesAdminRoute,
contentNodesAdminRoute,
practiceBlueprintsAdminRoute,
questionCollectionsAdminRoute,
replaceQuestionCollectionItemsRoute,
upsertContentEntryRoute,
upsertContentNodeRoute,
upsertPracticeBlueprintRoute,
upsertQuestionCollectionRoute,
} from './navigation.js';
import {
bindQuestionVideoRoute,
createQuestionRoute,
@@ -38,6 +49,15 @@ import {
} from './routes.js';
export const tenantContentRoutes: RouteDefinition[] = [
['GET', '/api/tenant-content/content-entries', contentEntriesAdminRoute],
['PUT', '/api/tenant-content/content-entries', upsertContentEntryRoute],
['GET', '/api/tenant-content/content-nodes', contentNodesAdminRoute],
['PUT', '/api/tenant-content/content-nodes', upsertContentNodeRoute],
['GET', '/api/tenant-content/question-collections', questionCollectionsAdminRoute],
['PUT', '/api/tenant-content/question-collections', upsertQuestionCollectionRoute],
['PUT', '/api/tenant-content/question-collections/items', replaceQuestionCollectionItemsRoute],
['GET', '/api/tenant-content/practice-blueprints', practiceBlueprintsAdminRoute],
['PUT', '/api/tenant-content/practice-blueprints', upsertPracticeBlueprintRoute],
['POST', '/api/tenant-content/questions', createQuestionRoute],
['PATCH', '/api/tenant-content/questions', updateQuestionRoute],
['GET', '/api/tenant-content/assets', assetsAdminRoute],

View File

@@ -0,0 +1,754 @@
import { randomUUID } from 'node:crypto';
import type pg from 'pg';
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, type TenantContentAuth } from './auth.js';
import { boolValue, intValue, jsonArrayValue, jsonObjectValue, nullableString } from './utils.js';
const ENTRY_TYPES = ['question_practice', 'vocabulary', 'handbook', 'scoreline', 'resource', 'ai_report', 'custom'];
const ENTRY_VISIBILITIES = ['public', 'members', 'svip', 'hidden'];
const NODE_TYPES = ['category', 'subject', 'chapter', 'paper', 'school', 'major', 'exam_target', 'resource_group', 'custom'];
const MARKER_TYPES = ['school', 'major', 'subject', 'exam_track', 'course_package', 'sales_intent', 'custom'];
const COLLECTION_TYPES = ['dynamic', 'manual', 'paper', 'chapter', 'mock_exam'];
const COLLECTION_SOURCE_TYPES = ['filters', 'manual_questions', 'node_descendants', 'category', 'subject', 'question_bank'];
const PRACTICE_MODES = ['sequential', 'random', 'mock_exam', 'paper', 'wrong_review', 'favorite_review'];
const ASSEMBLY_TYPES = ['collection', 'node_descendants', 'manual', 'filters'];
const STATUSES = ['draft', 'active', 'archived'];
type JsonObject = Record<string, unknown>;
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 optionalChoice(value: unknown, allowed: string[], label: string) {
const candidate = nullableString(value);
if (!candidate) return null;
if (!allowed.includes(candidate)) {
throw new HttpError(400, `Invalid ${label}: ${candidate}`, 'INVALID_FIELD_VALUE');
}
return candidate;
}
function arrayFrom(value: unknown) {
return Array.isArray(value) ? value : [];
}
function uuidArray(value: unknown) {
return arrayFrom(value)
.map(item => (typeof item === 'string' ? item.trim() : ''))
.filter(Boolean);
}
function ltreeLabel(id: string) {
return `n_${id.replace(/-/g, '')}`;
}
async function assertOptionalTenantReference(
client: pg.PoolClient,
tenantId: string,
table: string,
id: string | null,
code: string,
) {
if (!id) return;
const row = await client.query(
`select id from public.${table} where tenant_id = $1 and id = $2 limit 1`,
[tenantId, id],
);
if (!row.rows[0]) {
throw new HttpError(400, `${table} reference is not in this tenant`, code);
}
}
async function recordNavigationAudit(auth: TenantContentAuth, action: string, targetType: string, targetId: string | null, details: JsonObject) {
await query(
`
insert into public.audit_logs (tenant_id, actor_user_id, action, target_type, target_id, details)
values ($1, $2, $3, $4, $5, $6::jsonb)
`,
[auth.tenantId, auth.userId, action, targetType, targetId, JSON.stringify(details)],
);
}
export async function contentEntriesAdminRoute(ctx: RequestContext) {
const auth = await requireTenantContentEditor(ctx);
const regionId = stringParam(ctx, 'regionId');
const entryType = stringParam(ctx, 'entryType');
const params: unknown[] = [auth.tenantId];
const filters = ['tenant_id = $1'];
if (regionId) {
params.push(regionId);
filters.push(`region_id = $${params.length}`);
}
if (entryType) {
params.push(entryType);
filters.push(`entry_type = $${params.length}`);
}
const items = await query(
`
select id, region_id as "regionId", legacy_id as "legacyId",
entry_key as "entryKey", name, entry_type as "entryType",
icon, route, description, visibility, access_rules as "accessRules",
layout_config as "layoutConfig", sort_order as "order",
is_active as "isActive", created_by as "createdBy",
updated_by as "updatedBy", created_at as "createdAt",
updated_at as "updatedAt"
from public.content_entries
where ${filters.join(' and ')}
order by sort_order asc, created_at asc
`,
params,
);
return { items };
}
export async function upsertContentEntryRoute(ctx: RequestContext) {
const auth = await requireTenantContentEditor(ctx);
const body = await readJsonBody(ctx);
const name = requiredString(body, 'name');
const entryKey = optionalString(body, 'entryKey') || optionalString(body, 'key') || nullableString(body.id) || randomUUID();
const regionId = nullableString(body.regionId);
const entryType = choice(body.entryType, ENTRY_TYPES, 'question_practice', 'entryType');
const visibility = choice(body.visibility, ENTRY_VISIBILITIES, 'public', 'visibility');
const item = await transaction(async client => {
await assertOptionalTenantReference(client, auth.tenantId, 'regions', regionId, 'REGION_NOT_FOUND');
const result = await client.query(
`
insert into public.content_entries (
id, tenant_id, region_id, legacy_id, entry_key, name, entry_type,
icon, route, description, visibility, access_rules, layout_config,
sort_order, is_active, created_by, updated_by
)
values (
coalesce($1::uuid, gen_random_uuid()), $2, $3::uuid, $4, $5, $6, $7,
$8, $9, $10, $11, $12::jsonb, $13::jsonb,
$14, $15, $16, $16
)
on conflict (tenant_id, entry_key)
do update set region_id = excluded.region_id,
legacy_id = excluded.legacy_id,
name = excluded.name,
entry_type = excluded.entry_type,
icon = excluded.icon,
route = excluded.route,
description = excluded.description,
visibility = excluded.visibility,
access_rules = excluded.access_rules,
layout_config = excluded.layout_config,
sort_order = excluded.sort_order,
is_active = excluded.is_active,
updated_by = excluded.updated_by,
updated_at = now()
returning id, region_id as "regionId", legacy_id as "legacyId",
entry_key as "entryKey", name, entry_type as "entryType",
icon, route, description, visibility, access_rules as "accessRules",
layout_config as "layoutConfig", sort_order as "order",
is_active as "isActive", created_at as "createdAt",
updated_at as "updatedAt"
`,
[
nullableString(body.id),
auth.tenantId,
regionId,
nullableString(body.legacyId),
entryKey,
name,
entryType,
nullableString(body.icon),
nullableString(body.route),
nullableString(body.description),
visibility,
jsonObjectValue(body.accessRules),
jsonObjectValue(body.layoutConfig),
intValue(body.order, 0),
boolValue(body.isActive, true),
auth.userId,
],
);
return result.rows[0];
});
await recordNavigationAudit(auth, 'content.entry.upserted', 'content_entry', String(item.id), { name, entryKey, entryType });
return { item };
}
async function buildNodePath(client: pg.PoolClient, tenantId: string, entryId: string, nodeId: string, parentId: string | null) {
if (!parentId) return { path: ltreeLabel(nodeId), depth: 0, parentPath: null as string | null };
const parent = await client.query<{ path: string; depth: number }>(
`
select path::text as path, depth
from public.content_nodes
where tenant_id = $1 and entry_id = $2 and id = $3
limit 1
`,
[tenantId, entryId, parentId],
);
if (!parent.rows[0]) {
throw new HttpError(400, 'parentId is not in this entry', 'PARENT_NODE_NOT_FOUND');
}
return {
path: `${parent.rows[0].path}.${ltreeLabel(nodeId)}`,
depth: Number(parent.rows[0].depth || 0) + 1,
parentPath: parent.rows[0].path,
};
}
export async function contentNodesAdminRoute(ctx: RequestContext) {
const auth = await requireTenantContentEditor(ctx);
const entryId = stringParam(ctx, 'entryId');
const parentParam = ctx.url.searchParams.get('parentId');
const includeInactive = stringParam(ctx, 'includeInactive') === 'true';
const markerType = stringParam(ctx, 'markerType');
const mode = stringParam(ctx, 'mode') || 'children';
const params: unknown[] = [auth.tenantId];
const filters = ['tenant_id = $1'];
if (entryId) {
params.push(entryId);
filters.push(`entry_id = $${params.length}`);
}
if (parentParam !== null) {
if (parentParam === 'root') {
filters.push('parent_id is null');
} else {
params.push(parentParam);
filters.push(`parent_id = $${params.length}`);
}
}
if (!includeInactive) filters.push('is_active = true');
if (markerType) {
params.push(markerType);
filters.push(`marker_type = $${params.length}`);
}
const orderClause = mode === 'flat' ? 'coalesce(path::text, name) asc, sort_order asc' : 'sort_order asc, created_at asc';
const items = await query(
`
select id, entry_id as "entryId", region_id as "regionId",
parent_id as "parentId", legacy_id as "legacyId",
node_key as "nodeKey", name, node_type as "nodeType",
marker_type as "markerType", marker_config as "markerConfig",
path::text as path, depth, sort_order as "order",
is_active as "isActive", is_selectable as "isSelectable",
is_leaf as "isLeaf", metadata, created_by as "createdBy",
updated_by as "updatedBy", created_at as "createdAt",
updated_at as "updatedAt"
from public.content_nodes
where ${filters.join(' and ')}
order by ${orderClause}
`,
params,
);
return { items };
}
export async function upsertContentNodeRoute(ctx: RequestContext) {
const auth = await requireTenantContentEditor(ctx);
const body = await readJsonBody(ctx);
let nodeId = nullableString(body.id) || randomUUID();
const entryId = requiredString(body, 'entryId');
const parentId = nullableString(body.parentId);
const name = requiredString(body, 'name');
const nodeKey = nullableString(body.nodeKey) || nullableString(body.key);
const nodeType = choice(body.nodeType, NODE_TYPES, 'category', 'nodeType');
const markerType = optionalChoice(body.markerType, MARKER_TYPES, 'markerType');
const regionId = nullableString(body.regionId);
const item = await transaction(async client => {
await assertOptionalTenantReference(client, auth.tenantId, 'content_entries', entryId, 'ENTRY_NOT_FOUND');
await assertOptionalTenantReference(client, auth.tenantId, 'regions', regionId, 'REGION_NOT_FOUND');
if (!nullableString(body.id) && nodeKey) {
const keyedNode = await client.query<{ id: string }>(
`
select id
from public.content_nodes
where tenant_id = $1 and entry_id = $2 and node_key = $3
limit 1
for update
`,
[auth.tenantId, entryId, nodeKey],
);
nodeId = keyedNode.rows[0]?.id || nodeId;
}
const existing = await client.query<{ path: string | null; parent_id: string | null }>(
'select path::text as path, parent_id from public.content_nodes where tenant_id = $1 and id = $2 limit 1 for update',
[auth.tenantId, nodeId],
);
const previousPath = existing.rows[0]?.path || null;
const { path, depth } = await buildNodePath(client, auth.tenantId, entryId, nodeId, parentId);
const result = await client.query(
`
insert into public.content_nodes (
id, tenant_id, entry_id, region_id, parent_id, legacy_id, node_key,
name, node_type, marker_type, marker_config, path, depth, sort_order,
is_active, is_selectable, is_leaf, metadata, created_by, updated_by
)
values (
$1, $2, $3, $4::uuid, $5::uuid, $6, $7,
$8, $9, $10, $11::jsonb, $12::ltree, $13, $14,
$15, $16, $17, $18::jsonb, $19, $19
)
on conflict (id)
do update set entry_id = excluded.entry_id,
region_id = excluded.region_id,
parent_id = excluded.parent_id,
legacy_id = excluded.legacy_id,
node_key = excluded.node_key,
name = excluded.name,
node_type = excluded.node_type,
marker_type = excluded.marker_type,
marker_config = excluded.marker_config,
path = excluded.path,
depth = excluded.depth,
sort_order = excluded.sort_order,
is_active = excluded.is_active,
is_selectable = excluded.is_selectable,
is_leaf = excluded.is_leaf,
metadata = excluded.metadata,
updated_by = excluded.updated_by,
updated_at = now()
where public.content_nodes.tenant_id = excluded.tenant_id
returning id, entry_id as "entryId", region_id as "regionId",
parent_id as "parentId", legacy_id as "legacyId",
node_key as "nodeKey", name, node_type as "nodeType",
marker_type as "markerType", marker_config as "markerConfig",
path::text as path, depth, sort_order as "order",
is_active as "isActive", is_selectable as "isSelectable",
is_leaf as "isLeaf", metadata, created_at as "createdAt",
updated_at as "updatedAt"
`,
[
nodeId,
auth.tenantId,
entryId,
regionId,
parentId,
nullableString(body.legacyId),
nodeKey,
name,
nodeType,
markerType,
jsonObjectValue(body.markerConfig),
path,
depth,
intValue(body.order, 0),
boolValue(body.isActive, true),
boolValue(body.isSelectable, true),
boolValue(body.isLeaf, false),
jsonObjectValue(body.metadata),
auth.userId,
],
);
if (parentId) {
await client.query(
'update public.content_nodes set is_leaf = false, updated_at = now() where tenant_id = $1 and id = $2',
[auth.tenantId, parentId],
);
}
if (previousPath && previousPath !== path) {
await client.query(
`
update public.content_nodes
set path = $3::ltree || subpath(path, nlevel($2::ltree)),
depth = nlevel($3::ltree || subpath(path, nlevel($2::ltree))) - 1,
updated_at = now()
where tenant_id = $1
and id <> $4
and path <@ $2::ltree
`,
[auth.tenantId, previousPath, path, nodeId],
);
}
return result.rows[0];
});
await recordNavigationAudit(auth, 'content.node.upserted', 'content_node', String(item.id), { name, nodeType, markerType });
return { item };
}
export async function questionCollectionsAdminRoute(ctx: RequestContext) {
const auth = await requireTenantContentEditor(ctx);
const nodeId = stringParam(ctx, 'nodeId');
const entryId = stringParam(ctx, 'entryId');
const status = stringParam(ctx, 'status');
const limit = intParam(ctx, 'limit', 100, 500);
const params: unknown[] = [auth.tenantId];
const filters = ['tenant_id = $1'];
if (nodeId) {
params.push(nodeId);
filters.push(`node_id = $${params.length}`);
}
if (entryId) {
params.push(entryId);
filters.push(`entry_id = $${params.length}`);
}
if (status) {
params.push(status);
filters.push(`status = $${params.length}`);
}
params.push(limit);
const items = await query(
`
select id, region_id as "regionId", entry_id as "entryId",
node_id as "nodeId", subject_id as "subjectId",
category_id as "categoryId", question_bank_id as "questionBankId",
legacy_id as "legacyId", name, collection_type as "collectionType",
source_type as "sourceType", filters, question_count as "questionCount",
total_score as "totalScore", duration_minutes as "durationMinutes",
status, sort_order as "order", metadata,
created_by as "createdBy", updated_by as "updatedBy",
created_at as "createdAt", updated_at as "updatedAt"
from public.question_collections
where ${filters.join(' and ')}
order by sort_order asc, created_at asc
limit $${params.length}
`,
params,
);
return { items };
}
async function assertCollectionReferences(client: pg.PoolClient, auth: TenantContentAuth, body: JsonObject) {
const regionId = nullableString(body.regionId);
const entryId = nullableString(body.entryId);
const nodeId = nullableString(body.nodeId);
const subjectId = nullableString(body.subjectId);
const categoryId = nullableString(body.categoryId);
const questionBankId = nullableString(body.questionBankId);
await assertOptionalTenantReference(client, auth.tenantId, 'regions', regionId, 'REGION_NOT_FOUND');
await assertOptionalTenantReference(client, auth.tenantId, 'content_entries', entryId, 'ENTRY_NOT_FOUND');
await assertOptionalTenantReference(client, auth.tenantId, 'content_nodes', nodeId, 'CONTENT_NODE_NOT_FOUND');
await assertOptionalTenantReference(client, auth.tenantId, 'subjects', subjectId, 'SUBJECT_NOT_FOUND');
await assertOptionalTenantReference(client, auth.tenantId, 'categories', categoryId, 'CATEGORY_NOT_FOUND');
await assertOptionalTenantReference(client, auth.tenantId, 'question_banks', questionBankId, 'QUESTION_BANK_NOT_FOUND');
return { regionId, entryId, nodeId, subjectId, categoryId, questionBankId };
}
async function replaceCollectionItems(
client: pg.PoolClient,
auth: TenantContentAuth,
collectionId: string,
questions: unknown[],
) {
await client.query('delete from public.question_collection_items where tenant_id = $1 and collection_id = $2', [auth.tenantId, collectionId]);
let order = 0;
for (const raw of questions) {
const item = typeof raw === 'string' ? { questionId: raw } : raw && typeof raw === 'object' ? raw as JsonObject : {};
const questionId = nullableString(item.questionId ?? item.id);
if (!questionId) continue;
const question = await client.query<{ id: string }>(
'select id from public.questions where tenant_id = $1 and id = $2 limit 1',
[auth.tenantId, questionId],
);
if (!question.rows[0]) {
throw new HttpError(400, `Question ${questionId} is not in this tenant`, 'QUESTION_NOT_FOUND');
}
await client.query(
`
insert into public.question_collection_items (
tenant_id, collection_id, question_id, section_key, sort_order, score, required, metadata
)
values ($1, $2, $3, $4, $5, $6, $7, $8::jsonb)
on conflict (tenant_id, collection_id, question_id)
do update set section_key = excluded.section_key,
sort_order = excluded.sort_order,
score = excluded.score,
required = excluded.required,
metadata = excluded.metadata,
updated_at = now()
`,
[
auth.tenantId,
collectionId,
questionId,
nullableString(item.sectionKey),
item.order === undefined ? order : intValue(item.order, order),
item.score === undefined || item.score === null ? null : Number(item.score),
boolValue(item.required, true),
jsonObjectValue(item.metadata),
],
);
order += 1;
}
const count = await client.query<{ count: string }>(
'select count(*)::text as count from public.question_collection_items where tenant_id = $1 and collection_id = $2',
[auth.tenantId, collectionId],
);
const questionCount = Number(count.rows[0]?.count || 0);
await client.query(
'update public.question_collections set question_count = $3, updated_at = now() where tenant_id = $1 and id = $2',
[auth.tenantId, collectionId, questionCount],
);
return questionCount;
}
export async function upsertQuestionCollectionRoute(ctx: RequestContext) {
const auth = await requireTenantContentEditor(ctx);
const body = await readJsonBody(ctx);
const name = requiredString(body, 'name');
const collectionType = choice(body.collectionType, COLLECTION_TYPES, 'dynamic', 'collectionType');
const sourceType = choice(body.sourceType, COLLECTION_SOURCE_TYPES, collectionType === 'manual' ? 'manual_questions' : 'filters', 'sourceType');
const status = choice(body.status, STATUSES, 'active', 'status');
const item = await transaction(async client => {
const refs = await assertCollectionReferences(client, auth, body);
const result = await client.query(
`
insert into public.question_collections (
id, tenant_id, region_id, entry_id, node_id, subject_id,
category_id, question_bank_id, legacy_id, name, collection_type,
source_type, filters, question_count, total_score, duration_minutes,
status, sort_order, metadata, created_by, updated_by
)
values (
coalesce($1::uuid, gen_random_uuid()), $2, $3::uuid, $4::uuid, $5::uuid, $6::uuid,
$7::uuid, $8::uuid, $9, $10, $11,
$12, $13::jsonb, $14, $15, $16,
$17, $18, $19::jsonb, $20, $20
)
on conflict (id)
do update set region_id = excluded.region_id,
entry_id = excluded.entry_id,
node_id = excluded.node_id,
subject_id = excluded.subject_id,
category_id = excluded.category_id,
question_bank_id = excluded.question_bank_id,
legacy_id = excluded.legacy_id,
name = excluded.name,
collection_type = excluded.collection_type,
source_type = excluded.source_type,
filters = excluded.filters,
question_count = excluded.question_count,
total_score = excluded.total_score,
duration_minutes = excluded.duration_minutes,
status = excluded.status,
sort_order = excluded.sort_order,
metadata = excluded.metadata,
updated_by = excluded.updated_by,
updated_at = now()
where public.question_collections.tenant_id = excluded.tenant_id
returning id, region_id as "regionId", entry_id as "entryId",
node_id as "nodeId", subject_id as "subjectId",
category_id as "categoryId", question_bank_id as "questionBankId",
legacy_id as "legacyId", name, collection_type as "collectionType",
source_type as "sourceType", filters, question_count as "questionCount",
total_score as "totalScore", duration_minutes as "durationMinutes",
status, sort_order as "order", metadata,
created_at as "createdAt", updated_at as "updatedAt"
`,
[
nullableString(body.id),
auth.tenantId,
refs.regionId,
refs.entryId,
refs.nodeId,
refs.subjectId,
refs.categoryId,
refs.questionBankId,
nullableString(body.legacyId),
name,
collectionType,
sourceType,
jsonObjectValue(body.filters),
body.questionCount === undefined ? 0 : intValue(body.questionCount, 0),
body.totalScore === undefined || body.totalScore === null ? null : Number(body.totalScore),
body.durationMinutes === undefined ? null : intValue(body.durationMinutes, 0),
status,
intValue(body.order, 0),
jsonObjectValue(body.metadata),
auth.userId,
],
);
let collection = result.rows[0];
if (Array.isArray(body.questions) || Array.isArray(body.questionIds)) {
const questionCount = await replaceCollectionItems(client, auth, collection.id, Array.isArray(body.questions) ? body.questions : uuidArray(body.questionIds));
collection = { ...collection, questionCount };
}
return collection;
});
await recordNavigationAudit(auth, 'content.collection.upserted', 'question_collection', String(item.id), { name, collectionType, sourceType });
return { item };
}
export async function replaceQuestionCollectionItemsRoute(ctx: RequestContext) {
const auth = await requireTenantContentEditor(ctx);
const body = await readJsonBody(ctx);
const collectionId = requiredString(body, 'collectionId');
const questions = Array.isArray(body.questions) ? body.questions : uuidArray(body.questionIds);
const item = await transaction(async client => {
await assertOptionalTenantReference(client, auth.tenantId, 'question_collections', collectionId, 'QUESTION_COLLECTION_NOT_FOUND');
const questionCount = await replaceCollectionItems(client, auth, collectionId, questions);
return { collectionId, questionCount };
});
await recordNavigationAudit(auth, 'content.collection.items_replaced', 'question_collection', collectionId, { questionCount: item.questionCount });
return { item };
}
export async function practiceBlueprintsAdminRoute(ctx: RequestContext) {
const auth = await requireTenantContentEditor(ctx);
const nodeId = stringParam(ctx, 'nodeId');
const collectionId = stringParam(ctx, 'collectionId');
const mode = stringParam(ctx, 'mode');
const limit = intParam(ctx, 'limit', 100, 500);
const params: unknown[] = [auth.tenantId];
const filters = ['tenant_id = $1'];
if (nodeId) {
params.push(nodeId);
filters.push(`node_id = $${params.length}`);
}
if (collectionId) {
params.push(collectionId);
filters.push(`collection_id = $${params.length}`);
}
if (mode) {
params.push(mode);
filters.push(`mode = $${params.length}`);
}
params.push(limit);
const items = await query(
`
select id, region_id as "regionId", entry_id as "entryId",
node_id as "nodeId", collection_id as "collectionId",
legacy_id as "legacyId", name, mode,
assembly_type as "assemblyType", question_limit as "questionLimit",
duration_minutes as "durationMinutes", total_score as "totalScore",
pass_score as "passScore", sections, rules, status,
sort_order as "order", created_by as "createdBy",
updated_by as "updatedBy", created_at as "createdAt",
updated_at as "updatedAt"
from public.practice_blueprints
where ${filters.join(' and ')}
order by sort_order asc, created_at asc
limit $${params.length}
`,
params,
);
return { items };
}
export async function upsertPracticeBlueprintRoute(ctx: RequestContext) {
const auth = await requireTenantContentEditor(ctx);
const body = await readJsonBody(ctx);
const name = requiredString(body, 'name');
const mode = choice(body.mode, PRACTICE_MODES, 'sequential', 'mode');
const assemblyType = choice(body.assemblyType, ASSEMBLY_TYPES, 'collection', 'assemblyType');
const status = choice(body.status, STATUSES, 'active', 'status');
const item = await transaction(async client => {
const regionId = nullableString(body.regionId);
const entryId = nullableString(body.entryId);
const nodeId = nullableString(body.nodeId);
const collectionId = nullableString(body.collectionId);
await assertOptionalTenantReference(client, auth.tenantId, 'regions', regionId, 'REGION_NOT_FOUND');
await assertOptionalTenantReference(client, auth.tenantId, 'content_entries', entryId, 'ENTRY_NOT_FOUND');
await assertOptionalTenantReference(client, auth.tenantId, 'content_nodes', nodeId, 'CONTENT_NODE_NOT_FOUND');
await assertOptionalTenantReference(client, auth.tenantId, 'question_collections', collectionId, 'QUESTION_COLLECTION_NOT_FOUND');
const result = await client.query(
`
insert into public.practice_blueprints (
id, tenant_id, region_id, entry_id, node_id, collection_id,
legacy_id, name, mode, assembly_type, question_limit,
duration_minutes, total_score, pass_score, sections, rules,
status, sort_order, created_by, updated_by
)
values (
coalesce($1::uuid, gen_random_uuid()), $2, $3::uuid, $4::uuid, $5::uuid, $6::uuid,
$7, $8, $9, $10, $11,
$12, $13, $14, $15::jsonb, $16::jsonb,
$17, $18, $19, $19
)
on conflict (id)
do update set region_id = excluded.region_id,
entry_id = excluded.entry_id,
node_id = excluded.node_id,
collection_id = excluded.collection_id,
legacy_id = excluded.legacy_id,
name = excluded.name,
mode = excluded.mode,
assembly_type = excluded.assembly_type,
question_limit = excluded.question_limit,
duration_minutes = excluded.duration_minutes,
total_score = excluded.total_score,
pass_score = excluded.pass_score,
sections = excluded.sections,
rules = excluded.rules,
status = excluded.status,
sort_order = excluded.sort_order,
updated_by = excluded.updated_by,
updated_at = now()
where public.practice_blueprints.tenant_id = excluded.tenant_id
returning id, region_id as "regionId", entry_id as "entryId",
node_id as "nodeId", collection_id as "collectionId",
legacy_id as "legacyId", name, mode,
assembly_type as "assemblyType", question_limit as "questionLimit",
duration_minutes as "durationMinutes", total_score as "totalScore",
pass_score as "passScore", sections, rules, status,
sort_order as "order", created_at as "createdAt",
updated_at as "updatedAt"
`,
[
nullableString(body.id),
auth.tenantId,
regionId,
entryId,
nodeId,
collectionId,
nullableString(body.legacyId),
name,
mode,
assemblyType,
body.questionLimit === undefined ? null : intValue(body.questionLimit, 0),
body.durationMinutes === undefined ? null : intValue(body.durationMinutes, 0),
body.totalScore === undefined || body.totalScore === null ? null : Number(body.totalScore),
body.passScore === undefined || body.passScore === null ? null : Number(body.passScore),
jsonArrayValue(body.sections),
jsonObjectValue(body.rules),
status,
intValue(body.order, 0),
auth.userId,
],
);
return result.rows[0];
});
await recordNavigationAudit(auth, 'content.practice_blueprint.upserted', 'practice_blueprint', String(item.id), { name, mode });
return { item };
}

View File

@@ -6,29 +6,103 @@ import { boolValue, intValue, jsonArrayValue, jsonObjectValue, nullableString, o
const QUESTION_STATUSES = ['draft', 'published', 'archived'];
async function assertOptionalReference(
client: { query: (sql: string, params?: unknown[]) => Promise<{ rows: unknown[] }> },
tenantId: string,
table: string,
id: string | null,
code: string,
) {
if (!id) return;
const row = await client.query(`select id from public.${table} where tenant_id = $1 and id = $2 limit 1`, [tenantId, id]);
if (!row.rows[0]) {
throw new HttpError(400, `${table} reference is not in this tenant`, code);
}
}
async function syncPrimaryCollectionItem(
client: { query: (sql: string, params?: unknown[]) => Promise<{ rows: unknown[] }> },
tenantId: string,
collectionId: string | null,
questionId: string,
) {
if (!collectionId) return;
await client.query(
`
insert into public.question_collection_items (tenant_id, collection_id, question_id, sort_order)
values (
$1, $2, $3,
coalesce((select max(sort_order) + 1 from public.question_collection_items where tenant_id = $1 and collection_id = $2), 0)
)
on conflict (tenant_id, collection_id, question_id) do nothing
`,
[tenantId, collectionId, questionId],
);
await client.query(
`
update public.question_collections
set question_count = (
select count(*)
from public.question_collection_items
where tenant_id = $1 and collection_id = $2
),
updated_at = now()
where tenant_id = $1 and id = $2
`,
[tenantId, collectionId],
);
}
export async function createQuestionRoute(ctx: RequestContext) {
const auth = await requireTenantContentEditor(ctx);
const body = await readJsonBody(ctx);
const item = await transaction(async client => {
const questionBankId = nullableString(body.questionBankId);
const subjectId = nullableString(body.subjectId);
const categoryId = nullableString(body.categoryId);
const nodeId = nullableString(body.nodeId);
const entryId = nullableString(body.entryId);
const contentNodeId = nullableString(body.contentNodeId);
const primaryCollectionId = nullableString(body.primaryCollectionId) || nullableString(body.collectionId);
await assertOptionalReference(client, auth.tenantId, 'question_banks', questionBankId, 'QUESTION_BANK_NOT_FOUND');
await assertOptionalReference(client, auth.tenantId, 'subjects', subjectId, 'SUBJECT_NOT_FOUND');
await assertOptionalReference(client, auth.tenantId, 'categories', categoryId, 'CATEGORY_NOT_FOUND');
await assertOptionalReference(client, auth.tenantId, 'module_nodes', nodeId, 'NODE_NOT_FOUND');
await assertOptionalReference(client, auth.tenantId, 'content_entries', entryId, 'ENTRY_NOT_FOUND');
await assertOptionalReference(client, auth.tenantId, 'content_nodes', contentNodeId, 'CONTENT_NODE_NOT_FOUND');
await assertOptionalReference(client, auth.tenantId, 'question_collections', primaryCollectionId, 'QUESTION_COLLECTION_NOT_FOUND');
const questionResult = await client.query(
`
insert into public.questions (
tenant_id, question_bank_id, subject_id, category_id, node_id,
legacy_id, type, type_label, difficulty, tags, media_url, status
entry_id, content_node_id, primary_collection_id, legacy_id, type,
type_label, difficulty, tags, media_url, status, exam_markers
)
values (
$1, $2::uuid, $3::uuid, $4::uuid, $5::uuid,
$6::uuid, $7::uuid, $8::uuid, $9, $10,
$11, $12, $13::jsonb, $14, $15, $16::jsonb
)
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"
entry_id as "entryId", content_node_id as "contentNodeId",
primary_collection_id as "primaryCollectionId", type,
type_label as "typeLabel", difficulty, tags, media_url as "mediaUrl",
status, exam_markers as "examMarkers", created_at as "createdAt",
updated_at as "updatedAt"
`,
[
auth.tenantId,
nullableString(body.questionBankId),
nullableString(body.subjectId),
nullableString(body.categoryId),
nullableString(body.nodeId),
questionBankId,
subjectId,
categoryId,
nodeId,
entryId,
contentNodeId,
primaryCollectionId,
nullableString(body.legacyId),
optionalString(body, 'type') || 'choice',
optionalString(body, 'typeLabel') || null,
@@ -36,9 +110,11 @@ export async function createQuestionRoute(ctx: RequestContext) {
jsonArrayValue(body.tags),
nullableString(body.mediaUrl),
optionalStatus(body.status, QUESTION_STATUSES, 'published'),
jsonObjectValue(body.examMarkers),
],
);
const question = questionResult.rows[0];
await syncPrimaryCollectionItem(client, auth.tenantId, primaryCollectionId, question.id);
const versionResult = await client.query(
`
@@ -93,6 +169,22 @@ export async function updateQuestionRoute(ctx: RequestContext) {
const createVersion = body.createVersion === true;
const item = await transaction(async client => {
const questionBankId = nullableString(body.questionBankId);
const subjectId = nullableString(body.subjectId);
const categoryId = nullableString(body.categoryId);
const nodeId = nullableString(body.nodeId);
const entryId = nullableString(body.entryId);
const contentNodeId = nullableString(body.contentNodeId);
const primaryCollectionId = nullableString(body.primaryCollectionId) || nullableString(body.collectionId);
await assertOptionalReference(client, auth.tenantId, 'question_banks', questionBankId, 'QUESTION_BANK_NOT_FOUND');
await assertOptionalReference(client, auth.tenantId, 'subjects', subjectId, 'SUBJECT_NOT_FOUND');
await assertOptionalReference(client, auth.tenantId, 'categories', categoryId, 'CATEGORY_NOT_FOUND');
await assertOptionalReference(client, auth.tenantId, 'module_nodes', nodeId, 'NODE_NOT_FOUND');
await assertOptionalReference(client, auth.tenantId, 'content_entries', entryId, 'ENTRY_NOT_FOUND');
await assertOptionalReference(client, auth.tenantId, 'content_nodes', contentNodeId, 'CONTENT_NODE_NOT_FOUND');
await assertOptionalReference(client, auth.tenantId, 'question_collections', primaryCollectionId, 'QUESTION_COLLECTION_NOT_FOUND');
const questionResult = await client.query(
`
update public.questions
@@ -100,26 +192,36 @@ export async function updateQuestionRoute(ctx: RequestContext) {
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),
entry_id = coalesce($7::uuid, entry_id),
content_node_id = coalesce($8::uuid, content_node_id),
primary_collection_id = coalesce($9::uuid, primary_collection_id),
type = coalesce(nullif($10, ''), type),
type_label = coalesce(nullif($11, ''), type_label),
difficulty = coalesce($12, difficulty),
tags = case when $13::boolean then $14::jsonb else tags end,
media_url = coalesce(nullif($15, ''), media_url),
status = coalesce(nullif($16, ''), status),
exam_markers = case when $17::boolean then $18::jsonb else exam_markers end,
updated_at = now()
where tenant_id = $1 and id = $2
returning id, tenant_id as "tenantId", question_bank_id as "questionBankId",
subject_id as "subjectId", category_id as "categoryId", node_id as "nodeId",
type, type_label as "typeLabel", difficulty, tags, media_url as "mediaUrl",
status, current_version_id as "currentVersionId", updated_at as "updatedAt"
entry_id as "entryId", content_node_id as "contentNodeId",
primary_collection_id as "primaryCollectionId", type,
type_label as "typeLabel", difficulty, tags, media_url as "mediaUrl",
status, exam_markers as "examMarkers",
current_version_id as "currentVersionId", updated_at as "updatedAt"
`,
[
auth.tenantId,
questionId,
nullableString(body.questionBankId),
nullableString(body.subjectId),
nullableString(body.categoryId),
nullableString(body.nodeId),
questionBankId,
subjectId,
categoryId,
nodeId,
entryId,
contentNodeId,
primaryCollectionId,
optionalString(body, 'type'),
optionalString(body, 'typeLabel'),
body.difficulty === undefined ? null : intValue(body.difficulty, 1),
@@ -127,11 +229,14 @@ export async function updateQuestionRoute(ctx: RequestContext) {
jsonArrayValue(body.tags),
nullableString(body.mediaUrl),
body.status ? optionalStatus(body.status, QUESTION_STATUSES, 'published') : '',
Object.hasOwn(body, 'examMarkers'),
jsonObjectValue(body.examMarkers),
],
);
const question = questionResult.rows[0];
if (!question) throw new HttpError(404, 'Question not found', 'QUESTION_NOT_FOUND');
await syncPrimaryCollectionItem(client, auth.tenantId, question.primaryCollectionId || null, question.id);
if (!createVersion) return question;

View File

@@ -16,13 +16,13 @@ apps/api/src/
auth/ 短信验证码、迁移期 session、OAuth provider 预留
health/ 健康检查
tenant/ 租户解析、品牌配置、域名识别
catalog/ 公开题库、科目、手册、商城、资料资源只读接口
learning/ 答题、错题、收藏、练习 session
catalog/ 公开题库、内容入口、分类树、题目集合、练习蓝图、手册、商城、资料资源只读接口
learning/ 组卷 session、答题、错题、收藏、练习进度
commerce/ 订单、支付确认、激活码、权益
referral/ 销售/代理客资追踪、首绑保护、团队关系、CRM 队列
platform-admin/ 平台方 SaaS 租户、订阅、账单、使用量
tenant-admin/ 租户品牌、域名、公开设置、登录/商户配置、成员权限、活动/兑换码运营
tenant-content/ 租户后台内容维护:题目、视频、分数线、单词、知识手册、资料资源、批量导入
tenant-content/ 租户后台内容维护:入口、分类树、集合、练习蓝图、题目、视频、分数线、单词、知识手册、资料资源、批量导入
```
## 新业务域落位
@@ -32,12 +32,12 @@ apps/api/src/
```text
features/
auth/ 登录、绑定手机、OAuth 回调、会话换取
learning/ 答题记录、错题、收藏、学习进度
learning/ 顺序/随机/模考组卷、答题记录、错题、收藏、学习进度
commerce/ 商品、订单、支付、退款、权益开通
referral/ 销售/代理增长链路、客资归属、分佣依据、CRM 入队
platform-admin/ 平台租户管理、年费、服务费、账务审计
tenant-admin/ 合作商后台配置、品牌、域名、收款账户、登录 provider、密钥掩码、成员权限、审计、活动、兑换码、优惠券
tenant-content/ 合作商内容维护、批量导入、资源绑定、内容审计
tenant-content/ 合作商内容导航、题库维护、批量导入、资源绑定、内容审计
```
每个 feature 默认包含:
@@ -66,5 +66,7 @@ types.ts 仅本领域使用的类型
- `tenant-admin` 的敏感配置必须拆分:公开字段进入 `config_public`商户密钥、短信密钥、OAuth app secret 进入 `app_private.tenant_secrets` 或生产 KMS/Vault对前端只返回 `secretRef` 和掩码状态。
- `tenant-admin` 权限由 `tenant_memberships.role` 的默认权限和 `permissions` JSON 覆盖共同决定;后端接口必须校验具体权限点,不能只依赖前端菜单隐藏。
- `referral` 是增长/客资业务域,负责邀请码、扫码事件、首绑保护、销售/代理团队归属和 CRM 入队;真实 CRM webhook 发送应由 worker 处理API 只负责幂等入队。
- 题库前端入口不再只依赖旧 `module_nodes/subjects/categories`;新业务主模型是 `content_entries/content_nodes/question_collections/practice_blueprints`,用于表达可视化入口、多级分类、考试意向标记、题目列表和顺序/随机/全真模拟规则。
- `learning` 创建练习 session 时必须保存 `question_ids` 快照,避免随机刷题和模考过程中题目集合变化导致答题记录无法复盘。
- 资料、PDF、视频等对象存储资源必须先进入 `content_assets` 台账,再通过 API 做权限校验和签名 URL 下发;前端不能直接拼 OSS/COS/Supabase Storage 地址。
- 批量导入必须先写 `content_import_jobs/items/issues`,保留原始 payload、规范化 payload、逐行问题和审计记录同步 API 当前支持题目 JSONExcel/CSV 和其它内容类型应接入同一管线。

View File

@@ -6,8 +6,8 @@
- API Docker 镜像 `tiku-saas-dev-api:latest` 已可构建,并可从容器连接宿主 Supabase PostgreSQL。
- API 已按 `core/features` 分层:
- `auth`:短信验证码登录、迁移期 session、OAuth provider 预留。
- `catalog`:公开题库、地区、科目、手册、商品、SVIP 套餐、资料资源只读/下载接口。
- `learning`练习 session、答题记录、错题、收藏、背单词进度/收藏/统计。
- `catalog`:公开题库、地区、内容入口、分类树、题目集合、练习蓝图、手册、商品、SVIP 套餐、资料资源只读/下载接口。
- `learning`顺序/随机/全真模拟组卷 session、答题记录、错题、收藏、背单词进度/收藏/统计。
- `profile`:学生个人中心、目标院校/专业、会员状态、统计聚合、最近练习。
- `scoreline`:分数线字段、院校、专业、记录、趋势、年份。
- `video`:题目视频讲解、批量预加载、通用视频搜索。
@@ -15,12 +15,12 @@
- `referral`:销售/代理邀请码、首绑客资保护、销售统计、团队关系、CRM 队列。
- `platform-admin`平台方租户管理、SaaS 套餐、订阅、账单、服务费收款、使用量。
- `tenant-admin`:租户资料、品牌、公开设置、域名、支付账户、登录 provider、私密密钥掩码、活动内容、激活码批次、优惠券、成员管理、权限矩阵、审计查询。
- `tenant-content`:租户后台题目、视频、分数线、单词、知识手册、资料资源、题目 JSON 导入维护。
- `tenant-content`:租户后台内容入口、任意深度分类树、考试意向标记、题目集合、练习蓝图、题目、视频、分数线、单词、知识手册、资料资源、题目 JSON 导入维护。
- `tenant`:域名/租户解析。
- `src/services/supabaseApi.ts` 已加入新 API 客户端方法,供旧 Web 逐步替换和后续 Taro 复用。
- 已新增 `npm run db:smoke-seed`,用于 `supabase:reset` 后恢复最小烟测数据。
- 已新增 `npm run smoke:core-api`,用于验证个人中心、分数线、题目视频、背单词进度/收藏等学生端核心 API。
- 已新增 `npm run test:api`,自动 seed、构建、启动临时 API并断言核心学生端接口、租户隔离、资源权限和题目导入。
- 已新增 `npm run test:api`,自动 seed、构建、启动临时 API并断言核心学生端接口、内容导航/组卷、租户隔离、资源权限和题目导入。
## 已验证接口
@@ -49,6 +49,11 @@ POST /api/platform-admin/invoices/payments/manual-confirm
GET /api/platform-admin/usage
POST /api/platform-admin/usage
GET /api/catalog/*
GET /api/catalog/content-entries
GET /api/catalog/content-nodes
GET /api/catalog/question-collections
GET /api/catalog/question-collections/questions
GET /api/catalog/practice-blueprints
GET /api/catalog/assets
GET /api/catalog/assets/download
POST /api/learning/answers
@@ -71,6 +76,15 @@ GET /api/scoreline/years
GET /api/questions/{questionId}/videos
POST /api/questions/videos/batch
GET /api/videos/search
GET /api/tenant-content/content-entries
PUT /api/tenant-content/content-entries
GET /api/tenant-content/content-nodes
PUT /api/tenant-content/content-nodes
GET /api/tenant-content/question-collections
PUT /api/tenant-content/question-collections
PUT /api/tenant-content/question-collections/items
GET /api/tenant-content/practice-blueprints
PUT /api/tenant-content/practice-blueprints
POST /api/tenant-content/questions
PATCH /api/tenant-content/questions
GET /api/tenant-content/assets
@@ -158,7 +172,8 @@ GET /api/tenant-admin/audit-logs
- 销售/代理客资采用首绑保护:普通扫码/分享事件不会覆盖已有归属,只有具备 `referral:write` 的租户成员可手动强制补绑。
- CRM 当前完成配置、密钥入私密表、客资入队和队列查询;真实 webhook 发送、重试、签名在后续 `apps/worker` 中实现。
- 内容资源当前完成台账、租户后台维护、上传/下载签名占位和学生端 SVIP 下载权限真实对象存储签名、PDF 预览渲染和防盗链在 provider/worker 中实现。
-目批量导入当前支持 JSON 数组预览、逐行 issue、job/item 台账、执行导入和幂等跳过Excel/CSV、单词/手册/分数线导入会复用同一套 `content_import_jobs` 管线
-库内容导航当前以 `content_entries/content_nodes` 为主模型,可表达“入口 -> 多级分类 -> 院校/专业/学科/销售意向标记”;题目集合和练习方式由 `question_collections/practice_blueprints` 管理,练习 session 会保存当次题目 ID 快照
- 题目批量导入当前支持 JSON 数组预览、逐行 issue、job/item 台账、执行导入、幂等跳过并可落到新内容入口、分类节点和题目集合Excel/CSV、单词/手册/分数线导入会复用同一套 `content_import_jobs` 管线。
## 下一步

View File

@@ -1,6 +1,6 @@
# SaaS 蓝图覆盖矩阵
更新时间2026-06-21 21:42
更新时间2026-06-21 22:52
## 目标定位
@@ -18,8 +18,8 @@
| 平台超级管理员 | 部分完成 | 租户管理、SaaS 套餐、订阅、账单、服务费收款、用量记录 | 公共题库披露策略、地区/全国套餐权限、平台侧主题模板库、平台审计 |
| 租户品牌和域名 | 基础完成 | 品牌、Logo、主题 JSON、公开资源、域名、租户公开配置 | 三套默认主题、主题可视化编辑、图标/图片上传 |
| 租户成员权限 | 基础完成 | owner/admin/operator/teacher/sales/agent/student权限矩阵成员启停审计查询 | 前端权限 UI、自定义角色模板、菜单级可见配置 |
| 题库内容维护 | 基础完成 | 题目录入/更新、题目 JSON 预览/导入、视频绑定、分数线、单词、知识手册后台 API | Excel/CSV 批量导入、分类/节点完整管理、公题库采纳/复制/授权 |
| 学生刷题 | 基础完成 | 题目列表、练习 session、答题、错题本、收藏夹 | 模考、专项练习策略、错题复习计划、题型统计深度分析 |
| 题库内容维护 | 基础完成 | 内容入口、任意深度分类树、院校/专业/学科/销售意向标记、题目集合、顺序/随机/全真模拟练习蓝图、题目录入/更新、题目 JSON 预览/导入、视频绑定、分数线、单词、知识手册后台 API | Excel/CSV 批量导入、公题库采纳/复制/授权、可视化拖拽排序前端 |
| 学生刷题 | 基础完成 | 内容入口、分类树、题目集合、顺序刷题、随机刷题、全真模拟 session 题目快照、答题、错题本、收藏夹 | 完整模考交卷评分报告、专项练习策略、错题复习计划、题型统计深度分析 |
| 背单词 | 基础完成 | 单词单元、单词、进度、收藏、统计 | 复习算法、每日计划、排行榜 |
| 知识手册 | 基础完成 | 科目、章节、条目只读与后台维护 | 富文本资源、版本管理、附件/PDF 关联 |
| 分数线 | 基础完成 | 字段、院校、专业、记录、趋势、年份 | 复杂动态筛选、批量导入、AI 择校数据上下文 |
@@ -38,6 +38,7 @@
1. 完善内容导入和对象存储Excel/CSV、单词/手册/分数线/视频导入,真实 OSS/COS/Supabase Storage 签名。
2. 公共题库/地区题库授权:平台题库向租户披露、租户采纳、按 SaaS 套餐限制地区。
3. 视频会员控制:视频资源签名 URL、防盗链、水印、播放次数和会员权益
4. 数据看板 API把旧 dashboard/revenue 统计迁到新 API
5. 真实 provider短信、微信/QQ 登录、微信支付/支付宝、CRM worker
3. 完整模考与学习统计:交卷、评分报告、练习历史、正确率趋势、错题复习计划
4. 视频会员控制:视频资源签名 URL、防盗链、水印、播放次数和会员权益
5. 数据看板 API把旧 dashboard/revenue 统计迁到新 API
6. 真实 provider短信、微信/QQ 登录、微信支付/支付宝、CRM worker。

View File

@@ -1,10 +1,10 @@
# Supabase 重构功能进度矩阵
更新时间2026-06-21 21:42
更新时间2026-06-21 22:52
## 当前结论
当前重构已经完成了 Supabase/PostgreSQL 多租户底座、核心业务表、PocketBase 数据导入器雏形、部分学生端 API、租户后台 API、平台后台 SaaS 账务 API、内容资产/题目 JSON 批量导入基础闭环,以及本地 Docker/API 构建验证。
当前重构已经完成了 Supabase/PostgreSQL 多租户底座、核心业务表、PocketBase 数据导入器雏形、学生端核心 API、租户后台 API、平台后台 SaaS 账务 API、内容资产/题目 JSON 批量导入基础闭环、题库入口/任意深度分类/题目集合/练习蓝图/组卷快照基础闭环,以及本地 Docker/API 构建验证。
但这还不是完整商用交付状态,也不能说旧项目核心功能已经全部重构完成。现在更准确的状态是:后端商用架构骨架已经立住,核心业务正在按模块补齐。部分功能已经有可调用 API部分功能只有数据模型和导入映射部分功能还没有前端/自动化测试闭环。
@@ -24,7 +24,7 @@
| 模块 | 数据模型 | PocketBase 导入 | API | 自动化测试 | 当前状态 |
| --- | --- | --- | --- | --- | --- |
| 多租户隔离 | 已建 `tenants``tenant_domains``tenant_branding``tenant_settings`、RLS 基础 | 部分支持 | 租户解析、品牌、域名、支付账户、登录 provider、平台建租户已实现 | 核心 API 集成测试含租户隔离断言 | 基础可用,正式 JWT/RLS 权限闭环未完成 |
| 刷题题库 | 已建题库、题目、题目版本、分类、地区、科目、导入任务台账 | 已支持核心映射 | 题目列表、练习 session、答题提交、租户后台题目录入/更新、JSON 预览/导入已实现 | 核心 API 集成测试含导入断言 | 基础刷题链路、后台题目录入和 JSON 批量导入可跑,专项练习/模考/Excel 导入仍需补齐 |
| 刷题题库 | 已建题库、题目、题目版本、内容入口、任意深度分类树、考试意向标记、题目集合、练习蓝图、导入任务台账 | 已支持核心映射JSON 导入可落到新入口/节点/集合 | 题目列表、内容入口、分类树、集合题目、顺序/随机/全真模拟 session、答题提交、租户后台题目录入/更新、JSON 预览/导入已实现 | 核心 API 集成测试含导航、组卷、导入断言 | 新题库导航和组卷基础闭环可跑完整交卷评分报告、Excel 导入、公题库采纳/授权仍需补齐 |
| 错题本 | 已建 `wrong_questions` | 已支持旧错题归一化 | 错题列表、答题自动入错题、移出错题已实现 | 仅烟测 | 基础功能已实现,复习计划和统计未完成 |
| 收藏夹 | 已建 `favorite_questions` | 已支持旧收藏归一化 | 收藏/取消收藏、收藏列表已实现 | 仅烟测 | 基础功能已实现 |
| 用户订阅/题库会员/SVIP | 已建 `orders``payments``entitlements``svip_plans`、激活码 | 已映射旧 SVIP/会员权益 | 下单、手动支付确认、激活码兑换、权益查询已实现 | 仅烟测 | 业务骨架可跑,真实微信/支付宝支付和 webhook 未完成 |
@@ -36,7 +36,7 @@
| 个人中心 | 已建 `student_profiles`、会员权益、订单、练习记录 | 已支持部分用户资料导入 | 个人资料、目标院校/专业、会员状态、最近练习、统计聚合 API 已实现 | 核心 API 烟测 | 学生端基础个人中心已实现,签到/任务/更细统计待补 |
| 活动/优惠 | 已建优惠券、激活码、激活码批次、banner、FAQ、公告等基础表 | 部分支持 | banner/FAQ/公告只读与租户后台维护、激活码兑换、激活码批次、批量生成激活码、优惠券维护已实现 | 核心 API 集成测试 | 基础运营后台可用,复杂活动规则、营销自动化、核销报表待补 |
| 销售/代理客资追踪 | 已建推荐码、首绑客资、团队关系、小程序码缓存、CRM 队列 | 旧 `referral_tracks` 已有映射基础 | 邀请码、扫码/分享事件、首绑保护、销售统计、客资明细、手动补绑、团队关系、CRM 配置/队列已实现 | 核心 API 集成测试 | 增长链路基础可用真实微信小程序码、分佣结算单、CRM worker 推送待补 |
| 租户后台 | 已建品牌、域名、设置、支付账户、登录 provider、私密密钥表、成员、审计日志、资源台账、导入台账 | 不适用 | 概览、品牌、设置、域名、支付账户、登录配置、密钥掩码、活动内容、兑换码/优惠券、成员管理、权限矩阵、审计查询、内容维护、资源管理、题目 JSON 导入已实现 | 核心 API 集成测试含角色/权限/租户隔离/密钥不泄露/资源与导入断言 | 租户配置与运营闭环可用,前端权限 UI、Excel 导入、真实对象存储签名待补 |
| 租户后台 | 已建品牌、域名、设置、支付账户、登录 provider、私密密钥表、成员、审计日志、资源台账、导入台账、内容导航台账 | 不适用 | 概览、品牌、设置、域名、支付账户、登录配置、密钥掩码、活动内容、兑换码/优惠券、成员管理、权限矩阵、审计查询、内容入口/分类树/题目集合/练习蓝图维护、资源管理、题目 JSON 导入已实现 | 核心 API 集成测试含角色/权限/租户隔离/密钥不泄露/导航/组卷/资源与导入断言 | 租户配置与运营闭环可用,前端权限 UI、Excel 导入、真实对象存储签名待补 |
| 平台后台 | 已建 SaaS 套餐、订阅、账单、服务费、用量 | 不适用 | 租户管理、账单、收款确认、用量记录已实现 | 仅烟测 | 平台收费链路骨架可用,正式鉴权/审计/自动计费未完成 |
| 登录认证 | 已建短信验证码、会话、OAuth provider 配置表 | 旧用户映射已预留 | 短信 mock 登录、迁移期 session、OAuth 占位已实现 | 仅烟测 | 本地可测,真实短信/微信/QQ 登录未完成 |
| 数据导入 | 已建立 importer、risk report、validate | 已覆盖多类旧集合 | 命令行导入/校验 | `pb:import:validate` | 基础工具可用,需用真实完整数据做多轮 dry-run |
@@ -66,6 +66,11 @@ catalog:
GET /api/catalog/subjects
GET /api/catalog/categories
GET /api/catalog/questions
GET /api/catalog/content-entries
GET /api/catalog/content-nodes
GET /api/catalog/question-collections
GET /api/catalog/question-collections/questions
GET /api/catalog/practice-blueprints
GET /api/catalog/assets
GET /api/catalog/assets/download
GET /api/catalog/vocabulary-units
@@ -112,6 +117,15 @@ video:
GET /api/videos/search
tenant-content:
GET /api/tenant-content/content-entries
PUT /api/tenant-content/content-entries
GET /api/tenant-content/content-nodes
PUT /api/tenant-content/content-nodes
GET /api/tenant-content/question-collections
PUT /api/tenant-content/question-collections
PUT /api/tenant-content/question-collections/items
GET /api/tenant-content/practice-blueprints
PUT /api/tenant-content/practice-blueprints
POST /api/tenant-content/questions
PATCH /api/tenant-content/questions
GET /api/tenant-content/assets

View File

@@ -16,6 +16,14 @@ const ids = {
region: '00000000-0000-0000-0000-000000000301',
subject: '00000000-0000-0000-0000-000000000501',
category: '00000000-0000-0000-0000-000000000601',
contentEntry: '00000000-0000-0000-0000-000000000611',
contentNodeCulture: '00000000-0000-0000-0000-000000000612',
contentNodeProfessional: '00000000-0000-0000-0000-000000000613',
contentNodeSchoolTarget: '00000000-0000-0000-0000-000000000614',
questionCollection: '00000000-0000-0000-0000-000000000615',
practiceBlueprintSequential: '00000000-0000-0000-0000-000000000616',
practiceBlueprintRandom: '00000000-0000-0000-0000-000000000617',
practiceBlueprintMock: '00000000-0000-0000-0000-000000000618',
question: '00000000-0000-0000-0000-000000000401',
vocabularyUnit: '00000000-0000-0000-0000-000000000811',
vocabularyWord: '00000000-0000-0000-0000-000000000812',
@@ -126,6 +134,45 @@ async function testCatalogAndLearning() {
const question = questions.items?.find(item => item.id === ids.question);
assert.ok(question, 'main tenant should return smoke question');
assert.equal(question.hasVideoExplanation, true, 'smoke question should expose video marker');
assert.equal(question.contentNodeId, ids.contentNodeSchoolTarget, 'question should expose new content node binding');
const entries = await request('/api/catalog/content-entries', {
query: { regionId: ids.region, entryType: 'question_practice' },
});
assert.ok(entries.items?.some(item => item.id === ids.contentEntry), 'catalog should expose question practice entry');
const rootNodes = await request('/api/catalog/content-nodes', {
query: { entryId: ids.contentEntry, parentId: 'root' },
});
assert.ok(rootNodes.items?.some(item => item.id === ids.contentNodeProfessional), 'catalog should expose root professional node');
const schoolNodes = await request('/api/catalog/content-nodes', {
query: { entryId: ids.contentEntry, markerType: 'school', mode: 'flat' },
});
assert.ok(
schoolNodes.items?.some(item => item.id === ids.contentNodeSchoolTarget && item.markerConfig?.salesIntent === true),
'catalog should expose school target marker for sales intent',
);
const collections = await request('/api/catalog/question-collections', {
query: { nodeId: ids.contentNodeSchoolTarget },
});
assert.ok(collections.items?.some(item => item.id === ids.questionCollection), 'catalog should expose node question collection');
const collectionQuestions = await request('/api/catalog/question-collections/questions', {
query: { collectionId: ids.questionCollection },
});
assert.ok(collectionQuestions.items?.some(item => item.id === ids.question), 'collection questions should include smoke question');
const blueprintList = await request('/api/catalog/practice-blueprints', {
query: { collectionId: ids.questionCollection },
});
assert.ok(blueprintList.items?.some(item => item.id === ids.practiceBlueprintMock), 'catalog should expose mock exam blueprint');
const questionsByNode = await request('/api/catalog/questions', {
query: { contentNodeId: ids.contentNodeSchoolTarget, limit: 20 },
});
assert.ok(questionsByNode.items?.some(item => item.id === ids.question), 'catalog questions should filter by contentNodeId');
const session = await request('/api/learning/practice-sessions', {
method: 'POST',
@@ -133,6 +180,43 @@ async function testCatalogAndLearning() {
});
assert.ok(session.item?.id, 'practice session should be created');
const sequentialSession = await request('/api/learning/practice-sessions', {
method: 'POST',
body: {
userId: USER_ID,
mode: 'sequential',
collectionId: ids.questionCollection,
questionLimit: 5,
},
});
assert.equal(sequentialSession.item?.collectionId, ids.questionCollection, 'collection session should bind collection');
assert.ok(sequentialSession.item?.questionIds?.includes(ids.question), 'collection session should snapshot question ids');
const nodeSession = await request('/api/learning/practice-sessions', {
method: 'POST',
body: {
userId: USER_ID,
mode: 'random',
contentNodeId: ids.contentNodeProfessional,
questionLimit: 5,
},
});
assert.equal(nodeSession.item?.contentNodeId, ids.contentNodeProfessional, 'node session should bind parent content node');
assert.ok(nodeSession.item?.questionIds?.includes(ids.question), 'node session should include descendant questions');
const mockSession = await request('/api/learning/practice-sessions', {
method: 'POST',
body: {
userId: USER_ID,
blueprintId: ids.practiceBlueprintMock,
},
});
assert.equal(mockSession.item?.mode, 'mock_exam', 'mock blueprint should create mock_exam session');
assert.equal(mockSession.item?.blueprintId, ids.practiceBlueprintMock, 'mock session should bind blueprint');
assert.equal(mockSession.item?.durationMinutes, 120, 'mock session should inherit duration');
assert.equal(Number(mockSession.item?.totalScore), 100, 'mock session should inherit total score');
assert.ok(mockSession.item?.questionIds?.includes(ids.question), 'mock session should snapshot assembled questions');
const answer = await request('/api/learning/answers', {
method: 'POST',
body: {
@@ -251,6 +335,26 @@ async function testTenantIsolation() {
query: { unitId: ids.vocabularyUnit },
});
assert.equal(partnerWordStats.item?.totalWords, 0, 'partner tenant must not see main tenant vocabulary words');
const partnerEntries = await request('/api/catalog/content-entries', {
tenantId: PARTNER_TENANT_ID,
query: { regionId: ids.region },
});
assert.ok(!partnerEntries.items?.some(item => item.id === ids.contentEntry), 'partner tenant must not see main tenant content entries');
const partnerCollections = await request('/api/catalog/question-collections', {
tenantId: PARTNER_TENANT_ID,
query: { nodeId: ids.contentNodeSchoolTarget },
});
assert.ok(!partnerCollections.items?.some(item => item.id === ids.questionCollection), 'partner tenant must not see main tenant collections');
const partnerBlueprintSession = await request('/api/learning/practice-sessions', {
tenantId: PARTNER_TENANT_ID,
method: 'POST',
body: { userId: USER_ID, blueprintId: ids.practiceBlueprintMock },
expectStatus: 404,
});
assert.equal(partnerBlueprintSession.code, 'PRACTICE_BLUEPRINT_NOT_FOUND', 'partner tenant must not assemble main tenant blueprint');
}
async function testTenantContentAdmin() {
@@ -261,6 +365,64 @@ async function testTenantContentAdmin() {
});
assert.equal(denied.code, 'TENANT_CONTENT_EDITOR_REQUIRED', 'student should not write tenant content');
const deniedEntry = await request('/api/tenant-content/content-entries', {
method: 'PUT',
body: { entryKey: 'student-denied', name: '学生不能配置入口' },
expectStatus: 403,
});
assert.equal(deniedEntry.code, 'TENANT_CONTENT_EDITOR_REQUIRED', 'student should not manage content navigation');
const entry = await request('/api/tenant-content/content-entries', {
userId: TENANT_ADMIN_USER_ID,
method: 'PUT',
body: {
entryKey: 'integration-practice',
regionId: ids.region,
name: '集成测试刷题入口',
entryType: 'question_practice',
route: '/practice/integration',
layoutConfig: { tabs: ['all', 'paper', 'chapter', 'type'] },
order: 11,
},
});
assert.equal(entry.item?.entryKey, 'integration-practice', 'tenant admin should create content entry');
const rootNode = await request('/api/tenant-content/content-nodes', {
userId: TENANT_ADMIN_USER_ID,
method: 'PUT',
body: {
entryId: entry.item.id,
nodeKey: 'integration-professional',
regionId: ids.region,
name: '专业课',
nodeType: 'category',
markerType: 'exam_track',
markerConfig: { intentKey: 'professional' },
isLeaf: false,
order: 1,
},
});
assert.equal(rootNode.item?.markerType, 'exam_track', 'tenant admin should create marked root node');
const childNode = await request('/api/tenant-content/content-nodes', {
userId: TENANT_ADMIN_USER_ID,
method: 'PUT',
body: {
entryId: entry.item.id,
parentId: rootNode.item.id,
nodeKey: 'integration-school-target',
regionId: ids.region,
name: '集成测试学院',
nodeType: 'school',
markerType: 'school',
markerConfig: { salesIntent: true, schoolName: '集成测试学院' },
isLeaf: true,
order: 1,
},
});
assert.equal(childNode.item?.parentId, rootNode.item.id, 'tenant admin should create child node');
assert.ok(String(childNode.item?.path || '').includes(String(rootNode.item?.path || '')), 'child node should have hierarchical path');
const unit = await request('/api/tenant-content/vocabulary-units', {
userId: TENANT_ADMIN_USER_ID,
method: 'PUT',
@@ -304,6 +466,8 @@ async function testTenantContentAdmin() {
body: {
subjectId: '00000000-0000-0000-0000-000000000501',
categoryId: '00000000-0000-0000-0000-000000000601',
entryId: entry.item.id,
contentNodeId: childNode.item.id,
type: 'choice',
typeLabel: '单选题',
difficulty: 2,
@@ -317,11 +481,71 @@ async function testTenantContentAdmin() {
answerText: '4',
explanation: '基础加法。',
status: 'published',
examMarkers: { schoolName: '集成测试学院', salesIntent: true },
},
});
assert.ok(question.item?.id, 'tenant admin should create question');
assert.equal(question.item?.currentVersion?.correctOptionIndex, 1, 'created question should have a version');
const collection = await request('/api/tenant-content/question-collections', {
userId: TENANT_ADMIN_USER_ID,
method: 'PUT',
body: {
entryId: entry.item.id,
nodeId: childNode.item.id,
regionId: ids.region,
subjectId: ids.subject,
categoryId: ids.category,
questionBankId: '00000000-0000-0000-0000-000000000400',
name: '集成测试题目列表',
collectionType: 'manual',
sourceType: 'manual_questions',
durationMinutes: 90,
totalScore: 100,
questions: [{ questionId: question.item.id, sectionKey: 'choice', order: 1, score: 2 }],
},
});
assert.equal(collection.item?.questionCount, 1, 'tenant admin should create question collection with items');
const blueprint = await request('/api/tenant-content/practice-blueprints', {
userId: TENANT_ADMIN_USER_ID,
method: 'PUT',
body: {
entryId: entry.item.id,
nodeId: childNode.item.id,
collectionId: collection.item.id,
regionId: ids.region,
name: '集成测试全真模拟',
mode: 'mock_exam',
assemblyType: 'collection',
questionLimit: 10,
durationMinutes: 90,
totalScore: 100,
passScore: 60,
sections: [{ key: 'choice', questionType: 'choice', questionCount: 10, scoreEach: 2 }],
rules: { randomize: true },
},
});
assert.equal(blueprint.item?.mode, 'mock_exam', 'tenant admin should create mock exam blueprint');
const adminNodes = await request('/api/tenant-content/content-nodes', {
userId: TENANT_ADMIN_USER_ID,
query: { entryId: entry.item.id, mode: 'flat' },
});
assert.ok(adminNodes.items?.some(item => item.id === childNode.item.id), 'tenant admin should list created navigation nodes');
const publicCreatedCollections = await request('/api/catalog/question-collections', {
query: { nodeId: childNode.item.id },
});
assert.ok(publicCreatedCollections.items?.some(item => item.id === collection.item.id), 'catalog should expose created collection');
const createdBlueprintSession = await request('/api/learning/practice-sessions', {
method: 'POST',
body: { userId: USER_ID, blueprintId: blueprint.item.id },
});
assert.equal(createdBlueprintSession.item?.blueprintId, blueprint.item.id, 'created blueprint should assemble a practice session');
assert.ok(createdBlueprintSession.item?.questionIds?.includes(question.item.id), 'created blueprint session should include created question');
const binding = await request('/api/tenant-content/question-videos', {
userId: TENANT_ADMIN_USER_ID,
method: 'POST',
@@ -517,6 +741,9 @@ async function testTenantContentAssetsAndImports() {
subjectId: ids.subject,
categoryId: ids.category,
regionId: ids.region,
entryId: ids.contentEntry,
contentNodeId: ids.contentNodeSchoolTarget,
collectionId: ids.questionCollection,
items: [
{
legacyId: 'integration-import-choice-001',
@@ -568,11 +795,11 @@ async function testTenantContentAssetsAndImports() {
assert.ok(jobs.items?.some(item => item.id === validPreview.job.id && item.status === 'completed'), 'import job list should include completed job');
const importedQuestions = await request('/api/catalog/questions', {
query: { categoryId: ids.category, limit: 100 },
query: { collectionId: ids.questionCollection, limit: 100 },
});
assert.ok(
importedQuestions.items?.some(item => item.content === '批量导入题:企业级 SaaS 应优先使用哪种数据库?'),
'catalog should expose imported question',
'catalog should expose imported question through the new collection binding',
);
const partnerImports = await request('/api/tenant-content/imports', {

View File

@@ -14,6 +14,14 @@ const ids = {
region: '00000000-0000-0000-0000-000000000301',
subject: '00000000-0000-0000-0000-000000000501',
category: '00000000-0000-0000-0000-000000000601',
contentEntry: '00000000-0000-0000-0000-000000000611',
contentNodeCulture: '00000000-0000-0000-0000-000000000612',
contentNodeProfessional: '00000000-0000-0000-0000-000000000613',
contentNodeSchoolTarget: '00000000-0000-0000-0000-000000000614',
questionCollection: '00000000-0000-0000-0000-000000000615',
practiceBlueprintSequential: '00000000-0000-0000-0000-000000000616',
practiceBlueprintRandom: '00000000-0000-0000-0000-000000000617',
practiceBlueprintMock: '00000000-0000-0000-0000-000000000618',
questionBank: '00000000-0000-0000-0000-000000000400',
question: '00000000-0000-0000-0000-000000000401',
questionVersion: '00000000-0000-0000-0000-000000000402',
@@ -238,20 +246,235 @@ async function main() {
[ids.questionBank, tenantId, ids.region],
);
await client.query(
`
insert into public.content_entries (
id, tenant_id, region_id, legacy_id, entry_key, name, entry_type,
icon, route, description, visibility, layout_config, sort_order, is_active, created_by, updated_by
)
values (
$1, $2, $3, 'smoke-practice-entry', 'smoke-question-practice', '烟测刷题入口',
'question_practice', 'book-open', '/practice', '用于验证新内容导航和组卷链路',
'public', '{"tabs":["all","paper","chapter","type"]}'::jsonb, 1, true, $4, $4
)
on conflict (id)
do update set region_id = excluded.region_id,
name = excluded.name,
entry_key = excluded.entry_key,
entry_type = excluded.entry_type,
layout_config = excluded.layout_config,
is_active = true,
updated_at = now()
`,
[ids.contentEntry, tenantId, ids.region, ids.tenantAdminUser],
);
await client.query(
`
insert into public.content_nodes (
id, tenant_id, entry_id, region_id, parent_id, legacy_id, node_key,
name, node_type, marker_type, marker_config, path, depth, sort_order,
is_active, is_selectable, is_leaf, metadata, created_by, updated_by
)
values
(
$1, $2, $5, $6, null, 'smoke-culture-node', 'culture',
'文化课', 'category', 'exam_track', '{"intentKey":"culture"}'::jsonb,
'n_000000000000000000000000000000612'::ltree, 0, 1,
true, true, true, '{"businessMeaning":"公共课入口"}'::jsonb, $7, $7
),
(
$3, $2, $5, $6, null, 'smoke-professional-node', 'professional',
'专业课', 'category', 'exam_track', '{"intentKey":"professional"}'::jsonb,
'n_000000000000000000000000000000613'::ltree, 0, 2,
true, true, false, '{"businessMeaning":"院校自主命题入口"}'::jsonb, $7, $7
),
(
$4, $2, $5, $6, $3, 'smoke-school-target-node', 'professional-school-a',
'烟测学院专业课', 'school', 'school', '{"schoolName":"烟测学院","salesIntent":true}'::jsonb,
'n_000000000000000000000000000000613.n_000000000000000000000000000000614'::ltree, 1, 1,
true, true, true, '{"businessMeaning":"学生目标院校意向"}'::jsonb, $7, $7
)
on conflict (id)
do update set name = excluded.name,
parent_id = excluded.parent_id,
node_type = excluded.node_type,
marker_type = excluded.marker_type,
marker_config = excluded.marker_config,
path = excluded.path,
depth = excluded.depth,
is_leaf = excluded.is_leaf,
metadata = excluded.metadata,
updated_at = now()
`,
[
ids.contentNodeCulture,
tenantId,
ids.contentNodeProfessional,
ids.contentNodeSchoolTarget,
ids.contentEntry,
ids.region,
ids.tenantAdminUser,
],
);
await client.query(
`
insert into public.question_collections (
id, tenant_id, region_id, entry_id, node_id, subject_id, category_id,
question_bank_id, legacy_id, name, collection_type, source_type,
filters, question_count, total_score, duration_minutes, status,
sort_order, metadata, created_by, updated_by
)
values (
$1, $2, $3, $4, $5, $6, $7,
$8, 'smoke-collection', '烟测学院专业课题目列表', 'manual', 'manual_questions',
'{"tabs":["all","paper","chapter","type"]}'::jsonb, 0, 100, 120, 'active',
1, '{"business":"supports sequential random mock exam"}'::jsonb, $9, $9
)
on conflict (id)
do update set node_id = excluded.node_id,
subject_id = excluded.subject_id,
category_id = excluded.category_id,
question_bank_id = excluded.question_bank_id,
name = excluded.name,
collection_type = excluded.collection_type,
source_type = excluded.source_type,
filters = excluded.filters,
total_score = excluded.total_score,
duration_minutes = excluded.duration_minutes,
status = 'active',
updated_at = now()
`,
[
ids.questionCollection,
tenantId,
ids.region,
ids.contentEntry,
ids.contentNodeSchoolTarget,
ids.subject,
ids.category,
ids.questionBank,
ids.tenantAdminUser,
],
);
await client.query(
`
insert into public.questions (
id, tenant_id, question_bank_id, subject_id, category_id,
legacy_id, type, type_label, difficulty, status
entry_id, content_node_id, primary_collection_id,
legacy_id, type, type_label, difficulty, status, exam_markers
)
values (
$1, $2, $3, $4, $5, $6, $7, $8,
'smoke-question', 'choice', '单选题', 1, 'published',
'{"examTrack":"professional","school":"烟测学院"}'::jsonb
)
values ($1, $2, $3, $4, $5, 'smoke-question', 'choice', '单选题', 1, 'published')
on conflict (id)
do update set question_bank_id = excluded.question_bank_id,
subject_id = excluded.subject_id,
category_id = excluded.category_id,
entry_id = excluded.entry_id,
content_node_id = excluded.content_node_id,
primary_collection_id = excluded.primary_collection_id,
exam_markers = excluded.exam_markers,
updated_at = now()
`,
[ids.question, tenantId, ids.questionBank, ids.subject, ids.category],
[
ids.question,
tenantId,
ids.questionBank,
ids.subject,
ids.category,
ids.contentEntry,
ids.contentNodeSchoolTarget,
ids.questionCollection,
],
);
await client.query(
`
insert into public.question_collection_items (
tenant_id, collection_id, question_id, section_key, sort_order, score, required, metadata
)
values ($1, $2, $3, 'choice', 1, 2, true, '{"source":"smoke-seed"}'::jsonb)
on conflict (tenant_id, collection_id, question_id)
do update set section_key = excluded.section_key,
sort_order = excluded.sort_order,
score = excluded.score,
updated_at = now()
`,
[tenantId, ids.questionCollection, ids.question],
);
await client.query(
`
update public.question_collections
set question_count = (
select count(*)
from public.question_collection_items
where tenant_id = $1 and collection_id = $2
),
updated_at = now()
where tenant_id = $1 and id = $2
`,
[tenantId, ids.questionCollection],
);
await client.query(
`
insert into public.practice_blueprints (
id, tenant_id, region_id, entry_id, node_id, collection_id,
legacy_id, name, mode, assembly_type, question_limit,
duration_minutes, total_score, pass_score, sections, rules,
status, sort_order, created_by, updated_by
)
values
(
$1, $4, $5, $6, $7, $8,
'smoke-sequential-blueprint', '烟测顺序刷题', 'sequential', 'collection', 20,
null, null, null, '[]'::jsonb, '{"randomize":false}'::jsonb,
'active', 1, $9, $9
),
(
$2, $4, $5, $6, $7, $8,
'smoke-random-blueprint', '烟测随机刷题', 'random', 'collection', 20,
null, null, null, '[]'::jsonb, '{"randomize":true}'::jsonb,
'active', 2, $9, $9
),
(
$3, $4, $5, $6, $7, $8,
'smoke-mock-blueprint', '烟测全真模拟', 'mock_exam', 'collection', 10,
120, 100, 60,
'[{"key":"choice","title":"单选题","questionType":"choice","questionCount":10,"scoreEach":2}]'::jsonb,
'{"randomize":true,"showAnalysisAfterSubmit":false}'::jsonb,
'active', 3, $9, $9
)
on conflict (id)
do update set name = excluded.name,
mode = excluded.mode,
assembly_type = excluded.assembly_type,
question_limit = excluded.question_limit,
duration_minutes = excluded.duration_minutes,
total_score = excluded.total_score,
pass_score = excluded.pass_score,
sections = excluded.sections,
rules = excluded.rules,
status = 'active',
updated_at = now()
`,
[
ids.practiceBlueprintSequential,
ids.practiceBlueprintRandom,
ids.practiceBlueprintMock,
tenantId,
ids.region,
ids.contentEntry,
ids.contentNodeSchoolTarget,
ids.questionCollection,
ids.tenantAdminUser,
],
);
await client.query(

View File

@@ -0,0 +1,191 @@
create extension if not exists ltree;
create table if not exists public.content_entries (
id uuid primary key default gen_random_uuid(),
tenant_id uuid not null references public.tenants(id) on delete cascade,
region_id uuid references public.regions(id) on delete set null,
legacy_id text,
entry_key text not null,
name text not null,
entry_type text not null default 'question_practice'
check (entry_type in ('question_practice', 'vocabulary', 'handbook', 'scoreline', 'resource', 'ai_report', 'custom')),
icon text,
route text,
description text,
visibility text not null default 'public'
check (visibility in ('public', 'members', 'svip', 'hidden')),
access_rules jsonb not null default '{}'::jsonb,
layout_config jsonb not null default '{}'::jsonb,
sort_order integer not null default 0,
is_active boolean not null default true,
created_by uuid references public.platform_users(id) on delete set null,
updated_by uuid references public.platform_users(id) on delete set null,
created_at timestamptz not null default now(),
updated_at timestamptz not null default now(),
unique (tenant_id, entry_key),
unique (tenant_id, legacy_id)
);
create table if not exists public.content_nodes (
id uuid primary key default gen_random_uuid(),
tenant_id uuid not null references public.tenants(id) on delete cascade,
entry_id uuid not null references public.content_entries(id) on delete cascade,
region_id uuid references public.regions(id) on delete set null,
parent_id uuid references public.content_nodes(id) on delete cascade,
legacy_id text,
node_key text,
name text not null,
node_type text not null default 'category'
check (node_type in ('category', 'subject', 'chapter', 'paper', 'school', 'major', 'exam_target', 'resource_group', 'custom')),
marker_type text
check (marker_type is null or marker_type in ('school', 'major', 'subject', 'exam_track', 'course_package', 'sales_intent', 'custom')),
marker_config jsonb not null default '{}'::jsonb,
path ltree,
depth integer not null default 0 check (depth >= 0),
sort_order integer not null default 0,
is_active boolean not null default true,
is_selectable boolean not null default true,
is_leaf boolean not null default false,
metadata jsonb not null default '{}'::jsonb,
created_by uuid references public.platform_users(id) on delete set null,
updated_by uuid references public.platform_users(id) on delete set null,
created_at timestamptz not null default now(),
updated_at timestamptz not null default now(),
unique (tenant_id, legacy_id),
unique (tenant_id, entry_id, node_key)
);
create table if not exists public.question_collections (
id uuid primary key default gen_random_uuid(),
tenant_id uuid not null references public.tenants(id) on delete cascade,
region_id uuid references public.regions(id) on delete set null,
entry_id uuid references public.content_entries(id) on delete set null,
node_id uuid references public.content_nodes(id) on delete set null,
subject_id uuid references public.subjects(id) on delete set null,
category_id uuid references public.categories(id) on delete set null,
question_bank_id uuid references public.question_banks(id) on delete set null,
legacy_id text,
name text not null,
collection_type text not null default 'dynamic'
check (collection_type in ('dynamic', 'manual', 'paper', 'chapter', 'mock_exam')),
source_type text not null default 'filters'
check (source_type in ('filters', 'manual_questions', 'node_descendants', 'category', 'subject', 'question_bank')),
filters jsonb not null default '{}'::jsonb,
question_count integer not null default 0 check (question_count >= 0),
total_score numeric(8,2),
duration_minutes integer check (duration_minutes is null or duration_minutes > 0),
status text not null default 'active' check (status in ('draft', 'active', 'archived')),
sort_order integer not null default 0,
metadata jsonb not null default '{}'::jsonb,
created_by uuid references public.platform_users(id) on delete set null,
updated_by uuid references public.platform_users(id) on delete set null,
created_at timestamptz not null default now(),
updated_at timestamptz not null default now(),
unique (tenant_id, legacy_id)
);
create table if not exists public.question_collection_items (
id uuid primary key default gen_random_uuid(),
tenant_id uuid not null references public.tenants(id) on delete cascade,
collection_id uuid not null references public.question_collections(id) on delete cascade,
question_id uuid not null references public.questions(id) on delete cascade,
section_key text,
sort_order integer not null default 0,
score numeric(8,2),
required boolean not null default true,
metadata jsonb not null default '{}'::jsonb,
created_at timestamptz not null default now(),
updated_at timestamptz not null default now(),
unique (tenant_id, collection_id, question_id)
);
create table if not exists public.practice_blueprints (
id uuid primary key default gen_random_uuid(),
tenant_id uuid not null references public.tenants(id) on delete cascade,
region_id uuid references public.regions(id) on delete set null,
entry_id uuid references public.content_entries(id) on delete set null,
node_id uuid references public.content_nodes(id) on delete set null,
collection_id uuid references public.question_collections(id) on delete set null,
legacy_id text,
name text not null,
mode text not null check (mode in ('sequential', 'random', 'mock_exam', 'paper', 'wrong_review', 'favorite_review')),
assembly_type text not null default 'collection'
check (assembly_type in ('collection', 'node_descendants', 'manual', 'filters')),
question_limit integer check (question_limit is null or question_limit > 0),
duration_minutes integer check (duration_minutes is null or duration_minutes > 0),
total_score numeric(8,2),
pass_score numeric(8,2),
sections jsonb not null default '[]'::jsonb,
rules jsonb not null default '{}'::jsonb,
status text not null default 'active' check (status in ('draft', 'active', 'archived')),
sort_order integer not null default 0,
created_by uuid references public.platform_users(id) on delete set null,
updated_by uuid references public.platform_users(id) on delete set null,
created_at timestamptz not null default now(),
updated_at timestamptz not null default now(),
unique (tenant_id, legacy_id)
);
alter table public.questions
add column if not exists entry_id uuid references public.content_entries(id) on delete set null,
add column if not exists content_node_id uuid references public.content_nodes(id) on delete set null,
add column if not exists primary_collection_id uuid references public.question_collections(id) on delete set null,
add column if not exists exam_markers jsonb not null default '{}'::jsonb;
alter table public.content_assets
add column if not exists entry_id uuid references public.content_entries(id) on delete set null,
add column if not exists content_node_id uuid references public.content_nodes(id) on delete set null;
alter table public.content_import_jobs
add column if not exists target_entry_id uuid references public.content_entries(id) on delete set null,
add column if not exists target_content_node_id uuid references public.content_nodes(id) on delete set null,
add column if not exists target_collection_id uuid references public.question_collections(id) on delete set null;
alter table public.practice_sessions
add column if not exists blueprint_id uuid references public.practice_blueprints(id) on delete set null,
add column if not exists collection_id uuid references public.question_collections(id) on delete set null,
add column if not exists entry_id uuid references public.content_entries(id) on delete set null,
add column if not exists content_node_id uuid references public.content_nodes(id) on delete set null,
add column if not exists question_ids jsonb not null default '[]'::jsonb,
add column if not exists question_count integer not null default 0,
add column if not exists duration_minutes integer,
add column if not exists total_score numeric(8,2),
add column if not exists expires_at timestamptz;
create index if not exists idx_content_entries_tenant_region
on public.content_entries(tenant_id, region_id, entry_type, is_active, sort_order);
create index if not exists idx_content_nodes_tenant_entry_parent
on public.content_nodes(tenant_id, entry_id, parent_id, sort_order);
create index if not exists idx_content_nodes_path
on public.content_nodes using gist(path);
create index if not exists idx_content_nodes_marker
on public.content_nodes(tenant_id, marker_type)
where marker_type is not null;
create index if not exists idx_question_collections_node
on public.question_collections(tenant_id, node_id, status, sort_order);
create index if not exists idx_question_collection_items_collection
on public.question_collection_items(tenant_id, collection_id, section_key, sort_order);
create index if not exists idx_practice_blueprints_scope
on public.practice_blueprints(tenant_id, node_id, collection_id, mode, status, sort_order);
create index if not exists idx_questions_navigation
on public.questions(tenant_id, entry_id, content_node_id, primary_collection_id, status);
do $$
declare
table_name text;
begin
foreach table_name in array array[
'content_entries', 'content_nodes', 'question_collections',
'question_collection_items', 'practice_blueprints'
]
loop
execute format('alter table public.%I enable row level security', table_name);
execute format('drop policy if exists tenant_isolation on public.%I', table_name);
execute format(
'create policy tenant_isolation on public.%I for all using (tenant_id = app.current_tenant_id() or app.is_platform_admin()) with check (tenant_id = app.current_tenant_id() or app.is_platform_admin())',
table_name
);
execute format('drop trigger if exists set_updated_at on public.%I', table_name);
execute format('create trigger set_updated_at before update on public.%I for each row execute function app.touch_updated_at()', table_name);
end loop;
end $$;