feat: resolve public bank conflicts

This commit is contained in:
Codex
2026-06-29 13:50:41 +08:00
parent 93ec1a07d2
commit 7fc92986a5
13 changed files with 547 additions and 32 deletions

View File

@@ -48,6 +48,7 @@ import {
adoptPublicQuestionBankRoute,
publicQuestionBankConflictsRoute,
publicQuestionBanksRoute,
resolvePublicQuestionBankConflictRoute,
syncPublicQuestionBankRoute,
} from './public-banks.js';
import {
@@ -82,6 +83,7 @@ export const tenantContentRoutes: RouteDefinition[] = [
['POST', '/api/tenant-content/public-question-banks/adopt', adoptPublicQuestionBankRoute],
['POST', '/api/tenant-content/public-question-banks/sync', syncPublicQuestionBankRoute],
['GET', '/api/tenant-content/public-question-banks/conflicts', publicQuestionBankConflictsRoute],
['POST', '/api/tenant-content/public-question-banks/conflicts/resolve', resolvePublicQuestionBankConflictRoute],
['PUT', '/api/tenant-content/content-entries', upsertContentEntryRoute],
['GET', '/api/tenant-content/content-nodes', contentNodesAdminRoute],
['PUT', '/api/tenant-content/content-nodes', upsertContentNodeRoute],

View File

@@ -70,6 +70,7 @@ interface SyncQuestionResult {
sourceHash: string;
previousSourceHash: string | null;
targetHash: string | null;
resolution?: 'keep_local';
}
interface PublicQuestionBankSyncAuth {
@@ -263,6 +264,32 @@ function snapshotMap(value: unknown): Record<string, string> {
return result;
}
function sourceSnapshotObject(value: unknown) {
const snapshot = objectValue(value);
return {
...snapshot,
questions: objectValue(snapshot.questions),
};
}
function setSourceSnapshotQuestion(snapshot: Record<string, unknown> & { questions: Record<string, unknown> }, sourceQuestionId: string, sourceHash: string, extra: Record<string, unknown> = {}) {
snapshot.questions[sourceQuestionId] = {
...objectValue(snapshot.questions[sourceQuestionId]),
...extra,
sourceHash,
syncedAt: new Date().toISOString(),
};
}
function lastSyncConflicts(metadata: Record<string, unknown>) {
const lastSync = objectValue(metadata.lastSync);
return Array.isArray(lastSync.conflicts) ? lastSync.conflicts.map(item => objectValue(item)) : [];
}
function resolvedPublicBankConflicts(metadata: Record<string, unknown>) {
return objectValue(metadata.resolvedPublicBankConflicts);
}
async function sourceQuestionSnapshots(client: pg.PoolClient, input: {
sourceTenantId: string;
sourceQuestionBankId: string;
@@ -336,6 +363,7 @@ async function syncQuestionSnapshot(client: pg.PoolClient, input: {
targetCollectionId: string;
source: SourceQuestionSnapshot;
previousSourceHash: string | null;
conflictResolutions?: Record<string, unknown>;
order: number;
}) {
const legacyId = `public:${input.sourceTenantId}:${input.source.id}`;
@@ -360,6 +388,22 @@ async function syncQuestionSnapshot(client: pg.PoolClient, input: {
const targetMatchesCurrent = !!targetHash && targetHash === input.source.sourceHash;
const targetMatchesPrevious = !!targetHash && !!input.previousSourceHash && targetHash === input.previousSourceHash;
if (!targetMatchesCurrent && !targetMatchesPrevious) {
const resolved = objectValue(input.conflictResolutions?.[input.source.id]);
if (
resolved.decision === 'keep_local'
&& resolved.sourceHash === input.source.sourceHash
&& resolved.targetHash === targetHash
) {
return {
sourceQuestionId: input.source.id,
targetQuestionId: existingQuestion.id,
action: 'skipped',
sourceHash: input.source.sourceHash,
previousSourceHash: input.previousSourceHash,
targetHash,
resolution: 'keep_local',
} satisfies SyncQuestionResult;
}
return {
sourceQuestionId: input.source.id,
targetQuestionId: existingQuestion.id,
@@ -568,6 +612,7 @@ async function syncQuestionsSnapshot(client: pg.PoolClient, input: {
targetCollectionId: string;
copyLimit: number;
previousSnapshot?: unknown;
conflictResolutions?: Record<string, unknown>;
}) {
const sources = await sourceQuestionSnapshots(client, input);
const previous = snapshotMap(input.previousSnapshot);
@@ -583,6 +628,7 @@ async function syncQuestionsSnapshot(client: pg.PoolClient, input: {
targetCollectionId: input.targetCollectionId,
source,
previousSourceHash: previous[source.id] || null,
conflictResolutions: input.conflictResolutions,
order,
});
results.push(result);
@@ -952,6 +998,7 @@ export async function executePublicQuestionBankSync(input: PublicQuestionBankSyn
targetCollectionId: adoption.targetCollectionId,
copyLimit,
previousSnapshot: adoption.sourceSnapshot,
conflictResolutions: resolvedPublicBankConflicts(adoption.metadata || {}),
});
const conflicts = syncResult.results.filter(item => item.action === 'conflict');
const syncStatus = conflicts.length ? 'failed' : 'synced';
@@ -1114,3 +1161,266 @@ export async function publicQuestionBankConflictsRoute(ctx: RequestContext) {
},
};
}
export async function resolvePublicQuestionBankConflictRoute(ctx: RequestContext) {
const auth = await requireTenantContentEditor(ctx);
const body = await readJsonBody(ctx);
const adoptionId = requiredString(body, 'adoptionId');
const sourceQuestionId = requiredString(body, 'sourceQuestionId');
const resolution = requiredString(body, 'resolution');
if (!['accept_platform', 'keep_local'].includes(resolution)) {
throw new HttpError(400, 'resolution must be accept_platform or keep_local', 'PUBLIC_BANK_CONFLICT_RESOLUTION_INVALID');
}
const item = 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 <> 'archived'
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.targetQuestionBankId || !adoption.targetEntryId || !adoption.targetCollectionId) {
throw new HttpError(409, 'Question bank adoption target is incomplete', 'QUESTION_BANK_ADOPTION_TARGET_MISSING');
}
const lastConflicts = lastSyncConflicts(adoption.metadata || {});
const conflict = lastConflicts.find(item => item.sourceQuestionId === sourceQuestionId);
if (!conflict) {
throw new HttpError(404, 'Public question bank conflict not found', 'PUBLIC_BANK_CONFLICT_NOT_FOUND');
}
const targetQuestionId = nullableString(conflict.targetQuestionId);
if (!targetQuestionId) {
throw new HttpError(409, 'Conflict has no target question', 'PUBLIC_BANK_CONFLICT_TARGET_MISSING');
}
const grant = adoption.grantId ? await loadEligibleGrant(client, auth.tenantId, adoption.grantId) : null;
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 source = (await sourceQuestionSnapshots(client, {
sourceTenantId: grant.sourceTenantId,
sourceQuestionBankId: grant.sourceQuestionBankId,
copyLimit: 1000,
})).find(question => question.id === sourceQuestionId);
if (!source) throw new HttpError(404, 'Source question not found', 'PUBLIC_BANK_SOURCE_QUESTION_NOT_FOUND');
const target = 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.id = $2
limit 1
for update of q
`,
[auth.tenantId, targetQuestionId],
);
if (!target.rows[0]) throw new HttpError(404, 'Target question not found', 'PUBLIC_BANK_TARGET_QUESTION_NOT_FOUND');
const snapshot = sourceSnapshotObject(adoption.sourceSnapshot);
const metadata: Record<string, unknown> = {
...(adoption.metadata || {}),
resolvedPublicBankConflicts: {
...resolvedPublicBankConflicts(adoption.metadata || {}),
},
};
const resolved = objectValue(metadata.resolvedPublicBankConflicts);
if (resolution === 'accept_platform') {
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',
[targetQuestionId],
);
const nextVersionNo = Number(latest.rows[0]?.version_no || 0) + 1;
const version = 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, $3, $4, $5::jsonb, $6, $7::jsonb, $8, $9, $10::jsonb, $11, $12, $13, $14)
returning id
`,
[
auth.tenantId,
targetQuestionId,
nextVersionNo,
source.content,
JSON.stringify(source.options || []),
source.correctOptionIndex,
JSON.stringify(source.correctOptionIndices || []),
source.answerText,
source.explanation,
JSON.stringify(source.subQuestions || []),
source.codeLang,
source.codeTemplate,
source.sourceHash,
auth.userId,
],
);
await client.query(
`
update public.questions
set question_bank_id = $3,
entry_id = $4,
primary_collection_id = $5,
type = $6,
type_label = $7,
difficulty = $8,
tags = $9::jsonb,
media_url = $10,
has_video_explanation = $11,
status = 'published',
current_version_id = $12,
updated_at = now()
where tenant_id = $1 and id = $2
`,
[
auth.tenantId,
targetQuestionId,
adoption.targetQuestionBankId,
adoption.targetEntryId,
adoption.targetCollectionId,
source.type,
source.typeLabel,
source.difficulty,
JSON.stringify(source.tags || []),
source.mediaUrl,
source.hasVideoExplanation,
version.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,
metadata = excluded.metadata,
updated_at = now()
`,
[
auth.tenantId,
adoption.targetCollectionId,
targetQuestionId,
source.type,
0,
JSON.stringify({
source: 'public_question_bank_adoption',
sourceQuestionId,
lastConflictResolution: 'accept_platform',
}),
],
);
setSourceSnapshotQuestion(snapshot, sourceQuestionId, source.sourceHash, { resolution: 'accept_platform' });
delete resolved[sourceQuestionId];
} else {
setSourceSnapshotQuestion(snapshot, sourceQuestionId, source.sourceHash, { resolution: 'keep_local' });
resolved[sourceQuestionId] = {
decision: 'keep_local',
sourceHash: source.sourceHash,
targetHash: target.rows[0].source_hash,
resolvedAt: new Date().toISOString(),
resolvedBy: auth.userId,
};
}
const remainingConflicts = lastConflicts.filter(item => item.sourceQuestionId !== sourceQuestionId);
const lastSync = {
...objectValue((adoption.metadata || {}).lastSync),
status: remainingConflicts.length ? 'failed' : 'resolved',
conflictCount: remainingConflicts.length,
conflicts: remainingConflicts,
lastResolution: {
sourceQuestionId,
resolution,
resolvedAt: new Date().toISOString(),
},
};
metadata.resolvedPublicBankConflicts = resolved;
metadata.lastSync = lastSync;
const updated = await client.query<AdoptionRow>(
`
update public.tenant_question_bank_adoptions
set sync_status = $3,
source_snapshot = $4::jsonb,
metadata = $5::jsonb,
updated_by = $6,
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,
remainingConflicts.length ? 'failed' : 'synced',
JSON.stringify(snapshot),
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.conflict_resolved', 'tenant_question_bank_adoption', $3, $4::jsonb)
`,
[
auth.tenantId,
auth.userId,
adoption.id,
JSON.stringify({
sourceQuestionBankId: adoption.sourceQuestionBankId,
sourceQuestionId,
targetQuestionId,
resolution,
sourceHash: source.sourceHash,
previousTargetHash: target.rows[0].source_hash,
remainingConflictCount: remainingConflicts.length,
}),
],
);
return {
adoption: updated.rows[0],
sourceQuestionId,
targetQuestionId,
resolution,
remainingConflictCount: remainingConflicts.length,
syncStatus: updated.rows[0].syncStatus,
};
});
return { item };
}