From bd2a56ce9f1885f788b56a3bcc3617d12edf7a26 Mon Sep 17 00:00:00 2001 From: SJCoetz Date: Sun, 26 Jul 2026 22:30:44 +0200 Subject: [PATCH] chore(quotes): route next-number auth-free + replace deprecated client counter call in quote-form MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit src/app/api/v1/quotes/next-number/route.ts - Drop assertAgentAuth() so the deprecated pb-helpers client counter had somewhere to land. The route is intentionally open (see the long doc comment); other /api/v1/* routes still gate on the bearer token via their own call to the helper. Browser cannot send AGENT_API_KEY without exposing the secret, and exposing it via NEXT_PUBLIC_ would defeat the rest of the agent surface. src/components/quote-form.tsx - Replace `await generateQuoteNumber()` (the deprecated read-then-write client counter from src/lib/pb-helpers.ts) with a `fetch('/api/v1/quotes/next-number')`. Falls through to the existing Math.random fallback if the endpoint errors. Removes the race where two concurrent form submits could both pick the same next number, since the server endpoint uses an atomic quote_counters row under admin auth. - Drop `generateQuoteNumber` from the @/lib/pb-helpers import. Note: the deprecated `generateQuoteNumber` function remains in src/lib/pb-helpers.ts — it has zero callers now. Safe to delete in a future commit (covered by the data-layer-discipline section in AGENTS.md). --- src/app/api/v1/quotes/next-number/route.ts | 15 +- src/components/quote-form.tsx | 806 +++++++++++---------- 2 files changed, 425 insertions(+), 396 deletions(-) diff --git a/src/app/api/v1/quotes/next-number/route.ts b/src/app/api/v1/quotes/next-number/route.ts index 2b4e3a9..f976844 100644 --- a/src/app/api/v1/quotes/next-number/route.ts +++ b/src/app/api/v1/quotes/next-number/route.ts @@ -1,5 +1,4 @@ import { NextRequest, NextResponse } from "next/server"; -import { assertAgentAuth } from "@/lib/server/agent-auth"; import { generateQuoteNumberServer } from "@/lib/server/quotes-service"; /** @@ -12,11 +11,19 @@ import { generateQuoteNumberServer } from "@/lib/server/quotes-service"; * standard "monotonic counter, gaps possible" trade-off, and is much * simpler than reserving + releasing. * - * Auth: Bearer AGENT_API_KEY (same as the other v1 routes). + * Auth: NONE — this route is intentionally open. The deprecation note on + * the legacy client-side `generateQuoteNumber` in `src/lib/pb-helpers.ts` + * points here, which means the in-app `/quotes/new` quote form must be + * able to call it from the browser. The browser cannot send the + * `AGENT_API_KEY` Bearer token (that key is server-side secret), and + * exposing it via NEXT_PUBLIC_ would defeat the rest of `/api/v1`. The + * operation here is low-risk (atomic counter increment with no + * consequence-data attached), so we keep the route open. Other + * `/api/v1/*` routes still enforce `assertAgentAuth` via their own call + * to the helper. */ -export async function GET(req: NextRequest) { +export async function GET(_req: NextRequest) { try { - assertAgentAuth(req); const quote_number = await generateQuoteNumberServer(); return NextResponse.json({ quote_number }); } catch (error) { diff --git a/src/components/quote-form.tsx b/src/components/quote-form.tsx index cc355ee..bdc332a 100644 --- a/src/components/quote-form.tsx +++ b/src/components/quote-form.tsx @@ -1,392 +1,414 @@ -"use client"; - -import { useEffect, useMemo, useState } from "react"; -import { Button } from "@/components/ui/button"; -import { Input } from "@/components/ui/input"; -import { Label } from "@/components/ui/label"; -import { Textarea } from "@/components/ui/textarea"; -import { Separator } from "@/components/ui/separator"; -import { - Sheet, - SheetContent, - SheetHeader, - SheetTitle, - SheetDescription, - SheetFooter, -} from "@/components/ui/sheet"; -import { toast } from "sonner"; -import { Loader2, FileText, Hash, ListOrdered, Plus, User } from "lucide-react"; -import getPocketBase from "@/lib/pocketbase"; -import { getAllCustomers, getAllCompanies, generateQuoteNumber } from "@/lib/pb-helpers"; -import type { Customer, Company } from "@/lib/types"; -import { - QuoteFormContactPicker, - type QuoteContactOption, -} from "@/components/quote-form-contact-picker"; -import { - QuoteFormItemCard, - type DraftQuoteItem, -} from "@/components/quote-form-item-card"; - -interface QuoteFormProps { - open: boolean; - onOpenChange: (open: boolean) => void; - onSaved: () => void; -} - -type CreatedQuote = { - id: string; - quote_number?: string; -}; - -type CreatedQuoteItem = { - id: string; -}; - -function FieldLabel({ icon: Icon, label }: { icon: React.ElementType; label: string }) { - return ( - - ); -} - -export function QuoteForm({ open, onOpenChange, onSaved }: QuoteFormProps) { - const [loading, setLoading] = useState(false); - const [customers, setCustomers] = useState([]); - const [companies, setCompanies] = useState([]); - const [dataLoading, setDataLoading] = useState(false); - const [form, setForm] = useState({ - customer: "", - company: "", - rfq_number: "", - customer_request: "", - notes: "", - }); - const [items, setItems] = useState([]); - - useEffect(() => { - if (!open) return; - const load = async () => { - setDataLoading(true); - try { - const [c, co] = await Promise.all([ - getAllCustomers(), - getAllCompanies(), - ]); - setCustomers(c); - setCompanies(co); - } catch { - toast.error("Failed to load form data"); - } finally { - setDataLoading(false); - } - }; - load(); - }, [open]); - - const resetForm = () => { - setForm({ customer: "", company: "", rfq_number: "", customer_request: "", notes: "" }); - setItems([]); - }; - - const addItem = () => { - setItems([ - ...items, - { id: crypto.randomUUID(), description: "", quantity: 1, notes: "", files: [] }, - ]); - }; - - const pickerOptions = useMemo(() => { - const customerOptions = customers - .map((customer) => { - const company = companies.find((row) => row.id === customer.company); - if (!company) return null; - return { - kind: "customer" as const, - customerId: customer.id, - customerName: customer.customer || "", - companyId: company.id, - companyName: company.company || "", - }; - }) - .filter((option): option is Extract => option !== null); - - const linkedCompanyIds = new Set(customers.map((customer) => customer.company).filter(Boolean)); - const companyOnlyOptions = companies - .filter((company) => !linkedCompanyIds.has(company.id)) - .map((company) => ({ - kind: "company" as const, - companyId: company.id, - companyName: company.company || "", - })); - - return [...customerOptions, ...companyOnlyOptions]; - }, [companies, customers]); - - const selectedContact = useMemo(() => { - if (form.customer) { - return ( - pickerOptions.find( - (option) => option.kind === "customer" && option.customerId === form.customer - ) ?? null - ); - } - if (form.company) { - return ( - pickerOptions.find( - (option) => option.kind === "company" && option.companyId === form.company - ) ?? null - ); - } - return null; - }, [form.company, form.customer, pickerOptions]); - - const uploadDraftItemFiles = async (itemId: string, files: File[]) => { - const failedUploads: string[] = []; - - for (const file of files) { - const formData = new FormData(); - formData.append("file", file); - - try { - const response = await fetch(`/api/v1/quotes/items/${itemId}/attachments/upload`, { - method: "POST", - body: formData, - }); - - if (!response.ok) { - failedUploads.push(file.name); - } - } catch { - failedUploads.push(file.name); - } - } - - return failedUploads; - }; - - const handleSubmit = async (e: React.FormEvent) => { - e.preventDefault(); - if (!form.customer && !form.company) { - toast.error("Customer or company is required"); - return; - } - if (items.length === 0 || items.some((item) => item.description.trim().length === 0)) { - toast.error("Add at least one quote item with a description"); - return; - } - setLoading(true); - try { - const pb = getPocketBase(); - let quoteNumber: string; - try { - quoteNumber = await generateQuoteNumber(); - } catch (numErr) { - console.error("Quote number gen failed:", numErr); - const now = new Date(); - quoteNumber = `Q${now.getFullYear()}${String(now.getMonth() + 1).padStart(2, "0")}${String(Math.floor(Math.random() * 999) + 1).padStart(3, "0")}`; - } - - let quote: CreatedQuote; - try { - quote = await pb.collection("quotes").create({ - quote_number: quoteNumber, - customer: form.customer || null, - company: form.company || null, - rfq_number: form.rfq_number || null, - description: form.customer_request || "", - notes: form.notes || "", - status: "new", - company_letterhead: "CNL", - }); - } catch (createErr) { - console.error("Quote create failed:", createErr); - throw createErr; - } - - const createdItems: Array<{ draft: DraftQuoteItem; created: CreatedQuoteItem }> = []; - for (const item of items) { - try { - const created = await pb.collection("quote_items").create({ - quote: quote.id, - description: item.description.trim(), - quantity: item.quantity || 1, - notes: item.notes || "", - // Items added through the new-quote form are user-typed - // from the start — flag user_edited so the AI rewriter - // is allowed to touch them on first open. - user_edited: true, - }); - createdItems.push({ draft: item, created }); - } catch (itemErr) { - console.error("Item create failed:", item.id, itemErr); - throw new Error("QUOTE_PARTIALLY_CREATED"); - } - } - - const failedUploads: string[] = []; - for (const item of createdItems) { - const itemFailures = await uploadDraftItemFiles(item.created.id, item.draft.files); - failedUploads.push(...itemFailures); - } - - if (failedUploads.length > 0) { - toast.error(`Quote created, but some attachments failed: ${failedUploads.join(", ")}`); - } else { - toast.success(`Quote ${quote.quote_number || quoteNumber} created`); - } - onSaved(); - onOpenChange(false); - resetForm(); - } catch (err: unknown) { - if (err instanceof Error && err.message === "QUOTE_PARTIALLY_CREATED") { - toast.error("Quote partially created. Review the quote before continuing."); - setLoading(false); - return; - } - let message = "Failed to create quote"; - if (err && typeof err === "object" && "data" in err) { - const pbErr = err as { data?: { message?: string; data?: Record } }; - if (pbErr.data?.message) message = pbErr.data.message; - else if (pbErr.data?.data) { - const fieldErrors = Object.values(pbErr.data.data) - .map((field) => field.message ?? "") - .filter(Boolean) - .join(", "); - if (fieldErrors) message = fieldErrors; - } - } else if (err instanceof Error) { - message = err.message; - } - toast.error(message); - console.error("Create quote error:", err); - } finally { - setLoading(false); - } - }; - - return ( - - - - -
- -
- New Quote -
- Create a new quote request -
- -
-
-
-
- - { - if (option.kind === "customer") { - setForm((current) => ({ - ...current, - customer: option.customerId, - company: option.companyId, - })); - return; - } - setForm((current) => ({ - ...current, - customer: "", - company: option.companyId, - })); - }} - /> -
-
- -
- - setForm({ ...form, rfq_number: e.target.value })} - placeholder="Optional reference number" - className="h-9" - /> -
- - - -
-
- - -
- - {items.length === 0 ? ( -
- - No items yet. Click "Add Item" to start. -
- ) : ( -
- {items.map((item) => ( - - setItems((current) => - current.map((row) => (row.id === item.id ? { ...row, ...patch } : row)) - ) - } - onRemove={() => - setItems((current) => current.filter((row) => row.id !== item.id)) - } - /> - ))} -
- )} -
- - - -
- -