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*.
  • Subscription re-verified inside the write transaction (FIT-254 review Wave C, C5)activeSub is resolved BEFORE book()’s transaction opens; a concurrent cancel/sweep/debt-entry could move it to a terminal status in that gap. The transaction now re-selects the subscription’s own status and rejects (BadRequestException('Subscription not found or not active')) unless it’s still active/past_due, before any quota/credit/write logic runs. Flag-independent — plain correctness, not gated by booking-entitlement-sweep.
  • 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'.
  • External (guest) coach — a session can instead carry externalCoachName: a one-time guest who is not an org member (display-only, no account or permissions). Mutually exclusive with coachMembershipId — setting one clears the other, and supplying both in one request is rejected with 400.

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. Tap Open check-in screen (from the dashboard session sheet or the member schedule drawer) to launch the authenticated kiosk at /[lang]/kiosk/:sessionId — full-screen QR (GET /sessions/:id/qr-token) plus a live, tappable roster for a fixed front-desk tablet.
  4. Throughout class, POST /sessions/:id/check-in/:bookingId (tap on the kiosk roster) 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
Unsigned required_for_booking compliance form (club regulations)ConflictException 409 { code: 'form_signature_required', requirements }; the pending instance is lazily minted so clients deep-link into signing. Eligibility surfaces it as status='form_required' + requiredForms. Staff bypass. See forms/behavior.md §9.
Booking beyond a subscription’s pending end (cancelAtPeriodEnd=true), flag booking-entitlement-sweep onConflictException 409 { code: 'booking_beyond_subscription_end', endsAt: <ISO> } (FIT-254 C3). endsAt travels alongside code (FIT-254 review Wave C, C7) so clients render a fully localized, date-formatted message — the English message string is a fallback only. Flag off: unlimited-horizon booking, pre-FIT-254 behavior. See subscriptions-plans/behavior.md’s C3 — booking-horizon guard.
Booking beyond a subscription’s boundary with a scheduled plan change pending, flag onAllowed; daily/weekly caps evaluated against the target plan, overlap + credits against the current subscription (reduced-scope C3 follow-up). FIT-254 review Wave C, C3-guard: the target-plan day/week count queries only count bookings that themselves start AFTER the boundary (startsAt > currentPeriodEnd) — mirrors the boundary sweep’s own post-boundary-only counting, so a pre-boundary booking sharing the same UTC-day/week window as the post-boundary session being judged can no longer consume a target-plan quota it was never attributed to.
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
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/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).