Architecture
The Facilities module follows a layered architecture with clear separation of concerns.
System Overview
┌─────────────────────────────────────────────────────────────────────────┐
│ facilities-api (NestJS App) │
│ Port: 3106 │
├─────────────────────────────────────────────────────────────────────────┤
│ FacilitiesServiceModule │
├─────────────────────────────────────────────────────────────────────────┤
│ Controllers Services Resolvers │
│ ───────────────── ──────────────── ────────────── │
│ FacilityResource FacilityResource FacilityResource │
│ MaintenanceLog MaintenanceLog MaintenanceLog │
│ ResourceUsage ResourceUsage ResourceUsage │
│ Availability Availability Availability │
│ FacilityReservation FacilityReservation FacilityReservation│
│ ReservationSeries ReservationSeries ReservationSeries │
│ Waitlist Waitlist Waitlist │
│ BookingRule BookingRule BookingRule │
│ FacilityHours FacilityHours FacilityHours │
│ FacilityBlackout FacilityBlackout FacilityBlackout │
│ ───────────────────────────────────────────────────────────────────── │
│ SwimmingPool SwimmingPoolService │
│ Gym GymService │
│ FunctionRoom FunctionRoomService │
│ SquashCourt SquashCourtService │
│ GolfCart GolfCartService │
├─────────────────────────────────────────────────────────────────────────┤
│ FacilitiesDataModule │
│ ───────────────────────────────────────────────────────────────────── │
│ FacilityResourceRepository FacilityReservationRepository │
│ MaintenanceLogRepository ReservationSeriesRepository │
│ ResourceUsageRepository WaitlistRepository │
│ SwimmingPoolRepository BookingRuleRepository │
│ GymRepository FacilityHoursRepository │
│ FunctionRoomRepository FacilityBlackoutRepository │
│ SquashCourtRepository GolfCartRepository │
│ PrismaService (facilities-data) │
├─────────────────────────────────────────────────────────────────────────┤
│ PostgreSQL Database │
│ (facilities_db schema) │
└─────────────────────────────────────────────────────────────────────────┘
Layer Responsibilities
Controllers (REST)
- Handle HTTP requests and responses
- Input validation via DTOs and
ValidationPipe - Swagger/OpenAPI documentation
- Route protection with
@ApiBearerAuth
Resolvers (GraphQL)
- Handle GraphQL queries and mutations
- Mirror REST endpoints for consistent API surface
- Type-safe with NestJS GraphQL decorators
Services
- Business logic and orchestration
- Tenant/club scoping enforcement
- Cross-entity validation (e.g., resource exists before logging maintenance)
Repositories
- Data access layer using Prisma
- Abstract CRUD operations
- Query building with filters, pagination, and scoping
Data Model
enum ResourceType {
GOLF_COURSE
DRIVING_RANGE
SIMULATOR
GOLF_CART
TENNIS_COURT
PADEL_COURT
SWIMMING_POOL
GYM
FUNCTION_ROOM
SQUASH_COURT
OTHER
}
model FacilityResource {
id String @id @default(uuid())
tenantId String
clubId String
name String
type ResourceType
description String?
capacity Int?
active Boolean @default(true)
maintenanceRequired Boolean @default(false)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
// Relations
maintenanceLogs MaintenanceLog[]
resourceUsages ResourceUsage[]
reservations FacilityReservation[]
reservationSeries FacilityReservationSeries[]
waitlistEntries FacilityWaitlistEntry[]
hours FacilityHours[]
blackouts FacilityBlackout[]
// Extended details (one-to-one)
swimmingPoolDetails SwimmingPoolDetails?
gymDetails GymDetails?
functionRoomDetails FunctionRoomDetails?
squashCourtDetails SquashCourtDetails?
golfCartDetails GolfCartDetails?
}
model SwimmingPoolDetails {
id String @id @default(uuid())
resourceId String @unique
laneCount Int
lengthMeters Int
depthMinMeters Float
depthMaxMeters Float
temperatureCelsius Int?
lifeguardRequired Boolean @default(true)
maxCapacity Int
heated Boolean @default(false)
features String[]
resource FacilityResource @relation(fields: [resourceId], references: [id])
}
model GymDetails {
id String @id @default(uuid())
resourceId String @unique
squareMeters Int
maxCapacity Int
zones String[]
equipment String[]
hasLockers Boolean @default(false)
hasShowers Boolean @default(false)
resource FacilityResource @relation(fields: [resourceId], references: [id])
}
model FunctionRoomDetails {
id String @id @default(uuid())
resourceId String @unique
maxCapacity Int
minCapacity Int?
squareMeters Int
layoutOptions String[]
hasAV Boolean @default(false)
hasCatering Boolean @default(false)
floorLevel Int?
resource FacilityResource @relation(fields: [resourceId], references: [id])
}
model SquashCourtDetails {
id String @id @default(uuid())
resourceId String @unique
courtNumber Int?
courtType CourtType @default(SINGLES)
hasGlassWall Boolean @default(false)
hasAirCon Boolean @default(false)
resource FacilityResource @relation(fields: [resourceId], references: [id])
}
model MaintenanceLog {
id String @id @default(uuid())
resourceId String?
maintenanceType String?
description String?
startTime DateTime
endTime DateTime?
recordedBy String?
recordedAt DateTime @default(now())
resource FacilityResource? @relation(fields: [resourceId], references: [id])
}
model ResourceUsage {
id String @id @default(uuid())
resourceId String
bookingId String?
assignedAt DateTime
releasedAt DateTime?
resource FacilityResource @relation(fields: [resourceId], references: [id])
}
Booking Engine Models
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?
source ReservationSource @default(WEB)
externalBookingId String?
notes String?
version Int @default(0)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
resource FacilityResource @relation(...)
series FacilityReservationSeries? @relation(...)
}
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(...)
reservations FacilityReservation[]
}
model FacilityWaitlistEntry {
id String @id @default(uuid())
tenantId String
clubId String
resourceId String
memberId String
desiredStart DateTime
desiredEnd DateTime
priorityScore Int @default(0)
status WaitlistStatus @default(ACTIVE)
offeredAt DateTime?
offerExpiresAt DateTime?
acceptedAt DateTime?
cancelledAt DateTime?
notes String?
createdAt DateTime @default(now())
resource FacilityResource @relation(...)
}
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
}
model FacilityHours {
id String @id @default(uuid())
tenantId String
clubId String?
resourceId String?
resourceType ResourceType?
dayOfWeek Int
opensAtMinutes Int
closesAtMinutes Int
timeZone String
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
resource FacilityResource? @relation(...)
}
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(...)
}
Multi-Tenancy
All queries are scoped by tenant:
// Required: tenantId
GET /facility-resources?tenantId=1
// Optional: clubId for further scoping
GET /facility-resources?tenantId=1&clubId=10
Resources can only be accessed within their tenant/club scope. Cross-tenant access is prevented at the repository level.
Soft Delete
Resources are never permanently deleted. The DELETE endpoint sets active=false:
async remove(id: number, filters: { tenantId, clubId }) {
// Verify scope
const existing = await this.repo.findScoped({ id, ...filters });
if (!existing) throw new NotFoundException();
// Soft delete
return this.repo.softDeleteById(id);
}
To include inactive resources in queries, use includeInactive=true.