feat(po-attachments): add upload route for PO additional attachments

This commit is contained in:
SJCoetz 2026-07-09 23:15:08 +02:00
parent a16b8fcdcb
commit b362d92256

View file

@ -0,0 +1,111 @@
import { NextRequest, NextResponse } from "next/server";
import { getAdminPocketBase } from "@/lib/server/pocketbase-admin";
/**
* POST /api/v1/purchase-orders/[id]/attachments/upload
*
* Upload an additional file (supplier quote, drawing, NDA, etc.) to
* a PO. Sits alongside the versioned PDF in `documents`. Goes
* through admin PB so the operator doesn't need a PocketBase account
* with the createRule cleared.
*
* Body: multipart/form-data with a single `file` field.
*
* Response:
* 201 — { ok: true, attachments: [token, ...] }
* 400 — { error: "No file" }
* 413 — { error: "File too large (max 25 MB)" }
* 500 — { error: "PB admin unavailable" | <message> }
*
* Cap: 25 MB per file to match the rest of the app. n8n's outbound
* SMTP layer has its own limits; staying under 25 MB is safe.
*/
export const runtime = "nodejs";
export const dynamic = "force-dynamic";
const MAX_BYTES = 25 * 1024 * 1024;
export async function POST(
req: NextRequest,
{ params }: { params: Promise<{ id: string }> },
) {
const { id: poId } = await params;
let formData: FormData;
try {
formData = await req.formData();
} catch (err) {
return NextResponse.json(
{ error: err instanceof Error ? err.message : "Invalid form data" },
{ status: 400 },
);
}
const file = formData.get("file");
if (!file || typeof file === "string" || typeof (file as File).name !== "string" || typeof (file as File).size !== "number") {
return NextResponse.json(
{ error: "No file in request (expected field 'file')" },
{ status: 400 },
);
}
if (file.size > MAX_BYTES) {
return NextResponse.json(
{ error: `File too large (max ${MAX_BYTES / 1024 / 1024} MB)` },
{ status: 413 },
);
}
let adminPb;
try {
adminPb = await getAdminPocketBase();
} catch {
return NextResponse.json(
{ error: "PB admin unavailable" },
{ status: 500 },
);
}
try {
// Read the current attachments array, append the new file's
// token, write back. PB's FormData update replaces the field,
// so we need the read-modify-write — there is no "append" op
// on a multi-file field via the SDK.
const po = await adminPb
.collection("purchase_orders")
.getOne<{ additional_attachments?: string[] | string }>(poId, {
fields: "id,additional_attachments",
});
const currentRaw = po?.additional_attachments ?? [];
const current = Array.isArray(currentRaw) ? currentRaw : currentRaw ? [currentRaw] : [];
const updateFd = new FormData();
updateFd.append("file", file);
await adminPb.collection("purchase_orders").update(poId, updateFd);
// Re-read to get the actual token PB stored (FormData only
// carries the operator's filename; PB slug-normalises on disk).
const after = await adminPb
.collection("purchase_orders")
.getOne<{ additional_attachments?: string[] | string }>(poId, {
fields: "additional_attachments",
});
const afterRaw = after?.additional_attachments ?? [];
const afterArr = Array.isArray(afterRaw) ? afterRaw : afterRaw ? [afterRaw] : [];
return NextResponse.json(
{
ok: true,
attachments: afterArr,
added: afterArr.filter((t) => !current.includes(t)),
},
{ status: 201 },
);
} catch (err) {
return NextResponse.json(
{ error: err instanceof Error ? err.message : "Upload failed" },
{ status: 500 },
);
}
}