// 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'); } }