23 KiB
PO PDF: token-vs-filename split + GET through file proxy — Implementation Plan
For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (
- [ ]) syntax for tracking.
Goal: Fix Upstream PB file fetch failed: 404 / File not found on the PO PDF download path by storing the PB-side file token alongside the human-readable filename, and route the GET handler through the existing file proxy.
Architecture: Schema-agnostic JSON-field change inside documents_history (no PocketBase migration). POST captures the actual token from PB's upload response; GET resolves the token and forwards to /api/v1/files/purchase_orders/<poId>/<token>. One-shot backfill script for legacy rows.
Tech Stack: Next.js 16 route handlers, PocketBase admin SDK, TypeScript, Vitest.
Spec: docs/superpowers/specs/2026-07-09-po-pdf-token-vs-filename-design.md
File Structure
| File | Responsibility | Touched? |
|---|---|---|
src/lib/types.ts |
Add token to PurchaseOrderDocumentEntry |
Modify |
src/app/api/v1/purchase-orders/[id]/pdf/route.ts |
POST writes token from PB response; GET routes through proxy | Modify |
src/app/api/v1/purchase-orders/[id]/pdf/route.test.ts |
New route tests | Create |
scripts/migrations/2026-07-09-po-pdf-tokens.ts |
Backfill script | Create |
package.json |
Register migration npm script | Modify |
Task 1: Add token to PurchaseOrderDocumentEntry
Files:
-
Modify:
src/lib/types.ts -
Step 1: Find the type
Open src/lib/types.ts and locate PurchaseOrderDocumentEntry. Confirm
the field order (version, file, generatedAt, generatedBy,
reason, status).
- Step 2: Add the field
Insert token: string; immediately after file: string; so audit
fields and the lookup key sit next to each other:
interface PurchaseOrderDocumentEntry {
version: number;
file: string;
token: string;
generatedAt: string;
generatedBy: string;
reason: string;
status: "current" | "superseded";
}
- Step 3: Find every place that builds or reads an entry
Search the repo for documents_history, file: filename, and
PurchaseOrderDocumentEntry. Update any construction sites so they
provide token: "" initially — Task 2 will fill it in.
The construction sites you should expect:
src/app/api/v1/purchase-orders/[id]/pdf/route.ts(the POST handler)- Any TS test fixtures (e.g.
src/lib/pdf/render.test.ts)
Add token: "" to each new entry. Existing stored JSON in PB is
tolerated because token is optional at runtime; readers must
treat missing token as "needs migration" (Task 3 GET fallback
handles this).
- Step 4: Run typecheck / lint
npx tsc --noEmit 2>&1 | tail -n 30
npm run lint 2>&1 | tail -n 30
Expected: zero new errors. Existing test fixtures that built
PurchaseOrderDocumentEntry should pass once you add token: "".
- Step 5: Commit
git add src/lib/types.ts
git commit -m "feat(po-pdf): add token field to PurchaseOrderDocumentEntry"
Task 2: POST captures the real PB token; GET routes through proxy
Files:
-
Modify:
src/app/api/v1/purchase-orders/[id]/pdf/route.ts -
Step 1: Update the POST handler to capture the token
Replace the upload block (the current lines 224-232) so it captures
PB's response and writes token into the history entry:
const formData = new FormData();
formData.append(
"documents",
new Blob([new Uint8Array(pdfBuffer)], { type: "application/pdf" }),
filename
);
// PB returns the stored file tokens (slugged) in the response.
// We need the actual on-disk token for later fetches, not the
// human-readable `filename` we sent. Append to the existing array
// and pick the last element.
const updated = await pb.collection("purchase_orders").update<{
documents: string[] | string;
}>(poId, formData);
const docsRaw = updated?.documents ?? [];
const docs = Array.isArray(docsRaw) ? docsRaw : [docsRaw];
const newToken = docs[docs.length - 1] ?? "";
const newHistoryEntry: PurchaseOrderDocumentEntry = {
version: nextVersion,
file: filename,
token: newToken,
generatedAt: generatedAtIso,
generatedBy: body.generatedBy || "",
reason: (body.notes || "").slice(0, 500),
status: "current",
};
await pb.collection("purchase_orders").update(poId, {
documents_history: [...newHistoryBase, newHistoryEntry],
});
- Step 2: Replace the GET handler body
Replace the entire GET function body (lines 286-355) with one that
routes through the file proxy. Keep the same response semantics so
the existing version pills and download buttons keep working:
export async function GET(
req: NextRequest,
ctx: { params: Promise<{ id: string }> }
) {
const { id: poId } = await ctx.params;
const url = new URL(req.url);
const versionParam = url.searchParams.get("version");
const download = url.searchParams.get("download") === "1";
try {
const pb = await getAdminPocketBase();
const po = await pb.collection("purchase_orders").getOne<PurchaseOrder>(poId);
const history: PurchaseOrderDocumentEntry[] = po.documents_history ?? [];
if (history.length === 0) {
return NextResponse.json(
{ error: "No PDF documents have been generated for this PO yet." },
{ status: 404 }
);
}
// Pick the entry — by version if specified, else the latest
// "current" entry, else the last entry in the array.
let entry: PurchaseOrderDocumentEntry | undefined;
if (versionParam) {
const v = parseInt(versionParam, 10);
entry = history.find((h) => h.version === v);
} else {
entry =
history.find((h) => h.status === "current") ??
history[history.length - 1];
}
if (!entry) {
return NextResponse.json(
{ error: `No PDF for version ${versionParam ?? "latest"}` },
{ status: 404 }
);
}
// Resolve a token. Newer rows have `entry.token` (PB-stored slug).
// Legacy rows may have only `entry.file` (the human-readable name).
// For legacy rows, fall back to the live `documents` array — the
// POST handler always appends to both arrays in sync, so index
// alignment gives us the right token.
let token = entry.token;
if (!token) {
const docsRaw = po.documents ?? [];
const docs = Array.isArray(docsRaw) ? docsRaw : [docsRaw];
const idx = history.findIndex((h) => h.version === entry!.version);
token = docs[idx] || docs[0] || "";
}
if (!token) {
return NextResponse.json(
{
error: `PDF token missing for version ${entry.version} — please regenerate.`,
},
{ status: 404 }
);
}
// Hand off to the existing file proxy. The proxy already has
// `purchase_orders` whitelisted and streams the bytes back with
// admin auth.
const proxyUrl = `/api/v1/files/purchase_orders/${poId}/${encodeURIComponent(token)}`;
const proxyRes = await fetch(
new URL(proxyUrl, req.nextUrl.origin).toString(),
{
headers: { cookie: req.headers.get("cookie") ?? "" },
}
);
if (!proxyRes.ok) {
const body = await proxyRes.json().catch(() => ({}));
return NextResponse.json(
{ error: body?.error || `Proxy fetch failed: ${proxyRes.status}` },
{ status: proxyRes.status === 404 ? 404 : 502 }
);
}
const buf = Buffer.from(await proxyRes.arrayBuffer());
const safePo = String(po.po_number || poId).replace(/[^\w.-]/g, "_");
const filename = `PO-v${entry.version}-${safePo}.pdf`;
return new NextResponse(buf, {
headers: {
"Content-Type": "application/pdf",
"Content-Disposition": `${download ? "attachment" : "inline"}; filename="${filename}"`,
"Cache-Control": "private, no-store",
},
});
} catch (err) {
console.error("[po-pdf] GET failed:", err);
return NextResponse.json(
{ error: "PDF fetch failed", detail: err instanceof Error ? err.message : String(err) },
{ status: 500 }
);
}
}
- Step 3: Run targeted lint
npx eslint src/app/api/v1/purchase-orders/'[id]'/pdf/route.ts
Expected: exit 0. (If there are pre-existing errors on unrelated lines, note them but don't fix.)
- Step 4: Commit
git add src/app/api/v1/purchase-orders/'[id]'/pdf/route.ts
git commit -m "fix(po-pdf): capture PB token on upload and route GET through proxy"
Task 3: Failing route tests for the new behaviour
Files:
-
Create:
src/app/api/v1/purchase-orders/[id]/pdf/route.test.ts -
Step 1: Write the failing tests
The tests need the same admin-PB mock shape as the existing
/api/v1/orders/from-quote/route.test.ts — copy that file's mocks
and adapt the call signatures. Mock getAdminPocketBase() to return
a fake PB client with collection("purchase_orders").getOne and
update. Also stub fetch so we can assert the GET handler calls
the proxy URL.
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { NextRequest } from "next/server";
// ─── Mocks ─────────────────────────────────────────────────────────────
const getOneMock = vi.fn();
const updateMock = vi.fn();
const fetchMock = vi.fn();
vi.mock("@/lib/server/pocketbase-admin", () => ({
getAdminPocketBase: vi.fn().mockResolvedValue({
collection: () => ({
getOne: getOneMock,
update: updateMock,
}),
files: { getURL: vi.fn() },
authStore: { token: "test-token" },
}),
}));
// Letterhead + PDF render are heavy and orthogonal to this test — mock
// them so the route handler only does its own logic.
vi.mock("@/lib/letterhead", () => ({
fetchLetterhead: vi.fn().mockResolvedValue({ id: "cnl", name: "CNL" }),
loadLetterheadLogoDataUri: vi.fn().mockResolvedValue("data:image/svg+xml;base64,AAA"),
}));
vi.mock("@/lib/pdf/render", () => ({
renderPurchaseOrderPdf: vi.fn().mockResolvedValue(Buffer.from("PDF")),
}));
import { GET, POST } from "./route";
// ─── Helpers ──────────────────────────────────────────────────────────
function makeReq(url: string, init: { method?: string; body?: unknown } = {}) {
return new NextRequest(url, {
method: init.method ?? "GET",
headers: {
cookie: "test-cookie",
},
body: init.body !== undefined ? JSON.stringify(init.body) : undefined,
});
}
// ─── Tests ────────────────────────────────────────────────────────────
describe("/api/v1/purchase-orders/[id]/pdf", () => {
beforeEach(() => {
getOneMock.mockReset();
updateMock.mockReset();
fetchMock.mockReset();
vi.stubGlobal("fetch", fetchMock);
});
afterEach(() => {
vi.unstubAllGlobals();
});
describe("POST — token capture", () => {
it("stores the PB token, not the human-readable filename, in documents_history", async () => {
// First .update() call (form data) returns the new documents array.
// Second .update() call (history JSON) is the one we assert on.
updateMock
.mockResolvedValueOnce({
documents: ["slug-token-v3.pdf", "slug-token-v4.pdf"],
})
.mockResolvedValueOnce({});
getOneMock.mockResolvedValueOnce({
id: "po-1",
po_number: "PO-2026-07-009",
order: "order-1",
supplier: "supplier-1",
expand: { order: { id: "order-1" }, supplier: { id: "supplier-1" } },
documents: ["slug-token-v3.pdf"],
documents_history: [
{
version: 3,
file: "PO-CNL-PO-2026-07-009-v3.pdf",
token: "slug-token-v3.pdf",
generatedAt: "2026-07-09T00:00:00Z",
generatedBy: "",
reason: "",
status: "current",
},
],
});
const req = makeReq("http://localhost/api/v1/purchase-orders/po-1/pdf", {
method: "POST",
body: {},
});
const res = await POST(req, { params: Promise.resolve({ id: "po-1" }) });
expect(res.status).toBe(200);
// The second update must carry the new history entry whose token
// is the slug token returned by PB, not the original filename.
const historyCall = updateMock.mock.calls[1];
expect(historyCall[1]).toEqual({
documents_history: expect.arrayContaining([
expect.objectContaining({
version: 4,
file: "PO-CNL-PO-2026-07-009-v4.pdf",
token: "slug-token-v4.pdf",
status: "current",
}),
]),
});
});
});
describe("GET — proxy routing", () => {
it("streams the PDF through the file proxy using entry.token", async () => {
getOneMock.mockResolvedValueOnce({
id: "po-1",
po_number: "PO-2026-07-009",
documents: ["slug-v4.pdf"],
documents_history: [
{
version: 4,
file: "PO-CNL-PO-2026-07-009-v4.pdf",
token: "slug-v4.pdf",
generatedAt: "2026-07-09T00:00:00Z",
generatedBy: "",
reason: "",
status: "current",
},
],
});
fetchMock.mockResolvedValueOnce(
new Response(Buffer.from("PDF bytes"), {
status: 200,
headers: { "Content-Type": "application/pdf" },
}),
);
const req = makeReq("http://localhost/api/v1/purchase-orders/po-1/pdf?version=4");
const res = await GET(req, { params: Promise.resolve({ id: "po-1" }) });
expect(res.status).toBe(200);
expect(fetchMock).toHaveBeenCalledTimes(1);
const [url] = fetchMock.mock.calls[0];
expect(String(url)).toContain("/api/v1/files/purchase_orders/po-1/slug-v4.pdf");
});
it("falls back to documents[i] when entry.token is missing", async () => {
getOneMock.mockResolvedValueOnce({
id: "po-1",
po_number: "PO-2026-07-009",
documents: ["legacy-token.pdf"],
documents_history: [
{
version: 2,
file: "PO-CNL-PO-2026-07-009-v2.pdf",
// no token (legacy row, pre-migration)
generatedAt: "2026-07-09T00:00:00Z",
generatedBy: "",
reason: "",
status: "superseded",
},
],
});
fetchMock.mockResolvedValueOnce(
new Response(Buffer.from("PDF bytes"), { status: 200 }),
);
const req = makeReq("http://localhost/api/v1/purchase-orders/po-1/pdf?version=2");
const res = await GET(req, { params: Promise.resolve({ id: "po-1" }) });
expect(res.status).toBe(200);
const [url] = fetchMock.mock.calls[0];
expect(String(url)).toContain("/api/v1/files/purchase_orders/po-1/legacy-token.pdf");
});
it("404s with a clear message when no token is resolvable", async () => {
getOneMock.mockResolvedValueOnce({
id: "po-1",
po_number: "PO-2026-07-009",
documents: [],
documents_history: [
{
version: 1,
file: "PO-CNL-PO-2026-07-009-v1.pdf",
generatedAt: "2026-07-09T00:00:00Z",
generatedBy: "",
reason: "",
status: "current",
},
],
});
const req = makeReq("http://localhost/api/v1/purchase-orders/po-1/pdf");
const res = await GET(req, { params: Promise.resolve({ id: "po-1" }) });
expect(res.status).toBe(404);
const body = await res.json();
expect(body.error).toMatch(/token missing/i);
});
it("respects ?version=N and ?download=1", async () => {
getOneMock.mockResolvedValueOnce({
id: "po-1",
po_number: "PO-2026-07-009",
documents: ["slug-v2.pdf"],
documents_history: [
{
version: 1,
file: "PO-CNL-PO-2026-07-009-v1.pdf",
token: "slug-v1.pdf",
generatedAt: "2026-07-09T00:00:00Z",
generatedBy: "",
reason: "",
status: "superseded",
},
{
version: 2,
file: "PO-CNL-PO-2026-07-009-v2.pdf",
token: "slug-v2.pdf",
generatedAt: "2026-07-09T00:00:00Z",
generatedBy: "",
reason: "",
status: "current",
},
],
});
fetchMock.mockResolvedValueOnce(
new Response(Buffer.from("PDF"), { status: 200 }),
);
const req = makeReq(
"http://localhost/api/v1/purchase-orders/po-1/pdf?version=1&download=1",
);
const res = await GET(req, { params: Promise.resolve({ id: "po-1" }) });
expect(res.status).toBe(200);
const [url] = fetchMock.mock.calls[0];
expect(String(url)).toContain("/api/v1/files/purchase_orders/po-1/slug-v1.pdf");
expect(res.headers.get("Content-Disposition")).toMatch(/^attachment/);
});
});
});
- Step 2: Run the new test file
npm run test:run -- src/app/api/v1/purchase-orders/'[id]'/pdf/route.test.ts
Expected:
POST — stores the PB token, not the human-readable filenamefails on the secondupdateMockcall assertion because the route still usesfilenamefortoken.GET — streams the PDF through the file proxy using entry.tokenfails because the route still usespb.files.getURLdirectly.GET — falls back to documents[i]passes today (data fits the current code path).GET — 404s with a clear message when no token is resolvablemay pass or fail depending on current code; adjust if needed.GET — respects ?version=N and ?download=1passes today.
The red state we care about: the two new behaviour tests fail, and the regression tests pass.
- Step 3: Commit (test only)
git add src/app/api/v1/purchase-orders/'[id]'/pdf/route.test.ts
git commit -m "test(po-pdf): cover token capture + proxy routing"
Task 4: Final verification
- Step 1: Run targeted tests
npm run test:run -- src/app/api/v1/purchase-orders
Expected: all targeted tests pass.
- Step 2: Run full suite
npm run test:run
Expected: same passing set as before plus the new tests. Pre-existing failures unrelated to this change are acceptable.
- Step 3: Manual smoke
- Open an order detail page with at least one PO that has versions.
- Click "Download v{current}" — should download the PDF without 404.
- Click an older version pill (v3, v2, v1) — should download the corresponding PDF without 404.
- Open a PO whose version was generated before the migration script has run (or run the migration first per Task 5). Same download should still work via the documents-array fallback.
Task 5: Migration script
Files:
-
Create:
scripts/migrations/2026-07-09-po-pdf-tokens.ts -
Modify:
package.json -
Step 1: Create the migration script
// scripts/migrations/2026-07-09-po-pdf-tokens.ts
//
// One-shot backfill: write `token` into every PurchaseOrderDocumentEntry
// in `purchase_orders.documents_history` that doesn't have one yet.
//
// Strategy:
// - For each PO row, read `documents_history` and `documents`.
// - For each entry missing `token`, look up `documents[idx]` (or
// `documents[0]` as a fallback). The two arrays are kept in sync
// by the POST handler, so index alignment is correct.
// - Patch the row with the new history array.
//
// Idempotent: re-running this script is a no-op once every entry has
// a token.
import { getAdminPocketBase } from "@/lib/server/pocketbase-admin";
type Entry = {
version: number;
file: string;
token?: string;
generatedAt: string;
generatedBy: string;
reason: string;
status: "current" | "superseded";
};
type PoRow = {
id: string;
documents?: string[] | string;
documents_history?: Entry[];
};
async function main() {
const pb = await getAdminPocketBase();
let patchedRows = 0;
let patchedEntries = 0;
let unresolvable = 0;
const list = await pb.collection("purchase_orders").getList<PoRow>(1, 500, {
fields: "id,documents,documents_history",
filter: "documents_history != null",
});
for (const po of list.items) {
const history = po.documents_history ?? [];
if (history.length === 0) continue;
const docsRaw = po.documents ?? [];
const docs = Array.isArray(docsRaw) ? docsRaw : docsRaw ? [docsRaw] : [];
let changed = false;
const newHistory = history.map((entry, idx) => {
if (entry.token) return entry;
const token = docs[idx] || docs[0] || "";
if (!token) {
unresolvable += 1;
return entry;
}
changed = true;
return { ...entry, token };
});
if (changed) {
await pb.collection("purchase_orders").update(po.id, {
documents_history: newHistory,
});
patchedRows += 1;
patchedEntries += newHistory.filter((e) => e.token).length - history.filter((e) => e.token).length;
}
}
console.log(
`[po-pdf-tokens] rows patched=${patchedRows} entries patched=${patchedEntries} unresolvable=${unresolvable}`,
);
}
main().catch((err) => {
console.error("[po-pdf-tokens] failed:", err);
process.exit(1);
});
- Step 2: Register the npm script
In package.json, add a script entry alongside the other
pb:migrate:* scripts:
"pb:migrate:po-pdf-tokens": "tsx scripts/migrations/2026-07-09-po-pdf-tokens.ts"
Use the same runner as the other migrations in the repo
(tsx, ts-node, or node --loader=tsx — match what the existing
migrations use).
- Step 3: Verify the script runs
npm run pb:migrate:po-pdf-tokens
Expected output (counts will vary):
[po-pdf-tokens] rows patched=2 entries patched=8 unresolvable=0
Run it a second time:
npm run pb:migrate:po-pdf-tokens
Expected:
[po-pdf-tokens] rows patched=0 entries patched=0 unresolvable=0
(Confirms idempotence.)
- Step 4: Commit
git add scripts/migrations/2026-07-09-po-pdf-tokens.ts package.json
git commit -m "feat(po-pdf): add token backfill migration script"
Task 6: Final sign-off
- Step 1: Run all PO-PDF tests one more time
npm run test:run -- src/app/api/v1/purchase-orders
Expected: green.
- Step 2: Spot-check the proxy whitelist
rg -n "purchase_orders" src/app/api/v1/files/'[collection]'/'[recordId]'/'[filename]'/route.ts
Expected: purchase_orders is present (no edit needed; we confirmed
it during the audit).
- Step 3: Manual smoke in the browser
Repeat Task 4 Step 3 with a couple of different orders, including one whose PO was generated before the migration ran. All version pills should download successfully.