feat: add public question bank adoption

This commit is contained in:
Codex
2026-06-29 03:07:57 +08:00
parent c767b87c6f
commit 7c41bf525f
15 changed files with 1324 additions and 16 deletions

View File

@@ -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],

View File

@@ -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);

View File

@@ -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],

View 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 };
}