feat(quotes): expose is_no_quote on PATCH /api/v1/quotes/[id]

Adds the new field to the PATCHABLE_FIELDS whitelist, validates
type as boolean, and dispatches to a new setIsNoQuote service helper.
Tested via mocked route handler.
This commit is contained in:
Dave 2026-08-26 23:23:35 +02:00
parent 1ef24ffe2f
commit bf91b601aa
3 changed files with 76 additions and 12 deletions

View file

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

View file

@ -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<ReturnType<typeof setQuoteNotes>> | 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

View file

@ -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<Quote> {
const pb = await getAdminPocketBase();
return pb.collection(QUOTES_COLLECTION).update<Quote>(quoteId, { is_no_quote: value });
}