feat: quick-find search for clients and pets (GH #97) #103

Merged
groombook-engineer[bot] merged 3 commits from feat/quick-find-search-gh97 into main 2026-03-22 08:22:46 +00:00
6 changed files with 514 additions and 1 deletions
+162
View File
@@ -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<string, unknown> = {};
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);
});
});
+2
View File
@@ -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}`);
+70
View File
@@ -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 });
});
+2
View File
@@ -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}
</strong>
</div>
<GlobalSearch />
<Link
to="/admin/book"
style={{
+277
View File
@@ -0,0 +1,277 @@
import { useEffect, useRef, useState } from "react";
import { useNavigate } from "react-router-dom";
import { Search } from "lucide-react";
interface ClientResult {
id: string;
name: string;
email: string | null;
phone: string | null;
}
interface PetResult {
id: string;
name: string;
breed: string | null;
clientId: string;
ownerName: string;
}
interface SearchResults {
clients: ClientResult[];
pets: PetResult[];
}
export function GlobalSearch() {
const [query, setQuery] = useState("");
const [results, setResults] = useState<SearchResults | null>(null);
const [loading, setLoading] = useState(false);
const [open, setOpen] = useState(false);
const inputRef = useRef<HTMLInputElement>(null);
const dropdownRef = useRef<HTMLDivElement>(null);
const debounceRef = useRef<ReturnType<typeof setTimeout> | 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 (
<div style={{ position: "relative", flex: "1 1 0", maxWidth: 320, minWidth: 0 }}>
<div style={{ position: "relative" }}>
<Search
size={15}
style={{
position: "absolute",
left: 10,
top: "50%",
transform: "translateY(-50%)",
color: "#9ca3af",
pointerEvents: "none",
}}
/>
<input
ref={inputRef}
type="search"
placeholder="Search clients & pets…"
value={query}
onChange={(e) => 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"
/>
</div>
{open && (
<div
ref={dropdownRef}
role="listbox"
style={{
position: "absolute",
top: "calc(100% + 4px)",
left: 0,
right: 0,
background: "#fff",
border: "1px solid #e2e8f0",
borderRadius: 10,
boxShadow: "0 8px 24px rgba(0,0,0,0.10)",
zIndex: 100,
overflow: "hidden",
minWidth: "100%",
}}
>
{loading && (
<div style={{ padding: "12px 16px", fontSize: 13, color: "#6b7280" }}>
Searching
</div>
)}
{!loading && !hasResults && (
<div style={{ padding: "12px 16px", fontSize: 13, color: "#6b7280" }}>
No results found
</div>
)}
{!loading && results && results.clients.length > 0 && (
<div>
<div
style={{
padding: "6px 16px 4px",
fontSize: 11,
fontWeight: 600,
color: "#9ca3af",
textTransform: "uppercase",
letterSpacing: "0.05em",
borderBottom: "1px solid #f1f5f9",
}}
>
Clients
</div>
{results.clients.map((client) => (
<button
key={client.id}
role="option"
onClick={() => handleClientClick(client)}
style={{
display: "flex",
flexDirection: "column",
width: "100%",
padding: "12px 16px",
minHeight: 48,
background: "transparent",
border: "none",
borderBottom: "1px solid #f1f5f9",
cursor: "pointer",
textAlign: "left",
}}
onMouseEnter={(e) => {
(e.currentTarget as HTMLButtonElement).style.background = "#f8fafc";
}}
onMouseLeave={(e) => {
(e.currentTarget as HTMLButtonElement).style.background = "transparent";
}}
>
<span style={{ fontSize: 13, fontWeight: 500, color: "#1a202c" }}>
{client.name}
</span>
{client.phone && (
<span style={{ fontSize: 12, color: "#6b7280", marginTop: 1 }}>
{client.phone}
</span>
)}
</button>
))}
</div>
)}
{!loading && results && results.pets.length > 0 && (
<div>
<div
style={{
padding: "6px 16px 4px",
fontSize: 11,
fontWeight: 600,
color: "#9ca3af",
textTransform: "uppercase",
letterSpacing: "0.05em",
borderBottom: "1px solid #f1f5f9",
}}
>
Pets
</div>
{results.pets.map((pet) => (
<button
key={pet.id}
role="option"
onClick={() => handlePetClick(pet)}
style={{
display: "flex",
flexDirection: "column",
width: "100%",
padding: "12px 16px",
minHeight: 48,
background: "transparent",
border: "none",
borderBottom: "1px solid #f1f5f9",
cursor: "pointer",
textAlign: "left",
}}
onMouseEnter={(e) => {
(e.currentTarget as HTMLButtonElement).style.background = "#f8fafc";
}}
onMouseLeave={(e) => {
(e.currentTarget as HTMLButtonElement).style.background = "transparent";
}}
>
<span style={{ fontSize: 13, fontWeight: 500, color: "#1a202c" }}>
{pet.name}
{pet.breed && (
<span style={{ fontWeight: 400, color: "#4b5563" }}> · {pet.breed}</span>
)}
</span>
<span style={{ fontSize: 12, color: "#6b7280", marginTop: 1 }}>
Owner: {pet.ownerName}
</span>
</button>
))}
</div>
)}
</div>
)}
</div>
);
}
+1 -1
View File
@@ -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<typeof drizzle> | null = null;