diff --git a/docs/superpowers/plans/2026-07-09-po-pdf-token-vs-filename.md b/docs/superpowers/plans/2026-07-09-po-pdf-token-vs-filename.md new file mode 100644 index 0000000..97b9baf --- /dev/null +++ b/docs/superpowers/plans/2026-07-09-po-pdf-token-vs-filename.md @@ -0,0 +1,743 @@ +# 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//`. 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:///Volumes/EXT/DEVELOPMENT/WFDASH_NEXTJS/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: + +```ts +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** + +```bash +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** + +```bash +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: + +```ts +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: + +```ts +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(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** + +```bash +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** + +```bash +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. + +```ts +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** + +```bash +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 filename` fails + on the second `updateMock` call assertion because the route still + uses `filename` for `token`. +- `GET — streams the PDF through the file proxy using entry.token` + fails because the route still uses `pb.files.getURL` directly. +- `GET — falls back to documents[i]` passes today (data fits the + current code path). +- `GET — 404s with a clear message when no token is resolvable` + may pass or fail depending on current code; adjust if needed. +- `GET — respects ?version=N and ?download=1` passes today. + +The red state we care about: the two new behaviour tests fail, and +the regression tests pass. + +- [ ] **Step 3: Commit (test only)** + +```bash +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** + +```bash +npm run test:run -- src/app/api/v1/purchase-orders +``` + +Expected: all targeted tests pass. + +- [ ] **Step 2: Run full suite** + +```bash +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** + +1. Open an order detail page with at least one PO that has versions. +2. Click "Download v{current}" — should download the PDF without 404. +3. Click an older version pill (v3, v2, v1) — should download the + corresponding PDF without 404. +4. 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** + +```ts +// 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(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: + +```json +"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** + +```bash +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: + +```bash +npm run pb:migrate:po-pdf-tokens +``` + +Expected: + +``` +[po-pdf-tokens] rows patched=0 entries patched=0 unresolvable=0 +``` + +(Confirms idempotence.) + +- [ ] **Step 4: Commit** + +```bash +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** + +```bash +npm run test:run -- src/app/api/v1/purchase-orders +``` + +Expected: green. + +- [ ] **Step 2: Spot-check the proxy whitelist** + +```bash +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. \ No newline at end of file