feat(quotes): finish createQuote refactor for operator-supplied quote_number
The /api/v1/quotes/create route was wired to accept operator-supplied quote_number + rfq_number + quote_notes + notes + status, but the service-layer side never matched: QuoteNumberExistsError didn't exist and CreateQuoteInput ignored the new fields. Production deploy was failing on `Module has no exported member 'QuoteNumberExistsError'`. Service changes: - New QuoteNumberExistsError class (mirrors OrderNumberCollisionError): carries existingId + attemptedNumber, mapped to 409 by the route - CreateQuoteInput extended with quote_number, rfq_number, quote_notes, notes, status; imported QuoteStatus from @/lib/types - createQuote now uses input.quote_number verbatim when supplied, else falls back to the existing atomic-counter generator - createQuote's pb.create call is wrapped in try/catch: PB's UNIQUE constraint violation on quote_number is re-thrown as the typed error with the colliding row's id resolved via a follow-up getList New route test file (9 cases) covers happy path with supplied quote_number, the 409 collision branch, notes/quote_notes fallback merge, auth-gate failures, missing customer/items validation, and generic 500 fallback. Uses importOriginal so the real QuoteNumberExistsError class survives the service-layer mock. Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
parent
1717d62980
commit
d11a1848e8
2 changed files with 278 additions and 19 deletions
183
src/app/api/v1/quotes/create/route.test.ts
Normal file
183
src/app/api/v1/quotes/create/route.test.ts
Normal file
|
|
@ -0,0 +1,183 @@
|
|||
import { describe, it, expect, beforeEach, vi } from "vitest";
|
||||
import { NextRequest } from "next/server";
|
||||
|
||||
// Mock the service layer so we can drive createQuote's success /
|
||||
// 409 paths without touching PB. The route is a thin wrapper; the
|
||||
// service-side branches are exercised here by what the mock returns.
|
||||
//
|
||||
// We use `importOriginal` so the real `QuoteNumberExistsError` class
|
||||
// stays exported — the route catches `instanceof QuoteNumberExistsError`
|
||||
// to map the typed error → 409. Without it, `instanceof` always fails
|
||||
// and we'd 500 instead of 409.
|
||||
const createQuoteMock = vi.fn();
|
||||
vi.mock("@/lib/server/quotes-service", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("@/lib/server/quotes-service")>();
|
||||
return {
|
||||
...actual,
|
||||
createQuote: (...args: unknown[]) => createQuoteMock(...args),
|
||||
};
|
||||
});
|
||||
|
||||
// assertAgentAuth reads AGENT_API_KEY at call time, so we set it
|
||||
// in beforeEach. The matching Bearer header is added on each request.
|
||||
const AGENT_KEY = "test-agent-key";
|
||||
|
||||
import { POST } from "./route";
|
||||
|
||||
function makeReq(body: unknown) {
|
||||
return new NextRequest("http://localhost/api/v1/quotes/create", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(body),
|
||||
headers: {
|
||||
"content-type": "application/json",
|
||||
authorization: `Bearer ${AGENT_KEY}`,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
const baseBody = {
|
||||
customer: "cust-1",
|
||||
company: "co-1",
|
||||
description: "Walk-in for John",
|
||||
validity_days: 30,
|
||||
rfq_number: "RFQ-2026-014",
|
||||
items: [{ description: "Bearing 6205", quantity: 2 }],
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
createQuoteMock.mockReset();
|
||||
process.env.AGENT_API_KEY = AGENT_KEY;
|
||||
});
|
||||
|
||||
describe("POST /api/v1/quotes/create", () => {
|
||||
it("forwards the operator-supplied quote_number to the service and returns 201", async () => {
|
||||
createQuoteMock.mockResolvedValueOnce({
|
||||
quote: { id: "q-new", quote_number: "Q-2026-08-007" },
|
||||
customer: { id: "cust-1", customer: "John" },
|
||||
company: { id: "co-1", company: "Acme" },
|
||||
items: [{ id: "qi-1" }],
|
||||
quote_number: "Q-2026-08-007",
|
||||
});
|
||||
|
||||
const res = await POST(
|
||||
makeReq({
|
||||
...baseBody,
|
||||
quote_number: "Q-2026-08-007",
|
||||
quote_notes: "Why we cannot quote: out of stock",
|
||||
}),
|
||||
);
|
||||
expect(res.status).toBe(201);
|
||||
|
||||
const passed = createQuoteMock.mock.calls[0][0];
|
||||
expect(passed.quote_number).toBe("Q-2026-08-007");
|
||||
expect(passed.rfq_number).toBe("RFQ-2026-014");
|
||||
expect(passed.quote_notes).toBe("Why we cannot quote: out of stock");
|
||||
expect(passed.items).toHaveLength(1);
|
||||
|
||||
const body = await res.json();
|
||||
expect(body.quote_number).toBe("Q-2026-08-007");
|
||||
});
|
||||
|
||||
it("returns 409 with existing_id when the service throws QuoteNumberExistsError", async () => {
|
||||
const { QuoteNumberExistsError } = await import(
|
||||
"@/lib/server/quotes-service"
|
||||
);
|
||||
createQuoteMock.mockRejectedValueOnce(
|
||||
new QuoteNumberExistsError("q-existing-15", "Q-2026-08-007"),
|
||||
);
|
||||
|
||||
const res = await POST(
|
||||
makeReq({ ...baseBody, quote_number: "Q-2026-08-007" }),
|
||||
);
|
||||
expect(res.status).toBe(409);
|
||||
|
||||
const body = await res.json();
|
||||
expect(body.existing_id).toBe("q-existing-15");
|
||||
expect(body.code).toBe("quote_number_exists");
|
||||
expect(body.error).toMatch(/already in use/i);
|
||||
});
|
||||
|
||||
it("falls back from notes → quote_notes (per the route's merge rule)", async () => {
|
||||
createQuoteMock.mockResolvedValueOnce({
|
||||
quote: { id: "q-new", quote_number: "Q-2026-08-008" },
|
||||
customer: { id: "cust-1" },
|
||||
company: null,
|
||||
items: [],
|
||||
quote_number: "Q-2026-08-008",
|
||||
});
|
||||
|
||||
const res = await POST(makeReq({ ...baseBody, notes: "free-text note" }));
|
||||
expect(res.status).toBe(201);
|
||||
|
||||
const passed = createQuoteMock.mock.calls[0][0];
|
||||
// `notes` should map to `quote_notes` when only `notes` is supplied.
|
||||
expect(passed.quote_notes).toBe("free-text note");
|
||||
});
|
||||
|
||||
it("prefers quote_notes over notes when both are supplied", async () => {
|
||||
createQuoteMock.mockResolvedValueOnce({
|
||||
quote: { id: "q-new", quote_number: "Q-2026-08-009" },
|
||||
customer: { id: "cust-1" },
|
||||
company: null,
|
||||
items: [],
|
||||
quote_number: "Q-2026-08-009",
|
||||
});
|
||||
|
||||
const res = await POST(
|
||||
makeReq({
|
||||
...baseBody,
|
||||
notes: "fallback value",
|
||||
quote_notes: "primary value",
|
||||
}),
|
||||
);
|
||||
expect(res.status).toBe(201);
|
||||
|
||||
const passed = createQuoteMock.mock.calls[0][0];
|
||||
expect(passed.quote_notes).toBe("primary value");
|
||||
});
|
||||
|
||||
it("returns 401 when the Authorization header is missing", async () => {
|
||||
const req = new NextRequest("http://localhost/api/v1/quotes/create", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(baseBody),
|
||||
headers: { "content-type": "application/json" },
|
||||
});
|
||||
const res = await POST(req);
|
||||
expect(res.status).toBe(401);
|
||||
expect(createQuoteMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("returns 400 when the body is not JSON", async () => {
|
||||
const req = new NextRequest("http://localhost/api/v1/quotes/create", {
|
||||
method: "POST",
|
||||
body: "not-json",
|
||||
headers: {
|
||||
"content-type": "application/json",
|
||||
authorization: `Bearer ${AGENT_KEY}`,
|
||||
},
|
||||
});
|
||||
const res = await POST(req);
|
||||
expect(res.status).toBe(400);
|
||||
expect(createQuoteMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("returns 400 when customer is missing", async () => {
|
||||
const res = await POST(makeReq({ ...baseBody, customer: "" }));
|
||||
expect(res.status).toBe(400);
|
||||
expect(createQuoteMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("returns 400 when items is empty", async () => {
|
||||
const res = await POST(makeReq({ ...baseBody, items: [] }));
|
||||
expect(res.status).toBe(400);
|
||||
expect(createQuoteMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("returns 500 on a generic service error", async () => {
|
||||
createQuoteMock.mockRejectedValueOnce(new Error("boom"));
|
||||
const res = await POST(makeReq(baseBody));
|
||||
expect(res.status).toBe(500);
|
||||
const body = await res.json();
|
||||
expect(body.error).toMatch(/boom/);
|
||||
});
|
||||
});
|
||||
|
|
@ -5,7 +5,7 @@ import {
|
|||
PLACEHOLDER_CUSTOMER_IDS,
|
||||
PLACEHOLDER_CUSTOMER_NAMES,
|
||||
} from "@/lib/placeholder-customers";
|
||||
import type { Quote, QuoteItem, Customer, Company } from "@/lib/types";
|
||||
import type { Quote, QuoteItem, Customer, Company, QuoteStatus } from "@/lib/types";
|
||||
|
||||
/**
|
||||
* Server-side quote pipeline used by the MCP layer (n8n) and internal API routes.
|
||||
|
|
@ -2583,9 +2583,51 @@ export interface CreateQuoteInput {
|
|||
items: CreateQuoteInputItem[];
|
||||
/** Optional override — defaults to validity_days=30. */
|
||||
validity_days?: number;
|
||||
/** Optional operator-supplied quote number (e.g. for re-quoting the
|
||||
* same customer under a stable reference). If omitted, the server
|
||||
* generates a fresh number from the atomic counter. Collisions with
|
||||
* an existing `quotes.quote_number` throw `QuoteNumberExistsError`
|
||||
* (caught at the REST layer → 409). */
|
||||
quote_number?: string;
|
||||
/** Customer-side reference (their RFQ number, PO reference, etc.).
|
||||
* Persists to `quotes.rfq_number`. */
|
||||
rfq_number?: string;
|
||||
/** Operator-facing free-text note rendered on the customer PDF
|
||||
* (distinct from the `notes` audit-log field). Persists to
|
||||
* `quotes.quote_notes`. */
|
||||
quote_notes?: string;
|
||||
/** Notes string (the audit-log field). Most callers should leave this
|
||||
* empty on create — the rejection pipeline writes here. Walk-in
|
||||
* creation accepts it for completeness. */
|
||||
notes?: string;
|
||||
/** Optional status override — defaults to "new". The full QuoteStatus
|
||||
* union is accepted; callers that want a stricter surface (e.g. the
|
||||
* REST route) validate the subset they expose. */
|
||||
status?: QuoteStatus;
|
||||
is_no_quote?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Thrown by `createQuote` when the operator-supplied `quote_number`
|
||||
* collides with an existing quote. Maps to HTTP 409 at the REST layer
|
||||
* (`/api/v1/quotes/create`). The `existingId` field carries the
|
||||
* colliding row's PB id so the client can deep-link to it.
|
||||
*
|
||||
* Mirrors `OrderNumberCollisionError` (orders-service.ts).
|
||||
*/
|
||||
export class QuoteNumberExistsError extends Error {
|
||||
readonly existingId: string;
|
||||
readonly attemptedNumber: string;
|
||||
constructor(existingId: string, attemptedNumber: string) {
|
||||
super(
|
||||
`Quote number "${attemptedNumber}" is already in use by another quote.`,
|
||||
);
|
||||
this.name = "QuoteNumberExistsError";
|
||||
this.existingId = existingId;
|
||||
this.attemptedNumber = attemptedNumber;
|
||||
}
|
||||
}
|
||||
|
||||
export interface CreateQuoteResult {
|
||||
quote: Quote;
|
||||
customer: Customer;
|
||||
|
|
@ -2646,24 +2688,58 @@ export async function createQuote(
|
|||
);
|
||||
}
|
||||
|
||||
// ── 3. Generate a quote number.
|
||||
const quoteNumber = await generateQuoteNumberServer();
|
||||
// ── 3. Resolve quote number. The operator may supply one (e.g. for a
|
||||
// re-quote with a stable customer-side reference); otherwise we
|
||||
// generate a fresh one from the atomic counter.
|
||||
const operatorSuppliedNumber =
|
||||
typeof input.quote_number === "string" && input.quote_number.trim()
|
||||
? input.quote_number.trim()
|
||||
: null;
|
||||
const quoteNumber = operatorSuppliedNumber ?? (await generateQuoteNumberServer());
|
||||
|
||||
// ── 4. Create the quote row.
|
||||
const quote = await pb.collection(QUOTES_COLLECTION).create({
|
||||
quote_number: quoteNumber,
|
||||
customer: customer.id,
|
||||
company: company?.id || null,
|
||||
description: input.description || "",
|
||||
status: "new",
|
||||
notes: "",
|
||||
validity_days: input.validity_days ?? QUOTE_VALID_DAYS,
|
||||
company_letterhead: input.letterhead || "CNL",
|
||||
has_attachments: false,
|
||||
email_subject: input.description || "",
|
||||
email_body: "",
|
||||
is_no_quote: input.is_no_quote,
|
||||
});
|
||||
// ── 4. Create the quote row. PB enforces a unique index on
|
||||
// `quotes.quote_number`; the try/catch below maps the DB-level
|
||||
// UNIQUE violation back to the typed `QuoteNumberExistsError` so the
|
||||
// REST layer can respond 409 with the colliding row's id.
|
||||
// The let + try/catch shape means the type must be explicit; we
|
||||
// mirror the inference the const-form would have produced (the
|
||||
// generic RecordModel cast as `Quote` at the return point below).
|
||||
let quote: Record<string, unknown> & { id: string };
|
||||
try {
|
||||
quote = await pb.collection(QUOTES_COLLECTION).create({
|
||||
quote_number: quoteNumber,
|
||||
customer: customer.id,
|
||||
company: company?.id || null,
|
||||
description: input.description || "",
|
||||
status: input.status ?? "new",
|
||||
notes: input.notes ?? "",
|
||||
rfq_number: input.rfq_number ?? "",
|
||||
quote_notes: input.quote_notes ?? "",
|
||||
validity_days: input.validity_days ?? QUOTE_VALID_DAYS,
|
||||
company_letterhead: input.letterhead || "CNL",
|
||||
has_attachments: false,
|
||||
email_subject: input.description || "",
|
||||
email_body: "",
|
||||
is_no_quote: input.is_no_quote,
|
||||
});
|
||||
} catch (err) {
|
||||
// Race-safe: even when the operator didn't supply a number, the
|
||||
// generated one could theoretically collide if the counter
|
||||
// implementation changes. Catch the unique-constraint violation,
|
||||
// re-query for the existing row's id, and re-throw typed.
|
||||
if (err && /UNIQUE constraint failed/i.test(String(err))) {
|
||||
const escaped = quoteNumber.replace(/"/g, '\\"');
|
||||
const conflict = await pb
|
||||
.collection(QUOTES_COLLECTION)
|
||||
.getList<{ id: string }>(1, 1, {
|
||||
filter: `quote_number = "${escaped}"`,
|
||||
});
|
||||
if (conflict.items[0]) {
|
||||
throw new QuoteNumberExistsError(conflict.items[0].id, quoteNumber);
|
||||
}
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
|
||||
// ── 5. Create the line items via the shared service layer so the
|
||||
// F.1 auto-derive + F.2 whitelist apply unchanged.
|
||||
|
|
@ -2687,7 +2763,7 @@ export async function createQuote(
|
|||
}
|
||||
|
||||
return {
|
||||
quote: quote as Quote,
|
||||
quote: quote as unknown as Quote,
|
||||
customer,
|
||||
company: company as Customer | null,
|
||||
items: createdItems,
|
||||
|
|
|
|||
Loading…
Reference in a new issue