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 index 452948c..b24c9dc 100644 --- a/src/app/api/v1/quotes/[id]/assistant/run/route.test.ts +++ b/src/app/api/v1/quotes/[id]/assistant/run/route.test.ts @@ -6,6 +6,7 @@ vi.mock("@/lib/server/quote-assistant-service", () => ({ fillQuote: (...a: unknown[]) => fillQuote(...a), QUOTE_NOT_OPEN_ERROR_PREFIX: "quote is not open", AI_DISABLED_ERROR: "AI is disabled in settings", + NOT_SET_UP_ERROR: "Quote assistant is not set up yet", })); const getBrowserSessionUser = vi.fn(); @@ -83,6 +84,16 @@ describe("POST /api/v1/quotes/[id]/assistant/run", () => { expect(await res.json()).toEqual({ error: "AI is disabled in settings" }); }); + it("409s when the migration hasn't run yet", async () => { + fillQuote.mockResolvedValue({ + quoteId: "q1", filled: 0, unmatched: 0, skipped: 0, + error: "Quote assistant is not set up yet", + }); + const res = await POST(makeReq(), ctx); + expect(res.status).toBe(409); + expect(await res.json()).toEqual({ error: "Quote assistant is not set up yet" }); + }); + 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); diff --git a/src/app/api/v1/quotes/[id]/assistant/run/route.ts b/src/app/api/v1/quotes/[id]/assistant/run/route.ts index c80e344..5712136 100644 --- a/src/app/api/v1/quotes/[id]/assistant/run/route.ts +++ b/src/app/api/v1/quotes/[id]/assistant/run/route.ts @@ -3,6 +3,7 @@ import { getBrowserSessionUser } from "@/lib/server/browser-session"; import { AI_DISABLED_ERROR, fillQuote, + NOT_SET_UP_ERROR, QUOTE_NOT_OPEN_ERROR_PREFIX, } from "@/lib/server/quote-assistant-service"; @@ -15,8 +16,8 @@ import { * * 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 + * Response: the `FillQuoteResult`. 409 when the quote isn't open, the AI + * is switched off, or the quote-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 failed lines marked on the quote. */ export const runtime = "nodejs"; @@ -37,7 +38,8 @@ export async function POST( if ( result.error.startsWith(QUOTE_NOT_OPEN_ERROR_PREFIX) || - result.error === AI_DISABLED_ERROR + result.error === AI_DISABLED_ERROR || + result.error === NOT_SET_UP_ERROR ) { return NextResponse.json({ error: result.error }, { status: 409 }); } diff --git a/src/lib/server/quote-assistant-service.test.ts b/src/lib/server/quote-assistant-service.test.ts index 3c78314..6124053 100644 --- a/src/lib/server/quote-assistant-service.test.ts +++ b/src/lib/server/quote-assistant-service.test.ts @@ -9,6 +9,8 @@ const store = { updates: [] as { id: string; patch: Record }[], failUpdateFor: new Set(), supplierLoads: 0, + /** Field names PB reports for `quote_items` (the migration adds ai_fill). */ + quoteItemFields: ["description", "ai_fill"], }; const events: string[] = []; const actors: { name?: string; source?: string; ignoreRequestUser?: boolean }[] = []; @@ -35,6 +37,12 @@ function listItems(opts: { filter?: string; sort?: string; expand?: string }) { } const pb = { + collections: { + getOne: async (name: string) => ({ + name, + fields: store.quoteItemFields.map((f) => ({ name: f })), + }), + }, collection(name: string) { if (name === "quotes") { return { @@ -107,7 +115,9 @@ import { boundRunErrors, fillQuote, getAssistantCounts, + NOT_SET_UP_ERROR, QUOTE_NOT_OPEN_ERROR_PREFIX, + resetAiFillFieldCheck, QUOTE_MAX_LINES, runAssistantTick, TICK_MAX_LINES, @@ -204,6 +214,8 @@ beforeEach(() => { store.updates = []; store.failUpdateFor.clear(); store.supplierLoads = 0; + store.quoteItemFields = ["description", "ai_fill"]; + resetAiFillFieldCheck(); events.length = 0; actors.length = 0; seq = 0; @@ -219,6 +231,20 @@ beforeEach(() => { }); describe("fillQuote", () => { + it("refuses to write anything until the migration has added quote_items.ai_fill", async () => { + store.quoteItemFields = ["description"]; + addQuote("q1"); + addItem("q1", { id: "a" }); + const { deps, decide } = makeDeps(); + + const r = await fillQuote("q1", { deps }); + + expect(r.error).toBe(NOT_SET_UP_ERROR); + expect(decide).not.toHaveBeenCalled(); + expect(store.updates).toEqual([]); + expect(store.items.get("a")!.cost_price).toBe(0); + }); + it("fills a matched line and records no_match for an unmatched one", async () => { addQuote("q1"); const a = addItem("q1", { id: "a" }); diff --git a/src/lib/server/quote-assistant-service.ts b/src/lib/server/quote-assistant-service.ts index 49a61c8..f0950e7 100644 --- a/src/lib/server/quote-assistant-service.ts +++ b/src/lib/server/quote-assistant-service.ts @@ -41,6 +41,7 @@ import { escapeForPbString } from "@/lib/server/price-list-match"; import { loadSuppliersById } from "@/lib/server/item-intel-service"; import { isAiFillPending } from "@/lib/quote-assistant-review"; import type { Quote, QuoteAssistantRunSummary, QuoteItem } from "@/lib/types"; +import type { AdminPb } from "@/lib/server/quote-assistant-shortlist"; export interface AssistantDeps { decide?: (input: DecideInput) => Promise; @@ -73,8 +74,35 @@ const MAX_SUMMARY_ERROR_CHARS = 300; export const QUOTE_NOT_OPEN_ERROR_PREFIX = "quote is not open"; /** `FillQuoteResult.error` when the global AI switch is off (409). */ export const AI_DISABLED_ERROR = "AI is disabled in settings"; +/** `FillQuoteResult.error` when the live PB has no `quote_items.ai_fill` yet (409). */ +export const NOT_SET_UP_ERROR = + "Quote assistant is not set up yet: run `npm run pb:migrate:quote-assistant` against PocketBase first"; const COUNT_CHUNK = 50; +let aiFillFieldConfirmed = false; + +/** + * PocketBase silently drops unknown fields on update. Without the + * `quote_items.ai_fill` field (added by the migration) the AI's prices would + * land with no purple marker and no notes, so refuse to write until it exists. + * A positive answer is cached for the life of the process. + */ +async function hasAiFillField(pb: AdminPb): Promise { + if (aiFillFieldConfirmed) return true; + const collection = (await pb.collections.getOne("quote_items")) as { + fields?: Array<{ name: string }>; + schema?: Array<{ name: string }>; + }; + const fields = collection.fields ?? collection.schema ?? []; + aiFillFieldConfirmed = fields.some((f) => f.name === "ai_fill"); + return aiFillFieldConfirmed; +} + +/** Test hook: forget the cached schema check. */ +export function resetAiFillFieldCheck(): void { + aiFillFieldConfirmed = false; +} + /** Lines the AI may ever look at: nothing priced, edited or processed. */ const OPEN_LINE_FILTER = 'is_processed = false && cost_price = 0 && selling_price = 0 && user_edited = false && (pricing_source = "" || pricing_source = "ai_email") && moved_to_quote = ""'; @@ -121,6 +149,10 @@ export async function fillQuote( // token, which the audit hook would otherwise credit instead of the AI. return runWithAuditActor({ name: actorName, source: "agent", ignoreRequestUser: true }, async () => { const pb = await getAdminPocketBase(); + if (!(await hasAiFillField(pb))) { + result.error = NOT_SET_UP_ERROR; + return result; + } const quote = await pb.collection("quotes").getOne(quoteId); if (quote.is_template) { result.error = `${QUOTE_NOT_OPEN_ERROR_PREFIX} (template)`;