149 lines
5.4 KiB
TypeScript
149 lines
5.4 KiB
TypeScript
// Large-course mode — course publish facade (L1 → L2).
|
|
//
|
|
// `publishCourse` is the course-level publish gate:
|
|
// 1. bearer token check (same COURSEWARE_PUBLISH_TOKEN as coursewares),
|
|
// 2. framework completeness (non-empty module list),
|
|
// 3. every module has a published courseware record in the L2 courseware repo
|
|
// — a course cannot go live with unpublished modules,
|
|
// 4. write the manifest (idempotent, version increments each publish).
|
|
//
|
|
// Learners read the manifest via GET /api/learn/courses/:id (static, zero LLM).
|
|
|
|
import { createFileCoursewareRepo, COURSEWARES_DIR } from '@/lib/courseware-repo/store';
|
|
import { verifyPublishToken } from '@/lib/courseware-repo';
|
|
import type { CoursewareRepo } from '@/lib/courseware-repo/types';
|
|
import { courseManifestRepo } from './store';
|
|
import type { CourseManifestRecord, CourseManifestRepo } from './types';
|
|
|
|
export interface PublishCourseInput {
|
|
courseId: string;
|
|
token: string | null | undefined;
|
|
title: string;
|
|
summary: string;
|
|
language?: string;
|
|
modules: {
|
|
index: number;
|
|
title: string;
|
|
description: string;
|
|
coursewareId: string;
|
|
/** Exact pin returned by a just-completed server publish. */
|
|
coursewareVersion?: number;
|
|
contentHash?: string;
|
|
}[];
|
|
repos?: { coursewares?: CoursewareRepo; manifests?: CourseManifestRepo };
|
|
}
|
|
|
|
export type PublishCourseResult =
|
|
| { ok: true; record: CourseManifestRecord }
|
|
| { ok: false; errorCode: string; error: string };
|
|
|
|
export async function publishCourse(input: PublishCourseInput): Promise<PublishCourseResult> {
|
|
if (!verifyPublishToken(input.token)) {
|
|
return { ok: false, errorCode: 'UNAUTHORIZED', error: 'Invalid or missing publish token' };
|
|
}
|
|
if (!/^[a-zA-Z0-9_-]+$/.test(input.courseId)) {
|
|
return {
|
|
ok: false,
|
|
errorCode: 'INVALID_REQUEST',
|
|
error: `Invalid courseId: ${input.courseId}`,
|
|
};
|
|
}
|
|
if (input.modules.length === 0) {
|
|
return { ok: false, errorCode: 'INVALID_REQUEST', error: 'Course has no modules to publish' };
|
|
}
|
|
const sortedIndexes = input.modules
|
|
.map((moduleRecord) => moduleRecord.index)
|
|
.sort((a, b) => a - b);
|
|
if (sortedIndexes.some((index, position) => !Number.isInteger(index) || index !== position + 1)) {
|
|
return {
|
|
ok: false,
|
|
errorCode: 'INVALID_REQUEST',
|
|
error: 'Module indexes must be unique and contiguous starting at 1',
|
|
};
|
|
}
|
|
for (const moduleRec of input.modules) {
|
|
if (!moduleRec.coursewareId || !moduleRec.title || !moduleRec.description) {
|
|
return {
|
|
ok: false,
|
|
errorCode: 'INVALID_REQUEST',
|
|
error: `Module ${moduleRec.index} is missing title/description/coursewareId`,
|
|
};
|
|
}
|
|
if (!/^[a-zA-Z0-9_-]+$/.test(moduleRec.coursewareId)) {
|
|
return {
|
|
ok: false,
|
|
errorCode: 'INVALID_REQUEST',
|
|
error: `Module ${moduleRec.index} has an invalid coursewareId`,
|
|
};
|
|
}
|
|
const hasVersion = moduleRec.coursewareVersion !== undefined;
|
|
const hasHash = moduleRec.contentHash !== undefined;
|
|
if (hasVersion !== hasHash) {
|
|
return {
|
|
ok: false,
|
|
errorCode: 'INVALID_REQUEST',
|
|
error: `Module ${moduleRec.index} must provide both coursewareVersion and contentHash`,
|
|
};
|
|
}
|
|
}
|
|
|
|
// Gate and resolve: every module must point at one exact, complete,
|
|
// published courseware record. Keep the resolved identity for the manifest.
|
|
const records = input.repos?.coursewares ?? createFileCoursewareRepo(COURSEWARES_DIR);
|
|
const resolvedModules: Array<{
|
|
input: PublishCourseInput['modules'][number];
|
|
version: number;
|
|
contentHash: string;
|
|
}> = [];
|
|
for (const moduleRec of input.modules) {
|
|
const published = moduleRec.coursewareVersion
|
|
? await records.getRecord(moduleRec.coursewareId, moduleRec.coursewareVersion)
|
|
: await records.getLatestRecord(moduleRec.coursewareId, { status: 'published' });
|
|
if (!published || published.status !== 'published') {
|
|
return {
|
|
ok: false,
|
|
errorCode: 'MODULE_NOT_PUBLISHED',
|
|
error: `Module ${moduleRec.index} "${moduleRec.title}" is not published (courseware ${moduleRec.coursewareId})`,
|
|
};
|
|
}
|
|
if (!published.complete || !/^[0-9a-f]{64}$/.test(published.contentHash)) {
|
|
return {
|
|
ok: false,
|
|
errorCode: 'MODULE_INCOMPLETE',
|
|
error: `Module ${moduleRec.index} "${moduleRec.title}" has no complete immutable bundle`,
|
|
};
|
|
}
|
|
if (moduleRec.contentHash && moduleRec.contentHash !== published.contentHash) {
|
|
return {
|
|
ok: false,
|
|
errorCode: 'MODULE_VERSION_MISMATCH',
|
|
error: `Module ${moduleRec.index} courseware hash changed before the course manifest was committed`,
|
|
};
|
|
}
|
|
resolvedModules.push({
|
|
input: moduleRec,
|
|
version: published.version,
|
|
contentHash: published.contentHash,
|
|
});
|
|
}
|
|
|
|
const manifests = input.repos?.manifests ?? courseManifestRepo;
|
|
const record = await manifests.saveNextRecord({
|
|
schemaVersion: 2,
|
|
courseId: input.courseId,
|
|
title: input.title,
|
|
summary: input.summary,
|
|
...(input.language ? { language: input.language } : {}),
|
|
modules: resolvedModules.map(({ input: moduleRecord, version, contentHash }) => ({
|
|
index: moduleRecord.index,
|
|
title: moduleRecord.title,
|
|
description: moduleRecord.description,
|
|
coursewareId: moduleRecord.coursewareId,
|
|
coursewareVersion: version,
|
|
contentHash,
|
|
})),
|
|
publishedAt: new Date().toISOString(),
|
|
});
|
|
return { ok: true, record };
|
|
}
|