118 lines
4.4 KiB
TypeScript
118 lines
4.4 KiB
TypeScript
// 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<CourseCreateInput>;
|
|
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');
|
|
}
|
|
}
|