168 lines
5.9 KiB
TypeScript
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),
|
|
);
|
|
}
|
|
}
|