WFDASH-WEBAPP/docs/api-mcp-layer.md

461 lines
17 KiB
Markdown
Raw Normal View History

# 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. |
| `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. |
#### 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`):
```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..."
}
```
`stock_purchase_items.update` (required: `id`, `patch`):
```json
{
"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`):
```json
{
"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`):
```json
{ "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`):
```json
{ "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`):
```json
{
"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`):
```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`.