Whether this booking is part of a repeat series
/api/appointments/{id}/seriesOne id in, one (possibly null) id out. Its own tiny route rather than folding seriesId into GET /api/appointments/{id}'s already-large select: series_id (migration 0149) is a brand-new column, and the same "isolated, tolerated read" a brand-new column always gets here (see loadCardOnFileForBooking in lib/billing/checkout.ts for the precedent): folding it into a shared select would 42703 the WHOLE row for every booking on screen the moment this column isn't confirmed live yet, not just this one field. What BookingDetailDialog fetches once on open to decide whether to offer "End series" at all.
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
Path Parameters
Appointment id.
Response Body
application/json
application/json
application/json
curl -X GET "https://example.com/api/appointments/string/series"{ "seriesId": "9a4d45ee-59e9-44a5-8e84-1878a49320e7"}Create an appointment, or a repeat series (staff side) POST
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.
Read one repeat series and its visits GET
The series row plus every book_appointments row it generated, in date order, each with its OWN current status, not just the ones still upcoming. What the "End series" confirm step (BookingDetailDialog) reads to say how many visits are actually about to be cancelled, versus already past or already cancelled individually; that distinction needs the full list, not a second round trip filtered ahead of time.