feat(quote-assistant): tick/run/counts routes, MCP tool, re-price guard
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
bb06165318
commit
d34be52a66
11 changed files with 465 additions and 0 deletions
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -391,6 +391,14 @@ const TOOL_HINTS: Record<string, { hint: string; nextActions: string[] }> = {
|
|||
"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));
|
||||
|
|
|
|||
89
src/app/api/v1/quotes/[id]/assistant/run/route.test.ts
Normal file
89
src/app/api/v1/quotes/[id]/assistant/run/route.test.ts
Normal file
|
|
@ -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" });
|
||||
});
|
||||
});
|
||||
43
src/app/api/v1/quotes/[id]/assistant/run/route.ts
Normal file
43
src/app/api/v1/quotes/[id]/assistant/run/route.ts
Normal file
|
|
@ -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 });
|
||||
}
|
||||
}
|
||||
86
src/app/api/v1/quotes/[id]/re-price/route.test.ts
Normal file
86
src/app/api/v1/quotes/[id]/re-price/route.test.ts
Normal file
|
|
@ -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");
|
||||
});
|
||||
});
|
||||
|
|
@ -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" });
|
||||
|
|
|
|||
65
src/app/api/v1/quotes/assistant/counts/route.test.ts
Normal file
65
src/app/api/v1/quotes/assistant/counts/route.test.ts
Normal file
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
45
src/app/api/v1/quotes/assistant/counts/route.ts
Normal file
45
src/app/api/v1/quotes/assistant/counts/route.ts
Normal file
|
|
@ -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 });
|
||||
}
|
||||
}
|
||||
55
src/app/api/v1/quotes/assistant/tick/route.test.ts
Normal file
55
src/app/api/v1/quotes/assistant/tick/route.test.ts
Normal file
|
|
@ -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" });
|
||||
});
|
||||
});
|
||||
30
src/app/api/v1/quotes/assistant/tick/route.ts
Normal file
30
src/app/api/v1/quotes/assistant/tick/route.ts
Normal file
|
|
@ -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 });
|
||||
}
|
||||
}
|
||||
|
|
@ -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");
|
||||
});
|
||||
|
|
|
|||
Loading…
Reference in a new issue