From 3a1467fc91392400d85d3b41c21fc59322011d39 Mon Sep 17 00:00:00 2001 From: SJCoetz Date: Thu, 9 Jul 2026 23:00:18 +0200 Subject: [PATCH] Add PO additional attachments implementation plan --- .../2026-07-09-po-additional-attachments.md | 997 ++++++++++++++++++ 1 file changed, 997 insertions(+) create mode 100644 docs/superpowers/plans/2026-07-09-po-additional-attachments.md diff --git a/docs/superpowers/plans/2026-07-09-po-additional-attachments.md b/docs/superpowers/plans/2026-07-09-po-additional-attachments.md new file mode 100644 index 0000000..9422da6 --- /dev/null +++ b/docs/superpowers/plans/2026-07-09-po-additional-attachments.md @@ -0,0 +1,997 @@ +# PO additional attachments — 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:** Let the operator attach extra files (supplier quote, drawing, NDA) to a Purchase Order. The PO email to the supplier includes them alongside the PO PDF. The existing versioned-PDF flow stays untouched. + +**Architecture:** New `additional_attachments` file field on `purchase_orders`. New upload + delete routes mirroring the quote-attachments pattern. New UI panel in the Send PO modal. Webhook payload extended to include the attachment metadata for n8n to consume. + +**Tech Stack:** Next.js 16 route handlers, PocketBase admin SDK, TypeScript, Vitest, shadcn/ui (Dialog + Input + Button + Sheet). + +**Spec:** [docs/superpowers/specs/2026-07-09-po-additional-attachments-design.md](file:///Volumes/EXT/DEVELOPMENT/WFDASH_NEXTJS/docs/superpowers/specs/2026-07-09-po-additional-attachments-design.md) + +--- + +## File Structure + +| File | Responsibility | Touched? | +| --- | --- | --- | +| `src/lib/types.ts` | Add `additional_attachments` to `PurchaseOrder` | Modify | +| `src/app/api/v1/purchase-orders/[id]/attachments/upload/route.ts` | POST upload route | Create | +| `src/app/api/v1/purchase-orders/[id]/attachments/upload/route.test.ts` | Upload tests | Create | +| `src/app/api/v1/purchase-orders/[id]/attachments/[filename]/route.ts` | DELETE route | Create | +| `src/app/api/v1/purchase-orders/[id]/attachments/[filename]/route.test.ts` | Delete tests | Create | +| `src/lib/server/purchase-orders-service.ts` | Pass `additional_attachments` to n8n webhook | Modify | +| `src/app/(app)/orders/[id]/page.tsx` | New attachments panel in Send PO modal | Modify | +| `src/app/api/mcp/route.ts` | New `purchase_orders.add_attachment` tool | Modify | +| `src/app/api/mcp/route.test.ts` | Test the new MCP tool | Modify | +| `scripts/migrations/2026-07-09-po-additional-attachments.ts` | Add the PB field | Create | +| `package.json` | Register migration npm script | Modify | + +--- + +## Task 1: Add `additional_attachments` to the PurchaseOrder type + +**Files:** +- Modify: `src/lib/types.ts` + +- [ ] **Step 1: Find `PurchaseOrder`** + +Open `src/lib/types.ts` and locate the `PurchaseOrder` interface. + +- [ ] **Step 2: Add the field** + +Insert the new field after the existing `documents` field, with a doc comment explaining the split: + +```ts +/** + * Files attached to the PO for the supplier (supplier quote, drawing, + * NDA, etc.). Sent alongside the versioned PO PDF on email. Distinct + * from `documents`, which is the versioned-PDF history; mixing the two + * breaks the `documents ↔ documents_history` invariant the PDF download + * path depends on. + * + * PB file field, multi, no PB-side cap. The upload route enforces + * 25 MB per file to match the rest of the app. + */ +additional_attachments?: string[]; +``` + +- [ ] **Step 3: Run typecheck** + +```bash +npx tsc --noEmit 2>&1 | tail -n 20 +``` + +Expected: no new errors. Pre-existing errors (e.g. `collection-confirm/view-model.test.ts` setBy type) are acceptable. + +- [ ] **Step 4: Commit** + +```bash +git add src/lib/types.ts +git commit -m "feat(po): add additional_attachments to PurchaseOrder type" +``` + +--- + +## Task 2: Failing tests for the upload route + +**Files:** +- Create: `src/app/api/v1/purchase-orders/[id]/attachments/upload/route.test.ts` + +- [ ] **Step 1: Write the failing tests** + +The tests need the same admin-PB mock pattern used in Task 3 of the PO-PDF token-vs-filename plan. Mirror that file's structure. + +```ts +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { NextRequest } from "next/server"; + +const getOneMock = vi.fn(); +const updateMock = vi.fn(); + +vi.mock("@/lib/server/pocketbase-admin", () => ({ + getAdminPocketBase: vi.fn().mockResolvedValue({ + collection: () => ({ + getOne: getOneMock, + update: updateMock, + }), + }), +})); + +import { POST } from "./route"; + +function makeUploadReq(url: string, file: File | null) { + const fd = new FormData(); + if (file) fd.append("file", file); + return new NextRequest(url, { method: "POST", body: fd }); +} + +describe("POST /api/v1/purchase-orders/[id]/attachments/upload", () => { + beforeEach(() => { + getOneMock.mockReset(); + updateMock.mockReset(); + }); + + afterEach(() => { + vi.clearAllMocks(); + }); + + it("400s when no file is in the request", async () => { + const res = await POST( + makeUploadReq( + "http://localhost/api/v1/purchase-orders/po-1/attachments/upload", + null, + ), + { params: Promise.resolve({ id: "po-1" }) }, + ); + expect(res.status).toBe(400); + }); + + it("appends the new token to the existing array", async () => { + getOneMock.mockResolvedValueOnce({ + id: "po-1", + additional_attachments: ["existing-token.pdf"], + }); + updateMock.mockResolvedValueOnce({}); + + const res = await POST( + makeUploadReq( + "http://localhost/api/v1/purchase-orders/po-1/attachments/upload", + new File(["data"], "supplier-quote.pdf", { type: "application/pdf" }), + ), + { params: Promise.resolve({ id: "po-1" }) }, + ); + + expect(res.status).toBe(201); + expect(updateMock).toHaveBeenCalledTimes(1); + const payload = updateMock.mock.calls[0][1] as FormData; + // FormData wraps the file; we just need to confirm update was called + // with a FormData body. The route handles the read-modify-write. + expect(payload).toBeInstanceOf(FormData); + }); + + it("rejects files over 25 MB", async () => { + const big = new File([new Uint8Array(26 * 1024 * 1024)], "big.pdf", { + type: "application/pdf", + }); + const res = await POST( + makeUploadReq( + "http://localhost/api/v1/purchase-orders/po-1/attachments/upload", + big, + ), + { params: Promise.resolve({ id: "po-1" }) }, + ); + expect(res.status).toBe(413); + expect(updateMock).not.toHaveBeenCalled(); + }); +}); +``` + +- [ ] **Step 2: Run the test file** + +```bash +npm run test:run -- 'src/app/api/v1/purchase-orders/[id]/attachments/upload/route.test.ts' +``` + +Expected: all three tests fail because the route file doesn't exist. That's the red state we want before Task 3. + +- [ ] **Step 3: Commit (test only)** + +```bash +git add 'src/app/api/v1/purchase-orders/[id]/attachments/upload/route.test.ts' +git commit -m "test(po-attachments): cover upload route behaviour" +``` + +--- + +## Task 3: Implement the upload route + +**Files:** +- Create: `src/app/api/v1/purchase-orders/[id]/attachments/upload/route.ts` + +- [ ] **Step 1: Create the route** + +Use the same shape as `src/app/api/v1/sprint/items/[id]/attachments/upload/route.ts` (which is the closest sibling), with the 25 MB cap and the read-modify-write pattern for the array: + +```ts +import { NextRequest, NextResponse } from "next/server"; +import { getAdminPocketBase } from "@/lib/server/pocketbase-admin"; +import { getServerIdentity } from "@/lib/dev-identity"; + +/** + * POST /api/v1/purchase-orders/[id]/attachments/upload + * + * Upload an additional file (supplier quote, drawing, NDA, etc.) to + * a PO. Sits alongside the versioned PDF in `documents`. Goes + * through admin PB so the operator doesn't need a PocketBase account + * with the createRule cleared. + * + * Body: multipart/form-data with a single `file` field. + * + * Response: + * 201 — { ok: true, attachments: [token, ...] } + * 400 — { error: "No file" } + * 413 — { error: "File too large (max 25 MB)" } + * 500 — { error: "PB admin unavailable" | } + * + * Cap: 25 MB per file to match the rest of the app. n8n's outbound + * SMTP layer has its own limits; staying under 25 MB is safe. + */ + +export const runtime = "nodejs"; +export const dynamic = "force-dynamic"; + +const MAX_BYTES = 25 * 1024 * 1024; + +export async function POST( + req: NextRequest, + { params }: { params: Promise<{ id: string }> }, +) { + const { id: poId } = await params; + + let formData: FormData; + try { + formData = await req.formData(); + } catch (err) { + return NextResponse.json( + { error: err instanceof Error ? err.message : "Invalid form data" }, + { status: 400 }, + ); + } + + const file = formData.get("file"); + if (!file || !(file instanceof File)) { + return NextResponse.json( + { error: "No file in request (expected field 'file')" }, + { status: 400 }, + ); + } + + if (file.size > MAX_BYTES) { + return NextResponse.json( + { error: `File too large (max ${MAX_BYTES / 1024 / 1024} MB)` }, + { status: 413 }, + ); + } + + let adminPb; + try { + adminPb = await getAdminPocketBase(); + } catch { + return NextResponse.json( + { error: "PB admin unavailable" }, + { status: 500 }, + ); + } + + try { + // Read the current attachments array, append the new file's + // token, write back. PB's FormData update replaces the field, + // so we need the read-modify-write — there is no "append" op + // on a multi-file field via the SDK. + const po = await adminPb + .collection("purchase_orders") + .getOne<{ additional_attachments?: string[] | string }>(poId, { + fields: "id,additional_attachments", + }); + const currentRaw = po?.additional_attachments ?? []; + const current = Array.isArray(currentRaw) ? currentRaw : currentRaw ? [currentRaw] : []; + + const updateFd = new FormData(); + updateFd.append("file", file); + await adminPb.collection("purchase_orders").update(poId, updateFd); + + // Re-read to get the actual token PB stored (FormData only + // carries the operator's filename; PB slug-normalises on disk). + const after = await adminPb + .collection("purchase_orders") + .getOne<{ additional_attachments?: string[] | string }>(poId, { + fields: "additional_attachments", + }); + const afterRaw = after?.additional_attachments ?? []; + const afterArr = Array.isArray(afterRaw) ? afterRaw : afterRaw ? [afterRaw] : []; + + return NextResponse.json( + { + ok: true, + attachments: afterArr, + added: afterArr.filter((t) => !current.includes(t)), + }, + { status: 201 }, + ); + } catch (err) { + return NextResponse.json( + { error: err instanceof Error ? err.message : "Upload failed" }, + { status: 500 }, + ); + } +} +``` + +Note: `getServerIdentity` is imported for consistency with sibling upload routes but isn't currently written back to PB (the field has no `uploaded_by`). Kept the import in case a future field is added. + +Actually — drop the unused import. Use this cleaner version: + +```ts +import { NextRequest, NextResponse } from "next/server"; +import { getAdminPocketBase } from "@/lib/server/pocketbase-admin"; +``` + +(Remove the `getServerIdentity` line; no further changes.) + +- [ ] **Step 2: Run the new test file** + +```bash +npm run test:run -- 'src/app/api/v1/purchase-orders/[id]/attachments/upload/route.test.ts' +``` + +Expected: 3/3 pass. + +- [ ] **Step 3: Lint** + +```bash +npx eslint 'src/app/api/v1/purchase-orders/[id]/attachments/upload/route.ts' 'src/app/api/v1/purchase-orders/[id]/attachments/upload/route.test.ts' +``` + +Expected: exit 0. + +- [ ] **Step 4: Commit** + +```bash +git add 'src/app/api/v1/purchase-orders/[id]/attachments/upload/route.ts' 'src/app/api/v1/purchase-orders/[id]/attachments/upload/route.test.ts' +git commit -m "feat(po-attachments): add upload route for PO additional attachments" +``` + +--- + +## Task 4: Failing tests for the delete route + +**Files:** +- Create: `src/app/api/v1/purchase-orders/[id]/attachments/[filename]/route.test.ts` + +- [ ] **Step 1: Write the failing tests** + +```ts +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +const getOneMock = vi.fn(); +const updateMock = vi.fn(); + +vi.mock("@/lib/server/pocketbase-admin", () => ({ + getAdminPocketBase: vi.fn().mockResolvedValue({ + collection: () => ({ + getOne: getOneMock, + update: updateMock, + }), + }), +})); + +import { DELETE } from "./route"; + +describe("DELETE /api/v1/purchase-orders/[id]/attachments/[filename]", () => { + beforeEach(() => { + getOneMock.mockReset(); + updateMock.mockReset(); + }); + + afterEach(() => { + vi.clearAllMocks(); + }); + + it("removes the matching token from additional_attachments", async () => { + getOneMock.mockResolvedValueOnce({ + id: "po-1", + additional_attachments: ["keep-me.pdf", "delete-me.pdf"], + }); + updateMock.mockResolvedValueOnce({}); + + const res = await DELETE( + new Request( + "http://localhost/api/v1/purchase-orders/po-1/attachments/delete-me.pdf", + { method: "DELETE" }, + ) as any, + { params: Promise.resolve({ id: "po-1", filename: "delete-me.pdf" }) }, + ); + + expect(res.status).toBe(200); + expect(updateMock).toHaveBeenCalledTimes(1); + expect(updateMock.mock.calls[0][1]).toEqual({ + additional_attachments: ["keep-me.pdf"], + }); + }); + + it("returns 404 when the token is not in the array", async () => { + getOneMock.mockResolvedValueOnce({ + id: "po-1", + additional_attachments: ["only-this.pdf"], + }); + + const res = await DELETE( + new Request( + "http://localhost/api/v1/purchase-orders/po-1/attachments/other.pdf", + { method: "DELETE" }, + ) as any, + { params: Promise.resolve({ id: "po-1", filename: "other.pdf" }) }, + ); + + expect(res.status).toBe(404); + expect(updateMock).not.toHaveBeenCalled(); + }); +}); +``` + +- [ ] **Step 2: Run the test file** + +```bash +npm run test:run -- 'src/app/api/v1/purchase-orders/[id]/attachments/[filename]/route.test.ts' +``` + +Expected: both tests fail because the route file doesn't exist. + +- [ ] **Step 3: Commit (test only)** + +```bash +git add 'src/app/api/v1/purchase-orders/[id]/attachments/[filename]/route.test.ts' +git commit -m "test(po-attachments): cover delete route behaviour" +``` + +--- + +## Task 5: Implement the delete route + +**Files:** +- Create: `src/app/api/v1/purchase-orders/[id]/attachments/[filename]/route.ts` + +- [ ] **Step 1: Create the route** + +```ts +import { NextRequest, NextResponse } from "next/server"; +import { getAdminPocketBase } from "@/lib/server/pocketbase-admin"; + +/** + * DELETE /api/v1/purchase-orders/[id]/attachments/[filename] + * + * Remove a single additional attachment from a PO by token. The + * `filename` segment is the PB-stored token (the same string that + * appears in `additional_attachments[]`), not the operator's + * original filename. + * + * Response: + * 200 — { ok: true, attachments: [remaining tokens] } + * 404 — { error: "Attachment not found" } + * 500 — { error: "PB admin unavailable" | } + * + * Note: PB doesn't have a "delete from a multi-file field" op via + * the SDK, so we read the array, filter out the matching token, and + * write the rest back. The original PB file on disk is not deleted + * (PB doesn't expose a per-file delete from a multi-file field) — + * the token is just removed from the array, so the Send PO modal + * stops showing it. Re-uploading creates a fresh file. + */ + +export const runtime = "nodejs"; +export const dynamic = "force-dynamic"; + +export async function DELETE( + _req: NextRequest, + { params }: { params: Promise<{ id: string; filename: string }> }, +) { + const { id: poId, filename: token } = await params; + + let adminPb; + try { + adminPb = await getAdminPocketBase(); + } catch { + return NextResponse.json( + { error: "PB admin unavailable" }, + { status: 500 }, + ); + } + + try { + const po = await adminPb + .collection("purchase_orders") + .getOne<{ additional_attachments?: string[] | string }>(poId, { + fields: "id,additional_attachments", + }); + + const raw = po?.additional_attachments ?? []; + const arr = Array.isArray(raw) ? raw : raw ? [raw] : []; + + if (!arr.includes(token)) { + return NextResponse.json( + { error: "Attachment not found" }, + { status: 404 }, + ); + } + + const remaining = arr.filter((t) => t !== token); + await adminPb.collection("purchase_orders").update(poId, { + additional_attachments: remaining, + }); + + return NextResponse.json( + { ok: true, attachments: remaining }, + { status: 200 }, + ); + } catch (err) { + return NextResponse.json( + { error: err instanceof Error ? err.message : "Delete failed" }, + { status: 500 }, + ); + } +} +``` + +- [ ] **Step 2: Run the test file** + +```bash +npm run test:run -- 'src/app/api/v1/purchase-orders/[id]/attachments/[filename]/route.test.ts' +``` + +Expected: 2/2 pass. + +- [ ] **Step 3: Lint** + +```bash +npx eslint 'src/app/api/v1/purchase-orders/[id]/attachments/[filename]/route.ts' 'src/app/api/v1/purchase-orders/[id]/attachments/[filename]/route.test.ts' +``` + +Expected: exit 0. + +- [ ] **Step 4: Commit** + +```bash +git add 'src/app/api/v1/purchase-orders/[id]/attachments/[filename]/route.ts' 'src/app/api/v1/purchase-orders/[id]/attachments/[filename]/route.test.ts' +git commit -m "feat(po-attachments): add delete route for PO additional attachments" +``` + +--- + +## Task 6: Migration to add the PB field + +**Files:** +- Create: `scripts/migrations/2026-07-09-po-additional-attachments.ts` +- Modify: `package.json` + +- [ ] **Step 1: Read an existing schema-adding migration for the pattern** + +Open `scripts/migrations/2026-07-06-purchase-orders-send-fields.ts` to see the pattern. PB exposes a schema-modification API via `pb.collections.update(id, { schema, fields })` or by calling `pb.collection(id).update({...})` for collection-level metadata. For adding a file field, the typical pattern is to fetch the collection, push a new field object into `fields`, then PUT the collection back. + +- [ ] **Step 2: Create the migration script** + +```ts +// scripts/migrations/2026-07-09-po-additional-attachments.ts +// +// One-shot schema migration: add an `additional_attachments` file +// field to the `purchase_orders` collection. Same shape as +// `documents` (file, multi, no PB cap, any mime type). +// +// Why this exists: the operator wants to attach supporting files +// (supplier quotes, drawings, NDAs) to a PO that travel with the +// outbound email alongside the versioned PDF. We use a separate +// field rather than stuffing extras into `documents` so the +// `documents ↔ documents_history` invariant the PDF download +// path depends on stays clean. +// +// Idempotent: re-running is a no-op when the field is already present. + +import { getAdminPocketBase } from "@/lib/server/pocketbase-admin"; + +const COLLECTION_NAME = "purchase_orders"; +const FIELD_NAME = "additional_attachments"; + +async function main() { + const pb = await getAdminPocketBase(); + + const collection = await pb.collections.getOne(COLLECTION_NAME); + const existing = (collection.fields ?? []) as Array<{ name: string }>; + if (existing.some((f) => f.name === FIELD_NAME)) { + console.log(`[po-additional-attachments] field already present on ${COLLECTION_NAME}`); + return; + } + + const newField = { + name: FIELD_NAME, + type: "file", + required: false, + presentable: false, + protected: false, + maxSelect: 0, + maxSize: 0, + mimeTypes: null, + thumbs: null, + }; + + await pb.collections.update(collection.id, { + fields: [...existing, newField], + }); + + console.log( + `[po-additional-attachments] created field on ${COLLECTION_NAME} (file, multi, any mime)`, + ); +} + +main().catch((err) => { + console.error("[po-additional-attachments] failed:", err); + process.exit(1); +}); +``` + +- [ ] **Step 3: Register the npm script** + +In `package.json`, add a line right after `pb:migrate:po-pdf-tokens` (or after the last `pb:migrate:*` entry): + +```json +"pb:migrate:po-additional-attachments": "npx tsx scripts/migrations/2026-07-09-po-additional-attachments.ts" +``` + +- [ ] **Step 4: Verify** + +```bash +npx eslint scripts/migrations/2026-07-09-po-additional-attachments.ts +npm run pb:migrate:po-additional-attachments 2>&1 | tail -n 5 +npm run pb:migrate:po-additional-attachments 2>&1 | tail -n 5 # idempotence check +``` + +Expected first run: `[po-additional-attachments] created field on purchase_orders (file, multi, any mime)`. +Expected second run: `[po-additional-attachments] field already present on purchase_orders`. + +If the migration fails because the live PB admin doesn't have permission to mutate the schema, that's an environment issue, not a code issue — the script will exit non-zero and the operator can run it from the admin UI. + +- [ ] **Step 5: Commit** + +```bash +git add scripts/migrations/2026-07-09-po-additional-attachments.ts package.json +git commit -m "feat(po-attachments): add migration for additional_attachments field" +``` + +--- + +## Task 7: Extend the send-email webhook payload + +**Files:** +- Modify: `src/lib/server/purchase-orders-service.ts` + +- [ ] **Step 1: Find the webhook payload construction** + +In `src/lib/server/purchase-orders-service.ts`, locate the function that builds the webhook payload sent to n8n (the one with `po_number`, `supplier_email`, `pdf_token`, `pdf_filename`, etc.). + +- [ ] **Step 2: Fetch the attachments list and add it to the payload** + +Add a read of `additional_attachments` before building the payload, and add the field to the payload object: + +```ts +// Additional files (supplier quote, drawing, NDA) the operator +// wants to send with this PO. Sits alongside `documents` (the +// versioned PDF). The n8n workflow reads this array and attaches +// each token to the outbound SMTP message via the PB file proxy. +const additionalRaw = po.additional_attachments ?? []; +const additionalAttachments = Array.isArray(additionalRaw) + ? additionalRaw + : additionalRaw + ? [additionalRaw] + : []; +``` + +Then in the payload object: + +```ts +additional_attachments: additionalAttachments, // string[] of PB-stored tokens +``` + +Keep the rest of the payload unchanged. + +- [ ] **Step 3: Lint** + +```bash +npx eslint src/lib/server/purchase-orders-service.ts +``` + +Expected: exit 0. Pre-existing errors (if any) are fine. + +- [ ] **Step 4: Commit** + +```bash +git add src/lib/server/purchase-orders-service.ts +git commit -m "feat(po): include additional_attachments in send-email webhook" +``` + +--- + +## Task 8: UI — attachments panel in the Send PO modal + +**Files:** +- Modify: `src/app/(app)/orders/[id]/page.tsx` + +- [ ] **Step 1: Add the new state variables** + +Right after the existing send-modal state (around line 105, just after `sendMessage`), add: + +```ts +const [attachments, setAttachments] = useState([]); +const [attachmentsLoading, setAttachmentsLoading] = useState(false); +const [attachmentUploading, setAttachmentUploading] = useState(false); +``` + +- [ ] **Step 2: Load attachments when the modal opens** + +In `openSendPo` (the async function we updated in the previous task), add a read of `po.additional_attachments` right after the line-count fetch resolves. The simplest shape: read it from the PO record that came in via `setSendPo(po)` and the page's already-loaded `purchaseOrders` state. + +The cleanest approach: in `openSendPo`, after the count resolves, populate attachments from `po.additional_attachments`: + +```ts +const rawAtt = po.additional_attachments ?? []; +setAttachments(Array.isArray(rawAtt) ? rawAtt : rawAtt ? [rawAtt] : []); +``` + +- [ ] **Step 3: Add the upload handler** + +```ts +const uploadAttachments = async (files: FileList | null) => { + if (!sendPo || !files || files.length === 0) return; + setAttachmentUploading(true); + try { + for (const file of Array.from(files)) { + const fd = new FormData(); + fd.append("file", file); + const res = await fetch( + `/api/v1/purchase-orders/${sendPo.id}/attachments/upload`, + { method: "POST", body: fd }, + ); + if (!res.ok) { + const body = await res.json().catch(() => ({})); + toast.error( + `Upload failed for ${file.name}: ${body.error ?? res.status}`, + ); + continue; + } + const data = (await res.json()) as { attachments: string[] }; + setAttachments(data.attachments ?? []); + } + } finally { + setAttachmentUploading(false); + } +}; +``` + +- [ ] **Step 4: Add the remove handler** + +```ts +const removeAttachment = async (token: string) => { + if (!sendPo) return; + setAttachmentUploading(true); + try { + const res = await fetch( + `/api/v1/purchase-orders/${sendPo.id}/attachments/${encodeURIComponent(token)}`, + { method: "DELETE" }, + ); + if (!res.ok) { + toast.error("Couldn't remove attachment"); + return; + } + const data = (await res.json()) as { attachments: string[] }; + setAttachments(data.attachments ?? []); + } finally { + setAttachmentUploading(false); + } +}; +``` + +- [ ] **Step 5: Render the panel** + +Insert this block between the `Message` Textarea and the `mail_failed` banner, in the Send PO modal: + +```tsx +
+
+
+

Additional attachments

+

+ Files sent alongside the PO PDF (supplier quotes, drawings, etc.). +

+
+ +
+ {attachments.length === 0 ? ( +

No additional files attached.

+ ) : ( +
    + {attachments.map((token) => ( +
  • + + {token} + + +
  • + ))} +
+ )} +
+``` + +Add `Paperclip` and `Trash2` to the existing `lucide-react` import block (line ~39). Confirm they aren't already imported. + +- [ ] **Step 6: Lint** + +```bash +npx eslint 'src/app/(app)/orders/[id]/page.tsx' +``` + +Expected: exit 0 (the 2 pre-existing warnings on `useRef` / `hasHardLink` are fine). + +- [ ] **Step 7: Commit** + +```bash +git add 'src/app/(app)/orders/[id]/page.tsx' +git commit -m "feat(orders): add additional attachments panel to Send PO modal" +``` + +--- + +## Task 9: MCP tool — `purchase_orders.add_attachment` + +**Files:** +- Modify: `src/app/api/mcp/route.ts` +- Modify: `src/app/api/mcp/route.test.ts` + +- [ ] **Step 1: Read the existing `attachments.upload_for_quote` tool for the pattern** + +Open `src/app/api/mcp/route.ts` around the `attachments.upload_for_quote` definition. The pattern: register a tool object in the `tools` array, then handle it in the `tools/call` switch. + +- [ ] **Step 2: Add the new tool to the `tools` array** + +Insert right after the `attachments.upload_for_quote` entry: + +```ts +{ + name: "purchase_orders.add_attachment", + description: + "Upload a single additional file (supplier quote, drawing, NDA) to an existing PO. The file travels with the outbound email alongside the versioned PO PDF. Call once per file. Max 25 MB per file.", + inputSchema: { + type: "object", + required: ["po_id", "filename", "base64"], + properties: { + po_id: { type: "string" }, + filename: { type: "string" }, + content_type: { type: "string" }, + size: { type: "number" }, + base64: { type: "string" }, + }, + }, +}, +``` + +- [ ] **Step 3: Add the handler in the `tools/call` switch** + +Insert after the `attachments.upload_for_quote` handler: + +```ts +if (toolName === "purchase_orders.add_attachment") { + if (!args.po_id || !args.filename || !args.base64) { + throw new Error("Missing po_id, filename, or base64 for purchase_orders.add_attachment"); + } + const bytes = Buffer.from(args.base64, "base64"); + const fd = new FormData(); + fd.append( + "file", + new Blob([new Uint8Array(bytes)], { type: args.content_type || "application/octet-stream" }), + args.filename, + ); + const updated = await pb.collection("purchase_orders").update<{ + additional_attachments: string[] | string; + }>(args.po_id, fd); + const raw = updated?.additional_attachments ?? []; + const arr = Array.isArray(raw) ? raw : raw ? [raw] : []; + return NextResponse.json({ + jsonrpc: "2.0", + id: body.id ?? null, + result: { content: [{ type: "text", text: JSON.stringify({ ok: true, attachments: arr }) }] }, + }); +} +``` + +- [ ] **Step 4: Add a test** + +In `src/app/api/mcp/route.test.ts`, add a new test (mirror the structure of existing `attachments.upload_for_quote` tests): + +```ts +it("handles purchase_orders.add_attachment", async () => { + // Mock the PB update to return the new attachments array. + // (Adjust the existing test setup to support this — see how + // attachments.upload_for_quote is mocked and replicate it.) +}); +``` + +If the existing test setup doesn't already mock `purchase_orders.update`, extend it. The exact shape will depend on what `vi.mock` is in place in that test file. + +- [ ] **Step 5: Run targeted tests** + +```bash +npm run test:run -- src/app/api/mcp +``` + +Expected: green, including the new test. + +- [ ] **Step 6: Commit** + +```bash +git add src/app/api/mcp/route.ts src/app/api/mcp/route.test.ts +git commit -m "feat(mcp): add purchase_orders.add_attachment tool" +``` + +--- + +## Task 10: Final verification + +- [ ] **Step 1: Targeted tests** + +```bash +npm run test:run -- src/app/api/v1/purchase-orders src/app/api/mcp +``` + +Expected: green. + +- [ ] **Step 2: Full suite** + +```bash +npm run test:run +``` + +Expected: same passing set as before plus the new tests. The 6 pre-existing `collection-pool` failures are acceptable. + +- [ ] **Step 3: Manual smoke** + +1. Open an order detail page. +2. Click "Send" on a PO that has PDFs. +3. Confirm the new "Additional attachments" panel renders in the modal. +4. Click "Attach files" and upload a PDF or image. Confirm it appears in the list. +5. Click the trash icon on the uploaded file. Confirm it disappears. +6. Click Send. The actual email still goes through n8n — the panel is UI-only. +7. Check the PB admin: the PO row should show `additional_attachments: ["token1", "token2", ...]`. + +- [ ] **Step 4: Spot-check the new docs are present** + +```bash +ls docs/superpowers/specs/2026-07-09-po-additional-attachments-design.md +ls scripts/migrations/2026-07-09-po-additional-attachments.ts +``` + +Both should exist. + +--- + +## Operator follow-up + +A short note for whoever maintains the n8n workflow: + +> The Send PO webhook now includes `additional_attachments: [token, ...]` — PB-stored file tokens. For each token, fetch the file via the PB file proxy and attach it to the outbound SMTP message. Empty array means no extras. Tokens live in `purchase_orders.additional_attachments`; the file proxy URL is `/api/files/purchase_orders//` with admin auth. \ No newline at end of file