forked from wangziqi/gongxue-base
feat: add public question bank sync
This commit is contained in:
@@ -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],
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user