diff --git a/src/app/api/v1/quotes/create/route.test.ts b/src/app/api/v1/quotes/create/route.test.ts new file mode 100644 index 0000000..f23a880 --- /dev/null +++ b/src/app/api/v1/quotes/create/route.test.ts @@ -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(); + 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/); + }); +}); diff --git a/src/lib/server/quotes-service.ts b/src/lib/server/quotes-service.ts index 92b76b1..9103890 100644 --- a/src/lib/server/quotes-service.ts +++ b/src/lib/server/quotes-service.ts @@ -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 & { 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,