From f0f7c82a09f0068a9c0a0e4c9c38ac356d60bad2 Mon Sep 17 00:00:00 2001 From: DEls Date: Wed, 19 Aug 2026 19:47:24 +0200 Subject: [PATCH] feat(api): rewrite upload route to write data_import_batches POST /api/purchase-orders/upload no longer forwards the file to n8n synchronously. It now validates size/MIME, writes a data_import_batches row (batch_type='purchase_orders', status='pending', attempts=0) with the uploaded file attached, and returns 202 { batch_id, status: "queued", filename } immediately. n8n's 5-minute cron claims pending rows and calls back to /api/purchase-orders/process-batch; the browser polls /api/data-import-batches/[id]/status. Adds route tests covering validation guards, the queued 202 contract, the batch-create failure path, and that no synchronous fetch to n8n happens. Co-Authored-By: Claude --- .../api/purchase-orders/upload/route.test.ts | 140 ++++++++++++++++++ src/app/api/purchase-orders/upload/route.ts | 82 ++++------ 2 files changed, 170 insertions(+), 52 deletions(-) create mode 100644 src/app/api/purchase-orders/upload/route.test.ts diff --git a/src/app/api/purchase-orders/upload/route.test.ts b/src/app/api/purchase-orders/upload/route.test.ts new file mode 100644 index 0000000..b398b80 --- /dev/null +++ b/src/app/api/purchase-orders/upload/route.test.ts @@ -0,0 +1,140 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { MAX_FILE_BYTES } from "@/lib/purchase-order-schema"; + +// Mock pbAdmin so the route doesn't hit a real PocketBase instance. +const createMock = vi.fn(); + +vi.mock("@/lib/pb", () => ({ + pbAdmin: vi.fn(async () => ({ + collection: (name: string) => { + if (name !== "data_import_batches") { + throw new Error(`unexpected collection in test: ${name}`); + } + return { create: createMock }; + }, + })), +})); + +import { POST } from "./route"; + +function makeRequest(form: FormData): Request { + return new Request("http://x/api/purchase-orders/upload", { + method: "POST", + body: form, + }); +} + +function pdf(bytes = 128, type = "application/pdf"): File { + return new File([new Uint8Array(bytes)], "po-1234.pdf", { type }); +} + +describe("POST /api/purchase-orders/upload", () => { + beforeEach(() => { + createMock.mockReset(); + }); + + it("returns 400 when the body is not multipart form data", async () => { + const res = await POST( + new Request("http://x/api/purchase-orders/upload", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ nope: true }), + }), + ); + expect(res.status).toBe(400); + expect(await res.json()).toEqual({ error: "invalid_form_data" }); + expect(createMock).not.toHaveBeenCalled(); + }); + + it("returns 400 when no file field is present", async () => { + const res = await POST(makeRequest(new FormData())); + expect(res.status).toBe(400); + expect(await res.json()).toEqual({ error: "missing_file" }); + expect(createMock).not.toHaveBeenCalled(); + }); + + it("returns 400 for an empty file", async () => { + const form = new FormData(); + form.append("file", pdf(0)); + const res = await POST(makeRequest(form)); + expect(res.status).toBe(400); + expect(await res.json()).toEqual({ error: "empty_file" }); + }); + + it("returns 400 when the file exceeds the size cap", async () => { + const file = pdf(); + // Avoid allocating 20 MB just to trip the guard. The faked `size` only + // survives if the form is handed to the route directly — a real Request + // re-parses the multipart body and rebuilds the File. + Object.defineProperty(file, "size", { value: MAX_FILE_BYTES + 1 }); + const form = new FormData(); + form.append("file", file); + const res = await POST({ formData: async () => form } as Request); + expect(res.status).toBe(400); + const body = await res.json(); + expect(body.error).toBe("file_too_large"); + expect(body.max_bytes).toBe(MAX_FILE_BYTES); + }); + + it("returns 400 for an unsupported MIME type", async () => { + const form = new FormData(); + form.append("file", new File([new Uint8Array(16)], "po.docx", { + type: "application/vnd.openxmlformats-officedocument.wordprocessingml.document", + })); + const res = await POST(makeRequest(form)); + expect(res.status).toBe(400); + const body = await res.json(); + expect(body.error).toBe("unsupported_type"); + expect(body.got).toContain("wordprocessingml"); + }); + + it("queues a pending batch and returns 202 with the batch id", async () => { + createMock.mockResolvedValueOnce({ id: "abcdefghijklmno" }); + const form = new FormData(); + form.append("file", pdf()); + + const res = await POST(makeRequest(form)); + + expect(res.status).toBe(202); + expect(await res.json()).toEqual({ + batch_id: "abcdefghijklmno", + status: "queued", + filename: "po-1234.pdf", + }); + + expect(createMock).toHaveBeenCalledTimes(1); + const payload = createMock.mock.calls[0][0]; + expect(payload.batch_type).toBe("purchase_orders"); + expect(payload.status).toBe("pending"); + expect(payload.attempts).toBe(0); + expect(payload.content_type).toBe("application/pdf"); + expect(payload.original_file).toBeInstanceOf(File); + expect(payload.uploaded_by).toBe("web-upload"); + expect(typeof payload.date_uploaded).toBe("string"); + }); + + it("does not call n8n synchronously", async () => { + const fetchSpy = vi.spyOn(globalThis, "fetch"); + createMock.mockResolvedValueOnce({ id: "abcdefghijklmno" }); + const form = new FormData(); + form.append("file", pdf()); + + await POST(makeRequest(form)); + + expect(fetchSpy).not.toHaveBeenCalled(); + fetchSpy.mockRestore(); + }); + + it("returns 500 when the batch row cannot be created", async () => { + createMock.mockRejectedValueOnce(new Error("pb down")); + const errSpy = vi.spyOn(console, "error").mockImplementation(() => {}); + const form = new FormData(); + form.append("file", pdf()); + + const res = await POST(makeRequest(form)); + + expect(res.status).toBe(500); + expect(await res.json()).toEqual({ error: "batch_create_failed" }); + errSpy.mockRestore(); + }); +}); diff --git a/src/app/api/purchase-orders/upload/route.ts b/src/app/api/purchase-orders/upload/route.ts index 24bc2f6..e9b920e 100644 --- a/src/app/api/purchase-orders/upload/route.ts +++ b/src/app/api/purchase-orders/upload/route.ts @@ -1,9 +1,23 @@ import { NextResponse } from "next/server"; +import { pbAdmin } from "@/lib/pb"; import { ALLOWED_MIME, MAX_FILE_BYTES } from "@/lib/purchase-order-schema"; export const dynamic = "force-dynamic"; export const runtime = "nodejs"; +/** + * POST /api/purchase-orders/upload + * + * Accepts multipart/form-data with a single `file` field (PDF or image). + * + * Writes the file to data_import_batches with status='pending', + * batch_type='purchase_orders', and returns the batch_id. n8n's + * 5-minute cron will pick it up, download the file from PB, extract + * PO data via AI Agent, and callback to /api/purchase-orders/process-batch. + * + * The browser polls /api/data-import-batches/[id]/status to render + * the import state to the user. + */ export async function POST(request: Request) { const start = Date.now(); @@ -34,69 +48,33 @@ export async function POST(request: Request) { ); } - const n8nUrl = process.env.N8N_PO_INGEST_URL; - if (!n8nUrl) { - console.error("[po-upload] N8N_PO_INGEST_URL not configured"); - return NextResponse.json({ error: "n8n_unconfigured" }, { status: 500 }); - } - - // Forward to n8n as multipart - const n8nForm = new FormData(); - n8nForm.append("file", file, file.name); - n8nForm.append("filename", file.name); - n8nForm.append("contentType", file.type); - n8nForm.append("size", String(file.size)); - - let n8nRes: Response; - const controller = new AbortController(); - const timeoutId = setTimeout(() => controller.abort(), 10_000); + const pb = await pbAdmin(); + let batch: { id: string }; try { - n8nRes = await fetch(n8nUrl, { - method: "POST", - body: n8nForm, - signal: controller.signal, + batch = await pb.collection("data_import_batches").create({ + batch_type: "purchase_orders", + content_type: file.type, + original_file: file, + date_uploaded: new Date().toISOString(), + uploaded_by: "web-upload", + status: "pending", + attempts: 0, }); } catch (err) { - clearTimeout(timeoutId); - const isAbort = err instanceof Error && err.name === "AbortError"; - console.error("[po-upload] n8n unreachable", { - filename: file.name, - timed_out: isAbort, - err: String(err), - }); - return NextResponse.json( - { error: isAbort ? "n8n_timeout" : "n8n_unreachable" }, - { status: isAbort ? 504 : 502 }, - ); - } - clearTimeout(timeoutId); - - if (!n8nRes.ok) { - console.error("[po-upload] n8n rejected", { - filename: file.name, - n8n_status: n8nRes.status, - statusText: n8nRes.statusText, - }); - return NextResponse.json( - { - error: "n8n_rejected", - n8n_status: n8nRes.status, - statusText: n8nRes.statusText, - }, - { status: 502 }, - ); + console.error("[po-upload] batch_create failed", { err: String(err) }); + return NextResponse.json({ error: "batch_create_failed" }, { status: 500 }); } - console.log("[po-upload] forwarded", { + console.log("[po-upload] queued", { + batch_id: batch.id, filename: file.name, size: file.size, contentType: file.type, - n8n_status: n8nRes.status, duration_ms: Date.now() - start, }); return NextResponse.json( - { status: "processing", filename: file.name }, + { batch_id: batch.id, status: "queued", filename: file.name }, { status: 202 }, ); -} \ No newline at end of file +}