Skip to Content
Living documentation — last reviewed 2026-05-28
FeaturesScheduling BookingsScheduling & Bookings — Behavior

Scheduling & Bookings — Behavior

State machines

Class session (class_sessions.status, enum publish_status)

FromToTriggerAllowed roles
(insert)draftPOST /sessions defaultowner/admin/coach
(insert)publishedPOST /sessions with status='published'owner/admin/coach
draftpublishedPOST /sessions/:id/publishowner/admin
publisheddraftPOST /sessions/:id/unpublish (rejected if any non-cancelled booking exists)owner/admin
draft/publishedcancelledPOST /sessions/:id/cancelowner/admin
any(soft-delete)POST /sessions/bulk-delete (stamps deletedAt)owner/admin

archived is in the enum but not used for class sessions (it’s the course terminal state).

Booking (bookings.status, enum booking_status)

FromToTrigger
(insert)confirmedPOST /sessions/:id/book with capacity available
(insert)waitlistedPOST /sessions/:id/book when at capacity and waitlistCapacity open
waitlistedconfirmedAuto-promote: another confirmed booking on the session is cancelled (member or admin cancel)
confirmedattendedPOST /sessions/:id/check-in/:bookingId (staff) or POST /sessions/:id/self-checkin (member)
attendedconfirmedDELETE /sessions/:id/check-in/:bookingId (undo check-in)
confirmed/waitlistedcancelledDELETE /sessions/:id/book (self) or DELETE /sessions/:id/bookings/:bookingId (admin)
confirmedno_showTODO: verify — enum value exists, used by analytics but no explicit transition handler found.

Invariants

  • One active booking per (session, member) — DB-level unique(class_session_id, membership_id) on bookings. The service additionally guards by status: existing booking in ('confirmed','waitlisted') raises BadRequestException('Already booked for this session').
  • Capacity is hard — booking inserts only happen when count(confirmed) < session.capacity (or capacity is null = unlimited). waitlisted only when count(waitlisted) < session.waitlistCapacity. Check + insert run inside one SERIALIZABLE transaction.
  • Org isolation — every session read joins classType → program → organizationId. Writes use a subquery to constrain the UPDATE: inArray(classSessions.id, orgScopedSessionIds) (see ClassSessionsService.update/publish/unpublish/cancel/checkIn).
  • A draft cannot be bookedbook() throws BadRequestException('Session is not open for booking') unless session.status === 'published'.
  • Cancelled sessions cancel all bookingscancel() runs the status update and a bulk bookings update in a single transaction.
  • Unpublishing requires no active bookingsunpublish() throws BadRequestException('Cannot unpublish a session that has active bookings') if getBookingCounts returns > 0.
  • Bulk-delete is soft-delete — sets deletedAt. Active bookings get cancelled. Audit row created with batchId, sessionIds, filter payload.
  • Bulk-publish forces status filter to draft — caller’s filter status is overwritten regardless of input. Defense-in-depth.
  • Plan-credit consistency — credit deduction (deductCreditAtomic) and refund (refundCreditAtomic) run inside the same booking transaction. Unlimited subs (plan.type='subscription' AND classCredits IS NULL) skip both. Course-type subs are excluded entirely from resolveSubscription*.
  • Late cancel never refunds credit — even if allowLateCancellation=true, shouldRefund = wasConfirmed && !isLateCancellation && subscriptionId != null.
  • Coach role validationclassSession.coachMembershipId only accepts memberships with role ∈ {owner, admin, coach} and status='active'.

Golden paths

Owner: weekly schedule

  1. GET /organizations/:orgId/class-types to see programs/class types.
  2. POST /organizations/:orgId/sessions for each slot (default status='draft'). Pre-validated against classType + location + coach membership.
  3. Optionally PUT /sessions/:id/workouts to attach pre-built workouts, or POST /sessions/:id/workouts/inline to create one in place.
  4. POST /sessions/:id/publish (or POST /sessions/bulk-publish with a filter for a whole week of drafts).
  5. Members now see the session inside their schedule and may book.

Coach: daily class

  1. GET /organizations/:orgId/sessions?weekStart=… for the assigned slots.
  2. Open session, see roster via GET /sessions/:id/bookings.
  3. Generate display QR via POST /sessions/:id/display-url (10-minute signed URL embedding org+session+sig+exp).
  4. Throughout class, POST /sessions/:id/check-in/:bookingId for any member who hasn’t self-checked-in.
  5. Optionally POST /sessions/:id/workouts/inline to log what was actually trained that day.

Member: book + attend

  1. GET /sessions?weekStart=… — receives sessions with bookingEligibility per session.
  2. If eligibility.status='eligible' and the chosen plan is unblocked, POST /sessions/:id/book (server picks the single active sub automatically; passing subscriptionId explicitly for ambiguous multi-plan case).
  3. Receives confirmed or waitlisted (depends on capacity). Push + email fire after commit.
  4. T-30 minute reminder push lands (scheduled BullMQ job).
  5. On arrival, scan QR or tap GPS check-in → POST /sessions/:id/self-checkin. selfCheckin validates: confirmed booking exists, check-in window open (isWithinCheckinWindow), QR signature valid OR GPS distance ≤ GPS_MAX_METERS.
  6. Booking status → attended, checkedInAt, checkinMethod populated.

Member: cancel

  1. DELETE /organizations/:orgId/sessions/:sessionId/book.
  2. Service checks org.cancellationWindowHours against session.startsAt. If past deadline:
    • If allowLateCancellation=falseBadRequestException.
    • If allowLateCancellation=true → cancel proceeds, no credit refund.
  3. Otherwise cancel proceeds, credit refunded (if booking had subscriptionId).
  4. If the cancelled booking was confirmed, promoteFromWaitlist looks up the lowest waitlistPosition booking on the same session, flips it to confirmed, and fires the “you’re in” email.

Edge cases & error states

ScenarioBehavior
Member books an already-booked sessionBadRequestException('Already booked for this session')
Member books a draft sessionBadRequestException('Session is not open for booking')
Account in debt (activeSub.status='debt')BadRequestException with “outstanding balance” copy
No credits remaining (remaining_credits=0)deductCreditAtomic UPDATE matches 0 rows → BadRequestException('No credits remaining')
Daily quota hitBadRequestException('Daily booking limit reached (N per day)')
Weekly quota hitBadRequestException('Weekly booking limit reached (N per week)')
Overlap (and plan disallows)BadRequestException('You already have a booking that overlaps…')
Multiple active plans, no subscriptionId passedBadRequestException('Multiple active plans — please select…')
No active plan AND org has a payment providerBadRequestException('No active plan')
No active plan AND org has no providerBooking allowed, no credit accounting
Cancel a non-existent / non-active bookingNotFoundException('Booking not found')
Cancel after window with allowLateCancellation=falseBadRequestException with hours-before message
Self-checkin outside check-in windowBadRequestException('Check-in window is not open for this session')
Self-checkin with mismatched QR signature/expiryBadRequestException('Invalid or expired QR code')
Self-checkin GPS too farBadRequestException('GPS_TOO_FAR') — exact string for client i18n
Self-checkin GPS on feed-mode programBadRequestException('GPS check-in is not available for feed programs')
Cross-org booking attemptNotFoundException('Session not found') (every read joins through programs.organizationId)
Concurrent bookings racing for last slotSERIALIZABLE isolation + withSerializableRetry (3 attempts on 40001 serialization failure)
bulk-preview with includeBooked=falseExcludes sessions with non-cancelled bookings from matched count and samples; still reports withActiveBookings separately
bulk-delete with notifyAffectedMembers=falseSessions soft-deleted, bookings cancelled, audit written; no emails sent

Side effects

EventSide effects
Book (confirmed)Email (confirmation, respects classReminder/email opt-out); push notification; BullMQ T-30min reminder scheduled if start > now+30m.
Book (waitlisted)Email (waitlisted). Push only fires for confirmed.
Self-cancelEmail (cancellation, with creditRefunded flag); waitlist promotion fires “you’re in” email to promoted user.
Admin-cancelSame as self-cancel emails for affected member; auto-promotion.
Session cancelBulk cancel all non-cancelled bookings; per-user class-cancelled email (org TZ-formatted dateTime, logo).
Bulk-deleteSoft-delete; cancel all non-cancelled bookings on those sessions; one audit_logs row carrying batch metadata; per-user class-cancelled email per affected session unless notifyAffectedMembers=false.
Check-in (manual)bookings.status='attended', checkedInAt=now, checkinMethod='manual'.
Check-in (self/QR)Same with checkinMethod='qr'.
Check-in (self/GPS)Same with checkinMethod='gps'.
Undo check-instatus='confirmed', clear checkedInAt, clear checkinMethod.

Permissions matrix

Endpointowneradmincoachmember
POST /sessions (create)
PATCH /sessions/:id
PUT /sessions/:id/workouts
POST /sessions/:id/workouts/inline
POST /sessions/:id/publish
POST /sessions/:id/unpublish
POST /sessions/:id/cancel
POST /sessions/bulk-*
GET /sessions / GET /sessions/:id
GET /sessions/:id/bookings (roster)
POST /sessions/:id/check-in/:bookingId
DELETE /sessions/:id/check-in/:bookingId
GET /sessions/:id/qr-token
POST /sessions/:id/display-url
GET /sessions/:id/display-datapublic (signed)
POST /sessions/:id/self-checkin
GET /sessions/attendance/:membershipId
POST /sessions/:id/book✓ (staff bypass)
DELETE /sessions/:id/book
DELETE /sessions/:id/bookings/:bookingId (admin cancel)
POST /members/:membershipId/refund-credit
GET /bookings/attendance-trend
GET /analytics/attendance/summary
GET /analytics/attendance/daily
GET /bookings/myselfselfselfself

isStaffRole returns true for owner|admin|coach — used for credit/quota bypass on booking but not for the booking RBAC check (members can still book).