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

Scheduling & Bookings — Code Map

API

Module — class sessions (apps/api/src/class-sessions/)

FilePurpose
class-sessions.module.tsWires controller, service, R2 (logo presign), and dependencies (Memberships, Users, Email, DailyProgramming, Subscriptions, PaymentProviderConfig, Workouts).
class-sessions.controller.tsAll routes under organizations/:orgId/sessions.
class-sessions.service.tsCRUD + bulk + check-in + eligibility. update fills seats a capacity increase adds (WaitlistPromotionService.fillSessions, trigger capacity) and emits session.time_changed after a start/end move; unpublish refuses while any confirmed/attended/waitlisted row exists; cancel/deleteSession/bulkDelete run serializable, refund confirmed and attended seats and write cancellation_reason='session_cancelled'. toResponse adds waitlistOpen, waitlistCount, myWaitlistRank, myCancellationState (cancellationStateOf), myFreeCancelUntil, myPromotedAt; the roster (getBookings) adds waitlistRank.
dto/create-class-session.dto.tsclassTypeId, locationId?, coachMembershipId?, externalCoachName?, title?, startsAt, endsAt, capacity?, waitlistCapacity?, notes?, status? (‘draft’|‘published’), overrideSpaceConflict? / overrideSpaceCapacity? ({ reason }, owner/admin, flag spaces).
dto/update-class-session.dto.tsPartialType(OmitType(CreateClassSessionDto, ['classTypeId'])) — class type immutable post-create.
dto/set-session-workouts.dto.tsworkouts: [{ workoutId, sortOrder }].
dto/inline-workout.dto.tsprogramId?, title?, description?, scoring?, timeCap?.
dto/self-checkin.dto.tsmethod: 'qr'|'gps', token?, expiresAt?, latitude?, longitude?.
dto/bulk-filter.dto.tsprogramId?, classTypeId?, dateFrom?, dateTo?, daysOfWeek?, startTimes? (HH:mm in org TZ), status?, includeBooked?.
utils/checkin.utils.tsgenerateQrToken, verifyQrToken, haversineMeters, isWithinCheckinWindow, GPS_MAX_METERS.

Routes (prefix organizations/:orgId/sessions)

MethodPathHandler
POST/create
GET/list (by week, optional programId)
GET/upcoming-todayupcomingToday (owner/admin dashboard)
GET/today-workoutstodayWorkouts
GET/:idgetById
PATCH/:idupdate
PUT/:id/workoutssetWorkouts
POST/:id/workouts/inlinecreateInlineWorkout
POST/:id/copycopy (TKN-478)
POST/:id/publishpublish
POST/:id/unpublishunpublish
POST/:id/cancelcancel
POST/bulk-previewbulkPreview
POST/bulk-deletebulkDelete
POST/bulk-publishbulkPublish
GET/:id/bookingsgetBookings (roster, staff only)
POST/:id/check-in/:bookingIdcheckIn
DELETE/:id/check-in/:bookingIdundoCheckIn
GET/:id/rosterSessionRosterService.roster (staff; bookings + lead trial seats, see Trials)
PUT/:id/bookings/:bookingId/trialSessionRosterService.markBookingTrial
POST/:id/trials/:appointmentId/check-inSessionRosterService.checkInTrial
DELETE/:id/trials/:appointmentId/check-inSessionRosterService.undoTrialCheckIn
POST/:id/trials/:appointmentId/notesSessionRosterService.createTrialNote
GET/trials-todaySessionRosterService.trialsToday
GET/:id/qr-tokengetQrToken (staff, returns {token, expiresAt, checkinUrl})
POST/:id/self-checkinselfCheckin
GET/attendance/:membershipIdgetAttendanceHistory

Module — bookings (apps/api/src/bookings/)

FilePurpose
bookings.module.tsImports MembershipsModule, UsersModule, SubscriptionsModule, NotificationsModule, PushNotificationsModule, WaitlistPromotionModule. Provides BookingsService, WaitlistExpiryService, BookingMembershipListener.
bookings.controller.tsMixed routes (org-scoped and bookings/my).
bookings.service.tsbook (deleted/started guards, no-plan overlap, waitlist filled first via fillFreeSeats trigger book, place at max position + 1, waitlist hold limit), cancel/cancelForSubject and adminCancel (cancellation_reason, creditRefunded from the ledger, fillFreeSeats, booking_grace_cancel), credit accounting, analytics (getAttendanceSummary/Daily/Trend), getMyBookings (adds waitlistRank). Promotions are published after commit through WaitlistPromotionService.publish; this service sends no promotion email itself. book loads the facts and asks evaluateBooking twice: the horizon before the re-checks, overlap and caps once counted. bookingRefusal maps a reason to its error. resolveSubscriptionOptional picks the plan when none is named; with two or more that can pay, planSelectionRequired builds the 409 plan_selection_required (debug log + booking_plan_selection_required event).
booking-enforcement.util.tsloadBookingUsage (overlap + day/week/month counts for one session, target-plan caps past a scheduled change), noPlanUsageSubscription, bookingBlockedException (coded 400 per block reason, legacy message), atomic credit ops, SEAT_TAKING_STATUSES / sessionOccupancy / seatsTaken (the one seat count), loadWaitlistQueues + waitlistRank (queue order and computed rank), waitlistHoldCovered (credit-plan hold limit), fillFreeSeats (the waitlist engine: cutoff, registration close, eligibility, debit, promoted_at, same-day release).
waitlist-promotion-policy.tsWaitlistPolicy (org clock, debt policy, resolver, bucketsEnabled, heldSeats, onSkip), loadWaitlistPolicy, reportWaitlistSkips, publishPromotions (post-commit booking.promoted + waitlist_promoted / waitlist_removed_elsewhere / seats_filled_from_waitlist + skips).
waitlist-promotion.service.ts, waitlist-promotion.module.tsWaitlistPromotionService: policyFor (adds Arbox held seats), publish, fillSessions (one serializable tx per session, for callers outside a booking request: capacity edit, Arbox import, departures), releaseMemberBookings (member_removed). Own module so class sessions, memberships, Arbox and erasure can inject it without a cycle.
waitlist-expiry.service.tsWaitlistExpiryService, @ScheduledJob every 5 min: expireStarted (started classes’ waitlisted rows → waitlist_expired), then refillFreeSeats (backstop fill, trigger scheduler, 15 min – 14 days out).
booking-membership.listener.ts@OnEvent(MEMBERSHIP_DEACTIVATED) / @OnEvent(MEMBERSHIP_SUSPENDED) → releaseMemberBookings.
booking-events.tsDomain events: booking.no_show, booking.attended, booking.promoted (BookingPromotedEvent, BookingPromotionTrigger), session.time_changed (SessionTimeChangedEvent).
apps/api/src/notifications/booking-notifications.listener.ts, booking-notifier.service.tsThe single notifier for booking.promoted (email WAITLIST_PROMOTED with freeCancelUntil/removedWaitlists, push, WhatsApp confirmation, T-30 reminder) and session.time_changed (BOOKING_TIME_CHANGED email, push, reminder reschedule); BookingNotifierService holds the shared legs (guardian routing, bookingUpdate opt-outs).
apps/api/src/memberships/member-erasure.service.tsCalls releaseMemberBookings before deleting a member’s rows.
apps/api/src/arbox-bridge/arbox-schedule-import.service.tsInbound roster cancels write cancellation_reason='arbox'; seats it freed are filled (fillSessions, trigger arbox).
apps/api/src/analytics/space-stats.service.tsSpaces report late-cancel rate (lateCancelsBySession): only member/staff/NULL reasons, each judged against the booking’s own effective deadline (cancelTermsFor, promotion grace included).
libs/shared/src/lib/booking-evaluator.tsevaluateBooking — the booking rules and their order; isUnlimitedPlan, isExpiredConsumable. Pure, no DB.
dto/book-session.dto.tssubscriptionId? (UUID) — explicit plan selection; required once two or more plans can pay.

Routes

MethodPathHandler
POST/organizations/:orgId/sessions/:sessionId/bookbook
DELETE/organizations/:orgId/sessions/:sessionId/bookcancel (self)
DELETE/organizations/:orgId/sessions/:sessionId/bookings/:bookingIdadminCancel
POST/organizations/:orgId/members/:membershipId/refund-creditrefundCredit
GET/organizations/:orgId/bookings/attendance-trendattendanceTrend
GET/organizations/:orgId/analytics/attendance/summaryattendanceSummary
GET/organizations/:orgId/analytics/attendance/dailyattendanceDaily
GET/bookings/mymyBookings

Web

Routes

RouteDescription
apps/web/src/app/[lang]/(protected)/dashboard/schedule/page.tsxWeekly calendar for staff.
apps/web/src/app/[lang]/(protected)/dashboard/schedule/sessions/[sessionId]/page.tsxSession detail (roster, workouts, check-in display).
apps/web/src/app/[lang]/(protected)/dashboard/schedule/sessions/[sessionId]/builder/page.tsxInline workout builder for a session.
apps/web/src/app/[lang]/(protected)/(member)/schedule/page.tsxMember weekly view.
apps/web/src/app/[lang]/(protected)/(member)/checkin/page.tsxQR/GPS check-in landing.
apps/web/src/app/[lang]/(protected)/kiosk/[sessionId]/page.tsxAuthenticated staff-only kiosk: QR + tappable roster for door check-in.

Hooks / API helpers

  • useApi (client) and serverFetch (server) — Clerk bearer attached automatically. See apps/web/src/hooks/use-api.ts, apps/web/src/lib/api.ts.
  • apps/web/src/components/overview/schedule/* — calendar grid (weekly-calendar.tsx), create sheet (create-session-sheet.tsx), detail/edit sheet (session-detail-sheet.tsx), shared fields incl. the space picker (session-fields.tsx), bulk-delete sheet, timetable menu. Space required when flag spaces is on and the org has ≥2 active spaces; owner/admin override dialog on the space 409s (space-override-dialog.tsx: useSpaceOverride + SpaceOverrideDialog, used by the create and detail sheets).
  • apps/web/src/components/member/schedule/session-detail-drawer.tsx — member booking drawer. Always sends the plan it shows; on 409 plan_selection_required its plan picker lists the body’s plans (first preselected) and booking from it retries with the chosen subscriptionId. Waitlist offer, rank line and cancel button/policy text come from the server fields through session-booking-state.ts (isWaitlistOpen, resolveCancellationState, isLateCancellationState).
  • apps/web/src/components/overview/schedule/session-detail-sheet.tsx — staff roster: waitlist ordered and numbered by waitlistRank; cancelled rows carry a cancellationReason badge.
  • Drag layer schedule-drag.tsx (TKN-478): ScheduleDragProvider (the DnD context, used by SpaceSections and around the flat Expanded/Condensed views), resolveDrop (same day in another hall’s section → move; another day → copy with that section’s hall and, in the hour grid, the slot’s time), ScheduleDropZone (a condensed day column, or one hour slot of expanded-week-grid.tsx), ScheduleDraggable, useCopyToDay and the “Copy to day…” dialog. use-session-copy.ts posts the copy, runs the override dialog, opens the copy, toasts Undo (DELETE) and captures schedule_session_copied. SessionRowMenu (space-sections.tsx) is the condensed row’s menu: “Move to space” in a section, “Copy to day…” while copying is on.

DB tables involved

TableRole
class_sessionsThe session row itself.
class_session_workoutsJoin with sort order, cascade-delete on session/workout drop.
bookingsThe booking row + check-in metadata.
class_typesParent of session (mandatory FK).
programsParent of class_type; carries organizationId (the org-scoping anchor).
locationsThe session’s space. lat/lng powers GPS check-in; concurrency + capacity drive the space checks (flag spaces).
membershipscoachMembershipId and bookings.membershipId.
organizationscancellationWindowHours, allowLateCancellation, registrationOpensBeforeMinutes, registrationClosesBeforeMinutes, timezone.
subscriptionsbookings.subscriptionId (nullable for staff).
audit_logsOne row per bulk-delete / bulk-publish batch with payload.
audit_eventsclass_session.space_override (via AuditService), one row per overridden space rule.

Shared schemas

  • libs/shared/src/lib/schemas/scheduling.schema.ts — Zod schemas for session response, booking response, bulk filter, eligibility (BookingEligibility, BookingPlanEntry, BookingBlockReason), the plan_selection_required body’s entries (PlanSelectionCandidate), and spaces (spaceOverrideSchema, spaceConflictErrorSchema, spaceCapacityErrorSchema).
  • libs/shared/src/lib/constants/scheduling-error-codes.ts — SchedulingErrorCodes (space_conflict, space_capacity_exceeded).
  • libs/shared/src/lib/constants/payment-error-codes.ts — the booking gate’s codes: booking_blocked_debt, plan_required_to_book, plan_not_started, plan_selection_required.
  • libs/shared/src/lib/analytics/booking-events.ts — BOOKING_PLAN_SELECTION_REQUIRED (booking_plan_selection_required); waitlist: WAITLIST_PROMOTION_SKIPPED, WAITLIST_PROMOTED, WAITLIST_REMOVED_ELSEWHERE, WAITLIST_EXPIRED, WAITLIST_HOLD_LIMIT_REFUSED, BOOKING_GRACE_CANCEL, SEATS_FILLED_FROM_WAITLIST.
  • libs/shared/src/lib/constants/booking.ts — WAITLIST_PROMOTION_CUTOFF_MINUTES (15), WAITLIST_PROMOTION_GRACE_MINUTES (10), BOOKING_CANCELLATION_REASONS, MY_CANCELLATION_STATES.
  • scheduling.schema.ts also carries waitlistPromotionSkipReasonValues (evaluator reasons + subscription_inactive) and the response fields waitlistOpen, waitlistCount, myWaitlistRank, myCancellationState, myFreeCancelUntil, myPromotedAt (session) and promotedAt, cancellationReason, waitlistRank (booking).
  • libs/shared/src/lib/schemas/day-of-week.ts — dayOfWeekValues array used by bulk filter.
  • isStaffRole helper in @taikan/shared — single source of truth for staff vs member.

Tests

FileWhat it covers
apps/api/src/class-sessions/class-sessions.service.unit.spec.tsCRUD, publish/unpublish guards, cancel side effects.
apps/api/src/class-sessions/class-sessions.bulk.unit.spec.tsBulk preview/delete/publish, filter resolution, audit logging.
apps/api/src/bookings/bookings.service.unit.spec.tsBooking transaction, capacity (attended counts as taken), waitlist (max position + 1, hold limit), started/deleted session guards, staff seat without a plan, quotas, cancel + reason + ledger-truth refund + post-commit promotion; two plans and no subscriptionId: 409 listing them in eligibility order, the named plan debited on the retry.
apps/api/src/bookings/plan-selection.int.spec.tsReal DB over HTTP (TKN-440): a member’s own booking, a guardian’s for a child and staff’s for a member each get 409 plan_selection_required with the ledger balances and nothing is booked; the retry with a plan debits that plan; one usable plan is charged as before; staff booking their own seat books with no plan.
apps/api/src/bookings/booking-caps.int.spec.tsReal DB: attended and no-show bookings fill day/week caps; enforcement, the member’s quota card and the schedule’s eligibility agree; a cancellation frees the slot (TKN-457).
libs/shared/src/lib/booking-evaluator.unit.spec.tsTable-driven: every block reason alone, every pair in order, staff/untracked, facts not judged, credit and cap boundaries.
apps/api/src/bookings/waitlist-promotion-policy.int.spec.tsReal DB: a waitlisted member over their weekly cap is skipped and the next promoted, with booking-policy-rules on and off; a member’s own waitlist row never counts against them.
apps/api/src/bookings/waitlist-hardening.int.spec.ts (+ waitlist-hardening.driver.ts)Real DB: queue first on book(), capacity increase fills every new seat, staff cancel reason + refill, departing member release, 15-minute cutoff, registration close, same-day release, promotion grace (detail fields + refunded grace cancel), waitlistOpen/myWaitlistRank/myCancellationState, hold limit, no-plan overlap, stale-waitlist expiry, unpublish with waiters, session cancel refunding attended seats, session.time_changed.
apps/api/src/bookings/booking-enforcement.util.unit.spec.tsfillFreeSeats: fills every free seat in order, attended counts as taken, cutoff, grace, full class, inactive member / paused plan skipped, same-day release; waitlistRank.
apps/api/src/bookings/waitlist-promotion-policy.unit.spec.tspublishPromotions: domain event, waitlist_promoted / waitlist_removed_elsewhere / seats_filled_from_waitlist aggregation, skip reporting.
apps/api/src/bookings/waitlist-promotion.service.unit.spec.ts, booking-membership.listener.unit.spec.tsreleaseMemberBookings; the deactivated/suspended listener.
apps/api/src/booking-policy/booking-policy.resolver.unit.spec.tscancelTermsFor promotion grace (before/after the deadline, capped at the start, no deadline).
apps/api/src/notifications/booking-notifications.listener.unit.spec.tsPromotion and time-change notifications: opt-outs, grace line, released list, reminder, WhatsApp, guardian routing.
apps/web/src/components/member/schedule/session-booking-state.unit.spec.tsServer field wins over the legacy fallback for waitlist and cancel state.
apps/web/src/components/overview/schedule/session-detail-sheet.int.spec.tsxRoster: waitlist by rank, cancellation reason badge.
apps/web/e2e/specs/session-sheets.spec.ts, bulk-session-actions.spec.ts, multi-plan-booking.spec.tsPlaywright flows: session sheets, bulk actions, booking with several plans.
apps/web/src/components/member/schedule/session-detail-drawer.int.spec.tsxMember drawer: booking, refusals by code, acting for a child; the one plan shown is named in the request; a 409 plan_selection_required opens the picker with the server’s plans (child copy when acting for a child) and the retry carries the picked plan; waitlist offer from waitlistOpen, rank line, cancel label per myCancellationState, free-cancel time in the grace, was_late_cancel from the server state.
apps/api/src/entitlements/entitlement-ledger.int.spec.tsLedger vs real Postgres: cancel races refund once, concurrent bookings on one subscription spend its last credit once, a replayed debit at zero balance is a duplicate, pre-ledger/never-charged refunds, waitlist promotion debit once, renewal reset (and a cancel racing it), manual adjust + audit + request-id idempotency, the ledger deciding when the counter disagrees (booking and sweep promotion), the unopened-ledger fallback report (Sentry once per subscription), drift scan findings by check tag and its heartbeat.
apps/api/src/bookings/booking-concurrency.int.spec.tsConcurrent booking races; punch-card race asserts ledger debits = seats and ledger sum = counter.

Space context (TKN-460)

  • GET /sessions and GET /sessions/upcoming-today (class-sessions controller) and the booking attendance reads (/bookings/attendance-trend, /analytics/attendance/{summary,daily}) take an optional spaceId, resolved by SpaceScopeService and applied as class_sessions.location_id = :space; a session without a space matches only “every space” until S4. BookingsService.getAttendanceSummary drops deleted bookings in the join (the old unparenthesised OR let every org through). The web Today panel reads both session endpoints through useScopedApiQuery. See space-context/code-map.md.

Booking policy rules (TKN-447/448)

FilePurpose
libs/shared/src/lib/schemas/booking-policy.schema.tsCondition model, TimeRule, settings, rule / upsert / effective / conflict / snapshot schemas, BookingPolicyErrorCodes.
libs/shared/src/lib/booking-policy.tsPure matchesScope, scopesOverlap, ruleLayer, resolveBookingPolicy (tz-free).
apps/api/src/booking-policy/time-rule.tszonedInstant (DST: forward through a gap, earlier of an ambiguous hour), timeRuleInstant, validateTimeRules. The only place a policy instant is computed.
apps/api/src/booking-policy/booking-policy.resolver.tsloadBookingPolicyResolver (org columns + live rules, once per request; rules: false for the cancel paths), BookingPolicyResolver.forSession / snapshot / cancelTermsFor (the one reader of a booking’s cancel terms, for the schedule and the cancel), orgFlagEnabled.
apps/api/src/booking-policy/booking-policy.service.ts, .controller.ts, .module.tsRule list / create / update / archive (overlap check and write under one per-org advisory lock, MAX_LIVE_BOOKING_POLICY_RULES cap, audit booking_policy.updated), effective (incl. draft preview).
apps/api/src/booking-policy/booking-policy-reports.tsLog + PostHog event per rollout decision: reportQuotaRefusal, reportWindowRefusal, reportCancellationClosed, reportPolicyConflict, reportRuleSaved, reportPlanQuotasSaved, reportParityRevocations (observability).
apps/api/src/booking-policy/time-rule.unit.spec.tsMeasured DST instants, before_start = the TKN-357 maths, day_before_at local days, week_before_at, inverted window, close after start, week rule on close/cancel.
apps/api/src/bookings/booking-policy-parity.int.spec.ts17 scenarios run with booking-policy-rules off and on-with-no-data on one org; every observable equal; flag-off booking statement count pinned.
apps/api/src/bookings/booking-rules-parity.int.spec.tsThe four schedule / sweep / book() disagreements, failing before and passing after; #3 on both sides of booking-policy-rules.
apps/api/src/bookings/booking-policy-scenario.driver.tsShared real-Postgres driver for the three specs above (booking-policy-rules as a per-org dial, deterministic ids, frozen clock, query counter).
apps/web/e2e/specs/booking-policy.spec.tsStaff add a rule, the dialog previews the API’s opening time, the member drawer says when booking opens.

Spotter tools (booking rules and entry quotas)

apps/api/src/ai/agent/tools/leaves/booking-policy.tools.ts (BookingPolicyAgentTools) calls the services above, so permissions, the save checks and the refusal bodies are the dashboard’s. No tool reads a feature flag; the services enforce what they enforce. Reads are served over MCP too; writes wait for the approval card in Spotter and are never served over MCP.

ToolRolesWhat it does
booking_policy.explainowner, admin (subscriptions:view)“Can X book Y?”: ClassSessionsService.bookingPreview (verdict, ordered refusal codes, the plan that pays, per-plan credits, quota lines and caps, the session’s rules with decidedBy) plus rule names.
booking_policy.effectiveowner, admin, coach (bookingPolicy:view)A session’s (or a hall / class type / program / plan’s) resolved rules with decidedBy and dates.
booking_policy.rules_listowner, admin, coachThe org’s rules, live count against the 200 cap, names for the ids the scopes hold.
booking_policy.rule_create / rule_updateowner, admin (bookingPolicy:manage), always confirmedCreate / patch a rule. A same-layer clash reaches the model as conflict with details.code: booking_policy_conflict and details.conflicts; booking_policy_invalid (details.errors) and booking_policy_limit keep their codes.
booking_policy.rule_archiveowner, admin, destructive confirmArchive a rule.
plans.quotas_listowner, admin, coach (plans:view)Every active plan (or one) with its caps and ordered quotas; PlanEntitlementBucketsService.listForOrg reads the org’s quotas in one query.
plans.quotas_setowner, admin (bookingPolicy:manage), always confirmedReplace a plan’s quota list (PlanEntitlementBucketsService.replace).
memberships.quota_balancesowner, admin (subscriptions:view)A member’s live plans: ledger credits, cap usage (QuotaUsageService) and quota lines (EntitlementBalanceService.bucketBalancesFor).
  • Rule and quota inputs are flat shapes the model can read cheaply (libs/shared/src/lib/agent-schemas/booking-policy.ts), each held to the HTTP body schema it stands for, so an invalid one is refused as invalid_input before any service runs.
  • BookingPolicyService.names labels the ids an answer carries (halls, class types, programs, plans, rules) in one query per kind.
  • The refusal table Spotter answers with (meaning and next step per code, space_conflict and space_capacity_exceeded included) is BOOKING_RULES_PROMPT in apps/api/src/ai/agent/context-builder.service.ts; its spec fails when a refusal code has no row.
  • Tests: booking-policy.tools.unit.spec.ts (role scope, validation, refusal passthrough, approval, MCP exposure) over booking-policy.tools.driver.ts (real registry and runner, stubbed services).