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:
parent
0013ff5267
commit
c41908f4b7
7 changed files with 1220 additions and 15 deletions
|
|
@ -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.
|
||||
|
|
@ -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",
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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.");
|
||||
|
|
|
|||
128
src/app/api/v1/quote_items/[id]/route.test.ts
Normal file
128
src/app/api/v1/quote_items/[id]/route.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
133
src/app/api/v1/quote_items/[id]/route.ts
Normal file
133
src/app/api/v1/quote_items/[id]/route.ts
Normal 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;
|
||||
}
|
||||
182
src/app/api/v1/quote_items/route.test.ts
Normal file
182
src/app/api/v1/quote_items/route.test.ts
Normal 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",
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
153
src/app/api/v1/quote_items/route.ts
Normal file
153
src/app/api/v1/quote_items/route.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue