This commit adds several key features, improvements, and cleanups across the codebase: - Add `order_items.confirm_collected` MCP/REST endpoint to mark order items as collected without requiring driver assignment - Add standalone order creation and order split APIs for flexible order management - Update order list sort to oldest-first to align with user workflow of working through oldest orders first - Improve order search to show cross-tab matches and display source tab indicators on search results - Add customer name similarity check to warn about mismatched QuickBooks customers during invoice creation - Remove supplier name column from delivery note PDFs and clean up related data passing across delivery note routes - Add validation and debug logging to ItemIntelPopover for missing item ID on apply actions - Remove unused OrderItemContextPopoverTrigger component from order detail page - Add delivery note regeneration script and update test suites for new and existing functionality - Update documentation and add test coverage for the new collection confirm tool
18 KiB
WFDASH API + MCP Layer
This document describes the public HTTP surface intended for external
automation (n8n workflows, custom AI agents, the local "Hermes" agent,
etc.). Everything below is implemented in this repo and exercised by
the Vitest suite under src/app/api/.
The README still lists an older subset of this surface. This file is the source of truth. If you add or rename an endpoint or MCP tool, update both files in the same commit.
1. Environment
Add to .env.local:
NEXT_PUBLIC_POCKETBASE_URL=https://pocketbase-gnqc88suy8fqk6wyryjjdpvl.169.239.180.130.sslip.io
PB_ADMIN_EMAIL=david.els1@outlook.com
PB_ADMIN_PASSWORD=1234567890
AGENT_API_KEY=123456
ORDER_ITEMS_COLLECTION=order_items # or `order_items_v2` after migration
The MCP endpoint and every /api/v1/* route (except /api/v1/health)
require the agent bearer token:
Authorization: Bearer <AGENT_API_KEY>
Rotate AGENT_API_KEY in this file plus every consumer when a key
leaks.
2. REST endpoints (/api/v1/*)
All routes return JSON. All PATCH / POST bodies are JSON unless
noted. Pagination defaults to page=1, perPage=50 where relevant.
2.1 Health / discovery
| Method | Path | Auth | Notes |
|---|---|---|---|
GET |
/api/v1/health |
none | { ok, service, time, version, env } |
2.2 Orders
| Method | Path | Body / params | Notes |
|---|---|---|---|
GET |
/api/v1/orders |
?page&perPage&status&search |
Status filter is informational; items drive workflow. Expands company,customer,quote. |
POST |
/api/v1/orders/from-quote |
{ "quote_id": "..." } |
Creates order + items; idempotent on existing order_for_quote. |
GET |
/api/v1/orders/{id} |
— | Order detail with item rows. |
GET |
/api/v1/orders/{id}/matched-attachments |
— | Returns attachment matches against quote items. |
2.3 Order items
| Method | Path | Body | Notes |
|---|---|---|---|
PATCH |
/api/v1/order-items/{id}/status |
{ item_status?, drv_status?, notes?, problem_notes? } |
Accepts legacy driver_status alias. |
2.4 Quotes
| Method | Path | Body / params | Notes |
|---|---|---|---|
GET |
/api/v1/quotes/lookup |
?q=... |
Fuzzy lookup by quote number, ref, customer. |
GET |
/api/v1/quotes/next-number |
— | Atomic Q-YYYY-MM-NNN from quote_counters. |
POST |
/api/v1/quotes/{id}/clone |
— | Clone a quote + items. |
POST |
/api/v1/quotes/{id}/save-as-template |
{ name } |
Save a quote as a template. |
POST |
/api/v1/quotes/{id}/create-from-template |
{ template_id } |
New quote from a template. |
POST |
/api/v1/quotes/{id}/retry-email |
— | Resend the outbound quote email. |
POST |
/api/v1/quotes/{id}/send-email |
{ template? } |
Send the outbound quote email. |
GET |
/api/v1/quotes/{id}/pdf |
— | Render the quote PDF. |
POST |
/api/v1/quotes/{id}/re-price |
{ items: [...] } |
Bulk re-price quote items. |
GET |
/api/v1/quotes/{id}/attachments |
?direction=inbound|outbound|generated|all |
List quote-level attachments. |
POST |
/api/v1/quotes/{id}/attachments/upload |
multipart file |
Upload a quote-level attachment (admin). |
GET |
/api/v1/quotes/{id}/items/{itemId}/match |
— | Match a quote item to a price-list row. |
GET |
/api/v1/quotes/{id}/items/{itemId}/lookup |
— | Suggest matches for an item. |
POST |
/api/v1/quotes/items/rewrite |
{ items: [...] } |
AI rewrite item descriptions. |
2.5 Quote item attachments
| Method | Path | Body | Notes |
|---|---|---|---|
POST |
/api/v1/quotes/items/{id}/attachments/upload |
multipart file |
New manual-upload endpoint (source = "manual"). |
2.6 Sprint
| Method | Path | Body | Notes |
|---|---|---|---|
POST |
/api/v1/sprint/items/{id}/claim |
{ ttl_minutes? } |
Claim a sprint row (30-min default lock). |
GET |
/api/v1/sprint/items/{id}/attachments |
— | List attachments for a quote item. |
POST |
/api/v1/sprint/items/{id}/attachments/upload |
multipart file |
Upload a pricing proof (operator or AI). |
GET |
/api/v1/sprint/attachment-counts |
?quote_item_ids=... |
Bulk counts for sprint badges. |
2.7 Companies
| Method | Path | Body | Notes |
|---|---|---|---|
GET |
/api/v1/companies |
?page&perPage&search&sort |
Filter on company / vendor substring. |
POST |
/api/v1/companies |
{ company, address?, vendor?, coordinates?: {lon,lat} } |
company required. |
GET |
/api/v1/companies/{id} |
— | Single row. |
PATCH |
/api/v1/companies/{id} |
partial of create body |
|
DELETE |
/api/v1/companies/{id} |
— | PB cascade rules apply. |
2.8 Customers
| Method | Path | Body | Notes |
|---|---|---|---|
GET |
/api/v1/customers |
?page&perPage&search&sort&expand |
expand=company,rep to inline. |
POST |
/api/v1/customers |
{ customer, email?, alternative_email?, buyer_email?, number?, company?, rep?, coordinates?, description? } |
customer required. company / rep accept id or name. |
GET |
/api/v1/customers/{id} |
?expand |
|
PATCH |
/api/v1/customers/{id} |
partial | Empty string unsets company / rep. |
DELETE |
/api/v1/customers/{id} |
— | Cascade rules apply. |
2.9 Suppliers
| Method | Path | Body | Notes |
|---|---|---|---|
GET |
/api/v1/suppliers |
?page&perPage&search&sort |
coordinates is the legacy "lat, lng" text. |
POST |
/api/v1/suppliers |
{ suppliername, email?, suppliernumber?, supplieraddress?, supply_tags?, coordinates?, closingtime? } |
|
GET |
/api/v1/suppliers/{id} |
— | |
PATCH |
/api/v1/suppliers/{id} |
partial | |
DELETE |
/api/v1/suppliers/{id} |
— | Cascade rules apply. |
2.10 Collection pool / map
| Method | Path | Body | Notes |
|---|---|---|---|
GET |
/api/v1/collection-pool |
?page&perPage&status |
Pool status filter. |
POST |
/api/v1/collection-map/items/{id}/assign |
{ driver_id } |
Assign a pool row to a driver. |
2.11 Purchase orders
| Method | Path | Body | Notes |
|---|---|---|---|
POST |
/api/v1/purchase-orders/{id}/dispatch |
{ channel: "email"|"print" } |
Dispatch PO. |
POST |
/api/v1/purchase-orders/{id}/send-email |
{ to?, cc?, body? } |
Send PO email. |
GET |
/api/v1/purchase-orders/{id}/send-email/outcome |
— | Outcome of last send. |
GET |
/api/v1/purchase-orders/{id}/pdf |
— | Render PO PDF. |
GET |
/api/v1/purchase-orders/stale |
?days=... |
List POs untouched for N days. |
POST |
/api/v1/purchase-orders/{id}/attachments/upload |
multipart/form-data field file |
Append a free-form attachment to additional_attachments. Max 25 MB. |
DELETE |
/api/v1/purchase-orders/{id}/attachments/{filename} |
— | Remove a single additional-attachment token. |
2.12 Stock purchases
| Method | Path | Body | Notes |
|---|---|---|---|
POST |
/api/v1/stock-purchases/{id}/attachments/upload |
multipart/form-data field file |
Append a free-form attachment (supplier invoice, packing slip) to stock_purchases.additional_attachments. Max 25 MB. |
DELETE |
/api/v1/stock-purchases/{id}/attachments/{filename} |
— | Remove a single additional-attachment token. |
2.13 Users / file proxy
| Method | Path | Body | Notes |
|---|---|---|---|
GET |
/api/v1/users/lookup |
?q=... |
User search. |
GET |
/api/v1/files/{collection}/{recordId}/{filename} |
— | Auth-aware file proxy for collections whose rules reject the client. |
3. MCP endpoint (/api/mcp)
JSON-RPC over HTTP. Same Authorization bearer as /api/v1/*.
3.1 Methods
tools/list— returns the full tool list (23 tools as of writing).tools/call—{ params: { name, arguments } }.
3.2 Tools
Orders / pool / sprint
| Tool | Purpose |
|---|---|
orders.list |
List orders by order_number substring. |
orders.create_from_quote |
Create order + items from a quote. |
collection_pool.list |
List pool rows by status. |
order_items.update_status |
Update item_status / drv_status / notes. |
order_items.confirm_collected |
Mark one or more customer order_items as collected without requiring a driver assignment. The in-UI /collection-pool page only exposes "Mark Collected" after a drv_id is bound; this tool is the agent-side equivalent (mirrors stock_purchase_items.mark_collected on the stock side). Eligible from item_status ∈ {collection_pool, assigned}; per-row independence; bumps qty_collected + stamps drv_collected_at + appends a category: "collection" audit note to notes. Added 2026-08-11 to close the gap surfaced when goods physically arrived at the warehouse but no driver had ever been assigned. |
Quotes (email pipeline)
| Tool | Purpose |
|---|---|
quotes.create_from_email |
Create quote + customer + items from parsed email. Idempotent on message_id. Use attachments.upload_for_quote per file after this returns. |
quotes.append_reply |
Append a structured reply. Matches In-Reply-To then Q-YYYY-MM-NNN in subject. |
quotes.find_by_message_id |
Look up by RFC 5322 Message-ID. |
attachments.upload_for_quote |
Upload one attachment to a quote (base64, ≤25 MB). |
bulk_update_quote_items |
Patch up to 100 quote items in one call (whitelisted fields only). |
Companies / customers / suppliers (full CRUD)
| Tool | Purpose |
|---|---|
companies.list / .get / .create / .update / .delete |
Full CRUD. |
customers.list / .get / .create / .update / .delete |
Full CRUD. company / rep accept id or name. |
suppliers.list / .get / .create / .update / .delete |
Full CRUD. |
Stock purchases / POs (attachments)
| Tool | Purpose |
|---|---|
purchase_orders.add_attachment |
Upload one free-form attachment to a PO (base64, ≤25 MB). |
stock_purchases.add_attachment |
Upload one free-form attachment (supplier invoice, packing slip, photo of damaged stock) to a stock purchase (base64, ≤25 MB). Appends to additional_attachments; does NOT touch the versioned-PDF documents ledger. |
Stock purchases (line-level writes — added 2026-08-07)
| Tool | Purpose |
|---|---|
stock_purchase_items.update |
Patch whitelisted pricing + catalogue fields on a single stock_purchase_item (cost_price, sku, description, notes, unit, supplier_sku, quantity). Hand-edit guard on cost_price > 0. |
stock_purchase_items.bulk_update |
Batch patch (≤100 rows). Same field whitelist + hand-edit guard as .update. Per-row independence; dry_run: true returns identical shape with status: "dry_run" per row. |
stock_purchase_items.assign_driver |
Bind a line to a driver (drv_id). Transitions line status null → assigned_to_driver or collection_pool → assigned_to_driver. |
stock_purchase_items.mark_collected |
Mark a line as physically delivered. Status → collected, bumps quantity_received. Does NOT book into stock — that's stock_purchases.receive. Header auto-flips to received when every line on the PO has quantity_received >= quantity. |
Stock items (catalogue)
| Tool | Purpose |
|---|---|
stock_items.update |
Patch catalogue fields on a master stock_item (sku, description, notes, unit, selling_price, markup). For inventory qty use stock_items.adjust. |
3.3 Tool input shapes (condensed)
quotes.create_from_email (required: subject):
{
"from_email": "buyer@acme.co.za",
"from_name": "Acme Procurement",
"subject": "RFQ — magnetic drill",
"body_plain": "...",
"body_html": "...",
"message_id": "<unique-id@mail>",
"in_reply_to": "<reply@mail>",
"received_at": "2026-07-09T10:00:00Z",
"company_hint": "Acme",
"extracted_items": [
{ "description": "Magnetic drill", "quantity": 1, "unit": "ea", "notes": "" }
],
"attachments": []
}
attachments.upload_for_quote (required: quote_id, filename, base64):
{
"quote_id": "abc123",
"filename": "spec.pdf",
"content_type": "application/pdf",
"size": 482113,
"base64": "JVBERi0xLjQK..."
}
stock_purchase_items.update (required: id, patch):
{
"id": "15-char-line-id",
"patch": {
"cost_price": 145.50,
"sku": "HYD68-20",
"supplier_sku": "SUP-HYD-068"
},
"force_overwrite": false
}
Whitelist: cost_price, sku, description, notes, unit,
supplier_sku, quantity. Anything else returns forbidden_field.
Lines with cost_price > 0 already return already_priced unless
force_overwrite: true.
stock_purchase_items.bulk_update (required: items):
{
"items": [
{ "id": "line-a", "patch": { "sku": "HYD68-20" } },
{ "id": "line-b", "patch": { "cost_price": 230.00 }, "force_overwrite": true }
],
"dry_run": false
}
Returns { updated, skipped, errors, results: [{id, status, reason?, message?, previous?, item?}] }.
Cap is 100 rows per call.
stock_purchase_items.assign_driver (required: id, drv_id):
{ "id": "15-char-line-id", "drv_id": "15-char-driver-id", "assigned_at": "2026-08-07T08:00:00Z" }
stock_purchase_items.mark_collected (required: id):
{ "id": "15-char-line-id", "quantity_collected": 4 }
Returns { item, previous_quantity_received, new_quantity_received, header_status_after, header_flipped }.
header_flipped: true when this call completed the parent PO (auto-flip
from sent → received).
stock_items.update (required: id, patch):
{
"id": "15-char-stock-item-id",
"patch": {
"sku": "HYD68-20",
"selling_price": 285.00,
"markup": 35
}
}
Whitelist: sku, description, notes, unit, selling_price, markup.
Anything else returns reason: "forbidden_field". For inventory qty use
stock_items.adjust. Returns {ok: true, item, previous} on success or
{ok: false, reason, message, field?} on rejection.
bulk_update_quote_items (required: items):
{
"items": [
{
"id": "15-char-quote-item-id",
"patch": {
"cost_price": 1200.00,
"selling_price": 1800.00,
"supplier": "15-char-supplier-id",
"pricing_source": "ai",
"pricing_notes": "Auto-priced from Acme catalogue 2026-07-09"
},
"force_overwrite": false
}
],
"dry_run": false
}
Whitelist: cost_price, selling_price, description, sku, unit,
supplier, markup, pricing_source, pricing_notes,
matched_price_list_id, user_edited. Anything else returns
reason: "forbidden_field" per row. Pre-priced rows
(cost_price > 0 AND selling_price > 0) are skipped with
reason: "already_priced" unless force_overwrite: true. Cap is 100
rows per call.
companies.create:
{ "company": "Acme", "address": "1 Road", "vendor": "V-1", "coordinates": { "lon": 28.04, "lat": -26.20 } }
customers.create:
{
"customer": "Acme Procurement",
"email": "buyer@acme.co.za",
"alternative_email": "ops@acme.co.za",
"buyer_email": "ap@acme.co.za",
"number": "+27 11 555 0100",
"company": "Acme",
"rep": "David",
"coordinates": { "lon": 28.04, "lat": -26.20 },
"description": "Industrial buyer"
}
suppliers.create:
{
"suppliername": "Acme Supplies",
"email": "sales@acme.co.za",
"suppliernumber": "+27 11 555 0200",
"supplieraddress": "2 Road",
"supply_tags": "tools,fasteners",
"coordinates": "-26.20, 28.04",
"closingtime": "17:00"
}
3.4 Response envelopes
Success:
{
"jsonrpc": "2.0",
"id": 1,
"result": {
"content": [{ "type": "text", "text": "{\"...\":\"...\"}" }]
}
}
Error:
{
"jsonrpc": "2.0",
"id": 1,
"error": { "code": -32601, "message": "Method not found" }
}
Auth failures return HTTP 401 for /api/v1/* but land in the
JSON-RPC error envelope for /api/mcp (per the in-tree test suite).
4. Known quirks
orders.listhas nostatusfilter. Theorders.statuscolumn was removed; status lives on items now. The MCPorders.listtool only supportssearch/order_numbersubstring.- Quote-level attachments via the REST route (
/api/v1/quotes/{id}/attachments/upload) and via the MCP tool (attachments.upload_for_quote) are independent code paths. New agents should prefer the MCP tool. - Quote-item attachments have two upload routes — the new one at
/api/v1/quotes/items/{id}/attachments/upload(source"manual"), and the sprint one at/api/v1/sprint/items/{id}/attachments/upload(source"operator_proof"/"agent_proof"). Use the sprint route for pricing proofs; use the quotes route for raw attachments coming in with the original RFQ. customers.create/customers.updateacceptcompanyandrepas either ids or case-insensitive names. An unresolved name leaves the relation empty (no error) so the operator can wire it up later.suppliers.coordinatesis the legacy"lat, lng"text field. Don't migrate it to{lon,lat}—supplier-map.tsstill parses the text form.
5. Testing the surface
Unit/API tests:
npm run test:run -- src/app/api
End-to-end (boots Next dev on :3100):
export E2E_BASE_URL=http://localhost:3100
export E2E_AGENT_API_KEY=$AGENT_API_KEY
npm run e2e
The Playwright suite intentionally skips env-driven specs when the
keys aren't set, so a clean checkout reports passed, N skipped.