Skip to main content

Waitlist Notification Logic

Overview

The maybeTriggerWaitlist() method in the Timeslot model manages waitlist notifications for class seats, ensuring fair round-robin distribution with a limit of 3 notification attempts per student. Every active waitlist entry gets an opportunity to respond before cycling back to the first student (e.g., A → B → C → D → back to A).

Core Requirements

  1. Maximum 3 Attempts: Each student can receive up to 3 notification attempts
  2. 24-Hour Hold: When notified, a student has 24 hours to accept or decline
  3. 24-Hour Cooldown: A student cannot receive consecutive notifications within 24 hours
  4. Round-Robin Behavior: If a student doesn't respond, the next student gets the opportunity
  5. Re-notification: Students who miss an opportunity get another chance when seats open again (unless they've exhausted their 3 attempts)
  6. Automatic Closure: After 3 ignored attempts, the waitlist entry is automatically closed

Implementation Logic

Step-by-Step Flow

1. Check if seats are available
└─ If no → EXIT
└─ If yes → Continue

2. Get all active waitlists for this timeslot (ordered by ID)

3. Check if anyone currently has an active hold
└─ If yes → EXIT (someone is considering the seat)
└─ If no → Continue

4. For each waitlist entry (in order):

a. Check if student has reached 3 attempts
└─ If yes → Close their waitlist entry and continue to next student
└─ If no → Continue checking

b. Check if they have an expired hold
└─ If yes → Track in expiredHolds array and skip to next student (round-robin)
└─ If no → Continue checking

c. Check if they were notified within the last 24 hours (cooldown)
└─ If yes → Skip to next student (cooldown period not passed)
└─ If no → Continue checking

d. Student is eligible!
└─ Mark as waitListToNotify
└─ BREAK loop (found our candidate)

5. After loop completes:

a. If an eligible student was found:
└─ Set hold_expires_at to now + 24 hours
└─ Save the waitlist entry
└─ Send notification (increments notify_attempts and sets notified_at)
└─ EXIT (do NOT clear expired holds - they mark who already had their turn)

b. If NO eligible student found AND there are expired holds:
└─ Reset all expired holds to null (everyone had their turn, start new cycle)
└─ EXIT

Key Database Fields

notify_attempts (integer)

  • Tracks how many times the student has been notified
  • Incremented by sendNotification() method
  • Maximum value: 3 (after which the waitlist is closed)
  • Reset to 0 when a student accepts a partial fulfillment

hold_expires_at (datetime, nullable)

  • Set to now() + 24 hours when a notification is sent
  • Indicates when the student's opportunity expires
  • Cleared when:
    • Student accepts/rejects the seat
    • Hold expires and round-robin moves to next student
    • Waitlist entry is closed

notified_at (datetime, nullable)

  • Set to now() when a notification is sent
  • Used to enforce 24-hour cooldown between notifications
  • Prevents rapid-fire notifications to the same student

status (enum: WaitlistStatus)

  • ACTIVE: Student is active on the waitlist
  • CLOSED: Student has exhausted attempts or completed the waitlist

Example Scenarios

Scenario 1: Single Student, 3 Attempts

Call 1: Student A notified (attempts=1, hold=now+24h)
└─ A doesn't respond, hold expires

Call 2: Student A's hold expired, cleared
└─ Check cooldown: 25 hours since notification ✓
└─ A notified again (attempts=2, hold=now+24h)
└─ A doesn't respond, hold expires

Call 3: Student A's hold expired, cleared
└─ Check cooldown: 25 hours since notification ✓
└─ A notified again (attempts=3, hold=now+24h)
└─ A doesn't respond, hold expires

Call 4: Student A's hold expired
└─ Check attempts: 3 reached
└─ Close waitlist entry (status=CLOSED)

Scenario 2: Multiple Students, Round-Robin (A → B → C → D → A)

Setup: Four students (A, B, C, D) on the waitlist

Call 1: Student A notified (A: attempts=1, hold=now+24h)
└─ A doesn't respond, hold expires after 24 hours

Call 2 (after A's hold expires):
└─ Check A: Hold expired → track in expiredHolds and skip (round-robin)
└─ Check B: Eligible (no hold, cooldown passed)
└─ Student B notified (B: attempts=1, hold=now+24h)
└─ A's expired hold remains (marks they had their turn)
└─ B doesn't respond, hold expires after 24 hours

Call 3 (after B's hold expires):
└─ Check A: Expired hold → skip (round-robin)
└─ Check B: Hold expired → track in expiredHolds and skip
└─ Check C: Eligible (no hold, cooldown passed)
└─ Student C notified (C: attempts=1, hold=now+24h)
└─ A and B's expired holds remain
└─ C doesn't respond, hold expires after 24 hours

Call 4 (after C's hold expires):
└─ Check A: Expired hold → skip
└─ Check B: Expired hold → skip
└─ Check C: Hold expired → track in expiredHolds and skip
└─ Check D: Eligible
└─ Student D notified (D: attempts=1, hold=now+24h)
└─ A, B, C's expired holds remain
└─ D doesn't respond, hold expires after 24 hours

Call 5 (after D's hold expires):
└─ Check all: A, B, C, D all have expired holds
└─ NO ONE eligible → Reset all expired holds to null
└─ EXIT (cycle complete)

Call 6 (after reset):
└─ Check A: No hold, cooldown passed (>24h since notification) ✓
└─ Student A notified again (A: attempts=2, hold=now+24h)
└─ Cycle repeats from the beginning

Scenario 3: Active Hold Prevents Other Notifications

Call 1: Student A notified (A: attempts=1, hold=now+24h)
└─ Hold is still active (within 24 hours)

Call 2 (within 24 hours):
└─ Check for active holds: A has active hold
└─ EXIT (wait for A to respond or hold to expire)

Scenario 4: Partial Fulfillment

Student A has seats_quantity=2

Call 1: Student A notified (A: attempts=1)
└─ A accepts 1 seat
└─ fulfill(1) called: seats_quantity=1, notify_attempts=0 (reset!)

Call 2: New seat opens
└─ Student A is still first in line
└─ A notified (A: attempts=1, seats_quantity=1)

Scenario 5: All Students Have Expired Holds - Reset Cycle

Setup: Students A, B, and C all have expired holds and are still in cooldown

Call 1:
└─ Check A: Hold expired → track in expiredHolds and skip
└─ Check A: Cooldown NOT passed (<24h) → skip
└─ Check B: Hold expired → track in expiredHolds and skip
└─ Check B: Cooldown NOT passed (<24h) → skip
└─ Check C: Hold expired → track in expiredHolds and skip
└─ Check C: Cooldown NOT passed (<24h) → skip
└─ No eligible student found
└─ Reset all expired holds (A, B, and C) to null
└─ EXIT (wait for cooldown to pass)

Call 2 (after cooldown passes):
└─ Check A: No hold, cooldown passed ✓
└─ Student A notified (A: attempts incremented, hold=now+24h)
└─ New cycle begins

Testing Strategy

The test suite (tests/Unit/WaitlistTest.php) covers:

  1. Basic notification sequence: Student notified up to 3 times
  2. Closure after 3 attempts: Waitlist closed when student exhausts attempts
  3. Round-robin behavior: Multiple students get fair turns
  4. 24-hour cooldown: Prevents notifications within 24 hours
  5. Active hold blocking: No other notifications while someone has active hold
  6. Partial fulfillment: Student stays first in line with remaining seats
  7. Recursive handling: Multiple expired entries handled in sequence
  8. Full 24-hour hold period: Students get complete 24 hours to respond before next student is notified
  9. Four-student full cycle: Verifies A → B → C → D → reset → A notification pattern

Running the Tests

# Run all waitlist tests
vendor/bin/phpunit tests/Unit/WaitlistTest.php

# Run a specific test
vendor/bin/phpunit tests/Unit/WaitlistTest.php --filter test_student_gets_full_24_hours_before_next_notification

Important Notes

  • One notification per call: The method breaks after sending one notification to prevent multiple students being notified simultaneously for the same seat
  • Order matters: Waitlists are processed in ID order (first-come, first-served)
  • No active hold check is critical: Prevents double-booking situations where multiple students think they have the same seat
  • Mail is queued: Uses Mail::to()->queue() to avoid blocking execution
  • Observer disabled in tests: Tests disable the TimeslotObserver to prevent unintended side effects
  • Model: app/Models/Timeslot.php - Contains maybeTriggerWaitlist() method
  • Model: app/Models/Waitlist.php - Contains waitlist logic and sendNotification() method
  • Tests: tests/Unit/WaitlistTest.php - Comprehensive test coverage
  • Factory: database/factories/WaitlistFactory.php - Test data generation
  • Enum: app/Enums/WaitlistStatus.php - Status constants
  • Mail: app/Mail/WaitListNotification.php - Email notification template

Triggering the Waitlist

The maybeTriggerWaitlist() method is typically called when:

  • A subscription is cancelled or expires
  • A seat becomes available in a timeslot
  • Administrative actions free up capacity

The TimeslotObserver may automatically trigger this method on certain model events.