import { buildCourseModuleOutputDigest, computeClassroomSourceRevisionHash } from './module-digest'; import type { CourseModuleContinuityInputRef, CourseModuleOutputDigest, CourseModuleRecord, CourseRecord, } from './types'; import { readClassroom, type PersistedClassroomData } from '@/lib/server/classroom-storage'; import { readCourseRecordReconciled } from './store'; export interface ValidatedPublishModule { module: CourseModuleRecord; classroom: PersistedClassroomData; outputDigest: CourseModuleOutputDigest; sourceRevisionHash: string; } export interface CoursePublishSnapshot { courseId: string; fingerprint: string; modules: ValidatedPublishModule[]; } export class CoursePublishValidationError extends Error { constructor( public readonly code: string, message: string, public readonly moduleIndex?: number, ) { super(message); this.name = 'CoursePublishValidationError'; } } function sameContinuityRefs( actual: readonly CourseModuleContinuityInputRef[] | undefined, expected: readonly CourseModuleContinuityInputRef[], ): boolean { if ((actual?.length ?? 0) !== expected.length) return false; return expected.every((expectedRef, index) => { const actualRef = actual?.[index]; return ( actualRef?.moduleIndex === expectedRef.moduleIndex && actualRef.classroomId === expectedRef.classroomId && actualRef.semanticHash === expectedRef.semanticHash ); }); } function publishFingerprint( record: CourseRecord, modules: readonly ValidatedPublishModule[], ): string { return JSON.stringify({ framework: record.framework, modules: modules.map(({ module, outputDigest, sourceRevisionHash }) => ({ index: module.index, classroomId: module.classroomId, semanticHash: outputDigest.semanticHash, sourceRevisionHash, continuityInputRefs: module.continuityInputRefs ?? [], })), }); } export async function validateCoursePublishSnapshot( record: CourseRecord, ): Promise { if (!record.framework || record.framework.modules.length === 0) { throw new CoursePublishValidationError( 'FRAMEWORK_MISSING', 'Course framework is missing or empty', ); } if (record.modules.length !== record.framework.modules.length) { throw new CoursePublishValidationError( 'MODULE_LAYOUT_MISMATCH', 'Course runtime modules no longer match the approved framework', ); } const runtimeModules = [...record.modules].sort((a, b) => a.index - b.index); const frameworkModules = [...record.framework.modules].sort((a, b) => a.index - b.index); const validated: ValidatedPublishModule[] = []; for (let position = 0; position < runtimeModules.length; position += 1) { const moduleRecord = runtimeModules[position]; const spec = frameworkModules[position]; if (moduleRecord.index !== position + 1 || spec.index !== moduleRecord.index) { throw new CoursePublishValidationError( 'MODULE_LAYOUT_MISMATCH', `Module ${moduleRecord.index} is out of framework order`, moduleRecord.index, ); } if (moduleRecord.status !== 'succeeded' || !moduleRecord.classroomId) { throw new CoursePublishValidationError( 'MODULE_NOT_GENERATED', `Module ${moduleRecord.index} has no successful classroom`, moduleRecord.index, ); } const classroom = await readClassroom(moduleRecord.classroomId).catch(() => null); if (!classroom) { throw new CoursePublishValidationError( 'MODULE_CLASSROOM_MISSING', `Module ${moduleRecord.index} classroom ${moduleRecord.classroomId} is unavailable`, moduleRecord.index, ); } const outputDigest = buildCourseModuleOutputDigest(classroom); if (!moduleRecord.outputDigest) { throw new CoursePublishValidationError( 'MODULE_OUTPUT_UNVERIFIED', `Module ${moduleRecord.index} has no persisted output digest`, moduleRecord.index, ); } const sourceRevisionHash = computeClassroomSourceRevisionHash(classroom); if (!moduleRecord.outputDigest.sourceRevisionHash) { throw new CoursePublishValidationError( 'MODULE_OUTPUT_UNVERIFIED', `Module ${moduleRecord.index} predates exact source revision tracking; regenerate it before publishing`, moduleRecord.index, ); } if ( moduleRecord.outputDigest.semanticHash !== outputDigest.semanticHash || moduleRecord.outputDigest.sourceRevisionHash !== sourceRevisionHash ) { throw new CoursePublishValidationError( 'MODULE_OUTPUT_CHANGED', `Module ${moduleRecord.index} classroom changed after generation; regenerate it before publishing`, moduleRecord.index, ); } const hasInteractiveHtml = classroom.scenes.some( (scene) => scene.content.type === 'interactive' && typeof scene.content.html === 'string' && scene.content.html.trim().length > 0, ); if (!hasInteractiveHtml) { throw new CoursePublishValidationError( 'INTERACTIVE_HTML_MISSING', `Module ${moduleRecord.index} has no generated interactive HTML scene`, moduleRecord.index, ); } const expectedRefs = validated.map(({ module, outputDigest: priorDigest }) => ({ moduleIndex: module.index, classroomId: priorDigest.classroomId, semanticHash: priorDigest.semanticHash, })); if (!sameContinuityRefs(moduleRecord.continuityInputRefs, expectedRefs)) { throw new CoursePublishValidationError( moduleRecord.continuityInputRefs === undefined ? 'CONTINUITY_UNVERIFIED' : 'CONTINUITY_STALE', `Module ${moduleRecord.index} was not generated from the current preceding module outputs`, moduleRecord.index, ); } validated.push({ module: moduleRecord, classroom, outputDigest, sourceRevisionHash }); } return { courseId: record.id, fingerprint: publishFingerprint(record, validated), modules: validated, }; } export async function revalidateCoursePublishSnapshot( snapshot: CoursePublishSnapshot, ): Promise { const current = await readCourseRecordReconciled(snapshot.courseId); if (!current) { throw new CoursePublishValidationError( 'COURSE_CHANGED_DURING_PUBLISH', `Course ${snapshot.courseId} disappeared during publish`, ); } const revalidated = await validateCoursePublishSnapshot(current); if (revalidated.fingerprint !== snapshot.fingerprint) { throw new CoursePublishValidationError( 'COURSE_CHANGED_DURING_PUBLISH', `Course ${snapshot.courseId} changed while its modules were being frozen`, ); } return revalidated; }