// 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 { 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 }; }