187 lines
6.8 KiB
TypeScript
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');
|
|
}
|
|
}
|