feat: productionize learning engine and classroom
This commit is contained in:
@@ -1,288 +1,6 @@
|
||||
// Server-only transactional large-course ingestion.
|
||||
//
|
||||
// One multipart request carries `metadata` JSON plus `module-{index}` ZIPs.
|
||||
// The server stages every frozen bundle as unpublished, promotes the complete
|
||||
// batch, commits one schema-v2 manifest, and compensates this batch back to
|
||||
// unpublished on failure. Learner APIs never expose sourceClassroomId.
|
||||
import { type NextRequest } from 'next/server';
|
||||
|
||||
import { type NextRequest, NextResponse } from 'next/server';
|
||||
import { CoursePublishInProgressError } from '@/lib/course-framework/publish-state';
|
||||
import type { CourseManifestRepo } from '@/lib/course-manifest-repo/types';
|
||||
import {
|
||||
COURSEWARE_BUNDLES_DIR,
|
||||
createFileBundleByteStore,
|
||||
type BundleByteStore,
|
||||
} from '@/lib/courseware-repo/bundle-store';
|
||||
import { COURSEWARES_DIR, createFileCoursewareRepo } from '@/lib/courseware-repo/store';
|
||||
import {
|
||||
COURSE_PUBLISH_MAX_UPLOAD_BYTES_ENV,
|
||||
COURSEWARE_PUBLIC_BASE_URL_ENV,
|
||||
CoursePublishTransportError,
|
||||
DEFAULT_COURSE_PUBLISH_MAX_UPLOAD_BYTES,
|
||||
DEFAULT_COURSEWARE_MAX_UPLOAD_BYTES,
|
||||
MAX_COURSE_PUBLISH_METADATA_BYTES,
|
||||
parseRemoteCoursePublishMetadata,
|
||||
positiveIntegerEnv,
|
||||
resolveCoursewarePublicBaseUrl,
|
||||
type RemoteCoursePublishErrorBody,
|
||||
type RemoteCoursePublishSuccessBody,
|
||||
} from '@/lib/server/course-publish-contract';
|
||||
import {
|
||||
commitRemoteCoursePublish,
|
||||
type CoursePublishTransactionRepos,
|
||||
} from '@/lib/server/course-publish-transaction';
|
||||
import { buildRequestOrigin } from '@/lib/server/classroom-storage';
|
||||
import { capBodyStream } from '@/lib/server/capped-stream';
|
||||
import { createLogger } from '@/lib/logger';
|
||||
import {
|
||||
COURSEWARE_PUBLISH_TOKEN_ENV,
|
||||
isPublishTokenConfigured,
|
||||
verifyPublishToken,
|
||||
} from '@/lib/courseware-repo';
|
||||
import type { CoursewareRepo } from '@/lib/courseware-repo/types';
|
||||
import { getServerDeploymentRole } from '@/lib/server/ops-access';
|
||||
|
||||
const log = createLogger('Internal Course Publish API');
|
||||
|
||||
export interface CoursePublishRouteDependencies {
|
||||
coursewares?: CoursewareRepo;
|
||||
bundles?: BundleByteStore;
|
||||
manifests?: CourseManifestRepo;
|
||||
publicBaseUrl?: string;
|
||||
}
|
||||
|
||||
function bearerToken(request: NextRequest): string | null {
|
||||
const header = request.headers.get('authorization');
|
||||
if (!header?.startsWith('Bearer ')) return null;
|
||||
return header.slice('Bearer '.length).trim() || null;
|
||||
}
|
||||
|
||||
function errorResponse(error: CoursePublishTransportError): NextResponse {
|
||||
const body: RemoteCoursePublishErrorBody = {
|
||||
success: false,
|
||||
errorCode: error.errorCode,
|
||||
error: error.message,
|
||||
phase: error.phase,
|
||||
...(error.moduleIndex ? { moduleIndex: error.moduleIndex } : {}),
|
||||
...(error.details ? { details: error.details } : {}),
|
||||
};
|
||||
return NextResponse.json(body, { status: error.status });
|
||||
}
|
||||
|
||||
function routeRepos(deps: CoursePublishRouteDependencies): Partial<CoursePublishTransactionRepos> {
|
||||
return {
|
||||
coursewares: deps.coursewares ?? createFileCoursewareRepo(COURSEWARES_DIR),
|
||||
bundles: deps.bundles ?? createFileBundleByteStore(COURSEWARE_BUNDLES_DIR),
|
||||
...(deps.manifests ? { manifests: deps.manifests } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
function canonicalPublicBaseUrl(
|
||||
request: NextRequest,
|
||||
deps: CoursePublishRouteDependencies,
|
||||
): string {
|
||||
const configured =
|
||||
deps.publicBaseUrl?.trim() || process.env[COURSEWARE_PUBLIC_BASE_URL_ENV]?.trim();
|
||||
if (!configured && process.env.NODE_ENV === 'production') {
|
||||
throw new CoursePublishTransportError(
|
||||
'PUBLIC_URL_MISSING',
|
||||
`${COURSEWARE_PUBLIC_BASE_URL_ENV} is required on a production server`,
|
||||
'request',
|
||||
503,
|
||||
);
|
||||
}
|
||||
return resolveCoursewarePublicBaseUrl(configured || buildRequestOrigin(request));
|
||||
}
|
||||
|
||||
export async function handleCoursePublishRequest(
|
||||
request: NextRequest,
|
||||
deps: CoursePublishRouteDependencies = {},
|
||||
): Promise<NextResponse> {
|
||||
const role = getServerDeploymentRole();
|
||||
if (role !== 'server' && role !== 'all') {
|
||||
return errorResponse(
|
||||
new CoursePublishTransportError(
|
||||
'FORBIDDEN',
|
||||
'Transactional course publishing is only available on the server deployment',
|
||||
'request',
|
||||
403,
|
||||
),
|
||||
);
|
||||
}
|
||||
if (!isPublishTokenConfigured()) {
|
||||
return errorResponse(
|
||||
new CoursePublishTransportError(
|
||||
'PUBLISH_DISABLED',
|
||||
`Publish is disabled: ${COURSEWARE_PUBLISH_TOKEN_ENV} is not configured`,
|
||||
'request',
|
||||
503,
|
||||
),
|
||||
);
|
||||
}
|
||||
const token = bearerToken(request);
|
||||
if (!verifyPublishToken(token)) {
|
||||
return errorResponse(
|
||||
new CoursePublishTransportError(
|
||||
'UNAUTHORIZED',
|
||||
'Invalid or missing publish token',
|
||||
'request',
|
||||
401,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
if (!request.headers.get('content-type')?.toLowerCase().startsWith('multipart/form-data')) {
|
||||
throw new CoursePublishTransportError(
|
||||
'INVALID_CONTENT_TYPE',
|
||||
'Course publish requires multipart/form-data',
|
||||
'request',
|
||||
415,
|
||||
);
|
||||
}
|
||||
const maxUploadBytes = positiveIntegerEnv(
|
||||
COURSE_PUBLISH_MAX_UPLOAD_BYTES_ENV,
|
||||
DEFAULT_COURSE_PUBLISH_MAX_UPLOAD_BYTES,
|
||||
);
|
||||
const declaredLength = Number(request.headers.get('content-length') ?? '');
|
||||
if (Number.isFinite(declaredLength) && declaredLength > maxUploadBytes) {
|
||||
throw new CoursePublishTransportError(
|
||||
'COURSE_UPLOAD_TOO_LARGE',
|
||||
`Course publish exceeds ${maxUploadBytes} bytes`,
|
||||
'request',
|
||||
413,
|
||||
);
|
||||
}
|
||||
if (!request.body) {
|
||||
throw new CoursePublishTransportError(
|
||||
'BODY_MISSING',
|
||||
'Course publish body is missing',
|
||||
'request',
|
||||
400,
|
||||
);
|
||||
}
|
||||
|
||||
const capped = capBodyStream(request.body, maxUploadBytes);
|
||||
let form: FormData;
|
||||
try {
|
||||
form = await new Response(capped.stream, {
|
||||
headers: { 'content-type': request.headers.get('content-type')! },
|
||||
}).formData();
|
||||
} catch (error) {
|
||||
if (capped.exceeded()) {
|
||||
throw new CoursePublishTransportError(
|
||||
'COURSE_UPLOAD_TOO_LARGE',
|
||||
`Course publish exceeds ${maxUploadBytes} bytes`,
|
||||
'request',
|
||||
413,
|
||||
);
|
||||
}
|
||||
throw new CoursePublishTransportError(
|
||||
'MULTIPART_INVALID',
|
||||
'Course publish multipart body could not be parsed',
|
||||
'request',
|
||||
400,
|
||||
undefined,
|
||||
error instanceof Error ? error.message : undefined,
|
||||
);
|
||||
}
|
||||
|
||||
const metadataField = form.get('metadata');
|
||||
if (typeof metadataField !== 'string') {
|
||||
throw new CoursePublishTransportError(
|
||||
'METADATA_MISSING',
|
||||
'Course publish metadata is missing',
|
||||
'request',
|
||||
400,
|
||||
);
|
||||
}
|
||||
if (new TextEncoder().encode(metadataField).byteLength > MAX_COURSE_PUBLISH_METADATA_BYTES) {
|
||||
throw new CoursePublishTransportError(
|
||||
'METADATA_TOO_LARGE',
|
||||
'Course publish metadata exceeds the size limit',
|
||||
'request',
|
||||
413,
|
||||
);
|
||||
}
|
||||
let metadataValue: unknown;
|
||||
try {
|
||||
metadataValue = JSON.parse(metadataField) as unknown;
|
||||
} catch {
|
||||
throw new CoursePublishTransportError(
|
||||
'INVALID_METADATA',
|
||||
'Course publish metadata is not valid JSON',
|
||||
'request',
|
||||
400,
|
||||
);
|
||||
}
|
||||
const metadata = parseRemoteCoursePublishMetadata(metadataValue);
|
||||
const perModuleLimit = positiveIntegerEnv(
|
||||
'COURSEWARE_MAX_UPLOAD_BYTES',
|
||||
DEFAULT_COURSEWARE_MAX_UPLOAD_BYTES,
|
||||
);
|
||||
const archives = metadata.modules.map((moduleRecord) => {
|
||||
const fieldName = `module-${moduleRecord.index}`;
|
||||
const fields = form.getAll(fieldName);
|
||||
if (fields.length !== 1 || !(fields[0] instanceof File)) {
|
||||
throw new CoursePublishTransportError(
|
||||
'MODULE_ARCHIVE_MISSING',
|
||||
`Module ${moduleRecord.index} requires exactly one ZIP`,
|
||||
'request',
|
||||
400,
|
||||
moduleRecord.index,
|
||||
);
|
||||
}
|
||||
const file = fields[0];
|
||||
if (file.size > perModuleLimit) {
|
||||
throw new CoursePublishTransportError(
|
||||
'MODULE_ARCHIVE_TOO_LARGE',
|
||||
`Module ${moduleRecord.index} ZIP exceeds ${perModuleLimit} bytes`,
|
||||
'request',
|
||||
413,
|
||||
moduleRecord.index,
|
||||
);
|
||||
}
|
||||
return { index: moduleRecord.index, file };
|
||||
});
|
||||
const loadedArchives = await Promise.all(
|
||||
archives.map(async ({ index, file }) => ({
|
||||
index,
|
||||
zipBytes: new Uint8Array(await file.arrayBuffer()),
|
||||
})),
|
||||
);
|
||||
const result = await commitRemoteCoursePublish({
|
||||
metadata,
|
||||
archives: loadedArchives,
|
||||
token,
|
||||
publicBaseUrl: canonicalPublicBaseUrl(request, deps),
|
||||
repos: routeRepos(deps),
|
||||
});
|
||||
const body: RemoteCoursePublishSuccessBody = {
|
||||
success: true,
|
||||
record: result.record,
|
||||
idempotent: result.idempotent,
|
||||
};
|
||||
return NextResponse.json(body, { status: result.idempotent ? 200 : 201 });
|
||||
} catch (error) {
|
||||
if (error instanceof CoursePublishTransportError) return errorResponse(error);
|
||||
if (error instanceof CoursePublishInProgressError) {
|
||||
return errorResponse(
|
||||
new CoursePublishTransportError('PUBLISH_IN_PROGRESS', error.message, 'request', 409),
|
||||
);
|
||||
}
|
||||
log.error('Transactional course publish failed:', error);
|
||||
return errorResponse(
|
||||
new CoursePublishTransportError(
|
||||
'INTERNAL_ERROR',
|
||||
'Transactional course publish failed',
|
||||
'commit',
|
||||
500,
|
||||
undefined,
|
||||
error instanceof Error ? error.message : undefined,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
import { handleCoursePublishRequest } from '@/lib/server/course-publish-route';
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
return handleCoursePublishRequest(request);
|
||||
|
||||
Reference in New Issue
Block a user