8.2 KiB
PO additional attachments
Goal: Let the operator attach extra files (supplier quote, drawing, NDA, etc.) to a Purchase Order. The PO email to the supplier includes them alongside the PO PDF. The existing documents field (versioned PDFs) and the new additional_attachments field stay cleanly separate.
Scope
In Scope
- Add an
additional_attachmentsfile field to thepurchase_orderscollection. Same shape asdocuments(file, multi, no PB cap, any mime type). - Migration: add the field via
scripts/migrations/2026-07-09-po-additional-attachments.ts. Idempotent (PB's add-field migration is a no-op on re-run). - New server route:
POST /api/v1/purchase-orders/[id]/attachments/upload— multipart, admin-auth, returns the updatedadditional_attachmentsarray. - New server route:
DELETE /api/v1/purchase-orders/[id]/attachments/[filename]— removes a single token fromadditional_attachments. - Order detail UI: in the Send PO modal, an "Additional attachments" panel above the message textarea with:
- List of currently-attached files (filename, size, remove button)
- Multi-file picker to upload more
- Disabled while the modal is loading the line count
- Modal: list reads from
po.additional_attachments; refresh after upload/delete. - New MCP tool:
purchase_orders.add_attachment(POST a single file via base64, likeattachments.upload_for_quote). - Updated MCP tool:
purchase_orders.listreturns the attachment count per PO so the agent can see at a glance. - Updated
purchase-orders-service.tssend-email path: pass theadditional_attachmentsarray to the n8n webhook so the supplier gets them. - n8n is out of scope for this repo. The webhook payload now includes
additional_attachments: [ { filename, token, content_type, size_bytes }, ... ]— the workflow needs to read these and attach them. We surface this in the spec so the operator can wire it.
Out of Scope
- Anything that touches
documents/documents_history(the versioned-PDF flow). - Changing the existing
buildDefaultPoEmailBodytemplate. The body still says "(N lines)" — the attachments travel as files, not in the body text. - Auto-attaching the same supplier quote to every send. Each send picks from whatever is currently attached.
- Versioning the additional attachments. They live with the PO; if the operator wants to track "which quote did we send with v3", that's a future feature.
Current State
purchase_orders.documents is a multi-file field used for versioned PO PDFs. documents_history is a JSON array that tracks each version's file (human-readable name), token (PB-stored slug), generatedAt, generatedBy, reason, status. The two are kept in sync by the POST handler of /api/v1/purchase-orders/[id]/pdf.
The Send PO modal currently pre-populates the email body with the line count (we just fixed the count source). It has no concept of additional attachments.
Data Model
New field on purchase_orders
additional_attachments
type: file
required: false
maxSelect: 0 (unlimited)
maxSize: 0 (no PB cap; the upload route enforces 25 MB per file to match the rest of the app)
mimeTypes: null
thumbs: null
protected: false
Rationale: identical shape to documents so the existing files.getURL and /api/v1/files/purchase_orders/<id>/<token> proxy whitelist work without changes. The proxy already accepts purchase_orders.
Why a separate field and not the rename-into-documents shortcut
If we put additional files in documents and renamed them with a prefix, we'd break the documents ↔ documents_history invariant that the PDF download flow depends on. Operators regenerating POs risk dropping the attachment or having n8n pick the wrong file as the PDF. A separate field is a clean split.
Behavior
Operator workflow
- On the order detail page, open the Send PO modal for a PO that needs supporting context (supplier quote, spec sheet, etc.).
- In the Additional attachments panel, click "Attach files" → multi-file picker.
- Files upload via
POST /api/v1/purchase-orders/{poId}/attachments/upload. The panel refreshes to show the new list. - Existing files can be removed via the trash icon →
DELETE /api/v1/purchase-orders/{poId}/attachments/[filename]. - When the operator hits Send, the n8n webhook receives the new payload (see below) and the supplier gets the PO PDF plus all currently-attached files.
Upload route (POST)
- Multipart form:
file(required, File). - Admin PB (bypasses
createRule). - Inserts into
additional_attachmentswhile preserving the existing array. PB SDK's FormData update replaces the field, so we read first, append the new token, and write back. - Returns
{ ok: true, attachments: [{ filename, token, content_type, size_bytes }] }. - 400 if no file, 500 on PB failure.
Delete route (DELETE)
- Path:
DELETE /api/v1/purchase-orders/{poId}/attachments/[filename] - The
[filename]is the PB-stored token (matches thedocuments_history[].tokenpattern and the proxy URL path). - Admin PB. Read the current
additional_attachments, filter out the matching token, write back. - Returns
{ ok: true, attachments: [...] }. - 404 if the token isn't found in the array.
Send-email payload change
The /api/v1/purchase-orders/[id]/send-email route currently sends:
{
po_number, supplier_email, cc, bcc, subject, message,
pdf_token, pdf_filename, total_amount
}
We add:
additional_attachments: [
{ filename, token, content_type, size_bytes },
...
]
Empty array if nothing attached. n8n reads the field and attaches each token to the outbound SMTP message via the PB file proxy (or its own admin PB client — operator's choice).
Testing
Upload route (route.test.ts)
- POST without a file → 400.
- POST with a single file → 201, response includes the new token, PB update is called with the appended array.
- POST preserves existing attachments (read-modify-write pattern).
- Reject files over 25 MB at the route level (matches the quote-attachments cap).
- POST is admin-auth-gated (no admin → 500 with the existing
getAdminPocketBasefailure shape).
Delete route (route.test.ts)
- DELETE on an existing token → 200, token removed from the array.
- DELETE on a missing token → 404.
- DELETE preserves the rest of the array.
UI
Order detail page: existing modal still works; new attachments panel renders with empty state, "Attach files" button, and the upload list. The send-time test from the previous fix is unchanged.
MCP
tools/listincludespurchase_orders.add_attachment.purchase_orders.add_attachmentaccepts{ po_id, filename, base64, content_type? }and returns the same shape as the REST route.purchase_orders.list(new) returns anattachment_countfield per PO.
Migration
scripts/migrations/2026-07-09-po-additional-attachments.ts:
- Read
purchase_ordersschema from PB. - If
additional_attachmentsfield doesn't exist, add it via PB's field-add API. - Log
[po-additional-attachments] created field on purchase_ordersor[po-additional-attachments] already present.
Idempotent on re-run.
Add npm run pb:migrate:po-additional-attachments to package.json (same npx tsx convention as every other migration).
Acceptance Criteria
- Operator can upload and remove additional attachments from the Send PO modal.
- The Send PO webhook payload includes the additional attachments so n8n can attach them.
- Existing versioned-PDF flow is untouched.
- All targeted tests pass; full suite non-regressive.
- Migration is idempotent.
Risk / Rollback
- Schema risk: adding a file field with no constraints is safe — PB accepts empty arrays on existing rows. No data migration needed.
- Rollback: remove the field via the PB admin UI. Drop the routes, the UI panel, and the MCP tool. The n8n workflow ignores the new payload field if absent.
Operator-facing note (separate doc, post-implementation)
A short note for whoever maintains the n8n workflow:
The Send PO webhook now includes
additional_attachments: [{ filename, token, content_type, size_bytes }, ...]. Read each entry, fetch the file from the PB file proxy (/api/files/purchase_orders/<poId>/<token>with admin auth), and attach it to the outbound SMTP message. Empty array means no extras.