feat(events): waitlist and capacity enforcement#294
Merged
danielhe4rt merged 5 commits intoJun 4, 2026
Merged
Conversation
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.
YuriSouzaDev
approved these changes
May 31, 2026
danielhe4rt
approved these changes
Jun 4, 2026
Clintonrocha98
approved these changes
Jun 4, 2026
gvieira18
approved these changes
Jun 4, 2026
thalesmengue
approved these changes
Jun 4, 2026
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)
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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
422otherwise. Occupied seats countconfirmed,checked_in, andattendedenrollments; audit trail and domain events are emitted on enroll.Capacity & waitlist (
EnrollUserAction)scopeActive()onEnrollment:confirmed+checked_in+attendedoccupy capacity (replaces counting onlyconfirmedin capacity resolution)DB::transaction,lockForUpdate()on event and enrollment policy (unchanged from feat(events): rsvp enrollment end-to-end #275; ensures fresh state under concurrent enrollments)capacityisnull→ alwaysconfirmedactivecount< capacity→confirmed+EnrollmentConfirmedactivecount>= capacityandhas_waitlist=true→waitlistedwithwaitlist_position = max(position) + 1+EnrollmentWaitlisted(noEnrollmentConfirmed)activecount>= capacityandhas_waitlist=false→EnrollmentException::eventFull()(HTTP 422)EnrollmentTransitionrecorded for every enroll (from_status=null,to_status=<initial>,triggered_by=user)Domain event
EnrollmentWaitlisted: implementsShouldDispatchAfterCommit(payload:enrollment_id,event_id,user_id,waitlist_position) — no listener in this sliceApp panel (participant)
waitlistedcanConfirmPresence/isEventFulluseactive()scope (aligned with backend)Admin
waitlist_positioncolumn (toggleable)Scopes (
Enrollmentmodel)scopeConfirmed(),scopeWaitlisted(),scopeActive()—active= capacity-occupying statuses onlyArchitecture
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)
EnrollUserAction(active count, dispatchEnrollmentWaitlisted)EnrollmentWaitlistedEnrollment(scopeActivefix)EnrollmentStatus::getResponseMessage(?waitlistPosition)en/pt_BRpages(waitlist position, event full)EventDetail,event-detail.blade.phpEnrollmentsRelationManagerEnrollmentScopeTest,EnrollUserActionTest(+ capacity/FIFO/422/unlimited),RsvpEnrollmentTest(+ UI)Out of scope (future slices)
EnrollmentWaitlistedEnrollmentConfirmedCloses #241
Parent: #237
Blocked by / builds on: #240, #275
Test plan
php artisan test --filter=EnrollmentScopeTestphp artisan test --filter=EnrollUserActionTestphp artisan test --filter=RsvpEnrollmentTest./vendor/bin/pint --testAdmin
capacityandhas_waitlistwaitlistedApp — capacity / waitlist
checked_inoccupies last seat → new user → waitlisted (validatesactive()scope)App — API / action edge cases
EnrollmentException/ 422 (event_full)