Scheduling & Bookings — Code Map
API
Module — class sessions (apps/api/src/class-sessions/)
| File | Purpose |
|---|---|
class-sessions.module.ts | Wires controller, service, R2 (logo presign), and dependencies (Memberships, Users, Email, DailyProgramming, Subscriptions, PaymentProviderConfig, Workouts). |
class-sessions.controller.ts | All routes under organizations/:orgId/sessions. |
class-sessions.service.ts | CRUD + 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.ts | classTypeId, locationId?, coachMembershipId?, externalCoachName?, title?, startsAt, endsAt, capacity?, waitlistCapacity?, notes?, status? (‘draft’|‘published’), overrideSpaceConflict? / overrideSpaceCapacity? ({ reason }, owner/admin, flag spaces). |
dto/update-class-session.dto.ts | PartialType(OmitType(CreateClassSessionDto, ['classTypeId'])) — class type immutable post-create. |
dto/set-session-workouts.dto.ts | workouts: [{ workoutId, sortOrder }]. |
dto/inline-workout.dto.ts | programId?, title?, description?, scoring?, timeCap?. |
dto/self-checkin.dto.ts | method: 'qr'|'gps', token?, expiresAt?, latitude?, longitude?. |
dto/bulk-filter.dto.ts | programId?, classTypeId?, dateFrom?, dateTo?, daysOfWeek?, startTimes? (HH:mm in org TZ), status?, includeBooked?. |
utils/checkin.utils.ts | generateQrToken, verifyQrToken, haversineMeters, isWithinCheckinWindow, GPS_MAX_METERS. |
Routes (prefix organizations/:orgId/sessions)
| Method | Path | Handler |
|---|---|---|
| POST | / | create |
| GET | / | list (by week, optional programId) |
| GET | /upcoming-today | upcomingToday (owner/admin dashboard) |
| GET | /today-workouts | todayWorkouts |
| GET | /:id | getById |
| PATCH | /:id | update |
| PUT | /:id/workouts | setWorkouts |
| POST | /:id/workouts/inline | createInlineWorkout |
| POST | /:id/copy | copy (TKN-478) |
| POST | /:id/publish | publish |
| POST | /:id/unpublish | unpublish |
| POST | /:id/cancel | cancel |
| POST | /bulk-preview | bulkPreview |
| POST | /bulk-delete | bulkDelete |
| POST | /bulk-publish | bulkPublish |
| GET | /:id/bookings | getBookings (roster, staff only) |
| POST | /:id/check-in/:bookingId | checkIn |
| DELETE | /:id/check-in/:bookingId | undoCheckIn |
| GET | /:id/roster | SessionRosterService.roster (staff; bookings + lead trial seats, see Trials) |
| PUT | /:id/bookings/:bookingId/trial | SessionRosterService.markBookingTrial |
| POST | /:id/trials/:appointmentId/check-in | SessionRosterService.checkInTrial |
| DELETE | /:id/trials/:appointmentId/check-in | SessionRosterService.undoTrialCheckIn |
| POST | /:id/trials/:appointmentId/notes | SessionRosterService.createTrialNote |
| GET | /trials-today | SessionRosterService.trialsToday |
| GET | /:id/qr-token | getQrToken (staff, returns {token, expiresAt, checkinUrl}) |
| POST | /:id/self-checkin | selfCheckin |
| GET | /attendance/:membershipId | getAttendanceHistory |
Module — bookings (apps/api/src/bookings/)
| File | Purpose |
|---|---|
bookings.module.ts | Imports MembershipsModule, UsersModule, SubscriptionsModule, NotificationsModule, PushNotificationsModule, WaitlistPromotionModule. Provides BookingsService, WaitlistExpiryService, BookingMembershipListener. |
bookings.controller.ts | Mixed routes (org-scoped and bookings/my). |
bookings.service.ts | book (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.ts | loadBookingUsage (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.ts | WaitlistPolicy (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.ts | WaitlistPromotionService: 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.ts | WaitlistExpiryService, @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.ts | Domain 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.ts | The 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.ts | Calls releaseMemberBookings before deleting a member’s rows. |
apps/api/src/arbox-bridge/arbox-schedule-import.service.ts | Inbound roster cancels write cancellation_reason='arbox'; seats it freed are filled (fillSessions, trigger arbox). |
apps/api/src/analytics/space-stats.service.ts | Spaces 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.ts | evaluateBooking — the booking rules and their order; isUnlimitedPlan, isExpiredConsumable. Pure, no DB. |
dto/book-session.dto.ts | subscriptionId? (UUID) — explicit plan selection; required once two or more plans can pay. |
Routes
| Method | Path | Handler |
|---|---|---|
| POST | /organizations/:orgId/sessions/:sessionId/book | book |
| DELETE | /organizations/:orgId/sessions/:sessionId/book | cancel (self) |
| DELETE | /organizations/:orgId/sessions/:sessionId/bookings/:bookingId | adminCancel |
| POST | /organizations/:orgId/members/:membershipId/refund-credit | refundCredit |
| GET | /organizations/:orgId/bookings/attendance-trend | attendanceTrend |
| GET | /organizations/:orgId/analytics/attendance/summary | attendanceSummary |
| GET | /organizations/:orgId/analytics/attendance/daily | attendanceDaily |
| GET | /bookings/my | myBookings |
Web
Routes
| Route | Description |
|---|---|
apps/web/src/app/[lang]/(protected)/dashboard/schedule/page.tsx | Weekly calendar for staff. |
apps/web/src/app/[lang]/(protected)/dashboard/schedule/sessions/[sessionId]/page.tsx | Session detail (roster, workouts, check-in display). |
apps/web/src/app/[lang]/(protected)/dashboard/schedule/sessions/[sessionId]/builder/page.tsx | Inline workout builder for a session. |
apps/web/src/app/[lang]/(protected)/(member)/schedule/page.tsx | Member weekly view. |
apps/web/src/app/[lang]/(protected)/(member)/checkin/page.tsx | QR/GPS check-in landing. |
apps/web/src/app/[lang]/(protected)/kiosk/[sessionId]/page.tsx | Authenticated staff-only kiosk: QR + tappable roster for door check-in. |
Hooks / API helpers
useApi(client) andserverFetch(server) — Clerk bearer attached automatically. Seeapps/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 flagspacesis 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 409plan_selection_requiredits plan picker lists the body’splans(first preselected) and booking from it retries with the chosensubscriptionId. Waitlist offer, rank line and cancel button/policy text come from the server fields throughsession-booking-state.ts(isWaitlistOpen,resolveCancellationState,isLateCancellationState).apps/web/src/components/overview/schedule/session-detail-sheet.tsx— staff roster: waitlist ordered and numbered bywaitlistRank; cancelled rows carry acancellationReasonbadge.- Drag layer
schedule-drag.tsx(TKN-478):ScheduleDragProvider(the DnD context, used bySpaceSectionsand 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 ofexpanded-week-grid.tsx),ScheduleDraggable,useCopyToDayand the “Copy to day…” dialog.use-session-copy.tsposts the copy, runs the override dialog, opens the copy, toasts Undo (DELETE) and capturesschedule_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
| Table | Role |
|---|---|
class_sessions | The session row itself. |
class_session_workouts | Join with sort order, cascade-delete on session/workout drop. |
bookings | The booking row + check-in metadata. |
class_types | Parent of session (mandatory FK). |
programs | Parent of class_type; carries organizationId (the org-scoping anchor). |
locations | The session’s space. lat/lng powers GPS check-in; concurrency + capacity drive the space checks (flag spaces). |
memberships | coachMembershipId and bookings.membershipId. |
organizations | cancellationWindowHours, allowLateCancellation, registrationOpensBeforeMinutes, registrationClosesBeforeMinutes, timezone. |
subscriptions | bookings.subscriptionId (nullable for staff). |
audit_logs | One row per bulk-delete / bulk-publish batch with payload. |
audit_events | class_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), theplan_selection_requiredbody’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.tsalso carrieswaitlistPromotionSkipReasonValues(evaluator reasons +subscription_inactive) and the response fieldswaitlistOpen,waitlistCount,myWaitlistRank,myCancellationState,myFreeCancelUntil,myPromotedAt(session) andpromotedAt,cancellationReason,waitlistRank(booking).libs/shared/src/lib/schemas/day-of-week.ts—dayOfWeekValuesarray used by bulk filter.isStaffRolehelper in@taikan/shared— single source of truth for staff vs member.
Tests
| File | What it covers |
|---|---|
apps/api/src/class-sessions/class-sessions.service.unit.spec.ts | CRUD, publish/unpublish guards, cancel side effects. |
apps/api/src/class-sessions/class-sessions.bulk.unit.spec.ts | Bulk preview/delete/publish, filter resolution, audit logging. |
apps/api/src/bookings/bookings.service.unit.spec.ts | Booking 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.ts | Real 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.ts | Real 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.ts | Table-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.ts | Real 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.ts | fillFreeSeats: 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.ts | publishPromotions: 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.ts | releaseMemberBookings; the deactivated/suspended listener. |
apps/api/src/booking-policy/booking-policy.resolver.unit.spec.ts | cancelTermsFor promotion grace (before/after the deadline, capped at the start, no deadline). |
apps/api/src/notifications/booking-notifications.listener.unit.spec.ts | Promotion 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.ts | Server field wins over the legacy fallback for waitlist and cancel state. |
apps/web/src/components/overview/schedule/session-detail-sheet.int.spec.tsx | Roster: waitlist by rank, cancellation reason badge. |
apps/web/e2e/specs/session-sheets.spec.ts, bulk-session-actions.spec.ts, multi-plan-booking.spec.ts | Playwright flows: session sheets, bulk actions, booking with several plans. |
apps/web/src/components/member/schedule/session-detail-drawer.int.spec.tsx | Member 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.ts | Ledger 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.ts | Concurrent booking races; punch-card race asserts ledger debits = seats and ledger sum = counter. |
Space context (TKN-460)
GET /sessionsandGET /sessions/upcoming-today(class-sessions controller) and the booking attendance reads (/bookings/attendance-trend,/analytics/attendance/{summary,daily}) take an optionalspaceId, resolved bySpaceScopeServiceand applied asclass_sessions.location_id = :space; a session without a space matches only “every space” until S4.BookingsService.getAttendanceSummarydrops deleted bookings in the join (the old unparenthesised OR let every org through). The web Today panel reads both session endpoints throughuseScopedApiQuery. See space-context/code-map.md.
Booking policy rules (TKN-447/448)
| File | Purpose |
|---|---|
libs/shared/src/lib/schemas/booking-policy.schema.ts | Condition model, TimeRule, settings, rule / upsert / effective / conflict / snapshot schemas, BookingPolicyErrorCodes. |
libs/shared/src/lib/booking-policy.ts | Pure matchesScope, scopesOverlap, ruleLayer, resolveBookingPolicy (tz-free). |
apps/api/src/booking-policy/time-rule.ts | zonedInstant (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.ts | loadBookingPolicyResolver (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.ts | Rule 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.ts | Log + PostHog event per rollout decision: reportQuotaRefusal, reportWindowRefusal, reportCancellationClosed, reportPolicyConflict, reportRuleSaved, reportPlanQuotasSaved, reportParityRevocations (observability). |
apps/api/src/booking-policy/time-rule.unit.spec.ts | Measured 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.ts | 17 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.ts | The 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.ts | Shared 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.ts | Staff 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.
| Tool | Roles | What it does |
|---|---|---|
booking_policy.explain | owner, 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.effective | owner, admin, coach (bookingPolicy:view) | A session’s (or a hall / class type / program / plan’s) resolved rules with decidedBy and dates. |
booking_policy.rules_list | owner, admin, coach | The org’s rules, live count against the 200 cap, names for the ids the scopes hold. |
booking_policy.rule_create / rule_update | owner, admin (bookingPolicy:manage), always confirmed | Create / 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_archive | owner, admin, destructive confirm | Archive a rule. |
plans.quotas_list | owner, 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_set | owner, admin (bookingPolicy:manage), always confirmed | Replace a plan’s quota list (PlanEntitlementBucketsService.replace). |
memberships.quota_balances | owner, 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 asinvalid_inputbefore any service runs. BookingPolicyService.nameslabels 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_conflictandspace_capacity_exceededincluded) isBOOKING_RULES_PROMPTinapps/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) overbooking-policy.tools.driver.ts(real registry and runner, stubbed services).