Live client search for CustomerPicker
/api/customers/searchBacks the new-booking/new-reservation forms' client picker once a search term is typed (added 2026-08-30, audit fix). The picker's own initial page (fetched server-side by whichever dashboard page rendered it) is capped at 500 rows and filtered client-side for instant feedback on a small roster; past that cap a real client is otherwise unreachable, so this route exists as the live fallback once q is at least 2 characters.
Appointments: confined the same way the page-level picker list already is, via writeScope() (team-scope.ts): a scope-limited staff/manager login may only find a client they have at least one provider_confirmed appointment with. Hospitality: unconfined for every role, matching the reservations page's own unscoped list, since hospitality has no provider concept to confine by.
Redaction (staff_client_visibility) is applied for staff/manager on the appointments side, matching the page-level list exactly. Hospitality's own page-level list has never applied this redaction either, preserved as-is here rather than changed in passing.
Below 2 characters this returns an empty list rather than scanning: the picker's own local filter over its already-loaded page covers "just started typing" instantly.
Authorization
sessionCookie The dashboard's Supabase Auth session cookie, set at sign-in. Large sessions are split across
numbered chunks (…auth-token.0, .1), so treat this as a cookie family rather than one name.
Every request re-validates it against the Auth server (getUser()), never by decoding the cookie
locally: a JWT nothing has checked is not a credential. Tenancy is then read from the verified
app_metadata.company_id claim and enforced by row-level security; it is never read from request
input, on any route, ever.
role (admin / staff) is deliberately not in RLS. It gates specific actions in route code,
the operations marked admin below, so hiding a button in the UI is cosmetic only, and a route's
own check is the enforcement.
In: cookie
Query Parameters
Matched against name, email and phone with ILIKE. Under 2 characters (after stripping ,/(/)) returns an empty list.
length <= 80Response Body
application/json
application/json
application/json
application/json
curl -X GET "https://example.com/api/customers/search"{ "clients": [ { "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08", "name": "string", "email": "string", "phone": "string" } ]}Void a sale POST
Reverses the sale's stock ONLY (`book_void_sale()`, reason `sale_undo`) and marks it voided: it does not un-collect any payment or reverse a gift-card redemption, the same "no undo here, correct it the way any other mistake is corrected" posture GiftCardRedeemField's own redemption already takes. No `hasPermission` gate, matching POST /api/sales.
Global search, backs the dashboard's Cmd/Ctrl+K palette GET
One round trip across every entity a dashboard member might be hunting for mid-shift, instead of the palette racing several fetches itself. Reuses lib/booking/customer-search.ts's searchCustomers() for the `customers` half, so its scope confinement (appointments-only, staff/manager scoped to clients they share a confirmed appointment with) and staff_client_visibility redaction are identical to /api/customers/search rather than a second, driftable copy. `services` and `tables` are vertical-exclusive: appointments gets `services` (from `book_services`) and an always-empty `tables`, hospitality gets `tables` (from `book_tables`) and an always-empty `services`. `staff` is populated in both, from `book_providers` (appointments) or `book_servers` (hospitality). `customers` and `staff` are always attempted regardless of vertical. Below 2 characters this returns every key empty rather than scanning: the palette's own default state (no query yet) shows static quick links instead, so there is nothing useful to fetch this early. A failed sub-query (any of the four) degrades to an empty array for that key alone rather than failing the whole response: there is no 400 this route can emit.