WFDASH-WEBAPP/docs/superpowers/plans/2026-07-09-po-additional-attachments.md

31 KiB

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

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

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

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" | <message> }
 *
 * 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:

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
npm run test:run -- 'src/app/api/v1/purchase-orders/[id]/attachments/upload/route.test.ts'

Expected: 3/3 pass.

  • Step 3: Lint
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
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

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

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" | <message> }
 *
 * 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
npm run test:run -- 'src/app/api/v1/purchase-orders/[id]/attachments/[filename]/route.test.ts'

Expected: 2/2 pass.

  • Step 3: Lint
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
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
// 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):

"pb:migrate:po-additional-attachments": "npx tsx scripts/migrations/2026-07-09-po-additional-attachments.ts"
  • Step 4: Verify
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
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:

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

additional_attachments: additionalAttachments,  // string[] of PB-stored tokens

Keep the rest of the payload unchanged.

  • Step 3: Lint
npx eslint src/lib/server/purchase-orders-service.ts

Expected: exit 0. Pre-existing errors (if any) are fine.

  • Step 4: Commit
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:

const [attachments, setAttachments] = useState<string[]>([]);
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:

const rawAtt = po.additional_attachments ?? [];
setAttachments(Array.isArray(rawAtt) ? rawAtt : rawAtt ? [rawAtt] : []);
  • Step 3: Add the upload handler
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
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:

<div className="space-y-2 rounded-md border p-3">
  <div className="flex items-center justify-between">
    <div>
      <p className="text-sm font-medium">Additional attachments</p>
      <p className="text-xs text-muted-foreground">
        Files sent alongside the PO PDF (supplier quotes, drawings, etc.).
      </p>
    </div>
    <label className="inline-flex cursor-pointer items-center gap-1.5 rounded-md border bg-background px-2 py-1 text-xs hover:bg-accent">
      <Paperclip className="h-3 w-3" />
      {attachmentUploading ? "Uploading…" : "Attach files"}
      <input
        type="file"
        multiple
        className="hidden"
        onChange={(e) => uploadAttachments(e.target.files)}
        disabled={attachmentUploading}
      />
    </label>
  </div>
  {attachments.length === 0 ? (
    <p className="text-xs text-muted-foreground">No additional files attached.</p>
  ) : (
    <ul className="space-y-1">
      {attachments.map((token) => (
        <li
          key={token}
          className="flex items-center justify-between gap-2 rounded border bg-muted/30 px-2 py-1 text-xs"
        >
          <a
            href={`/api/v1/files/purchase_orders/${sendPo.id}/${encodeURIComponent(token)}`}
            target="_blank"
            rel="noopener noreferrer"
            className="truncate text-foreground hover:underline"
          >
            {token}
          </a>
          <Button
            type="button"
            variant="ghost"
            size="icon"
            className="h-5 w-5 text-muted-foreground hover:text-destructive"
            onClick={() => removeAttachment(token)}
            disabled={attachmentUploading}
            aria-label={`Remove ${token}`}
          >
            <Trash2 className="h-3 w-3" />
          </Button>
        </li>
      ))}
    </ul>
  )}
</div>

Add Paperclip and Trash2 to the existing lucide-react import block (line ~39). Confirm they aren't already imported.

  • Step 6: Lint
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
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:

{
  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:

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

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
npm run test:run -- src/app/api/mcp

Expected: green, including the new test.

  • Step 6: Commit
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
npm run test:run -- src/app/api/v1/purchase-orders src/app/api/mcp

Expected: green.

  • Step 2: Full suite
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
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/<poId>/<token> with admin auth.