Waitlist System
The waitlist system allows players to queue for sold-out tee times and automatically receive offers when slots become available through cancellations.
Architecture
┌─────────────────┐ ┌──────────────────┐ ┌─────────────────┐
│ Player joins │────▶│ WaitlistService │────▶│ WaitlistEntry │
│ waitlist │ │ │ │ (database) │
└─────────────────┘ └──────────────────┘ └─────────────────┘
│
┌─────────────────┐ ┌──────────────────┐ ▼
│ Slot becomes │────▶│ SlotOfferService│◀───────────┘
│ available │ │ │
└─────────────────┘ └──────────────────┘
│
▼
┌──────────────────┐
│ WaitlistOffer │──▶ Notification
│ (time-limited) │──▶ WebSocket
└──────────────────┘
Sources:
libs/tee-time-services/src/lib/waitlist/waitlist.service.tslibs/tee-time-services/src/lib/waitlist/slot-offer.service.ts
Core Concepts
Waitlist Entry
A player's position in the queue for a specific course/date/time window:
interface WaitlistEntry {
id: string;
courseId: string;
playerId: string;
preferredDate: Date;
preferredTimeStart: string; // "08:00"
preferredTimeEnd: string; // "12:00"
playerCount: number; // 1-4
nineHoles: boolean;
status: 'active' | 'notified' | 'booked' | 'cancelled' | 'expired';
position: number; // Queue position
createdAt: Date;
expiresAt?: Date;
}
Waitlist Offer
A time-limited offer for an available slot:
interface WaitlistOffer {
id: string;
entryId: string;
slotId: string;
status: 'pending' | 'accepted' | 'declined' | 'expired';
expiresAt: Date;
notifiedAt: Date;
respondedAt?: Date;
createdAt: Date;
}
Waitlist Flow
1. Player Joins Waitlist
// POST /v1/courses/:courseId/waitlist
{
"playerId": "player-123",
"preferredDate": "2025-12-15",
"preferredTimeStart": "08:00",
"preferredTimeEnd": "12:00",
"playerCount": 2,
"nineHoles": false
}
// Response
{
"id": "entry-abc",
"position": 3,
"status": "active"
}
2. Slot Becomes Available
When a booking is cancelled, the system:
- Finds matching waitlist entries (same course, overlapping time window)
- Orders by position (FIFO)
- Creates offer for first matching entry
- Sends notification (push, SMS, email)
- Emits WebSocket event
3. Player Responds to Offer
Accept:
// POST /v1/waitlist/offers/:offerId/accept
// Creates booking, marks entry as 'booked'
Decline:
// POST /v1/waitlist/offers/:offerId/decline
// Cascades offer to next player in queue
Expire:
- Offer expires after configured timeout (default: 30 min)
- Automatically cascades to next player
Configuration
Per-course waitlist settings:
interface CourseWaitlistSettings {
enabled: boolean; // Waitlist feature toggle
maxQueueSize: number; // Max entries per course/date (default: 50)
offerExpiryMinutes: number; // Offer timeout (default: 30)
timeWindowMinutes: number; // Match window flexibility (default: 60)
maxNotifications: number; // Max offers per entry (default: 3)
autoExpireStale: boolean; // Expire old entries automatically
staleExpiryMinutes: number; // Stale threshold (default: 1440 = 24h)
autoCascade: boolean; // Auto-cascade declined/expired offers
}
Admin API
// GET /admin/waitlist/:courseId/settings
// PUT /admin/waitlist/:courseId/settings
// DELETE /admin/waitlist/:courseId/settings (reset to defaults)
Matching Algorithm
When a slot becomes available:
SELECT * FROM waitlist_entries
WHERE course_id = :courseId
AND preferred_date = :date
AND status = 'active'
AND player_count <= :availableSlots
AND (
(preferred_time_start <= :slotTime AND preferred_time_end >= :slotTime)
OR ABS(EXTRACT(EPOCH FROM (preferred_time_start - :slotTime))) <= :windowMinutes * 60
)
ORDER BY position ASC
LIMIT 1;
Matching criteria:
- Same course and date
- Player count fits available slots
- Slot time within preferred window (± configured flexibility)
- Entry is active (not already notified/booked)
Cascade Logic
When an offer is declined or expires:
async cascadeOffer(offer: WaitlistOffer): Promise<void> {
// Mark current offer as declined/expired
await this.offerRepo.updateStatus(offer.id, 'declined');
// Find next eligible entry
const nextEntry = await this.findNextMatch(offer.slotId);
if (nextEntry) {
// Create new offer for next player
await this.createOffer(nextEntry.id, offer.slotId);
} else {
// No more matches, slot returns to general availability
await this.releaseSlot(offer.slotId);
}
}
Admin UI
WaitlistSettingsTab
Configure waitlist behavior per course:
- Enable/disable waitlist
- Set queue size limits
- Configure offer expiry
- Set time window flexibility
- Toggle auto-cascade
WaitlistAnalyticsTab
View waitlist performance:
| Metric | Description |
|---|---|
| Total entries | Queue size |
| Active entries | Waiting for offers |
| Offers sent | Total offers created |
| Acceptance rate | Offers accepted / total |
| Avg response time | Time to accept/decline |
| Conversion rate | Entries → bookings |
API Endpoints
Player Endpoints
| Method | Endpoint | Description |
|---|---|---|
POST | /v1/courses/:courseId/waitlist | Join waitlist |
GET | /v1/courses/:courseId/waitlist/me | Get my entries |
DELETE | /v1/waitlist/entries/:id | Leave waitlist |
POST | /v1/waitlist/offers/:id/accept | Accept offer |
POST | /v1/waitlist/offers/:id/decline | Decline offer |
Admin Endpoints
| Method | Endpoint | Description |
|---|---|---|
GET | /admin/waitlist/:courseId/entries | List all entries |
GET | /admin/waitlist/:courseId/settings | Get settings |
PUT | /admin/waitlist/:courseId/settings | Update settings |
GET | /admin/waitlist/:courseId/analytics | Get analytics |
DELETE | /admin/waitlist/entries/:id | Remove entry |
WebSocket Events
| Event | Trigger | Payload |
|---|---|---|
waitlist.joined | Player joins | { entryId, position } |
waitlist.left | Player leaves | { entryId } |
waitlist.offer-created | Offer sent | { offerId, slotId, expiresAt } |
waitlist.offer-accepted | Offer accepted | { offerId, bookingId } |
waitlist.offer-declined | Offer declined | { offerId } |
waitlist.offer-expired | Offer timed out | { offerId } |
Notifications
Offers trigger multi-channel notifications:
- Push (highest priority) — Instant mobile notification
- SMS — Text message with accept link
- Email — Detailed email with slot info
Template: waitlist-offer
Subject: Tee time available at {clubName}!
A tee time has become available:
- Date: {date}
- Time: {time}
- Course: {courseName}
Accept now: {acceptUrl}
This offer expires in {expiryMinutes} minutes.
Expiry Cron
WaitlistExpiryCron runs periodically to:
- Expire stale entries (> 24h old by default)
- Expire timed-out offers
- Cascade expired offers to next player
- Clean up orphaned entries
Schedule: Every 5 minutes
Metrics
| Metric | Description |
|---|---|
waitlist_entries_total | Total entries by status |
waitlist_offers_total | Offers by outcome |
waitlist_offer_response_time | Accept/decline latency |
waitlist_conversion_rate | Entry → booking rate |
waitlist_cascade_depth | Offers per slot before book |
Troubleshooting
Offers Not Sending
- Check waitlist is enabled for course
- Verify entry matches slot criteria
- Check notification service health
- Review
maxNotificationssetting
Slow Cascade
- Check cron job is running
- Verify Redis connectivity
- Review database query performance
- Check for large queue sizes
Duplicate Offers
- Verify offer status checks
- Check for race conditions
- Review transaction isolation