Scheduling & Bookings — Behavior
State machines
Class session (class_sessions.status, enum publish_status)
| From | To | Trigger | Allowed roles |
|---|---|---|---|
| (insert) | draft | POST /sessions default | owner/admin/coach |
| (insert) | published | POST /sessions with status='published' | owner/admin/coach |
draft | published | POST /sessions/:id/publish | owner/admin |
published | draft | POST /sessions/:id/unpublish (rejected if any non-cancelled booking exists) | owner/admin |
draft/published | cancelled | POST /sessions/:id/cancel | owner/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)
| From | To | Trigger |
|---|---|---|
| (insert) | confirmed | POST /sessions/:id/book with capacity available |
| (insert) | waitlisted | POST /sessions/:id/book when at capacity and waitlistCapacity open |
waitlisted | confirmed | Auto-promote: another confirmed booking on the session is cancelled (member or admin cancel) |
confirmed | attended | POST /sessions/:id/check-in/:bookingId (staff) or POST /sessions/:id/self-checkin (member) |
attended | confirmed | DELETE /sessions/:id/check-in/:bookingId (undo check-in) |
confirmed/waitlisted | cancelled | DELETE /sessions/:id/book (self) or DELETE /sessions/:id/bookings/:bookingId (admin) |
confirmed | no_show | TODO: 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)onbookings. The service additionally guards by status: existing booking in('confirmed','waitlisted')raisesBadRequestException('Already booked for this session'). - Capacity is hard — booking inserts only happen when
count(confirmed) < session.capacity(or capacity is null = unlimited).waitlistedonly whencount(waitlisted) < session.waitlistCapacity. Check + insert run inside oneSERIALIZABLEtransaction. - Org isolation — every session read joins
classType → program → organizationId. Writes use a subquery to constrain the UPDATE:inArray(classSessions.id, orgScopedSessionIds)(seeClassSessionsService.update/publish/unpublish/cancel/checkIn). - A draft cannot be booked —
book()throwsBadRequestException('Session is not open for booking')unlesssession.status === 'published'. - Cancelled sessions cancel all bookings —
cancel()runs the status update and a bulkbookingsupdate in a single transaction. - Unpublishing requires no active bookings —
unpublish()throwsBadRequestException('Cannot unpublish a session that has active bookings')ifgetBookingCountsreturns > 0. - Bulk-delete is soft-delete — sets
deletedAt. Active bookings get cancelled. Audit row created withbatchId,sessionIds, filter payload. - Bulk-publish forces status filter to
draft— caller’s filterstatusis 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'ANDclassCredits IS NULL) skip both. Course-type subs are excluded entirely fromresolveSubscription*. - Subscription re-verified inside the write transaction (FIT-254 review Wave C, C5) —
activeSubis resolved BEFOREbook()’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 ownstatusand rejects (BadRequestException('Subscription not found or not active')) unless it’s stillactive/past_due, before any quota/credit/write logic runs. Flag-independent — plain correctness, not gated bybooking-entitlement-sweep. - Late cancel never refunds credit — even if
allowLateCancellation=true,shouldRefund = wasConfirmed && !isLateCancellation && subscriptionId != null. - Coach role validation —
classSession.coachMembershipIdonly accepts memberships with role ∈{owner, admin, coach}andstatus='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 withcoachMembershipId— setting one clears the other, and supplying both in one request is rejected with 400.
Golden paths
Owner: weekly schedule
GET /organizations/:orgId/class-typesto see programs/class types.POST /organizations/:orgId/sessionsfor each slot (defaultstatus='draft'). Pre-validated against classType + location + coach membership.- Optionally
PUT /sessions/:id/workoutsto attach pre-built workouts, orPOST /sessions/:id/workouts/inlineto create one in place. POST /sessions/:id/publish(orPOST /sessions/bulk-publishwith a filter for a whole week of drafts).- Members now see the session inside their schedule and may book.
Coach: daily class
GET /organizations/:orgId/sessions?weekStart=…for the assigned slots.- Open session, see roster via
GET /sessions/:id/bookings. - 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. - Throughout class,
POST /sessions/:id/check-in/:bookingId(tap on the kiosk roster) for any member who hasn’t self-checked-in. - Optionally
POST /sessions/:id/workouts/inlineto log what was actually trained that day.
Member: book + attend
GET /sessions?weekStart=…— receives sessions withbookingEligibilityper session.- If
eligibility.status='eligible'and the chosen plan is unblocked,POST /sessions/:id/book(server picks the single active sub automatically; passingsubscriptionIdexplicitly for ambiguous multi-plan case). - Receives
confirmedorwaitlisted(depends on capacity). Push + email fire after commit. - T-30 minute reminder push lands (scheduled BullMQ job).
- On arrival, scan QR or tap GPS check-in →
POST /sessions/:id/self-checkin.selfCheckinvalidates: confirmed booking exists, check-in window open (isWithinCheckinWindow), QR signature valid OR GPS distance ≤GPS_MAX_METERS. - Booking status →
attended,checkedInAt,checkinMethodpopulated.
Member: cancel
DELETE /organizations/:orgId/sessions/:sessionId/book.- Service checks
org.cancellationWindowHoursagainstsession.startsAt. If past deadline:- If
allowLateCancellation=false→BadRequestException. - If
allowLateCancellation=true→ cancel proceeds, no credit refund.
- If
- Otherwise cancel proceeds, credit refunded (if booking had
subscriptionId). - If the cancelled booking was
confirmed,promoteFromWaitlistlooks up the lowestwaitlistPositionbooking on the same session, flips it toconfirmed, and fires the “you’re in” email.
Edge cases & error states
| Scenario | Behavior |
|---|---|
| Member books an already-booked session | BadRequestException('Already booked for this session') |
Member books a draft session | BadRequestException('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 hit | BadRequestException('Daily booking limit reached (N per day)') |
| Weekly quota hit | BadRequestException('Weekly booking limit reached (N per week)') |
| Overlap (and plan disallows) | BadRequestException('You already have a booking that overlaps…') |
Multiple active plans, no subscriptionId passed | BadRequestException('Multiple active plans — please select…') |
| No active plan AND org has a payment provider | BadRequestException('No active plan') |
| No active plan AND org has no provider | Booking 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 on | ConflictException 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 on | Allowed; 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 booking | NotFoundException('Booking not found') |
Cancel after window with allowLateCancellation=false | BadRequestException with hours-before message |
| Self-checkin outside check-in window | BadRequestException('Check-in window is not open for this session') |
| Self-checkin with mismatched QR signature/expiry | BadRequestException('Invalid or expired QR code') |
| Self-checkin GPS too far | BadRequestException('GPS_TOO_FAR') — exact string for client i18n |
| Cross-org booking attempt | NotFoundException('Session not found') (every read joins through programs.organizationId) |
| Concurrent bookings racing for last slot | SERIALIZABLE isolation + withSerializableRetry (3 attempts on 40001 serialization failure) |
bulk-preview with includeBooked=false | Excludes sessions with non-cancelled bookings from matched count and samples; still reports withActiveBookings separately |
bulk-delete with notifyAffectedMembers=false | Sessions soft-deleted, bookings cancelled, audit written; no emails sent |
Side effects
| Event | Side 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-cancel | Email (cancellation, with creditRefunded flag); waitlist promotion fires “you’re in” email to promoted user. |
| Admin-cancel | Same as self-cancel emails for affected member; auto-promotion. |
| Session cancel | Bulk cancel all non-cancelled bookings; per-user class-cancelled email (org TZ-formatted dateTime, logo). |
| Bulk-delete | Soft-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-in | status='confirmed', clear checkedInAt, clear checkinMethod. |
Permissions matrix
| Endpoint | owner | admin | coach | member |
|---|---|---|---|---|
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/my | self | self | self | self |
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).