diff --git a/apps/api/src/__tests__/search.test.ts b/apps/api/src/__tests__/search.test.ts new file mode 100644 index 0000000..3c4ca9a --- /dev/null +++ b/apps/api/src/__tests__/search.test.ts @@ -0,0 +1,162 @@ +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"); + + 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 (err) { + console.warn("GlobalSearch: fetch error", err); + } 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?highlight=${client.id}`); + } + + function handlePetClick(pet: PetResult) { + setOpen(false); + setQuery(""); + navigate(`/admin/clients?highlight=${pet.clientId}`); + } + + 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;