fix(quote-assistant): refuse to fill until the ai_fill field exists

PocketBase drops unknown fields on update, so before the migration runs
the AI's prices would land with no purple marker and no notes. The
manual button / MCP now return 409 'not set up yet' instead.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Dave 2026-10-17 00:26:13 +02:00
parent dca9c01319
commit 78ba560096
4 changed files with 74 additions and 3 deletions

View file

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

View file

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

View file

@ -9,6 +9,8 @@ const store = {
updates: [] as { id: string; patch: Record<string, unknown> }[],
failUpdateFor: new Set<string>(),
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" });

View file

@ -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<DecideResult>;
@ -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<boolean> {
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<Quote>(quoteId);
if (quote.is_template) {
result.error = `${QUOTE_NOT_OPEN_ERROR_PREFIX} (template)`;