WFDASH-WEBAPP/docs/superpowers/plans/2026-07-09-po-pdf-token-vs-filename.md

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 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)
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
  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

// 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.