362 lines
No EOL
13 KiB
Markdown
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`. |