From b362d92256a948535bc1ffcf906b3461d7e5790e Mon Sep 17 00:00:00 2001 From: SJCoetz Date: Thu, 9 Jul 2026 23:15:08 +0200 Subject: [PATCH] feat(po-attachments): add upload route for PO additional attachments --- .../[id]/attachments/upload/route.ts | 111 ++++++++++++++++++ 1 file changed, 111 insertions(+) create mode 100644 src/app/api/v1/purchase-orders/[id]/attachments/upload/route.ts diff --git a/src/app/api/v1/purchase-orders/[id]/attachments/upload/route.ts b/src/app/api/v1/purchase-orders/[id]/attachments/upload/route.ts new file mode 100644 index 0000000..02e1e75 --- /dev/null +++ b/src/app/api/v1/purchase-orders/[id]/attachments/upload/route.ts @@ -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" | } + * + * 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 }, + ); + } +}