diff --git a/src/app/api/v1/quotes/[id]/route.test.ts b/src/app/api/v1/quotes/[id]/route.test.ts index 57b5d0e..f974907 100644 --- a/src/app/api/v1/quotes/[id]/route.test.ts +++ b/src/app/api/v1/quotes/[id]/route.test.ts @@ -2,9 +2,11 @@ import { describe, it, expect, beforeEach, vi } from "vitest"; import { NextRequest } from "next/server"; const setQuoteNotesMock = vi.fn(); +const setIsNoQuoteMock = vi.fn(); vi.mock("@/lib/server/quotes-service", () => ({ setQuoteNotes: (...args: unknown[]) => setQuoteNotesMock(...args), + setIsNoQuote: (...args: unknown[]) => setIsNoQuoteMock(...args), })); import { PATCH } from "./route"; @@ -101,3 +103,38 @@ describe("PATCH /api/v1/quotes/[id]", () => { expect(setQuoteNotesMock).toHaveBeenCalledWith("q-123", ""); }); }); + +describe("PATCH /api/v1/quotes/[id] is_no_quote", () => { + beforeEach(() => { + setIsNoQuoteMock.mockReset(); + }); + + it("accepts is_no_quote: true and calls setIsNoQuote", async () => { + setIsNoQuoteMock.mockResolvedValue({ id: "q-123", is_no_quote: true }); + const res = await callPATCH({ is_no_quote: true }); + expect(res.status).toBe(200); + expect(setIsNoQuoteMock).toHaveBeenCalledWith("q-123", true); + }); + + it("accepts is_no_quote: false and calls setIsNoQuote", async () => { + setIsNoQuoteMock.mockResolvedValue({ id: "q-123", is_no_quote: false }); + const res = await callPATCH({ is_no_quote: false }); + expect(res.status).toBe(200); + expect(setIsNoQuoteMock).toHaveBeenCalledWith("q-123", false); + }); + + it("rejects is_no_quote: \"yes\" (non-boolean)", async () => { + const res = await callPATCH({ is_no_quote: "yes" }); + expect(res.status).toBe(400); + expect(setIsNoQuoteMock).not.toHaveBeenCalled(); + }); + + it("can patch is_no_quote and quote_notes in the same call", async () => { + setQuoteNotesMock.mockResolvedValue({ id: "q-123", quote_notes: "Out of scope" }); + setIsNoQuoteMock.mockResolvedValue({ id: "q-123", is_no_quote: true }); + const res = await callPATCH({ is_no_quote: true, quote_notes: "Out of scope" }); + expect(res.status).toBe(200); + expect(setIsNoQuoteMock).toHaveBeenCalledWith("q-123", true); + expect(setQuoteNotesMock).toHaveBeenCalledWith("q-123", "Out of scope"); + }); +}); diff --git a/src/app/api/v1/quotes/[id]/route.ts b/src/app/api/v1/quotes/[id]/route.ts index f26d721..ff19063 100644 --- a/src/app/api/v1/quotes/[id]/route.ts +++ b/src/app/api/v1/quotes/[id]/route.ts @@ -1,5 +1,5 @@ import { NextRequest, NextResponse } from "next/server"; -import { setQuoteNotes } from "@/lib/server/quotes-service"; +import { setQuoteNotes, setIsNoQuote } from "@/lib/server/quotes-service"; /** * PATCH /api/v1/quotes/[id] @@ -19,11 +19,11 @@ import { setQuoteNotes } from "@/lib/server/quotes-service"; * operator-facing and the AGENT_API_KEY is reserved for cron. * * Body (JSON): - * { quote_notes?: string } + * { quote_notes?: string, is_no_quote?: boolean } * * Response: * 200 — { quote } - * 400 — { error } (quote_notes is not a string) + * 400 — { error } (quote_notes is not a string, is_no_quote is not a boolean) * 404 — { error } (quote not found) * 500 — { error } (PB failure, surfaced verbatim) * @@ -33,7 +33,7 @@ import { setQuoteNotes } from "@/lib/server/quotes-service"; export const runtime = "nodejs"; export const dynamic = "force-dynamic"; -const PATCHABLE_FIELDS = ["quote_notes"] as const; +const PATCHABLE_FIELDS = ["quote_notes", "is_no_quote"] as const; type PatchableField = (typeof PATCHABLE_FIELDS)[number]; function isPatchableField(key: string): key is PatchableField { @@ -74,6 +74,13 @@ export async function PATCH( ); } + if ("is_no_quote" in body && typeof body.is_no_quote !== "boolean") { + return NextResponse.json( + { error: "is_no_quote must be a boolean" }, + { status: 400 }, + ); + } + // Caller token for future audit logging. The route does NOT enforce // auth here — the agent-auth check is reserved for the cron endpoints. const callerToken = req.headers.get("authorization") ?? undefined; @@ -82,17 +89,25 @@ export async function PATCH( void callerToken; try { + let quote: Awaited> | undefined; + if (typeof body.quote_notes === "string") { - const quote = await setQuoteNotes(quoteId, body.quote_notes); - return NextResponse.json({ quote }); + quote = await setQuoteNotes(quoteId, body.quote_notes); + } + if (typeof body.is_no_quote === "boolean") { + quote = await setIsNoQuote(quoteId, body.is_no_quote); } - // No whitelisted fields present — nothing to do. Return 400 so - // callers don't think they updated something silently. - return NextResponse.json( - { error: "No patchable fields supplied" }, - { status: 400 }, - ); + if (!quote) { + // No whitelisted fields present — nothing to do. Return 400 so + // callers don't think they updated something silently. + return NextResponse.json( + { error: "No patchable fields supplied" }, + { status: 400 }, + ); + } + + return NextResponse.json({ quote }); } catch (err) { const message = err instanceof Error ? err.message : "Failed to update quote"; // PB throws a 404 when the record doesn't exist; surface that diff --git a/src/lib/server/quotes-service.ts b/src/lib/server/quotes-service.ts index 9ca2ab2..af8c211 100644 --- a/src/lib/server/quotes-service.ts +++ b/src/lib/server/quotes-service.ts @@ -2793,3 +2793,15 @@ export async function setQuoteNotes( }); return updated; } + +/** + * Set or clear the no-quote flag on a quote. Reverts to false (a real + * quote) when called with `false`. Stamps `updated` automatically. + */ +export async function setIsNoQuote( + quoteId: string, + value: boolean, +): Promise { + const pb = await getAdminPocketBase(); + return pb.collection(QUOTES_COLLECTION).update(quoteId, { is_no_quote: value }); +}