forked from wangziqi/gongxue-base
feat: add public question bank adoption
This commit is contained in:
@@ -7,18 +7,24 @@ import {
|
||||
createTenantRoute,
|
||||
platformOverviewRoute,
|
||||
platformPlansRoute,
|
||||
platformQuestionBanksRoute,
|
||||
questionBankGrantsRoute,
|
||||
recordUsageRoute,
|
||||
tenantDetailRoute,
|
||||
tenantInvoicesRoute,
|
||||
tenantsRoute,
|
||||
tenantUsageRoute,
|
||||
updateTenantStatusRoute,
|
||||
upsertQuestionBankGrantRoute,
|
||||
upsertBillingProfileRoute,
|
||||
} from './routes.js';
|
||||
|
||||
export const platformAdminRoutes: RouteDefinition[] = [
|
||||
['GET', '/api/platform-admin/overview', platformOverviewRoute],
|
||||
['GET', '/api/platform-admin/plans', platformPlansRoute],
|
||||
['GET', '/api/platform-admin/question-banks', platformQuestionBanksRoute],
|
||||
['GET', '/api/platform-admin/question-bank-grants', questionBankGrantsRoute],
|
||||
['PUT', '/api/platform-admin/question-bank-grants', upsertQuestionBankGrantRoute],
|
||||
['GET', '/api/platform-admin/tenants', tenantsRoute],
|
||||
['POST', '/api/platform-admin/tenants', createTenantRoute],
|
||||
['GET', '/api/platform-admin/tenants/detail', tenantDetailRoute],
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { HttpError, type RequestContext } from '../../core/http.js';
|
||||
import {
|
||||
intParam,
|
||||
optionalStringArray,
|
||||
optionalString,
|
||||
readJsonBody,
|
||||
requiredString,
|
||||
@@ -32,6 +33,26 @@ function toDateText(value: unknown) {
|
||||
return String(value).slice(0, 10);
|
||||
}
|
||||
|
||||
function optionalUuidArray(body: Record<string, unknown>, key: string) {
|
||||
return optionalStringArray(body, key).filter(Boolean);
|
||||
}
|
||||
|
||||
function grantScopeFrom(value: string) {
|
||||
const scope = value || 'plans';
|
||||
if (!['all_active_tenants', 'plans', 'tenants', 'mixed'].includes(scope)) {
|
||||
throw new HttpError(400, 'grantScope is invalid', 'INVALID_GRANT_SCOPE');
|
||||
}
|
||||
return scope;
|
||||
}
|
||||
|
||||
function grantStatusFrom(value: string) {
|
||||
const status = value || 'active';
|
||||
if (!['active', 'disabled', 'expired'].includes(status)) {
|
||||
throw new HttpError(400, 'status is invalid', 'INVALID_STATUS');
|
||||
}
|
||||
return status;
|
||||
}
|
||||
|
||||
export async function platformOverviewRoute(ctx: RequestContext) {
|
||||
await requirePlatformAdmin(ctx);
|
||||
|
||||
@@ -129,6 +150,209 @@ export async function platformPlansRoute(ctx: RequestContext) {
|
||||
return { items };
|
||||
}
|
||||
|
||||
export async function platformQuestionBanksRoute(ctx: RequestContext) {
|
||||
await requirePlatformAdmin(ctx);
|
||||
|
||||
const q = listQuery(ctx, 'q');
|
||||
const regionId = listQuery(ctx, 'regionId');
|
||||
const status = listQuery(ctx, 'status') || 'active';
|
||||
const includeTenantBanks = listQuery(ctx, 'includeTenantBanks') === 'true';
|
||||
const limit = intParam(ctx, 'limit', 50, 200);
|
||||
|
||||
const items = await query(
|
||||
`
|
||||
select qb.id, qb.tenant_id as "tenantId", t.slug::text as "tenantSlug", t.name as "tenantName",
|
||||
qb.region_id as "regionId", r.name as "regionName", qb.name,
|
||||
qb.source_scope as "sourceScope", qb.status, qb.metadata,
|
||||
coalesce(qs.question_count, 0)::integer as "questionCount",
|
||||
qb.created_at as "createdAt", qb.updated_at as "updatedAt"
|
||||
from public.question_banks qb
|
||||
join public.tenants t on t.id = qb.tenant_id
|
||||
left join public.regions r on r.id = qb.region_id and r.tenant_id = qb.tenant_id
|
||||
left join lateral (
|
||||
select count(*)::integer as question_count
|
||||
from public.questions q
|
||||
where q.tenant_id = qb.tenant_id
|
||||
and q.question_bank_id = qb.id
|
||||
and q.status = 'published'
|
||||
) qs on true
|
||||
where ($1::boolean = true or qb.source_scope = 'platform')
|
||||
and ($2::text = '' or qb.status = $2)
|
||||
and ($3::uuid is null or qb.region_id = $3::uuid)
|
||||
and (
|
||||
$4::text = ''
|
||||
or qb.name ilike '%' || $4 || '%'
|
||||
or coalesce(r.name, '') ilike '%' || $4 || '%'
|
||||
)
|
||||
order by qb.source_scope asc, r.sort_order asc nulls last, qb.created_at desc
|
||||
limit $5
|
||||
`,
|
||||
[includeTenantBanks, status, regionId || null, q, limit],
|
||||
);
|
||||
|
||||
return { items };
|
||||
}
|
||||
|
||||
export async function questionBankGrantsRoute(ctx: RequestContext) {
|
||||
await requirePlatformAdmin(ctx);
|
||||
|
||||
const questionBankId = listQuery(ctx, 'questionBankId');
|
||||
const status = listQuery(ctx, 'status');
|
||||
const limit = intParam(ctx, 'limit', 100, 500);
|
||||
|
||||
const items = await query(
|
||||
`
|
||||
select g.id, g.source_question_bank_id as "sourceQuestionBankId",
|
||||
qb.name as "sourceQuestionBankName", qb.region_id as "sourceRegionId",
|
||||
r.name as "sourceRegionName", g.grant_scope as "grantScope",
|
||||
g.allowed_plan_codes as "allowedPlanCodes",
|
||||
g.allowed_tenant_ids as "allowedTenantIds",
|
||||
g.allowed_region_ids as "allowedRegionIds",
|
||||
g.allowed_subject_ids as "allowedSubjectIds",
|
||||
g.status, g.starts_at as "startsAt", g.expires_at as "expiresAt",
|
||||
g.metadata, g.created_at as "createdAt", g.updated_at as "updatedAt"
|
||||
from public.question_bank_grants g
|
||||
join public.question_banks qb on qb.id = g.source_question_bank_id
|
||||
left join public.regions r on r.id = qb.region_id and r.tenant_id = qb.tenant_id
|
||||
where ($1::uuid is null or g.source_question_bank_id = $1::uuid)
|
||||
and ($2::text = '' or g.status = $2)
|
||||
order by g.updated_at desc, g.created_at desc
|
||||
limit $3
|
||||
`,
|
||||
[questionBankId || null, status, limit],
|
||||
);
|
||||
|
||||
return { items };
|
||||
}
|
||||
|
||||
export async function upsertQuestionBankGrantRoute(ctx: RequestContext) {
|
||||
await requirePlatformAdmin(ctx);
|
||||
|
||||
const body = await readJsonBody(ctx);
|
||||
const questionBankId = requiredString(body, 'sourceQuestionBankId');
|
||||
const grantScope = grantScopeFrom(optionalString(body, 'grantScope'));
|
||||
const allowedPlanCodes = optionalStringArray(body, 'allowedPlanCodes');
|
||||
const allowedTenantIds = optionalUuidArray(body, 'allowedTenantIds');
|
||||
const allowedRegionIds = optionalUuidArray(body, 'allowedRegionIds');
|
||||
const allowedSubjectIds = optionalUuidArray(body, 'allowedSubjectIds');
|
||||
const status = grantStatusFrom(optionalString(body, 'status'));
|
||||
const startsAt = optionalString(body, 'startsAt') || null;
|
||||
const expiresAt = optionalString(body, 'expiresAt') || null;
|
||||
|
||||
if (grantScope === 'plans' && allowedPlanCodes.length === 0) {
|
||||
throw new HttpError(400, 'allowedPlanCodes is required for plan grants', 'ALLOWED_PLANS_REQUIRED');
|
||||
}
|
||||
if (grantScope === 'tenants' && allowedTenantIds.length === 0) {
|
||||
throw new HttpError(400, 'allowedTenantIds is required for tenant grants', 'ALLOWED_TENANTS_REQUIRED');
|
||||
}
|
||||
if (grantScope === 'mixed' && allowedPlanCodes.length === 0 && allowedTenantIds.length === 0) {
|
||||
throw new HttpError(400, 'mixed grants require plans or tenants', 'GRANT_TARGET_REQUIRED');
|
||||
}
|
||||
|
||||
const item = await transaction(async client => {
|
||||
const bankResult = await client.query<{ id: string }>(
|
||||
`
|
||||
select id
|
||||
from public.question_banks
|
||||
where id = $1
|
||||
and source_scope = 'platform'
|
||||
limit 1
|
||||
`,
|
||||
[questionBankId],
|
||||
);
|
||||
if (!bankResult.rows[0]) {
|
||||
throw new HttpError(404, 'Platform question bank not found', 'PLATFORM_QUESTION_BANK_NOT_FOUND');
|
||||
}
|
||||
|
||||
if (allowedPlanCodes.length) {
|
||||
const planCount = await client.query<{ count: string }>(
|
||||
'select count(*)::text as count from public.platform_saas_plans where code = any($1::text[])',
|
||||
[allowedPlanCodes],
|
||||
);
|
||||
if (Number(planCount.rows[0]?.count || 0) !== allowedPlanCodes.length) {
|
||||
throw new HttpError(400, 'One or more SaaS plans do not exist', 'SAAS_PLAN_NOT_FOUND');
|
||||
}
|
||||
}
|
||||
|
||||
if (allowedTenantIds.length) {
|
||||
const tenantCount = await client.query<{ count: string }>(
|
||||
'select count(*)::text as count from public.tenants where id = any($1::uuid[])',
|
||||
[allowedTenantIds],
|
||||
);
|
||||
if (Number(tenantCount.rows[0]?.count || 0) !== allowedTenantIds.length) {
|
||||
throw new HttpError(400, 'One or more tenants do not exist', 'TENANT_NOT_FOUND');
|
||||
}
|
||||
}
|
||||
|
||||
const result = await client.query(
|
||||
`
|
||||
insert into public.question_bank_grants (
|
||||
id, source_question_bank_id, grant_scope, allowed_plan_codes,
|
||||
allowed_tenant_ids, allowed_region_ids, allowed_subject_ids,
|
||||
status, starts_at, expires_at, metadata
|
||||
)
|
||||
values (
|
||||
coalesce($1::uuid, gen_random_uuid()), $2, $3, $4::text[],
|
||||
$5::uuid[], $6::uuid[], $7::uuid[],
|
||||
$8, $9::timestamptz, $10::timestamptz, $11::jsonb
|
||||
)
|
||||
on conflict (id)
|
||||
do update set source_question_bank_id = excluded.source_question_bank_id,
|
||||
grant_scope = excluded.grant_scope,
|
||||
allowed_plan_codes = excluded.allowed_plan_codes,
|
||||
allowed_tenant_ids = excluded.allowed_tenant_ids,
|
||||
allowed_region_ids = excluded.allowed_region_ids,
|
||||
allowed_subject_ids = excluded.allowed_subject_ids,
|
||||
status = excluded.status,
|
||||
starts_at = excluded.starts_at,
|
||||
expires_at = excluded.expires_at,
|
||||
metadata = excluded.metadata,
|
||||
updated_at = now()
|
||||
returning id, source_question_bank_id as "sourceQuestionBankId",
|
||||
grant_scope as "grantScope", allowed_plan_codes as "allowedPlanCodes",
|
||||
allowed_tenant_ids as "allowedTenantIds", allowed_region_ids as "allowedRegionIds",
|
||||
allowed_subject_ids as "allowedSubjectIds", status,
|
||||
starts_at as "startsAt", expires_at as "expiresAt",
|
||||
metadata, created_at as "createdAt", updated_at as "updatedAt"
|
||||
`,
|
||||
[
|
||||
optionalString(body, 'id') || null,
|
||||
questionBankId,
|
||||
grantScope,
|
||||
allowedPlanCodes,
|
||||
allowedTenantIds,
|
||||
allowedRegionIds,
|
||||
allowedSubjectIds,
|
||||
status,
|
||||
startsAt,
|
||||
expiresAt,
|
||||
jsonBodyValue(body.metadata),
|
||||
],
|
||||
);
|
||||
|
||||
await client.query(
|
||||
`
|
||||
insert into public.audit_logs (tenant_id, action, target_type, target_id, details)
|
||||
values (null, 'platform.question_bank.grant_upserted', 'question_bank_grant', $1, $2::jsonb)
|
||||
`,
|
||||
[
|
||||
result.rows[0].id,
|
||||
JSON.stringify({
|
||||
sourceQuestionBankId: questionBankId,
|
||||
grantScope,
|
||||
allowedPlanCodes,
|
||||
allowedTenantIds,
|
||||
status,
|
||||
}),
|
||||
],
|
||||
);
|
||||
|
||||
return result.rows[0];
|
||||
});
|
||||
|
||||
return { item };
|
||||
}
|
||||
|
||||
export async function tenantsRoute(ctx: RequestContext) {
|
||||
await requirePlatformAdmin(ctx);
|
||||
|
||||
|
||||
@@ -26,6 +26,10 @@ import {
|
||||
upsertPracticeBlueprintRoute,
|
||||
upsertQuestionCollectionRoute,
|
||||
} from './navigation.js';
|
||||
import {
|
||||
adoptPublicQuestionBankRoute,
|
||||
publicQuestionBanksRoute,
|
||||
} from './public-banks.js';
|
||||
import {
|
||||
bindQuestionVideoRoute,
|
||||
createQuestionRoute,
|
||||
@@ -54,6 +58,8 @@ import {
|
||||
|
||||
export const tenantContentRoutes: RouteDefinition[] = [
|
||||
['GET', '/api/tenant-content/content-entries', contentEntriesAdminRoute],
|
||||
['GET', '/api/tenant-content/public-question-banks', publicQuestionBanksRoute],
|
||||
['POST', '/api/tenant-content/public-question-banks/adopt', adoptPublicQuestionBankRoute],
|
||||
['PUT', '/api/tenant-content/content-entries', upsertContentEntryRoute],
|
||||
['GET', '/api/tenant-content/content-nodes', contentNodesAdminRoute],
|
||||
['PUT', '/api/tenant-content/content-nodes', upsertContentNodeRoute],
|
||||
|
||||
646
apps/api/src/features/tenant-content/public-banks.ts
Normal file
646
apps/api/src/features/tenant-content/public-banks.ts
Normal file
@@ -0,0 +1,646 @@
|
||||
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, transaction } from '../../core/db.js';
|
||||
import { requireTenantContentEditor, type TenantContentAuth } from './auth.js';
|
||||
import { boolValue, intValue, jsonObjectValue, nullableString } from './utils.js';
|
||||
|
||||
interface EligibleBankRow {
|
||||
grantId: string;
|
||||
sourceQuestionBankId: string;
|
||||
sourceQuestionBankName: string;
|
||||
sourceTenantId: string;
|
||||
sourceRegionId: string | null;
|
||||
sourceRegionName: string | null;
|
||||
grantScope: string;
|
||||
allowedPlanCodes: string[];
|
||||
questionCount: number;
|
||||
adoptedId: string | null;
|
||||
adoptionStatus: string | null;
|
||||
targetQuestionBankId: string | null;
|
||||
targetEntryId: string | null;
|
||||
targetCollectionId: string | null;
|
||||
}
|
||||
|
||||
function slugFromName(name: string) {
|
||||
const ascii = name
|
||||
.normalize('NFKD')
|
||||
.replace(/[^\w\s-]/g, '')
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
.replace(/[\s_]+/g, '-')
|
||||
.replace(/-+/g, '-')
|
||||
.slice(0, 48);
|
||||
return ascii || 'public-bank';
|
||||
}
|
||||
|
||||
async function ensureTenantRegion(client: pg.PoolClient, targetTenantId: string, sourceTenantId: string, sourceRegionId: string | null) {
|
||||
if (!sourceRegionId) return null;
|
||||
const existing = await client.query<{ id: string }>(
|
||||
`
|
||||
select id
|
||||
from public.regions
|
||||
where tenant_id = $1
|
||||
and config->'sourceAdoption'->>'sourceTenantId' = $2
|
||||
and config->'sourceAdoption'->>'sourceRegionId' = $3
|
||||
limit 1
|
||||
`,
|
||||
[targetTenantId, sourceTenantId, sourceRegionId],
|
||||
);
|
||||
if (existing.rows[0]) return existing.rows[0].id;
|
||||
|
||||
const source = await client.query<{
|
||||
name: string;
|
||||
code: string | null;
|
||||
short_name: string | null;
|
||||
full_name: string | null;
|
||||
icon: string | null;
|
||||
pinyin: string | null;
|
||||
sort_order: number;
|
||||
is_hot: boolean;
|
||||
config: Record<string, unknown>;
|
||||
}>(
|
||||
`
|
||||
select name, code, short_name, full_name, icon, pinyin, sort_order, is_hot, config
|
||||
from public.regions
|
||||
where tenant_id = $1 and id = $2
|
||||
limit 1
|
||||
`,
|
||||
[sourceTenantId, sourceRegionId],
|
||||
);
|
||||
const row = source.rows[0];
|
||||
if (!row) return null;
|
||||
|
||||
const inserted = await client.query<{ id: string }>(
|
||||
`
|
||||
insert into public.regions (
|
||||
tenant_id, name, code, short_name, full_name, icon, pinyin,
|
||||
sort_order, is_hot, is_active, config
|
||||
)
|
||||
values (
|
||||
$1, $2, $3, $4, $5, $6, $7,
|
||||
$8, $9, true, $10::jsonb
|
||||
)
|
||||
returning id
|
||||
`,
|
||||
[
|
||||
targetTenantId,
|
||||
row.name,
|
||||
row.code,
|
||||
row.short_name,
|
||||
row.full_name,
|
||||
row.icon,
|
||||
row.pinyin,
|
||||
row.sort_order,
|
||||
row.is_hot,
|
||||
JSON.stringify({
|
||||
...(row.config || {}),
|
||||
sourceAdoption: {
|
||||
source: 'public_question_bank_adoption',
|
||||
sourceTenantId,
|
||||
sourceRegionId,
|
||||
},
|
||||
}),
|
||||
],
|
||||
);
|
||||
|
||||
return inserted.rows[0]?.id || null;
|
||||
}
|
||||
|
||||
async function loadEligibleGrant(
|
||||
client: pg.PoolClient,
|
||||
tenantId: string,
|
||||
grantId: string,
|
||||
) {
|
||||
const result = await client.query<EligibleBankRow>(
|
||||
`
|
||||
with active_subscriptions as (
|
||||
select plan_code
|
||||
from public.tenant_subscriptions
|
||||
where tenant_id = $1
|
||||
and status in ('trial', 'active')
|
||||
and (expires_at is null or expires_at > now())
|
||||
)
|
||||
select g.id as "grantId",
|
||||
qb.id as "sourceQuestionBankId",
|
||||
qb.name as "sourceQuestionBankName",
|
||||
qb.tenant_id as "sourceTenantId",
|
||||
qb.region_id as "sourceRegionId",
|
||||
r.name as "sourceRegionName",
|
||||
g.grant_scope as "grantScope",
|
||||
g.allowed_plan_codes as "allowedPlanCodes",
|
||||
coalesce(qs.question_count, 0)::integer as "questionCount",
|
||||
a.id as "adoptedId",
|
||||
a.status as "adoptionStatus",
|
||||
a.target_question_bank_id as "targetQuestionBankId",
|
||||
a.target_entry_id as "targetEntryId",
|
||||
a.target_collection_id as "targetCollectionId"
|
||||
from public.question_bank_grants g
|
||||
join public.question_banks qb on qb.id = g.source_question_bank_id
|
||||
left join public.regions r on r.id = qb.region_id and r.tenant_id = qb.tenant_id
|
||||
left join lateral (
|
||||
select count(*)::integer as question_count
|
||||
from public.questions q
|
||||
where q.tenant_id = qb.tenant_id
|
||||
and q.question_bank_id = qb.id
|
||||
and q.status = 'published'
|
||||
) qs on true
|
||||
left join public.tenant_question_bank_adoptions a
|
||||
on a.tenant_id = $1
|
||||
and a.source_question_bank_id = qb.id
|
||||
where g.id = $2
|
||||
and g.status = 'active'
|
||||
and (g.starts_at is null or g.starts_at <= now())
|
||||
and (g.expires_at is null or g.expires_at > now())
|
||||
and qb.source_scope = 'platform'
|
||||
and qb.status = 'active'
|
||||
and (
|
||||
(
|
||||
g.grant_scope = 'all_active_tenants'
|
||||
and exists (select 1 from active_subscriptions)
|
||||
)
|
||||
or (
|
||||
g.grant_scope in ('plans', 'mixed')
|
||||
and exists (
|
||||
select 1
|
||||
from active_subscriptions s
|
||||
where s.plan_code = any(g.allowed_plan_codes)
|
||||
)
|
||||
)
|
||||
or (
|
||||
g.grant_scope in ('tenants', 'mixed')
|
||||
and $1 = any(g.allowed_tenant_ids)
|
||||
)
|
||||
)
|
||||
limit 1
|
||||
`,
|
||||
[tenantId, grantId],
|
||||
);
|
||||
return result.rows[0] || null;
|
||||
}
|
||||
|
||||
async function copyQuestionsSnapshot(client: pg.PoolClient, input: {
|
||||
auth: TenantContentAuth;
|
||||
sourceTenantId: string;
|
||||
sourceQuestionBankId: string;
|
||||
targetQuestionBankId: string;
|
||||
targetEntryId: string;
|
||||
targetCollectionId: string;
|
||||
copyLimit: number;
|
||||
}) {
|
||||
const sourceQuestions = await client.query<{
|
||||
id: string;
|
||||
subject_id: string | null;
|
||||
category_id: string | null;
|
||||
type: string;
|
||||
type_label: string | null;
|
||||
difficulty: number | null;
|
||||
tags: unknown[];
|
||||
media_url: string | null;
|
||||
has_video_explanation: boolean;
|
||||
current_version_id: string | null;
|
||||
content: string | null;
|
||||
options: unknown[];
|
||||
correct_option_index: number | null;
|
||||
correct_option_indices: unknown[];
|
||||
answer_text: string | null;
|
||||
explanation: string | null;
|
||||
sub_questions: unknown[];
|
||||
code_lang: string | null;
|
||||
code_template: string | null;
|
||||
source_hash: string | null;
|
||||
}>(
|
||||
`
|
||||
select q.id, q.subject_id, q.category_id, q.type, q.type_label,
|
||||
q.difficulty, q.tags, q.media_url, q.has_video_explanation,
|
||||
q.current_version_id,
|
||||
v.content, v.options, v.correct_option_index, v.correct_option_indices,
|
||||
v.answer_text, v.explanation, v.sub_questions, v.code_lang,
|
||||
v.code_template, v.source_hash
|
||||
from public.questions q
|
||||
left join public.question_versions v on v.id = q.current_version_id
|
||||
where q.tenant_id = $1
|
||||
and q.question_bank_id = $2
|
||||
and q.status = 'published'
|
||||
order by q.created_at asc
|
||||
limit $3
|
||||
`,
|
||||
[input.sourceTenantId, input.sourceQuestionBankId, input.copyLimit],
|
||||
);
|
||||
|
||||
let order = 0;
|
||||
for (const source of sourceQuestions.rows) {
|
||||
const legacyId = `public:${input.sourceTenantId}:${source.id}`;
|
||||
const questionResult = await client.query<{ id: string }>(
|
||||
`
|
||||
insert into public.questions (
|
||||
tenant_id, question_bank_id, entry_id, primary_collection_id,
|
||||
legacy_id, type, type_label, difficulty, tags, media_url,
|
||||
has_video_explanation, status, exam_markers
|
||||
)
|
||||
values (
|
||||
$1, $2, $3, $4,
|
||||
$5, $6, $7, $8, $9::jsonb, $10,
|
||||
$11, 'published', $12::jsonb
|
||||
)
|
||||
on conflict (tenant_id, legacy_id)
|
||||
do update set question_bank_id = excluded.question_bank_id,
|
||||
entry_id = excluded.entry_id,
|
||||
primary_collection_id = excluded.primary_collection_id,
|
||||
type = excluded.type,
|
||||
type_label = excluded.type_label,
|
||||
difficulty = excluded.difficulty,
|
||||
tags = excluded.tags,
|
||||
media_url = excluded.media_url,
|
||||
has_video_explanation = excluded.has_video_explanation,
|
||||
status = 'published',
|
||||
exam_markers = excluded.exam_markers,
|
||||
updated_at = now()
|
||||
returning id
|
||||
`,
|
||||
[
|
||||
input.auth.tenantId,
|
||||
input.targetQuestionBankId,
|
||||
input.targetEntryId,
|
||||
input.targetCollectionId,
|
||||
legacyId,
|
||||
source.type,
|
||||
source.type_label,
|
||||
source.difficulty,
|
||||
JSON.stringify(source.tags || []),
|
||||
source.media_url,
|
||||
source.has_video_explanation,
|
||||
JSON.stringify({
|
||||
sourceTenantId: input.sourceTenantId,
|
||||
sourceQuestionBankId: input.sourceQuestionBankId,
|
||||
sourceQuestionId: source.id,
|
||||
}),
|
||||
],
|
||||
);
|
||||
const questionId = questionResult.rows[0].id;
|
||||
|
||||
const versionResult = await client.query<{ id: string }>(
|
||||
`
|
||||
insert into public.question_versions (
|
||||
tenant_id, question_id, version_no, content, options,
|
||||
correct_option_index, correct_option_indices, answer_text,
|
||||
explanation, sub_questions, code_lang, code_template,
|
||||
source_hash, created_by
|
||||
)
|
||||
values (
|
||||
$1, $2, 1, $3, $4::jsonb,
|
||||
$5, $6::jsonb, $7,
|
||||
$8, $9::jsonb, $10, $11,
|
||||
$12, $13
|
||||
)
|
||||
on conflict (question_id, version_no)
|
||||
do update set content = excluded.content,
|
||||
options = excluded.options,
|
||||
correct_option_index = excluded.correct_option_index,
|
||||
correct_option_indices = excluded.correct_option_indices,
|
||||
answer_text = excluded.answer_text,
|
||||
explanation = excluded.explanation,
|
||||
sub_questions = excluded.sub_questions,
|
||||
code_lang = excluded.code_lang,
|
||||
code_template = excluded.code_template,
|
||||
source_hash = excluded.source_hash
|
||||
returning id
|
||||
`,
|
||||
[
|
||||
input.auth.tenantId,
|
||||
questionId,
|
||||
source.content,
|
||||
JSON.stringify(source.options || []),
|
||||
source.correct_option_index,
|
||||
JSON.stringify(source.correct_option_indices || []),
|
||||
source.answer_text,
|
||||
source.explanation,
|
||||
JSON.stringify(source.sub_questions || []),
|
||||
source.code_lang,
|
||||
source.code_template,
|
||||
source.source_hash || `public:${source.id}`,
|
||||
input.auth.userId,
|
||||
],
|
||||
);
|
||||
|
||||
await client.query(
|
||||
'update public.questions set current_version_id = $3, updated_at = now() where tenant_id = $1 and id = $2',
|
||||
[input.auth.tenantId, questionId, versionResult.rows[0].id],
|
||||
);
|
||||
|
||||
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, null, true, $6::jsonb)
|
||||
on conflict (tenant_id, collection_id, question_id)
|
||||
do update set section_key = excluded.section_key,
|
||||
sort_order = excluded.sort_order,
|
||||
metadata = excluded.metadata,
|
||||
updated_at = now()
|
||||
`,
|
||||
[
|
||||
input.auth.tenantId,
|
||||
input.targetCollectionId,
|
||||
questionId,
|
||||
source.type,
|
||||
order,
|
||||
JSON.stringify({ source: 'public_question_bank_adoption', sourceQuestionId: source.id }),
|
||||
],
|
||||
);
|
||||
order += 1;
|
||||
}
|
||||
|
||||
await client.query(
|
||||
`
|
||||
update public.question_collections
|
||||
set question_count = $3,
|
||||
updated_at = now()
|
||||
where tenant_id = $1 and id = $2
|
||||
`,
|
||||
[input.auth.tenantId, input.targetCollectionId, sourceQuestions.rows.length],
|
||||
);
|
||||
|
||||
return sourceQuestions.rows.length;
|
||||
}
|
||||
|
||||
export async function publicQuestionBanksRoute(ctx: RequestContext) {
|
||||
const auth = await requireTenantContentEditor(ctx);
|
||||
const q = stringParam(ctx, 'q');
|
||||
const regionId = stringParam(ctx, 'regionId');
|
||||
const onlyNotAdopted = stringParam(ctx, 'onlyNotAdopted') === 'true';
|
||||
const limit = intParam(ctx, 'limit', 50, 200);
|
||||
|
||||
const items = await query<EligibleBankRow>(
|
||||
`
|
||||
with active_subscriptions as (
|
||||
select plan_code
|
||||
from public.tenant_subscriptions
|
||||
where tenant_id = $1
|
||||
and status in ('trial', 'active')
|
||||
and (expires_at is null or expires_at > now())
|
||||
),
|
||||
eligible_grants as (
|
||||
select g.*
|
||||
from public.question_bank_grants g
|
||||
where g.status = 'active'
|
||||
and (g.starts_at is null or g.starts_at <= now())
|
||||
and (g.expires_at is null or g.expires_at > now())
|
||||
and (
|
||||
(
|
||||
g.grant_scope = 'all_active_tenants'
|
||||
and exists (select 1 from active_subscriptions)
|
||||
)
|
||||
or (
|
||||
g.grant_scope in ('plans', 'mixed')
|
||||
and exists (
|
||||
select 1
|
||||
from active_subscriptions s
|
||||
where s.plan_code = any(g.allowed_plan_codes)
|
||||
)
|
||||
)
|
||||
or (
|
||||
g.grant_scope in ('tenants', 'mixed')
|
||||
and $1 = any(g.allowed_tenant_ids)
|
||||
)
|
||||
)
|
||||
)
|
||||
select g.id as "grantId",
|
||||
qb.id as "sourceQuestionBankId",
|
||||
qb.name as "sourceQuestionBankName",
|
||||
qb.tenant_id as "sourceTenantId",
|
||||
qb.region_id as "sourceRegionId",
|
||||
r.name as "sourceRegionName",
|
||||
g.grant_scope as "grantScope",
|
||||
g.allowed_plan_codes as "allowedPlanCodes",
|
||||
coalesce(qs.question_count, 0)::integer as "questionCount",
|
||||
a.id as "adoptedId",
|
||||
a.status as "adoptionStatus",
|
||||
a.target_question_bank_id as "targetQuestionBankId",
|
||||
a.target_entry_id as "targetEntryId",
|
||||
a.target_collection_id as "targetCollectionId"
|
||||
from eligible_grants g
|
||||
join public.question_banks qb on qb.id = g.source_question_bank_id
|
||||
left join public.regions r on r.id = qb.region_id and r.tenant_id = qb.tenant_id
|
||||
left join public.tenant_question_bank_adoptions a
|
||||
on a.tenant_id = $1
|
||||
and a.source_question_bank_id = qb.id
|
||||
left join lateral (
|
||||
select count(*)::integer as question_count
|
||||
from public.questions q
|
||||
where q.tenant_id = qb.tenant_id
|
||||
and q.question_bank_id = qb.id
|
||||
and q.status = 'published'
|
||||
) qs on true
|
||||
where qb.source_scope = 'platform'
|
||||
and qb.status = 'active'
|
||||
and ($2::uuid is null or qb.region_id = $2::uuid)
|
||||
and ($3::text = '' or qb.name ilike '%' || $3 || '%' or coalesce(r.name, '') ilike '%' || $3 || '%')
|
||||
and ($4::boolean = false or a.id is null)
|
||||
order by r.sort_order asc nulls last, qb.created_at desc
|
||||
limit $5
|
||||
`,
|
||||
[auth.tenantId, regionId || null, q, onlyNotAdopted, limit],
|
||||
);
|
||||
|
||||
return { items };
|
||||
}
|
||||
|
||||
export async function adoptPublicQuestionBankRoute(ctx: RequestContext) {
|
||||
const auth = await requireTenantContentEditor(ctx);
|
||||
const body = await readJsonBody(ctx);
|
||||
const grantId = requiredString(body, 'grantId');
|
||||
const copyLimit = Math.max(1, Math.min(intValue(body.copyLimit, 200), 1000));
|
||||
const entryNameInput = nullableString(body.entryName);
|
||||
const collectionNameInput = nullableString(body.collectionName);
|
||||
const isActive = boolValue(body.isActive, true);
|
||||
|
||||
const item = await transaction(async client => {
|
||||
const grant = await loadEligibleGrant(client, auth.tenantId, grantId);
|
||||
if (!grant) {
|
||||
throw new HttpError(403, 'Question bank grant is not available for this tenant', 'QUESTION_BANK_GRANT_NOT_AVAILABLE');
|
||||
}
|
||||
if (grant.adoptedId && grant.adoptionStatus !== 'archived') {
|
||||
throw new HttpError(409, 'Question bank has already been adopted by this tenant', 'QUESTION_BANK_ALREADY_ADOPTED');
|
||||
}
|
||||
|
||||
const targetRegionId = await ensureTenantRegion(client, auth.tenantId, grant.sourceTenantId, grant.sourceRegionId);
|
||||
const baseKey = `public-${slugFromName(grant.sourceQuestionBankName)}-${grant.sourceQuestionBankId.slice(0, 8)}`;
|
||||
const entryName = entryNameInput || `${grant.sourceQuestionBankName}`;
|
||||
const collectionName = collectionNameInput || `${grant.sourceQuestionBankName}题目`;
|
||||
|
||||
const targetBankResult = await client.query<{ id: string }>(
|
||||
`
|
||||
insert into public.question_banks (tenant_id, region_id, name, source_scope, status, metadata)
|
||||
values ($1, $2::uuid, $3, 'tenant', 'active', $4::jsonb)
|
||||
returning id
|
||||
`,
|
||||
[
|
||||
auth.tenantId,
|
||||
targetRegionId,
|
||||
grant.sourceQuestionBankName,
|
||||
JSON.stringify({
|
||||
source: 'public_question_bank_adoption',
|
||||
sourceTenantId: grant.sourceTenantId,
|
||||
sourceQuestionBankId: grant.sourceQuestionBankId,
|
||||
grantId,
|
||||
}),
|
||||
],
|
||||
);
|
||||
const targetQuestionBankId = targetBankResult.rows[0].id;
|
||||
|
||||
const entryResult = await client.query<{ id: string }>(
|
||||
`
|
||||
insert into public.content_entries (
|
||||
tenant_id, region_id, entry_key, name, entry_type,
|
||||
icon, route, description, visibility, access_rules,
|
||||
layout_config, sort_order, is_active, created_by, updated_by
|
||||
)
|
||||
values (
|
||||
$1, $2::uuid, $3, $4, 'question_practice',
|
||||
$5, '/practice', $6, 'public', '{}'::jsonb,
|
||||
$7::jsonb, 100, $8, $9, $9
|
||||
)
|
||||
returning id
|
||||
`,
|
||||
[
|
||||
auth.tenantId,
|
||||
targetRegionId,
|
||||
baseKey,
|
||||
entryName,
|
||||
optionalString(body, 'icon') || 'book-open',
|
||||
`采纳自平台公共题库:${grant.sourceQuestionBankName}`,
|
||||
jsonObjectValue(body.layoutConfig || { source: 'public_question_bank_adoption', tabs: ['all', 'paper', 'chapter', 'type'] }),
|
||||
isActive,
|
||||
auth.userId,
|
||||
],
|
||||
);
|
||||
const targetEntryId = entryResult.rows[0].id;
|
||||
|
||||
const collectionResult = await client.query<{ id: string }>(
|
||||
`
|
||||
insert into public.question_collections (
|
||||
tenant_id, region_id, entry_id, question_bank_id, name,
|
||||
collection_type, source_type, filters, question_count,
|
||||
status, sort_order, access_rules, metadata, created_by, updated_by
|
||||
)
|
||||
values (
|
||||
$1, $2::uuid, $3, $4, $5,
|
||||
'manual', 'manual_questions', '{}'::jsonb, 0,
|
||||
'active', 1, $6::jsonb, $7::jsonb, $8, $8
|
||||
)
|
||||
returning id
|
||||
`,
|
||||
[
|
||||
auth.tenantId,
|
||||
targetRegionId,
|
||||
targetEntryId,
|
||||
targetQuestionBankId,
|
||||
collectionName,
|
||||
jsonObjectValue(body.accessRules),
|
||||
JSON.stringify({
|
||||
source: 'public_question_bank_adoption',
|
||||
sourceTenantId: grant.sourceTenantId,
|
||||
sourceQuestionBankId: grant.sourceQuestionBankId,
|
||||
grantId,
|
||||
}),
|
||||
auth.userId,
|
||||
],
|
||||
);
|
||||
const targetCollectionId = collectionResult.rows[0].id;
|
||||
|
||||
const copiedQuestionCount = await copyQuestionsSnapshot(client, {
|
||||
auth,
|
||||
sourceTenantId: grant.sourceTenantId,
|
||||
sourceQuestionBankId: grant.sourceQuestionBankId,
|
||||
targetQuestionBankId,
|
||||
targetEntryId,
|
||||
targetCollectionId,
|
||||
copyLimit,
|
||||
});
|
||||
|
||||
const adoptionResult = await client.query(
|
||||
`
|
||||
insert into public.tenant_question_bank_adoptions (
|
||||
tenant_id, source_question_bank_id, grant_id, target_question_bank_id,
|
||||
target_entry_id, target_collection_id, adoption_mode, status,
|
||||
sync_status, source_snapshot, copied_question_count, metadata,
|
||||
created_by, updated_by, last_synced_at
|
||||
)
|
||||
values (
|
||||
$1, $2, $3, $4,
|
||||
$5, $6, 'copied_snapshot', 'active',
|
||||
'synced', $7::jsonb, $8, $9::jsonb,
|
||||
$10, $10, now()
|
||||
)
|
||||
on conflict (tenant_id, source_question_bank_id)
|
||||
do update set grant_id = excluded.grant_id,
|
||||
target_question_bank_id = excluded.target_question_bank_id,
|
||||
target_entry_id = excluded.target_entry_id,
|
||||
target_collection_id = excluded.target_collection_id,
|
||||
status = 'active',
|
||||
sync_status = excluded.sync_status,
|
||||
source_snapshot = excluded.source_snapshot,
|
||||
copied_question_count = excluded.copied_question_count,
|
||||
metadata = excluded.metadata,
|
||||
updated_by = excluded.updated_by,
|
||||
last_synced_at = excluded.last_synced_at,
|
||||
updated_at = now()
|
||||
returning id, tenant_id as "tenantId",
|
||||
source_question_bank_id as "sourceQuestionBankId",
|
||||
grant_id as "grantId",
|
||||
target_question_bank_id as "targetQuestionBankId",
|
||||
target_entry_id as "targetEntryId",
|
||||
target_collection_id as "targetCollectionId",
|
||||
adoption_mode as "adoptionMode", status, sync_status as "syncStatus",
|
||||
source_snapshot as "sourceSnapshot",
|
||||
copied_question_count as "copiedQuestionCount",
|
||||
metadata, created_at as "createdAt", updated_at as "updatedAt"
|
||||
`,
|
||||
[
|
||||
auth.tenantId,
|
||||
grant.sourceQuestionBankId,
|
||||
grantId,
|
||||
targetQuestionBankId,
|
||||
targetEntryId,
|
||||
targetCollectionId,
|
||||
JSON.stringify({
|
||||
sourceTenantId: grant.sourceTenantId,
|
||||
sourceQuestionBankId: grant.sourceQuestionBankId,
|
||||
sourceQuestionBankName: grant.sourceQuestionBankName,
|
||||
sourceRegionId: grant.sourceRegionId,
|
||||
sourceRegionName: grant.sourceRegionName,
|
||||
sourceQuestionCount: grant.questionCount,
|
||||
}),
|
||||
copiedQuestionCount,
|
||||
jsonObjectValue(body.metadata),
|
||||
auth.userId,
|
||||
],
|
||||
);
|
||||
|
||||
await client.query(
|
||||
`
|
||||
insert into public.audit_logs (tenant_id, actor_user_id, action, target_type, target_id, details)
|
||||
values ($1, $2, 'content.public_question_bank.adopted', 'tenant_question_bank_adoption', $3, $4::jsonb)
|
||||
`,
|
||||
[
|
||||
auth.tenantId,
|
||||
auth.userId,
|
||||
adoptionResult.rows[0].id,
|
||||
JSON.stringify({
|
||||
grantId,
|
||||
sourceQuestionBankId: grant.sourceQuestionBankId,
|
||||
targetQuestionBankId,
|
||||
targetEntryId,
|
||||
targetCollectionId,
|
||||
copiedQuestionCount,
|
||||
}),
|
||||
],
|
||||
);
|
||||
|
||||
return adoptionResult.rows[0];
|
||||
});
|
||||
|
||||
return { item };
|
||||
}
|
||||
Reference in New Issue
Block a user