Two engines
Appointments and table reservations are separate products sharing plumbing. Read this first.
Gaplessly runs two separate booking engines behind one multi-tenant product.
- Appointments: a client books one service with one staff member for a set duration.
Tables:
book_services,book_providers,book_appointments. Overlap is prevented bybook_appointments_no_overlap, keyed on the provider. - Table reservations (hospitality): a guest books a table for a party size inside a
service period, for a turn. Tables:
book_tables,book_service_periods,book_reservations. Overlap is prevented bybook_reservations_no_overlap, keyed on the table.
They are two products, not one product with a flag. Separate tables, separate exclusion constraints, separate slot mathematics. Shared plumbing (clients, notifications, auth, uploads, organization settings) is intended and is grouped under Shared. Shared booking logic is not.
An organization picks its engine once, at creation, via businessType. It cannot be changed
afterwards: flipping a live org strands whatever it already has, so a conversion is a data
migration rather than a settings toggle.
The 403 you will hit first
Every dashboard operation tagged Appointments engine or Hospitality engine carries
x-engine. Those operations call requireMember(role, vertical), which compares the vertical
against the caller org's business_type and returns 403 on a mismatch: an appointments org
calling POST /api/tables is refused before any write. Operations without x-engine do not make
that check.
That distinction is load-bearing, and one place it bites: the public
POST /api/book/{slug}/appointments and GET /api/book/{slug}/availability are tagged as the
appointments engine but carry no x-engine, because they check only that the org is active. Their
hospitality counterparts resolve the org through a helper that refuses a non-hospitality business
outright. So the public surface enforces the boundary in one direction only.