import type { CreateFlowUploadPurpose } from "./createFlowUploadPurpose"; import { CreateFlowUploadValidationError, messageKeyForCreateFlowUploadReason, validateCreateFlowUploadFile, type CreateFlowUploadValidationReason, } from "./createFlowUploadValidation"; export type UploadToServerResult = { url: string; id: string; mimeType: string; byteLength: number; }; const VALIDATION_REASONS = new Set([ "empty", "tooLarge", "svg", "invalidType", "undecodable", ]); function reasonFromUnknown(value: unknown): CreateFlowUploadValidationReason | null { return typeof value === "string" && VALIDATION_REASONS.has(value as CreateFlowUploadValidationReason) ? (value as CreateFlowUploadValidationReason) : null; } /** * Authenticated multipart upload to `POST /api/uploads`. * Caller must have a session cookie (same-origin fetch). */ export async function uploadCreateFlowFile( file: File, purpose: CreateFlowUploadPurpose, ): Promise { const validated = await validateCreateFlowUploadFile(file, purpose); if (validated.ok === false) { throw new CreateFlowUploadValidationError(validated.reason); } const formData = new FormData(); formData.append("purpose", purpose); formData.append("file", file); const res = await fetch("/api/uploads", { method: "POST", body: formData, credentials: "same-origin", }); let body: unknown; try { body = await res.json(); } catch { body = {}; } const errParts = (() => { if (body && typeof body === "object" && "error" in body) { const e = (body as { error?: { message?: string; code?: string }; details?: { reason?: unknown }; }).error; const details = (body as { details?: { reason?: unknown } }).details; const reason = reasonFromUnknown(details?.reason); if (!e) { return { message: null as string | null, code: null as string | null, reason, }; } return { message: typeof e.message === "string" ? e.message : null, code: typeof e.code === "string" ? e.code : null, reason, }; } return { message: null, code: null, reason: null }; })(); if (!res.ok) { const fallback = res.status === 413 ? "FILE_TOO_LARGE" : res.status === 401 ? "UNAUTHORIZED" : "UPLOAD_FAILED"; const code = errParts.code ?? errParts.message ?? fallback; throw new UploadToServerError(res.status, code, errParts.reason); } const data = body as { url?: string; id?: string; mimeType?: string; byteLength?: number; }; if ( typeof data.url !== "string" || typeof data.id !== "string" || typeof data.mimeType !== "string" || typeof data.byteLength !== "number" ) { throw new UploadToServerError(res.status, "INVALID_RESPONSE"); } return { url: data.url, id: data.id, mimeType: data.mimeType, byteLength: data.byteLength, }; } export class UploadToServerError extends Error { readonly status: number; readonly code: string; readonly reason: CreateFlowUploadValidationReason | null; constructor( status: number, code: string, reason: CreateFlowUploadValidationReason | null = null, ) { super(code); this.name = "UploadToServerError"; this.status = status; this.code = code; this.reason = reason; } } export function createFlowUploadFailureMessageKey(err: unknown): string { if (err instanceof CreateFlowUploadValidationError) { return messageKeyForCreateFlowUploadReason(err.reason); } if (err instanceof UploadToServerError) { if (err.reason) return messageKeyForCreateFlowUploadReason(err.reason); if (err.status === 413) return "errors.tooLarge"; if (err.status === 401) return "errors.unauthorized"; if (err.code === "server_misconfigured") { return "errors.misconfigured"; } } return "errors.generic"; } export { CreateFlowUploadValidationError };