// 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 { configuredWorksSquareApiOrigin, requireOpsAccess, } from '@/lib/server/ops-access'; const log = createLogger('Courses API'); export async function POST(req: NextRequest) { const denied = await 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.interactiveMode != null ? { interactiveMode: rawBody.interactiveMode } : {}), ...(rawBody.taskEngineMode != null ? { taskEngineMode: rawBody.taskEngineMode } : {}), ...(rawBody.pdfContent ? { pdfContent: rawBody.pdfContent } : {}), }; const { requirement } = body; if (!requirement) { return apiError( API_ERROR_CODES.MISSING_REQUIRED_FIELD, 400, 'Missing required field: requirement', ); } const worksOrigin = configuredWorksSquareApiOrigin(); if (worksOrigin) { const upstream = await fetch(`${worksOrigin}/api/admin/learning/generations`, { method: 'POST', headers: { Authorization: req.headers.get('authorization') ?? '', 'Content-Type': 'application/json', Accept: 'application/json', }, body: JSON.stringify({ requirement: body.requirement, enableWebSearch: body.enableWebSearch ?? false, enableImageGeneration: body.enableImageGeneration ?? false, enableVideoGeneration: body.enableVideoGeneration ?? false, enableTTS: body.enableTTS ?? true, interactiveMode: body.interactiveMode ?? true, taskEngineMode: body.taskEngineMode ?? false, }), redirect: 'error', cache: 'no-store', signal: AbortSignal.timeout(30_000), }); const result = (await upstream.json().catch(() => null)) as { jobId?: unknown; job_id?: unknown; status?: unknown; message?: unknown; } | null; if (!upstream.ok) { return apiError( API_ERROR_CODES.UPSTREAM_ERROR, upstream.status >= 400 && upstream.status < 500 ? upstream.status : 502, 'Works rejected the large-course generation request', ); } const remoteJobId = typeof result?.jobId === 'string' ? result.jobId : typeof result?.job_id === 'string' ? result.job_id : ''; if (!remoteJobId) { return apiError( API_ERROR_CODES.UPSTREAM_ERROR, 502, 'Works returned an invalid generation response', ); } return apiSuccess( { courseId: remoteJobId, jobId: remoteJobId, status: typeof result?.status === 'string' ? result.status : 'queued', message: typeof result?.message === 'string' ? result.message : 'Course framework generation started', detailUrl: `${buildRequestOrigin(req)}/api/courses/${remoteJobId}`, pollIntervalMs: 3000, }, 202, ); } 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 = await 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'); } }