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 <noreply@anthropic.com>
This commit is contained in:
Dave 2026-10-24 13:06:51 +02:00
parent a04d58b781
commit 85533245bf
8 changed files with 499 additions and 2 deletions

View file

@ -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`. - `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. - 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 # 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` Runbook: `docs/collection-ai-checks.md`. Files: `src/lib/server/collection-ai-{read,pair,findings,service,prompt}.ts`

178
docs/order-assistant.md Normal file
View file

@ -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 <AGENT_API_KEY>` 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.

View file

@ -34,6 +34,13 @@ vi.mock("@/lib/server/delivery-notes-service", () => ({
setDeliveryNoteOrder: (...args: unknown[]) => setDeliveryNoteOrder(...args), 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 createQuoteFromEmail = vi.fn();
const appendReplyToQuote = vi.fn(); const appendReplyToQuote = vi.fn();
const findQuoteByMessageId = vi.fn(); const findQuoteByMessageId = vi.fn();
@ -163,6 +170,7 @@ describe("POST /api/mcp", () => {
createQuoteFromEmail.mockReset(); createQuoteFromEmail.mockReset();
appendReplyToQuote.mockReset(); appendReplyToQuote.mockReset();
findQuoteByMessageId.mockReset(); findQuoteByMessageId.mockReset();
matchOrder.mockReset();
createCompany.mockReset(); createCompany.mockReset();
updateCompany.mockReset(); updateCompany.mockReset();
deleteCompany.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): // Hermes unblock (PR 1, 2026-08-21):
// set_quote writes the orders.quote relation for an existing order // set_quote writes the orders.quote relation for an existing order
"orders.set_quote", "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 // Backlog sweep for the historical orders the intake matcher
// never saw (2026-08-30). // never saw (2026-08-30).
"orders.quote_match_sweep", "orders.quote_match_sweep",
@ -890,8 +901,42 @@ const toolNames = body.result.tools.map((t: { name: string }) => t.name);
expect(r2.status).toBeGreaterThanOrEqual(400); expect(r2.status).toBeGreaterThanOrEqual(400);
}); });
it("tools/call orders.find_by_order_number delegates to the service", async () => { it("tools/call orders.assistant_match delegates to the order assistant service", async () => {
findOrderByOrderNumber.mockResolvedValueOnce({ id: "o1", order_number: "ORD-1" }); 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( const res = await POST(
mcpRequest({ mcpRequest({
method: "tools/call", method: "tools/call",

View file

@ -399,6 +399,14 @@ const TOOL_HINTS: Record<string, { hint: string; nextActions: string[] }> = {
"Filled lines stay purple until a rep reviews them — do not mark them done or send the quote", "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": { "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.", 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: [ 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", name: "orders.quote_match_sweep",
description: 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") { if (toolName === "orders.quote_match_sweep") {
// Forward to the REST route — keeps the surface in lock-step // Forward to the REST route — keeps the surface in lock-step
// with the agent-auth route and surfaces the structured 4xx/5xx // with the agent-auth route and surfaces the structured 4xx/5xx

View file

@ -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" });
});
});

View file

@ -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 });
}
}

View file

@ -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" });
});
});

View file

@ -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 });
}
}