diff --git a/.agents/skills/wfdash-giellie/SKILL.md b/.agents/skills/wfdash-giellie/SKILL.md index 0a958a5..24839d6 100644 --- a/.agents/skills/wfdash-giellie/SKILL.md +++ b/.agents/skills/wfdash-giellie/SKILL.md @@ -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""`, `?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": ` 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=""'` to scope to one quote, `filter: 'sku~"M16"'` to find SKUs across quotes, `filter: 'supplier=""'` 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=\"\"" } +// } +// } // 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": "", + "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=\"\"", "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": "" } +}} + +// ⚠️ `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. \ No newline at end of file +- **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. \ No newline at end of file diff --git a/src/app/api/mcp/route.test.ts b/src/app/api/mcp/route.test.ts index 0bce544..bce05af 100644 --- a/src/app/api/mcp/route.test.ts +++ b/src/app/api/mcp/route.test.ts @@ -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", + }); + }); + }); }); diff --git a/src/app/api/mcp/route.ts b/src/app/api/mcp/route.ts index e0e1646..339eaf6 100644 --- a/src/app/api/mcp/route.ts +++ b/src/app/api/mcp/route.ts @@ -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=\"\"`, `sku~\"M16\"`, `supplier=\"\"`). 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; + const patch: Record = {}; + 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."); diff --git a/src/app/api/v1/quote_items/[id]/route.test.ts b/src/app/api/v1/quote_items/[id]/route.test.ts new file mode 100644 index 0000000..5e6adba --- /dev/null +++ b/src/app/api/v1/quote_items/[id]/route.test.ts @@ -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 = {}) { + 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); + }); +}); diff --git a/src/app/api/v1/quote_items/[id]/route.ts b/src/app/api/v1/quote_items/[id]/route.ts new file mode 100644 index 0000000..dd31694 --- /dev/null +++ b/src/app/api/v1/quote_items/[id]/route.ts @@ -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 `. + * + * 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 | null; + if (!body || typeof body !== "object" || Array.isArray(body)) { + return NextResponse.json({ error: "Body must be a JSON object" }, { status: 400 }); + } + + const patch: Record = {}; + 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; +} diff --git a/src/app/api/v1/quote_items/route.test.ts b/src/app/api/v1/quote_items/route.test.ts new file mode 100644 index 0000000..59fe508 --- /dev/null +++ b/src/app/api/v1/quote_items/route.test.ts @@ -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 = {}) { + 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", + }), + ); + }); +}); diff --git a/src/app/api/v1/quote_items/route.ts b/src/app/api/v1/quote_items/route.ts new file mode 100644 index 0000000..83511cc --- /dev/null +++ b/src/app/api/v1/quote_items/route.ts @@ -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 `. + * + * 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 | 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 }); + } +}