Add PO PDF token-vs-filename implementation plan
This commit is contained in:
parent
538986d7db
commit
169c1d82c5
1 changed files with 743 additions and 0 deletions
743
docs/superpowers/plans/2026-07-09-po-pdf-token-vs-filename.md
Normal file
743
docs/superpowers/plans/2026-07-09-po-pdf-token-vs-filename.md
Normal 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.
|
||||
Loading…
Reference in a new issue