# NestJS Time-Slot & Scheduling System Analysis ## Executive Summary This is a comprehensive analysis of the pilates studio booking system's time-slot generation and scheduling backend. The system automatically generates time slots from reusable weekly templates, maintains their lifecycle, and integrates tightly with the booking and membership management systems. --- ## 1. Data Models (Prisma Schema) ### 1.1 WeekTemplate Model **Location:** `packages/server/prisma/schema.prisma` (lines 113-126) ```prisma model WeekTemplate { id String @id @default(uuid()) dayOfWeek Int @map("day_of_week") // 1=Mon, 7=Sun (ISO standard) startTime String @map("start_time") // e.g., "09:00" endTime String @map("end_time") // e.g., "10:00" capacity Int @default(1) // Max participants isActive Boolean @default(true) // Enable/disable template createdAt DateTime @default(now()) updatedAt DateTime @updatedAt timeSlots TimeSlot[] // Generated slots from this template } ``` **Purpose:** - Defines recurring time slots by day of week and time - Used as blueprint for automatic slot generation - Capacity defines how many people can book each slot **Key Constraints:** - `dayOfWeek` uses **ISO 8601 standard** (1=Monday through 7=Sunday) - NOT JavaScript getDay() (0=Sunday) - Conversion happens in SlotGeneratorService.toIsoWeekday() --- ### 1.2 TimeSlot Model **Location:** `packages/server/prisma/schema.prisma` (lines 128-148) ```prisma model TimeSlot { id String @id @default(uuid()) date DateTime @db.Date // Calendar date (midnight UTC) startTime String @map("start_time") // "HH:mm" format endTime String @map("end_time") // "HH:mm" format capacity Int @default(1) // Max participants bookedCount Int @default(0) // Current bookings status TimeSlotStatus @default(OPEN) // OPEN | FULL | CLOSED source TimeSlotSource @default(TEMPLATE) // TEMPLATE | MANUAL templateId String? @map("template_id") // Reference to WeekTemplate createdAt DateTime @default(now()) updatedAt DateTime @updatedAt template WeekTemplate? @relation(fields: [templateId], references: [id]) bookings Booking[] @@unique([date, startTime, endTime]) // Prevent duplicate slots @@index([date]) @@index([status]) } ``` **Status Lifecycle:** - **OPEN**: Accepts bookings, bookedCount < capacity - **FULL**: No more bookings, bookedCount >= capacity - **CLOSED**: Past date or manually closed, no bookings allowed **Source Types:** - **TEMPLATE**: Auto-generated from WeekTemplate - **MANUAL**: Created directly by admin --- ### 1.3 Booking Model **Location:** `packages/server/prisma/schema.prisma` (lines 150-168) ```prisma model Booking { id String @id @default(uuid()) userId String @map("user_id") timeSlotId String @map("time_slot_id") membershipId String @map("membership_id") status BookingStatus @default(CONFIRMED) cancelledAt DateTime? @map("cancelled_at") createdAt DateTime @default(now()) updatedAt DateTime @updatedAt user User @relation(fields: [userId], references: [id]) timeSlot TimeSlot @relation(fields: [timeSlotId], references: [id]) membership Membership @relation(fields: [membershipId], references: [id]) @@unique([userId, timeSlotId]) // One booking per user per slot @@index([userId]) @@index([status]) } ``` **Booking Status Values:** - **CONFIRMED**: Active reservation - **CANCELLED**: User cancelled - **COMPLETED**: Slot time has passed - **NO_SHOW**: Marked manually if user didn't attend --- ## 2. SlotGeneratorService **Location:** `packages/server/src/time-slot/slot-generator.service.ts` ### 2.1 Service Overview Core service responsible for: 1. **Generating** time slots from WeekTemplate 2. **Cleaning up** expired slots 3. **Managing** membership expiration 4. **Marking** past bookings as completed ### 2.2 Key Methods #### `generateSlots(daysAhead: number = 14): Promise` **Purpose:** Creates time slots for the next N days based on active WeekTemplates. **Algorithm:** ``` 1. Fetch all active WeekTemplates (isActive = true) 2. Calculate tomorrow at midnight UTC as start date 3. For each day in [tomorrow, tomorrow + daysAhead): a. Get ISO weekday (1-7) from JavaScript date b. Find matching templates for this weekday c. For each matching template, create slot data: - date: UTC midnight - startTime/endTime: from template - capacity: from template - source: TimeSlotSource.TEMPLATE - templateId: template.id 4. Batch create all slots using createMany() with skipDuplicates: true 5. Return count of newly created slots ``` **Key Features:** - **Idempotent:** Re-running is safe; duplicate date+startTime+endTime combos are skipped - **Timezone Aware:** Uses UTC midnight for dates - **Weekday Mapping:** Converts JS getDay() → ISO weekday - **Batch Insert:** Creates all slots in single database operation **Example Execution:** - Today: Monday, April 7, 2026 - Daylight: 14 days - Template: Monday 09:00-10:00, Friday 18:00-19:00 - Result: 2 slots tomorrow (Monday), 0 Wed-Thu, 1 Friday, repeat pattern --- #### `cleanupExpiredSlots(): Promise` **Purpose:** Marks all OPEN slots with dates before today as CLOSED. **Logic:** ```sql UPDATE time_slots SET status = 'CLOSED' WHERE status = 'OPEN' AND date < TODAY_MIDNIGHT_UTC ``` **Returns:** Count of slots closed. --- #### `checkExpiredMemberships(): Promise` **Purpose:** Manages membership expiration in two ways: 1. **By Expiration Date:** ``` WHERE status = ACTIVE AND expireDate < NOW SET status = EXPIRED ``` 2. **By Used-Up Sessions:** ``` WHERE status = ACTIVE AND remainingTimes = 0 SET status = USED_UP ``` **Returns:** Total count of memberships updated. --- #### `completeBookings(): Promise` **Purpose:** Marks CONFIRMED bookings for past time slots as COMPLETED. **Logic:** ```sql UPDATE bookings SET status = 'COMPLETED' WHERE status = 'CONFIRMED' AND timeSlot.date < TODAY_MIDNIGHT_UTC ``` --- ## 3. TimeSlotService **Location:** `packages/server/src/time-slot/time-slot.service.ts` ### 3.1 Service Overview Handles time slot queries and management for both members and admins. ### 3.2 Key Methods #### `getAvailableSlots(date: string, userId?: string): Promise` **Purpose:** Retrieve all non-closed slots for a specific date, optionally including user's booking status. **Query Logic:** ``` 1. Parse date string to Date object 2. Find all slots for that calendar day: - WHERE status != CLOSED - ORDER BY startTime ASC 3. If userId provided: - Include bookings where userId=X AND status=CONFIRMED - Map to "isBookedByMe" and "myBookingId" fields 4. Return TimeSlotWithBookingStatus[] ``` **Response Type:** ```typescript interface TimeSlotWithBookingStatus { id: string date: string // ISO date "YYYY-MM-DD" startTime: string // "HH:mm" endTime: string capacity: number bookedCount: number status: TimeSlotStatus // OPEN | FULL | CLOSED source: TimeSlotSource // TEMPLATE | MANUAL templateId: string | null createdAt: string // ISO datetime updatedAt: string isBookedByMe: boolean // Current user's booking? myBookingId: string | null // For cancellation } ``` --- #### `getSlotById(id: string): Promise` Returns full slot details including all bookings. Throws NotFoundException if not found. --- #### `createManualSlot(dto: CreateManualSlotDto): Promise` **Purpose:** Allow admins to create one-off time slots outside templates. **DTO:** ```typescript class CreateManualSlotDto { date: string // "YYYY-MM-DD" startTime: string // "HH:mm" endTime: string // "HH:mm" capacity?: number // Defaults to DEFAULT_SLOT_CAPACITY (1) } ``` **Creates slot with:** - `source: TimeSlotSource.MANUAL` - `templateId: null` --- #### `closeSlot(id: string): Promise` Sets slot status to CLOSED. Prevents new bookings but keeps existing ones. --- #### `getWeekTemplates(): Promise` Lists all templates ordered by dayOfWeek and startTime. --- #### `replaceWeekTemplates(items: WeekTemplateItemDto[]): Promise` **Purpose:** Atomic replacement of all templates (used during admin config). **Transaction:** ``` 1. DELETE FROM week_templates (all rows) 2. CREATE week_templates with new items 3. Return count ``` --- ## 4. TimeSlotController & AdminTimeSlotController **Location:** `packages/server/src/time-slot/time-slot.controller.ts` ### 4.1 Member Endpoints #### `GET /time-slot/available?date=YYYY-MM-DD` - Returns available slots for the date - Includes current user's booking status - Requires JWT authentication #### `GET /time-slot/:id` - Returns full slot details with all bookings - Requires JWT authentication --- ### 4.2 Admin Endpoints All require `@Roles(UserRole.ADMIN)` and JWT auth. #### `GET /admin/week-template` Lists all WeekTemplate entries. #### `PUT /admin/week-template` Replaces all templates. Request body: ```json { "templates": [ { "dayOfWeek": 1, "startTime": "09:00", "endTime": "10:00", "capacity": 1, "isActive": true } ] } ``` #### `POST /admin/time-slot/manual` Creates a manual slot. Request body: ```json { "date": "2026-04-10", "startTime": "14:00", "endTime": "15:00", "capacity": 2 } ``` #### `PUT /admin/time-slot/:id/close` Closes a specific slot. #### `POST /admin/generate-slots` Manually trigger slot generation (default 14 days ahead). --- ## 5. SchedulerService - Automated Jobs **Location:** `packages/server/src/scheduler/scheduler.service.ts` ### 5.1 Overview Uses `@nestjs/schedule` to run daily maintenance tasks. All times in UTC. ### 5.2 Cron Jobs #### Job 1: Slot Generation ``` @Cron('0 2 * * *') // 02:00 UTC daily async handleSlotGeneration() ``` - Calls: `slotGenerator.generateSlots(14)` - Generates slots 14 days ahead - Purpose: Keep pipeline filled --- #### Job 2: Slot Cleanup ``` @Cron('30 2 * * *') // 02:30 UTC daily async handleCleanupSlots() ``` - Calls: `slotGenerator.cleanupExpiredSlots()` - Marks past OPEN slots as CLOSED --- #### Job 3: Membership Check ``` @Cron('0 3 * * *') // 03:00 UTC daily async handleCheckMemberships() ``` - Calls: `slotGenerator.checkExpiredMemberships()` - Expires memberships by date or used-up sessions --- #### Job 4: Booking Completion ``` @Cron('0 22 * * *') // 22:00 UTC daily async handleCompleteBookings() ``` - Calls: `slotGenerator.completeBookings()` - Marks past CONFIRMED bookings as COMPLETED --- ## 6. BookingService - Integration with TimeSlots **Location:** `packages/server/src/booking/booking.service.ts` ### 6.1 Key Integration Points #### `createBooking(userId: string, dto: CreateBookingDto): Promise` **DTO:** ```typescript class CreateBookingDto { timeSlotId: string // UUID of TimeSlot membershipId: string // UUID of Membership } ``` **Transaction Flow:** ``` 1. Fetch TimeSlot - validate status = OPEN 2. Check unique constraint - user not already booked this slot 3. Fetch Membership - validate: - Belongs to user - Status = ACTIVE - Has remaining capacity: * TIMES/TRIAL: remainingTimes > 0 * DURATION: not expired 4. Create Booking(userId, timeSlotId, membershipId) → CONFIRMED 5. Update TimeSlot: - bookedCount++ - If bookedCount >= capacity, set status = FULL 6. Update Membership (if time-based): - remainingTimes-- - If remainingTimes = 0, set status = USED_UP 7. Return booking with relations ``` **Error Handling:** - TimeSlot not OPEN → BadRequestException - Duplicate booking → ConflictException - Invalid membership → ForbiddenException - No remaining sessions → BadRequestException --- #### `cancelBooking(userId: string, bookingId: string): Promise` **Refund Logic:** ``` cancelHoursLimit = StudioConfig.cancelHoursLimit (default 2 hours) slotStartMs = Date(date).setUTC Hours + startTime deadlineMs = NOW + (cancelHoursLimit * 3600 * 1000) withinLimit = slotStartMs >= deadlineMs IF withinLimit: Restore membership.remainingTimes++ ELSE: No refund ``` **Transaction Flow:** ``` 1. Mark Booking → CANCELLED, set cancelledAt 2. Decrement TimeSlot.bookedCount 3. If slot was FULL, restore to OPEN 4. If within cancel window: - For TIMES/TRIAL: increment remainingTimes - Restore membership status if was USED_UP ``` --- #### `getMyBookings(userId: string, status?, page, limit): Promise` Lists user's bookings with pagination, optionally filtered by status. --- #### `getUpcomingBookings(userId: string): Promise` Returns all CONFIRMED bookings for dates >= today, ordered by date. --- ## 7. Data Flow Diagrams ### 7.1 Slot Generation Flow ``` Daily 02:00 UTC ↓ SchedulerService.handleSlotGeneration() ↓ SlotGeneratorService.generateSlots(14) ↓ 1. Query WeekTemplate (isActive=true) 2. For next 14 days: - Match templates by ISO weekday - Create TimeSlot entries 3. Use createMany(skipDuplicates: true) ↓ Database: Insert new TimeSlot records ↓ Return: count of new slots ``` --- ### 7.2 Booking Flow ``` User Action ↓ POST /booking timeSlotId: UUID membershipId: UUID ↓ BookingService.createBooking() ↓ START TRANSACTION ├─ Validate TimeSlot (status=OPEN) ├─ Check unique(userId, timeSlotId) ├─ Validate Membership (ACTIVE, not expired) ├─ CREATE Booking(CONFIRMED) ├─ UPDATE TimeSlot(bookedCount++, status=?) └─ UPDATE Membership(remainingTimes--) COMMIT ↓ Return: BookingWithRelations ``` --- ### 7.3 Cancellation Flow ``` User Action ↓ PUT /booking/:id/cancel ↓ BookingService.cancelBooking() ↓ Check: Now vs Slot Time + cancelHoursLimit ↓ START TRANSACTION ├─ UPDATE Booking(CANCELLED, cancelledAt=NOW) ├─ UPDATE TimeSlot(bookedCount--, status=?) └─ IF within cancel window: └─ UPDATE Membership(remainingTimes++) COMMIT ↓ Return: { booking, refunded: boolean } ``` --- ## 8. DTOs & Request/Response ### 8.1 Time Slot DTOs **Location:** `packages/server/src/time-slot/dto/` #### `QuerySlotsDto` ```typescript class QuerySlotsDto { @IsDateString() date!: string // Format: YYYY-MM-DD } ``` #### `CreateManualSlotDto` ```typescript class CreateManualSlotDto { @IsDateString() date!: string @IsString() startTime!: string @IsString() endTime!: string @IsOptional() @IsInt() @Min(1) capacity?: number } ``` #### `WeekTemplateItemDto` & `UpdateWeekTemplateDto` ```typescript class WeekTemplateItemDto { @IsInt() @Min(1) @Max(7) dayOfWeek!: number // ISO: 1=Mon, 7=Sun @IsString() startTime!: string @IsString() endTime!: string @IsOptional() capacity?: number @IsOptional() isActive?: boolean } class UpdateWeekTemplateDto { @ArrayNotEmpty() templates!: WeekTemplateItemDto[] } ``` --- ## 9. Shared Constants & Enums **Location:** `packages/shared/src/` ### 9.1 Constants ```typescript // constants.ts export const DEFAULT_CANCEL_HOURS_LIMIT = 2 export const DEFAULT_SLOT_CAPACITY = 1 export const SLOT_GENERATION_DAYS = 14 export const TIME_PERIODS = { MORNING: { label: '上午', start: '06:00', end: '12:00' }, AFTERNOON: { label: '下午', start: '12:00', end: '18:00' }, EVENING: { label: '晚上', start: '18:00', end: '22:00' }, } export const DATE_SELECTOR_DAYS = 7 export const WEEKDAY_LABELS = ['', '周一', '周二', '周三', '周四', '周五', '周六', '周日'] ``` ### 9.2 Enums ```typescript // enums.ts enum TimeSlotStatus { OPEN = 'OPEN', FULL = 'FULL', CLOSED = 'CLOSED', } enum TimeSlotSource { TEMPLATE = 'TEMPLATE', MANUAL = 'MANUAL', } enum BookingStatus { CONFIRMED = 'CONFIRMED', CANCELLED = 'CANCELLED', COMPLETED = 'COMPLETED', NO_SHOW = 'NO_SHOW', } enum MembershipStatus { ACTIVE = 'ACTIVE', EXPIRED = 'EXPIRED', USED_UP = 'USED_UP', } ``` --- ## 10. File Structure Summary ``` packages/server/src/ ├── time-slot/ │ ├── __tests__/ │ │ ├── slot-generator.service.spec.ts (170 lines, comprehensive tests) │ │ └── time-slot.service.spec.ts │ ├── dto/ │ │ ├── query-slots.dto.ts │ │ ├── create-manual-slot.dto.ts │ │ └── week-template.dto.ts │ ├── slot-generator.service.ts (172 lines, 4 key methods) │ ├── time-slot.service.ts (142 lines) │ ├── time-slot.controller.ts (93 lines, 2 controllers) │ └── time-slot.module.ts │ ├── scheduler/ │ ├── __tests__/ │ │ └── scheduler.service.spec.ts │ ├── scheduler.service.ts (55 lines, 4 cron jobs) │ └── scheduler.module.ts │ ├── booking/ │ ├── __tests__/ │ │ └── booking.service.spec.ts │ ├── dto/ │ │ └── create-booking.dto.ts │ ├── booking.service.ts (367 lines) │ ├── booking.controller.ts (82 lines) │ └── booking.module.ts │ ├── prisma/ │ └── schema.prisma (205 lines, includes models) │ └── app.module.ts (imports TimeSlotModule, SchedulerModule) packages/shared/src/ ├── types/ │ ├── time-slot.ts │ └── (others) ├── constants.ts (22 lines) ├── enums.ts (47 lines) └── index.ts ``` --- ## 11. Key Architectural Patterns ### 11.1 Idempotent Slot Generation **Problem:** If scheduler crashes or delays, slots might not be generated. **Solution:** - Use `createMany(skipDuplicates: true)` with unique constraint on `[date, startTime, endTime]` - Safe to re-run multiple times - Only inserts new combinations --- ### 11.2 Atomic Transactions **For Booking Creation:** - Create booking, update slot, update membership in single transaction - All-or-nothing: ensures consistency if any step fails **For Cancellation:** - Cancel booking, restore slot, conditionally restore membership - Prevents race conditions --- ### 11.3 ISO Weekday Mapping **Problem:** JavaScript `Date.getDay()` uses 0=Sunday, but WeekTemplate uses ISO 8601 (1=Monday). **Solution:** Helper function `toIsoWeekday()`: ```typescript function toIsoWeekday(jsDay: number): number { return jsDay === 0 ? 7 : jsDay } ``` --- ### 11.4 Membership Type Handling **TIMES/TRIAL cardType:** - Deduct `remainingTimes--` on booking - Mark USED_UP when remainingTimes = 0 - Refund if cancelled within window **DURATION cardType:** - Check `expireDate` not passed - No deduction; just check validity - No refund on cancellation --- ## 12. Example Scenarios ### Scenario 1: Setup Studio with Mon-Fri Classes **Admin Actions:** ```json PUT /admin/week-template { "templates": [ { "dayOfWeek": 1, "startTime": "09:00", "endTime": "10:00", "capacity": 1 }, { "dayOfWeek": 1, "startTime": "10:30", "endTime": "11:30", "capacity": 1 }, { "dayOfWeek": 2, "startTime": "09:00", "endTime": "10:00", "capacity": 1 }, { "dayOfWeek": 3, "startTime": "09:00", "endTime": "10:00", "capacity": 1 }, { "dayOfWeek": 4, "startTime": "09:00", "endTime": "10:00", "capacity": 1 }, { "dayOfWeek": 5, "startTime": "09:00", "endTime": "10:00", "capacity": 1 }, { "dayOfWeek": 5, "startTime": "18:00", "endTime": "19:00", "capacity": 1 } ] } ``` **Next Day (02:00 UTC):** - Scheduler auto-generates 14 days of slots - Result: 14 Mon morning + 14 Mon mid-morning + 14 Tue morning + ... + 14 Fri evening **Member Action (View Availability):** ``` GET /time-slot/available?date=2026-04-10 → Returns all slots for April 10 (Friday) → Includes bookings for current user ``` --- ### Scenario 2: Member Books, Then Cancels **Member Books:** ``` POST /booking { "timeSlotId": "slot-123", "membershipId": "mem-456" } ``` **System:** 1. Validates slot is OPEN, membership is ACTIVE with remaining sessions 2. Creates Booking(CONFIRMED) 3. Increments slot.bookedCount (1 → 2) 4. If now at capacity, sets slot.status = FULL 5. Decrements membership.remainingTimes (5 → 4) **Member Cancels (within 2-hour window):** ``` PUT /booking/booking-789/cancel ``` **System:** 1. Checks if NOW + 2 hours ≤ slot start time ✓ 2. Sets booking.status = CANCELLED 3. Decrements slot.bookedCount (2 → 1) 4. If slot was FULL, restores to OPEN 5. Increments membership.remainingTimes (4 → 5) ✓ refunded --- ### Scenario 3: Membership Expires **Overnight at 03:00 UTC:** - Scheduler runs `handleCheckMemberships()` - Updates all ACTIVE memberships where `expireDate < NOW` to EXPIRED - User tries to book → BadRequestException "Membership is not active (status: EXPIRED)" --- ## 13. Testing Guide ### Key Test Files 1. **`slot-generator.service.spec.ts`** (310 lines) - Tests slot generation from templates - Tests weekday mapping (JS vs ISO) - Tests cleanup and expiration logic - Tests membership and booking expiration 2. **`time-slot.service.spec.ts`** (existing) - Tests getAvailableSlots with user booking status - Tests manual slot creation 3. **`booking.service.spec.ts`** (existing) - Tests booking creation with all validations - Tests cancellation with refund logic --- ## 14. Configuration & Environment ### Required Env Variables ``` DATABASE_URL=mysql://... ``` ### Studio Config (StudioConfig table) - `cancelHoursLimit`: Hours before slot to allow free cancellation (default 2) ### Constants (shared package) - `SLOT_GENERATION_DAYS`: 14 (days ahead to generate) - `DEFAULT_SLOT_CAPACITY`: 1 (private lessons) - `DEFAULT_CANCEL_HOURS_LIMIT`: 2 --- ## 15. Performance Considerations ### Database Indexes - `TimeSlot(date)` - for date range queries - `TimeSlot(status)` - for status filtering - `Booking(userId)` - for user's bookings - `Booking(status)` - for status filtering ### Batch Operations - Slot generation uses `createMany()` for efficiency - Expiration checks use `updateMany()` instead of loops ### Transaction Isolation - All booking/cancellation operations wrapped in transactions - Prevents race conditions on bookedCount and remainingTimes --- ## 16. Security Notes ### Authorization - JWT guard on all endpoints - RolesGuard for admin endpoints (only ADMIN role) - Users can only modify their own bookings/memberships ### Validation - All DTOs have class-validator decorators - UUID validation on foreign keys - Date string validation (YYYY-MM-DD format) ### Data Integrity - Unique constraint on `[userId, timeSlotId]` prevents duplicate bookings - Unique constraint on `[date, startTime, endTime]` prevents duplicate slots - Foreign key constraints on relations --- ## 17. Future Enhancement Ideas 1. **Overbooking Buffer:** - Allow configurable overbooking ratio (e.g., 110% capacity) 2. **Waitlist Support:** - Add BookingStatus.WAITLISTED - Auto-promote when slot opens 3. **Recurring Cancellation:** - Cancel all future bookings of a series - Batch refunds 4. **Slot Availability Notifications:** - Alert users when slots available - Implement notification queue 5. **Dynamic Pricing:** - Peak vs off-peak pricing - Last-minute discounts --- ## Summary This time-slot and scheduling system is well-architected with: ✅ **Idempotent slot generation** - Safe to re-run ✅ **Atomic transactions** - ACID compliance for bookings ✅ **Automatic maintenance** - 4 daily cron jobs ✅ **Flexible membership types** - TIMES, DURATION, TRIAL ✅ **Refund policy** - Configurable cancellation window ✅ **ISO weekday standard** - Proper international support ✅ **Comprehensive validation** - DTOs with decorators ✅ **Role-based access** - Admin vs member endpoints The system handles: - Auto-generating 14 days of slots nightly - Accepting bookings with capacity management - Canceling with conditional refunds - Expiring memberships and marking past bookings - All with transactional integrity and concurrent safety.