104 lines
2.9 KiB
TypeScript
104 lines
2.9 KiB
TypeScript
// Client-side publish upload — the ops workbench → server publish route.
|
|
//
|
|
// Pure fetch wrapper so the wire contract (multipart fields, optional bearer token,
|
|
// response shape) is testable without a browser. The server route
|
|
// (`app/api/coursewares/route.ts`) validates the bundle and rejects
|
|
// tampered/incomplete uploads with a 4xx and an error body.
|
|
|
|
export interface PublishedRecord {
|
|
coursewareId: string;
|
|
version: number;
|
|
title: string;
|
|
language?: string;
|
|
status: string;
|
|
publishedAt: string;
|
|
contentHash: string;
|
|
sceneCount: number;
|
|
quizSceneCount: number;
|
|
bundleUrl: string;
|
|
}
|
|
|
|
export interface UploadCoursewareResult {
|
|
ok: boolean;
|
|
record?: PublishedRecord;
|
|
/** Error code from the server body, e.g. INVALID_REQUEST. */
|
|
errorCode?: string;
|
|
error?: string;
|
|
details?: string;
|
|
/** HTTP status for diagnostics (0 = network failure). */
|
|
status: number;
|
|
}
|
|
|
|
export interface UploadCoursewareOptions {
|
|
/** The frozen bundle ZIP produced by `packageCourseware`. */
|
|
zip: Blob;
|
|
coursewareId: string;
|
|
/** Used for ops→server publishing. Same-origin ops sessions omit it. */
|
|
token?: string;
|
|
/**
|
|
* Explicit immutable version. It must already match the ZIP's internal
|
|
* `bundle.json`; omit it for browser-built bundles so the server can allocate
|
|
* and stamp the next monotonic version under its publish lock.
|
|
*/
|
|
version?: number;
|
|
status?: 'published' | 'unpublished';
|
|
/** Defaults to the same-origin publish route. */
|
|
endpoint?: string;
|
|
fetchImpl?: typeof fetch;
|
|
}
|
|
|
|
export async function uploadCoursewareZip(
|
|
options: UploadCoursewareOptions,
|
|
): Promise<UploadCoursewareResult> {
|
|
const {
|
|
zip,
|
|
coursewareId,
|
|
token,
|
|
version,
|
|
status = 'published',
|
|
endpoint = '/api/coursewares',
|
|
fetchImpl = fetch,
|
|
} = options;
|
|
|
|
const form = new FormData();
|
|
form.append('zip', zip, 'bundle.zip');
|
|
form.append('coursewareId', coursewareId);
|
|
if (version !== undefined) form.append('version', String(version));
|
|
if (status !== 'published') form.append('status', status);
|
|
|
|
let response: Response;
|
|
try {
|
|
response = await fetchImpl(endpoint, {
|
|
method: 'POST',
|
|
...(token ? { headers: { authorization: `Bearer ${token}` } } : {}),
|
|
body: form,
|
|
});
|
|
} catch {
|
|
return { ok: false, status: 0, error: 'Network error during publish upload' };
|
|
}
|
|
|
|
let body: {
|
|
success?: boolean;
|
|
record?: PublishedRecord;
|
|
errorCode?: string;
|
|
error?: string;
|
|
details?: string;
|
|
} = {};
|
|
try {
|
|
body = (await response.json()) as typeof body;
|
|
} catch {
|
|
// Non-JSON failure (proxy error page, etc.)
|
|
}
|
|
|
|
if (!response.ok || !body.success) {
|
|
return {
|
|
ok: false,
|
|
status: response.status,
|
|
errorCode: body.errorCode,
|
|
error: body.error ?? `Publish failed (HTTP ${response.status})`,
|
|
...(body.details ? { details: body.details } : {}),
|
|
};
|
|
}
|
|
return { ok: true, status: response.status, record: body.record };
|
|
}
|