Skip to content

feat(events): waitlist and capacity enforcement#294

Merged
danielhe4rt merged 5 commits into
he4rt:feat/eventsfrom
BrunaDomingues:feat/events-enrollment-capacity-waitlist
Jun 4, 2026
Merged

feat(events): waitlist and capacity enforcement#294
danielhe4rt merged 5 commits into
he4rt:feat/eventsfrom
BrunaDomingues:feat/events-enrollment-capacity-waitlist

Conversation

@BrunaDomingues
Copy link
Copy Markdown

Summary

Completes atomic capacity enforcement and waitlist (FIFO) on top of the RSVP flow (#275): when an event is full, participants are waitlisted if the policy allows, or rejected with 422 otherwise. Occupied seats count confirmed, checked_in, and attended enrollments; audit trail and domain events are emitted on enroll.

Capacity & waitlist (EnrollUserAction)

  • scopeActive() on Enrollment: confirmed + checked_in + attended occupy capacity (replaces counting only confirmed in capacity resolution)
  • Inside DB::transaction, lockForUpdate() on event and enrollment policy (unchanged from feat(events): rsvp enrollment end-to-end #275; ensures fresh state under concurrent enrollments)
  • If capacity is null → always confirmed
  • If active count < capacityconfirmed + EnrollmentConfirmed
  • If active count >= capacity and has_waitlist=truewaitlisted with waitlist_position = max(position) + 1 + EnrollmentWaitlisted (no EnrollmentConfirmed)
  • If active count >= capacity and has_waitlist=falseEnrollmentException::eventFull() (HTTP 422)
  • EnrollmentTransition recorded for every enroll (from_status=null, to_status=<initial>, triggered_by=user)

Domain event

  • EnrollmentWaitlisted: implements ShouldDispatchAfterCommit (payload: enrollment_id, event_id, user_id, waitlist_position) — no listener in this slice

App panel (participant)

  • Event detail: persistent copy “You are on the waitlist (position X)” when waitlisted
  • Event detail: “This event is full” when at capacity without waitlist (no Confirm Presence button)
  • Success notification uses waitlist message with position after RSVP
  • canConfirmPresence / isEventFull use active() scope (aligned with backend)

Admin

  • Enrollments relation manager: waitlist_position column (toggleable)
  • Status filter unchanged (confirmed, waitlisted, etc.)

Scopes (Enrollment model)

  • scopeConfirmed(), scopeWaitlisted(), scopeActive()active = capacity-occupying statuses only

Architecture

Events module └── Enrollment domain ├── EnrollUserAction (capacity via active() + lockForUpdate) ├── EnrollmentWaitlisted (domain event) └── Enrollment ├── scopeConfirmed / scopeWaitlisted / scopeActive

App panel └── EventDetail (+ waitlist copy, event full state)

Admin panel └── EnrollmentsRelationManager (+ waitlist_position column)

Files (high level)

Area Count / notes
Actions EnrollUserAction (active count, dispatch EnrollmentWaitlisted)
Domain events EnrollmentWaitlisted
Models Enrollment (scopeActive fix)
Enums EnrollmentStatus::getResponseMessage(?waitlistPosition)
Lang en / pt_BR pages (waitlist position, event full)
App panel EventDetail, event-detail.blade.php
Admin EnrollmentsRelationManager
Tests EnrollmentScopeTest, EnrollUserActionTest (+ capacity/FIFO/422/unlimited), RsvpEnrollmentTest (+ UI)

Out of scope (future slices)

  • Promote from waitlist on cancellation (FIFO promotion job/action)
  • Notifications listener for EnrollmentWaitlisted
  • True parallel-process concurrency test (race on last seat)
  • Gamification listener for EnrollmentConfirmed

Closes #241
Parent: #237
Blocked by / builds on: #240, #275


Test plan

  • php artisan test --filter=EnrollmentScopeTest
  • php artisan test --filter=EnrollUserActionTest
  • php artisan test --filter=RsvpEnrollmentTest
  • ./vendor/bin/pint --test

Admin

  • Edit event with RSVP policy → set capacity and has_waitlist
  • Open Enrollments tab → confirm Waitlist column and filter by waitlisted

App — capacity / waitlist

  • Event without capacity limit → Confirm PresenceConfirmed
  • Fill to capacity with waitlist → next enroll → Waitlisted badge + “position X” on detail + success notification with position
  • Fill to capacity without waitlist → “This event is full”, no Confirm Presence button
  • Existing enrollment in checked_in occupies last seat → new user → waitlisted (validates active() scope)

App — API / action edge cases

  • Enroll when full without waitlist → EnrollmentException / 422 (event_full)
  • Multiple enrollments beyond capacity with waitlist → positions 1, 2, … (FIFO)

Treat confirmed, checked-in, and attended enrollments as occupying seats when resolving RSVP capacity and waitlist placement.
Emit after-commit event when RSVP enrollment is placed on the waitlist, for future notifications.
Display waitlist position after RSVP and when the event has no remaining seats without a waitlist.
Assert 422 on full events, fifo waitlist positions, and that active seats never exceed capacity under rapid enrollments.
@BrunaDomingues BrunaDomingues self-assigned this May 29, 2026
Copy link
Copy Markdown

@GabrielFVDev GabrielFVDev left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM

@danielhe4rt danielhe4rt merged commit 0063f21 into he4rt:feat/events Jun 4, 2026
1 check passed
danielhe4rt pushed a commit that referenced this pull request Jun 4, 2026
## Summary

Completes **atomic capacity enforcement** and **waitlist (FIFO)** on top
of the RSVP flow (#275): when an event is full, participants are
waitlisted if the policy allows, or rejected with `422` otherwise.
Occupied seats count `confirmed`, `checked_in`, and `attended`
enrollments; audit trail and domain events are emitted on enroll.

### Capacity & waitlist (`EnrollUserAction`)

- **`scopeActive()`** on `Enrollment`: `confirmed` + `checked_in` +
`attended` occupy capacity (replaces counting only `confirmed` in
capacity resolution)
- Inside **`DB::transaction`**, `lockForUpdate()` on **event** and
**enrollment policy** (unchanged from #275; ensures fresh state under
concurrent enrollments)
- If `capacity` is `null` → always **`confirmed`**
- If `active` count `< capacity` → **`confirmed`** +
`EnrollmentConfirmed`
- If `active` count `>= capacity` and **`has_waitlist=true`** →
**`waitlisted`** with `waitlist_position = max(position) + 1` +
`EnrollmentWaitlisted` (no `EnrollmentConfirmed`)
- If `active` count `>= capacity` and **`has_waitlist=false`** →
**`EnrollmentException::eventFull()`** (HTTP 422)
- **`EnrollmentTransition`** recorded for every enroll
(`from_status=null`, `to_status=<initial>`, `triggered_by=user`)

### Domain event

- **`EnrollmentWaitlisted`**: implements `ShouldDispatchAfterCommit`
(payload: `enrollment_id`, `event_id`, `user_id`, `waitlist_position`) —
no listener in this slice

### App panel (participant)

- **Event detail**: persistent copy **“You are on the waitlist (position
X)”** when `waitlisted`
- **Event detail**: **“This event is full”** when at capacity without
waitlist (no **Confirm Presence** button)
- Success notification uses waitlist message **with position** after
RSVP
- **`canConfirmPresence` / `isEventFull`** use `active()` scope (aligned
with backend)

### Admin

- **Enrollments** relation manager: **`waitlist_position`** column
(toggleable)
- Status filter unchanged (confirmed, waitlisted, etc.)

### Scopes (`Enrollment` model)

- **`scopeConfirmed()`**, **`scopeWaitlisted()`**, **`scopeActive()`** —
`active` = capacity-occupying statuses only

### Architecture

Events module └── Enrollment domain ├── EnrollUserAction (capacity via
active() + lockForUpdate) ├── EnrollmentWaitlisted (domain event) └──
Enrollment ├── scopeConfirmed / scopeWaitlisted / scopeActive

App panel └── EventDetail (+ waitlist copy, event full state)

Admin panel └── EnrollmentsRelationManager (+ waitlist_position column)


### Files (high level)

| Area | Count / notes |
|------|----------------|
| Actions | `EnrollUserAction` (active count, dispatch
`EnrollmentWaitlisted`) |
| Domain events | `EnrollmentWaitlisted` |
| Models | `Enrollment` (`scopeActive` fix) |
| Enums | `EnrollmentStatus::getResponseMessage(?waitlistPosition)` |
| Lang | `en` / `pt_BR` `pages` (waitlist position, event full) |
| App panel | `EventDetail`, `event-detail.blade.php` |
| Admin | `EnrollmentsRelationManager` |
| Tests | `EnrollmentScopeTest`, `EnrollUserActionTest` (+
capacity/FIFO/422/unlimited), `RsvpEnrollmentTest` (+ UI) |

### Out of scope (future slices)

- Promote from waitlist on cancellation (FIFO promotion job/action)
- Notifications listener for `EnrollmentWaitlisted`
- True parallel-process concurrency test (race on last seat)
- Gamification listener for `EnrollmentConfirmed`

Closes #241  
Parent: #237  
Blocked by / builds on: #240, #275

---

## Test plan

- `php artisan test --filter=EnrollmentScopeTest`
- `php artisan test --filter=EnrollUserActionTest`
- `php artisan test --filter=RsvpEnrollmentTest`
- `./vendor/bin/pint --test`

### Admin

- Edit event with RSVP policy → set `capacity` and `has_waitlist`
- Open **Enrollments** tab → confirm **Waitlist** column and filter by
`waitlisted`

### App — capacity / waitlist

- Event **without** capacity limit → **Confirm Presence** →
**Confirmed**
- Fill to capacity **with** waitlist → next enroll → **Waitlisted**
badge + **“position X”** on detail + success notification with position
- Fill to capacity **without** waitlist → **“This event is full”**, no
**Confirm Presence** button
- Existing enrollment in **`checked_in`** occupies last seat → new user
→ **waitlisted** (validates `active()` scope)

### App — API / action edge cases

- Enroll when full without waitlist → `EnrollmentException` / 422
(`event_full`)
- Multiple enrollments beyond capacity with waitlist → positions **1, 2,
…** (FIFO)
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

7 participants