Shared

Global search, backs the dashboard's Cmd/Ctrl+K palette

get/api/search

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.

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 each entity's name (customers also match email/phone) with ILIKE. Under 2 characters (after stripping ,/(/)) returns every key empty.

Lengthlength <= 80

Response Body

application/json

application/json

application/json

curl -X GET "https://example.com/api/search"
{  "customers": [    {      "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08",      "name": "string",      "email": "string",      "phone": "string"    }  ],  "services": [    {      "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08",      "name": "string"    }  ],  "staff": [    {      "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08",      "name": "string"    }  ],  "tables": [    {      "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08",      "name": "string"    }  ]}

Live client search for CustomerPicker GET

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.

Send a message to the native AI assistant (v1, read-only) POST

Runs entirely as the signed-in user through requireMember()'s RLS-scoped client, never service-role, with one exception: book_usage_events has no RLS policy for `authenticated` at all (0064), so the atomic credit reservation opens its own serviceRoleClient() for that one read/write, never for anything the assistant itself can see or do (src/lib/usage.ts's reserveAiCredit). requireMember() is called with no `vertical` argument on purpose: either engine's staff can reach this, and buildTools() (src/lib/ai/tools.ts) hands back a DIFFERENT tool set per ctx.businessType instead: e.g. a hospitality company's model sees getTodaysReservations, never getTodaysAppointments, and vice versa. No tool ever takes a company id as an argument, the same non-negotiable rule requireApiKey() follows for API keys, inherited here for a fourth trust model. v1 is read-only: the assistant can look up this company's own bookings, clients and revenue, and search the help centre, but cannot create, change, or cancel anything; enforced by the system prompt, not by tool absence alone. Streams back an AI SDK v5+ UI-message stream (`text/event-stream`), not a JSON body, so the response schema below is a description rather than a checked shape (this file's own opening note: "prose, field-level schemas, and examples" are not machine-derived). Calling OpenRouter's free-tier models (v1's only model tier) is a platform-wide shared cap on this one key, separate from the per-company credit gate below; see src/lib/ai/provider.ts. OpenRouter and the underlying free-tier model vendors are listed in SUBPROCESSORS (src/lib/marketing/legal.ts) since real customer/booking data reaches them as tool-call context.