Files

187 lines
6.8 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 {
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<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.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');
}
}