## Features ### Admin Schedule Page (`packages/app/src/pages/admin/schedule.vue`) - Interactive date-based slot editor for managing daily schedules - Real-time slot editing: start/end times, capacity adjustments - Slot deletion with conflict warnings when bookings exist - Add new slots with modal dialog - Live booking status display (booked count, people names) - Publish/Save changes with sync feedback - Revert unsaved changes with confirmation - Skeleton loading states and empty state handling - Responsive design with optimized mobile UX ### Backend Enhancements - **New DTO** (`PublishDaySlotsDto`): Structured slot publishing with validation - Date string validation - Slot array with existing slot IDs for updates - Time and capacity validation per slot - **Schedule Preview API** (`getSchedulePreview`): - Check for existing published slots - Fallback to active WeekTemplates for unpublished dates - Unified response format with isPublished flag - **Publish Slots API** (`publishDaySlots`): - Atomic transaction for consistency - Update existing slots with new times/capacity - Create new slots from template data - Delete unpublished slots or set to CLOSED if bookings exist - Prevent capacity reduction below existing bookings - Returns all published slots for feedback ### State Management - Enhanced admin store with schedule state - Support for pending/unsaved slot changes - Optimistic UI updates with server sync ### Documentation - Comprehensive scheduling system architecture docs - Quick reference for admin workflows - Flow diagrams and state transitions - Implementation guide for future maintenance ## Breaking Changes None ## Testing Recommendations - Create slots for future dates via schedule editor - Verify booking prevention for locked/full slots - Test capacity adjustments with existing bookings - Confirm template-based schedule generation - Verify transaction rollback on publish failures Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
967 lines
24 KiB
Markdown
967 lines
24 KiB
Markdown
# 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<number>`
|
|
|
|
**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<number>`
|
|
|
|
**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<number>`
|
|
|
|
**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<number>`
|
|
|
|
**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<TimeSlotWithBookingStatus[]>`
|
|
|
|
**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<TimeSlot>`
|
|
|
|
Returns full slot details including all bookings. Throws NotFoundException if not found.
|
|
|
|
---
|
|
|
|
#### `createManualSlot(dto: CreateManualSlotDto): Promise<TimeSlot>`
|
|
|
|
**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<TimeSlot>`
|
|
|
|
Sets slot status to CLOSED. Prevents new bookings but keeps existing ones.
|
|
|
|
---
|
|
|
|
#### `getWeekTemplates(): Promise<WeekTemplate[]>`
|
|
|
|
Lists all templates ordered by dayOfWeek and startTime.
|
|
|
|
---
|
|
|
|
#### `replaceWeekTemplates(items: WeekTemplateItemDto[]): Promise<CreateBatchPayload>`
|
|
|
|
**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<BookingWithRelations>`
|
|
|
|
**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<CancelBookingResult>`
|
|
|
|
**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<PaginatedResult>`
|
|
|
|
Lists user's bookings with pagination, optionally filtered by status.
|
|
|
|
---
|
|
|
|
#### `getUpcomingBookings(userId: string): Promise<BookingWithRelations[]>`
|
|
|
|
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.
|
|
|