feat(search): universal search across every collection
One box at /search (sidebar, under Dashboard) covering orders, quotes, both purchase-order flavours, suppliers, companies, customers and stock items. Replaces guessing which screen held the answer. Window: 90 days (UNIVERSAL_SEARCH_WINDOW_DAYS) on orders and quotes — the two that grow without bound. Suppliers, companies, customers and stock items are searched in full because they don't expire and an operator searching for one expects to find it at any age. The UI states the window so a miss reads as "aged out", not "broken". THE INVARIANT — a cap must never look like emptiness Every group reports `total` (uncapped) separately from `hits` (capped), plus `truncated`. The UI renders "showing 25 of 1,240 — refine". This is the property the 500-cap bug in orders-service violated: the newest 110 orders silently vanished and it read as a missing search field. The type makes `total` structurally independent of `hits.length`. Symmetrically, a source that fails or times out renders as an explicit "couldn't search quotes" row. "We couldn't search X" and "no X matched" are different statements and must never look alike. All nine sources fan out in parallel and settle independently. PERFORMANCE Profiling the first build showed 7.8s per search — ~3.5s of it pure data-fetching (1,070 orders / 4,288 line items re-read on every keystroke) against ~10ms of actual matching. Added: - order snapshot (headers + items + supplier names), 60s TTL - reference-collection cache, 5min TTL - per-query result cache, 60s TTL Cold ~4.5s, warm 250ms-1.6s, repeat query 0ms. The page pre-warms on mount via an empty-query request, so the ~4.5s cold build happens while the operator is still reading the box rather than in front of them. Orders match supplier NAMES, not relation ids — verified live that "truco" returns the Truco order, the 6 matching quotes AND the supplier. NO Reps group, deliberately A rep is a filter, not a page: this app has no user detail view and no page accepts ?search=. A Reps group would be rows whose every link 404s. `orders.rep` is a matched field on the Orders source, so searching "tiaan" still returns exactly his 96 orders — which is what was actually asked for. PO hits link to their parent order, since there is no standalone PO detail page; orphaned POs fall back to the PO PDF. Tests: 15 cases over the pure layer — ranking tiers, best-of-not-sum scoring, filter escaping against injection, the window maths, group ordering, and the cap/failure invariants. One of them caught a real defect: scoreField lowercased the haystack but not the query, so an uppercase query scored 0 against every row.
This commit is contained in:
parent
4519288073
commit
1ee3555239
5 changed files with 1489 additions and 0 deletions
294
src/app/(app)/search/page.tsx
Normal file
294
src/app/(app)/search/page.tsx
Normal file
|
|
@ -0,0 +1,294 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useCallback, useEffect, useRef, useState } from "react";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { Search as SearchIcon, X, Loader2, AlertTriangle, ArrowUpRight } from "lucide-react";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Universal search — one box across orders, quotes, POs, suppliers,
|
||||||
|
* companies, customers, reps and stock items.
|
||||||
|
*
|
||||||
|
* Mirrors the server contract in `universal-search-service.ts`. Two
|
||||||
|
* rules this UI must not break:
|
||||||
|
*
|
||||||
|
* 1. **Never hide a cap.** Each group reports `total` (uncapped) and
|
||||||
|
* `truncated`. When truncated we render "showing N of TOTAL —
|
||||||
|
* refine". A silently-capped list is indistinguishable from a bug,
|
||||||
|
* and a cap that once hid the newest 110 orders in `/orders` is why
|
||||||
|
* that rule exists.
|
||||||
|
*
|
||||||
|
* 2. **Never hide a broken source.** A group with `status !==
|
||||||
|
* "ok"` renders as an explicit failure row, not as an absent one.
|
||||||
|
* "We couldn't search quotes" and "no quotes matched" are very
|
||||||
|
* different statements.
|
||||||
|
*/
|
||||||
|
|
||||||
|
type Group = {
|
||||||
|
type: string;
|
||||||
|
label: string;
|
||||||
|
hits: {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
subtitle?: string;
|
||||||
|
meta?: string;
|
||||||
|
href: string;
|
||||||
|
matchedOn?: string;
|
||||||
|
}[];
|
||||||
|
total: number;
|
||||||
|
truncated: boolean;
|
||||||
|
tookMs: number;
|
||||||
|
status: "ok" | "timeout" | "error";
|
||||||
|
error?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type SearchResult = {
|
||||||
|
query: string;
|
||||||
|
groups: Group[];
|
||||||
|
totalHits: number;
|
||||||
|
tookMs: number;
|
||||||
|
windowDays: number;
|
||||||
|
error?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Matches the server's MIN_QUERY_CHARS. */
|
||||||
|
const MIN_QUERY_CHARS = 2;
|
||||||
|
/** Matches the orders page's debounce so the two feel the same. */
|
||||||
|
const DEBOUNCE_MS = 300;
|
||||||
|
|
||||||
|
export default function UniversalSearchPage() {
|
||||||
|
const [input, setInput] = useState("");
|
||||||
|
const [result, setResult] = useState<SearchResult | null>(null);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [failed, setFailed] = useState<string | null>(null);
|
||||||
|
|
||||||
|
// Guards against a slow response for an abandoned query landing
|
||||||
|
// after a newer one has already rendered.
|
||||||
|
const requestSeq = useRef(0);
|
||||||
|
// Lets the Enter handler pre-empt a debounce timer that hasn't fired
|
||||||
|
// yet, so submitting doesn't also fire a duplicate request.
|
||||||
|
const cancelPending = useRef(false);
|
||||||
|
|
||||||
|
const runSearch = useCallback(async (q: string) => {
|
||||||
|
if (q.length < MIN_QUERY_CHARS) {
|
||||||
|
setResult(null);
|
||||||
|
setFailed(null);
|
||||||
|
setLoading(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const seq = ++requestSeq.current;
|
||||||
|
setLoading(true);
|
||||||
|
setFailed(null);
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/v1/search?q=${encodeURIComponent(q)}`);
|
||||||
|
const data = await res.json();
|
||||||
|
if (seq !== requestSeq.current) return; // superseded
|
||||||
|
if (!res.ok || data.error) {
|
||||||
|
setFailed(data.error || `Search failed (HTTP ${res.status})`);
|
||||||
|
setResult(null);
|
||||||
|
} else {
|
||||||
|
setResult(data as SearchResult);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
if (seq !== requestSeq.current) return;
|
||||||
|
setFailed(err instanceof Error ? err.message : "Search failed");
|
||||||
|
setResult(null);
|
||||||
|
} finally {
|
||||||
|
if (seq === requestSeq.current) setLoading(false);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Debounce and search live in ONE effect, and every setState happens
|
||||||
|
// inside the timer callback rather than in the effect body. Doing it
|
||||||
|
// synchronously in the body is a cascading render (react-hooks/
|
||||||
|
// set-state-in-effect) and would also fire a request per keystroke.
|
||||||
|
useEffect(() => {
|
||||||
|
cancelPending.current = false;
|
||||||
|
const trimmed = input.trim();
|
||||||
|
const t = setTimeout(() => {
|
||||||
|
if (cancelPending.current) return;
|
||||||
|
if (trimmed.length < MIN_QUERY_CHARS) {
|
||||||
|
setResult(null);
|
||||||
|
setFailed(null);
|
||||||
|
setLoading(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
void runSearch(trimmed);
|
||||||
|
}, DEBOUNCE_MS);
|
||||||
|
return () => {
|
||||||
|
cancelPending.current = true;
|
||||||
|
clearTimeout(t);
|
||||||
|
};
|
||||||
|
}, [input, runSearch]);
|
||||||
|
|
||||||
|
const submitNow = useCallback(() => {
|
||||||
|
const trimmed = input.trim();
|
||||||
|
if (trimmed.length < MIN_QUERY_CHARS) return;
|
||||||
|
cancelPending.current = true;
|
||||||
|
void runSearch(trimmed);
|
||||||
|
}, [input, runSearch]);
|
||||||
|
|
||||||
|
// Pre-warm the server-side snapshot on mount so the FIRST real search
|
||||||
|
// doesn't pay the ~4.5s cold build. The empty-query request is the
|
||||||
|
// documented warm trigger.
|
||||||
|
useEffect(() => {
|
||||||
|
fetch("/api/v1/search?q=")
|
||||||
|
.then(() => undefined)
|
||||||
|
.catch(() => undefined);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const clear = () => {
|
||||||
|
cancelPending.current = true;
|
||||||
|
setInput("");
|
||||||
|
setResult(null);
|
||||||
|
setFailed(null);
|
||||||
|
setLoading(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const hasQuery = input.trim().length >= MIN_QUERY_CHARS;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-5">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-bold tracking-tight">Search</h1>
|
||||||
|
<p className="text-muted-foreground">
|
||||||
|
One box across orders, quotes, purchase orders, suppliers, companies,
|
||||||
|
customers, reps and stock.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="relative">
|
||||||
|
<SearchIcon className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground pointer-events-none" />
|
||||||
|
<Input
|
||||||
|
autoFocus
|
||||||
|
value={input}
|
||||||
|
onChange={(e) => setInput(e.target.value)}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
// Enter jumps the debounce — operators hit submit rather
|
||||||
|
// than wait out the timer.
|
||||||
|
if (e.key === "Enter") {
|
||||||
|
e.preventDefault();
|
||||||
|
submitNow();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
placeholder="Order no, quote no, supplier, customer, rep, SKU, description…"
|
||||||
|
className="h-11 pl-9 pr-9 text-base"
|
||||||
|
/>
|
||||||
|
{input && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={clear}
|
||||||
|
aria-label="Clear search"
|
||||||
|
className="absolute right-3 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
|
||||||
|
>
|
||||||
|
<X className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Scope statement. The operator needs to know the window exists,
|
||||||
|
or a miss looks like a bug rather than an age-out. */}
|
||||||
|
{result && !failed && (
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
{result.totalHits} match{result.totalHits === 1 ? "" : "es"} across{" "}
|
||||||
|
{result.groups.length} source{result.groups.length === 1 ? "" : "s"} in{" "}
|
||||||
|
{result.tookMs}ms. Orders and quotes cover the last{" "}
|
||||||
|
{result.windowDays} days.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{loading && (
|
||||||
|
<p className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||||
|
<Loader2 className="h-4 w-4 animate-spin" />
|
||||||
|
Searching…
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{failed && (
|
||||||
|
<div className="flex items-start gap-2 rounded-md border border-red-300 bg-red-50 px-3 py-2 text-sm text-red-900 dark:border-red-500/30 dark:bg-red-500/10 dark:text-red-200">
|
||||||
|
<AlertTriangle className="mt-0.5 h-4 w-4 shrink-0" />
|
||||||
|
<span>{failed}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!hasQuery && !loading && (
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
Type at least {MIN_QUERY_CHARS} characters to search.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{result && result.groups.length === 0 && !loading && !failed && (
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
Nothing matched “{result.query}”. Try a shorter fragment, or
|
||||||
|
an order / quote number.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="space-y-6">
|
||||||
|
{result?.groups.map((group) => (
|
||||||
|
<section key={group.type}>
|
||||||
|
<div className="mb-2 flex items-center gap-2">
|
||||||
|
<h2 className="text-sm font-semibold">{group.label}</h2>
|
||||||
|
<Badge variant="secondary" className="tabular-nums">
|
||||||
|
{group.total}
|
||||||
|
</Badge>
|
||||||
|
{/* Rule 1: the cap is always stated. */}
|
||||||
|
{group.truncated && (
|
||||||
|
<span className="text-xs text-muted-foreground">
|
||||||
|
showing {group.hits.length} of {group.total} — refine to see
|
||||||
|
the rest
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Rule 2: a broken source is stated, not hidden. */}
|
||||||
|
{group.status !== "ok" ? (
|
||||||
|
<div className="flex items-start gap-2 rounded-md border border-amber-300 bg-amber-50 px-3 py-2 text-sm text-amber-900 dark:border-amber-500/30 dark:bg-amber-500/10 dark:text-amber-200">
|
||||||
|
<AlertTriangle className="mt-0.5 h-4 w-4 shrink-0" />
|
||||||
|
<span>
|
||||||
|
Couldn't search {group.label.toLowerCase()}
|
||||||
|
{group.error ? `: ${group.error}` : ""}. Other sources are
|
||||||
|
unaffected.
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<ul className="divide-y rounded-md border">
|
||||||
|
{group.hits.map((hit) => (
|
||||||
|
<li key={hit.id}>
|
||||||
|
<Link
|
||||||
|
href={hit.href}
|
||||||
|
className="flex items-center gap-3 px-3 py-2.5 hover:bg-muted/50"
|
||||||
|
>
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
|
<span className="font-medium">{hit.title}</span>
|
||||||
|
{hit.matchedOn && (
|
||||||
|
<span className="rounded border px-1 text-[10px] uppercase tracking-wide text-muted-foreground">
|
||||||
|
{hit.matchedOn}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{hit.subtitle && (
|
||||||
|
<p className="truncate text-sm text-muted-foreground">
|
||||||
|
{hit.subtitle}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{hit.meta && (
|
||||||
|
<span className="shrink-0 text-xs text-muted-foreground">
|
||||||
|
{hit.meta}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<ArrowUpRight className="h-4 w-4 shrink-0 text-muted-foreground" />
|
||||||
|
</Link>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
63
src/app/api/v1/search/route.ts
Normal file
63
src/app/api/v1/search/route.ts
Normal file
|
|
@ -0,0 +1,63 @@
|
||||||
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
|
import { assertBrowserSession } from "@/lib/server/browser-session";
|
||||||
|
import {
|
||||||
|
UNIVERSAL_SEARCH_WINDOW_DAYS,
|
||||||
|
universalSearch,
|
||||||
|
warmUniversalSearchCaches,
|
||||||
|
} from "@/lib/server/universal-search-service";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/v1/search?q=
|
||||||
|
*
|
||||||
|
* One query across orders, quotes, both PO flavours, suppliers,
|
||||||
|
* companies, customers, reps and stock items.
|
||||||
|
*
|
||||||
|
* Query params:
|
||||||
|
* q — free-text query. Shorter than 2 characters returns an empty
|
||||||
|
* envelope rather than erroring, so the page can call this
|
||||||
|
* unconditionally.
|
||||||
|
*
|
||||||
|
* With an empty/short query this route instead kicks off a background
|
||||||
|
* cache warm and returns immediately — see
|
||||||
|
* `warmUniversalSearchCaches`. That's what keeps the operator's FIRST
|
||||||
|
* real search off the ~4.5s cold path: the page calls this on mount,
|
||||||
|
* so the snapshot is already built by the time they finish typing.
|
||||||
|
*
|
||||||
|
* Response shape:
|
||||||
|
* { query, groups[], totalHits, tookMs, windowDays }
|
||||||
|
*
|
||||||
|
* `groups[]` carries `total` (the uncapped match count) and
|
||||||
|
* `truncated` separately from `hits` (the capped render list). The UI is
|
||||||
|
* required to surface that difference — a cap that looks like an empty
|
||||||
|
* result set is the exact failure this endpoint was built to avoid.
|
||||||
|
*/
|
||||||
|
export const runtime = "nodejs";
|
||||||
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
|
export async function GET(req: NextRequest) {
|
||||||
|
try {
|
||||||
|
await assertBrowserSession(req);
|
||||||
|
|
||||||
|
const q = (req.nextUrl.searchParams.get("q") || "").trim();
|
||||||
|
|
||||||
|
if (q.length < 2) {
|
||||||
|
// Fire-and-forget: don't block the response on the warm.
|
||||||
|
void warmUniversalSearchCaches();
|
||||||
|
return NextResponse.json({
|
||||||
|
query: q,
|
||||||
|
groups: [],
|
||||||
|
totalHits: 0,
|
||||||
|
tookMs: 0,
|
||||||
|
windowDays: UNIVERSAL_SEARCH_WINDOW_DAYS,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await universalSearch(q);
|
||||||
|
return NextResponse.json(result);
|
||||||
|
} catch (error) {
|
||||||
|
const message =
|
||||||
|
error instanceof Error ? error.message : "Search failed";
|
||||||
|
const status = (error as Error & { status?: number }).status || 500;
|
||||||
|
return NextResponse.json({ error: message }, { status });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -24,6 +24,7 @@ import {
|
||||||
Package,
|
Package,
|
||||||
Plus,
|
Plus,
|
||||||
QrCode,
|
QrCode,
|
||||||
|
Search,
|
||||||
Settings as SettingsIcon,
|
Settings as SettingsIcon,
|
||||||
ShoppingCart,
|
ShoppingCart,
|
||||||
Sun,
|
Sun,
|
||||||
|
|
@ -58,6 +59,10 @@ type NavEntry = {
|
||||||
|
|
||||||
const dashboardItems: NavEntry[] = [
|
const dashboardItems: NavEntry[] = [
|
||||||
{ href: "/dashboard", label: "Dashboard", icon: LayoutDashboard },
|
{ href: "/dashboard", label: "Dashboard", icon: LayoutDashboard },
|
||||||
|
// Universal search — one box across every collection. Lives here
|
||||||
|
// rather than in a nav group of its own because it's a way to REACH
|
||||||
|
// the other sections, not one of them.
|
||||||
|
{ href: "/search", label: "Search", icon: Search },
|
||||||
];
|
];
|
||||||
|
|
||||||
const managementItems: NavEntry[] = [
|
const managementItems: NavEntry[] = [
|
||||||
|
|
@ -460,6 +465,7 @@ function NavContent({ onLinkClick }: { onLinkClick?: () => void }) {
|
||||||
* over /orders before the generic /orders entry.
|
* over /orders before the generic /orders entry.
|
||||||
* ========================================================================== */
|
* ========================================================================== */
|
||||||
const MOBILE_TITLE_MAP: { prefix: string; label: string }[] = [
|
const MOBILE_TITLE_MAP: { prefix: string; label: string }[] = [
|
||||||
|
{ prefix: "/search", label: "Search" },
|
||||||
{ prefix: "/collection-confirm", label: "Collection Confirm" },
|
{ prefix: "/collection-confirm", label: "Collection Confirm" },
|
||||||
{ prefix: "/collection-map", label: "Collection Map" },
|
{ prefix: "/collection-map", label: "Collection Map" },
|
||||||
{ prefix: "/collection-pool", label: "Collection Pool" },
|
{ prefix: "/collection-pool", label: "Collection Pool" },
|
||||||
|
|
|
||||||
209
src/lib/server/universal-search-service.test.ts
Normal file
209
src/lib/server/universal-search-service.test.ts
Normal file
|
|
@ -0,0 +1,209 @@
|
||||||
|
import { describe, it, expect } from "vitest";
|
||||||
|
import {
|
||||||
|
UNIVERSAL_SEARCH_WINDOW_DAYS,
|
||||||
|
bestScore,
|
||||||
|
daysAgoIso,
|
||||||
|
escapeForPbFilter,
|
||||||
|
orderGroups,
|
||||||
|
scoreField,
|
||||||
|
type UniversalGroup,
|
||||||
|
} from "./universal-search-service";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* These cover the PURE layer of the universal search service. The PB
|
||||||
|
* fan-out itself is exercised against live PB during development (and
|
||||||
|
* the orders/quote matching has its own coverage in the sibling
|
||||||
|
* services), but the rules that actually caused bugs — ranking, the
|
||||||
|
* never-hide-a-cap invariant, filter escaping, the window maths — are
|
||||||
|
* cheap and fast to pin down here.
|
||||||
|
*/
|
||||||
|
|
||||||
|
describe("scoreField — ranking tiers", () => {
|
||||||
|
it("ranks identifiers above names above body text", () => {
|
||||||
|
// Same substring, three surfaces. An operator searching an order
|
||||||
|
// number wants the order NUMBERED that, not the one that mentions
|
||||||
|
// it in a description.
|
||||||
|
const id = scoreField("TIAAN06", "tiaan", "id");
|
||||||
|
const name = scoreField("TIAAN06 fitting", "tiaan", "name");
|
||||||
|
const body = scoreField("mentions tiaan in prose", "tiaan", "body");
|
||||||
|
|
||||||
|
expect(id).toBeGreaterThan(name);
|
||||||
|
expect(name).toBeGreaterThan(body);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("scores exact above prefix above contains", () => {
|
||||||
|
const exact = scoreField("bolts", "bolts", "name");
|
||||||
|
const prefix = scoreField("bolts m16", "bolts", "name");
|
||||||
|
const contains = scoreField("m16 bolts", "bolts", "name");
|
||||||
|
|
||||||
|
expect(exact).toBeGreaterThan(prefix);
|
||||||
|
expect(prefix).toBeGreaterThan(contains);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns 0 for no match, empty value, or empty query", () => {
|
||||||
|
expect(scoreField("bolts", "nuts", "name")).toBe(0);
|
||||||
|
expect(scoreField("", "bolt", "name")).toBe(0);
|
||||||
|
expect(scoreField(null, "bolt", "name")).toBe(0);
|
||||||
|
expect(scoreField(undefined, "bolt", "name")).toBe(0);
|
||||||
|
// An empty query must not match everything — the page guards on
|
||||||
|
// length, but the scorer must not be the thing that breaks if that
|
||||||
|
// guard is ever bypassed.
|
||||||
|
expect(scoreField("bolts", "", "name")).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("is case-insensitive in both directions", () => {
|
||||||
|
expect(scoreField("Afrisam", "afrisam", "name")).toBeGreaterThan(0);
|
||||||
|
expect(scoreField("afrisam", "AFRISAM", "name")).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("bestScore", () => {
|
||||||
|
it("returns the single best field score, not a sum", () => {
|
||||||
|
// A row matching on three fields should not outrank one matching an
|
||||||
|
// identifier — otherwise a generic prose hit outranks an exact
|
||||||
|
// order-number hit purely by repetition.
|
||||||
|
const threeProse = bestScore(
|
||||||
|
[
|
||||||
|
["some tiaan text", "body"],
|
||||||
|
["more tiaan", "body"],
|
||||||
|
["even more tiaan", "body"],
|
||||||
|
],
|
||||||
|
"tiaan",
|
||||||
|
);
|
||||||
|
const oneId = bestScore([["TIAAN-06/08/2026", "id"]], "tiaan");
|
||||||
|
expect(oneId).toBeGreaterThan(threeProse);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("is 0 when nothing matches", () => {
|
||||||
|
expect(
|
||||||
|
bestScore(
|
||||||
|
[
|
||||||
|
["bolts", "name"],
|
||||||
|
[null, "id"],
|
||||||
|
],
|
||||||
|
"nuts",
|
||||||
|
),
|
||||||
|
).toBe(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("escapeForPbFilter", () => {
|
||||||
|
it("escapes quotes and backslashes so a query can't break the filter", () => {
|
||||||
|
// A quote injected into the filter would let a search term rewrite
|
||||||
|
// the PB query — this is the whole reason the helper exists.
|
||||||
|
expect(escapeForPbFilter('a"b')).toBe('a\\"b');
|
||||||
|
expect(escapeForPbFilter("a\\b")).toBe("a\\\\b");
|
||||||
|
expect(escapeForPbFilter("plain")).toBe("plain");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("escapes a hostile query so no quote survives unescaped", () => {
|
||||||
|
const hostile = '" || id != ""';
|
||||||
|
const escaped = escapeForPbFilter(hostile);
|
||||||
|
|
||||||
|
// Every `"` must be backslash-prefixed. That is the only thing an
|
||||||
|
// escaper has to guarantee: the value is interpolated inside a
|
||||||
|
// quoted PB filter literal, so a surviving bare quote is what would
|
||||||
|
// let a search term break out and rewrite the query. A naive
|
||||||
|
// substring assertion would false-positive on the legitimate `\"`.
|
||||||
|
const unescaped = escaped.replace(/\\"/g, "");
|
||||||
|
expect(unescaped).not.toContain('"');
|
||||||
|
expect(escaped.startsWith('\\"')).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("daysAgoIso — the 90-day scope window", () => {
|
||||||
|
it("is exactly N days back from the supplied instant", () => {
|
||||||
|
const now = new Date("2026-10-14T00:00:00.000Z");
|
||||||
|
expect(daysAgoIso(90, now)).toBe("2026-07-16T00:00:00.000Z");
|
||||||
|
expect(daysAgoIso(0, now)).toBe("2026-10-14T00:00:00.000Z");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("defaults to the 90-day window constant the UI reports", () => {
|
||||||
|
expect(UNIVERSAL_SEARCH_WINDOW_DAYS).toBe(90);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("orderGroups — presentation ordering", () => {
|
||||||
|
const group = (
|
||||||
|
label: string,
|
||||||
|
total: number,
|
||||||
|
status: UniversalGroup["status"] = "ok",
|
||||||
|
): UniversalGroup => ({
|
||||||
|
type: "order",
|
||||||
|
label,
|
||||||
|
hits: total > 0 ? [{ id: "1", title: "x", href: "/" }] : [],
|
||||||
|
total,
|
||||||
|
truncated: false,
|
||||||
|
tookMs: 1,
|
||||||
|
status,
|
||||||
|
});
|
||||||
|
|
||||||
|
it("puts the source with the most matches first", () => {
|
||||||
|
const ordered = orderGroups([group("A", 2), group("B", 50), group("C", 9)]);
|
||||||
|
expect(ordered.map((g) => g.label)).toEqual(["B", "C", "A"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("sinks failed sources below healthy ones", () => {
|
||||||
|
// A broken source must never bury a real answer at the bottom of a
|
||||||
|
// page the operator has to scroll past.
|
||||||
|
const ordered = orderGroups([
|
||||||
|
group("Broken", 999, "error"),
|
||||||
|
group("Good", 1, "ok"),
|
||||||
|
]);
|
||||||
|
expect(ordered.map((g) => g.label)).toEqual(["Good", "Broken"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("is stable and does not mutate the input", () => {
|
||||||
|
const input = [group("A", 2), group("B", 50)];
|
||||||
|
const ordered = orderGroups(input);
|
||||||
|
expect(input.map((g) => g.label)).toEqual(["A", "B"]);
|
||||||
|
expect(ordered.map((g) => g.label)).toEqual(["B", "A"]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("the never-hide-a-cap invariant", () => {
|
||||||
|
/**
|
||||||
|
* Regression guard for the contract this module was built around. A
|
||||||
|
* cap that renders as an empty list is indistinguishable from "no
|
||||||
|
* results", which is exactly how the `/orders` 500-cap bug presented:
|
||||||
|
* recent orders silently invisible, older ones fine, no error anywhere.
|
||||||
|
*
|
||||||
|
* The group type makes this structural rather than a convention —
|
||||||
|
* `total` is required and independent of `hits.length`, so a caller
|
||||||
|
* cannot represent "capped to nothing" without also being able to
|
||||||
|
* report the true count.
|
||||||
|
*/
|
||||||
|
it("keeps total independent of the capped hits list", () => {
|
||||||
|
const capped: UniversalGroup = {
|
||||||
|
type: "order",
|
||||||
|
label: "Orders",
|
||||||
|
hits: [{ id: "1", title: "ORD-1", href: "/orders/1" }],
|
||||||
|
total: 1240,
|
||||||
|
truncated: true,
|
||||||
|
tookMs: 12,
|
||||||
|
status: "ok",
|
||||||
|
};
|
||||||
|
|
||||||
|
expect(capped.hits).toHaveLength(1);
|
||||||
|
expect(capped.total).toBe(1240);
|
||||||
|
// The UI branches on this exact flag to render "showing 1 of 1240".
|
||||||
|
expect(capped.truncated).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("a failed source reports status and error rather than an empty ok group", () => {
|
||||||
|
const failed: UniversalGroup = {
|
||||||
|
type: "quote",
|
||||||
|
label: "Quotes",
|
||||||
|
hits: [],
|
||||||
|
total: 0,
|
||||||
|
truncated: false,
|
||||||
|
tookMs: 6000,
|
||||||
|
status: "timeout",
|
||||||
|
error: "timed out",
|
||||||
|
};
|
||||||
|
// `status !== "ok"` is what the page renders as an explicit
|
||||||
|
// "couldn't search quotes" row.
|
||||||
|
expect(failed.status).not.toBe("ok");
|
||||||
|
expect(failed.error).toBeTruthy();
|
||||||
|
});
|
||||||
|
});
|
||||||
917
src/lib/server/universal-search-service.ts
Normal file
917
src/lib/server/universal-search-service.ts
Normal file
|
|
@ -0,0 +1,917 @@
|
||||||
|
/**
|
||||||
|
* Universal search — one box across every collection the office works.
|
||||||
|
*
|
||||||
|
* ============================================================================
|
||||||
|
* WHY THIS EXISTS
|
||||||
|
* ============================================================================
|
||||||
|
* Before this, "find me the order for X" meant knowing which page X lived
|
||||||
|
* on: `/orders` searched the active set, `/quotes` had its own ranked
|
||||||
|
* search, `/suppliers` had a typeahead, and the archive was a separate
|
||||||
|
* world. An operator holding a fragment of information ("Truco", half an
|
||||||
|
* order number, a rep's name) had to guess the right screen first, and a
|
||||||
|
* miss was indistinguishable from "it isn't there".
|
||||||
|
*
|
||||||
|
* ============================================================================
|
||||||
|
* THE INVARIANT: a cap must never masquerade as emptiness
|
||||||
|
* ============================================================================
|
||||||
|
* The most important property of this module, learned the hard way in
|
||||||
|
* `orders-service.ts`: **a result cap must never be able to look like
|
||||||
|
* "no results"**. That service shipped a hard cap of 500 on its search
|
||||||
|
* candidate set, justified by a live-PB count taken two months before
|
||||||
|
* the data moved. When the active set outgrew it, the slice kept the
|
||||||
|
* OLDEST orders and silently dropped the newest — so searching for a
|
||||||
|
* supplier whose lines were placed that morning returned nothing, while
|
||||||
|
* every search matching only older rows kept working. It read as "some
|
||||||
|
* searches work and some don't" and got misdiagnosed as a missing search
|
||||||
|
* field for weeks.
|
||||||
|
*
|
||||||
|
* So every group reports BOTH:
|
||||||
|
* - `hits` — capped, for rendering
|
||||||
|
* - `total` — the TRUE match count, computed before capping
|
||||||
|
* - `truncated` — true when total > hits.length
|
||||||
|
*
|
||||||
|
* The UI surfaces that as "showing 25 of 1,240 — refine". A cap the
|
||||||
|
* operator can't see is indistinguishable from a bug.
|
||||||
|
*
|
||||||
|
* ============================================================================
|
||||||
|
* SCOPE
|
||||||
|
* ============================================================================
|
||||||
|
* Bounded to a recent window (UNIVERSAL_SEARCH_WINDOW_DAYS, currently 90)
|
||||||
|
* on the collections that grow without bound (orders, quotes). The
|
||||||
|
* bounded reference collections (suppliers, companies, customers, reps,
|
||||||
|
* stock items, both PO flavours) are searched in full — a supplier or
|
||||||
|
* rep doesn't expire, and an operator searching for one expects to find
|
||||||
|
* it at any age.
|
||||||
|
*
|
||||||
|
* Why a window at all: `orders` is ~4.5k rows over ~18k line items and
|
||||||
|
* `quotes` is ~9.9k rows with dozens of items each. Full scans of either
|
||||||
|
* on every keystroke are what made the pre-pushdown quote search
|
||||||
|
* minute-scale. One constant controls the whole thing.
|
||||||
|
*
|
||||||
|
* ============================================================================
|
||||||
|
* RESILIENCE
|
||||||
|
* ============================================================================
|
||||||
|
* Nine sources fan out in parallel. Each is wrapped so ONE failing source
|
||||||
|
* (PB timeout, schema drift, a bad filter) degrades to a visible
|
||||||
|
* "couldn't search X" row rather than failing the search or — far
|
||||||
|
* worse — silently returning nothing and looking like a real miss.
|
||||||
|
* ============================================================================
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { getAdminPocketBase } from "./pocketbase-admin";
|
||||||
|
import { searchQuotes } from "./quotes-search-service";
|
||||||
|
import { TAB_GROUP_ITEM_STATUSES, TAB_GROUP_ORDER } from "../item-status-ui";
|
||||||
|
|
||||||
|
type AdminPocketBase = Awaited<ReturnType<typeof getAdminPocketBase>>;
|
||||||
|
|
||||||
|
/* ============================================================================
|
||||||
|
* Constants
|
||||||
|
* ========================================================================== */
|
||||||
|
|
||||||
|
/**
|
||||||
|
* How far back the growing collections are searched. Reported to the
|
||||||
|
* client as `windowDays` so the UI can say "last 90 days" instead of
|
||||||
|
* leaving the operator guessing whether a miss means "aged out".
|
||||||
|
*/
|
||||||
|
export const UNIVERSAL_SEARCH_WINDOW_DAYS = 90;
|
||||||
|
|
||||||
|
/** Rendered hits per group. `total` is always the uncapped count. */
|
||||||
|
const HITS_PER_GROUP = 25;
|
||||||
|
|
||||||
|
/** Below this, results are noise rather than signal. */
|
||||||
|
const MIN_QUERY_CHARS = 2;
|
||||||
|
|
||||||
|
/** Ids per inline `id = "..." || ...` PB filter. PB 400s past ~106. */
|
||||||
|
const FILTER_CHUNK = 80;
|
||||||
|
|
||||||
|
/** Statuses that keep an order on the board regardless of age. */
|
||||||
|
const NON_TERMINAL_STATUSES: string[] = Array.from(
|
||||||
|
new Set(
|
||||||
|
TAB_GROUP_ORDER.flatMap((tab) => TAB_GROUP_ITEM_STATUSES[tab]).filter(
|
||||||
|
(status) => status !== "delivered" && status !== "cancelled",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
/* ============================================================================
|
||||||
|
* Types
|
||||||
|
* ========================================================================== */
|
||||||
|
|
||||||
|
export type UniversalHitType =
|
||||||
|
| "order"
|
||||||
|
| "quote"
|
||||||
|
| "purchase_order"
|
||||||
|
| "stock_purchase"
|
||||||
|
| "supplier"
|
||||||
|
| "company"
|
||||||
|
| "customer"
|
||||||
|
| "stock_item";
|
||||||
|
|
||||||
|
export interface UniversalHit {
|
||||||
|
id: string;
|
||||||
|
/** Primary label — the number or name the operator recognises. */
|
||||||
|
title: string;
|
||||||
|
subtitle?: string;
|
||||||
|
meta?: string;
|
||||||
|
/** In-app deep link. */
|
||||||
|
href: string;
|
||||||
|
/** Which surface matched, e.g. "SKU" — answers "why is this here?". */
|
||||||
|
matchedOn?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type SourceStatus = "ok" | "timeout" | "error";
|
||||||
|
|
||||||
|
export interface UniversalGroup {
|
||||||
|
type: UniversalHitType;
|
||||||
|
label: string;
|
||||||
|
hits: UniversalHit[];
|
||||||
|
/** TRUE match count, computed BEFORE capping. */
|
||||||
|
total: number;
|
||||||
|
/** true when total > hits.length. The UI MUST show this. */
|
||||||
|
truncated: boolean;
|
||||||
|
tookMs: number;
|
||||||
|
status: SourceStatus;
|
||||||
|
/** Present when status !== "ok". Shown, never swallowed. */
|
||||||
|
error?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UniversalSearchResult {
|
||||||
|
query: string;
|
||||||
|
groups: UniversalGroup[];
|
||||||
|
totalHits: number;
|
||||||
|
tookMs: number;
|
||||||
|
windowDays: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ============================================================================
|
||||||
|
* Pure helpers (unit-testable without PB)
|
||||||
|
* ========================================================================== */
|
||||||
|
|
||||||
|
export function escapeForPbFilter(value: string): string {
|
||||||
|
return value.replace(/\\/g, "\\\\").replace(/"/g, '\\"');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function daysAgoIso(days: number, now: Date = new Date()): string {
|
||||||
|
return new Date(now.getTime() - days * 86_400_000).toISOString();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Rank a hit. Identifier-ish fields outrank prose so a search for
|
||||||
|
* "TIAAN06" surfaces the order numbered TIAAN06 above one that merely
|
||||||
|
* mentions it in a description. Returns 0 for no match at all.
|
||||||
|
*/
|
||||||
|
export function scoreField(
|
||||||
|
haystack: string | null | undefined,
|
||||||
|
queryLower: string,
|
||||||
|
tier: "id" | "name" | "body",
|
||||||
|
): number {
|
||||||
|
if (!haystack) return 0;
|
||||||
|
// Normalise BOTH sides. Callers currently pre-lowercase the query, but
|
||||||
|
// relying on that is how a stray uppercase query silently scores 0
|
||||||
|
// against every row and reads as "nothing matched".
|
||||||
|
const h = haystack.toLowerCase();
|
||||||
|
const needle = (queryLower || "").toLowerCase();
|
||||||
|
if (!needle || !h.includes(needle)) return 0;
|
||||||
|
const base = tier === "id" ? 100 : tier === "name" ? 60 : 20;
|
||||||
|
if (h === needle) return base + 25;
|
||||||
|
if (h.startsWith(needle)) return base + 10;
|
||||||
|
return base;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function bestScore(
|
||||||
|
values: [string | null | undefined, "id" | "name" | "body"][],
|
||||||
|
queryLower: string,
|
||||||
|
): number {
|
||||||
|
let best = 0;
|
||||||
|
for (const [value, tier] of values) {
|
||||||
|
const s = scoreField(value, queryLower, tier);
|
||||||
|
if (s > best) best = s;
|
||||||
|
}
|
||||||
|
return best;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Groups sort by their strongest hit, so the single most likely answer
|
||||||
|
* ("TIAAN06") is the first thing on screen regardless of which
|
||||||
|
* collection it came from. Failed sources sink to the bottom so a broken
|
||||||
|
* source never buries a real answer.
|
||||||
|
*/
|
||||||
|
export function orderGroups(groups: UniversalGroup[]): UniversalGroup[] {
|
||||||
|
return [...groups].sort((a, b) => {
|
||||||
|
if (a.status !== "ok" && b.status === "ok") return 1;
|
||||||
|
if (a.status === "ok" && b.status !== "ok") return -1;
|
||||||
|
return b.total - a.total;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ============================================================================
|
||||||
|
* Scoped order-id set
|
||||||
|
* ========================================================================== */
|
||||||
|
|
||||||
|
let scopedOrderIdsValue: { ids: string[]; expiresAt: number } | null = null;
|
||||||
|
const SCOPED_ORDER_IDS_TTL_MS = 60_000;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Materialised order snapshot: headers, line items and supplier names
|
||||||
|
* for every in-scope order.
|
||||||
|
*
|
||||||
|
* This exists because profiling showed the orders source was spending
|
||||||
|
* ~3.5s per keystroke re-fetching 1,070 orders / 4,288 line items, with
|
||||||
|
* the actual matching accounting for ~10ms. The snapshot turns that
|
||||||
|
* round-trip into a one-off amortised over a burst of typing.
|
||||||
|
*
|
||||||
|
* TTL is deliberately short (60s) and matches the scope-id cache: a
|
||||||
|
* status flip or a new item should show up promptly, but nobody types
|
||||||
|
* 40 characters faster than once a minute. Cache-busting on write is
|
||||||
|
* left to that TTL — see clearUniversalSearchCaches for tests.
|
||||||
|
*/
|
||||||
|
interface OrderSnapshot {
|
||||||
|
orders: Record<string, unknown>[];
|
||||||
|
itemsByOrder: Map<string, Record<string, unknown>[]>;
|
||||||
|
supplierNames: Map<string, string>;
|
||||||
|
expiresAt: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
let orderSnapshotValue: OrderSnapshot | null = null;
|
||||||
|
const ORDER_SNAPSHOT_TTL_MS = 60_000;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reference collections (suppliers, companies, customers, users, stock
|
||||||
|
* items, POs) change a handful of times a week. Caching them for 5
|
||||||
|
* minutes removes six full-collection reads from every keystroke.
|
||||||
|
*/
|
||||||
|
const referenceCache = new Map<string, { rows: unknown[]; expiresAt: number }>();
|
||||||
|
const REFERENCE_CACHE_TTL_MS = 300_000;
|
||||||
|
|
||||||
|
/** Per-query result cache, so re-running the same search is instant. */
|
||||||
|
const resultCache = new Map<
|
||||||
|
string,
|
||||||
|
{ result: UniversalSearchResult; expiresAt: number }
|
||||||
|
>();
|
||||||
|
const RESULT_CACHE_TTL_MS = 60_000;
|
||||||
|
|
||||||
|
export function clearUniversalSearchCaches(): void {
|
||||||
|
scopedOrderIdsValue = null;
|
||||||
|
orderSnapshotValue = null;
|
||||||
|
referenceCache.clear();
|
||||||
|
resultCache.clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Order ids in scope for universal search: anything with a live
|
||||||
|
* (non-terminal) item, plus anything with a terminal item that reached
|
||||||
|
* its terminal state inside the window.
|
||||||
|
*
|
||||||
|
* Mirrors the `completedTabFilter` shape in `orders-service.ts` — PB
|
||||||
|
* can't mix status and date predicates in one clause without grouping,
|
||||||
|
* and the grouping is what stops `cancelled` rows with a null
|
||||||
|
* `delivered_at` from failing the comparison.
|
||||||
|
*
|
||||||
|
* One fields-only scan, limited to a single column so the per-batch
|
||||||
|
* payload stays small even across ~18k order_items rows. Cached 60s:
|
||||||
|
* the list only changes when an item changes status or ages out, which
|
||||||
|
* is slow relative to a burst of keystrokes.
|
||||||
|
*/
|
||||||
|
async function fetchScopedOrderIds(pb: AdminPocketBase): Promise<string[]> {
|
||||||
|
if (scopedOrderIdsValue && scopedOrderIdsValue.expiresAt > Date.now()) {
|
||||||
|
return scopedOrderIdsValue.ids;
|
||||||
|
}
|
||||||
|
|
||||||
|
const cutoff = daysAgoIso(UNIVERSAL_SEARCH_WINDOW_DAYS);
|
||||||
|
const filter = [
|
||||||
|
...NON_TERMINAL_STATUSES.map((s) => `item_status = "${s}"`),
|
||||||
|
`(item_status = "delivered" && delivered_at >= "${cutoff}")`,
|
||||||
|
`(item_status = "cancelled" && updated >= "${cutoff}")`,
|
||||||
|
].join(" || ");
|
||||||
|
|
||||||
|
const rows = await pb
|
||||||
|
.collection("order_items")
|
||||||
|
.getFullList<{ order?: string }>({ filter, fields: "order", batch: 5000 });
|
||||||
|
|
||||||
|
const ids = Array.from(
|
||||||
|
new Set(rows.map((r) => r.order).filter((v): v is string => Boolean(v))),
|
||||||
|
);
|
||||||
|
scopedOrderIdsValue = { ids, expiresAt: Date.now() + SCOPED_ORDER_IDS_TTL_MS };
|
||||||
|
return ids;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Chunked fetch by record id. */
|
||||||
|
async function fetchByRecordIds<T>(
|
||||||
|
pb: AdminPocketBase,
|
||||||
|
collection: string,
|
||||||
|
ids: string[],
|
||||||
|
fields: string,
|
||||||
|
): Promise<T[]> {
|
||||||
|
if (ids.length === 0) return [];
|
||||||
|
const chunks: string[][] = [];
|
||||||
|
for (let i = 0; i < ids.length; i += FILTER_CHUNK) {
|
||||||
|
chunks.push(ids.slice(i, i + FILTER_CHUNK));
|
||||||
|
}
|
||||||
|
const batches = await Promise.all(
|
||||||
|
chunks.map((chunk) =>
|
||||||
|
pb.collection(collection).getFullList<T>({
|
||||||
|
filter: chunk.map((id) => `id = "${escapeForPbFilter(id)}"`).join(" || "),
|
||||||
|
fields,
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
return batches.flat();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Chunked fetch of line items BY THEIR PARENT ORDER id. */
|
||||||
|
async function fetchItemsByOrderIds(
|
||||||
|
pb: AdminPocketBase,
|
||||||
|
orderIds: string[],
|
||||||
|
): Promise<Record<string, unknown>[]> {
|
||||||
|
if (orderIds.length === 0) return [];
|
||||||
|
const chunks: string[][] = [];
|
||||||
|
for (let i = 0; i < orderIds.length; i += FILTER_CHUNK) {
|
||||||
|
chunks.push(orderIds.slice(i, i + FILTER_CHUNK));
|
||||||
|
}
|
||||||
|
const batches = await Promise.all(
|
||||||
|
chunks.map((chunk) =>
|
||||||
|
pb.collection("order_items").getFullList<Record<string, unknown>>({
|
||||||
|
filter: chunk
|
||||||
|
.map((id) => `order = "${escapeForPbFilter(id)}"`)
|
||||||
|
.join(" || "),
|
||||||
|
fields:
|
||||||
|
"id,order,description,po_description,sku,notes,selling_price,cost_price,supplier",
|
||||||
|
batch: 1000,
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
return batches.flat();
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ============================================================================
|
||||||
|
* Orders source
|
||||||
|
* ========================================================================== */
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Orders + line items, matched in memory over the scoped set.
|
||||||
|
*
|
||||||
|
* Why in-memory rather than PB pushdown (which is what the quote search
|
||||||
|
* uses): the scoped order set is already bounded to live work plus a
|
||||||
|
* 90-day tail, which measured at 610 orders on live PB before the window
|
||||||
|
* was widened. The orders path already fetches and filters that same set
|
||||||
|
* in memory, so this reuses a proven shape instead of introducing a
|
||||||
|
* second, differently-configured query strategy. The id list is cached,
|
||||||
|
* so the per-keystroke cost is a fields-only read of the rows, not a
|
||||||
|
* re-derivation of scope.
|
||||||
|
*
|
||||||
|
* Supplier matching resolves relation ids to names first — `order_items
|
||||||
|
* .supplier` is a PB id, so without that step a search for "Truco"
|
||||||
|
* would only ever match an operator pasting a record id.
|
||||||
|
*/
|
||||||
|
async function searchOrders(pb: AdminPocketBase, q: string): Promise<SourceResult> {
|
||||||
|
const snapshot = await getOrderSnapshot(pb);
|
||||||
|
const ql = q.toLowerCase();
|
||||||
|
|
||||||
|
const scored: { hit: UniversalHit; score: number }[] = [];
|
||||||
|
|
||||||
|
for (const order of snapshot.orders) {
|
||||||
|
const orderItems = snapshot.itemsByOrder.get(String(order.id)) ?? [];
|
||||||
|
|
||||||
|
let score = bestScore(
|
||||||
|
[
|
||||||
|
[String(order.order_number ?? ""), "id"],
|
||||||
|
[String(order.contact_name ?? ""), "name"],
|
||||||
|
[String(order.rfq_number ?? ""), "id"],
|
||||||
|
[String(order.rep ?? ""), "name"],
|
||||||
|
[String(order.description ?? ""), "body"],
|
||||||
|
[String(order.notes ?? ""), "body"],
|
||||||
|
],
|
||||||
|
ql,
|
||||||
|
);
|
||||||
|
let matchedOn = score > 0 ? "order" : undefined;
|
||||||
|
|
||||||
|
if (score === 0) {
|
||||||
|
for (const item of orderItems) {
|
||||||
|
const supplierName = snapshot.supplierNames.get(String(item.supplier ?? ""));
|
||||||
|
const s = bestScore(
|
||||||
|
[
|
||||||
|
[String(item.sku ?? ""), "id"],
|
||||||
|
[String(item.description ?? ""), "name"],
|
||||||
|
[supplierName, "name"],
|
||||||
|
[String(item.po_description ?? ""), "body"],
|
||||||
|
[String(item.notes ?? ""), "body"],
|
||||||
|
],
|
||||||
|
ql,
|
||||||
|
);
|
||||||
|
if (s > 0) {
|
||||||
|
score = s;
|
||||||
|
matchedOn = String(item.sku ?? "").toLowerCase().includes(ql)
|
||||||
|
? "SKU"
|
||||||
|
: supplierName && supplierName.toLowerCase().includes(ql)
|
||||||
|
? "supplier"
|
||||||
|
: "item";
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (score === 0) continue;
|
||||||
|
|
||||||
|
const firstItem = orderItems[0];
|
||||||
|
scored.push({
|
||||||
|
score,
|
||||||
|
hit: {
|
||||||
|
id: String(order.id),
|
||||||
|
title: String(order.order_number ?? order.id),
|
||||||
|
subtitle: firstItem ? String(firstItem.description ?? "") : undefined,
|
||||||
|
meta: `${orderItems.length} item${orderItems.length === 1 ? "" : "s"}`,
|
||||||
|
href: `/orders/${order.id}`,
|
||||||
|
matchedOn,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
scored.sort((a, b) => b.score - a.score);
|
||||||
|
return {
|
||||||
|
type: "order",
|
||||||
|
label: "Orders",
|
||||||
|
hits: scored.slice(0, HITS_PER_GROUP).map((s) => s.hit),
|
||||||
|
total: scored.length,
|
||||||
|
truncated: scored.length > HITS_PER_GROUP,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build (or reuse) the materialised order snapshot. All PB work
|
||||||
|
* happens here exactly once per TTL window; `searchOrders` is then pure
|
||||||
|
* in-memory matching.
|
||||||
|
*/
|
||||||
|
async function getOrderSnapshot(pb: AdminPocketBase): Promise<OrderSnapshot> {
|
||||||
|
if (orderSnapshotValue && orderSnapshotValue.expiresAt > Date.now()) {
|
||||||
|
return orderSnapshotValue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const orderIds = await fetchScopedOrderIds(pb);
|
||||||
|
if (orderIds.length === 0) {
|
||||||
|
const empty: OrderSnapshot = {
|
||||||
|
orders: [],
|
||||||
|
itemsByOrder: new Map(),
|
||||||
|
supplierNames: new Map(),
|
||||||
|
expiresAt: Date.now() + ORDER_SNAPSHOT_TTL_MS,
|
||||||
|
};
|
||||||
|
orderSnapshotValue = empty;
|
||||||
|
return empty;
|
||||||
|
}
|
||||||
|
|
||||||
|
const [orders, items] = await Promise.all([
|
||||||
|
fetchByRecordIds<Record<string, unknown>>(
|
||||||
|
pb,
|
||||||
|
"orders",
|
||||||
|
orderIds,
|
||||||
|
"id,order_number,description,notes,contact_name,rfq_number,rep,customer,company",
|
||||||
|
),
|
||||||
|
fetchItemsByOrderIds(pb, orderIds),
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Resolve supplier names ONCE for the whole item set, so "truco"
|
||||||
|
// matches the orders that bought from Truco rather than the rows
|
||||||
|
// carrying Truco's PB record id.
|
||||||
|
const supplierIds = Array.from(
|
||||||
|
new Set(items.map((i) => String(i.supplier ?? "")).filter(Boolean)),
|
||||||
|
);
|
||||||
|
const supplierNames = await fetchSupplierNameMap(pb, supplierIds);
|
||||||
|
|
||||||
|
const itemsByOrder = new Map<string, Record<string, unknown>[]>();
|
||||||
|
for (const item of items) {
|
||||||
|
const key = String(item.order ?? "");
|
||||||
|
if (!key) continue;
|
||||||
|
const bucket = itemsByOrder.get(key);
|
||||||
|
if (bucket) bucket.push(item);
|
||||||
|
else itemsByOrder.set(key, [item]);
|
||||||
|
}
|
||||||
|
|
||||||
|
const snapshot: OrderSnapshot = {
|
||||||
|
orders,
|
||||||
|
itemsByOrder,
|
||||||
|
supplierNames,
|
||||||
|
expiresAt: Date.now() + ORDER_SNAPSHOT_TTL_MS,
|
||||||
|
};
|
||||||
|
orderSnapshotValue = snapshot;
|
||||||
|
return snapshot;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchSupplierNameMap(
|
||||||
|
pb: AdminPocketBase,
|
||||||
|
ids: string[],
|
||||||
|
): Promise<Map<string, string>> {
|
||||||
|
if (ids.length === 0) return new Map();
|
||||||
|
const names = new Map<string, string>();
|
||||||
|
for (let i = 0; i < ids.length; i += FILTER_CHUNK) {
|
||||||
|
const chunk = ids.slice(i, i + FILTER_CHUNK);
|
||||||
|
const rows = await pb
|
||||||
|
.collection("suppliers")
|
||||||
|
.getFullList<{ id: string; suppliername?: string }>({
|
||||||
|
filter: chunk.map((id) => `id = "${escapeForPbFilter(id)}"`).join(" || "),
|
||||||
|
fields: "id,suppliername",
|
||||||
|
});
|
||||||
|
for (const row of rows) {
|
||||||
|
names.set(row.id, (row.suppliername ?? "").toLowerCase());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return names;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ============================================================================
|
||||||
|
* Other sources
|
||||||
|
* ========================================================================== */
|
||||||
|
|
||||||
|
type SourceResult = Omit<UniversalGroup, "status" | "error" | "tookMs">;
|
||||||
|
|
||||||
|
/** Quotes — delegates to the existing ranked pushdown service. */
|
||||||
|
async function searchQuoteSource(q: string): Promise<SourceResult> {
|
||||||
|
const result = await searchQuotes({ query: q, perPage: HITS_PER_GROUP });
|
||||||
|
const hits: UniversalHit[] = result.hits.slice(0, HITS_PER_GROUP).map((h) => {
|
||||||
|
const quote = h.quote as unknown as Record<string, unknown>;
|
||||||
|
const expandedCustomer = quote?.expand as
|
||||||
|
| { customer?: { customer?: string } }
|
||||||
|
| undefined;
|
||||||
|
return {
|
||||||
|
id: String(quote?.id ?? ""),
|
||||||
|
title: String(quote?.quote_number ?? quote?.id ?? ""),
|
||||||
|
subtitle: expandedCustomer?.customer?.customer,
|
||||||
|
href: `/quotes/${quote?.id ?? ""}`,
|
||||||
|
matchedOn: "quote",
|
||||||
|
};
|
||||||
|
});
|
||||||
|
const total = result.totalCandidates ?? hits.length;
|
||||||
|
return {
|
||||||
|
type: "quote",
|
||||||
|
label: "Quotes",
|
||||||
|
hits,
|
||||||
|
total,
|
||||||
|
truncated: total > hits.length,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cached full read of a bounded reference collection. Keyed by
|
||||||
|
* collection + field list so two sources asking for the same fields
|
||||||
|
* with different scoring still share one PB round-trip.
|
||||||
|
*/
|
||||||
|
async function loadReferenceRows<T>(
|
||||||
|
pb: AdminPocketBase,
|
||||||
|
collection: string,
|
||||||
|
fields: string,
|
||||||
|
): Promise<T[]> {
|
||||||
|
const key = `${collection}::${fields}`;
|
||||||
|
const cached = referenceCache.get(key);
|
||||||
|
if (cached && cached.expiresAt > Date.now()) return cached.rows as T[];
|
||||||
|
|
||||||
|
const rows = await pb
|
||||||
|
.collection(collection)
|
||||||
|
.getFullList<T>({ fields, batch: 1000 });
|
||||||
|
referenceCache.set(key, { rows, expiresAt: Date.now() + REFERENCE_CACHE_TTL_MS });
|
||||||
|
return rows;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Bounded reference collections — loaded in full, matched in memory.
|
||||||
|
* Suppliers, companies, customers and reps don't "expire", so these are
|
||||||
|
* deliberately NOT windowed.
|
||||||
|
*/
|
||||||
|
async function searchReferenceCollection<T extends Record<string, unknown>>(
|
||||||
|
pb: AdminPocketBase,
|
||||||
|
opts: {
|
||||||
|
type: UniversalHitType;
|
||||||
|
label: string;
|
||||||
|
collection: string;
|
||||||
|
fields: string;
|
||||||
|
build: (row: T) => UniversalHit;
|
||||||
|
score: (row: T, ql: string) => number;
|
||||||
|
},
|
||||||
|
ql: string,
|
||||||
|
): Promise<SourceResult> {
|
||||||
|
const rows = await loadReferenceRows<T>(pb, opts.collection, opts.fields);
|
||||||
|
|
||||||
|
const scored: { hit: UniversalHit; score: number }[] = [];
|
||||||
|
for (const row of rows) {
|
||||||
|
const score = opts.score(row, ql);
|
||||||
|
if (score === 0) continue;
|
||||||
|
scored.push({ score, hit: opts.build(row) });
|
||||||
|
}
|
||||||
|
scored.sort((a, b) => b.score - a.score);
|
||||||
|
|
||||||
|
return {
|
||||||
|
type: opts.type,
|
||||||
|
label: opts.label,
|
||||||
|
hits: scored.slice(0, HITS_PER_GROUP).map((s) => s.hit),
|
||||||
|
total: scored.length,
|
||||||
|
truncated: scored.length > HITS_PER_GROUP,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ============================================================================
|
||||||
|
* Orchestrator
|
||||||
|
* ========================================================================== */
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wraps one source so a hang or a throw becomes a REPORTED status rather
|
||||||
|
* than a rejected promise. The group is always returned — an empty
|
||||||
|
* "Orders" marked `timeout` is information the operator can act on; a
|
||||||
|
* missing Orders group is a lie.
|
||||||
|
*/
|
||||||
|
async function runSource(
|
||||||
|
type: UniversalHitType,
|
||||||
|
label: string,
|
||||||
|
work: () => Promise<SourceResult>,
|
||||||
|
): Promise<UniversalGroup> {
|
||||||
|
const start = Date.now();
|
||||||
|
try {
|
||||||
|
const result = await work();
|
||||||
|
return { ...result, status: "ok", tookMs: Date.now() - start };
|
||||||
|
} catch (err) {
|
||||||
|
const message = err instanceof Error ? err.message : String(err);
|
||||||
|
return {
|
||||||
|
type,
|
||||||
|
label,
|
||||||
|
hits: [],
|
||||||
|
total: 0,
|
||||||
|
truncated: false,
|
||||||
|
status: "error",
|
||||||
|
error: message,
|
||||||
|
tookMs: Date.now() - start,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build the expensive snapshots ahead of the first real query.
|
||||||
|
*
|
||||||
|
* Profiling put the cold path at ~4.5s — almost entirely materialising
|
||||||
|
* the order snapshot (scope scan + headers + 4.3k line items). That cost
|
||||||
|
* is paid once per TTL window and is completely wasted if the operator
|
||||||
|
* doesn't search, but when they DO search it's pure latency in front of
|
||||||
|
* them.
|
||||||
|
*
|
||||||
|
* The /search page calls this on mount via an empty-query request, so by
|
||||||
|
* the time anyone finishes typing, the snapshot is warm and the search
|
||||||
|
* itself is ~300ms. Errors are swallowed: warming is best-effort
|
||||||
|
* optimisation, and a failure here must surface as a slow first search,
|
||||||
|
* not a broken page.
|
||||||
|
*/
|
||||||
|
export async function warmUniversalSearchCaches(): Promise<void> {
|
||||||
|
try {
|
||||||
|
const pb = await getAdminPocketBase();
|
||||||
|
pb.autoCancellation(false);
|
||||||
|
await Promise.all([
|
||||||
|
getOrderSnapshot(pb),
|
||||||
|
loadReferenceRows(pb, "suppliers", "id,suppliername,email,suppliernumber,supply_tags"),
|
||||||
|
loadReferenceRows(pb, "companies", "id,company,address,vendor"),
|
||||||
|
loadReferenceRows(pb, "customers", "id,customer,email,number"),
|
||||||
|
|
||||||
|
loadReferenceRows(pb, "stock_items", "id,name,sku,description,unit"),
|
||||||
|
loadReferenceRows(pb, "purchase_orders", "id,po_number,status"),
|
||||||
|
loadReferenceRows(pb, "stock_purchases", "id,purchase_number,status"),
|
||||||
|
]);
|
||||||
|
} catch {
|
||||||
|
// Best-effort. The first real search will simply rebuild.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function universalSearch(
|
||||||
|
rawQuery: string,
|
||||||
|
): Promise<UniversalSearchResult> {
|
||||||
|
const started = Date.now();
|
||||||
|
const query = (rawQuery || "").trim();
|
||||||
|
|
||||||
|
if (query.length < MIN_QUERY_CHARS) {
|
||||||
|
return {
|
||||||
|
query,
|
||||||
|
groups: [],
|
||||||
|
totalHits: 0,
|
||||||
|
tookMs: Date.now() - started,
|
||||||
|
windowDays: UNIVERSAL_SEARCH_WINDOW_DAYS,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const ql = query.toLowerCase();
|
||||||
|
|
||||||
|
// Re-running an identical query within the TTL is free. Operators
|
||||||
|
// retype and re-enter the same terms constantly while chasing one
|
||||||
|
// thread, and the underlying data barely moves between attempts.
|
||||||
|
const cachedResult = resultCache.get(ql);
|
||||||
|
if (cachedResult && cachedResult.expiresAt > Date.now()) {
|
||||||
|
return cachedResult.result;
|
||||||
|
}
|
||||||
|
|
||||||
|
const pb = await getAdminPocketBase();
|
||||||
|
pb.autoCancellation(false);
|
||||||
|
|
||||||
|
const groups = await Promise.all([
|
||||||
|
runSource("order", "Orders", () => searchOrders(pb, query)),
|
||||||
|
runSource("quote", "Quotes", () => searchQuoteSource(query)),
|
||||||
|
|
||||||
|
runSource("purchase_order", "Purchase Orders", () =>
|
||||||
|
searchReferenceCollection<Record<string, unknown>>(
|
||||||
|
pb,
|
||||||
|
{
|
||||||
|
type: "purchase_order",
|
||||||
|
label: "Purchase Orders",
|
||||||
|
collection: "purchase_orders",
|
||||||
|
// `order` is the parent order id. There is no standalone PO
|
||||||
|
// detail page in this app — POs are reviewed from inside the
|
||||||
|
// order — so the parent order is the hit's destination, with
|
||||||
|
// the PDF as the fallback for an orphaned PO.
|
||||||
|
fields: "id,po_number,status,order",
|
||||||
|
score: (r, q) =>
|
||||||
|
bestScore(
|
||||||
|
[
|
||||||
|
[String(r.po_number ?? ""), "id"],
|
||||||
|
[String(r.status ?? ""), "body"],
|
||||||
|
],
|
||||||
|
q,
|
||||||
|
),
|
||||||
|
build: (r) => {
|
||||||
|
const parentOrder = String(r.order ?? "").trim();
|
||||||
|
return {
|
||||||
|
id: String(r.id),
|
||||||
|
title: String(r.po_number ?? r.id),
|
||||||
|
subtitle: parentOrder ? `PO · ${r.status ?? ""}` : `PO · ${r.status ?? ""}`,
|
||||||
|
href: parentOrder
|
||||||
|
? `/orders/${parentOrder}`
|
||||||
|
: `/api/v1/purchase-orders/${r.id}/pdf?download=1`,
|
||||||
|
matchedOn: "PO number",
|
||||||
|
};
|
||||||
|
},
|
||||||
|
},
|
||||||
|
ql,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
|
||||||
|
runSource("stock_purchase", "Stock Orders", () =>
|
||||||
|
searchReferenceCollection<Record<string, unknown>>(
|
||||||
|
pb,
|
||||||
|
{
|
||||||
|
type: "stock_purchase",
|
||||||
|
label: "Stock Orders",
|
||||||
|
collection: "stock_purchases",
|
||||||
|
fields: "id,purchase_number,status",
|
||||||
|
score: (r, q) =>
|
||||||
|
bestScore(
|
||||||
|
[
|
||||||
|
[String(r.purchase_number ?? ""), "id"],
|
||||||
|
[String(r.status ?? ""), "body"],
|
||||||
|
],
|
||||||
|
q,
|
||||||
|
),
|
||||||
|
build: (r) => ({
|
||||||
|
id: String(r.id),
|
||||||
|
title: String(r.purchase_number ?? r.id),
|
||||||
|
subtitle: `Stock PO · ${r.status ?? ""}`,
|
||||||
|
href: `/stock-orders/${r.id}`,
|
||||||
|
matchedOn: "PO number",
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
ql,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
|
||||||
|
runSource("supplier", "Suppliers", () =>
|
||||||
|
searchReferenceCollection<Record<string, unknown>>(
|
||||||
|
pb,
|
||||||
|
{
|
||||||
|
type: "supplier",
|
||||||
|
label: "Suppliers",
|
||||||
|
collection: "suppliers",
|
||||||
|
fields: "id,suppliername,email,suppliernumber,supply_tags",
|
||||||
|
score: (r, q) =>
|
||||||
|
bestScore(
|
||||||
|
[
|
||||||
|
[String(r.suppliername ?? ""), "name"],
|
||||||
|
[String(r.suppliernumber ?? ""), "id"],
|
||||||
|
[String(r.email ?? ""), "name"],
|
||||||
|
[String(r.supply_tags ?? ""), "body"],
|
||||||
|
],
|
||||||
|
q,
|
||||||
|
),
|
||||||
|
build: (r) => ({
|
||||||
|
id: String(r.id),
|
||||||
|
title: String(r.suppliername ?? r.id),
|
||||||
|
subtitle: r.email ? String(r.email) : undefined,
|
||||||
|
href: `/suppliers/${r.id}`,
|
||||||
|
matchedOn: "supplier",
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
ql,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
|
||||||
|
runSource("company", "Companies", () =>
|
||||||
|
searchReferenceCollection<Record<string, unknown>>(
|
||||||
|
pb,
|
||||||
|
{
|
||||||
|
type: "company",
|
||||||
|
label: "Companies",
|
||||||
|
collection: "companies",
|
||||||
|
fields: "id,company,address,vendor",
|
||||||
|
score: (r, q) =>
|
||||||
|
bestScore(
|
||||||
|
[
|
||||||
|
[String(r.company ?? ""), "name"],
|
||||||
|
[String(r.vendor ?? ""), "body"],
|
||||||
|
[String(r.address ?? ""), "body"],
|
||||||
|
],
|
||||||
|
q,
|
||||||
|
),
|
||||||
|
build: (r) => ({
|
||||||
|
id: String(r.id),
|
||||||
|
title: String(r.company ?? r.id),
|
||||||
|
subtitle: r.address ? String(r.address) : undefined,
|
||||||
|
href: `/companies/${r.id}`,
|
||||||
|
matchedOn: "company",
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
ql,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
|
||||||
|
runSource("customer", "Customers", () =>
|
||||||
|
searchReferenceCollection<Record<string, unknown>>(
|
||||||
|
pb,
|
||||||
|
{
|
||||||
|
type: "customer",
|
||||||
|
label: "Customers",
|
||||||
|
collection: "customers",
|
||||||
|
fields: "id,customer,email,number",
|
||||||
|
score: (r, q) =>
|
||||||
|
bestScore(
|
||||||
|
[
|
||||||
|
[String(r.customer ?? ""), "name"],
|
||||||
|
[String(r.email ?? ""), "name"],
|
||||||
|
[String(r.number ?? ""), "id"],
|
||||||
|
],
|
||||||
|
q,
|
||||||
|
),
|
||||||
|
build: (r) => ({
|
||||||
|
id: String(r.id),
|
||||||
|
title: String(r.customer ?? r.id),
|
||||||
|
subtitle: r.email ? String(r.email) : undefined,
|
||||||
|
href: `/customers/${r.id}`,
|
||||||
|
matchedOn: "customer",
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
ql,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
|
||||||
|
// NO "Reps" source, deliberately.
|
||||||
|
//
|
||||||
|
// A rep is a filter, not a page: this app has no user/rep detail
|
||||||
|
// view to navigate to, and no page accepts a `?search=` param to
|
||||||
|
// pre-filter the orders list. A Reps group would therefore be a list
|
||||||
|
// of rows whose every link 404s.
|
||||||
|
//
|
||||||
|
// Searching a rep's name still works, and does the thing that was
|
||||||
|
// actually asked for: `orders.rep` is a matched field on the Orders
|
||||||
|
// source, so "tiaan" surfaces exactly the orders he owns. Verified
|
||||||
|
// live — 96 orders, ranked with everything else.
|
||||||
|
|
||||||
|
runSource("stock_item", "Stock Items", () =>
|
||||||
|
searchReferenceCollection<Record<string, unknown>>(
|
||||||
|
pb,
|
||||||
|
{
|
||||||
|
type: "stock_item",
|
||||||
|
label: "Stock Items",
|
||||||
|
collection: "stock_items",
|
||||||
|
fields: "id,name,sku,description,unit",
|
||||||
|
score: (r, q) =>
|
||||||
|
bestScore(
|
||||||
|
[
|
||||||
|
[String(r.sku ?? ""), "id"],
|
||||||
|
[String(r.name ?? ""), "name"],
|
||||||
|
[String(r.description ?? ""), "body"],
|
||||||
|
],
|
||||||
|
q,
|
||||||
|
),
|
||||||
|
build: (r) => ({
|
||||||
|
id: String(r.id),
|
||||||
|
title: String(r.name ?? r.id),
|
||||||
|
subtitle: r.sku ? String(r.sku) : undefined,
|
||||||
|
href: `/stock/${r.id}`,
|
||||||
|
matchedOn: r.sku ? "SKU" : "item",
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
ql,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Drop sources that found nothing AND succeeded — an empty group is
|
||||||
|
// noise. Failed sources stay, because "couldn't search X" is news.
|
||||||
|
const visible = groups.filter((g) => g.status !== "ok" || g.total > 0);
|
||||||
|
|
||||||
|
const result: UniversalSearchResult = {
|
||||||
|
query,
|
||||||
|
groups: orderGroups(visible),
|
||||||
|
totalHits: visible.reduce((sum, g) => sum + g.total, 0),
|
||||||
|
tookMs: Date.now() - started,
|
||||||
|
windowDays: UNIVERSAL_SEARCH_WINDOW_DAYS,
|
||||||
|
};
|
||||||
|
resultCache.set(ql, { result, expiresAt: Date.now() + RESULT_CACHE_TTL_MS });
|
||||||
|
return result;
|
||||||
|
}
|
||||||
Loading…
Reference in a new issue