Files
openmaic/OpenMAIC/app/api/generate-classroom/[jobId]/route.ts
T

168 lines
5.9 KiB
TypeScript

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),
);
}
}