forked from wangziqi/gongxue-base
feat: add content navigation practice assembly
This commit is contained in:
14
README.md
14
README.md
@@ -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
|
||||
```
|
||||
|
||||
结果:通过。
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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],
|
||||
|
||||
247
apps/api/src/features/catalog/navigation.ts
Normal file
247
apps/api/src/features/catalog/navigation.ts
Normal 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 };
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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],
|
||||
|
||||
754
apps/api/src/features/tenant-content/navigation.ts
Normal file
754
apps/api/src/features/tenant-content/navigation.ts
Normal 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 };
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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 当前支持题目 JSON,Excel/CSV 和其它内容类型应接入同一管线。
|
||||
|
||||
@@ -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` 管线。
|
||||
|
||||
## 下一步
|
||||
|
||||
|
||||
@@ -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。
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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', {
|
||||
|
||||
@@ -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(
|
||||
|
||||
191
supabase/migrations/202606210008_content_navigation_practice.sql
Normal file
191
supabase/migrations/202606210008_content_navigation_practice.sql
Normal 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 $$;
|
||||
Reference in New Issue
Block a user