This commit overhauls the order management system to use item-level status tracking instead of a single order-level status: - Remove deprecated `orders.status` field and add `invoiced` and quote match audit fields - Add `isOrderComplete` utility with tests to validate fully delivered orders - Add purchase order PDF rendering support and tooling - Add geo coordinate fields for companies/customers with map link UI - Add REST management APIs for customers, companies, and suppliers - Update customer/company services to use relation fields instead of free text - Add status pill and tab filter components for item status visualization - Add quote-to-order matching logic and unit tests - Update all pages, components, and tests to match the new schema - Include all required PocketBase migrations and schema updates
759 lines
26 KiB
TypeScript
759 lines
26 KiB
TypeScript
import { NextRequest, NextResponse } from "next/server";
|
||
import { assertAgentAuth } from "@/lib/server/agent-auth";
|
||
import { getAdminPocketBase } from "@/lib/server/pocketbase-admin";
|
||
import { normalizeOrderItemStatusInput } from "@/lib/server/order-item-status";
|
||
import { createOrderFromQuote, getOrderItemsCollectionName, listCollectionPool } from "@/lib/server/orders-service";
|
||
import {
|
||
appendReplyToQuote,
|
||
createQuoteFromEmail,
|
||
findQuoteByMessageId,
|
||
uploadAttachmentForQuote,
|
||
type EmailIntakePayload,
|
||
type EmailReplyPayload,
|
||
} from "@/lib/server/quotes-service";
|
||
import {
|
||
createCompany,
|
||
updateCompany,
|
||
deleteCompany,
|
||
createCustomer,
|
||
updateCustomer,
|
||
deleteCustomer,
|
||
createSupplier,
|
||
updateSupplier,
|
||
deleteSupplier,
|
||
} from "@/lib/server/management-service";
|
||
|
||
/**
|
||
* Small helper to wrap the JSON-RPC success envelope. Centralised so
|
||
* every tool handler returns the same shape without us forgetting a
|
||
* field on one of the 23 tools.
|
||
*/
|
||
function jsonRpcOk(id: unknown, result: unknown) {
|
||
return NextResponse.json({
|
||
jsonrpc: "2.0",
|
||
id: id ?? null,
|
||
result: { content: [{ type: "text", text: JSON.stringify(result) }] },
|
||
});
|
||
}
|
||
|
||
const tools = [
|
||
{
|
||
name: "orders.list",
|
||
description: "List orders, optionally filtered by a substring on order_number",
|
||
inputSchema: {
|
||
type: "object",
|
||
properties: {
|
||
page: { type: "number" },
|
||
perPage: { type: "number" },
|
||
search: { type: "string" },
|
||
},
|
||
},
|
||
},
|
||
{
|
||
name: "orders.create_from_quote",
|
||
description: "Create a new order and order items from an existing quote",
|
||
inputSchema: {
|
||
type: "object",
|
||
required: ["quote_id"],
|
||
properties: {
|
||
quote_id: { type: "string" },
|
||
},
|
||
},
|
||
},
|
||
{
|
||
name: "collection_pool.list",
|
||
description: "List collection_pool records by pool status",
|
||
inputSchema: {
|
||
type: "object",
|
||
properties: {
|
||
page: { type: "number" },
|
||
perPage: { type: "number" },
|
||
status: { type: "string" },
|
||
},
|
||
},
|
||
},
|
||
{
|
||
name: "order_items.update_status",
|
||
description: "Update order item status using item_status and drv_status",
|
||
inputSchema: {
|
||
type: "object",
|
||
required: ["id"],
|
||
properties: {
|
||
id: { type: "string" },
|
||
item_status: { type: "string" },
|
||
drv_status: { type: "string" },
|
||
notes: { type: "string" },
|
||
problem_notes: { type: "string" },
|
||
},
|
||
},
|
||
},
|
||
{
|
||
name: "quotes.create_from_email",
|
||
description:
|
||
"Create a new quote (with customer/company/items) from a parsed email payload. Idempotent on message_id. `from_email` is optional — if missing (e.g. an email with no From: header), the quote is linked to a sentinel 'Unknown Sender' customer (`unknown-sender@no-from.wfdash.local`) that the quoter reassigns in the existing quote-editing form. `message_id` is also optional in the same spirit — a stable `synth-…@wfdash.local` id is derived from the email identity when missing. The legacy `attachments` field is accepted for back-compat but no longer uploaded from this call — use `attachments.upload_for_quote` once per file after this call returns the new quote id.",
|
||
inputSchema: {
|
||
type: "object",
|
||
required: ["subject"],
|
||
properties: {
|
||
from_email: { type: "string" },
|
||
from_name: { type: "string" },
|
||
subject: { type: "string" },
|
||
body_plain: { type: "string" },
|
||
body_html: { type: "string" },
|
||
message_id: { type: "string" },
|
||
in_reply_to: { type: "string" },
|
||
received_at: { type: "string" },
|
||
company_hint: { type: "string" },
|
||
extracted_items: {
|
||
type: "array",
|
||
items: {
|
||
type: "object",
|
||
properties: {
|
||
description: { type: "string" },
|
||
quantity: { type: "number" },
|
||
unit: { type: "string" },
|
||
notes: { type: "string" },
|
||
},
|
||
},
|
||
},
|
||
attachments: {
|
||
type: "array",
|
||
description:
|
||
"Legacy field, accepted but no longer acted on. Use attachments.upload_for_quote for each file instead.",
|
||
items: {
|
||
type: "object",
|
||
properties: {
|
||
filename: { type: "string" },
|
||
content_type: { type: "string" },
|
||
size: { type: "number" },
|
||
base64: { type: "string" },
|
||
},
|
||
},
|
||
},
|
||
},
|
||
},
|
||
},
|
||
{
|
||
name: "quotes.append_reply",
|
||
description:
|
||
"Append a structured reply to an existing quote. Matches by In-Reply-To header first, then by Q-YYYY-MM-NNN in the subject.",
|
||
inputSchema: {
|
||
type: "object",
|
||
required: ["from_email", "subject"],
|
||
properties: {
|
||
from_email: { type: "string" },
|
||
from_name: { type: "string" },
|
||
subject: { type: "string" },
|
||
body_plain: { type: "string" },
|
||
body_html: { type: "string" },
|
||
message_id: { type: "string" },
|
||
in_reply_to: { type: "string" },
|
||
received_at: { type: "string" },
|
||
quoted_text: { type: "string" },
|
||
},
|
||
},
|
||
},
|
||
{
|
||
name: "quotes.find_by_message_id",
|
||
description: "Look up a quote by the inbound email's RFC 5322 Message-ID header.",
|
||
inputSchema: {
|
||
type: "object",
|
||
required: ["message_id"],
|
||
properties: {
|
||
message_id: { type: "string" },
|
||
},
|
||
},
|
||
},
|
||
{
|
||
name: "attachments.upload_for_quote",
|
||
description:
|
||
"Upload a single attachment (PDF, image, Word, Excel, CSV, plain text, or any other binary up to 25 MB) to an existing quote. The caller (n8n) should call this once per attachment AFTER `quotes.create_from_email` returns the new quote id. Errors are returned per-attachment so a single bad file does not roll back the quote. `base64` is the raw file bytes encoded as base64 (no `data:` URL prefix).",
|
||
inputSchema: {
|
||
type: "object",
|
||
required: ["quote_id", "filename", "base64"],
|
||
properties: {
|
||
quote_id: { type: "string" },
|
||
filename: { type: "string" },
|
||
content_type: { type: "string" },
|
||
size: { type: "number" },
|
||
base64: { type: "string" },
|
||
message_id: { type: "string" },
|
||
},
|
||
},
|
||
},
|
||
// ─── Management CRUD — companies / customers / suppliers ──────────
|
||
// Five verbs (list / get / create / update / delete) × three
|
||
// collections. Hermes agents can run any of these to maintain the
|
||
// CRM. `create` / `update` accept either a record id OR a name for
|
||
// relation fields (`company`, `rep`) — names are resolved by
|
||
// case-insensitive lookup so agents don't need to know ids up
|
||
// front.
|
||
{
|
||
name: "companies.list",
|
||
description: "List companies, optionally filtered by name or vendor substring.",
|
||
inputSchema: {
|
||
type: "object",
|
||
properties: {
|
||
page: { type: "number" },
|
||
perPage: { type: "number" },
|
||
search: { type: "string" },
|
||
sort: { type: "string" },
|
||
},
|
||
},
|
||
},
|
||
{
|
||
name: "companies.get",
|
||
description: "Fetch a single company by id.",
|
||
inputSchema: {
|
||
type: "object",
|
||
required: ["id"],
|
||
properties: { id: { type: "string" } },
|
||
},
|
||
},
|
||
{
|
||
name: "companies.create",
|
||
description:
|
||
"Create a new company. `company` is the display name and is required. `coordinates` is a `{ lon, lat }` object (PB geoPoint) — pass null/omit if unknown.",
|
||
inputSchema: {
|
||
type: "object",
|
||
required: ["company"],
|
||
properties: {
|
||
company: { type: "string" },
|
||
address: { type: "string" },
|
||
vendor: { type: "string" },
|
||
coordinates: {
|
||
type: "object",
|
||
properties: {
|
||
lon: { type: "number" },
|
||
lat: { type: "number" },
|
||
},
|
||
},
|
||
},
|
||
},
|
||
},
|
||
{
|
||
name: "companies.update",
|
||
description: "Patch fields on a company. Only the fields present in `patch` are written.",
|
||
inputSchema: {
|
||
type: "object",
|
||
required: ["id"],
|
||
properties: {
|
||
id: { type: "string" },
|
||
patch: {
|
||
type: "object",
|
||
properties: {
|
||
company: { type: "string" },
|
||
address: { type: "string" },
|
||
vendor: { type: "string" },
|
||
coordinates: {
|
||
type: "object",
|
||
properties: {
|
||
lon: { type: "number" },
|
||
lat: { type: "number" },
|
||
},
|
||
},
|
||
},
|
||
},
|
||
},
|
||
},
|
||
},
|
||
{
|
||
name: "companies.delete",
|
||
description:
|
||
"Delete a company. PB's relation cascade rules apply — a company with linked customers will fail to delete unless those customers are deleted first (or the relation's cascadeDelete is true).",
|
||
inputSchema: {
|
||
type: "object",
|
||
required: ["id"],
|
||
properties: { id: { type: "string" } },
|
||
},
|
||
},
|
||
{
|
||
name: "customers.list",
|
||
description:
|
||
"List customers, optionally filtered by name / email / phone substring. Returns the row with `company` and `rep` fields as relation ids; pass `expand: \"company,rep\"` to receive the expanded company + user records inline.",
|
||
inputSchema: {
|
||
type: "object",
|
||
properties: {
|
||
page: { type: "number" },
|
||
perPage: { type: "number" },
|
||
search: { type: "string" },
|
||
sort: { type: "string" },
|
||
expand: { type: "string" },
|
||
},
|
||
},
|
||
},
|
||
{
|
||
name: "customers.get",
|
||
description: "Fetch a single customer by id. Pass `expand` to inline company / rep records.",
|
||
inputSchema: {
|
||
type: "object",
|
||
required: ["id"],
|
||
properties: {
|
||
id: { type: "string" },
|
||
expand: { type: "string" },
|
||
},
|
||
},
|
||
},
|
||
{
|
||
name: "customers.create",
|
||
description:
|
||
"Create a new customer. `customer` (display name) is required. `company` and `rep` accept either a record id OR a name — names are resolved by case-insensitive lookup. If the name doesn't match, the relation is left empty so the operator can link it later. `coordinates` is `{ lon, lat }` (PB geoPoint).",
|
||
inputSchema: {
|
||
type: "object",
|
||
required: ["customer"],
|
||
properties: {
|
||
customer: { type: "string" },
|
||
email: { type: "string" },
|
||
alternative_email: { type: "string" },
|
||
buyer_email: { type: "string" },
|
||
number: { type: "string" },
|
||
company: { type: "string" },
|
||
rep: { type: "string" },
|
||
coordinates: {
|
||
type: "object",
|
||
properties: {
|
||
lon: { type: "number" },
|
||
lat: { type: "number" },
|
||
},
|
||
},
|
||
description: { type: "string" },
|
||
},
|
||
},
|
||
},
|
||
{
|
||
name: "customers.update",
|
||
description:
|
||
"Patch fields on a customer. Like `customers.create`, `company` and `rep` accept either id or name. Pass `company: \"\"` or `rep: \"\"` to unset a relation.",
|
||
inputSchema: {
|
||
type: "object",
|
||
required: ["id"],
|
||
properties: {
|
||
id: { type: "string" },
|
||
patch: {
|
||
type: "object",
|
||
properties: {
|
||
customer: { type: "string" },
|
||
email: { type: "string" },
|
||
alternative_email: { type: "string" },
|
||
buyer_email: { type: "string" },
|
||
number: { type: "string" },
|
||
company: { type: "string" },
|
||
rep: { type: "string" },
|
||
coordinates: {
|
||
type: "object",
|
||
properties: {
|
||
lon: { type: "number" },
|
||
lat: { type: "number" },
|
||
},
|
||
},
|
||
description: { type: "string" },
|
||
},
|
||
},
|
||
},
|
||
},
|
||
},
|
||
{
|
||
name: "customers.delete",
|
||
description:
|
||
"Delete a customer. Cascade rules apply — customers linked to quotes or orders will fail to delete unless those rows are removed first.",
|
||
inputSchema: {
|
||
type: "object",
|
||
required: ["id"],
|
||
properties: { id: { type: "string" } },
|
||
},
|
||
},
|
||
{
|
||
name: "suppliers.list",
|
||
description:
|
||
"List suppliers, optionally filtered by name / email / phone / tags substring. `coordinates` is the legacy \"lat, lng\" text field (suppliers pre-date the geopoint migration).",
|
||
inputSchema: {
|
||
type: "object",
|
||
properties: {
|
||
page: { type: "number" },
|
||
perPage: { type: "number" },
|
||
search: { type: "string" },
|
||
sort: { type: "string" },
|
||
},
|
||
},
|
||
},
|
||
{
|
||
name: "suppliers.get",
|
||
description: "Fetch a single supplier by id.",
|
||
inputSchema: {
|
||
type: "object",
|
||
required: ["id"],
|
||
properties: { id: { type: "string" } },
|
||
},
|
||
},
|
||
{
|
||
name: "suppliers.create",
|
||
description:
|
||
"Create a new supplier. `suppliername` is required. `coordinates` is the legacy \"lat, lng\" string — keep suppliers on the old format for now (it's what supplier-map.ts parses).",
|
||
inputSchema: {
|
||
type: "object",
|
||
required: ["suppliername"],
|
||
properties: {
|
||
suppliername: { type: "string" },
|
||
email: { type: "string" },
|
||
suppliernumber: { type: "string" },
|
||
supplieraddress: { type: "string" },
|
||
supply_tags: { type: "string" },
|
||
coordinates: { type: "string" },
|
||
closingtime: { type: "string" },
|
||
},
|
||
},
|
||
},
|
||
{
|
||
name: "suppliers.update",
|
||
description: "Patch fields on a supplier.",
|
||
inputSchema: {
|
||
type: "object",
|
||
required: ["id"],
|
||
properties: {
|
||
id: { type: "string" },
|
||
patch: {
|
||
type: "object",
|
||
properties: {
|
||
suppliername: { type: "string" },
|
||
email: { type: "string" },
|
||
suppliernumber: { type: "string" },
|
||
supplieraddress: { type: "string" },
|
||
supply_tags: { type: "string" },
|
||
coordinates: { type: "string" },
|
||
closingtime: { type: "string" },
|
||
},
|
||
},
|
||
},
|
||
},
|
||
},
|
||
{
|
||
name: "suppliers.delete",
|
||
description:
|
||
"Delete a supplier. Cascade rules apply — suppliers linked to purchase orders / order items / quote items will fail to delete unless those rows are removed first.",
|
||
inputSchema: {
|
||
type: "object",
|
||
required: ["id"],
|
||
properties: { id: { type: "string" } },
|
||
},
|
||
},
|
||
];
|
||
|
||
export async function POST(req: NextRequest) {
|
||
try {
|
||
assertAgentAuth(req);
|
||
const body = await req.json();
|
||
|
||
if (body?.method === "tools/list") {
|
||
return NextResponse.json({ jsonrpc: "2.0", id: body.id ?? null, result: { tools } });
|
||
}
|
||
|
||
if (body?.method === "tools/call") {
|
||
const pb = await getAdminPocketBase();
|
||
const toolName = body?.params?.name;
|
||
const args = body?.params?.arguments || {};
|
||
|
||
if (toolName === "orders.list") {
|
||
const page = Number(args.page || 1);
|
||
const perPage = Number(args.perPage || 25);
|
||
// orders no longer carry a `status` field — items drive the
|
||
// workflow. Filter by order_number substring instead, so the
|
||
// tool is still useful for finding a specific order.
|
||
const filter = args.search ? `order_number ~ "${args.search}"` : "";
|
||
const result = await pb.collection("orders").getList(page, perPage, {
|
||
filter,
|
||
sort: "-created",
|
||
expand: "company,customer,quote",
|
||
});
|
||
|
||
return NextResponse.json({
|
||
jsonrpc: "2.0",
|
||
id: body.id ?? null,
|
||
result: { content: [{ type: "text", text: JSON.stringify(result) }] },
|
||
});
|
||
}
|
||
|
||
if (toolName === "orders.create_from_quote") {
|
||
const result = await createOrderFromQuote(args.quote_id);
|
||
return NextResponse.json({
|
||
jsonrpc: "2.0",
|
||
id: body.id ?? null,
|
||
result: { content: [{ type: "text", text: JSON.stringify(result) }] },
|
||
});
|
||
}
|
||
|
||
if (toolName === "collection_pool.list") {
|
||
const result = await listCollectionPool({
|
||
page: Number(args.page || 1),
|
||
perPage: Number(args.perPage || 50),
|
||
status: args.status,
|
||
});
|
||
return NextResponse.json({
|
||
jsonrpc: "2.0",
|
||
id: body.id ?? null,
|
||
result: { content: [{ type: "text", text: JSON.stringify(result) }] },
|
||
});
|
||
}
|
||
|
||
if (toolName === "order_items.update_status") {
|
||
if (!args.id) {
|
||
throw new Error("Missing id for order item update.");
|
||
}
|
||
|
||
const patch = normalizeOrderItemStatusInput({
|
||
item_status: args.item_status,
|
||
drv_status: args.drv_status,
|
||
notes: args.notes,
|
||
problem_notes: args.problem_notes,
|
||
});
|
||
const result = await pb.collection(getOrderItemsCollectionName()).update(args.id, patch);
|
||
|
||
return NextResponse.json({
|
||
jsonrpc: "2.0",
|
||
id: body.id ?? null,
|
||
result: { content: [{ type: "text", text: JSON.stringify(result) }] },
|
||
});
|
||
}
|
||
|
||
if (toolName === "quotes.create_from_email") {
|
||
const payload: EmailIntakePayload = {
|
||
from_email: args.from_email,
|
||
from_name: args.from_name,
|
||
subject: args.subject,
|
||
body_plain: args.body_plain,
|
||
body_html: args.body_html,
|
||
message_id: args.message_id,
|
||
in_reply_to: args.in_reply_to,
|
||
received_at: args.received_at,
|
||
company_hint: args.company_hint,
|
||
extracted_items: args.extracted_items,
|
||
attachments: args.attachments,
|
||
};
|
||
const result = await createQuoteFromEmail(payload);
|
||
return NextResponse.json({
|
||
jsonrpc: "2.0",
|
||
id: body.id ?? null,
|
||
result: { content: [{ type: "text", text: JSON.stringify(result) }] },
|
||
});
|
||
}
|
||
|
||
if (toolName === "quotes.append_reply") {
|
||
const payload: EmailReplyPayload = {
|
||
from_email: args.from_email,
|
||
from_name: args.from_name,
|
||
subject: args.subject,
|
||
body_plain: args.body_plain,
|
||
body_html: args.body_html,
|
||
message_id: args.message_id,
|
||
in_reply_to: args.in_reply_to,
|
||
received_at: args.received_at,
|
||
quoted_text: args.quoted_text,
|
||
};
|
||
const result = await appendReplyToQuote(payload);
|
||
return NextResponse.json({
|
||
jsonrpc: "2.0",
|
||
id: body.id ?? null,
|
||
result: { content: [{ type: "text", text: JSON.stringify(result) }] },
|
||
});
|
||
}
|
||
|
||
if (toolName === "quotes.find_by_message_id") {
|
||
if (!args.message_id) {
|
||
throw new Error("Missing message_id for quotes.find_by_message_id.");
|
||
}
|
||
const quote = await findQuoteByMessageId(args.message_id);
|
||
return NextResponse.json({
|
||
jsonrpc: "2.0",
|
||
id: body.id ?? null,
|
||
result: { content: [{ type: "text", text: JSON.stringify({ quote }) }] },
|
||
});
|
||
}
|
||
|
||
if (toolName === "attachments.upload_for_quote") {
|
||
if (!args.quote_id) throw new Error("Missing quote_id for attachments.upload_for_quote.");
|
||
if (!args.filename) throw new Error("Missing filename for attachments.upload_for_quote.");
|
||
if (!args.base64) throw new Error("Missing base64 for attachments.upload_for_quote.");
|
||
const attachment = await uploadAttachmentForQuote({
|
||
quoteId: args.quote_id,
|
||
filename: args.filename,
|
||
contentType: args.content_type,
|
||
size: args.size,
|
||
base64: args.base64,
|
||
messageId: args.message_id,
|
||
});
|
||
return NextResponse.json({
|
||
jsonrpc: "2.0",
|
||
id: body.id ?? null,
|
||
result: { content: [{ type: "text", text: JSON.stringify({ attachment }) }] },
|
||
});
|
||
}
|
||
|
||
// ─── Companies ────────────────────────────────────────────────
|
||
if (toolName === "companies.list") {
|
||
const page = Number(args.page || 1);
|
||
const perPage = Number(args.perPage || 25);
|
||
const filter = args.search
|
||
? `company ~ "${args.search}" || vendor ~ "${args.search}"`
|
||
: "";
|
||
const result = await pb.collection("companies").getList(page, perPage, {
|
||
filter,
|
||
sort: args.sort || "-created",
|
||
});
|
||
return jsonRpcOk(body.id, result);
|
||
}
|
||
|
||
if (toolName === "companies.get") {
|
||
if (!args.id) throw new Error("Missing id for companies.get.");
|
||
const company = await pb.collection("companies").getOne(args.id);
|
||
return jsonRpcOk(body.id, { company });
|
||
}
|
||
|
||
if (toolName === "companies.create") {
|
||
if (!args.company) throw new Error("Missing company name for companies.create.");
|
||
const company = await createCompany(pb, {
|
||
company: args.company,
|
||
address: args.address,
|
||
vendor: args.vendor,
|
||
coordinates: args.coordinates,
|
||
});
|
||
return jsonRpcOk(body.id, { company });
|
||
}
|
||
|
||
if (toolName === "companies.update") {
|
||
if (!args.id) throw new Error("Missing id for companies.update.");
|
||
if (!args.patch || typeof args.patch !== "object") {
|
||
throw new Error("Missing patch object for companies.update.");
|
||
}
|
||
const company = await updateCompany(pb, args.id, args.patch);
|
||
return jsonRpcOk(body.id, { company });
|
||
}
|
||
|
||
if (toolName === "companies.delete") {
|
||
if (!args.id) throw new Error("Missing id for companies.delete.");
|
||
await deleteCompany(pb, args.id);
|
||
return jsonRpcOk(body.id, { deleted: args.id });
|
||
}
|
||
|
||
// ─── Customers ────────────────────────────────────────────────
|
||
if (toolName === "customers.list") {
|
||
const page = Number(args.page || 1);
|
||
const perPage = Number(args.perPage || 25);
|
||
const filter = args.search
|
||
? `customer ~ "${args.search}" || email ~ "${args.search}" || number ~ "${args.search}"`
|
||
: "";
|
||
const result = await pb.collection("customers").getList(page, perPage, {
|
||
filter,
|
||
sort: args.sort || "-created",
|
||
expand: args.expand,
|
||
});
|
||
return jsonRpcOk(body.id, result);
|
||
}
|
||
|
||
if (toolName === "customers.get") {
|
||
if (!args.id) throw new Error("Missing id for customers.get.");
|
||
const customer = await pb.collection("customers").getOne(args.id, {
|
||
expand: args.expand,
|
||
});
|
||
return jsonRpcOk(body.id, { customer });
|
||
}
|
||
|
||
if (toolName === "customers.create") {
|
||
if (!args.customer) throw new Error("Missing customer name for customers.create.");
|
||
const customer = await createCustomer(pb, {
|
||
customer: args.customer,
|
||
email: args.email,
|
||
alternative_email: args.alternative_email,
|
||
buyer_email: args.buyer_email,
|
||
number: args.number,
|
||
company: args.company,
|
||
rep: args.rep,
|
||
coordinates: args.coordinates,
|
||
description: args.description,
|
||
});
|
||
return jsonRpcOk(body.id, { customer });
|
||
}
|
||
|
||
if (toolName === "customers.update") {
|
||
if (!args.id) throw new Error("Missing id for customers.update.");
|
||
if (!args.patch || typeof args.patch !== "object") {
|
||
throw new Error("Missing patch object for customers.update.");
|
||
}
|
||
const customer = await updateCustomer(pb, args.id, args.patch);
|
||
return jsonRpcOk(body.id, { customer });
|
||
}
|
||
|
||
if (toolName === "customers.delete") {
|
||
if (!args.id) throw new Error("Missing id for customers.delete.");
|
||
await deleteCustomer(pb, args.id);
|
||
return jsonRpcOk(body.id, { deleted: args.id });
|
||
}
|
||
|
||
// ─── Suppliers ────────────────────────────────────────────────
|
||
if (toolName === "suppliers.list") {
|
||
const page = Number(args.page || 1);
|
||
const perPage = Number(args.perPage || 25);
|
||
const filter = args.search
|
||
? `suppliername ~ "${args.search}" || email ~ "${args.search}" || suppliernumber ~ "${args.search}" || supply_tags ~ "${args.search}"`
|
||
: "";
|
||
const result = await pb.collection("suppliers").getList(page, perPage, {
|
||
filter,
|
||
sort: args.sort || "-created",
|
||
});
|
||
return jsonRpcOk(body.id, result);
|
||
}
|
||
|
||
if (toolName === "suppliers.get") {
|
||
if (!args.id) throw new Error("Missing id for suppliers.get.");
|
||
const supplier = await pb.collection("suppliers").getOne(args.id);
|
||
return jsonRpcOk(body.id, { supplier });
|
||
}
|
||
|
||
if (toolName === "suppliers.create") {
|
||
if (!args.suppliername) throw new Error("Missing suppliername for suppliers.create.");
|
||
const supplier = await createSupplier(pb, {
|
||
suppliername: args.suppliername,
|
||
email: args.email,
|
||
suppliernumber: args.suppliernumber,
|
||
supplieraddress: args.supplieraddress,
|
||
supply_tags: args.supply_tags,
|
||
coordinates: args.coordinates,
|
||
closingtime: args.closingtime,
|
||
});
|
||
return jsonRpcOk(body.id, { supplier });
|
||
}
|
||
|
||
if (toolName === "suppliers.update") {
|
||
if (!args.id) throw new Error("Missing id for suppliers.update.");
|
||
if (!args.patch || typeof args.patch !== "object") {
|
||
throw new Error("Missing patch object for suppliers.update.");
|
||
}
|
||
const supplier = await updateSupplier(pb, args.id, args.patch);
|
||
return jsonRpcOk(body.id, { supplier });
|
||
}
|
||
|
||
if (toolName === "suppliers.delete") {
|
||
if (!args.id) throw new Error("Missing id for suppliers.delete.");
|
||
await deleteSupplier(pb, args.id);
|
||
return jsonRpcOk(body.id, { deleted: args.id });
|
||
}
|
||
|
||
throw new Error(`Unknown tool: ${toolName}`);
|
||
}
|
||
|
||
return NextResponse.json(
|
||
{ jsonrpc: "2.0", id: body?.id ?? null, error: { code: -32601, message: "Method not found" } },
|
||
{ status: 404 }
|
||
);
|
||
} catch (error) {
|
||
const message = error instanceof Error ? error.message : "MCP request failed";
|
||
// Honour tagged HTTP status (e.g. assertAgentAuth sets status: 401).
|
||
// Falls back to 500 for untagged internal errors. JSON-RPC code -32001
|
||
// is reserved for auth failures so clients can distinguish them from
|
||
// generic server errors (-32000).
|
||
const status = (error as Error & { status?: number }).status ?? 500;
|
||
const code = status === 401 ? -32001 : -32000;
|
||
return NextResponse.json(
|
||
{ jsonrpc: "2.0", id: null, error: { code, message } },
|
||
{ status }
|
||
);
|
||
}
|
||
}
|