pelagia-portal/App/app/actions/upload-po-documents.ts
Hardik 158b446117
All checks were successful
PR checks / checks (pull_request) Successful in 51s
PR checks / integration (pull_request) Successful in 31s
feat(po): feature-flagged attachments on closed POs (bug remediation)
Adds NEXT_PUBLIC_CLOSED_PO_ATTACHMENTS_ENABLED. When on, a CLOSED PO's own
submitter -- plus Accounts / Manager / SuperUser -- can attach documents to
it, so POs whose uploads were lost to the document-upload bug can be fixed
without reopening them. Off by default, so production stays unchanged until
enabled.

- lib/permissions.ts: canAddClosedPoAttachment(role, { isSubmitter }) gated
  by the flag; allowed roles are ACCOUNTS/MANAGER/SUPERUSER (plus the PO's
  own submitter regardless of role).
- uploadPoDocuments: a CLOSED PO is otherwise immutable, so it now enforces
  the permission server-side; the normal create/receipt flows upload while
  the PO is pre-CLOSED and are unaffected.
- po-detail.tsx: when allowed, the Attachments card renders an uploader
  (ClosedPoAttachmentUploader) and shows even when the PO has no docs yet.
- Enabled on staging (staging-up.sh) so the remediation can be exercised;
  documented in .env.example and CLAUDE.md.

Tests: closed-po-attachments.test.ts covers the flag-on role matrix (own
submitter / Accounts / Manager / SuperUser allowed; other submitter-role and
auditor refused; non-closed PO unaffected); po-document-upload.test.ts adds
the flag-off case (closed PO stays immutable). Full unit + integration suites
green; tsc clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-28 01:11:29 +05:30

76 lines
3 KiB
TypeScript

"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;
}