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*. - 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'.
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. - Generate display QR via
POST /sessions/:id/display-url(10-minute signed URL embedding org+session+sig+exp). - Throughout class,
POST /sessions/:id/check-in/:bookingIdfor 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 |
| 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 |
| Self-checkin GPS on feed-mode program | BadRequestException('GPS check-in is not available for feed programs') |
| 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/display-url | ✓ | ✓ | ✓ | ✗ |
GET /sessions/:id/display-data | public (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/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).