diff --git a/src/app/(app)/search/page.tsx b/src/app/(app)/search/page.tsx new file mode 100644 index 0000000..cd5faf2 --- /dev/null +++ b/src/app/(app)/search/page.tsx @@ -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(null); + const [loading, setLoading] = useState(false); + const [failed, setFailed] = useState(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 ( +
+
+

Search

+

+ One box across orders, quotes, purchase orders, suppliers, companies, + customers, reps and stock. +

+
+ +
+ + 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 && ( + + )} +
+ + {/* Scope statement. The operator needs to know the window exists, + or a miss looks like a bug rather than an age-out. */} + {result && !failed && ( +

+ {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. +

+ )} + + {loading && ( +

+ + Searching… +

+ )} + + {failed && ( +
+ + {failed} +
+ )} + + {!hasQuery && !loading && ( +

+ Type at least {MIN_QUERY_CHARS} characters to search. +

+ )} + + {result && result.groups.length === 0 && !loading && !failed && ( +

+ Nothing matched “{result.query}”. Try a shorter fragment, or + an order / quote number. +

+ )} + +
+ {result?.groups.map((group) => ( +
+
+

{group.label}

+ + {group.total} + + {/* Rule 1: the cap is always stated. */} + {group.truncated && ( + + showing {group.hits.length} of {group.total} — refine to see + the rest + + )} +
+ + {/* Rule 2: a broken source is stated, not hidden. */} + {group.status !== "ok" ? ( +
+ + + Couldn't search {group.label.toLowerCase()} + {group.error ? `: ${group.error}` : ""}. Other sources are + unaffected. + +
+ ) : ( +
    + {group.hits.map((hit) => ( +
  • + +
    +
    + {hit.title} + {hit.matchedOn && ( + + {hit.matchedOn} + + )} +
    + {hit.subtitle && ( +

    + {hit.subtitle} +

    + )} +
    + {hit.meta && ( + + {hit.meta} + + )} + + +
  • + ))} +
+ )} +
+ ))} +
+
+ ); +} \ No newline at end of file diff --git a/src/app/api/v1/search/route.ts b/src/app/api/v1/search/route.ts new file mode 100644 index 0000000..e9da2c9 --- /dev/null +++ b/src/app/api/v1/search/route.ts @@ -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 }); + } +} \ No newline at end of file diff --git a/src/components/sidebar.tsx b/src/components/sidebar.tsx index 451ecb1..b664326 100644 --- a/src/components/sidebar.tsx +++ b/src/components/sidebar.tsx @@ -24,6 +24,7 @@ import { Package, Plus, QrCode, + Search, Settings as SettingsIcon, ShoppingCart, Sun, @@ -58,6 +59,10 @@ type NavEntry = { const dashboardItems: NavEntry[] = [ { 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[] = [ @@ -460,6 +465,7 @@ function NavContent({ onLinkClick }: { onLinkClick?: () => void }) { * over /orders before the generic /orders entry. * ========================================================================== */ const MOBILE_TITLE_MAP: { prefix: string; label: string }[] = [ + { prefix: "/search", label: "Search" }, { prefix: "/collection-confirm", label: "Collection Confirm" }, { prefix: "/collection-map", label: "Collection Map" }, { prefix: "/collection-pool", label: "Collection Pool" }, diff --git a/src/lib/server/universal-search-service.test.ts b/src/lib/server/universal-search-service.test.ts new file mode 100644 index 0000000..d2446c2 --- /dev/null +++ b/src/lib/server/universal-search-service.test.ts @@ -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(); + }); +}); \ No newline at end of file diff --git a/src/lib/server/universal-search-service.ts b/src/lib/server/universal-search-service.ts new file mode 100644 index 0000000..c1907f2 --- /dev/null +++ b/src/lib/server/universal-search-service.ts @@ -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>; + +/* ============================================================================ + * 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[]; + itemsByOrder: Map[]>; + supplierNames: Map; + 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(); +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 { + 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( + pb: AdminPocketBase, + collection: string, + ids: string[], + fields: string, +): Promise { + 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({ + 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[]> { + 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>({ + 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 { + 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 { + 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>( + 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[]>(); + 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> { + if (ids.length === 0) return new Map(); + const names = new Map(); + 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; + +/** Quotes — delegates to the existing ranked pushdown service. */ +async function searchQuoteSource(q: string): Promise { + 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; + 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( + pb: AdminPocketBase, + collection: string, + fields: string, +): Promise { + 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({ 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>( + pb: AdminPocketBase, + opts: { + type: UniversalHitType; + label: string; + collection: string; + fields: string; + build: (row: T) => UniversalHit; + score: (row: T, ql: string) => number; + }, + ql: string, +): Promise { + const rows = await loadReferenceRows(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, +): Promise { + 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 { + 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 { + 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>( + 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>( + 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>( + 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>( + 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>( + 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>( + 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; +} \ No newline at end of file