diff --git a/app/api/invoices/intake/[id]/document/route.ts b/app/api/invoices/intake/[id]/document/route.ts new file mode 100644 index 0000000..82d9a95 --- /dev/null +++ b/app/api/invoices/intake/[id]/document/route.ts @@ -0,0 +1,62 @@ +import { serverAuth } from "@/lib/pb"; + +export const dynamic = "force-dynamic"; + +// GET /api/invoices/intake/:id/document +// Streams the PDF a FAILED intake kept. +// +// A problem invoice is exactly the one you most need to look at — it was refused +// because something about it did not reconcile — and before this route its PDF was +// stored on `invoice_intake.source_pdf` with no way to open it. /api/invoices/:id/ +// document only serves the `invoices` collection, which a failed import never reaches, +// so the inbox could report a problem and still not show the document behind it. +// +// Same reasoning as that route: do not link at PocketBase directly. The file is +// protected, so a bare `/api/files/` 404s in the browser. Fetch it +// server-side with the admin credentials and proxy. + +export async function GET( + _req: Request, + { params }: { params: Promise<{ id: string }> }, +) { + const { id } = await params; + + try { + const pb = await serverAuth(); + const intake = await pb.collection("invoice_intake").getOne(id); + + const token = intake.source_pdf; + if (!token) { + return Response.json({ ok: false, error: "No document attached" }, { status: 404 }); + } + + // PocketBase resolves protected files by the full collectionId/recordId/filename + // path, so let the SDK build the URL from THIS record rather than hand-writing one. + const upstream = pb.files.getURL(intake, token); + + const res = await fetch(upstream, { + headers: pb.authStore.token ? { Authorization: pb.authStore.token } : undefined, + }); + if (!res.ok) { + return Response.json( + { ok: false, error: "Document could not be read from storage" }, + { status: 502 }, + ); + } + + const name = String(intake.file_name || "invoice.pdf").replace(/["\\]/g, "_"); + return new Response(res.body, { + status: 200, + headers: { + "Content-Type": res.headers.get("Content-Type") ?? "application/pdf", + "Content-Disposition": `inline; filename="${name}"`, + "Cache-Control": "private, max-age=0, must-revalidate", + }, + }); + } catch (e: any) { + return Response.json( + { ok: false, error: e?.message || "Could not load document" }, + { status: 500 }, + ); + } +} \ No newline at end of file diff --git a/app/api/invoices/intake/route.ts b/app/api/invoices/intake/route.ts index b7a0a3d..adcd068 100644 --- a/app/api/invoices/intake/route.ts +++ b/app/api/invoices/intake/route.ts @@ -1,7 +1,7 @@ import { revalidatePath } from "next/cache"; import { serverAuth } from "@/lib/pb"; import { parseInvoicePdf, ParseError, registerCodeFrom } from "@/lib/invoice-parser"; -import { syncSitesFromInvoices, findSiteContainer } from "@/lib/sites"; +import { syncSitesFromInvoices, findSiteContainer, resolveSiteName } from "@/lib/sites"; export const dynamic = "force-dynamic"; @@ -124,6 +124,15 @@ export async function POST(req: Request) { ); } + // The supplier abbreviates the site on the invoice ("…Sep 2026 Cosmo" for the site the + // register calls "Cosmo City"). Resolve it BEFORE writing, because sites are derived + // from the invoice wording — saving the abbreviation would create a second "Cosmo" and + // split this customer across two sites on /sites. + parsed.site = resolveSiteName( + parsed.site, + [...new Set(allInvoices.map((i: any) => i.site).filter((s: unknown): s is string => typeof s === "string" && !!s.trim()))], + ); + const periodEnd = new Date(parsed.period_to); const bookedMonth = periodEnd.getUTCMonth() + 1; const bookedYear = periodEnd.getUTCFullYear(); diff --git a/components/InvoiceIntake.tsx b/components/InvoiceIntake.tsx index 657a776..57c8b64 100644 --- a/components/InvoiceIntake.tsx +++ b/components/InvoiceIntake.tsx @@ -402,6 +402,16 @@ export function ProblemInbox() {

+ + + PDF +