feat: quick-find search for clients and pets (GH #97, GRO-140)
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 <noreply@anthropic.com>
This commit is contained in:
@@ -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<string, unknown> = {};
|
||||
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<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);
|
||||
});
|
||||
});
|
||||
@@ -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}`);
|
||||
|
||||
@@ -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 });
|
||||
});
|
||||
Reference in New Issue
Block a user