Skip to main content

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.ts
  • libs/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:

  1. Finds matching waitlist entries (same course, overlapping time window)
  2. Orders by position (FIFO)
  3. Creates offer for first matching entry
  4. Sends notification (push, SMS, email)
  5. 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:

  1. Same course and date
  2. Player count fits available slots
  3. Slot time within preferred window (± configured flexibility)
  4. 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:

MetricDescription
Total entriesQueue size
Active entriesWaiting for offers
Offers sentTotal offers created
Acceptance rateOffers accepted / total
Avg response timeTime to accept/decline
Conversion rateEntries → bookings

API Endpoints

Player Endpoints

MethodEndpointDescription
POST/v1/courses/:courseId/waitlistJoin waitlist
GET/v1/courses/:courseId/waitlist/meGet my entries
DELETE/v1/waitlist/entries/:idLeave waitlist
POST/v1/waitlist/offers/:id/acceptAccept offer
POST/v1/waitlist/offers/:id/declineDecline offer

Admin Endpoints

MethodEndpointDescription
GET/admin/waitlist/:courseId/entriesList all entries
GET/admin/waitlist/:courseId/settingsGet settings
PUT/admin/waitlist/:courseId/settingsUpdate settings
GET/admin/waitlist/:courseId/analyticsGet analytics
DELETE/admin/waitlist/entries/:idRemove entry

WebSocket Events

EventTriggerPayload
waitlist.joinedPlayer joins{ entryId, position }
waitlist.leftPlayer leaves{ entryId }
waitlist.offer-createdOffer sent{ offerId, slotId, expiresAt }
waitlist.offer-acceptedOffer accepted{ offerId, bookingId }
waitlist.offer-declinedOffer declined{ offerId }
waitlist.offer-expiredOffer timed out{ offerId }

Notifications

Offers trigger multi-channel notifications:

  1. Push (highest priority) — Instant mobile notification
  2. SMS — Text message with accept link
  3. 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:

  1. Expire stale entries (> 24h old by default)
  2. Expire timed-out offers
  3. Cascade expired offers to next player
  4. Clean up orphaned entries

Schedule: Every 5 minutes

Metrics

MetricDescription
waitlist_entries_totalTotal entries by status
waitlist_offers_totalOffers by outcome
waitlist_offer_response_timeAccept/decline latency
waitlist_conversion_rateEntry → booking rate
waitlist_cascade_depthOffers per slot before book

Troubleshooting

Offers Not Sending

  1. Check waitlist is enabled for course
  2. Verify entry matches slot criteria
  3. Check notification service health
  4. Review maxNotifications setting

Slow Cascade

  1. Check cron job is running
  2. Verify Redis connectivity
  3. Review database query performance
  4. Check for large queue sizes

Duplicate Offers

  1. Verify offer status checks
  2. Check for race conditions
  3. Review transaction isolation