// Large-course mode API — create + list courses (ops side). // // POST /api/courses create a large course from a requirement and start // the two-layer generation job (framework → modules) // GET /api/courses list courses (newest first) // // The generation job runs on the server (after()), reusing the classroom job // infrastructure per module; closing the page does not interrupt it. Poll the // course detail route (`GET /api/courses/:id`) for progress. import { after, type NextRequest } from 'next/server'; import { nanoid } from 'nanoid'; import { apiError, apiSuccess, API_ERROR_CODES } from '@/lib/server/api-response'; import { buildRequestOrigin } from '@/lib/server/classroom-storage'; import { createLogger } from '@/lib/logger'; import type { CourseCreateInput } from '@/lib/course-framework/types'; import { createCourse, runCourseFrameworkGeneration } from '@/lib/course-framework/runner'; import { listCourseRecords, readCourseRecordReconciled } from '@/lib/course-framework/store'; import { requireOpsAccess } from '@/lib/server/ops-access'; const log = createLogger('Courses API'); export async function POST(req: NextRequest) { const denied = requireOpsAccess(req); if (denied) return denied; let requirementSnippet: string | undefined; try { const rawBody = (await req.json()) as Partial; requirementSnippet = rawBody.requirement?.substring(0, 60); const body: CourseCreateInput = { requirement: rawBody.requirement || '', ...(rawBody.enableWebSearch != null ? { enableWebSearch: rawBody.enableWebSearch } : {}), ...(rawBody.enableImageGeneration != null ? { enableImageGeneration: rawBody.enableImageGeneration } : {}), ...(rawBody.enableVideoGeneration != null ? { enableVideoGeneration: rawBody.enableVideoGeneration } : {}), ...(rawBody.enableTTS != null ? { enableTTS: rawBody.enableTTS } : {}), ...(rawBody.pdfContent ? { pdfContent: rawBody.pdfContent } : {}), }; const { requirement } = body; if (!requirement) { return apiError( API_ERROR_CODES.MISSING_REQUIRED_FIELD, 400, 'Missing required field: requirement', ); } const courseId = nanoid(10); const baseUrl = buildRequestOrigin(req); await createCourse(courseId, body); const detailUrl = `${baseUrl}/api/courses/${courseId}`; // Phase 1 only: generate the course framework, then wait for the operator // to review and confirm before any module is generated (POST /start). after(() => runCourseFrameworkGeneration(courseId, baseUrl)); return apiSuccess( { courseId, status: 'queued', message: 'Course framework generation started; awaiting confirmation before modules', detailUrl, pollIntervalMs: 3000, }, 202, ); } catch (error) { log.error( `Course creation failed [requirement="${requirementSnippet ?? 'unknown'}..."]:`, error, ); return apiError( API_ERROR_CODES.INTERNAL_ERROR, 500, 'Failed to create course', error instanceof Error ? error.message : 'Unknown error', ); } } export async function GET(request: NextRequest) { const denied = requireOpsAccess(request); if (denied) return denied; try { const limitParam = Number(new URL(request.url).searchParams.get('limit') ?? 50); const limit = Number.isFinite(limitParam) && limitParam > 0 ? Math.min(limitParam, 100) : 50; const records = await listCourseRecords(limit); const items = await Promise.all( records.map(async (record) => { // Reconcile live state so the list reflects job outcomes without polling. const reconciled = (await readCourseRecordReconciled(record.id).catch(() => null)) ?? record; return { courseId: reconciled.id, status: reconciled.status, title: reconciled.framework?.courseTitle ?? reconciled.requirement.slice(0, 40), moduleCount: reconciled.modules.length, succeededCount: reconciled.modules.filter((m) => m.status === 'succeeded').length, error: reconciled.error, createdAt: reconciled.createdAt, updatedAt: reconciled.updatedAt, }; }), ); return apiSuccess({ items }); } catch (error) { log.error('Course listing failed:', error); return apiError(API_ERROR_CODES.INTERNAL_ERROR, 500, 'Failed to list courses'); } }