Shared

Live client search for CustomerPicker

get/api/customers/search

Backs 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
sb-qxrvgfkjyvbngipqvslu-auth-token<token>

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

q?string

Matched against name, email and phone with ILIKE. Under 2 characters (after stripping ,/(/)) returns an empty list.

Lengthlength <= 80

Response 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.