Add PO PDF token-vs-filename implementation plan

This commit is contained in:
SJCoetz 2026-07-09 22:15:22 +02:00
parent 538986d7db
commit 169c1d82c5

View file

@ -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/<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:///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<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**
```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<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:
```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.