Broadens the feature-flagged attachment affordance (same flag,
NEXT_PUBLIC_CLOSED_PO_ATTACHMENTS_ENABLED) from CLOSED-only to **any PO state
except REJECTED / CANCELLED**, for the same roles: the PO's own submitter plus
Accounts / Manager / SuperUser.
- lib/permissions.ts: canAddClosedPoAttachment → canAddPoAttachment(role,
status, { isSubmitter }); allows the submitter + ACCOUNTS/MANAGER/SUPERUSER
in any non-voided state. REJECTED/CANCELLED are always refused.
- uploadPoDocuments: voided POs are refused regardless of the flag; with the
flag on, uploads are restricted to those roles in any live state (the normal
create/receipt actors qualify, so those flows keep working); with the flag
off, the legacy behaviour stands (closed POs immutable).
- po-detail.tsx: the Attachments card now shows the uploader for any non-voided
state when permitted (not just CLOSED).
- Renamed ClosedPoAttachmentUploader → PoAttachmentUploader and the test file
to po-attachment-permissions.test.ts (flag-on matrix now covers live states +
rejected/cancelled refusal). Docs updated (feature-flags, .env.example,
CLAUDE.md).
Full unit + integration suites green; tsc clean.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
86 lines
3.5 KiB
TypeScript
86 lines
3.5 KiB
TypeScript
"use server";
|
|
|
|
import { auth } from "@/auth";
|
|
import { db } from "@/lib/db";
|
|
import { buildStorageKey, uploadBuffer } from "@/lib/storage";
|
|
import { canAddPoAttachment } from "@/lib/permissions";
|
|
import { CLOSED_PO_ATTACHMENTS_ENABLED } from "@/lib/feature-flags";
|
|
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 voided PO never accepts attachments, regardless of the flag.
|
|
if (po.status === "REJECTED" || po.status === "CANCELLED") {
|
|
return { error: "Attachments can't be added to a rejected or cancelled purchase order." };
|
|
}
|
|
|
|
if (CLOSED_PO_ATTACHMENTS_ENABLED) {
|
|
// Feature on: only the PO's submitter + Accounts / Manager / SuperUser may
|
|
// attach, in any non-voided state. The normal create / receipt flows are run
|
|
// by exactly those actors, so they keep working.
|
|
const allowed = canAddPoAttachment(session.user.role, po.status, {
|
|
isSubmitter: po.submitterId === session.user.id,
|
|
});
|
|
if (!allowed) {
|
|
return { error: "Adding attachments to this purchase order isn't allowed." };
|
|
}
|
|
} else if (po.status === "CLOSED") {
|
|
// Feature off: a closed PO stays immutable (legacy behaviour). The create /
|
|
// receipt flows upload while the PO is pre-CLOSED, so they're unaffected.
|
|
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;
|
|
}
|