feat: add public question bank sync

This commit is contained in:
Codex
2026-06-29 08:37:40 +08:00
parent 69f8e28d4e
commit 35cff06df7
11 changed files with 690 additions and 128 deletions

View File

@@ -43,6 +43,7 @@ import {
import {
adoptPublicQuestionBankRoute,
publicQuestionBanksRoute,
syncPublicQuestionBankRoute,
} from './public-banks.js';
import {
bindQuestionVideoRoute,
@@ -74,6 +75,7 @@ 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],
['POST', '/api/tenant-content/public-question-banks/sync', syncPublicQuestionBankRoute],
['PUT', '/api/tenant-content/content-entries', upsertContentEntryRoute],
['GET', '/api/tenant-content/content-nodes', contentNodesAdminRoute],
['PUT', '/api/tenant-content/content-nodes', upsertContentNodeRoute],

View File

@@ -17,9 +17,59 @@ interface EligibleBankRow {
questionCount: number;
adoptedId: string | null;
adoptionStatus: string | null;
syncStatus: string | null;
targetQuestionBankId: string | null;
targetEntryId: string | null;
targetCollectionId: string | null;
copiedQuestionCount: number | null;
lastSyncedAt: string | null;
}
interface AdoptionRow {
id: string;
tenantId: string;
sourceQuestionBankId: string;
grantId: string | null;
targetQuestionBankId: string | null;
targetEntryId: string | null;
targetCollectionId: string | null;
adoptionMode: string;
status: string;
syncStatus: string;
sourceSnapshot: unknown;
copiedQuestionCount: number;
metadata: Record<string, unknown>;
createdAt: string;
updatedAt: string;
}
interface SourceQuestionSnapshot {
id: string;
type: string;
typeLabel: string | null;
difficulty: number | null;
tags: unknown[];
mediaUrl: string | null;
hasVideoExplanation: boolean;
content: string | null;
options: unknown[];
correctOptionIndex: number | null;
correctOptionIndices: unknown[];
answerText: string | null;
explanation: string | null;
subQuestions: unknown[];
codeLang: string | null;
codeTemplate: string | null;
sourceHash: string;
}
interface SyncQuestionResult {
sourceQuestionId: string;
targetQuestionId: string | null;
action: 'inserted' | 'updated' | 'skipped' | 'conflict';
sourceHash: string;
previousSourceHash: string | null;
targetHash: string | null;
}
function slugFromName(name: string) {
@@ -132,9 +182,12 @@ async function loadEligibleGrant(
coalesce(qs.question_count, 0)::integer as "questionCount",
a.id as "adoptedId",
a.status as "adoptionStatus",
a.sync_status as "syncStatus",
a.target_question_bank_id as "targetQuestionBankId",
a.target_entry_id as "targetEntryId",
a.target_collection_id as "targetCollectionId"
a.target_collection_id as "targetCollectionId",
a.copied_question_count as "copiedQuestionCount",
a.last_synced_at as "lastSyncedAt"
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
@@ -179,19 +232,27 @@ async function loadEligibleGrant(
return result.rows[0] || null;
}
async function copyQuestionsSnapshot(client: pg.PoolClient, input: {
auth: TenantContentAuth;
function snapshotMap(value: unknown): Record<string, string> {
if (!value || typeof value !== 'object' || Array.isArray(value)) return {};
const questions = (value as { questions?: unknown }).questions;
if (!questions || typeof questions !== 'object' || Array.isArray(questions)) return {};
const result: Record<string, string> = {};
for (const [key, raw] of Object.entries(questions as Record<string, unknown>)) {
if (typeof raw === 'string' && raw) result[key] = raw;
else if (raw && typeof raw === 'object' && typeof (raw as { sourceHash?: unknown }).sourceHash === 'string') {
result[key] = (raw as { sourceHash: string }).sourceHash;
}
}
return result;
}
async function sourceQuestionSnapshots(client: pg.PoolClient, input: {
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;
@@ -228,56 +289,132 @@ async function copyQuestionsSnapshot(client: pg.PoolClient, input: {
[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,
}),
],
return sourceQuestions.rows.map((source): SourceQuestionSnapshot => ({
id: source.id,
type: source.type,
typeLabel: source.type_label,
difficulty: source.difficulty,
tags: source.tags || [],
mediaUrl: source.media_url,
hasVideoExplanation: source.has_video_explanation,
content: source.content,
options: source.options || [],
correctOptionIndex: source.correct_option_index,
correctOptionIndices: source.correct_option_indices || [],
answerText: source.answer_text,
explanation: source.explanation,
subQuestions: source.sub_questions || [],
codeLang: source.code_lang,
codeTemplate: source.code_template,
sourceHash: source.source_hash || `public:${source.id}`,
}));
}
async function syncQuestionSnapshot(client: pg.PoolClient, input: {
auth: TenantContentAuth;
sourceTenantId: string;
sourceQuestionBankId: string;
targetQuestionBankId: string;
targetEntryId: string;
targetCollectionId: string;
source: SourceQuestionSnapshot;
previousSourceHash: string | null;
order: number;
}) {
const legacyId = `public:${input.sourceTenantId}:${input.source.id}`;
const existing = await client.query<{
id: string;
source_hash: string | null;
}>(
`
select q.id, 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.legacy_id = $2
limit 1
for update of q
`,
[input.auth.tenantId, legacyId],
);
const existingQuestion = existing.rows[0] || null;
const targetHash = existingQuestion?.source_hash || null;
if (existingQuestion) {
const targetMatchesCurrent = !!targetHash && targetHash === input.source.sourceHash;
const targetMatchesPrevious = !!targetHash && !!input.previousSourceHash && targetHash === input.previousSourceHash;
if (!targetMatchesCurrent && !targetMatchesPrevious) {
return {
sourceQuestionId: input.source.id,
targetQuestionId: existingQuestion.id,
action: 'conflict',
sourceHash: input.source.sourceHash,
previousSourceHash: input.previousSourceHash,
targetHash,
} satisfies SyncQuestionResult;
}
}
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,
input.source.type,
input.source.typeLabel,
input.source.difficulty,
JSON.stringify(input.source.tags || []),
input.source.mediaUrl,
input.source.hasVideoExplanation,
JSON.stringify({
sourceTenantId: input.sourceTenantId,
sourceQuestionBankId: input.sourceQuestionBankId,
sourceQuestionId: input.source.id,
}),
],
);
const questionId = questionResult.rows[0].id;
const action: SyncQuestionResult['action'] = existingQuestion
? targetHash === input.source.sourceHash
? 'skipped'
: 'updated'
: 'inserted';
if (action !== 'skipped') {
const latest = await client.query<{ version_no: number }>(
'select coalesce(max(version_no), 0) as version_no from public.question_versions where question_id = $1',
[questionId],
);
const questionId = questionResult.rows[0].id;
const nextVersionNo = Number(latest.rows[0]?.version_no || 0) + 1;
const versionNo = existingQuestion ? nextVersionNo : 1;
const versionResult = await client.query<{ id: string }>(
`
@@ -288,10 +425,10 @@ async function copyQuestionsSnapshot(client: pg.PoolClient, input: {
source_hash, created_by
)
values (
$1, $2, 1, $3, $4::jsonb,
$5, $6::jsonb, $7,
$8, $9::jsonb, $10, $11,
$12, $13
$1, $2, $3, $4, $5::jsonb,
$6, $7::jsonb, $8,
$9, $10::jsonb, $11, $12,
$13, $14
)
on conflict (question_id, version_no)
do update set content = excluded.content,
@@ -309,16 +446,17 @@ async function copyQuestionsSnapshot(client: pg.PoolClient, input: {
[
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}`,
versionNo,
input.source.content,
JSON.stringify(input.source.options || []),
input.source.correctOptionIndex,
JSON.stringify(input.source.correctOptionIndices || []),
input.source.answerText,
input.source.explanation,
JSON.stringify(input.source.subQuestions || []),
input.source.codeLang,
input.source.codeTemplate,
input.source.sourceHash,
input.auth.userId,
],
);
@@ -327,42 +465,124 @@ async function copyQuestionsSnapshot(client: pg.PoolClient, input: {
'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 }),
],
);
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,
input.source.type,
input.order,
JSON.stringify({ source: 'public_question_bank_adoption', sourceQuestionId: input.source.id }),
],
);
return {
sourceQuestionId: input.source.id,
targetQuestionId: questionId,
action,
sourceHash: input.source.sourceHash,
previousSourceHash: input.previousSourceHash,
targetHash,
} satisfies SyncQuestionResult;
}
function syncCounts(results: SyncQuestionResult[]) {
return {
inserted: results.filter(item => item.action === 'inserted').length,
updated: results.filter(item => item.action === 'updated').length,
skipped: results.filter(item => item.action === 'skipped').length,
conflicts: results.filter(item => item.action === 'conflict').length,
};
}
function buildSourceSnapshot(input: {
sourceTenantId: string;
sourceQuestionBankId: string;
sourceQuestionBankName: string;
sourceRegionId: string | null;
sourceRegionName: string | null;
sourceQuestionCount: number;
sourceQuestions: SourceQuestionSnapshot[];
syncSummary?: Record<string, unknown>;
}) {
return {
sourceTenantId: input.sourceTenantId,
sourceQuestionBankId: input.sourceQuestionBankId,
sourceQuestionBankName: input.sourceQuestionBankName,
sourceRegionId: input.sourceRegionId,
sourceRegionName: input.sourceRegionName,
sourceQuestionCount: input.sourceQuestionCount,
questions: Object.fromEntries(
input.sourceQuestions.map(question => [
question.id,
{
sourceHash: question.sourceHash,
syncedAt: new Date().toISOString(),
},
]),
),
syncSummary: input.syncSummary || undefined,
};
}
async function syncQuestionsSnapshot(client: pg.PoolClient, input: {
auth: TenantContentAuth;
sourceTenantId: string;
sourceQuestionBankId: string;
targetQuestionBankId: string;
targetEntryId: string;
targetCollectionId: string;
copyLimit: number;
previousSnapshot?: unknown;
}) {
const sources = await sourceQuestionSnapshots(client, input);
const previous = snapshotMap(input.previousSnapshot);
const results: SyncQuestionResult[] = [];
let order = 0;
for (const source of sources) {
const result = await syncQuestionSnapshot(client, {
auth: input.auth,
sourceTenantId: input.sourceTenantId,
sourceQuestionBankId: input.sourceQuestionBankId,
targetQuestionBankId: input.targetQuestionBankId,
targetEntryId: input.targetEntryId,
targetCollectionId: input.targetCollectionId,
source,
previousSourceHash: previous[source.id] || null,
order,
});
results.push(result);
order += 1;
}
await client.query(
`
update public.question_collections
set question_count = $3,
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
`,
[input.auth.tenantId, input.targetCollectionId, sourceQuestions.rows.length],
[input.auth.tenantId, input.targetCollectionId],
);
return sourceQuestions.rows.length;
return { sourceQuestions: sources, results, counts: syncCounts(results) };
}
export async function publicQuestionBanksRoute(ctx: RequestContext) {
@@ -417,9 +637,12 @@ export async function publicQuestionBanksRoute(ctx: RequestContext) {
coalesce(qs.question_count, 0)::integer as "questionCount",
a.id as "adoptedId",
a.status as "adoptionStatus",
a.sync_status as "syncStatus",
a.target_question_bank_id as "targetQuestionBankId",
a.target_entry_id as "targetEntryId",
a.target_collection_id as "targetCollectionId"
a.target_collection_id as "targetCollectionId",
a.copied_question_count as "copiedQuestionCount",
a.last_synced_at as "lastSyncedAt"
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
@@ -550,7 +773,7 @@ export async function adoptPublicQuestionBankRoute(ctx: RequestContext) {
);
const targetCollectionId = collectionResult.rows[0].id;
const copiedQuestionCount = await copyQuestionsSnapshot(client, {
const syncResult = await syncQuestionsSnapshot(client, {
auth,
sourceTenantId: grant.sourceTenantId,
sourceQuestionBankId: grant.sourceQuestionBankId,
@@ -559,8 +782,19 @@ export async function adoptPublicQuestionBankRoute(ctx: RequestContext) {
targetCollectionId,
copyLimit,
});
const copiedQuestionCount = syncResult.sourceQuestions.length;
const sourceSnapshot = buildSourceSnapshot({
sourceTenantId: grant.sourceTenantId,
sourceQuestionBankId: grant.sourceQuestionBankId,
sourceQuestionBankName: grant.sourceQuestionBankName,
sourceRegionId: grant.sourceRegionId,
sourceRegionName: grant.sourceRegionName,
sourceQuestionCount: grant.questionCount,
sourceQuestions: syncResult.sourceQuestions,
syncSummary: syncResult.counts,
});
const adoptionResult = await client.query(
const adoptionResult = await client.query<AdoptionRow>(
`
insert into public.tenant_question_bank_adoptions (
tenant_id, source_question_bank_id, grant_id, target_question_bank_id,
@@ -605,14 +839,7 @@ export async function adoptPublicQuestionBankRoute(ctx: RequestContext) {
targetQuestionBankId,
targetEntryId,
targetCollectionId,
JSON.stringify({
sourceTenantId: grant.sourceTenantId,
sourceQuestionBankId: grant.sourceQuestionBankId,
sourceQuestionBankName: grant.sourceQuestionBankName,
sourceRegionId: grant.sourceRegionId,
sourceRegionName: grant.sourceRegionName,
sourceQuestionCount: grant.questionCount,
}),
JSON.stringify(sourceSnapshot),
copiedQuestionCount,
jsonObjectValue(body.metadata),
auth.userId,
@@ -635,6 +862,7 @@ export async function adoptPublicQuestionBankRoute(ctx: RequestContext) {
targetEntryId,
targetCollectionId,
copiedQuestionCount,
syncSummary: syncResult.counts,
}),
],
);
@@ -644,3 +872,150 @@ export async function adoptPublicQuestionBankRoute(ctx: RequestContext) {
return { item };
}
export async function syncPublicQuestionBankRoute(ctx: RequestContext) {
const auth = await requireTenantContentEditor(ctx);
const body = await readJsonBody(ctx);
const adoptionId = requiredString(body, 'adoptionId');
const copyLimit = Math.max(1, Math.min(intValue(body.copyLimit, 1000), 1000));
const result = await transaction(async client => {
const adoptionResult = await client.query<AdoptionRow>(
`
select 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"
from public.tenant_question_bank_adoptions
where tenant_id = $1
and id = $2
and status in ('active', 'sync_pending')
limit 1
for update
`,
[auth.tenantId, adoptionId],
);
const adoption = adoptionResult.rows[0];
if (!adoption) {
throw new HttpError(404, 'Question bank adoption not found', 'QUESTION_BANK_ADOPTION_NOT_FOUND');
}
if (!adoption.grantId) {
throw new HttpError(409, 'Question bank adoption has no active grant', 'QUESTION_BANK_ADOPTION_GRANT_MISSING');
}
if (!adoption.targetQuestionBankId || !adoption.targetEntryId || !adoption.targetCollectionId) {
throw new HttpError(409, 'Question bank adoption target is incomplete', 'QUESTION_BANK_ADOPTION_TARGET_MISSING');
}
const grant = await loadEligibleGrant(client, auth.tenantId, adoption.grantId);
if (!grant || grant.sourceQuestionBankId !== adoption.sourceQuestionBankId) {
throw new HttpError(403, 'Question bank grant is not available for this tenant', 'QUESTION_BANK_GRANT_NOT_AVAILABLE');
}
const syncResult = await syncQuestionsSnapshot(client, {
auth,
sourceTenantId: grant.sourceTenantId,
sourceQuestionBankId: grant.sourceQuestionBankId,
targetQuestionBankId: adoption.targetQuestionBankId,
targetEntryId: adoption.targetEntryId,
targetCollectionId: adoption.targetCollectionId,
copyLimit,
previousSnapshot: adoption.sourceSnapshot,
});
const conflicts = syncResult.results.filter(item => item.action === 'conflict');
const syncStatus = conflicts.length ? 'failed' : 'synced';
const sourceSnapshot = buildSourceSnapshot({
sourceTenantId: grant.sourceTenantId,
sourceQuestionBankId: grant.sourceQuestionBankId,
sourceQuestionBankName: grant.sourceQuestionBankName,
sourceRegionId: grant.sourceRegionId,
sourceRegionName: grant.sourceRegionName,
sourceQuestionCount: grant.questionCount,
sourceQuestions: syncResult.sourceQuestions,
syncSummary: syncResult.counts,
});
const metadata = {
...(adoption.metadata || {}),
lastSync: {
status: syncStatus,
counts: syncResult.counts,
conflictCount: conflicts.length,
conflicts: conflicts.slice(0, 50),
},
};
const updated = await client.query<AdoptionRow>(
`
update public.tenant_question_bank_adoptions
set sync_status = $3,
status = 'active',
source_snapshot = $4::jsonb,
copied_question_count = $5,
metadata = $6::jsonb,
updated_by = $7,
last_synced_at = now(),
updated_at = now()
where tenant_id = $1 and id = $2
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,
adoption.id,
syncStatus,
JSON.stringify(sourceSnapshot),
syncResult.sourceQuestions.length,
JSON.stringify(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.synced', 'tenant_question_bank_adoption', $3, $4::jsonb)
`,
[
auth.tenantId,
auth.userId,
adoption.id,
JSON.stringify({
grantId: adoption.grantId,
sourceQuestionBankId: grant.sourceQuestionBankId,
targetQuestionBankId: adoption.targetQuestionBankId,
targetEntryId: adoption.targetEntryId,
targetCollectionId: adoption.targetCollectionId,
syncStatus,
syncSummary: syncResult.counts,
conflicts,
}),
],
);
return {
item: updated.rows[0],
sync: {
status: conflicts.length ? 'conflict' : 'synced',
counts: syncResult.counts,
results: syncResult.results,
},
};
});
return result;
}