Files
openmaic/OpenMAIC/app/api/courses/[courseId]/route.ts

126 lines
5.2 KiB
TypeScript

// Large-course mode API — course detail (ops side).
//
// GET /api/courses/:courseId
//
// Returns the full course record (reconciled against classroom jobs) plus
// per-module publish annotations from the L2 courseware repo. Modules that are
// currently generating carry the live progress of their classroom job so the
// builder page can show real-time progress.
import { type NextRequest } from 'next/server';
import { apiError, apiSuccess, API_ERROR_CODES } from '@/lib/server/api-response';
import { createLogger } from '@/lib/logger';
import { isValidCourseId, readCourseRecordReconciled } from '@/lib/course-framework/store';
import { readClassroomGenerationJob } from '@/lib/server/classroom-job-store';
import { COURSEWARES_DIR, createFileCoursewareRepo } from '@/lib/courseware-repo/store';
import type { CourseDetail, CourseModuleView } from '@/lib/course-framework/types';
import { courseModuleCoursewareId } from '@/lib/course-framework/publish-identity';
import { requireOpsAccess } from '@/lib/server/ops-access';
import { readClassroom } from '@/lib/server/classroom-storage';
import { computeClassroomSourceRevisionHash } from '@/lib/course-framework/module-digest';
const log = createLogger('Course Detail API');
export async function GET(
request: NextRequest,
{ params }: { params: Promise<{ courseId: string }> },
) {
const denied = await requireOpsAccess(request);
if (denied) return denied;
try {
const { courseId } = await params;
if (!isValidCourseId(courseId)) {
return apiError(API_ERROR_CODES.INVALID_REQUEST, 400, `Invalid course id: ${courseId}`);
}
const record = await readCourseRecordReconciled(courseId);
if (!record) {
return apiError(API_ERROR_CODES.INVALID_REQUEST, 404, `Course not found: ${courseId}`);
}
// Annotate each generated module with its published state. Learner-facing
// ids are derived from course + module and deliberately differ from the
// mutable operations-side classroom id.
const coursewareRepo = createFileCoursewareRepo(COURSEWARES_DIR);
const modules: CourseModuleView[] = [];
for (const moduleRec of record.modules) {
let published = false;
let publishedCoursewareId: string | undefined;
if (record.externalPublication?.status === 'published') {
published = true;
publishedCoursewareId = `${record.externalPublication.courseId}_m${moduleRec.index}`;
}
if (moduleRec.classroomId) {
const coursewareId = courseModuleCoursewareId(courseId, moduleRec.index);
const receipt = record.publication?.modules.find(
(entry) => entry.index === moduleRec.index,
);
const currentClassroom = receipt
? await readClassroom(moduleRec.classroomId).catch(() => null)
: null;
const currentSourceRevisionHash = currentClassroom
? computeClassroomSourceRevisionHash(currentClassroom)
: undefined;
published = published || Boolean(
receipt &&
receipt.coursewareId === coursewareId &&
receipt.sourceClassroomId === moduleRec.classroomId &&
receipt.sourceRevisionHash === moduleRec.outputDigest?.sourceRevisionHash &&
receipt.sourceRevisionHash === currentSourceRevisionHash &&
receipt.sourceSemanticHash === moduleRec.outputDigest?.semanticHash,
);
// A receipt is authoritative and must fail closed if the mutable
// source/digest has drifted. The registry fallback is legacy-only.
if (!published && !record.publication) {
const latest = await coursewareRepo
.getLatestRecord(coursewareId, { status: 'published' })
.catch(() => null);
published = latest?.sourceClassroomId === moduleRec.classroomId;
}
if (published) publishedCoursewareId = coursewareId;
}
let jobProgress: number | undefined;
let jobMessage: string | undefined;
let scenesGenerated: number | undefined;
let totalScenes: number | undefined;
if (moduleRec.status === 'generating' && moduleRec.jobId) {
const job = await readClassroomGenerationJob(moduleRec.jobId).catch(() => null);
if (job) {
jobProgress = job.progress;
jobMessage = job.message;
scenesGenerated = job.scenesGenerated;
totalScenes = job.totalScenes;
}
}
modules.push({
...moduleRec,
published,
publishedCoursewareId,
jobProgress,
jobMessage,
scenesGenerated,
totalScenes,
});
}
const detail: CourseDetail = {
...record,
modules,
frameworkTitle: record.framework?.courseTitle,
counts: {
total: modules.length,
succeeded: modules.filter((m) => m.status === 'succeeded').length,
failed: modules.filter((m) => m.status === 'failed').length,
generating: modules.filter((m) => m.status === 'generating').length,
pending: modules.filter((m) => m.status === 'pending').length,
published: modules.filter((m) => m.published).length,
},
};
return apiSuccess({ course: detail });
} catch (error) {
log.error('Course detail failed:', error);
return apiError(API_ERROR_CODES.INTERNAL_ERROR, 500, 'Failed to load course');
}
}