diff --git a/src/app/api/mcp/route.test.ts b/src/app/api/mcp/route.test.ts index fd7431f..9e6babc 100644 --- a/src/app/api/mcp/route.test.ts +++ b/src/app/api/mcp/route.test.ts @@ -278,6 +278,7 @@ const toolNames = body.result.tools.map((t: { name: string }) => t.name); "quotes.submit", "quotes.re_price", "quotes.next_number", + "quotes.assistant_fill", "quotes.lookup", "attachments.upload_for_quote", // Quote attachments — list + PDF retrieval (added 2026-08-05) diff --git a/src/app/api/mcp/route.ts b/src/app/api/mcp/route.ts index c7d7108..f27c9d0 100644 --- a/src/app/api/mcp/route.ts +++ b/src/app/api/mcp/route.ts @@ -391,6 +391,14 @@ const TOOL_HINTS: Record = { "If 500, check server logs for the PDFKit stack trace", ], }, + "quotes.assistant_fill": { + hint: "The run reports problems in `error` instead of throwing: \"quote is not open…\" (only draft/pending quotes are filled), \"AI is disabled in settings\", or \"AI call failed…\" (the affected lines are marked `error` and retry automatically after 1 hour). Deterministic ones should not be retried.", + nextActions: [ + "Check the quote status with quotes.get before retrying", + "If the AI is disabled, ask the operator to enable it in /settings/ai", + "Filled lines stay purple until a rep reviews them — do not mark them done or send the quote", + ], + }, "quotes.re_price": { hint: "Re-price can fail with 422 (no items left to price) or 500 (PB write).", nextActions: [ @@ -1576,6 +1584,18 @@ const tools = [ "Reserve the next quote number (`Q{yy}{mm}{NNN}`). Atomic counter; gaps are possible if the caller never persists the resulting draft.", inputSchema: { type: "object", properties: {} }, }, + { + name: "quotes.assistant_fill", + description: + "Run the AI quote assistant on one quote now: fills unpriced lines from matching POS items (purple, unreviewed) and notes the closest items for unmatched lines. Never marks lines done or sends anything.", + inputSchema: { + type: "object", + required: ["quote_id"], + properties: { + quote_id: { type: "string", description: "quote id" }, + }, + }, + }, { name: "quotes.lookup", description: @@ -5327,6 +5347,22 @@ async function handleMcpPost(req: NextRequest) { return jsonRpcOk(body.id, { quote_number }); } + if (toolName === "quotes.assistant_fill") { + const quoteId = typeof args.quote_id === "string" ? args.quote_id.trim() : ""; + if (!quoteId) throw new Error("quote_id is required."); + const { fillQuote } = await import("@/lib/server/quote-assistant-service"); + const result = await fillQuote(quoteId, { force: true, actorName: "MCP" }); + if (result.error) { + const hintEntry = TOOL_HINTS["quotes.assistant_fill"]; + return jsonRpcOk(body.id, { + ...result, + hint: hintEntry?.hint, + nextActions: hintEntry?.nextActions, + }); + } + return jsonRpcOk(body.id, result); + } + if (toolName === "quotes.lookup") { const params = new URLSearchParams(); if (args.q) params.set("q", String(args.q)); diff --git a/src/app/api/v1/quotes/[id]/assistant/run/route.test.ts b/src/app/api/v1/quotes/[id]/assistant/run/route.test.ts new file mode 100644 index 0000000..41cde32 --- /dev/null +++ b/src/app/api/v1/quotes/[id]/assistant/run/route.test.ts @@ -0,0 +1,89 @@ +import { describe, it, expect, beforeEach, vi } from "vitest"; +import { NextRequest } from "next/server"; + +const fillQuote = vi.fn(); +vi.mock("@/lib/server/quote-assistant-service", () => ({ + fillQuote: (...a: unknown[]) => fillQuote(...a), +})); + +const getBrowserSessionUser = vi.fn(); +vi.mock("@/lib/server/browser-session", () => ({ + getBrowserSessionUser: (...a: unknown[]) => getBrowserSessionUser(...a), +})); + +import { POST } from "./route"; + +function makeReq() { + return new NextRequest("http://localhost/api/v1/quotes/q1/assistant/run", { + method: "POST", + headers: { authorization: "Bearer tok" }, + }); +} +const ctx = { params: Promise.resolve({ id: "q1" }) }; + +beforeEach(() => { + vi.clearAllMocks(); + getBrowserSessionUser.mockResolvedValue({ id: "u1", user_type: null, name: "Sam" }); +}); + +describe("POST /api/v1/quotes/[id]/assistant/run", () => { + it("401s without a valid session", async () => { + getBrowserSessionUser.mockRejectedValue( + Object.assign(new Error("Not authenticated."), { status: 401 }), + ); + const res = await POST(makeReq(), ctx); + expect(res.status).toBe(401); + expect(fillQuote).not.toHaveBeenCalled(); + }); + + it("runs fillQuote forced, attributed to the user", async () => { + const result = { quoteId: "q1", filled: 2, unmatched: 1, skipped: 0, error: "" }; + fillQuote.mockResolvedValue(result); + const res = await POST(makeReq(), ctx); + expect(fillQuote).toHaveBeenCalledWith("q1", { force: true, actorName: "Sam" }); + expect(res.status).toBe(200); + expect(await res.json()).toEqual(result); + }); + + it("falls back to 'a user' when the user has no name", async () => { + getBrowserSessionUser.mockResolvedValue({ id: "u1", user_type: null, name: "" }); + fillQuote.mockResolvedValue({ quoteId: "q1", filled: 0, unmatched: 0, skipped: 0, error: "" }); + await POST(makeReq(), ctx); + expect(fillQuote).toHaveBeenCalledWith("q1", { force: true, actorName: "a user" }); + }); + + it("409s when the quote is not open", async () => { + fillQuote.mockResolvedValue({ + quoteId: "q1", filled: 0, unmatched: 0, skipped: 0, + error: "quote is not open (status: sent)", + }); + const res = await POST(makeReq(), ctx); + expect(res.status).toBe(409); + expect(await res.json()).toEqual({ error: "quote is not open (status: sent)" }); + }); + + it("409s when the AI is disabled", async () => { + fillQuote.mockResolvedValue({ + quoteId: "q1", filled: 0, unmatched: 0, skipped: 0, + error: "AI is disabled in settings", + }); + const res = await POST(makeReq(), ctx); + expect(res.status).toBe(409); + expect(await res.json()).toEqual({ error: "AI is disabled in settings" }); + }); + + it("returns 200 with the result when the AI call failed", async () => { + const result = { quoteId: "q1", filled: 0, unmatched: 0, skipped: 0, error: "AI call failed: timeout" }; + fillQuote.mockResolvedValue(result); + const res = await POST(makeReq(), ctx); + expect(res.status).toBe(200); + expect(await res.json()).toEqual(result); + }); + + it("500s when fillQuote throws unexpectedly", async () => { + fillQuote.mockRejectedValue(new Error("pb down")); + const res = await POST(makeReq(), ctx); + expect(res.status).toBe(500); + expect(await res.json()).toEqual({ error: "pb down" }); + }); +}); diff --git a/src/app/api/v1/quotes/[id]/assistant/run/route.ts b/src/app/api/v1/quotes/[id]/assistant/run/route.ts new file mode 100644 index 0000000..554a900 --- /dev/null +++ b/src/app/api/v1/quotes/[id]/assistant/run/route.ts @@ -0,0 +1,43 @@ +import { NextRequest, NextResponse } from "next/server"; +import { getBrowserSessionUser } from "@/lib/server/browser-session"; +import { fillQuote } from "@/lib/server/quote-assistant-service"; + +/** + * POST /api/v1/quotes/[id]/assistant/run + * + * Manual "run the AI quote assistant now" for one quote (the button on the + * Process page). Forces a run past the retry/backoff rules, attributed to + * the signed-in user in the audit trail. + * + * Auth: browser session (PB token forwarded in `Authorization`). + * + * Response: the `FillQuoteResult`. 409 when the quote isn't open or the AI + * is switched off. An AI-call failure is not an HTTP error: it comes back + * as 200 with `error` set and the failed lines marked on the quote. + */ +export const runtime = "nodejs"; +export const dynamic = "force-dynamic"; + +export async function POST( + req: NextRequest, + { params }: { params: Promise<{ id: string }> }, +) { + try { + const user = await getBrowserSessionUser(req); + const { id } = await params; + + const result = await fillQuote(id, { + force: true, + actorName: user.name || "a user", + }); + + if (result.error.startsWith("quote is not open") || result.error === "AI is disabled in settings") { + return NextResponse.json({ error: result.error }, { status: 409 }); + } + return NextResponse.json(result); + } catch (err) { + const message = err instanceof Error ? err.message : "Assistant run failed"; + const status = (err as Error & { status?: number }).status || 500; + return NextResponse.json({ error: message }, { status }); + } +} diff --git a/src/app/api/v1/quotes/[id]/re-price/route.test.ts b/src/app/api/v1/quotes/[id]/re-price/route.test.ts new file mode 100644 index 0000000..13bcb15 --- /dev/null +++ b/src/app/api/v1/quotes/[id]/re-price/route.test.ts @@ -0,0 +1,86 @@ +import { describe, it, expect, beforeEach, vi } from "vitest"; +import { NextRequest } from "next/server"; + +const findFirstPriceListBySku = vi.fn(); +vi.mock("@/lib/server/price-list-match", () => ({ + findFirstPriceListBySku: (...a: unknown[]) => findFirstPriceListBySku(...a), +})); + +const update = vi.fn(); +const getFullList = vi.fn(); +const suppliersGetList = vi.fn(); +vi.mock("@/lib/server/pocketbase-admin", () => ({ + getAdminPocketBase: vi.fn().mockResolvedValue({ + collection: (name: string) => { + if (name === "quotes") return { getOne: vi.fn().mockResolvedValue({ id: "q1" }) }; + if (name === "quote_items") return { getFullList, update }; + if (name === "suppliers") return { getList: suppliersGetList }; + throw new Error(`unexpected collection ${name}`); + }, + }), +})); + +import { POST } from "./route"; + +const ctx = { params: Promise.resolve({ id: "q1" }) }; +function makeReq() { + return new NextRequest("http://localhost/api/v1/quotes/q1/re-price", { + method: "POST", + headers: { authorization: "Bearer tok" }, + }); +} + +const aiPending = { + id: "i1", + sku: "X1", + description: "Widget", + pricing_source: "ai_pos", + ai_fill: { status: "filled", stock_item_id: "s1" }, +}; + +beforeEach(() => { + vi.clearAllMocks(); + // The route's session check validates the token against PB. + vi.stubGlobal("fetch", vi.fn().mockResolvedValue({ ok: true, json: async () => ({}) })); + suppliersGetList.mockResolvedValue({ items: [] }); + findFirstPriceListBySku.mockResolvedValue({ + id: "pl1", + sku: "X1", + description: "Catalogue widget", + cost_price: 10, + selling_price: 15, + supplier: "Acme", + }); +}); + +describe("POST /api/v1/quotes/[id]/re-price — AI line guard", () => { + it("skips an unreviewed AI line and never looks it up", async () => { + getFullList.mockResolvedValue([aiPending]); + const res = await POST(makeReq(), ctx); + const body = await res.json(); + expect(res.status).toBe(200); + expect(body.perItem).toEqual([{ itemId: "i1", sku: "X1", outcome: "skipped" }]); + expect(body.skipped).toBe(1); + expect(body.matched).toBe(0); + expect(findFirstPriceListBySku).not.toHaveBeenCalled(); + expect(update).not.toHaveBeenCalled(); + }); + + it("still re-prices an AI line once it has been reviewed", async () => { + getFullList.mockResolvedValue([ + { ...aiPending, ai_fill: { ...aiPending.ai_fill, reviewed_at: "2026-10-16T10:00:00Z" } }, + ]); + const res = await POST(makeReq(), ctx); + const body = await res.json(); + expect(findFirstPriceListBySku).toHaveBeenCalledWith("X1"); + expect(body.matched).toBe(1); + expect(update).toHaveBeenCalledTimes(1); + }); + + it("re-prices a normal SKU line", async () => { + getFullList.mockResolvedValue([{ id: "i2", sku: "X1", description: "Widget" }]); + const res = await POST(makeReq(), ctx); + expect((await res.json()).matched).toBe(1); + expect(findFirstPriceListBySku).toHaveBeenCalledWith("X1"); + }); +}); diff --git a/src/app/api/v1/quotes/[id]/re-price/route.ts b/src/app/api/v1/quotes/[id]/re-price/route.ts index 02f3728..636fcf6 100644 --- a/src/app/api/v1/quotes/[id]/re-price/route.ts +++ b/src/app/api/v1/quotes/[id]/re-price/route.ts @@ -4,6 +4,8 @@ import { type PriceListMatch, } from "@/lib/server/price-list-match"; import { getAdminPocketBase } from "@/lib/server/pocketbase-admin"; +import { isAiFillPending } from "@/lib/quote-assistant-review"; +import type { QuoteItemAiFill } from "@/lib/types"; /** * POST /api/v1/quotes/[id]/re-price @@ -161,6 +163,8 @@ export async function POST( selling_price?: number; markup?: number; supplier?: string; + pricing_source: string; + ai_fill?: QuoteItemAiFill | null; }>({ filter: `quote = "${quoteId.replace(/"/g, '\\"')}"`, sort: "+created", @@ -173,6 +177,13 @@ export async function POST( for (const item of items) { const sku = (item.sku ?? "").trim(); + // Never overwrite an AI-filled line nobody has reviewed yet: + // the rep's review (approve / edit) comes first. + if (isAiFillPending(item)) { + skipped++; + perItem.push({ itemId: item.id, sku, outcome: "skipped" }); + continue; + } if (!sku) { skipped++; perItem.push({ itemId: item.id, sku: "", outcome: "skipped" }); diff --git a/src/app/api/v1/quotes/assistant/counts/route.test.ts b/src/app/api/v1/quotes/assistant/counts/route.test.ts new file mode 100644 index 0000000..ada3ef4 --- /dev/null +++ b/src/app/api/v1/quotes/assistant/counts/route.test.ts @@ -0,0 +1,65 @@ +import { describe, it, expect, beforeEach, vi } from "vitest"; +import { NextRequest } from "next/server"; + +const getAssistantCounts = vi.fn(); +vi.mock("@/lib/server/quote-assistant-service", () => ({ + getAssistantCounts: (...a: unknown[]) => getAssistantCounts(...a), +})); + +const getBrowserSessionUser = vi.fn(); +vi.mock("@/lib/server/browser-session", () => ({ + getBrowserSessionUser: (...a: unknown[]) => getBrowserSessionUser(...a), +})); + +import { GET } from "./route"; + +function makeReq(qs = "") { + return new NextRequest(`http://localhost/api/v1/quotes/assistant/counts${qs}`, { + headers: { authorization: "Bearer tok" }, + }); +} + +beforeEach(() => { + vi.clearAllMocks(); + getBrowserSessionUser.mockResolvedValue({ id: "u1", user_type: null, name: "Sam" }); +}); + +describe("GET /api/v1/quotes/assistant/counts", () => { + it("401s without a valid session", async () => { + getBrowserSessionUser.mockRejectedValue( + Object.assign(new Error("Not authenticated."), { status: 401 }), + ); + const res = await GET(makeReq("?ids=a")); + expect(res.status).toBe(401); + expect(getAssistantCounts).not.toHaveBeenCalled(); + }); + + it("400s when ids is missing or empty", async () => { + expect((await GET(makeReq())).status).toBe(400); + expect((await GET(makeReq("?ids="))).status).toBe(400); + expect((await GET(makeReq("?ids=,"))).status).toBe(400); + expect(getAssistantCounts).not.toHaveBeenCalled(); + }); + + it("400s when more than 500 ids are passed", async () => { + const ids = Array.from({ length: 501 }, (_, i) => `id${i}`).join(","); + const res = await GET(makeReq(`?ids=${ids}`)); + expect(res.status).toBe(400); + expect(getAssistantCounts).not.toHaveBeenCalled(); + }); + + it("passes the split, trimmed ids to the service", async () => { + getAssistantCounts.mockResolvedValue({ a: { pending: 1, total: 3 } }); + const res = await GET(makeReq("?ids=a,%20b,c")); + expect(getAssistantCounts).toHaveBeenCalledWith(["a", "b", "c"]); + expect(res.status).toBe(200); + expect(await res.json()).toEqual({ a: { pending: 1, total: 3 } }); + }); + + it("accepts exactly 500 ids", async () => { + getAssistantCounts.mockResolvedValue({}); + const ids = Array.from({ length: 500 }, (_, i) => `id${i}`).join(","); + const res = await GET(makeReq(`?ids=${ids}`)); + expect(res.status).toBe(200); + }); +}); diff --git a/src/app/api/v1/quotes/assistant/counts/route.ts b/src/app/api/v1/quotes/assistant/counts/route.ts new file mode 100644 index 0000000..9d8c9f8 --- /dev/null +++ b/src/app/api/v1/quotes/assistant/counts/route.ts @@ -0,0 +1,45 @@ +import { NextRequest, NextResponse } from "next/server"; +import { getBrowserSessionUser } from "@/lib/server/browser-session"; +import { getAssistantCounts } from "@/lib/server/quote-assistant-service"; + +/** + * GET /api/v1/quotes/assistant/counts?ids=a,b,c + * + * Per-quote AI-fill counts for the Quotes board badge: + * { [quoteId]: { pending, total } } + * Quotes with no lines are absent from the result. + * + * Auth: browser session (PB token forwarded in `Authorization`). + * 400 when `ids` is missing/empty or has more than 500 entries. + */ +export const runtime = "nodejs"; +export const dynamic = "force-dynamic"; + +const MAX_IDS = 500; + +export async function GET(req: NextRequest) { + try { + await getBrowserSessionUser(req); + + const raw = new URL(req.url).searchParams.get("ids") ?? ""; + const ids = raw + .split(",") + .map((s) => s.trim()) + .filter(Boolean); + if (ids.length === 0) { + return NextResponse.json({ error: "ids is required" }, { status: 400 }); + } + if (ids.length > MAX_IDS) { + return NextResponse.json( + { error: `ids may contain at most ${MAX_IDS} entries` }, + { status: 400 }, + ); + } + + return NextResponse.json(await getAssistantCounts(ids)); + } catch (err) { + const message = err instanceof Error ? err.message : "Counts failed"; + const status = (err as Error & { status?: number }).status || 500; + return NextResponse.json({ error: message }, { status }); + } +} diff --git a/src/app/api/v1/quotes/assistant/tick/route.test.ts b/src/app/api/v1/quotes/assistant/tick/route.test.ts new file mode 100644 index 0000000..f328f29 --- /dev/null +++ b/src/app/api/v1/quotes/assistant/tick/route.test.ts @@ -0,0 +1,55 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; +import { NextRequest } from "next/server"; + +const runAssistantTick = vi.fn(); +vi.mock("@/lib/server/quote-assistant-service", () => ({ + runAssistantTick: (...a: unknown[]) => runAssistantTick(...a), +})); + +import { POST } from "./route"; + +function makeReq(token?: string) { + return new NextRequest("http://localhost/api/v1/quotes/assistant/tick", { + method: "POST", + headers: token ? { authorization: `Bearer ${token}` } : {}, + }); +} + +let prevKey: string | undefined; +beforeEach(() => { + vi.clearAllMocks(); + prevKey = process.env.AGENT_API_KEY; + process.env.AGENT_API_KEY = "good-key"; +}); +afterEach(() => { + if (prevKey === undefined) delete process.env.AGENT_API_KEY; + else process.env.AGENT_API_KEY = prevKey; +}); + +describe("POST /api/v1/quotes/assistant/tick", () => { + it("401s without a key", async () => { + const res = await POST(makeReq()); + expect(res.status).toBe(401); + expect(runAssistantTick).not.toHaveBeenCalled(); + }); + + it("401s with a wrong key", async () => { + const res = await POST(makeReq("bad-key")); + expect(res.status).toBe(401); + expect(runAssistantTick).not.toHaveBeenCalled(); + }); + + it("returns the tick result for a good key", async () => { + runAssistantTick.mockResolvedValue({ ran: false, reason: "not_due" }); + const res = await POST(makeReq("good-key")); + expect(res.status).toBe(200); + expect(await res.json()).toEqual({ ran: false, reason: "not_due" }); + }); + + it("500s when the tick throws", async () => { + runAssistantTick.mockRejectedValue(new Error("boom")); + const res = await POST(makeReq("good-key")); + expect(res.status).toBe(500); + expect(await res.json()).toEqual({ error: "boom" }); + }); +}); diff --git a/src/app/api/v1/quotes/assistant/tick/route.ts b/src/app/api/v1/quotes/assistant/tick/route.ts new file mode 100644 index 0000000..952e0f0 --- /dev/null +++ b/src/app/api/v1/quotes/assistant/tick/route.ts @@ -0,0 +1,30 @@ +import { NextRequest, NextResponse } from "next/server"; +import { assertAgentAuth } from "@/lib/server/agent-auth"; +import { runAssistantTick } from "@/lib/server/quote-assistant-service"; + +/** + * POST /api/v1/quotes/assistant/tick + * + * Scheduled entry point for the AI quote assistant. n8n calls this every + * 5 minutes; the service itself decides whether anything is due (AI and + * the assistant switched on, configured interval elapsed). + * + * Auth: `assertAgentAuth` (Bearer `AGENT_API_KEY`). + * + * Response: the `runAssistantTick` result, e.g. + * { ran: false, reason: "disabled" | "not_due" } or { ran: true, summary } + */ +export const runtime = "nodejs"; +export const dynamic = "force-dynamic"; + +export async function POST(req: NextRequest) { + try { + assertAgentAuth(req); + const result = await runAssistantTick(); + return NextResponse.json(result); + } catch (err) { + const message = err instanceof Error ? err.message : "Assistant tick failed"; + const status = (err as Error & { status?: number }).status || 500; + return NextResponse.json({ error: message }, { status }); + } +} diff --git a/src/lib/server/giellie-tool-policy.test.ts b/src/lib/server/giellie-tool-policy.test.ts index 50906e8..b810b74 100644 --- a/src/lib/server/giellie-tool-policy.test.ts +++ b/src/lib/server/giellie-tool-policy.test.ts @@ -94,6 +94,10 @@ describe("classifyTool", () => { expect(classifyTool("stock_purchases.next_number")).toBe("write"); }); + it("treats quotes.assistant_fill as write (AI-spending, writes lines)", () => { + expect(classifyTool("quotes.assistant_fill")).toBe("write"); + }); + it("default-denies (write) unknown tools", () => { expect(classifyTool("brand_new.tool")).toBe("write"); });