From f88f451d6e94b2ef98b857e30d165bfe57485d92 Mon Sep 17 00:00:00 2001 From: SJCoetz Date: Sun, 9 Aug 2026 20:23:26 +0200 Subject: [PATCH] fix(invoicing): print bundle fails on production due to self-fetch MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The /api/invoicing/print-bundle route was doing a self-fetch through its own public URL to download the invoice PDF it needed to bundle: fetch(`${origin}/api/v1/files/order_items/${id}/${token}`) That works on local because Node inside the container can hit localhost:3000 as a loopback. On production (Coolify / Docker) the container's fetch to its own public hostname fails — DNS resolves to an external IP that doesn't route back, the outbound TCP is blocked, or the TLS handshake fails. Node's fetch throws 'fetch failed' at that point, which bubbles up to the browser as a 500 toast. Fix: fetch the invoice PDF directly from PocketBase with admin auth, the same pattern /api/v1/files/... uses internally and that delivery-note-pdf already uses. Drop the now-unused 'origin' parameter from handlePrintBundle and the GET/POST wrappers. Tests updated to mock pb.files.getURL + pb.authStore.token and assert the new PB file URL + Authorization header. All 65 tests across the print-bundle, picking-slip, view-model, and invoicing page suites pass. The 2 pre-existing pocketbase-admin.test failures are unrelated (reproducible on main without this change). --- .../api/invoicing/print-bundle/route.test.ts | 36 ++++++++++++++++--- src/app/api/invoicing/print-bundle/route.ts | 25 ++++++++++--- 2 files changed, 52 insertions(+), 9 deletions(-) diff --git a/src/app/api/invoicing/print-bundle/route.test.ts b/src/app/api/invoicing/print-bundle/route.test.ts index b8c9959..afa0dd7 100644 --- a/src/app/api/invoicing/print-bundle/route.test.ts +++ b/src/app/api/invoicing/print-bundle/route.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it, beforeEach, vi } from "vitest"; -const { fetchMock, getFullList, buildPrintBundle, pdfMock, pickingSlipPdfMock } = vi.hoisted(() => { +const { fetchMock, getFullList, buildPrintBundle, pdfMock, pickingSlipPdfMock, filesGetUrl, authStore } = vi.hoisted(() => { const fetchMock = vi.fn(); const getFullList = vi.fn(); const buildPrintBundle = vi.fn(); @@ -8,7 +8,24 @@ const { fetchMock, getFullList, buildPrintBundle, pdfMock, pickingSlipPdfMock } toString: vi.fn().mockResolvedValue("SLIP_PDF"), })); const pickingSlipPdfMock = vi.fn((_props: unknown) => "PICKING_SLIP_DOC"); - return { fetchMock, getFullList, buildPrintBundle, pdfMock, pickingSlipPdfMock }; + // Mirror the PB SDK's files.getURL: the caller passes + // { collectionId, id } and a filename, the SDK returns + // `${base}/api/files/${collectionId}/${recordId}/${filename}`. + // We control the URL via the mock so the test is environment-independent. + const filesGetUrl = vi.fn( + (record: { collectionId: string; id: string }, filename: string) => + `http://127.0.0.1:8090/api/files/${record.collectionId}/${record.id}/${filename}`, + ); + const authStore = { token: "TEST_ADMIN_TOKEN" }; + return { + fetchMock, + getFullList, + buildPrintBundle, + pdfMock, + pickingSlipPdfMock, + filesGetUrl, + authStore, + }; }); vi.stubGlobal("fetch", fetchMock); @@ -16,6 +33,8 @@ vi.stubGlobal("fetch", fetchMock); vi.mock("@/lib/server/pocketbase-admin", () => ({ getAdminPocketBase: vi.fn().mockResolvedValue({ collection: () => ({ getFullList }), + files: { getURL: filesGetUrl }, + authStore, }), })); @@ -55,6 +74,7 @@ describe("GET /api/invoicing/print-bundle", () => { buildPrintBundle.mockReset(); pdfMock.mockClear(); pickingSlipPdfMock.mockReset(); + filesGetUrl.mockClear(); }); it("returns 400 when invoiceNumber is missing", async () => { @@ -148,7 +168,10 @@ describe("GET /api/invoicing/print-bundle", () => { ); expect(fetchMock).toHaveBeenCalledWith( - "https://test.local/api/v1/files/order_items/item-1/inv.pdf", + "http://127.0.0.1:8090/api/files/order_items/item-1/inv.pdf", + expect.objectContaining({ + headers: expect.objectContaining({ Authorization: "TEST_ADMIN_TOKEN" }), + }), ); expect(pickingSlipPdfMock).toHaveBeenCalledTimes(1); @@ -195,6 +218,11 @@ describe("GET /api/invoicing/print-bundle", () => { const res = await GET(makeGetRequest("INV-1") as any); expect(res.status).toBe(200); - expect(fetchMock).toHaveBeenCalledWith("https://test.local/api/v1/files/order_items/item-1/inv.pdf"); + expect(fetchMock).toHaveBeenCalledWith( + "http://127.0.0.1:8090/api/files/order_items/item-1/inv.pdf", + expect.objectContaining({ + headers: expect.objectContaining({ Authorization: "TEST_ADMIN_TOKEN" }), + }), + ); }); }); diff --git a/src/app/api/invoicing/print-bundle/route.ts b/src/app/api/invoicing/print-bundle/route.ts index a447ea3..4c4aadb 100644 --- a/src/app/api/invoicing/print-bundle/route.ts +++ b/src/app/api/invoicing/print-bundle/route.ts @@ -75,7 +75,7 @@ function asNumber(value: unknown): number { return typeof value === "number" && Number.isFinite(value) ? value : 0; } -async function handlePrintBundle(input: { invoiceNumber: string; origin: string }) { +async function handlePrintBundle(input: { invoiceNumber: string }) { const invoiceNumber = input.invoiceNumber.trim(); if (!invoiceNumber) { return NextResponse.json({ error: "Missing invoiceNumber" }, { status: 400 }); @@ -103,8 +103,23 @@ async function handlePrintBundle(input: { invoiceNumber: string; origin: string return NextResponse.json({ error: "Invoice PDF not available" }, { status: 409 }); } - const proxyUrl = `${input.origin}/api/v1/files/order_items/${sourceItem.id}/${encodeURIComponent(fileToken)}`; - const invoiceRes = await fetch(proxyUrl); + // Fetch the invoice PDF directly from PocketBase with admin auth. + // We deliberately do NOT self-fetch through `/api/v1/files/...` here: + // that approach breaks on production because the Next.js server + // can't reach its own public hostname from inside the container + // (DNS resolves to an external IP that doesn't route back, the + // outbound TCP is blocked, or the TLS handshake fails). The + // `/api/v1/files/...` proxy uses the same pattern internally + // (see src/app/api/v1/files/[collection]/[recordId]/[filename]/route.ts), + // so this is just inlining that one step to avoid the self-loop. + const adminToken = pb.authStore?.token ?? ""; + const fileUrl = pb.files.getURL( + { collectionId: sourceItem.collectionId ?? "order_items", id: sourceItem.id }, + fileToken, + ); + const invoiceRes = await fetch(fileUrl, { + headers: adminToken ? { Authorization: adminToken } : {}, + }); if (invoiceRes.status === 404) { return NextResponse.json({ error: "Invoice PDF not found" }, { status: 404 }); } @@ -163,7 +178,7 @@ async function handlePrintBundle(input: { invoiceNumber: string; origin: string export async function GET(request: NextRequest) { const url = new URL(request.url); const invoiceNumber = url.searchParams.get("invoiceNumber") ?? ""; - return handlePrintBundle({ invoiceNumber, origin: url.origin }); + return handlePrintBundle({ invoiceNumber }); } export async function POST(request: NextRequest) { @@ -176,5 +191,5 @@ export async function POST(request: NextRequest) { const invoiceNumber = body && typeof body === "object" ? String((body as { invoiceNumber?: unknown }).invoiceNumber ?? "") : ""; - return handlePrintBundle({ invoiceNumber, origin: new URL(request.url).origin }); + return handlePrintBundle({ invoiceNumber }); }