126 lines
5.2 KiB
TypeScript
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');
|
|
}
|
|
}
|