WFDASH-WEBAPP/docs/api-mcp-layer.md
SJCoetz 47b01bddfa feat: add picking slips, bulk print tracking, supplier map
- Add Supplier Map component to suppliers page and reorganize sidebar navigation
- Implement picking slip PDF generation with supplier per line item and picked-by signature footer
- Add invoice bulk print tracking API and view models to track printed invoice state
- Extend company search and form to support email/telephone fields and improve searchability
- Fix data table debounce race condition by using useRef for onSearch handler
- Fix supplier page fetch race condition with debounced timeout
- Reduce list page item counts to 10 per page for customers, companies, and suppliers
- Update company search filter to include email and telephone in search queries
- Add comprehensive test suite for new and existing components and APIs
- Add pdf-lib dependency for PDF generation and update related tooling
- Clean up unused code and fix minor linting issues
2026-07-10 10:47:11 +02:00

362 lines
No EOL
13 KiB
Markdown

# 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`:
```bash
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. |
### 2.12 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. |
#### 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. |
### 3.3 Tool input shapes (condensed)
`quotes.create_from_email` (required: `subject`):
```json
{
"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`):
```json
{
"quote_id": "abc123",
"filename": "spec.pdf",
"content_type": "application/pdf",
"size": 482113,
"base64": "JVBERi0xLjQK..."
}
```
`bulk_update_quote_items` (required: `items`):
```json
{
"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`:
```json
{ "company": "Acme", "address": "1 Road", "vendor": "V-1", "coordinates": { "lon": 28.04, "lat": -26.20 } }
```
`customers.create`:
```json
{
"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`:
```json
{
"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:
```json
{
"jsonrpc": "2.0",
"id": 1,
"result": {
"content": [{ "type": "text", "text": "{\"...\":\"...\"}" }]
}
}
```
Error:
```json
{
"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.list` has no `status` filter.** The `orders.status` column
was removed; status lives on items now. The MCP `orders.list` tool
only supports `search` / `order_number` substring.
- **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.update`** accept `company` and
`rep` as either ids or case-insensitive names. An unresolved name
leaves the relation empty (no error) so the operator can wire it
up later.
- **`suppliers.coordinates`** is the legacy `"lat, lng"` text field.
Don't migrate it to `{lon,lat}` — `supplier-map.ts` still parses
the text form.
---
## 5. Testing the surface
Unit/API tests:
```bash
npm run test:run -- src/app/api
```
End-to-end (boots Next dev on `:3100`):
```bash
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`.