Skip to main content

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.