chore(quotes): route next-number auth-free + replace deprecated client counter call in quote-form

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).
This commit is contained in:
SJCoetz 2026-07-26 22:30:44 +02:00
parent 3d3a012174
commit bd2a56ce9f
2 changed files with 425 additions and 396 deletions

View file

@ -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) {

View file

@ -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 (
<Label className="mb-1 flex items-center gap-1.5 text-xs font-medium text-muted-foreground">
<Icon className="h-3 w-3" />
{label}
</Label>
);
}
export function QuoteForm({ open, onOpenChange, onSaved }: QuoteFormProps) {
const [loading, setLoading] = useState(false);
const [customers, setCustomers] = useState<Customer[]>([]);
const [companies, setCompanies] = useState<Company[]>([]);
const [dataLoading, setDataLoading] = useState(false);
const [form, setForm] = useState({
customer: "",
company: "",
rfq_number: "",
customer_request: "",
notes: "",
});
const [items, setItems] = useState<DraftQuoteItem[]>([]);
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<QuoteContactOption[]>(() => {
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<QuoteContactOption, { kind: "customer" }> => 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<QuoteContactOption | null>(() => {
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<string, { message?: string }> } };
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 (
<Sheet open={open} onOpenChange={onOpenChange}>
<SheetContent className="w-full sm:max-w-2xl overflow-y-auto p-0 flex flex-col max-h-[100dvh]">
<SheetHeader className="px-6 pt-6 pb-2 border-b shrink-0">
<SheetTitle className="text-lg flex items-center gap-2">
<div className="flex h-8 w-8 items-center justify-center rounded-md bg-primary/10 text-primary">
<FileText className="h-4 w-4" />
</div>
New Quote
</SheetTitle>
<SheetDescription>Create a new quote request</SheetDescription>
</SheetHeader>
<form onSubmit={handleSubmit} className="flex-1 overflow-y-auto">
<div className="space-y-6 px-6 py-5">
<div className="grid grid-cols-2 gap-4">
<div className="col-span-2">
<FieldLabel icon={User} label="Customer or Company *" />
<QuoteFormContactPicker
disabled={dataLoading}
options={pickerOptions}
value={selectedContact}
onSelect={(option) => {
if (option.kind === "customer") {
setForm((current) => ({
...current,
customer: option.customerId,
company: option.companyId,
}));
return;
}
setForm((current) => ({
...current,
customer: "",
company: option.companyId,
}));
}}
/>
</div>
</div>
<div>
<FieldLabel icon={Hash} label="RFQ Number" />
<Input
value={form.rfq_number}
onChange={(e) => setForm({ ...form, rfq_number: e.target.value })}
placeholder="Optional reference number"
className="h-9"
/>
</div>
<Separator />
<div className="space-y-2">
<div className="flex items-center justify-between">
<FieldLabel icon={ListOrdered} label="Quote Items" />
<Button type="button" variant="outline" size="sm" onClick={addItem} className="h-7 text-xs">
<Plus className="mr-1 h-3 w-3" />
Add Item
</Button>
</div>
{items.length === 0 ? (
<div className="rounded-md border border-dashed py-8 text-center text-sm text-muted-foreground">
<ListOrdered className="h-6 w-6 mx-auto mb-2 opacity-50" />
No items yet. Click &quot;Add Item&quot; to start.
</div>
) : (
<div className="space-y-3">
{items.map((item) => (
<QuoteFormItemCard
key={item.id}
item={item}
onChange={(patch) =>
setItems((current) =>
current.map((row) => (row.id === item.id ? { ...row, ...patch } : row))
)
}
onRemove={() =>
setItems((current) => current.filter((row) => row.id !== item.id))
}
/>
))}
</div>
)}
</div>
<Separator />
<div>
<FieldLabel icon={FileText} label="Customer Request" />
<Textarea
value={form.customer_request}
onChange={(e) => setForm({ ...form, customer_request: e.target.value })}
rows={2}
placeholder="Overall RFQ context, urgency, or customer message..."
className="resize-none text-sm"
/>
</div>
<div>
<FieldLabel icon={FileText} label="Notes" />
<Textarea
value={form.notes}
onChange={(e) => setForm({ ...form, notes: e.target.value })}
rows={2}
placeholder="Internal notes..."
className="resize-none text-sm"
/>
</div>
</div>
<SheetFooter className="px-6 pb-6 border-t pt-4 shrink-0">
<Button type="submit" disabled={loading || dataLoading} className="w-full">
{loading && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
Create Quote
</Button>
</SheetFooter>
</form>
</SheetContent>
</Sheet>
);
}
"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 } 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 (
<Label className="mb-1 flex items-center gap-1.5 text-xs font-medium text-muted-foreground">
<Icon className="h-3 w-3" />
{label}
</Label>
);
}
export function QuoteForm({ open, onOpenChange, onSaved }: QuoteFormProps) {
const [loading, setLoading] = useState(false);
const [customers, setCustomers] = useState<Customer[]>([]);
const [companies, setCompanies] = useState<Company[]>([]);
const [dataLoading, setDataLoading] = useState(false);
const [form, setForm] = useState({
customer: "",
company: "",
rfq_number: "",
customer_request: "",
notes: "",
});
const [items, setItems] = useState<DraftQuoteItem[]>([]);
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<QuoteContactOption[]>(() => {
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<QuoteContactOption, { kind: "customer" }> => 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<QuoteContactOption | null>(() => {
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 {
// Atomic, server-incremented. Replaces the deprecated
// client-side `generateQuoteNumber` from `pb-helpers.ts` —
// that one was racy under concurrent submits because it
// read-then-wrote the last quote locally instead of going
// through `quote_counters` server-side.
// The endpoint is intentionally open (no auth) — see its
// route file for the rationale.
const res = await fetch("/api/v1/quotes/next-number", {
headers: { Accept: "application/json" },
});
if (!res.ok) {
throw new Error(`next-number HTTP ${res.status}`);
}
const data = (await res.json()) as { quote_number?: string };
if (!data.quote_number) {
throw new Error("next-number response missing quote_number");
}
quoteNumber = data.quote_number;
} catch (numErr) {
// Last-resort fallback so a transient network blip on the
// counter endpoint doesn't block the operator from saving
// a quote. Same format as `formatQuoteNumber` — operator
// may end up with a duplicate; PB `quote_number` is a
// plain text field with no uniqueness constraint.
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<string, { message?: string }> } };
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 (
<Sheet open={open} onOpenChange={onOpenChange}>
<SheetContent className="w-full sm:max-w-2xl overflow-y-auto p-0 flex flex-col max-h-[100dvh]">
<SheetHeader className="px-6 pt-6 pb-2 border-b shrink-0">
<SheetTitle className="text-lg flex items-center gap-2">
<div className="flex h-8 w-8 items-center justify-center rounded-md bg-primary/10 text-primary">
<FileText className="h-4 w-4" />
</div>
New Quote
</SheetTitle>
<SheetDescription>Create a new quote request</SheetDescription>
</SheetHeader>
<form onSubmit={handleSubmit} className="flex-1 overflow-y-auto">
<div className="space-y-6 px-6 py-5">
<div className="grid grid-cols-2 gap-4">
<div className="col-span-2">
<FieldLabel icon={User} label="Customer or Company *" />
<QuoteFormContactPicker
disabled={dataLoading}
options={pickerOptions}
value={selectedContact}
onSelect={(option) => {
if (option.kind === "customer") {
setForm((current) => ({
...current,
customer: option.customerId,
company: option.companyId,
}));
return;
}
setForm((current) => ({
...current,
customer: "",
company: option.companyId,
}));
}}
/>
</div>
</div>
<div>
<FieldLabel icon={Hash} label="RFQ Number" />
<Input
value={form.rfq_number}
onChange={(e) => setForm({ ...form, rfq_number: e.target.value })}
placeholder="Optional reference number"
className="h-9"
/>
</div>
<Separator />
<div className="space-y-2">
<div className="flex items-center justify-between">
<FieldLabel icon={ListOrdered} label="Quote Items" />
<Button type="button" variant="outline" size="sm" onClick={addItem} className="h-7 text-xs">
<Plus className="mr-1 h-3 w-3" />
Add Item
</Button>
</div>
{items.length === 0 ? (
<div className="rounded-md border border-dashed py-8 text-center text-sm text-muted-foreground">
<ListOrdered className="h-6 w-6 mx-auto mb-2 opacity-50" />
No items yet. Click &quot;Add Item&quot; to start.
</div>
) : (
<div className="space-y-3">
{items.map((item) => (
<QuoteFormItemCard
key={item.id}
item={item}
onChange={(patch) =>
setItems((current) =>
current.map((row) => (row.id === item.id ? { ...row, ...patch } : row))
)
}
onRemove={() =>
setItems((current) => current.filter((row) => row.id !== item.id))
}
/>
))}
</div>
)}
</div>
<Separator />
<div>
<FieldLabel icon={FileText} label="Customer Request" />
<Textarea
value={form.customer_request}
onChange={(e) => setForm({ ...form, customer_request: e.target.value })}
rows={2}
placeholder="Overall RFQ context, urgency, or customer message..."
className="resize-none text-sm"
/>
</div>
<div>
<FieldLabel icon={FileText} label="Notes" />
<Textarea
value={form.notes}
onChange={(e) => setForm({ ...form, notes: e.target.value })}
rows={2}
placeholder="Internal notes..."
className="resize-none text-sm"
/>
</div>
</div>
<SheetFooter className="px-6 pb-6 border-t pt-4 shrink-0">
<Button type="submit" disabled={loading || dataLoading} className="w-full">
{loading && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
Create Quote
</Button>
</SheetFooter>
</form>
</SheetContent>
</Sheet>
);
}