Appointments engine

Create an appointment, or a repeat series (staff side)

post/api/appointments

A booking taken by staff; a phone call, a walk-in. Deliberately NOT slot-validated the way guest checkout is: staff have the calendar in front of them, and book_appointments_no_overlap (a Postgres exclusion constraint) is the real backstop either way.

price, duration_minutes and ends_at are all snapshotted from the chosen service; sending them is not possible and would not be honoured. Status is always confirmed.

Repeats (migration 0149). An optional repeats object turns this into a series: every so-many weeks, on one or more weekdays, ending after a number of visits or on a date. When present it REPLACES startsAt entirely (repeats.startsOn + repeats.timeOfDay already say when the first visit is) and the response shape changes; see the 200 below. Deliberately the only creation surface a repeat series gets: not the public booking widget, and not the hospitality engine. book_appointments_no_overlap is still what actually prevents double-booking a generated date; a date that conflicts is skipped, not fatal to the rest of the series, matching the shipped feature this follows ("dates that don't fit... can be skipped"). One series_confirmed notification job is queued for the whole series, not one booking_confirmed per visit: each visit still gets its own reminder for free, since it is an ordinary row the existing reminder cron already reaches, and can still be individually moved or cancelled through this same PATCH/route below exactly like any other booking.

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

Request Body

application/json

The client is given one of two ways, matching the picker on the dashboard: either customerId for someone already on the roster, or customerName and customerPhone (plus optionally an email) to find-or-create one. When customerId is present the typed fields are ignored entirely.

customerPhone is required on the typed branch and only there. The check lives in resolveOrCreateCustomer, the single writer that creates a client from typed details, so it applies identically here, on POST /api/reservations and on POST /api/clients. Picking an existing client short-circuits to their id and never reaches it, which is what keeps a regular who predates the rule bookable.

TypeScript Definitions

Use the request body type in TypeScript.

Response Body

application/json

application/json

application/json

application/json

application/json

curl -X POST "https://example.com/api/appointments" \  -H "Content-Type: application/json" \  -d '{    "serviceId": "8f8bb40f-b96b-40fe-9064-5031fbe483f9",    "providerId": "4834bcdc-4a64-444d-966b-1a6fe381da24",    "startsAt": "2019-08-24T14:15:22Z"  }'
{  "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08",  "seriesId": "9a4d45ee-59e9-44a5-8e84-1878a49320e7",  "createdCount": 0,  "requestedCount": 0,  "skippedDates": [    "2019-08-24"  ]}