From c826f65bd69967ab336134240584b596d6345d5a Mon Sep 17 00:00:00 2001 From: Scrubs McBarkley Date: Sun, 22 Mar 2026 00:16:28 +0000 Subject: [PATCH 1/2] feat: quick-find search for clients and pets (GH #97, GRO-140) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Backend: - GET /api/search?q={query} — returns up to 10 matching active clients and 10 matching pets in a single request; clients matched on name/email/phone, pets matched on name/breed with owner name included - Special chars (%, _, \) escaped before ILIKE to prevent injection/accidents - Disabled clients excluded; pets from disabled client owners excluded via JOIN filter - Route registered under protected API (auth + RBAC middleware applies automatically) - Export `ilike` from @groombook/db alongside existing drizzle-orm helpers Frontend: - GlobalSearch component in sticky admin header: debounced input (300ms), grouped dropdown (Clients / Pets sections), loading/empty states - Client results show name + phone; pet results show name, breed, owner name - Touch-friendly: 44px input height, 48px min row height, full-width dropdown - Outside-click closes dropdown; selecting a result navigates to /admin/clients Tests (apps/api/src/__tests__/search.test.ts): - 400 on missing/empty/whitespace q - Returns matching clients and pets - Empty arrays on no match - Response shape always has clients/pets keys - Special character inputs handled without errors Co-Authored-By: Claude Sonnet 4.6 --- apps/api/src/__tests__/search.test.ts | 172 ++++++++++++++ apps/api/src/index.ts | 2 + apps/api/src/routes/search.ts | 70 ++++++ apps/web/src/App.tsx | 2 + apps/web/src/components/GlobalSearch.tsx | 277 +++++++++++++++++++++++ packages/db/src/index.ts | 2 +- 6 files changed, 524 insertions(+), 1 deletion(-) create mode 100644 apps/api/src/__tests__/search.test.ts create mode 100644 apps/api/src/routes/search.ts create mode 100644 apps/web/src/components/GlobalSearch.tsx diff --git a/apps/api/src/__tests__/search.test.ts b/apps/api/src/__tests__/search.test.ts new file mode 100644 index 0000000..979bc90 --- /dev/null +++ b/apps/api/src/__tests__/search.test.ts @@ -0,0 +1,172 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { Hono } from "hono"; + +// ─── Mock data ──────────────────────────────────────────────────────────────── + +const ACTIVE_CLIENT = { + id: "client-1", + name: "Alice Johnson", + email: "alice@example.com", + phone: "555-1234", +}; + +const PET_ROW = { + id: "pet-1", + name: "Bella", + breed: "Golden Retriever", + clientId: "client-1", + ownerName: "Alice Johnson", +}; + +// ─── Mock DB ────────────────────────────────────────────────────────────────── + +let clientResults: typeof ACTIVE_CLIENT[] = []; +let petResults: typeof PET_ROW[] = []; + +vi.mock("@groombook/db", () => { + // Proxy objects for table/column references — values don't matter for tests + const tableProxy = (name: string) => + new Proxy( + { _name: name }, + { get: (t, p) => (p === "_name" ? name : { table: name, column: p }) } + ); + + const clients = tableProxy("clients"); + const pets = tableProxy("pets"); + + function makeSelectChain(results: unknown[]): unknown { + const chain: Record = {}; + const terminal = () => Promise.resolve(results); + chain.from = () => chain; + chain.innerJoin = () => chain; + chain.where = () => chain; + chain.limit = terminal; + return chain; + } + + return { + getDb: () => ({ + select: (_fields?: unknown) => { + // Route which mock results to use based on a global flag set per test + return { + from: (table: { _name?: string }) => { + const results = table._name === "pets" ? petResults : clientResults; + const chain: Record = {}; + chain.where = () => chain; + chain.innerJoin = () => chain; + chain.limit = () => Promise.resolve(results); + return chain; + }, + }; + }, + }), + clients, + pets, + and: (...args: unknown[]) => ({ and: args }), + or: (...args: unknown[]) => ({ or: args }), + eq: (a: unknown, b: unknown) => ({ eq: [a, b] }), + ilike: (col: unknown, pat: unknown) => ({ ilike: [col, pat] }), + }; +}); + +// ─── App under test ─────────────────────────────────────────────────────────── + +async function makeApp() { + const { searchRouter } = await import("../routes/search.js"); + const app = new Hono(); + app.route("/search", searchRouter); + return app; +} + +// ─── Tests ──────────────────────────────────────────────────────────────────── + +beforeEach(() => { + vi.resetModules(); + clientResults = []; + petResults = []; +}); + +describe("GET /search", () => { + it("returns 400 when q is missing", async () => { + const app = await makeApp(); + const res = await app.request("/search"); + expect(res.status).toBe(400); + const body = await res.json(); + expect(body.error).toBeTruthy(); + }); + + it("returns 400 when q is empty string", async () => { + const app = await makeApp(); + const res = await app.request("/search?q="); + expect(res.status).toBe(400); + }); + + it("returns 400 when q is only whitespace", async () => { + const app = await makeApp(); + const res = await app.request("/search?q= "); + expect(res.status).toBe(400); + }); + + it("returns matching clients and pets", async () => { + clientResults = [ACTIVE_CLIENT]; + petResults = [PET_ROW]; + + const app = await makeApp(); + const res = await app.request("/search?q=bell"); + expect(res.status).toBe(200); + const body = await res.json(); + expect(body.clients).toEqual([ACTIVE_CLIENT]); + expect(body.pets).toEqual([PET_ROW]); + }); + + it("returns empty arrays when no matches", async () => { + clientResults = []; + petResults = []; + + const app = await makeApp(); + const res = await app.request("/search?q=xyzzy"); + expect(res.status).toBe(200); + const body = await res.json(); + expect(body.clients).toEqual([]); + expect(body.pets).toEqual([]); + }); + + it("returns shape with clients and pets keys", async () => { + const app = await makeApp(); + const res = await app.request("/search?q=a"); + expect(res.status).toBe(200); + const body = await res.json(); + expect(body).toHaveProperty("clients"); + expect(body).toHaveProperty("pets"); + expect(Array.isArray(body.clients)).toBe(true); + expect(Array.isArray(body.pets)).toBe(true); + }); + + it("handles special characters in query without throwing", async () => { + clientResults = []; + petResults = []; + + const app = await makeApp(); + // These characters should be escaped, not cause errors + const res = await app.request("/search?q=foo%25bar_baz"); + expect(res.status).toBe(200); + }); +}); + +describe("escapeLike helper (via integration)", () => { + it("% in query does not break the request", async () => { + clientResults = []; + petResults = []; + const app = await makeApp(); + const res = await app.request("/search?q=%25"); + expect(res.status).toBe(200); + }); + + it("_ in query does not break the request", async () => { + clientResults = []; + petResults = []; + const app = await makeApp(); + const res = await app.request("/search?q=_"); + expect(res.status).toBe(200); + }); +}); diff --git a/apps/api/src/index.ts b/apps/api/src/index.ts index 3454a83..495c285 100644 --- a/apps/api/src/index.ts +++ b/apps/api/src/index.ts @@ -14,6 +14,7 @@ import { appointmentGroupsRouter } from "./routes/appointmentGroups.js"; import { groomingLogsRouter } from "./routes/groomingLogs.js"; import { impersonationRouter } from "./routes/impersonation.js"; import { settingsRouter } from "./routes/settings.js"; +import { searchRouter } from "./routes/search.js"; import { getDb, businessSettings } from "@groombook/db"; import { authMiddleware } from "./middleware/auth.js"; import { resolveStaffMiddleware, requireRole } from "./middleware/rbac.js"; @@ -98,6 +99,7 @@ api.route("/appointment-groups", appointmentGroupsRouter); api.route("/grooming-logs", groomingLogsRouter); api.route("/impersonation", impersonationRouter); api.route("/admin/settings", settingsRouter); +api.route("/search", searchRouter); const port = Number(process.env.PORT ?? 3000); console.log(`API server listening on port ${port}`); diff --git a/apps/api/src/routes/search.ts b/apps/api/src/routes/search.ts new file mode 100644 index 0000000..14e4ec3 --- /dev/null +++ b/apps/api/src/routes/search.ts @@ -0,0 +1,70 @@ +import { Hono } from "hono"; +import { and, eq, getDb, clients, ilike, or, pets } from "@groombook/db"; + +export const searchRouter = new Hono(); + +const LIMIT = 10; + +/** Escape %, _, and \ in user input before wrapping with ILIKE wildcards. */ +function escapeLike(s: string): string { + return `%${s.replace(/[%_\\]/g, "\\$&")}%`; +} + +/** + * GET /api/search?q={query} + * + * Returns up to 10 matching active clients and up to 10 matching pets. + * Clients are matched on name, email, or phone. + * Pets are matched on name or breed; includes owner name. + */ +searchRouter.get("/", async (c) => { + const q = c.req.query("q"); + if (!q || q.trim().length === 0) { + return c.json({ error: "Query parameter q is required" }, 400); + } + + const pattern = escapeLike(q.trim()); + const db = getDb(); + + const [matchingClients, matchingPets] = await Promise.all([ + db + .select({ + id: clients.id, + name: clients.name, + email: clients.email, + phone: clients.phone, + }) + .from(clients) + .where( + and( + eq(clients.status, "active"), + or( + ilike(clients.name, pattern), + ilike(clients.email, pattern), + ilike(clients.phone, pattern) + ) + ) + ) + .limit(LIMIT), + + db + .select({ + id: pets.id, + name: pets.name, + breed: pets.breed, + clientId: pets.clientId, + ownerName: clients.name, + }) + .from(pets) + .innerJoin(clients, and(eq(pets.clientId, clients.id), eq(clients.status, "active"))) + .where( + or( + ilike(pets.name, pattern), + ilike(pets.breed, pattern) + ) + ) + .limit(LIMIT), + ]); + + return c.json({ clients: matchingClients, pets: matchingPets }); +}); diff --git a/apps/web/src/App.tsx b/apps/web/src/App.tsx index 23ecc24..f819165 100644 --- a/apps/web/src/App.tsx +++ b/apps/web/src/App.tsx @@ -13,6 +13,7 @@ import { CustomerPortal } from "./portal/CustomerPortal.js"; import { DevLoginSelector, getDevUser } from "./pages/DevLoginSelector.js"; import { DevSessionIndicator } from "./components/DevSessionIndicator.js"; import { BrandingProvider, useBranding } from "./BrandingContext.js"; +import { GlobalSearch } from "./components/GlobalSearch.js"; const NAV_LINKS = [ { to: "/admin", label: "Appointments" }, @@ -68,6 +69,7 @@ function AdminLayout() { {branding.businessName} + (null); + const [loading, setLoading] = useState(false); + const [open, setOpen] = useState(false); + const inputRef = useRef(null); + const dropdownRef = useRef(null); + const debounceRef = useRef | null>(null); + const navigate = useNavigate(); + + // Debounced search + useEffect(() => { + if (debounceRef.current) clearTimeout(debounceRef.current); + + const trimmed = query.trim(); + if (trimmed.length === 0) { + setResults(null); + setOpen(false); + return; + } + + debounceRef.current = setTimeout(async () => { + setLoading(true); + try { + const res = await fetch(`/api/search?q=${encodeURIComponent(trimmed)}`); + if (res.ok) { + const data: SearchResults = await res.json(); + setResults(data); + setOpen(true); + } + } catch { + // ignore fetch errors + } finally { + setLoading(false); + } + }, 300); + + return () => { + if (debounceRef.current) clearTimeout(debounceRef.current); + }; + }, [query]); + + // Close dropdown on outside click + useEffect(() => { + function handleClick(e: MouseEvent) { + if ( + inputRef.current && + !inputRef.current.contains(e.target as Node) && + dropdownRef.current && + !dropdownRef.current.contains(e.target as Node) + ) { + setOpen(false); + } + } + document.addEventListener("mousedown", handleClick); + return () => document.removeEventListener("mousedown", handleClick); + }, []); + + function handleClientClick(client: ClientResult) { + setOpen(false); + setQuery(""); + navigate("/admin/clients"); + } + + function handlePetClick(pet: PetResult) { + setOpen(false); + setQuery(""); + navigate("/admin/clients"); + } + + const hasResults = results && (results.clients.length > 0 || results.pets.length > 0); + + return ( +
+
+ + setQuery(e.target.value)} + onFocus={() => results && setOpen(true)} + style={{ + width: "100%", + boxSizing: "border-box", + height: 44, + paddingLeft: 32, + paddingRight: 12, + fontSize: 13, + border: "1px solid #e2e8f0", + borderRadius: 8, + outline: "none", + background: "#f8fafc", + color: "#1a202c", + }} + aria-label="Search clients and pets" + aria-expanded={open} + aria-haspopup="listbox" + role="combobox" + aria-autocomplete="list" + /> +
+ + {open && ( +
+ {loading && ( +
+ Searching… +
+ )} + + {!loading && !hasResults && ( +
+ No results found +
+ )} + + {!loading && results && results.clients.length > 0 && ( +
+
+ Clients +
+ {results.clients.map((client) => ( + + ))} +
+ )} + + {!loading && results && results.pets.length > 0 && ( +
+
+ Pets +
+ {results.pets.map((pet) => ( + + ))} +
+ )} +
+ )} +
+ ); +} diff --git a/packages/db/src/index.ts b/packages/db/src/index.ts index 9ea804f..0ba0d5e 100644 --- a/packages/db/src/index.ts +++ b/packages/db/src/index.ts @@ -3,7 +3,7 @@ import postgres from "postgres"; import * as schema from "./schema.js"; export * from "./schema.js"; -export { and, asc, desc, eq, gte, gt, lt, lte, ne, or, sql } from "drizzle-orm"; +export { and, asc, desc, eq, gte, gt, ilike, lt, lte, ne, or, sql } from "drizzle-orm"; let _db: ReturnType | null = null; -- 2.52.0 From 0c182da366c16fae669231868420f58cc86a9ea6 Mon Sep 17 00:00:00 2001 From: Scrubs McBarkley Date: Sun, 22 Mar 2026 04:10:54 +0000 Subject: [PATCH 2/2] fix: address CTO review feedback on quick-find search (GH #97, GRO-134) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Remove unused makeSelectChain function from search.test.ts (lint blocker) - Fix handleClientClick/handlePetClick to navigate to /admin/clients?highlight={id} so the target client is identified in the URL rather than silently ignored - Add console.warn for fetch errors in GlobalSearch instead of swallowing silently Auth middleware verified: searchRouter is registered on the api Hono instance which applies authMiddleware + resolveStaffMiddleware globally — no coverage gap. Co-Authored-By: Paperclip --- apps/api/src/__tests__/search.test.ts | 10 ---------- apps/web/src/components/GlobalSearch.tsx | 8 ++++---- 2 files changed, 4 insertions(+), 14 deletions(-) diff --git a/apps/api/src/__tests__/search.test.ts b/apps/api/src/__tests__/search.test.ts index 979bc90..3c4ca9a 100644 --- a/apps/api/src/__tests__/search.test.ts +++ b/apps/api/src/__tests__/search.test.ts @@ -34,16 +34,6 @@ vi.mock("@groombook/db", () => { const clients = tableProxy("clients"); const pets = tableProxy("pets"); - function makeSelectChain(results: unknown[]): unknown { - const chain: Record = {}; - const terminal = () => Promise.resolve(results); - chain.from = () => chain; - chain.innerJoin = () => chain; - chain.where = () => chain; - chain.limit = terminal; - return chain; - } - return { getDb: () => ({ select: (_fields?: unknown) => { diff --git a/apps/web/src/components/GlobalSearch.tsx b/apps/web/src/components/GlobalSearch.tsx index 877c807..8971fde 100644 --- a/apps/web/src/components/GlobalSearch.tsx +++ b/apps/web/src/components/GlobalSearch.tsx @@ -52,8 +52,8 @@ export function GlobalSearch() { setResults(data); setOpen(true); } - } catch { - // ignore fetch errors + } catch (err) { + console.warn("GlobalSearch: fetch error", err); } finally { setLoading(false); } @@ -83,13 +83,13 @@ export function GlobalSearch() { function handleClientClick(client: ClientResult) { setOpen(false); setQuery(""); - navigate("/admin/clients"); + navigate(`/admin/clients?highlight=${client.id}`); } function handlePetClick(pet: PetResult) { setOpen(false); setQuery(""); - navigate("/admin/clients"); + navigate(`/admin/clients?highlight=${pet.clientId}`); } const hasResults = results && (results.clients.length > 0 || results.pets.length > 0); -- 2.52.0