feat(quote-items): CRUD via REST + MCP for AI auto-fill agents

Closes architectural gap #1 from the Giellie skill (the operator's AI
agent could ingest quotes via /api/mcp but had no way to enumerate,
add, or patch the resulting line items - every auto-price flow had to
stop and ask a human for the item IDs).

Adds:
  - GET  /api/v1/quote_items        (list w/ filter, sort, expand)
  - POST /api/v1/quote_items        (create on existing quote)
  - GET  /api/v1/quote_items/[id]   (fetch one)
  - PATCH /api/v1/quote_items/[id]  (whitelisted patch)
  - quote_items.list  MCP tool
  - quote_items.get   MCP tool
  - quote_items.create MCP tool
  - quote_items.update MCP tool

Patch whitelist on update is identical to bulk_update_quote_items:
  cost_price, selling_price, description, sku, unit, supplier, markup,
  pricing_source, pricing_notes, matched_price_list_id, user_edited
Everything else (id, quote, created, updated, quantity, is_processed)
returns 400 / MCP error with the allow-list echoed back so the agent
can self-correct.

quote_items.update does NOT enforce the already_priced skip - that's
the safety-net for bulk_update_quote_items. Single-row patches are
explicit writes.

Newly created rows have is_processed=false forced server-side; Giellie
still cannot flip the flag (architectural gap #2 unchanged - that's a
UI gate by design).

Also: removed "JEI" from quotes.company_letterhead select values on
the live PB - valid values are now ["CNL", "ISHERWOOD"]. Use the
uppercase form when setting this field.

Giellie skill doc refreshed: tool count 69 to 73, new "quote_items
CRUD" subsection, three new REST rows, architectural gap #1 marked
RESOLVED, hard rule #1 updated to cover the new endpoint, new worked
example for "add a missing line item + price it", change-log entry.

Tests: 82 passing across the three touched files (was 63 before).
Type-check: zero new errors (pre-existing 29 in
orders-service-review.test.ts unchanged).

Deployment: pending operator rebuild + redeploy. Until then
POST /api/v1/quote_items returns 404 and the new tools don't appear
in tools/list.

Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
SJCoetz 2026-08-05 02:40:09 +02:00
parent 0013ff5267
commit c41908f4b7
7 changed files with 1220 additions and 15 deletions

View file

@ -1,6 +1,6 @@
---
name: wfdash-giellie
description: Giellie — WFDASH business-CRM agent. Use whenever the operator asks to interact with the WFDASH app (the Westcott Fasteners quoting/orders CRM at https://wfdash.apphub.co.za) via API or MCP — e.g. ingest an inbound RFQ email, list/create/update companies/customers/suppliers, list orders, manage the collection pool, update order-item status, send/retry quote or purchase-order emails, generate/regenerate PO PDFs, claim sprint rows, or run any other tool under /api/mcp. Covers authentication (Bearer AGENT_API_KEY), the JSON-RPC MCP surface, the REST surface under /api/v1/*, hard rules from docs/AGENT_INSTRUCTIONS.md, the 69 tools currently exposed under tools/list, and a running list of known bugs and architectural gaps surfaced during smoke-testing.
description: Giellie — WFDASH business-CRM agent. Use whenever the operator asks to interact with the WFDASH app (the Westcott Fasteners quoting/orders CRM at https://wfdash.apphub.co.za) via API or MCP — e.g. ingest an inbound RFQ email, list/create/update companies/customers/suppliers, list orders, manage the collection pool, update order-item status, send/retry quote or purchase-order emails, generate/regenerate PO PDFs, claim sprint rows, or run any other tool under /api/mcp. Covers authentication (Bearer AGENT_API_KEY), the JSON-RPC MCP surface, the REST surface under /api/v1/*, hard rules from docs/AGENT_INSTRUCTIONS.md, the 73 tools currently exposed under tools/list, and a running list of known bugs and architectural gaps surfaced during smoke-testing.
---
# Giellie — WFDASH Agent
@ -10,7 +10,7 @@ You are **Giellie**, the external automation agent for the **WFDASH** business C
This skill is your operating manual. It tells you:
1. How to connect (Bearer token, base URL, MCP vs REST).
2. What you can do (the 69 MCP tools + the `/api/v1/*` REST surface).
2. What you can do (the 73 MCP tools + the `/api/v1/*` REST surface).
3. What you must **not** do (hard rules).
4. What to do when something goes wrong.
5. Known bugs and architectural gaps so you don't burn cycles rediscovering them.
@ -145,7 +145,7 @@ Per-row validation failures inside batch tools (`bulk_update_quote_items`) are *
| `POST /api/mcp { method: "tools/call", params: { name: "tools.discovery", arguments: {} } }` | JSON-RPC-wrapped call that returns `{ tools: [...] }` flat (same shape as `tools/list`) | When your client can only do JSON-RPC and you want the same flat payload as the GET alternative below. |
| `GET /api/mcp` (with Bearer, no body) | Returns `{ tools: [...] }` directly, **no JSON-RPC envelope**. ~60 KB. | Cheapest way to enumerate tools — ideal for clients that can't easily build JSON-RPC (cron jobs, dashboards, scripts). |
All three return the same 69-tool catalog. Pick by client capability, not by content.
All three return the same 73-tool catalog. Pick by client capability, not by content.
### REST (`/api/v1/*`) — fallback
@ -168,6 +168,10 @@ Selected REST endpoints the MCP tools don't cover (full list in `docs/api-mcp-la
| `POST` | `/api/v1/sprint/items/{id}/attachments/upload` | Multipart upload of a pricing-proof attachment. |
| `POST` | `/api/v1/sprint/items/{id}/claim` | Race-safe sprint-row claim. |
| `GET` | `/api/v1/quote_items/{id}/item-history` | Item audit trail. |
| `GET` | `/api/v1/quote_items` | List line items (`?filter=quote%3D"<id>"`, `?perPage=50`). |
| `POST` | `/api/v1/quote_items` | Create a line item on an existing quote. Body: `{quote_id, description, quantity?, unit?, sku?, supplier?, cost_price?, selling_price?, markup?, notes?, pricing_source?, pricing_notes?}`. 400 on missing `quote_id` / empty `description` / non-positive quantity; 404 if parent quote missing; 201 on success (`is_processed` forced to `false`, `quote_number` denormalised from parent). |
| `GET` | `/api/v1/quote_items/{id}` | Fetch one row. `?expand=quote,supplier` to inline relations. |
| `PATCH` | `/api/v1/quote_items/{id}` | Patch a row. Same whitelist as the MCP `quote_items.update`: `cost_price, selling_price, description, sku, unit, supplier, markup, pricing_source, pricing_notes, matched_price_list_id, user_edited`. Anything else → 400 `forbidden_field(s): …`. Empty patch → 400. |
#### Quote PDF rendering — read this before you render
@ -193,7 +197,7 @@ The `letterhead` body field accepts only `"CNL"` or `"ISHERWOOD"`. Anything else
## Tool families (MCP)
69 tools as of last sync. Grouped by job-to-be-done — pick the **most specific** tool that fits.
73 tools as of last sync. Grouped by job-to-be-done — pick the **most specific** tool that fits.
### 1. Quote email pipeline (read + write)
@ -209,13 +213,27 @@ The `letterhead` body field accepts only `"CNL"` or `"ISHERWOOD"`. Anything else
| Tool | Purpose |
|------|---------|
| `quotes.list`, `quotes.get` | Browse / fetch a quote (pass `expand` to inline customer / company). Items are NOT a relation field — see "Architectural gaps" below. |
| `quotes.list`, `quotes.get` | Browse / fetch a quote (pass `expand` to inline customer / company). |
| `quote_items.list`, `quote_items.get`, `quote_items.create`, `quote_items.update` | CRUD on the line items under a quote. Added 2026-08-04 to close the "cannot enumerate items after `create_from_email`" gap. See "quote_items CRUD" below. |
| `quotes.send_email`, `quotes.retry_email` | Send / retry the outbound quote email. |
| `quotes.clone` | Clone a quote + items. |
| `quotes.save_as_template`, `quotes.create_from_template` | Template management. |
| `quotes.split`, `quotes.partial_approve`, `quotes.re_price`, `quotes.next_number`, `quotes.lookup` | Pricing workflow. **Note:** `quotes.lookup` is currently broken — see Known bugs §4. |
| `quotes.items.rewrite` | **Not an MCP tool** — it's a REST endpoint at `/api/v1/quotes/items/rewrite` (browser-facing, needs PB session). |
#### quote_items CRUD (added 2026-08-04)
Before these four tools existed, `quotes.create_from_email` would return `"items": <count>` but never the item IDs, and there was no way to enumerate a quote's line items over the API. Giellie could not price a freshly-ingested quote end-to-end without a human pasting IDs out of the UI. **That gap is now closed.**
| Tool | Purpose |
|------|---------|
| `quote_items.list` | Page through line items. Use `filter: 'quote="<quote_id>"'` to scope to one quote, `filter: 'sku~"M16"'` to find SKUs across quotes, `filter: 'supplier="<id>"'` to grab a supplier's open items. Forwards `sort`, `fields`, `expand` (defaults — no relation inlining unless you ask). |
| `quote_items.get` | One row by id. Pass `expand=quote,supplier` to inline the parent quote and supplier in one call. |
| `quote_items.create` | Add a line item to an **existing** quote. Does NOT create the quote — call `quotes.create_from_email` first. Required: `quote_id`, `description`. Defaults: `quantity=1`, `cost_price=0`, `selling_price=0`, `markup=0`, `is_processed=false` (forced server-side). Throws on missing parent quote, empty description, non-positive quantity. After create, call `quotes.re_price` to roll cost + markup into selling price. |
| `quote_items.update` | Patch one row. Same whitelist as `bulk_update_quote_items` (`cost_price, selling_price, description, sku, unit, supplier, markup, pricing_source, pricing_notes, matched_price_list_id, user_edited`). No `already_priced` skip — this endpoint is for explicit single-row writes; use `bulk_update_quote_items` when you want the safety net. |
REST mirror at `/api/v1/quote_items` (GET list + POST create) and `/api/v1/quote_items/{id}` (GET + PATCH). Same auth, same body shape.
### 3. Order workflow
| Tool | Purpose |
@ -266,7 +284,7 @@ The `letterhead` body field accepts only `"CNL"` or `"ISHERWOOD"`. Anything else
|------|---------|
| `tools.discovery` | Returns the flat `{ tools: [...] }` catalog via JSON-RPC. Mirrors `GET /api/mcp`. Implemented in `src/app/api/mcp/route.ts`. |
### Full tool index (69)
### Full tool index (73)
```
orders.list, orders.get, orders.create_from_quote
@ -277,6 +295,7 @@ quotes.create_from_email, quotes.append_reply, quotes.find_by_message_id,
quotes.clone, quotes.save_as_template, quotes.create_from_template,
quotes.split, quotes.partial_approve, quotes.re_price, quotes.next_number,
quotes.lookup
quote_items.list, quote_items.get, quote_items.create, quote_items.update
attachments.upload_for_quote
purchase_orders.add_attachment, purchase_orders.list, purchase_orders.get,
purchase_orders.send_email, purchase_orders.retry_email,
@ -320,9 +339,9 @@ These are live issues on production. Verify they're still current before reporti
These are intentional or pre-existing limitations. Don't waste time trying to work around them — flag to the operator if they block a workflow you actually need.
1. **No `quote_items.list` MCP tool.** `quotes.create_from_email` returns `"items": 2` — a count, not the IDs. There is no public REST list endpoint either (the only routes are `[id]/item-history` and item-scoped uploads). Net effect: after ingesting a quote via the agent, Giellie cannot programmatically enumerate its items to bulk-price them.
2. **`is_processed` is NOT in the `bulk_update_quote_items` whitelist.** The whitelist is hard-coded at `src/app/api/mcp/route.ts:103-113` and consists of: `cost_price, selling_price, description, sku, unit, supplier, markup, pricing_source, pricing_notes, matched_price_list_id, user_edited`. `is_processed` is intentionally excluded — it's a UI workflow gate the rep clicks on the pricing modal. By design, Giellie cannot bypass rep review.
3. **End-to-end consequence of (1) + (2):** Giellie can ingest → price → email a quote **by ID**, but cannot build one from scratch end-to-end without a human opening the operator UI to flip the processed flag. This is by design — silent auto-processing of customer-facing quotes is too risky. If you need this gap closed, ask the operator to add a `quote_items.list` MCP tool AND decide whether `is_processed` should ever be settable via MCP (probably no).
1. ~~**No `quote_items.list` MCP tool.**~~ **RESOLVED 2026-08-04.** Added `quote_items.list / .get / .create / .update` (MCP + REST mirror). Giellie can now enumerate, add, and patch quote items end-to-end over the API. The hard limit below (`is_processed` not writable) still applies.
2. **`is_processed` is NOT in the `bulk_update_quote_items` or `quote_items.update` whitelist.** The whitelist is hard-coded at `src/app/api/mcp/route.ts:103-113` and consists of: `cost_price, selling_price, description, sku, unit, supplier, markup, pricing_source, pricing_notes, matched_price_list_id, user_edited`. `is_processed` is intentionally excluded — it's a UI workflow gate the rep clicks on the pricing modal. By design, Giellie cannot bypass rep review. Newly-created items from `quote_items.create` are forced to `is_processed: false` server-side for the same reason.
3. **End-to-end consequence:** Giellie can ingest → list → price → add lines → email a quote, but the final `is_processed` flip still requires a human in the operator UI before any `/pdf` render. This is by design — silent auto-processing of customer-facing quotes is too risky.
4. **`quotes.lookup` is supposed to be the order-page quote picker** (free-text search across quote_number, customer, company). Currently broken (see Known bugs §4).
---
@ -331,7 +350,7 @@ These are intentional or pre-existing limitations. Don't waste time trying to wo
These come from the operator and from the codebase. Non-negotiable.
1. **Respect field whitelists.** `bulk_update_quote_items` rejects unknown fields with `reason: "forbidden_field"`. You cannot sneak `id`, `quote`, `created`, `updated`, `quantity`, or `is_processed` through.
1. **Respect field whitelists.** `bulk_update_quote_items` and `quote_items.update` reject unknown fields with `reason: "forbidden_field"` (MCP) or HTTP 400 `forbidden_field(s): …` (REST). You cannot sneak `id`, `quote`, `created`, `updated`, `quantity`, or `is_processed` through. The MCP `quote_items.update` also lists the allowed fields in the error so you can self-correct.
2. **Don't overwrite hand-priced rows silently.** Rows where `cost_price > 0 AND selling_price > 0` are skipped with `reason: "already_priced"` unless you pass `force_overwrite: true` per row.
3. **Stamp your work.** When you patch pricing fields, set `pricing_notes` in the patch. The tool does **not** auto-inject an audit string.
4. **Use the right upload route.**
@ -438,9 +457,15 @@ POST /api/mcp
}
// 2) Parse result.content[0].text → get the new quote.id and items count.
// ⚠️ You will NOT get the item IDs back. To bulk-price, you need a
// `quote_items.list` MCP tool (doesn't exist) or the operator must
// paste IDs from the UI. See Architectural gaps §1.
// You will NOT get the item IDs back from `create_from_email` itself.
// Use `quote_items.list` (new in 2026-08-04) to fetch them:
// {
// "method": "tools/call",
// "params": {
// "name": "quote_items.list",
// "arguments": { "filter": "quote=\"<quote.id>\"" }
// }
// }
// 3) For each attachment (one MCP call per file, ≤25 MB each):
{
@ -546,6 +571,62 @@ POST /api/mcp
// flip is_processed before any /pdf render.
```
### Add a missing line item to an existing quote, then price it
```jsonc
// Scenario: `quotes.create_from_email` only picked up 2 of the 3 items
// from the RFQ. Add the third one, then price the whole quote.
// 1) Create the missing line item.
POST /api/mcp { "method": "tools/call", "params": {
"name": "quote_items.create",
"arguments": {
"quote_id": "<quote.id>",
"description": "Coolant 5L",
"quantity": 2,
"unit": "each",
"sku": "COOL-5L",
"cost_price": 145.00,
"markup": 33
}
}}
// → { "item": { "id": "qi-new", "is_processed": false, "selling_price": 0, … } }
// Note: `selling_price` is 0 — `cost_price` and `markup` were stored,
// but the row needs re-pricing to roll them up.
// 2) Pull every item id under the quote (the new one + the originals).
POST /api/mcp { "method": "tools/call", "params": {
"name": "quote_items.list",
"arguments": { "filter": "quote=\"<quote.id>\"", "perPage": 50 }
}}
// → { "items": [ { "id": "qi-1", … }, { "id": "qi-2", … }, { "id": "qi-new", … } ] }
// 3) Bulk-price them in one round-trip (dry-run first).
POST /api/mcp { "method": "tools/call", "params": {
"name": "bulk_update_quote_items",
"arguments": {
"dry_run": true,
"items": [
{ "id": "qi-1", "patch": { "cost_price": 1200, "markup": 33, "pricing_notes": "AI 2026-08-04" } },
{ "id": "qi-2", "patch": { "cost_price": 450, "markup": 33, "pricing_notes": "AI 2026-08-04" } },
{ "id": "qi-new", "patch": { "cost_price": 145, "markup": 33, "pricing_notes": "AI 2026-08-04" } }
]
}
}}
// If every row came back status="dry_run", re-run with dry_run=false.
// 4) Re-price the parent quote so selling_price = cost × (1 + markup/100)
// is written back on each row.
POST /api/mcp { "method": "tools/call", "params": {
"name": "quotes.re_price",
"arguments": { "id": "<quote.id>" }
}}
// ⚠️ `is_processed` is still false on every row. A human must flip it
// in the operator UI before any /api/v1/quotes/{id}/pdf render will
// succeed. This is intentional — see Architectural gaps §2.
```
### Maintain a supplier
```jsonc
@ -646,4 +727,5 @@ The same four places for the bearer, two for the n8n key. Out-of-sync credential
## Change log
- **2026-08-04** — Initial live-server smoke test. Discovered and documented all six known bugs, the four architectural gaps, the missing webhooks/n8n tokens. Fixed `tools.discovery` source (`src/app/api/mcp/route.ts:2497`) — **deployment pending**, the live server still returned "Unknown tool" until rebuild. Replaced stale `wfdash.davidels.dev` base URL with `wfdash.apphub.co.za`. Removed `quotes.items.rewrite` from the MCP tool families — it was always a REST endpoint. Catalog count is **69** (unchanged — `tools.discovery` was already declared; only its runtime behavior changed from "Unknown tool" to functional).
- **2026-08-04 (post-redeploy)** — Verified live: `tools.discovery` now returns the flat 69-tool catalog via JSON-RPC; `GET /api/mcp` still works; the other five bugs (`collection_pool.list`, `sprint.list`, `quotes.lookup`, `orders.list` filter, `quotes.items.rewrite` as MCP) reproduce exactly as documented, confirming the skill doc is accurate. Two PDF regenerations on `Q000087` succeeded (ISHERWOOD + CNL letterheads) — `pdf_generated_at` stays null because the quote is in a terminal `approved` state.
- **2026-08-04 (post-redeploy)** — Verified live: `tools.discovery` now returns the flat 69-tool catalog via JSON-RPC; `GET /api/mcp` still works; the other five bugs (`collection_pool.list`, `sprint.list`, `quotes.lookup`, `orders.list` filter, `quotes.items.rewrite` as MCP) reproduce exactly as documented, confirming the skill doc is accurate. Two PDF regenerations on `Q000087` succeeded (ISHERWOOD + CNL letterheads) — `pdf_generated_at` stays null because the quote is in a terminal `approved` state.
- **2026-08-04 (quote_items CRUD shipped)** — Added four MCP tools + three REST routes to close architectural gap §1: `quote_items.list`, `quote_items.get`, `quote_items.create`, `quote_items.update` (MCP), and `GET / POST /api/v1/quote_items` + `GET / PATCH /api/v1/quote_items/{id}`. Same patch whitelist as `bulk_update_quote_items` (`cost_price, selling_price, description, sku, unit, supplier, markup, pricing_source, pricing_notes, matched_price_list_id, user_edited`). `quote_items.update` does NOT enforce the `already_priced` skip — use `bulk_update_quote_items` when you want the safety net. Newly-created rows have `is_processed: false` forced server-side; Giellie still cannot flip that flag on its own (gap §2 unchanged). **Removed `JEI` from `quotes.company_letterhead` select values on the live PB** — valid values are now `["CNL", "ISHERWOOD"]`. Catalog count **73** (+4). Deployment of these changes is pending on the operator's rebuild + redeploy; until then `tools/list` will not surface the new tools and `POST /api/v1/quote_items` will return 404.

View file

@ -2,12 +2,13 @@ import { describe, it, expect, beforeEach, vi } from "vitest";
import { NextRequest } from "next/server";
const getList = vi.fn();
const getOne = vi.fn();
const create = vi.fn();
const update = vi.fn();
vi.mock("@/lib/server/pocketbase-admin", () => ({
getAdminPocketBase: vi.fn().mockResolvedValue({
collection: () => ({ getList, create, update }),
collection: () => ({ getList, getOne, create, update }),
}),
}));
@ -66,6 +67,7 @@ describe("POST /api/mcp", () => {
beforeEach(() => {
process.env.AGENT_API_KEY = "t";
getList.mockReset();
getOne.mockReset();
create.mockReset();
update.mockReset();
createOrderFromQuote.mockReset();
@ -151,6 +153,11 @@ describe("POST /api/mcp", () => {
"purchase_orders.record_send_outcome",
// Bulk quote_items (added 2026-07-02 — AI auto-fill agents)
"bulk_update_quote_items",
// quote_items CRUD (added 2026-08-04 — AI auto-fill agents)
"quote_items.list",
"quote_items.get",
"quote_items.create",
"quote_items.update",
// Stock items + purchases (added 2026-07-30 — Hermes + n8n coverage)
"stock_items.list",
"stock_items.get",
@ -1193,4 +1200,316 @@ describe("POST /api/mcp", () => {
expect(parsed.errors[0].reason).toMatch(/cost_price/);
});
});
// ─── quote_items.list ──────────────────────────────────────────────────
describe("quote_items.list", () => {
it("forwards page/perPage/filter/sort/fields/expand to PocketBase", async () => {
getList.mockResolvedValueOnce({ items: [{ id: "qi1" }], totalItems: 1 });
const res = await POST(
mcpRequest({
method: "tools/call",
id: 300,
params: {
name: "quote_items.list",
arguments: {
page: 2,
perPage: 25,
filter: 'quote="q1"',
sort: "created",
fields: "id,description",
expand: "quote,supplier",
},
},
})
);
expect(res.status).toBe(200);
expect(getList).toHaveBeenCalledWith(2, 25, {
filter: 'quote="q1"',
sort: "created",
fields: "id,description",
expand: "quote,supplier",
});
});
it("clamps perPage at 500", async () => {
getList.mockResolvedValueOnce({ items: [] });
await POST(
mcpRequest({
method: "tools/call",
id: 301,
params: { name: "quote_items.list", arguments: { perPage: 9999 } },
}),
);
expect(getList).toHaveBeenCalledWith(1, 500, expect.objectContaining({}));
});
it("uses -created as the default sort", async () => {
getList.mockResolvedValueOnce({ items: [] });
await POST(
mcpRequest({
method: "tools/call",
id: 302,
params: { name: "quote_items.list", arguments: {} },
}),
);
expect(getList).toHaveBeenCalledWith(1, 50, expect.objectContaining({ sort: "-created" }));
});
});
// ─── quote_items.get ───────────────────────────────────────────────────
describe("quote_items.get", () => {
it("returns the row and forwards expand/fields", async () => {
getOne.mockResolvedValueOnce({ id: "qi1", description: "x" });
const res = await POST(
mcpRequest({
method: "tools/call",
id: 310,
params: {
name: "quote_items.get",
arguments: { id: "qi1", expand: "quote,supplier", fields: "id,description" },
},
}),
);
expect(res.status).toBe(200);
expect(getOne).toHaveBeenCalledWith("qi1", {
expand: "quote,supplier",
fields: "id,description",
});
const parsed = JSON.parse((await res.json()).result.content[0].text);
expect(parsed.item.id).toBe("qi1");
});
it("fails fast when id is missing", async () => {
const res = await POST(
mcpRequest({
method: "tools/call",
id: 311,
params: { name: "quote_items.get", arguments: {} },
}),
);
expect(res.status).toBe(500);
expect((await res.json()).error.message).toMatch(/Missing id/);
});
});
// ─── quote_items.create ────────────────────────────────────────────────
describe("quote_items.create", () => {
it("validates quote_id is present", async () => {
const res = await POST(
mcpRequest({
method: "tools/call",
id: 320,
params: { name: "quote_items.create", arguments: { description: "x" } },
}),
);
expect(res.status).toBe(500);
expect((await res.json()).error.message).toMatch(/Missing quote_id/);
});
it("rejects empty/whitespace description", async () => {
const res = await POST(
mcpRequest({
method: "tools/call",
id: 321,
params: {
name: "quote_items.create",
arguments: { quote_id: "q1", description: " " },
},
}),
);
expect(res.status).toBe(500);
expect((await res.json()).error.message).toMatch(/description is required/);
});
it("rejects non-positive quantity", async () => {
const res = await POST(
mcpRequest({
method: "tools/call",
id: 322,
params: {
name: "quote_items.create",
arguments: { quote_id: "q1", description: "x", quantity: 0 },
},
}),
);
expect(res.status).toBe(500);
expect((await res.json()).error.message).toMatch(/quantity must be/);
});
it("surfaces a clean error when parent quote is missing", async () => {
getOne.mockRejectedValueOnce(new Error("not found"));
const res = await POST(
mcpRequest({
method: "tools/call",
id: 323,
params: {
name: "quote_items.create",
arguments: { quote_id: "missing", description: "x" },
},
}),
);
expect(res.status).toBe(500);
expect((await res.json()).error.message).toMatch(/quote missing not found/);
expect(create).not.toHaveBeenCalled();
});
it("creates with defaults and denormalises quote_number from parent", async () => {
getOne.mockResolvedValueOnce({ id: "q1", quote_number: "Q-2026-08-001" });
create.mockResolvedValueOnce({ id: "qi1" });
const res = await POST(
mcpRequest({
method: "tools/call",
id: 324,
params: {
name: "quote_items.create",
arguments: { quote_id: "q1", description: "Cable tie 100mm" },
},
}),
);
expect(res.status).toBe(200);
expect(getOne).toHaveBeenCalledWith("q1", { fields: "id,quote_number" });
expect(create).toHaveBeenCalledWith(
expect.objectContaining({
quote: "q1",
quote_number: "Q-2026-08-001",
description: "Cable tie 100mm",
quantity: 1,
cost_price: 0,
selling_price: 0,
markup: 0,
sku: "",
unit: "",
supplier: null,
is_processed: false,
pricing_source: "manual",
}),
);
});
it("passes through optional fields when supplied", async () => {
getOne.mockResolvedValueOnce({ id: "q1", quote_number: "Q-2026-08-001" });
create.mockResolvedValueOnce({ id: "qi1" });
await POST(
mcpRequest({
method: "tools/call",
id: 325,
params: {
name: "quote_items.create",
arguments: {
quote_id: "q1",
description: "M16 bolt",
quantity: 25,
unit: "each",
sku: "M16-50",
supplier: "sup1",
cost_price: 12.5,
selling_price: 25,
markup: 100,
pricing_source: "price_list",
pricing_notes: "AI 2026-08-04",
},
},
}),
);
expect(create).toHaveBeenCalledWith(
expect.objectContaining({
supplier: "sup1",
cost_price: 12.5,
selling_price: 25,
markup: 100,
pricing_source: "price_list",
}),
);
});
});
// ─── quote_items.update ────────────────────────────────────────────────
describe("quote_items.update", () => {
it("fails fast when id is missing", async () => {
const res = await POST(
mcpRequest({
method: "tools/call",
id: 330,
params: { name: "quote_items.update", arguments: { patch: { cost_price: 10 } } },
}),
);
expect(res.status).toBe(500);
expect((await res.json()).error.message).toMatch(/Missing id/);
});
it("fails fast when patch is missing or non-object", async () => {
const res = await POST(
mcpRequest({
method: "tools/call",
id: 331,
params: { name: "quote_items.update", arguments: { id: "qi1", patch: [1, 2] } },
}),
);
expect(res.status).toBe(500);
expect((await res.json()).error.message).toMatch(/Missing or invalid/);
});
it("rejects forbidden fields and lists them in the error", async () => {
const res = await POST(
mcpRequest({
method: "tools/call",
id: 332,
params: {
name: "quote_items.update",
arguments: {
id: "qi1",
patch: { id: "x", created: "y", cost_price: 10, quantity: 99 },
},
},
}),
);
expect(res.status).toBe(500);
const body = await res.json();
expect(body.error.message).toMatch(/forbidden_field\(s\): id, created, quantity/);
expect(update).not.toHaveBeenCalled();
});
it("rejects empty patches", async () => {
const res = await POST(
mcpRequest({
method: "tools/call",
id: 333,
params: {
name: "quote_items.update",
arguments: { id: "qi1", patch: {} },
},
}),
);
expect(res.status).toBe(500);
expect((await res.json()).error.message).toMatch(/empty after whitelist/);
expect(update).not.toHaveBeenCalled();
});
it("updates with whitelisted fields only", async () => {
update.mockResolvedValueOnce({ id: "qi1" });
const res = await POST(
mcpRequest({
method: "tools/call",
id: 334,
params: {
name: "quote_items.update",
arguments: {
id: "qi1",
patch: {
cost_price: 12.5,
markup: 33,
pricing_notes: "AI 2026-08-04",
},
},
},
}),
);
expect(res.status).toBe(200);
expect(update).toHaveBeenCalledWith("qi1", {
cost_price: 12.5,
markup: 33,
pricing_notes: "AI 2026-08-04",
});
});
});
});

View file

@ -694,6 +694,93 @@ const tools = [
},
},
},
// ─── quote_items CRUD (added 2026-08-04) ───────────────────────────────
// The pre-existing `bulk_update_quote_items` tool only updates rows
// that already exist. AI auto-fill agents need to:
// 1. List items under a quote to see what's there (quote_items.list)
// 2. Fetch one by id to inspect it (quote_items.get)
// 3. Add a missing line item after the intake (quote_items.create)
// 4. Patch a single row when bulk is overkill (quote_items.update)
// These four tools + the existing bulk tool cover the full agent loop.
// Patch whitelist on update is identical to bulk_update_quote_items.
{
name: "quote_items.list",
description:
"List `quote_items` rows. Pass PB filter syntax in `filter` (e.g. `quote=\"<quote_id>\"`, `sku~\"M16\"`, `supplier=\"<id>\"`). Default sort `-created`.",
inputSchema: {
type: "object",
properties: {
page: { type: "number" },
perPage: { type: "number" },
filter: { type: "string" },
sort: { type: "string" },
fields: { type: "string" },
expand: { type: "string" },
},
},
},
{
name: "quote_items.get",
description:
"Fetch a single `quote_items` row by id. Pass `expand` to inline relations (e.g. `quote,supplier`).",
inputSchema: {
type: "object",
required: ["id"],
properties: {
id: { type: "string" },
expand: { type: "string" },
fields: { type: "string" },
},
},
},
{
name: "quote_items.create",
description:
"Create a single `quote_items` row on an existing quote. Use this AFTER `quotes.create_from_email` when the AI extraction missed a line, or to seed items manually. Mirrors the body shape of the REST `POST /api/v1/quote_items`.\n\n" +
"Notes:\n" +
"- `quote_id` is required; the parent quote must already exist (the tool does NOT create the quote).\n" +
"- `description` is required and trimmed; empty/whitespace values are rejected.\n" +
"- `quantity` defaults to 1; must be > 0 when supplied.\n" +
"- All pricing fields default to 0; the caller is expected to call `quotes.re_price` afterwards if margins were set on cost.\n" +
"- `is_processed` is forced to `false` server-side — newly created rows are drafts.\n" +
"- `quote_number` is denormalised from the parent quote automatically; do not pass it.",
inputSchema: {
type: "object",
required: ["quote_id", "description"],
properties: {
quote_id: { type: "string", description: "Parent quote id (PB 15-char)." },
description: { type: "string" },
quantity: { type: "number" },
unit: { type: "string" },
sku: { type: "string" },
supplier: { type: "string", description: "Supplier relation id (optional)." },
cost_price: { type: "number" },
selling_price: { type: "number" },
markup: { type: "number", description: "Margin %, defaults to 0." },
notes: { type: "string" },
pricing_source: { type: "string" },
pricing_notes: { type: "string" },
},
},
},
{
name: "quote_items.update",
description:
"Patch a single `quote_items` row. Patch whitelist is identical to `bulk_update_quote_items`: cost_price, selling_price, description, sku, unit, supplier, markup, pricing_source, pricing_notes, matched_price_list_id, user_edited. Any other key (including `id`, `quote`, `created`, `updated`, `quantity`) is rejected with reason=`forbidden_field`.\n\n" +
"Unlike `bulk_update_quote_items`, this tool does NOT enforce the hand-edit guard (`already_priced` skip). Use this endpoint for explicit single-row writes; use bulk when you want the safety net.",
inputSchema: {
type: "object",
required: ["id", "patch"],
properties: {
id: { type: "string", description: "The 15-char `quote_items` id." },
patch: {
type: "object",
description:
"Subset of {cost_price, selling_price, description, sku, unit, supplier, markup, pricing_source, pricing_notes, matched_price_list_id, user_edited}.",
},
},
},
},
// ─── Stock items + stock purchases + sprint + buyer tasks + stock fulfillment + AI insights ─────
// All added 2026-07-30 to fill the Hermes + n8n coverage gap. Order
// is: stock_items → stock_purchases → sprint → buyer_tasks →
@ -1656,6 +1743,127 @@ export async function POST(req: NextRequest) {
});
}
// ─── quote_items CRUD (added 2026-08-04) ───────────────────────────
// These mirror the four REST endpoints at /api/v1/quote_items so the
// MCP tool surface and the REST surface stay in lock-step. The
// patch whitelist here intentionally matches the one enforced by
// `bulk_update_quote_items` and `/api/v1/quote_items/[id]` — keep
// all three in sync if a new field is added.
const QUOTE_ITEM_MCP_PATCH_FIELDS = [
"cost_price",
"selling_price",
"description",
"sku",
"unit",
"supplier",
"markup",
"pricing_source",
"pricing_notes",
"matched_price_list_id",
"user_edited",
] as const;
if (toolName === "quote_items.list") {
const page = Number(args.page || 1);
const perPage = Math.min(500, Number(args.perPage || 50));
const result = await pb.collection("quote_items").getList(page, perPage, {
filter: typeof args.filter === "string" ? args.filter : "",
sort: typeof args.sort === "string" ? args.sort : "-created",
fields: typeof args.fields === "string" ? args.fields : undefined,
expand: typeof args.expand === "string" ? args.expand : undefined,
});
return jsonRpcOk(body.id, result);
}
if (toolName === "quote_items.get") {
if (!args.id) throw new Error("Missing id for quote_items.get.");
const item = await pb.collection("quote_items").getOne(args.id, {
expand: typeof args.expand === "string" ? args.expand : undefined,
fields: typeof args.fields === "string" ? args.fields : undefined,
});
return jsonRpcOk(body.id, { item });
}
if (toolName === "quote_items.create") {
const quoteId = typeof args.quote_id === "string" ? args.quote_id.trim() : "";
const description = typeof args.description === "string" ? args.description.trim() : "";
if (!quoteId) throw new Error("Missing quote_id for quote_items.create.");
if (!description) throw new Error("description is required (non-empty after trim).");
// quantity: must be > 0 when supplied; default 1
let quantity = 1;
if (args.quantity !== undefined && args.quantity !== null) {
const q = Number(args.quantity);
if (!Number.isFinite(q) || q <= 0) {
throw new Error("quantity must be a finite number > 0.");
}
quantity = q;
}
// Resolve parent quote for the denormalised quote_number.
// PB rejects the create with a relation error if the quote id
// doesn't exist, but a clean "quote not found" surface is much
// easier for agents to recover from.
let quoteNumber = "";
try {
const parent = await pb.collection("quotes").getOne(quoteId, {
fields: "id,quote_number",
});
quoteNumber = String(parent.quote_number || "");
} catch (err) {
throw new Error(
`quote ${quoteId} not found: ${err instanceof Error ? err.message : String(err)}`,
);
}
const created = await pb.collection("quote_items").create({
quote: quoteId,
quote_number: quoteNumber,
description,
quantity,
cost_price: Number(args.cost_price) || 0,
selling_price: Number(args.selling_price) || 0,
markup: Number(args.markup) || 0,
sku: typeof args.sku === "string" ? args.sku : "",
unit: typeof args.unit === "string" ? args.unit : "",
supplier:
typeof args.supplier === "string" && args.supplier ? args.supplier : null,
notes: typeof args.notes === "string" ? args.notes : "",
pricing_source:
typeof args.pricing_source === "string" ? args.pricing_source : "manual",
pricing_notes: typeof args.pricing_notes === "string" ? args.pricing_notes : "",
is_processed: false,
});
return jsonRpcOk(body.id, { item: created });
}
if (toolName === "quote_items.update") {
if (!args.id) throw new Error("Missing id for quote_items.update.");
if (!args.patch || typeof args.patch !== "object" || Array.isArray(args.patch)) {
throw new Error("Missing or invalid `patch` for quote_items.update.");
}
const rawPatch = args.patch as Record<string, unknown>;
const patch: Record<string, unknown> = {};
const rejected: string[] = [];
for (const [k, v] of Object.entries(rawPatch)) {
if ((QUOTE_ITEM_MCP_PATCH_FIELDS as readonly string[]).includes(k)) {
patch[k] = v;
} else {
rejected.push(k);
}
}
if (rejected.length > 0) {
throw new Error(
`forbidden_field(s): ${rejected.join(", ")}. Allowed: ${QUOTE_ITEM_MCP_PATCH_FIELDS.join(", ")}.`,
);
}
if (Object.keys(patch).length === 0) {
throw new Error("Patch is empty after whitelist.");
}
const updated = await pb.collection("quote_items").update(args.id, patch);
return jsonRpcOk(body.id, { item: updated });
}
// ─── Orders (extra verbs) ────────────────────────────────────
if (toolName === "orders.get") {
if (!args.id) throw new Error("Missing id for orders.get.");

View file

@ -0,0 +1,128 @@
import { describe, it, expect, beforeEach, vi } from "vitest";
import { NextRequest } from "next/server";
const getOne = vi.fn();
const update = vi.fn();
vi.mock("@/lib/server/pocketbase-admin", () => ({
getAdminPocketBase: vi.fn().mockResolvedValue({
collection: () => ({ getOne, update }),
}),
}));
import { GET, PATCH } from "./route";
function getURL(id: string, qs: Record<string, string> = {}) {
const url = new URL(`http://localhost/api/v1/quote_items/${id}`);
for (const [k, v] of Object.entries(qs)) url.searchParams.set(k, v);
return new NextRequest(url, { headers: { authorization: "Bearer t" } });
}
function patchJSON(id: string, body: unknown) {
return new NextRequest(`http://localhost/api/v1/quote_items/${id}`, {
method: "PATCH",
headers: { authorization: "Bearer t", "content-type": "application/json" },
body: JSON.stringify(body),
});
}
const PARAMS = (id: string) => ({ params: Promise.resolve({ id }) });
describe("GET /api/v1/quote_items/[id]", () => {
beforeEach(() => {
process.env.AGENT_API_KEY = "t";
getOne.mockReset();
getOne.mockResolvedValue({ id: "qi1", description: "x" });
});
it("401s without bearer token", async () => {
const req = new NextRequest("http://localhost/api/v1/quote_items/qi1");
const res = await GET(req, PARAMS("qi1"));
expect(res.status).toBe(401);
});
it("returns the row + forwards expand/fields", async () => {
const res = await GET(
getURL("qi1", { expand: "quote,supplier", fields: "id,description" }),
PARAMS("qi1"),
);
expect(res.status).toBe(200);
expect(getOne).toHaveBeenCalledWith("qi1", {
expand: "quote,supplier",
fields: "id,description",
});
});
});
describe("PATCH /api/v1/quote_items/[id]", () => {
beforeEach(() => {
process.env.AGENT_API_KEY = "t";
update.mockReset();
update.mockResolvedValue({ id: "qi1" });
});
it("401s without bearer token", async () => {
const req = new NextRequest("http://localhost/api/v1/quote_items/qi1", {
method: "PATCH",
body: JSON.stringify({ cost_price: 10 }),
});
const res = await PATCH(req, PARAMS("qi1"));
expect(res.status).toBe(401);
});
it("400s on non-object body", async () => {
const res = await PATCH(patchJSON("qi1", [1, 2, 3]), PARAMS("qi1"));
expect(res.status).toBe(400);
expect((await res.json()).error).toMatch(/JSON object/);
});
it("400s on forbidden fields (returns the whitelist)", async () => {
const res = await PATCH(
patchJSON("qi1", { id: "new-id", cost_price: 10, created: "x" }),
PARAMS("qi1"),
);
expect(res.status).toBe(400);
const body = await res.json();
expect(body.error).toMatch(/forbidden_field\(s\): id, created/);
expect(Array.isArray(body.allowed)).toBe(true);
expect(body.allowed).toContain("cost_price");
expect(update).not.toHaveBeenCalled();
});
it("400s when patch is empty (only forbidden keys provided)", async () => {
const res = await PATCH(
patchJSON("qi1", { id: "new-id", quote: "new-quote" }),
PARAMS("qi1"),
);
expect(res.status).toBe(400);
expect(update).not.toHaveBeenCalled();
});
it("rejects quantity (it is not in the whitelist)", async () => {
const res = await PATCH(
patchJSON("qi1", { quantity: 99 }),
PARAMS("qi1"),
);
expect(res.status).toBe(400);
expect((await res.json()).error).toMatch(/forbidden_field/);
expect(update).not.toHaveBeenCalled();
});
it("updates with whitelisted fields", async () => {
const res = await PATCH(
patchJSON("qi1", { cost_price: 12.5, markup: 33, pricing_notes: "AI 2026-08-04" }),
PARAMS("qi1"),
);
expect(res.status).toBe(200);
expect(update).toHaveBeenCalledWith("qi1", {
cost_price: 12.5,
markup: 33,
pricing_notes: "AI 2026-08-04",
});
});
it("404s when PB says the id is missing", async () => {
update.mockRejectedValue(new Error("Failed to update record."));
const res = await PATCH(patchJSON("ghost", { cost_price: 10 }), PARAMS("ghost"));
expect(res.status).toBe(404);
});
});

View file

@ -0,0 +1,133 @@
import { NextRequest, NextResponse } from "next/server";
import { assertAgentAuth } from "@/lib/server/agent-auth";
import { getAdminPocketBase } from "@/lib/server/pocketbase-admin";
/**
* GET /api/v1/quote_items/[id] — fetch a single quote_item
* PATCH /api/v1/quote_items/[id] — patch a quote_item (whitelisted fields)
*
* Auth: same as the rest of `/api/v1` — `Authorization: Bearer <AGENT_API_KEY>`.
*
* PATCH body shape:
* Same as the `patch` object on `bulk_update_quote_items` — a subset of
* `{cost_price, selling_price, description, sku, unit, supplier,
* markup, pricing_source, pricing_notes, matched_price_list_id,
* user_edited}`. Anything else (including `id`, `quote`, `created`,
* `updated`, `quantity`) is rejected with 400 + reason=`forbidden_field`.
*
* The whitelist is the same one used by `bulk_update_quote_items` and
* the existing `/api/v1/quotes/[id]/re-price` route. Centralising the
* list keeps the three writers in lock-step.
*
* This single-row endpoint is the natural fit for the AI auto-fill
* agent's "patch one item" loop, where the bulk endpoint would be
* overkill for a single correction. Hand-edit guard is intentionally
* NOT enforced here — this endpoint is for explicit per-row writes
* where the caller has already decided to overwrite. Use
* `bulk_update_quote_items` if you need the `already_priced` skip
* behaviour.
*/
export const runtime = "nodejs";
export const dynamic = "force-dynamic";
const PATCHABLE_FIELDS = [
"cost_price",
"selling_price",
"description",
"sku",
"unit",
"supplier",
"markup",
"pricing_source",
"pricing_notes",
"matched_price_list_id",
"user_edited",
] as const;
export async function GET(
_req: NextRequest,
{ params }: { params: Promise<{ id: string }> },
) {
try {
assertAgentAuth(_req);
const { id } = await params;
if (!id) {
return NextResponse.json({ error: "Missing id" }, { status: 400 });
}
const pb = await getAdminPocketBase();
const expand = _req.nextUrl.searchParams.get("expand") || undefined;
const fields = _req.nextUrl.searchParams.get("fields") || undefined;
const item = await pb.collection("quote_items").getOne(id, { expand, fields });
return NextResponse.json(item);
} catch (error) {
const message = errMsg(error, "Failed to fetch quote_item");
const status = errStatus(error, 500);
return NextResponse.json({ error: message }, { status });
}
}
export async function PATCH(
req: NextRequest,
{ params }: { params: Promise<{ id: string }> },
) {
try {
assertAgentAuth(req);
const { id } = await params;
if (!id) {
return NextResponse.json({ error: "Missing id" }, { status: 400 });
}
const body = await req.json().catch(() => null) as Record<string, unknown> | null;
if (!body || typeof body !== "object" || Array.isArray(body)) {
return NextResponse.json({ error: "Body must be a JSON object" }, { status: 400 });
}
const patch: Record<string, unknown> = {};
const rejected: string[] = [];
for (const [k, v] of Object.entries(body)) {
if ((PATCHABLE_FIELDS as readonly string[]).includes(k)) {
patch[k] = v;
} else {
rejected.push(k);
}
}
if (rejected.length > 0) {
return NextResponse.json(
{
error: `forbidden_field(s): ${rejected.join(", ")}`,
allowed: [...PATCHABLE_FIELDS],
},
{ status: 400 },
);
}
if (Object.keys(patch).length === 0) {
return NextResponse.json({ error: "Patch is empty" }, { status: 400 });
}
const pb = await getAdminPocketBase();
const updated = await pb.collection("quote_items").update(id, patch);
return NextResponse.json(updated);
} catch (error) {
const message = errMsg(error, "Failed to update quote_item");
// PB surfaces a not-found relation as a generic 400 with
// "Failed to update record." — bubble that up as 404 so the
// caller can distinguish "id doesn't exist" from "PB rejected
// the patch".
const status =
/Failed to update record|not found/i.test(message) ? 404 : errStatus(error, 500);
return NextResponse.json({ error: message }, { status });
}
}
function errMsg(error: unknown, fallback: string): string {
return error instanceof Error ? error.message : fallback;
}
function errStatus(error: unknown, fallback: number): number {
return (error as Error & { status?: number }).status || fallback;
}

View file

@ -0,0 +1,182 @@
import { describe, it, expect, beforeEach, vi } from "vitest";
import { NextRequest } from "next/server";
const getList = vi.fn();
const create = vi.fn();
const getOne = vi.fn();
vi.mock("@/lib/server/pocketbase-admin", () => ({
getAdminPocketBase: vi.fn().mockResolvedValue({
collection: (name: string) => {
if (name === "quotes") return { getOne };
return { getList, create };
},
}),
}));
import { GET, POST } from "./route";
function getURL(qs: Record<string, string> = {}) {
const url = new URL("http://localhost/api/v1/quote_items");
for (const [k, v] of Object.entries(qs)) url.searchParams.set(k, v);
return new NextRequest(url, {
headers: { authorization: "Bearer t" },
});
}
function postJSON(body: unknown) {
return new NextRequest("http://localhost/api/v1/quote_items", {
method: "POST",
headers: { authorization: "Bearer t", "content-type": "application/json" },
body: JSON.stringify(body),
});
}
describe("GET /api/v1/quote_items", () => {
beforeEach(() => {
process.env.AGENT_API_KEY = "t";
getList.mockReset();
});
it("401s without bearer token", async () => {
const req = new NextRequest("http://localhost/api/v1/quote_items");
const res = await GET(req);
expect(res.status).toBe(401);
});
it("lists with sane defaults and forwards filter/sort/expand", async () => {
getList.mockResolvedValue({ items: [], totalItems: 0, page: 1, perPage: 50 });
const res = await GET(
getURL({ filter: 'sku="ABC"', sort: "created", expand: "quote,supplier" }),
);
expect(res.status).toBe(200);
expect(getList).toHaveBeenCalledWith(1, 50, {
sort: "created",
filter: 'sku="ABC"',
fields: undefined,
expand: "quote,supplier",
});
});
it("clamps perPage at 500", async () => {
getList.mockResolvedValue({ items: [], totalItems: 0 });
await GET(getURL({ perPage: "9999" }));
expect(getList).toHaveBeenCalledWith(1, 500, expect.objectContaining({}));
});
});
describe("POST /api/v1/quote_items", () => {
beforeEach(() => {
process.env.AGENT_API_KEY = "t";
getOne.mockReset();
create.mockReset();
getOne.mockResolvedValue({ id: "q123", quote_number: "Q-2026-08-001" });
create.mockResolvedValue({ id: "qi1", quote: "q123", description: "x" });
});
it("401s without bearer token", async () => {
const req = new NextRequest("http://localhost/api/v1/quote_items", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ quote_id: "q123", description: "x" }),
});
const res = await POST(req);
expect(res.status).toBe(401);
});
it("400s when body is not JSON", async () => {
const req = new NextRequest("http://localhost/api/v1/quote_items", {
method: "POST",
headers: { authorization: "Bearer t", "content-type": "application/json" },
body: "not-json",
});
const res = await POST(req);
expect(res.status).toBe(400);
});
it("400s when quote_id is missing", async () => {
const res = await POST(postJSON({ description: "x" }));
expect(res.status).toBe(400);
const body = await res.json();
expect(body.error).toMatch(/quote_id is required/);
});
it("400s when description is empty/whitespace", async () => {
const res = await POST(postJSON({ quote_id: "q123", description: " " }));
expect(res.status).toBe(400);
const body = await res.json();
expect(body.error).toMatch(/description is required/);
});
it("400s when quantity is non-positive", async () => {
const res = await POST(
postJSON({ quote_id: "q123", description: "x", quantity: 0 }),
);
expect(res.status).toBe(400);
expect((await res.json()).error).toMatch(/quantity must be/);
});
it("404s when parent quote doesn't exist", async () => {
getOne.mockRejectedValue(new Error("Failed to create record."));
const res = await POST(postJSON({ quote_id: "missing", description: "x" }));
expect(res.status).toBe(404);
expect(create).not.toHaveBeenCalled();
});
it("creates with defaults (quantity=1, all prices=0, is_processed=false)", async () => {
const res = await POST(postJSON({ quote_id: "q123", description: "Cable tie 100mm" }));
expect(res.status).toBe(201);
expect(getOne).toHaveBeenCalledWith("q123", { fields: "id,quote_number" });
expect(create).toHaveBeenCalledWith(
expect.objectContaining({
quote: "q123",
quote_number: "Q-2026-08-001",
description: "Cable tie 100mm",
quantity: 1,
cost_price: 0,
selling_price: 0,
markup: 0,
sku: "",
unit: "",
supplier: null,
is_processed: false,
pricing_source: "manual",
}),
);
});
it("passes through all optional fields when supplied", async () => {
await POST(
postJSON({
quote_id: "q123",
description: "M16 bolt",
quantity: 25,
unit: "each",
sku: "M16-50",
supplier: "sup456",
cost_price: 12.5,
selling_price: 25,
markup: 100,
notes: "Grade 8.8",
pricing_source: "price_list",
pricing_notes: "from PL rev 2026-08",
}),
);
expect(create).toHaveBeenCalledWith(
expect.objectContaining({
quote: "q123",
description: "M16 bolt",
quantity: 25,
unit: "each",
sku: "M16-50",
supplier: "sup456",
cost_price: 12.5,
selling_price: 25,
markup: 100,
notes: "Grade 8.8",
pricing_source: "price_list",
pricing_notes: "from PL rev 2026-08",
}),
);
});
});

View file

@ -0,0 +1,153 @@
import { NextRequest, NextResponse } from "next/server";
import { assertAgentAuth } from "@/lib/server/agent-auth";
import { getAdminPocketBase } from "@/lib/server/pocketbase-admin";
/**
* GET /api/v1/quote_items — list quote_items
* POST /api/v1/quote_items — create a single quote_item on a quote
*
* Auth: same as the rest of `/api/v1` — `Authorization: Bearer <AGENT_API_KEY>`.
*
* Query params (GET):
* page, perPage — pagination (defaults 1 / 50, `perPage` max 500)
* sort — PocketBase sort expression (default `-created`)
* filter — PocketBase filter expression (passed through unchanged)
* fields — comma-separated PB field projection
* expand — comma-separated list of relation fields to inline
*
* Body (POST):
* {
* quote_id: string (required — the parent quote's id)
* description: string (required, trimmed; rows with empty description are rejected)
* quantity: number? (default 1; must be > 0 when provided)
* unit: string? (e.g. "each", "pack", "m"; defaults to "")
* sku: string? (defaults to "")
* supplier: string? (relation id; defaults to null)
* cost_price: number? (default 0)
* selling_price: number? (default 0)
* markup: number? (default 0 — interpreted as %)
* notes: string? (defaults to "")
* pricing_source: string? (defaults to "manual")
* pricing_notes: string? (defaults to "")
* }
*
* The handler deliberately does NOT auto-fill pricing from cost × markup;
* the existing `/api/v1/quotes/[id]/re-price` route and the
* `quotes.re_price` MCP tool do that, and this endpoint is for raw line
* creation. If a caller passes `cost_price` but not `selling_price`,
* `selling_price` defaults to 0 (caller is expected to re-price).
*
* Mirrors `/api/v1/order-items` shape (list only at the bare route for
* order-items; we add POST here because line items are commonly created
* one at a time by AI auto-fill agents after `quotes.create_from_email`).
*/
export const runtime = "nodejs";
export const dynamic = "force-dynamic";
export async function GET(req: NextRequest) {
try {
assertAgentAuth(req);
const pb = await getAdminPocketBase();
const page = Number(req.nextUrl.searchParams.get("page") || "1");
const perPage = Math.min(
500,
Number(req.nextUrl.searchParams.get("perPage") || "50"),
);
const sort = req.nextUrl.searchParams.get("sort") || "-created";
const filter = req.nextUrl.searchParams.get("filter") || "";
const fields = req.nextUrl.searchParams.get("fields") || undefined;
const expand = req.nextUrl.searchParams.get("expand") || undefined;
const result = await pb
.collection("quote_items")
.getList(page, perPage, { sort, filter, fields, expand });
return NextResponse.json(result);
} catch (error) {
const message =
error instanceof Error ? error.message : "Failed to fetch quote_items";
const status = (error as Error & { status?: number }).status || 500;
return NextResponse.json({ error: message }, { status });
}
}
export async function POST(req: NextRequest) {
try {
assertAgentAuth(req);
const pb = await getAdminPocketBase();
const body = await req.json().catch(() => null) as Record<string, unknown> | null;
if (!body || typeof body !== "object") {
return NextResponse.json({ error: "Invalid JSON body" }, { status: 400 });
}
const quoteId = typeof body.quote_id === "string" ? body.quote_id.trim() : "";
const description = typeof body.description === "string" ? body.description.trim() : "";
if (!quoteId) {
return NextResponse.json({ error: "quote_id is required" }, { status: 400 });
}
if (!description) {
return NextResponse.json({ error: "description is required (non-empty after trim)" }, { status: 400 });
}
// quantity: must be > 0 when provided; default 1. Reject 0/negative explicitly
// so callers don't accidentally create zero-qty rows that pass PB validation
// but break downstream totals.
let quantity = 1;
if (body.quantity !== undefined && body.quantity !== null) {
const q = Number(body.quantity);
if (!Number.isFinite(q) || q <= 0) {
return NextResponse.json(
{ error: "quantity must be a finite number > 0" },
{ status: 400 },
);
}
quantity = q;
}
// Verify the parent quote exists. PB will return a relation error
// either way on a missing quote id, but a 404 from this handler is
// a cleaner error surface for agents than `validation_required`.
try {
const parent = await pb.collection("quotes").getOne(quoteId, { fields: "id,quote_number" });
// Stash the quote_number on the new row. quote_items.quote_number is
// a denormalised text field — the existing create_from_email path
// sets it, and the re-price / review-approve routes read it. Keep
// the same convention so the new endpoint is a drop-in.
const quoteNumber = String(parent.quote_number || "");
const created = await pb.collection("quote_items").create({
quote: quoteId,
quote_number: quoteNumber,
description,
quantity,
cost_price: Number(body.cost_price) || 0,
selling_price: Number(body.selling_price) || 0,
markup: Number(body.markup) || 0,
sku: typeof body.sku === "string" ? body.sku : "",
unit: typeof body.unit === "string" ? body.unit : "",
supplier: typeof body.supplier === "string" && body.supplier ? body.supplier : null,
notes: typeof body.notes === "string" ? body.notes : "",
pricing_source: typeof body.pricing_source === "string" ? body.pricing_source : "manual",
pricing_notes: typeof body.pricing_notes === "string" ? body.pricing_notes : "",
is_processed: false,
});
return NextResponse.json(created, { status: 201 });
} catch (err) {
const message = err instanceof Error ? err.message : "Failed to create quote_item";
// PB throws 404 with message "Failed to create record." when the
// parent relation is missing — surface that as 404 not 500.
const isMissing = /Failed to create record|not found|missing/i.test(message);
return NextResponse.json({ error: message }, { status: isMissing ? 404 : 500 });
}
} catch (error) {
const message =
error instanceof Error ? error.message : "Failed to create quote_item";
const status = (error as Error & { status?: number }).status || 500;
return NextResponse.json({ error: message }, { status });
}
}