Facility Booking Engine - Discovery Notes
Last updated: 2026-01-13
Goal
Define the core booking engine for facilities so courts, pools, gyms, rooms, and simulators can be reserved with recurring rules, waitlists, and member priority windows.
Decision
Facilities will own the booking engine and reservation lifecycle. Rules, pricing, and entitlements will be provided by external policy adapters (SCL first, MCA second) rather than embedding SCL/MCA booking logic in Facilities.
MVP focus: single-resource reservations (court-style). Capacity-based reservations (pools/gyms) are deferred to #10583.
Current State
- Facilities service supports facility resources, maintenance logs, resource usage, and availability queries.
- Availability uses
ResourceUsage+ maintenance windows; there is no reservation model. - Golf cart flows exist and link to external bookings (tee-time), but the booking system lives elsewhere.
Dependencies & Inputs
- Member identity, tier, and role from SCL/Access Control.
- Club time zone (tenant or club settings) for recurring rules.
- External booking IDs from TeeTime or MCA for cross-system linking.
Scope (MVP)
- Create/update/cancel facility reservations.
- Time window validation and conflict detection.
- Recurring reservations (simple RRULE or weekly patterns).
- Waitlist for full slots with priority ordering.
- Member priority windows (advance booking by tier/role).
- Guest booking rules (caps per member, guest limits).
- Availability computed from reservations + maintenance.
- External rule evaluation via adapter (advance windows, guest limits, pricing hints).
Out of Scope (MVP)
- Payment capture and pricing (handled by billing layer).
- Full competition/tournament management.
- Hardware control and access devices.
Proposed Data Model (Draft)
FacilityReservationSeries
id
tenantId
clubId
resourceId
memberId
status (ACTIVE | PAUSED | CANCELLED)
rule (RRULE or custom weekly pattern)
timeZone
startTime
endTime
createdAt
updatedAt
FacilityReservation
id
seriesId (optional)
tenantId
clubId
resourceId
memberId
guestCount
status (ACTIVE | CANCELLED | NO_SHOW)
startTime
endTime
cancelledAt (optional)
cancellationReason (optional)
noShowAt (optional)
createdBy (optional)
updatedBy (optional)
notes (optional)
createdAt
updatedAt
source (WEB | ADMIN | API)
externalBookingId (optional)
version
FacilityWaitlistEntry
id
tenantId
clubId
resourceId
memberId
desiredStart
desiredEnd
priorityScore
offeredAt (optional)
offerExpiresAt (optional)
acceptedAt (optional)
cancelledAt (optional)
createdAt
status (ACTIVE | OFFERED | ACCEPTED | EXPIRED | CANCELLED)
notes (optional)
FacilityBookingRule
id
tenantId
clubId
resourceType
memberTier
minAdvanceHours
maxAdvanceDays
guestLimit
maxActiveBookings
priorityWindowHours
minDurationMinutes
maxDurationMinutes
bufferMinutes
FacilityHours
id
tenantId
clubId
resourceId (optional)
resourceType (optional)
dayOfWeek
opensAtMinutes
closesAtMinutes
timeZone
FacilityBlackout
id
tenantId
clubId
resourceId (optional)
resourceType (optional)
startTime
endTime
reason (optional)
Notes:
FacilityReservationSeriesstores recurrence rules; instances areFacilityReservationrows.- Keep
ResourceUsagefor actual usage tracking; reservations reserve time windows. externalBookingIdsupports TeeTime or MCA integration and should be unique per tenant.- Rules can be stored per resource type or specific resource;
clubIdis optional for tenant-wide defaults.
Recurrence Rules & Time Zones
Facilities accepts two formats for series rules:
- RRULE strings (subset)
FREQ=DAILY,FREQ=WEEKLY, orFREQ=MONTHLYINTERVAL=<n>BYDAY=MO,TU,WE,TH,FR,SA,SU(weekly only)BYMONTHDAY=1,15,30(monthly only)COUNT=<n>(optional, caps occurrences)UNTIL=<YYYYMMDD>orUNTIL=<YYYYMMDDTHHMMSSZ>(optional)EXDATE=<YYYYMMDD>orEXDATE=<YYYYMMDDTHHMMSSZ>(optional, multiple comma-separated)
Example:
RRULE:FREQ=WEEKLY;INTERVAL=1;BYDAY=MO,WE;COUNT=6
RRULE:FREQ=MONTHLY;BYMONTHDAY=10,20;COUNT=4
- JSON payloads (weekly/daily)
{
"frequency": "WEEKLY",
"interval": 1,
"byDay": ["MON", "WED"],
"count": 6,
"until": "2026-01-31"
}
{
"frequency": "MONTHLY",
"interval": 1,
"byMonthDay": [10, 20],
"count": 4
}
Accepted JSON aliases:
frequency/freq/recurrenceTypebyDay/byDays/recurrenceDaysbyMonthDay/monthDays/monthDaycount/occurrencesuntil/recurrenceEndDateexdates/exceptions/excludeDates
Unsupported for now: complex BYSETPOS and multi-resource series.
Time zones
FacilityReservationSeries.timeZonecontrols local time generation.- Occurrences are generated to keep the same local time across daylight-saving shifts.
- Timestamps are stored in UTC; the generator uses
Intl.DateTimeFormatoffsets to convert local dates to UTC.
Series exceptions
- Canceling a single series instance marks it as a series exception (kept cancelled on future syncs).
- Rescheduling a single instance records a cancelled placeholder for the original slot and detaches the instance from the series.
- Updating or resuming a series only recreates missing instances that are not marked as exceptions.
Rules + Pricing Adapters (Draft)
Facilities calls an injected rules provider to validate bookings and optionally quote pricing. This avoids hard dependencies on SCL/MCA internals.
type BookingRuleDecision = {
canBook: boolean;
denyReason?: string;
maxGuests?: number;
advanceWindow?: { minHours?: number; maxDays?: number };
priorityWindowHours?: number;
};
type PricingHint = {
priceCents?: number;
currency?: string;
discountCode?: string;
feeBreakdown?: Array<{ label: string; amountCents: number }>;
};
export interface FacilityBookingRulesProvider {
validateBooking(input: CreateFacilityReservationRequest): Promise<BookingRuleDecision>;
quotePrice?(input: CreateFacilityReservationRequest): Promise<PricingHint>;
}
Phase order:
- Phase 1: SCL rules provider (member tiers, guest limits, advance windows).
- Phase 2: MCA rules provider (legacy clubs).
Prisma Schema Draft
enum ReservationStatus {
ACTIVE
CANCELLED
NO_SHOW
}
enum ReservationSeriesStatus {
ACTIVE
PAUSED
CANCELLED
}
enum ReservationSource {
WEB
ADMIN
API
}
enum WaitlistStatus {
ACTIVE
OFFERED
ACCEPTED
EXPIRED
CANCELLED
}
model FacilityReservation {
id String @id @default(uuid())
seriesId String?
tenantId String
clubId String
resourceId String
memberId String
guestCount Int @default(0)
status ReservationStatus @default(ACTIVE)
startTime DateTime
endTime DateTime
cancelledAt DateTime?
cancellationReason String?
noShowAt DateTime?
createdBy String?
updatedBy String?
notes String?
source ReservationSource @default(WEB)
externalBookingId String?
version Int @default(0)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
resource FacilityResource @relation(fields: [resourceId], references: [id])
series FacilityReservationSeries? @relation(fields: [seriesId], references: [id])
@@index([tenantId, clubId, startTime])
@@index([resourceId, startTime, endTime, status])
@@index([seriesId])
@@unique([tenantId, externalBookingId])
}
model FacilityReservationSeries {
id String @id @default(uuid())
tenantId String
clubId String
resourceId String
memberId String
status ReservationSeriesStatus @default(ACTIVE)
rule String
timeZone String
startTime DateTime
endTime DateTime
version Int @default(0)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
resource FacilityResource @relation(fields: [resourceId], references: [id])
reservations FacilityReservation[]
@@index([tenantId, clubId, resourceId])
@@index([resourceId, status])
}
model FacilityWaitlistEntry {
id String @id @default(uuid())
tenantId String
clubId String
resourceId String
memberId String
desiredStart DateTime
desiredEnd DateTime
priorityScore Int @default(0)
offeredAt DateTime?
offerExpiresAt DateTime?
acceptedAt DateTime?
cancelledAt DateTime?
notes String?
status WaitlistStatus @default(ACTIVE)
createdAt DateTime @default(now())
resource FacilityResource @relation(fields: [resourceId], references: [id])
@@index([tenantId, clubId, resourceId])
@@index([resourceId, desiredStart, desiredEnd])
}
model FacilityBookingRule {
id String @id @default(uuid())
tenantId String
clubId String?
resourceId String?
resourceType ResourceType?
memberTier String?
minAdvanceHours Int?
maxAdvanceDays Int?
guestLimit Int?
maxActiveBookings Int?
priorityWindowHours Int?
minDurationMinutes Int?
maxDurationMinutes Int?
bufferMinutes Int?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([tenantId, clubId])
}
model FacilityHours {
id String @id @default(uuid())
tenantId String
clubId String?
resourceId String?
resourceType ResourceType?
dayOfWeek Int // 0-6
opensAtMinutes Int // minutes since midnight
closesAtMinutes Int
timeZone String
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
resource FacilityResource? @relation(fields: [resourceId], references: [id])
@@index([tenantId, clubId, resourceType])
@@index([resourceId, dayOfWeek])
}
model FacilityBlackout {
id String @id @default(uuid())
tenantId String
clubId String?
resourceId String?
resourceType ResourceType?
startTime DateTime
endTime DateTime
reason String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
resource FacilityResource? @relation(fields: [resourceId], references: [id])
@@index([tenantId, clubId, startTime])
@@index([resourceId, startTime, endTime])
}
// Existing model additions:
// FacilityResource { reservations, reservationSeries, waitlistEntries, hours, blackouts }
Migration Plan (Draft)
- Add enums and new tables to
libs/prisma/facilities-data/prisma/schema.prisma. - Add a SQL migration for overlap protection (Postgres
btree_gist+EXCLUDEconstraint on resource + time range for ACTIVE reservations). - Include hours/blackouts in availability calculations.
- Generate migration and Prisma client.
- Add repository layer for reservations, waitlist, rules, series, hours, and blackouts.
- Add service methods with transactional reservation creation, reschedule, cancel, and conflict checks.
- Update availability to include ACTIVE reservations, maintenance windows, and blackouts.
- Add controllers/resolvers and wire to facilities-api.
- Add tests for overlap, rule enforcement, waitlist ordering, idempotency on externalBookingId, and hours/blackouts.
Validation & Conflict Rules (Draft)
startTimemust be beforeendTimeand respectminDurationMinutes/maxDurationMinutes.- Apply
bufferMinutesto prevent back-to-back collisions. - Enforce
maxActiveBookingsandguestLimitper member tier. - Respect
minAdvanceHoursandmaxAdvanceDaysbased on club time zone. - Reservation creation uses a transaction plus DB-level overlap constraints to avoid race conditions.
- Use
versionfor optimistic locking on updates/reschedules. externalBookingIdis unique per tenant for idempotent imports.
Availability Logic (Proposed)
A resource is available if:
- Active and not under maintenance.
- No overlapping ACTIVE reservations in the window.
- Capacity rules permit additional bookings (for group resources).
API Surface (Draft)
REST
GET /facility-reservations?tenantId&clubId&resourceId&start&endPOST /facility-reservationsPUT /facility-reservations/:idPOST /facility-reservations/:id/reschedulePOST /facility-reservations/:id/cancelPOST /facility-reservations/:id/no-showPOST /facility-reservations/:id/recurrenceGET /facility-reservation-series?tenantId&clubId&resourceIdPOST /facility-reservation-seriesPOST /facility-reservation-series/:id/cancelGET /facility-waitlist?tenantId&clubId&resourceIdPOST /facility-waitlistPOST /facility-waitlist/:id/offerPOST /facility-waitlist/:id/acceptPOST /facility-waitlist/:id/expirePOST /facility-waitlist/:id/cancelGET /facility-booking-rules?tenantId&clubId&resourceTypePOST /facility-booking-rulesPUT /facility-booking-rules/:idDELETE /facility-booking-rules/:id
REST Contracts (Draft)
type ReservationStatus = 'ACTIVE' | 'CANCELLED' | 'NO_SHOW';
type ReservationSeriesStatus = 'ACTIVE' | 'PAUSED' | 'CANCELLED';
type ReservationSource = 'WEB' | 'ADMIN' | 'API';
type WaitlistStatus = 'ACTIVE' | 'OFFERED' | 'ACCEPTED' | 'EXPIRED' | 'CANCELLED';
type CreateFacilityReservationRequest = {
tenantId: string;
clubId: string;
resourceId: string;
memberId: string;
guestCount?: number;
startTime: string;
endTime: string;
source?: ReservationSource;
externalBookingId?: string;
notes?: string;
};
type UpdateFacilityReservationRequest = {
guestCount?: number;
notes?: string;
version?: number;
};
type RescheduleFacilityReservationRequest = {
startTime: string;
endTime: string;
reason?: string;
version?: number;
};
type CancelFacilityReservationRequest = {
reason?: string;
cancelledBy?: string;
version?: number;
};
type FacilityReservationResponse = {
id: string;
seriesId?: string;
tenantId: string;
clubId: string;
resourceId: string;
memberId: string;
guestCount: number;
status: ReservationStatus;
startTime: string;
endTime: string;
cancelledAt?: string;
cancellationReason?: string;
noShowAt?: string;
source: ReservationSource;
externalBookingId?: string;
notes?: string;
createdAt: string;
updatedAt: string;
};
type CreateFacilityReservationSeriesRequest = {
tenantId: string;
clubId: string;
resourceId: string;
memberId: string;
rule: string;
timeZone: string;
startTime: string;
endTime: string;
source?: ReservationSource;
notes?: string;
};
type FacilityReservationSeriesResponse = {
id: string;
tenantId: string;
clubId: string;
resourceId: string;
memberId: string;
status: ReservationSeriesStatus;
rule: string;
timeZone: string;
startTime: string;
endTime: string;
createdAt: string;
updatedAt: string;
};
type CreateWaitlistEntryRequest = {
tenantId: string;
clubId: string;
resourceId: string;
memberId: string;
desiredStart: string;
desiredEnd: string;
};
type FacilityWaitlistEntryResponse = {
id: string;
tenantId: string;
clubId: string;
resourceId: string;
memberId: string;
desiredStart: string;
desiredEnd: string;
priorityScore: number;
status: WaitlistStatus;
offeredAt?: string;
offerExpiresAt?: string;
acceptedAt?: string;
cancelledAt?: string;
notes?: string;
createdAt: string;
};
type OfferWaitlistEntryRequest = {
offerExpiresAt?: string;
offeredBy?: string;
};
type AcceptWaitlistEntryRequest = {
acceptedBy?: string;
};
type CancelWaitlistEntryRequest = {
reason?: string;
cancelledBy?: string;
};
type CreateBookingRuleRequest = {
tenantId: string;
clubId?: string;
resourceId?: string;
resourceType?: string;
memberTier?: string;
minAdvanceHours?: number;
maxAdvanceDays?: number;
guestLimit?: number;
maxActiveBookings?: number;
priorityWindowHours?: number;
minDurationMinutes?: number;
maxDurationMinutes?: number;
bufferMinutes?: number;
};
type FacilityBookingRuleResponse = CreateBookingRuleRequest & {
id: string;
createdAt: string;
updatedAt: string;
};
GraphQL
Mirror the REST endpoints with facilityReservations, facilityReservationSeries, facilityWaitlist,
and facilityBookingRules queries/mutations.
type FacilityReservation {
id: ID!
seriesId: ID
tenantId: ID!
clubId: ID!
resourceId: ID!
memberId: ID!
guestCount: Int!
status: ReservationStatus!
startTime: DateTime!
endTime: DateTime!
cancelledAt: DateTime
cancellationReason: String
noShowAt: DateTime
source: ReservationSource!
externalBookingId: String
notes: String
createdAt: DateTime!
updatedAt: DateTime!
}
input CreateFacilityReservationInput {
tenantId: ID!
clubId: ID!
resourceId: ID!
memberId: ID!
guestCount: Int
startTime: DateTime!
endTime: DateTime!
source: ReservationSource
externalBookingId: String
notes: String
}
type FacilityReservationSeries {
id: ID!
tenantId: ID!
clubId: ID!
resourceId: ID!
memberId: ID!
status: ReservationSeriesStatus!
rule: String!
timeZone: String!
startTime: DateTime!
endTime: DateTime!
createdAt: DateTime!
updatedAt: DateTime!
}
input CreateFacilityReservationSeriesInput {
tenantId: ID!
clubId: ID!
resourceId: ID!
memberId: ID!
rule: String!
timeZone: String!
startTime: DateTime!
endTime: DateTime!
source: ReservationSource
notes: String
}
type FacilityWaitlistEntry {
id: ID!
tenantId: ID!
clubId: ID!
resourceId: ID!
memberId: ID!
desiredStart: DateTime!
desiredEnd: DateTime!
priorityScore: Int!
status: WaitlistStatus!
offeredAt: DateTime
offerExpiresAt: DateTime
acceptedAt: DateTime
cancelledAt: DateTime
notes: String
createdAt: DateTime!
}
input CreateWaitlistEntryInput {
tenantId: ID!
clubId: ID!
resourceId: ID!
memberId: ID!
desiredStart: DateTime!
desiredEnd: DateTime!
}
type FacilityBookingRule {
id: ID!
tenantId: ID!
clubId: ID
resourceId: ID
resourceType: ResourceType
memberTier: String
minAdvanceHours: Int
maxAdvanceDays: Int
guestLimit: Int
maxActiveBookings: Int
priorityWindowHours: Int
minDurationMinutes: Int
maxDurationMinutes: Int
bufferMinutes: Int
createdAt: DateTime!
updatedAt: DateTime!
}
input CreateFacilityBookingRuleInput {
tenantId: ID!
clubId: ID
resourceId: ID
resourceType: ResourceType
memberTier: String
minAdvanceHours: Int
maxAdvanceDays: Int
guestLimit: Int
maxActiveBookings: Int
priorityWindowHours: Int
minDurationMinutes: Int
maxDurationMinutes: Int
bufferMinutes: Int
}
Workflows
Reservation Create
- Validate booking rules (advance window, guest limits, max active bookings) via rules provider.
- Check availability (reservations + maintenance).
- Create reservation record (ACTIVE).
- If unavailable, create a waitlist entry instead of a reservation.
Reschedule or Cancel
- Re-validate booking rules for the target window.
- Update the reservation timestamps or mark as CANCELLED/NO_SHOW with reason.
- If a slot opens, trigger the waitlist offer flow.
Waitlist Offer
- Slot becomes available.
- Offer to highest priority entry.
- Accept within a time window, else expire and offer next.
Recurring Bookings
- Validate rule against max horizon and conflicts.
- Create a reservation series and initial reservation instances.
- Generate additional instances within a horizon window (async job).
Integrations
- TeeTime: allow linking a booking to a facility reservation by
externalBookingId. - SCL/MCA: standard API for member booking and self-service flows.
- Messaging: notifications for confirmations, cancellations, waitlist offers.
Test Plan (Draft)
- Unit: overlap checks, rule evaluation, waitlist prioritization, idempotent imports.
- Integration: DB overlap constraint, transactional create/reschedule, waitlist offer expiry.
- Contract: REST/GraphQL request/response validation and error mapping.
- Time zone: recurring booking expansion around DST shifts.
Open Questions
- Should reservations be per resource or per resource type with capacity? (MVP: per resource; capacity deferred to #10583.)
- How to handle shared resources (e.g., pool lanes) with variable capacity? (Deferred to #10583.)
- Do we need facility-specific hours and blackout calendars per resource? (Added FacilityHours/FacilityBlackout models.)
- Where do membership tiers and guest limits live (SCL vs Facilities)? (SCL rules provider in Phase 1.)
Deliverables (Discovery)
- Final data model and migration plan.
- REST/GraphQL contracts.
- Rule evaluation strategy (tier + role + facility).
- Concurrency strategy (transactional reservation creation).