import { type NextRequest } from 'next/server'; import { apiError, apiSuccess } from '@/lib/server/api-response'; import { deleteClassroomGenerationJob, isValidClassroomJobId, markClassroomGenerationJobCancelled, readClassroomGenerationJob, } from '@/lib/server/classroom-job-store'; import { cancelClassroomGenerationJob } from '@/lib/server/classroom-job-runner'; import { buildRequestOrigin, deleteClassroom } from '@/lib/server/classroom-storage'; import { isPublishedClassroomSource } from '@/lib/server/published-classroom-access'; import { ClassroomSourceMutationInProgressError, runClassroomSourceDeleteExclusive, } from '@/lib/course-framework/publish-state'; import { createLogger } from '@/lib/logger'; import { authorizeClassroomJobRequest, classroomJobAccessError, } from '@/lib/server/authz/classroom-job-access'; const log = createLogger('ClassroomJob API'); export const dynamic = 'force-dynamic'; export async function GET(req: NextRequest, context: { params: Promise<{ jobId: string }> }) { let resolvedJobId: string | undefined; try { const { jobId } = await context.params; resolvedJobId = jobId; if (!isValidClassroomJobId(jobId)) { return apiError('INVALID_REQUEST', 400, 'Invalid classroom generation job id'); } const job = await readClassroomGenerationJob(jobId); if (!job) { return apiError('INVALID_REQUEST', 404, 'Classroom generation job not found'); } const access = await authorizeClassroomJobRequest(req, job, 'read'); if (!access.allowed) return classroomJobAccessError(access); const pollUrl = `${buildRequestOrigin(req)}/api/generate-classroom/${jobId}`; return apiSuccess({ jobId: job.id, status: job.status, step: job.step, progress: job.progress, message: job.message, pollUrl, pollIntervalMs: 5000, scenesGenerated: job.scenesGenerated, totalScenes: job.totalScenes, result: job.result, error: job.error, done: job.status === 'succeeded' || job.status === 'failed', }); } catch (error) { log.error(`Classroom job retrieval failed [jobId=${resolvedJobId ?? 'unknown'}]:`, error); return apiError( 'INTERNAL_ERROR', 500, 'Failed to retrieve classroom generation job', error instanceof Error ? error.message : String(error), ); } } /** * POST /api/generate-classroom/[jobId]/cancel — stop a running job at the next * scene boundary. The job becomes `cancelled`; nothing is persisted. */ export async function POST(req: NextRequest, context: { params: Promise<{ jobId: string }> }) { try { const { jobId } = await context.params; if (!isValidClassroomJobId(jobId)) { return apiError('INVALID_REQUEST', 400, 'Invalid classroom generation job id'); } const job = await readClassroomGenerationJob(jobId); if (!job) { return apiError('INVALID_REQUEST', 404, 'Classroom generation job not found'); } const access = await authorizeClassroomJobRequest(req, job, 'cancel'); if (!access.allowed) return classroomJobAccessError(access); if (job.status !== 'running' && job.status !== 'queued') { return apiError( 'INVALID_REQUEST', 409, `Job is ${job.status}; only running/queued jobs can be cancelled`, ); } cancelClassroomGenerationJob(jobId); // The runner marks the job `cancelled` once the current scene finishes; // mark queued-but-not-started jobs immediately. if (job.status === 'queued') { await markClassroomGenerationJobCancelled(jobId, 'Cancelled before start'); } return apiSuccess({ jobId, status: 'cancelling' }); } catch (error) { log.error('Classroom job cancel failed:', error); return apiError( 'INTERNAL_ERROR', 500, 'Failed to cancel classroom generation job', error instanceof Error ? error.message : String(error), ); } } /** * DELETE /api/generate-classroom/[jobId] — cancel (if running) and delete the * job record plus any classroom it produced. */ export async function DELETE(req: NextRequest, context: { params: Promise<{ jobId: string }> }) { try { const { jobId } = await context.params; if (!isValidClassroomJobId(jobId)) { return apiError('INVALID_REQUEST', 400, 'Invalid classroom generation job id'); } const job = await readClassroomGenerationJob(jobId); if (!job) { return apiError('INVALID_REQUEST', 404, 'Classroom generation job not found'); } const access = await authorizeClassroomJobRequest(req, job, 'delete'); if (!access.allowed) return classroomJobAccessError(access); const classroomId = job.result?.classroomId; const removeJobAndClassroom = async () => { if (classroomId && (await isPublishedClassroomSource(classroomId))) { return apiError( 'INVALID_REQUEST', 409, 'A published classroom source cannot be deleted with its generation job', ); } // Stop a running job first (cooperative; the record is removed below so // the runner's final mark-cancelled write is a no-op on a deleted file). cancelClassroomGenerationJob(jobId); // Remove the persisted classroom the job produced, if any. if (classroomId) { await deleteClassroom(classroomId); } await deleteClassroomGenerationJob(jobId); return apiSuccess({ jobId, deleted: true }); }; return classroomId ? await runClassroomSourceDeleteExclusive(classroomId, removeJobAndClassroom) : await removeJobAndClassroom(); } catch (error) { if (error instanceof ClassroomSourceMutationInProgressError) { return apiError('INVALID_REQUEST', 409, error.message); } log.error('Classroom job delete failed:', error); return apiError( 'INTERNAL_ERROR', 500, 'Failed to delete classroom generation job', error instanceof Error ? error.message : String(error), ); } }