Scheduling & Bookings — Data Model
All tables live in libs/db/src/lib/schema/scheduling.ts.
class_sessions
| Column | Type | Notes |
|---|---|---|
id | uuid PK | defaultRandom() |
class_type_id | uuid NOT NULL | FK → class_types.id. Mandatory; org-scoping anchor via class_types → programs → organization_id. |
location_id | uuid | FK → locations.id (the session’s space). Nullable. On create, omitted → the class type’s active default_location_id. |
coach_membership_id | uuid | FK → memberships.id. Coach must have role ∈ {coach, admin, owner} and status='active' (service-enforced). |
external_coach_name | varchar(255) | One-time guest coach who is not an org member — display-only free text, no login/permissions. Mutually exclusive with coach_membership_id (service-enforced: setting one clears the other; sending both is a 400). In responses it is folded into coach as { id: null, firstName: <name>, isExternal: true } so all clients render it through the same field. |
title | varchar(255) | Optional override of class type name. |
date | date | Stored separately from starts_at; used by bulk filter dateFrom/dateTo. TODO: verify whether always populated. |
starts_at / ends_at | timestamptz | UTC. Display formatting uses org.timezone. |
capacity | int | NULL = unlimited. With flag spaces on, may not exceed the space’s capacity without an owner/admin override. |
waitlist_capacity | int | NULL = unlimited waitlist, 0 = no waitlist, N = at most N live waitlisted rows. Surfaced to clients as waitlistOpen. |
status | publish_status enum | draft | published | cancelled | archived (archived unused here). Default draft. |
notes | text | Free text. |
created_at / updated_at | timestamptz | |
deleted_at | timestamptz | Soft-delete. Bulk-delete stamps this. All read queries filter isNull(deletedAt). |
trial_brief_sent_for | timestamptz | The starts_at the coach’s trials brief push went out for; a moved session re-arms it. See Trials. |
Indexes: (class_type_id), (starts_at, status), (date), class_sessions_location_starts_at_idx (location_id, starts_at) (space overlap check, migration 0167_spaces).
class_session_workouts
| Column | Type | Notes |
|---|---|---|
id | uuid PK | |
class_session_id | uuid NOT NULL | FK → class_sessions.id ON DELETE CASCADE. |
workout_id | uuid NOT NULL | FK → workouts.id ON DELETE CASCADE. |
sort_order | int NOT NULL | Default 0. Multiple workouts per session render in this order. |
created_at | timestamptz |
Constraints: UNIQUE(class_session_id, workout_id).
No soft-delete here — setWorkouts does delete+insert in a transaction.
bookings
| Column | Type | Notes |
|---|---|---|
id | uuid PK | |
class_session_id | uuid NOT NULL | FK → class_sessions.id. |
membership_id | uuid NOT NULL | FK → memberships.id. Resolves the org. |
subscription_id | uuid | FK → subscriptions.id. NULL for staff bookings and for orgs without a payment provider. |
status | booking_status enum | confirmed | waitlisted | cancelled | no_show | attended. Default confirmed. |
waitlist_position | int | Only set on waitlisted: max(live waitlisted positions on the session) + 1 at join. Has gaps after cancels and promotions, so it is a sort key only; responses show the computed waitlistRank. Cleared on promotion. |
checked_in_at | timestamptz | Set on attended; cleared on undo-check-in. |
checkin_method | varchar(20) | manual | qr | gps. |
cancelled_at | timestamptz | Set on transition to cancelled. |
promoted_at | timestamptz | Set when a waitlisted row becomes confirmed through fillFreeSeats; the promotion grace is measured from it (cancelTermsFor). Cleared when the row is revived. Migration 0181_waitlist_hardening. |
cancellation_reason | varchar(40) | Why the row was cancelled: one of BOOKING_CANCELLATION_REASONS (member, staff, session_cancelled, entitlement_sweep, member_removed, waitlist_promoted_elsewhere, waitlist_expired, arbox); see behavior › Cancellation reasons. NULL on rows cancelled before migration 0181 and on live rows; cleared on revive. |
policy_snapshot | jsonb | The cancel terms accepted at booking (ADR-0024 D12, BookingPolicySnapshot): { version: 1, cancelDeadlineAt, allowLateCancel, lateCancelCredit, decidedBy: { cancelDeadline, allowLateCancel, lateCancelCredit } }. Written only while booking-policy-rules is on, rewritten on revive; null = the cancel reads the org columns. |
trial_marked_by_user_id | uuid | FK → users.id ON DELETE SET NULL. A staff member marked the booking a trial (PUT /sessions/:id/bookings/:bookingId/trial); null = not marked. See Trials. |
trial_appointment_id | uuid | FK → lead_trial_appointments.id ON DELETE SET NULL. The lead trial seat this booking took over when the lead converted before the class; such a booking is a trial and the entitlement sweep never revokes it. |
created_at / updated_at | timestamptz | |
deleted_at | timestamptz | Reserved; not currently used by booking flows (cancellations use status). |
Constraints: UNIQUE(class_session_id, membership_id) — one row per (session, member) ever.
Capacity also counts lead_trial_appointments rows with this class_session_id, outcome IN ('pending', 'attended') and superseded_at IS NULL (Sales trial seats; see Trials).
Indexes: (class_session_id), (membership_id), (status), bookings_session_status_idx (class_session_id, status) (seat and queue reads per session, migration 0181), bookings_subscription_id_idx (subscription_id) (entry-quota counts read a subscription’s bookings in a window, TKN-446).
booking_policy_rules (TKN-447, ADR-0024 D9–D12)
Layered overrides of the org’s booking settings. Read by the policy resolver once per request (BookingPolicyService), never per session.
| Column | Type | Notes |
|---|---|---|
id | uuid PK | |
organization_id | uuid NOT NULL | FK → organizations.id. |
class_session_id | uuid | FK → class_sessions.id ON DELETE CASCADE. Set = a rule pinned to one session (layer session). |
scope | jsonb NOT NULL | PolicyScope ({ anyOf: [{ allOf: Condition[] }] }), default matches every session. space values are hall ids. |
settings | jsonb NOT NULL | BookingPolicySettings, partial: only the keys this rule decides (registrationOpens, registrationCloses, cancelDeadline, allowLateCancel, lateCancelCredit). |
name | varchar(120) | Optional staff label. |
archived_at | timestamptz | Delete archives; archived rules are ignored. |
created_by | uuid | FK → users.id. |
created_at / updated_at | timestamptz |
Indexes: booking_policy_rules_org_idx (organization_id) WHERE archived_at IS NULL, booking_policy_rules_session_idx (class_session_id) WHERE class_session_id IS NOT NULL.
The layer is derived, never stored: a pinned session, else the scope naming a class type, a hall, a program, or none of them. The organizations columns are the bottom layer; no migration writes a rule for them.
locations
(See docs/features/locations/data-model.md for full detail.) A row is a space. Relevant here: latitude, longitude, capacity (session ceiling when flag spaces is on), concurrency (exclusive | shared), is_active. Soft-delete via is_active=false (deleted_at is unused). GPS check-in raises if either coord is null.
Multi-org isolation pattern
class_sessions has no direct organization_id column. Org-scoping is derived through the FK chain class_sessions.class_type_id → class_types.program_id → programs.organization_id.
Two layers enforce this:
- Pre-read — service-level:
findFirst({ with: { classType: { with: { program: true } } } })thenif (session.classType.program.organizationId !== orgId) throw NotFoundException. - Defense-in-depth SQL — for mutating queries, an
inArray(classSessions.id, orgScopedSessionIds)subquery is added to the WHERE:
SELECT class_sessions.id
FROM class_sessions
INNER JOIN class_types ON class_types.id = class_sessions.class_type_id
INNER JOIN programs ON programs.id = class_types.program_id
WHERE programs.organization_id = $orgIdbookings is org-scoped indirectly via membership_id → memberships.organization_id AND class_session_id → … → organizationId. Both anchors must agree; mismatched (cross-org) tuples fail the session-level org check.
Lifecycle of a row
class_sessions
- Create — INSERT with
status='draft'(default) or'published'(explicit, owner/admin/coach). - Edit window —
updateaccepts any field exceptclassTypeId. A capacity increase fills the new seats from the waitlist; a start/end change notifies confirmed and waitlisted members (session.time_changed). Bookings are never cancelled by an edit. - Publish / unpublish — owner/admin only. Unpublish blocked while any
confirmed,attendedorwaitlistedbooking exists. - Cancel — terminal. Bookings cascade-cancel inside the same serializable transaction (
session_cancelled; confirmed and attended seats refunded), and the session’s pending trial appointments end (endSessionTrials: checked in →attended, elsecancelled+ superseded). - Bulk-delete —
deletedAtstamped. Active bookings cancelled and pending trials ended as in cancel. Single audit row.
bookings
- Insert — only inside
SERIALIZABLEtxn with retry. Status set toconfirmedorwaitlistedbased on capacity. If the member already has acancelled(or soft-deleted) row for the session, that row is revived (UPDATE, same id) instead: the unique constraint allows no second row. - Promote — whenever a seat is free (
fillFreeSeats), waitlisted rows in(waitlist_position, created_at, id)order flip toconfirmedwithpromoted_atset, as long as they pass the promotion checks; the promoted member’s other same-day waitlisted rows are cancelled (waitlist_promoted_elsewhere). 2a. Expire — awaitlistedrow whose session has started is cancelled by the 5-minute job (waitlist_expired). - Check-in —
status='attended', populatechecked_in_at+checkin_method. - Cancel —
status='cancelled', setcancelled_atandcancellation_reason. Credit refunded if eligible. - (no terminal hard-delete) —
deleted_atexists but unused; cancellations live asstatus='cancelled'rows, and re-booking the session revives that row (step 1). Consumers keyed onbookings.idmust tolerate one id going through several book → cancel cycles: the Fizikal outbox settles a revived booking’s previous occupy/release pair inenqueueOccupy, and the T-30 push reminder is one BullMQ job per booking, start time and locale.
Soft-delete vs hard-delete
| Table | Strategy |
|---|---|
class_sessions | Soft (deleted_at). Required because bulk-delete must remain reversible/auditable. |
class_session_workouts | Hard delete (cascade on session/workout deletion; setWorkouts replaces). |
bookings | Soft semantic via status='cancelled' + cancelled_at. deleted_at column exists but unused in mutating paths. |
Computed/derived response fields
These are computed in ClassSessionsService.toResponse, not stored:
bookingCount— seats taken: liveconfirmed+attendedper session, plus seat-holding trial appointments and seats an Arbox roster holds.capacityRemaining—max(0, capacity - bookingCount)(null if uncapped).cancellationDeadline— for a booked member, what their cancel enforces (cancelTermsFor): the booking’spolicy_snapshot.cancelDeadlineAt, else the org columns (starts_at - cancellationWindowHoursif the window > 0), never a rule the booking did not take; moved to the end of the promotion grace for a booking promoted past it. Otherwise the resolvedcancelDeadlinerule (flag off: the org columns).waitlistOpen/waitlistCount/myWaitlistRank— from the session’s live waitlisted rows in queue order (loadWaitlistQueues).myCancellationState/myFreeCancelUntil/myPromotedAt— the viewer’s cancel options, resolved server-side (cancellationStateOfinclass-sessions.service.ts); see behavior › What the session and booking responses say.registrationOpensAt/registrationClosesAt/registrationState— derived on every read fromstarts_atand the resolved policy: the org’sregistration_opens_before_minutes/registration_closes_before_minutes, overridden per setting bybooking_policy_ruleswhilebooking-policy-rulesis on, computed in the org’s timezone. Never stored on the session.myBookingStatus/myCheckedInAt/myCheckinMethod— requester-relative.workoutSource—'session' | 'daily_programming' | 'none'.bookingEligibility— seeBookingEligibilityin@taikan/shared; computed per-member via active subs + plan limits.attendees— array of{id, firstName, lastName, imageUrl}for confirmed/attended bookings.
Entitlement ledger link
entitlement_ledger.booking_id → bookings.id (ON DELETE SET NULL) records
each booking’s debit and reversal rows; see
subscriptions-plans/data-model.md.