"use server"; import { auth } from "@/auth"; import { db } from "@/lib/db"; import { buildStorageKey, uploadBuffer } from "@/lib/storage"; import { canAddClosedPoAttachment } from "@/lib/permissions"; import { revalidatePath } from "next/cache"; // Matches the FileUploader hint ("up to 10 MB each") and // next.config.ts → experimental.serverActions.bodySizeLimit. const MAX_BYTES = 10 * 1024 * 1024; /** * Persist PO attachments **server-side**: write each file to storage with * `uploadBuffer` and create its `PODocument` row in the same step. * * Replaces the earlier browser-presigned-PUT flow (`POST /api/files/sign` → the * browser `PUT`s the file straight to R2 → `linkDocument` creates the row). That * direct browser→R2 `PUT` only works if the R2 bucket carries a CORS policy * allowing `PUT` from the portal's origin. In production that policy was missing, * so the browser silently blocked the upload, `linkDocument` was never reached, * and **no `PODocument` row was created** — the "documents uploaded but not * visible anywhere" report (0 PODocument rows in prod/staging). * * Uploading through the server — the same pattern the crewing module already uses * for CVs / crew documents (`uploadBuffer`) — removes the CORS dependency and * makes the store-and-link atomic, so a created row always has its file. * * Returns `{ error }` on the first failure, or `null` on success (the contract * the PO and receipt forms already expect). */ export async function uploadPoDocuments( poId: string, files: File[], type: "po-document" | "receipt" = "po-document" ): Promise<{ error: string } | null> { const session = await auth(); if (!session?.user) return { error: "Unauthorized" }; const po = await db.purchaseOrder.findUnique({ where: { id: poId }, select: { id: true, status: true, submitterId: true }, }); if (!po) return { error: "PO not found" }; // A CLOSED PO is otherwise immutable; attaching to one is only allowed via the // feature-flagged remediation path (canAddClosedPoAttachment). The normal create // and receipt flows upload while the PO is pre-CLOSED, so they're unaffected. if (po.status === "CLOSED") { const allowed = canAddClosedPoAttachment(session.user.role, { isSubmitter: po.submitterId === session.user.id, }); if (!allowed) { return { error: "Adding attachments to a closed purchase order isn't allowed." }; } } for (const file of files) { if (!(file instanceof File) || file.size === 0) continue; if (file.size > MAX_BYTES) { return { error: `${file.name} is larger than the 10 MB limit.` }; } const key = buildStorageKey(type, poId, file.name); const mimeType = file.type || "application/octet-stream"; const buffer = Buffer.from(await file.arrayBuffer()); await uploadBuffer(key, buffer, mimeType); await db.pODocument.create({ data: { poId, storageKey: key, fileName: file.name, fileSize: file.size, mimeType }, }); } revalidatePath(`/po/${poId}`); return null; }