From 85533245bfd2d10c0fa05467c0dd478ec708d1e3 Mon Sep 17 00:00:00 2001 From: Dave Date: Sat, 24 Oct 2026 13:06:51 +0200 Subject: [PATCH] feat(orders): order assistant routes, MCP tool and runbook Task 7 of the AI order assistant plan. - POST /api/v1/orders/assistant/tick (n8n, assertAgentAuth) - POST /api/v1/orders/{id}/assistant/run (browser session; 409 for "AI is disabled" and "not set up yet") - MCP orders.assistant_match { orderId } -> matchOrder(force) with a TOOL_HINTS entry; deliberately NOT in READ_TOOLS - docs/order-assistant.md runbook and the AGENTS.md section Co-Authored-By: Claude Opus 5.5 --- AGENTS.md | 14 ++ docs/order-assistant.md | 178 ++++++++++++++++++ src/app/api/mcp/route.test.ts | 49 ++++- src/app/api/mcp/route.ts | 43 +++++ .../orders/[id]/assistant/run/route.test.ts | 88 +++++++++ .../api/v1/orders/[id]/assistant/run/route.ts | 44 +++++ .../v1/orders/assistant/tick/route.test.ts | 55 ++++++ src/app/api/v1/orders/assistant/tick/route.ts | 30 +++ 8 files changed, 499 insertions(+), 2 deletions(-) create mode 100644 docs/order-assistant.md create mode 100644 src/app/api/v1/orders/[id]/assistant/run/route.test.ts create mode 100644 src/app/api/v1/orders/[id]/assistant/run/route.ts create mode 100644 src/app/api/v1/orders/assistant/tick/route.test.ts create mode 100644 src/app/api/v1/orders/assistant/tick/route.ts diff --git a/AGENTS.md b/AGENTS.md index daec7aa..ea3a38d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -194,6 +194,20 @@ Runbook: `docs/quote-assistant.md`. Files: `src/lib/server/quote-assistant-{core - `pricing_source "ai_pos"` must survive human saves: route every save through `nextPricingSourceOnSave` / `withAiReview`, or a stale page turns the line back to `manual`. - Manual live steps (like select values and pb_hooks): run `npm run pb:migrate:quote-assistant` against the live PB before deploying, then activate the n8n workflow "WFDASH Quote assistant tick" (every 5 min, POSTs the tick route). See the runbook. +# AI order assistant — matches orders to the quote they came from + +Runbook: `docs/order-assistant.md`. Files: `src/lib/server/order-assistant-{core,ai,prompt,service}.ts` +(eligibility + auto-link rule, AI call, default prompt, orchestration) and +`src/lib/server/order-quote-link-service.ts` (the deterministic shortlist). Routes: +`POST /api/v1/orders/assistant/tick` (n8n, `assertAgentAuth`), +`POST /api/v1/orders/{id}/assistant/run` (browser), MCP `orders.assistant_match`. Settings live in +Settings → AI → Order assistant. + +- `orders.quote` is written **only** through `setOrderQuote`, inside `runWithAuditActor({ name: "AI order assistant" }, …)`. A 409 from it is not an error — the assistant falls back to a suggestion. +- Auto-link needs **all** of: the setting on, confidence `high`, every real order line paired `same_item "sure"`, every pair on a line of the one chosen quote, and the quote's company = the order's company. Otherwise it only writes `potential_quote` (confidence 0.9 / 0.7 / 0.5) and `quote_match_method "ai_suggest"`. +- The AI never changes a line's status, creates a PO or sends anything. An AI failure is recorded on `orders.ai_quote_match` with status `error` and is never thrown out of `matchOrder`. +- Manual live steps (like select values and pb_hooks): run `npm run pb:migrate:order-assistant` against the live PB, publish the `ai_quote_match` / `ai_fill` `IGNORED_FIELDS` hook change, deploy, dry-run then `--apply` the stale-suggestion clean-up script, activate the n8n workflow "WFDASH Order assistant tick" (every 5 min), then switch the order assistant on in Settings → AI. See the runbook. + # AI Collection Confirm checks — reads driver photos, ticks lines, never confirms Runbook: `docs/collection-ai-checks.md`. Files: `src/lib/server/collection-ai-{read,pair,findings,service,prompt}.ts` diff --git a/docs/order-assistant.md b/docs/order-assistant.md new file mode 100644 index 0000000..6307ea3 --- /dev/null +++ b/docs/order-assistant.md @@ -0,0 +1,178 @@ +# AI order assistant runbook + +Design: `docs/superpowers/specs/2026-10-24-ai-order-assistant-design.md`. +Plan: `docs/superpowers/plans/2026-10-24-ai-order-assistant.md`. + +## What it does + +An order that came from a quote should point at that quote. Today only an +order created *from* a quote carries the link; every other order — a +walk-in, an email intake, a counter sale — has to be linked by hand. + +The order assistant works out which quote an order came from. On a +schedule it looks at orders that have no quote yet, asks the AI which of +the candidate quotes the order came from, and then either: + +- **links** the order to the quote itself (when it is sure), or +- **suggests** the quote, and a person accepts it. + +The AI never touches prices, never changes a line's status, never creates a +purchase order and never sends anything. It only decides which quote an +order belongs to. + +## The auto-link rule, in plain words + +The assistant links an order to a quote **only** when all of these are +true: + +- The **"Auto-link when sure"** setting is on (it is on by default). +- The AI says it is **sure** about the match. +- **Every** real order line is paired to a quote line, and each of those + pairs is a **sure** match. +- **Every** pair points at a line of the **one** chosen quote (not a + mixture of two quotes). +- The chosen quote belongs to the **same company** as the order. + +If any of those fail, the assistant only writes a **suggestion**. The +order page then shows "AI suggests Q…" with the reason, the doubts and the +line-by-line pairing, and the existing **Link** button is there to accept +it. + +The assistant also refuses to link when PocketBase says no — for example +when a line on the order has already moved past `pending`, or when +another order already points at that quote. A refusal is **not** an error: +the assistant falls back to a suggestion. + +## Going live (manual steps, in this order) + +1. **Migrate PocketBase.** Run against the live PB: + `npm run pb:migrate:order-assistant` + (adds `orders.ai_quote_match`, `order_items.ai_fill` and the + `order_assistant_*` fields on `ai_settings`). If the app is deployed + before this, PocketBase drops the unknown fields silently and the + assistant's result lands nowhere. +2. **Publish the PB hook change.** `ai_quote_match` and `ai_fill` were + added to `IGNORED_FIELDS` in `pocketbase/pb_hooks/audit_log_utils.js`, + so the AI's own writes do not flood the audit log. Publish it the same + way the other hook changes are published + (`npm run pb:hooks:publish`). +3. **Deploy** the app. Pushing to main does not auto-deploy; it is a + manual deploy that takes 5 to 10 minutes. +4. **Clean up the stale suggestions.** The old rule-based matcher wrote + soft suggestions that were not backed by a matching line (one quote + was suggested to 16 different orders). Run the clean-up script against + the live PB — **dry run first**: + ```bash + npx tsx scripts/2026-10-24-rematch-quote-suggestions.ts + npx tsx scripts/2026-10-24-rematch-quote-suggestions.ts --apply + ``` + The dry run only reads. Read its report before running it with + `--apply`. +5. **Activate the n8n clock** (below). +6. **Turn it on** in Settings, AI: the global "AI enabled" switch must be + on, then switch on **"Order assistant enabled"** in the Order assistant + section and click Save. Defaults: every 10 minutes, auto-link on. + +The tick does nothing until both switches are on, so steps 5 and 6 are +safe in either order. + +## n8n workflow: "WFDASH Order assistant tick" + +n8n only acts as a clock. The app decides whether a run is due (AI and +the assistant on, and at least "Run every" minutes since the last run), so +the setting in the app controls the real frequency. + +Spec: + +| Node | Setting | +|---|---| +| Name | `WFDASH Order assistant tick` | +| Trigger | Schedule Trigger, every 5 minutes | +| Action | HTTP Request | +| Method / URL | `POST` `https://wfdash.apphub.co.za/api/v1/orders/assistant/tick` | +| Authentication | Generic Credential Type, Header Auth, **the same credential** the quote assistant tick workflow uses (an `Authorization: Bearer ` header). Do not create a new secret. | +| Body | none | +| Settings tab | Retry On Fail: **off**; Timeout: **120000 ms** (120 s) | +| Status | **inactive** until the app is deployed and migrated | + +Click-by-click: + +1. n8n, Workflows, Create workflow. Name it `WFDASH Order assistant tick`. +2. Add node, Schedule Trigger. Trigger Interval: Minutes, Minutes Between + Triggers: `5`. +3. Add node, HTTP Request, connect it after the trigger. + - Method: `POST`. + - URL: `https://wfdash.apphub.co.za/api/v1/orders/assistant/tick`. + - Authentication: Generic Credential Type, Generic Auth Type: Header + Auth. Pick the credential used by the quote assistant tick workflow + (open that workflow and read its HTTP Request node to see which one). + - Options, Add option, Timeout: `120000`. + - Settings tab on the node: turn **Retry On Fail** off. +4. Save. Leave the workflow **inactive** until the steps above are done. +5. After deploy, click Execute workflow once. Expected response while the + assistant is off: `{"ran":false,"reason":"disabled"}`. When on and not + yet due: `{"ran":false,"reason":"not_due"}`. While an earlier run is + still going: `{"ran":false,"reason":"running"}`. When it ran: + `{"ran":true,"summary":{...}}`. +6. Toggle the workflow Active. + +A 401 means the credential is wrong (it must send the app's +`AGENT_API_KEY` as a Bearer token). A tick that takes long is normal when +there is work. Each run is bounded: at most **10 orders**, oldest first, +and it stops starting new orders once 90 seconds have passed, so a run +normally answers inside the 120 s timeout; leftovers carry to the next run. + +Runs never overlap. A run stamps its start time before it does any work and +writes its summary when it ends. While a start is newer than the last +summary and less than 10 minutes old, other ticks answer +`{"ran":false,"reason":"running"}`. If a run dies without writing its +summary, the next tick after those 10 minutes takes over. + +## Running it by hand + +- **Order page** — the **"Match with AI"** button on the quote match card + runs the assistant for that one order and reloads the order. +- **MCP tool** — `orders.assistant_match` with `{"orderId": "..."}`. + +Both force a run past the "already looked at these lines" rule. + +## Reading `ai_quote_match` + +`orders.ai_quote_match` (JSON) is the record of what the assistant decided +about an order. Read it in the PB admin. + +| Field | Meaning | +|---|---| +| `status` | `matched` (the order was linked), `suggested`, `no_match` or `error` | +| `quote_id`, `quote_number` | The quote the assistant chose | +| `confidence` | `high`, `medium` or `low` | +| `reason`, `doubts` | The AI's plain-language reasoning and anything to check | +| `lines` | The pairing: order line id → quote line id, with `same_item` (`sure` / `likely` / `unsure`) | +| `auto_linked` | True only when the assistant itself created the link | +| `lines_hash` | Fingerprint of the order lines the run looked at — the same lines are not looked at twice | +| `model` | The model that answered | +| `run_at` | When the run happened | +| `error` | Set when `status` is `error` | + +## Settings (Settings → AI → Order assistant) + +| Setting | Default | Meaning | +|---|---|---| +| Order assistant enabled | off | The tick does nothing while this is off | +| Run every (minutes) | 10 | How often the tick may run | +| Auto-link when sure | on | May the assistant link the order itself? | +| Fill lines from the quote | on | Reserved for the next phase — the assistant does not fill lines yet | +| Prompt | empty | Overrides the built-in prompt when set | + +`orders.ai_quote_match` and `order_items.ai_fill` are in `IGNORED_FIELDS` +of the audit hook, so the assistant's own writes do not flood the audit +log. + +## Not in this phase + +- The assistant does **not** fill order lines from the quote yet — that is + the next phase. The `order_items.ai_fill` field and the "Fill lines from + the quote" switch are already in place for it. +- The assistant does not touch prices, suppliers or SKUs. +- It never creates a purchase order, never sends an email and never + changes a line's status. diff --git a/src/app/api/mcp/route.test.ts b/src/app/api/mcp/route.test.ts index 26464ea..9e75022 100644 --- a/src/app/api/mcp/route.test.ts +++ b/src/app/api/mcp/route.test.ts @@ -34,6 +34,13 @@ vi.mock("@/lib/server/delivery-notes-service", () => ({ setDeliveryNoteOrder: (...args: unknown[]) => setDeliveryNoteOrder(...args), })); +const matchOrder = vi.fn(); +vi.mock("@/lib/server/order-assistant-service", () => ({ + matchOrder: (...args: unknown[]) => matchOrder(...args), + AI_DISABLED_ERROR: "AI is disabled in settings", + NOT_SET_UP_ERROR: "Order assistant is not set up yet", +})); + const createQuoteFromEmail = vi.fn(); const appendReplyToQuote = vi.fn(); const findQuoteByMessageId = vi.fn(); @@ -163,6 +170,7 @@ describe("POST /api/mcp", () => { createQuoteFromEmail.mockReset(); appendReplyToQuote.mockReset(); findQuoteByMessageId.mockReset(); + matchOrder.mockReset(); createCompany.mockReset(); updateCompany.mockReset(); deleteCompany.mockReset(); @@ -237,6 +245,9 @@ const toolNames = body.result.tools.map((t: { name: string }) => t.name); // Hermes unblock (PR 1, 2026-08-21): // set_quote writes the orders.quote relation for an existing order "orders.set_quote", + // AI order assistant (2026-10-24): matches an order to the quote + // it came from; links only when the AI is sure. + "orders.assistant_match", // Backlog sweep for the historical orders the intake matcher // never saw (2026-08-30). "orders.quote_match_sweep", @@ -890,8 +901,42 @@ const toolNames = body.result.tools.map((t: { name: string }) => t.name); expect(r2.status).toBeGreaterThanOrEqual(400); }); - it("tools/call orders.find_by_order_number delegates to the service", async () => { - findOrderByOrderNumber.mockResolvedValueOnce({ id: "o1", order_number: "ORD-1" }); + it("tools/call orders.assistant_match delegates to the order assistant service", async () => { + matchOrder.mockResolvedValueOnce({ + orderId: "o1", + outcome: "suggested", + quoteId: "q1", + error: "", + }); + const res = await POST( + mcpRequest({ + method: "tools/call", + id: 69, + params: { name: "orders.assistant_match", arguments: { orderId: "o1" } }, + }), + ); + expect(res.status).toBe(200); + expect(matchOrder).toHaveBeenCalledWith("o1", { force: true }); + const body = await res.json(); + const data = JSON.parse(body.result.content[0].text); + expect(data.outcome).toBe("suggested"); + expect(data.quoteId).toBe("q1"); + expect(data.hint).toBeTruthy(); + }); + + it("tools/call orders.assistant_match fails fast without an orderId", async () => { + const res = await POST( + mcpRequest({ + method: "tools/call", + id: 70, + params: { name: "orders.assistant_match", arguments: {} }, + }), + ); + expect(res.status).toBeGreaterThanOrEqual(400); + expect(matchOrder).not.toHaveBeenCalled(); + }); + + it("tools/call orders.find_by_order_number delegates to the service", async () => { findOrderByOrderNumber.mockResolvedValueOnce({ id: "o1", order_number: "ORD-1" }); const res = await POST( mcpRequest({ method: "tools/call", diff --git a/src/app/api/mcp/route.ts b/src/app/api/mcp/route.ts index 0efb01e..bfba0e0 100644 --- a/src/app/api/mcp/route.ts +++ b/src/app/api/mcp/route.ts @@ -399,6 +399,14 @@ const TOOL_HINTS: Record = { "Filled lines stay purple until a rep reviews them — do not mark them done or send the quote", ], }, + "orders.assistant_match": { + hint: "The run reports problems in `error` instead of throwing: \"AI is disabled in settings\", \"Order assistant is not set up yet…\" (the PB migration has not run) or \"AI call failed…\" (retried automatically after an hour). Deterministic ones should not be retried. A `skipped` outcome means the order has no quote yet but nothing to decide on (no real lines, already looked at, or a recent AI error).", + nextActions: [ + "If the AI is disabled, ask the operator to enable it in /settings/ai", + "If it is not set up, ask the operator to run the order-assistant PB migration", + "When the outcome is 'suggested', a person must accept the suggestion with orders.set_quote — the tool only suggests", + ], + }, "collection_confirm.ai_check": { hint: "The check reports problems in `error` instead of throwing: \"Collection checks are not set up yet…\" (the PB migration has not run) or \"AI is disabled in settings\". Deterministic ones should not be retried. The check only writes the line `ai_check`, the document findings and an empty line SKU; it never changes cost, quantities, status or invoice details.", nextActions: [ @@ -905,6 +913,25 @@ const tools = [ }, }, }, + { + name: "orders.assistant_match", + description: + "Run the AI order assistant on one order now: it works out which quote the order came from, and " + + "links the order to that quote when it is sure. Mirrors the REST POST " + + "/api/v1/orders/{id}/assistant/run endpoint.\n\n" + + "Auto-linking happens only when the AI is sure: high confidence, every real order line paired " + + "'sure', every pair on a line of the one chosen quote, and the quote belonging to the order's " + + "company. Otherwise the quote is only a suggestion (`potential_quote`) for a person to accept.\n\n" + + "Never changes a line's status, never creates a purchase order and never sends anything.\n\n" + + "Returns `{ orderId, outcome: linked | suggested | no_match | skipped | error, quoteId, error }`.", + inputSchema: { + type: "object", + required: ["orderId"], + properties: { + orderId: { type: "string", description: "Order id (PB 15-char)." }, + }, + }, + }, { name: "orders.quote_match_sweep", description: @@ -4095,6 +4122,22 @@ async function handleMcpPost(req: NextRequest) { }); } + if (toolName === "orders.assistant_match") { + const orderId = String(args.orderId || "").trim(); + if (!orderId) throw new Error("Missing orderId for orders.assistant_match."); + // Called directly rather than through the REST route: the run + // route is behind the browser session, and this tool carries the + // agent's own auth. + const { matchOrder } = await import("@/lib/server/order-assistant-service"); + const result = await matchOrder(orderId, { force: true }); + const hintEntry = TOOL_HINTS["orders.assistant_match"]; + return jsonRpcOk(body.id, { + ...result, + hint: hintEntry?.hint, + nextActions: hintEntry?.nextActions, + }); + } + if (toolName === "orders.quote_match_sweep") { // Forward to the REST route — keeps the surface in lock-step // with the agent-auth route and surfaces the structured 4xx/5xx diff --git a/src/app/api/v1/orders/[id]/assistant/run/route.test.ts b/src/app/api/v1/orders/[id]/assistant/run/route.test.ts new file mode 100644 index 0000000..54287d4 --- /dev/null +++ b/src/app/api/v1/orders/[id]/assistant/run/route.test.ts @@ -0,0 +1,88 @@ +import { describe, it, expect, beforeEach, vi } from "vitest"; +import { NextRequest } from "next/server"; + +const matchOrder = vi.fn(); +vi.mock("@/lib/server/order-assistant-service", () => ({ + matchOrder: (...a: unknown[]) => matchOrder(...a), + AI_DISABLED_ERROR: "AI is disabled in settings", + NOT_SET_UP_ERROR: "Order assistant is not set up yet", +})); + +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/orders/o1/assistant/run", { + method: "POST", + headers: { authorization: "Bearer tok" }, + }); +} +const ctx = { params: Promise.resolve({ id: "o1" }) }; + +beforeEach(() => { + vi.clearAllMocks(); + getBrowserSessionUser.mockResolvedValue({ id: "u1", user_type: null, name: "Sam" }); +}); + +describe("POST /api/v1/orders/[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(matchOrder).not.toHaveBeenCalled(); + }); + + it("matches the order, forced", async () => { + const result = { orderId: "o1", outcome: "linked", quoteId: "q1", error: "" }; + matchOrder.mockResolvedValue(result); + const res = await POST(makeReq(), ctx); + expect(matchOrder).toHaveBeenCalledWith("o1", { force: true }); + expect(res.status).toBe(200); + expect(await res.json()).toEqual(result); + }); + + it("409s when the AI is disabled", async () => { + matchOrder.mockResolvedValue({ + orderId: "o1", + outcome: "error", + quoteId: null, + 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("409s when the migration hasn't run yet", async () => { + matchOrder.mockResolvedValue({ + orderId: "o1", + outcome: "error", + quoteId: null, + error: "Order assistant is not set up yet", + }); + const res = await POST(makeReq(), ctx); + expect(res.status).toBe(409); + expect(await res.json()).toEqual({ error: "Order assistant is not set up yet" }); + }); + + it("returns 200 with the result when the AI call failed", async () => { + const result = { orderId: "o1", outcome: "error", quoteId: null, error: "AI call failed: timeout" }; + matchOrder.mockResolvedValue(result); + const res = await POST(makeReq(), ctx); + expect(res.status).toBe(200); + expect(await res.json()).toEqual(result); + }); + + it("500s when matchOrder throws unexpectedly", async () => { + matchOrder.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/orders/[id]/assistant/run/route.ts b/src/app/api/v1/orders/[id]/assistant/run/route.ts new file mode 100644 index 0000000..bc44c4e --- /dev/null +++ b/src/app/api/v1/orders/[id]/assistant/run/route.ts @@ -0,0 +1,44 @@ +import { NextRequest, NextResponse } from "next/server"; +import { getBrowserSessionUser } from "@/lib/server/browser-session"; +import { + AI_DISABLED_ERROR, + matchOrder, + NOT_SET_UP_ERROR, +} from "@/lib/server/order-assistant-service"; + +/** + * POST /api/v1/orders/{id}/assistant/run + * + * Manual "match this order to a quote with AI" (the button on the + * order page). Forces a run past the retry rules. + * + * Auth: browser session (PB token forwarded in `Authorization`). + * + * Response: the `MatchOrderResult`. 409 when the AI is switched off or + * the order-assistant migration hasn't run yet. An AI-call failure is + * not an HTTP error: it comes back as 200 with `error` set and the + * failure recorded on `orders.ai_quote_match`. + */ +export const runtime = "nodejs"; +export const dynamic = "force-dynamic"; + +export async function POST( + req: NextRequest, + { params }: { params: Promise<{ id: string }> }, +) { + try { + await getBrowserSessionUser(req); + const { id } = await params; + + const result = await matchOrder(id, { force: true }); + + if (result.error === AI_DISABLED_ERROR || result.error === NOT_SET_UP_ERROR) { + 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/orders/assistant/tick/route.test.ts b/src/app/api/v1/orders/assistant/tick/route.test.ts new file mode 100644 index 0000000..4cdb16c --- /dev/null +++ b/src/app/api/v1/orders/assistant/tick/route.test.ts @@ -0,0 +1,55 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; +import { NextRequest } from "next/server"; + +const runOrderAssistantTick = vi.fn(); +vi.mock("@/lib/server/order-assistant-service", () => ({ + runOrderAssistantTick: (...a: unknown[]) => runOrderAssistantTick(...a), +})); + +import { POST } from "./route"; + +function makeReq(token?: string) { + return new NextRequest("http://localhost/api/v1/orders/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/orders/assistant/tick", () => { + it("401s without a key", async () => { + const res = await POST(makeReq()); + expect(res.status).toBe(401); + expect(runOrderAssistantTick).not.toHaveBeenCalled(); + }); + + it("401s with a wrong key", async () => { + const res = await POST(makeReq("bad-key")); + expect(res.status).toBe(401); + expect(runOrderAssistantTick).not.toHaveBeenCalled(); + }); + + it("returns the tick result for a good key", async () => { + runOrderAssistantTick.mockResolvedValue({ ran: false, reason: "disabled" }); + const res = await POST(makeReq("good-key")); + expect(res.status).toBe(200); + expect(await res.json()).toEqual({ ran: false, reason: "disabled" }); + }); + + it("500s when the tick throws", async () => { + runOrderAssistantTick.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/orders/assistant/tick/route.ts b/src/app/api/v1/orders/assistant/tick/route.ts new file mode 100644 index 0000000..012c807 --- /dev/null +++ b/src/app/api/v1/orders/assistant/tick/route.ts @@ -0,0 +1,30 @@ +import { NextRequest, NextResponse } from "next/server"; +import { assertAgentAuth } from "@/lib/server/agent-auth"; +import { runOrderAssistantTick } from "@/lib/server/order-assistant-service"; + +/** + * POST /api/v1/orders/assistant/tick + * + * Scheduled entry point for the AI order 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 `runOrderAssistantTick` result, e.g. + * { ran: false, reason: "disabled" | "not_due" | "running" } 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 runOrderAssistantTick(); + 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 }); + } +}