feat: events module#309
Draft
danielhe4rt wants to merge 10 commits into
Draft
Conversation
- Events domain: `Event` + `EventType` com regras de enrollment e enums de status/attendance - Enrollment flow: `Enrollment`, `EnrollmentPolicy`, `EnrollmentTransition` com factories e testes - Check-in flow: `CheckIn`, `CheckInCode`, `QrToken` + `CheckInMethod` - Admin panel: `EventResource` com pages de create/edit/list, relation manager de enrollments, melhorias em form/infolist/table - Cleanup/docs: ajuste de legado, ADRs e atualizacao de context map Events module |-- Event domain (EventType + Event) |-- Enrollment domain (EnrollmentMethod/Status/AttendanceRequirement + Enrollment/Policy/Transition) `-- Check-in domain (CheckInMethod + CheckIn/CheckInCode/QrToken) Admin panel `-- Event management (EventResource + pages + relation manager + form/infolist/table) - 8 migrations (inclui drop do legado) - 7 models e 5 enums - 7 factories - 3 testes (feature + unit) - Recursos do painel admin para eventos - php artisan migrate - php artisan test --filter=EventFactoriesTest - php artisan test --filter=EventResourceTest - php artisan test --filter=EnrollmentStatusTest --------- Co-authored-by: danielhe4rt <danielhe4rt@gmail.com>
Implements the **Events** bounded context (foundation) and the **RSVP enrollment** flow end-to-end: participant confirms presence → enrollment created as `confirmed` or `waitlisted` (when at capacity with waitlist) → transition recorded in audit trail → enrollment visible in App and Admin panels. - **`EnrollUserAction`**: validates event (published, upcoming, RSVP/rsvp_checkin), resolves initial status (`confirmed` vs `waitlisted` vs rejected when full), creates enrollment, writes `EnrollmentTransition` (`from_status=null`, `to_status=<initial>`, `triggered_by=user`), dispatches `EnrollmentConfirmed` only when status is `confirmed` — all inside `DB::transaction` with `lockForUpdate` on policy and duplicate handling via `UniqueConstraintViolationException` - **`EnrollUserDTO`**: input object built from `Event` + `User` (status, dates, policy capacity/waitlist, XP reward) - **Domain event**: `EnrollmentConfirmed` implements `ShouldDispatchAfterCommit` (payload: `enrollment_id`, `event_id`, `user_id`, `xpRewardOnConfirmed`) — no listener in this slice - **`EnrollmentException`**: `alreadyEnrolled`, `eventPast`, `eventNotActive`, `invalidEnrollmentMethod`, `eventFull` - When `enrollment_policy.capacity` is set and confirmed count ≥ capacity: - **`has_waitlist=true`** → enrollment `waitlisted` with `waitlist_position` (no `EnrollmentConfirmed`) - **`has_waitlist=false`** → `EnrollmentException::eventFull()` - App UI: **Confirm Presence** hidden when full without waitlist; success notification differs for confirmed vs waitlisted; **My Events** shows status badges (including waitlisted) - Events listing (`EventsPage` / `EventsList`) — published upcoming events - Event detail (`EventPage` / `EventDetail`) — **Confirm Presence** button - My Events (`MyEventsPage` / `MyEventsList`) — enrollments with status badges; enrolled users can open detail for `completed` events - **`EventResource`**: form/infolist/table aligned with `status` column (`EventStatus` enum) instead of legacy `active` toggle - Unique event **slug per tenant** validation on create/edit - Read-only **Enrollments** relation manager Schema, models (`Event`, `Enrollment`, `EnrollmentPolicy`, `EnrollmentTransition`, check-in models), enums, factories, migrations, ADRs, `CONTEXT.md`, admin resource scaffolding, unit tests for enums. Events module ├── Enrollment domain │ ├── EnrollUserAction │ ├── EnrollUserDTO │ ├── EnrollmentConfirmed (domain event) │ └── EnrollmentException ├── Event domain (scopes: published, upcoming, viewableByParticipant) └── Check-in models (schema only — no flow in this PR) App panel ├── EventsPage / EventsList ├── EventPage / EventDetail (+ Confirm Presence) └── MyEventsPage / MyEventsList Admin panel └── EventResource (+ EnrollmentsRelationManager) | Area | Count / notes | |------|----------------| | Migrations | 7 (+ drop legacy tables) | | Actions | `EnrollUserAction` | | DTOs | `EnrollUserDTO` | | Domain events | `EnrollmentConfirmed` | | Exceptions + lang | `EnrollmentException`, en/pt_BR exceptions + pages + enums | | App panel | 3 Filament pages, 3 Livewire components, 7 Blade views (incl. partial) | | Admin | `EventResource` schemas + relation manager | | Tests | `EnrollUserActionTest`, `RsvpEnrollmentTest`, `EventResourceTest`, `EventFactoriesTest`, enum unit tests | | Docs | 5 ADRs, `CONTEXT.md`, `CONTEXT-MAP.md` | - Gamification listener for XP (`EnrollmentConfirmed` subscriber) - Application enrollment flow (approve/reject) - Enrollment management actions in admin (promote from waitlist, cancel, etc.) - Check-in / QR flows - Bot integration Closes #240 Parent: #237 Related foundation PR: #249 (included in this branch when targeting `4.x`) --- - [ ] `php artisan migrate` - [ ] `php artisan test --filter=EnrollUserActionTest` - [ ] `php artisan test --filter=RsvpEnrollmentTest` - [ ] `php artisan test --filter=EventResourceTest` - [ ] `php artisan test --filter=EventFactoriesTest` - [ ] `./vendor/bin/pint --test` - [ ] Create event: **Published**, future `starts_at`, enrollment method **RSVP** - [ ] Set `capacity` and toggle `has_waitlist` on enrollment policy - [ ] Edit event → **Enrollments** tab shows participants - [ ] `/app/{tenant}/events` — event appears in list - [ ] Open event → **Confirm Presence** → success notification - [ ] `/app/{tenant}/my-events` — enrollment shows **Confirmed** badge - [ ] Fill event to capacity **without** waitlist → **Confirm Presence** hidden; direct enroll returns `event_full` - [ ] Fill event to capacity **with** waitlist → button still visible; enroll creates **Waitlisted** badge and waitlist notification - [ ] Duplicate RSVP → error notification (`already_enrolled`) - [ ] Past event → no **Confirm Presence** button - [ ] Draft / non-RSVP event → not enrollable via RSVP - [ ] Enrolled user on **completed** event → detail page still accessible --------- Co-authored-by: Cursor <cursoragent@cursor.com> Co-authored-by: GabrielFV <gabrielvasco906@gmail.com> Co-authored-by: Daniel Reis <danielhe4rt@gmail.com>
## Summary ### Solve: #244 Implements manual check-in: organizers mark participants as present via the Admin panel. Creates a check-in record and transitions enrollment status. Covers single-day and multi-day events with duplicate prevention, date range validation, audit trail, and bulk check-in. ## What was implemented ### `CheckInAction` — Core action - Accepts enrollment, method (enum), payload (array), event_date (Carbon date) - Validates enrollment status is `confirmed` or `checked_in` - Validates event_date is within the event's date range - Validates no existing check-in for the same enrollment + date - Creates `events_check_ins` record with `method=manual`, `payload={actor_user_id}`, `event_date`, `checked_in_at=now` - First check-in transitions enrollment from `confirmed` → `checked_in` - Subsequent check-ins (multi-day) only create the check-in record - Records audit trail (`triggered_by=admin`, `actor_user_id=organizer`) - Dispatches `ParticipantCheckedIn` domain event (`ShouldDispatchAfterCommit`) ### Admin panel — `EnrollmentsRelationManager` - "Check In" action on enrollment records (visible when status is `confirmed` or `checked_in`) - Date picker defaulting to today, constrained to event date range - Bulk "Check In Selected" action for multiple participants - Check-in history column per enrollment ### `TransitionEnrollmentAction` Extracted reusable action for enrollment status transitions, used by the check-in flow and available for future transitions (`attended`, `cancelled`). ### `CheckInException` Check-in validation errors moved to the CheckIn module boundary (`CheckIn/Exceptions/`) instead of leaking into `EnrollmentException`. ## Acceptance criteria - [x] Organizer can mark a participant as checked in from Admin panel - [x] Check-in record created in `events_check_ins` with method=manual and correct date - [x] First check-in transitions enrollment from `confirmed` → `checked_in` - [x] Subsequent check-ins (multi-day) create records without re-transitioning - [x] Duplicate check-in on same date is rejected - [x] Check-in outside event date range is rejected - [x] Bulk check-in action works for multiple participants - [x] `ParticipantCheckedIn` domain event is dispatched - [x] Transition audit trail records the organizer - [x] Feature tests: single, multi-day, duplicate, date range, status validation - [x] Pint passes ## Files changed ### New (8 files) | File | Purpose | |---|---| | `app-modules/events/src/CheckIn/Actions/CheckInAction.php` | Core check-in action (validate, create record, transition, dispatch) | | `app-modules/events/src/CheckIn/Exceptions/CheckInException.php` | Check-in specific exceptions (invalid status, outside range, duplicate, invalid actor) | | `app-modules/events/src/CheckIn/Events/ParticipantCheckedIn.php` | Domain event dispatched after check-in (`ShouldDispatchAfterCommit`) | | `app-modules/events/src/Enrollment/Actions/TransitionEnrollmentAction.php` | Reusable action for enrollment status transitions with audit trail | | `app-modules/events/lang/en/check_in.php` | English check-in exception messages | | `app-modules/events/lang/pt_BR/check_in.php` | Brazilian Portuguese check-in exception messages | | `app-modules/events/database/factories/CheckInFactory.php` | Factory for CheckIn model | | `app-modules/events/tests/Feature/CheckInActionTest.php` | 6 feature tests (single, multi-day, duplicate, date range, status, actor) | ### Modified (11 files) | File | What changed | |---|---| | `app-modules/events/src/CheckIn/Models/CheckIn.php` | Added `checked_in_at` to fillable, casts, phpdocs | | `app-modules/events/database/migrations/2026_05_22_000001_add_checked_in_at_to_events_check_ins_table.php` | Added `checked_in_at` column to `events_check_ins` | | `app-modules/events/src/Enrollment/Models/Enrollment.php` | Added `checkIns()` hasMany relationship | | `app-modules/events/src/Enrollment/Enums/EnrollmentStatus.php` | Added `CheckedIn` case with transition rules | | `app-modules/events/src/Enrollment/Exceptions/EnrollmentException.php` | Added `invalidTransition()`; removed check-in exceptions (moved to CheckInException) | | `app-modules/events/src/Enrollment/Actions/EnrollUserAction.php` | Import adjustment | | `app-modules/events/src/Event/Models/Event.php` | Relationship additions for enrollment policy | | `app-modules/events/lang/en/exceptions.php` | Added `invalid_transition` key; removed check-in keys | | `app-modules/events/lang/pt_BR/exceptions.php` | Added `invalid_transition` key; removed check-in keys | | `app-modules/events/tests/Feature/EventResourceTest.php` | 5 tests: Filament CRUD, single + bulk check-in, check-in history | | `app-modules/panel-admin/src/Filament/Resources/Events/RelationManagers/EnrollmentsRelationManager.php` | "Check In" action, bulk "Check In Selected", check-in history column, date picker |
## 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)
Closes #245 Implements self-service check-in via short-lived numeric codes announced by the organizer. Participants enter the code on the event detail page to check themselves in without organizer intervention. **Domain layer** - Added `revoked_at` column to `events_check_in_codes` (soft revoke, per ADR-0003) - Created `NumericCodeCheckInDTO` carrying enrollment, code, and eventDate - Created `NumericCodeCheckInAction` with 5-step validation pipeline: existence → date binding → expiry/revoked → max uses → atomic increment + delegation to core `CheckInAction` - Added 4 new `CheckInException` factory methods: `invalidCheckInCode`, `checkInCodeExpired`, `checkInCodeExhausted`, `checkInCodeWrongDate` - Translated error messages in en/pt_BR **Admin panel** - Added `CheckInCodesRelationManager` on the Event edit page - Generate codes with 4 or 6 digit length selector, auto-generated read-only code - `event_date` defaults to the event's starts_at, constrained to the event date range - Revoke action with confirmation (sets `revoked_at`) **App panel** - Created `NumericCodeCheckIn` Livewire component embedded in the event detail page - Code input visible when enrollment is `confirmed` or `checked_in` - Validation errors surface as inline messages below the input **Tests** - 8 feature tests: valid check-in, invalid code, wrong date, expired, revoked, max uses exhausted, atomic increment, outside event range - Pint: passed - PHPStan: passed (0 errors) - All events tests: 86/86 passed (0 regressions) - ADR-0006 documents all design decisions --------- Co-authored-by: davi.oliveira <davi.oliveira@ernestoborges.com.br> Co-authored-by: danielhe4rt <danielhe4rt@gmail.com>
…#298) Implements end-to-end QR code check-in for community events. When an enrollment reaches `confirmed` status, the system automatically generates a unique QR token. Organizers scan tokens via the admin panel to check participants in; participants view and share their QR code from the app panel. Closes #237 — blocked on #240 (RSVP enrollment, already merged). --- - `bacon/bacon-qr-code` — SVG QR generation via `BaconQrCode\Writer` + `SvgRenderer`. Chosen over `simplesoftwareio/simple-qrcode` due to PHP 8.4 GD extension incompatibility and Filament auto-discovery conflicts. --- | File | Role | | --- | --- | | `database/migrations/…create_events_qr_tokens_table.php` | `events_qr_tokens` table: `enrollment_id`, `token` (unique), `expires_at` (nullable) | | `src/CheckIn/Actions/GenerateQrTokenAction.php` | Creates token (64-char URL-safe hex via `bin2hex(random_bytes(32))`). Idempotent — skips if token already exists. | | `src/CheckIn/Actions/QrCheckInAction.php` | Validates token: exists, belongs to event enrollment, enrollment is `confirmed`/`checked_in`, not expired, not duplicate for today. Delegates to existing `CheckInAction` with `method=qr_code`. | | `src/CheckIn/DTOs/QrCheckInDTO.php` | Input DTO: `token`, `event_date` | | `src/CheckIn/Listeners/GenerateQrTokenOnConfirmed.php` | Listens to `EnrollmentConfirmed` domain event; dispatches `GenerateQrTokenAction`. Implements `ShouldDispatchAfterCommit` for transaction safety. | | `src/EventsServiceProvider.php` | Registers `EnrollmentConfirmed → GenerateQrTokenOnConfirmed` listener | | `src/CheckIn/Exceptions/CheckInException.php` | Adds `qrTokenNotFound`, `qrTokenExpired` factory methods | | `lang/en/check_in.php`, `lang/pt_BR/check_in.php` | i18n strings for new error states | --- - `EditEvent` resource: - Adds **Scan QR** header action with continuous-scan modal. - After each scan, the modal auto-reopens for rapid sequential check-ins. - Reopen is triggered via a Livewire event (`dispatch` + `#[On]`) so it runs in a separate request after Filament's `unmountAction` lifecycle completes. - Avoids calling `mountAction` directly inside `->after()` because Filament cleanup silently resets it. - Field uses `autofocus` so cursor lands on the token input every reopen. --- - `EventDetail` Livewire component: - Renders enrollment QR code as inline SVG when status is: - `confirmed` - `checked_in` - Adds: - copy-token button - download-SVG button - computed check-in history list - "present today" badge based on today's check-in records --- 10 feature tests covering all acceptance criteria: | Test | Scenario | | --- | --- | | `generates_qr_token_on_confirmed_enrollment` | Token created on `EnrollmentConfirmed` event | | `does_not_regenerate_existing_token` | Idempotency: second confirmation doesn't overwrite | | `valid_qr_scan_creates_check_in` | Happy path, `method=qr_code`, payload has token | | `dispatches_participant_checked_in_event` | Domain event fired on successful scan | | `rejects_unknown_token` | `qrTokenNotFound` exception | | `rejects_expired_token` | `expires_at < now()` → `qrTokenExpired` | | `rejects_duplicate_scan_same_day` | Already checked in today → `alreadyCheckedIn` | | `rejects_cancelled_enrollment_token` | Status check gates QR scan | | `same_token_creates_new_check_in_each_day` | Multi-day reuse creates separate records | | `listener_generates_token_after_commit` | `ShouldDispatchAfterCommit` respected in test | --- - [x] QR token is auto-generated when enrollment reaches `confirmed` status - [x] Token is unique and URL-safe - [x] Organizer can scan/input token and check participant in - [x] Same token works across multiple event days (creates new check-in per day) - [x] Expired token is rejected - [x] Duplicate scan on same day is rejected - [x] Cancelled enrollment's token is rejected on scan (status check) - [x] Participant sees their QR code in App panel - [x] Check-in record stores `method=qr_code` with token in payload - [x] `ParticipantCheckedIn` domain event dispatched - [x] Feature tests covering: - token generation - valid scan - expired token - duplicate scan - cancelled enrollment scan - multi-day reuse - [x] Pint passes --- ```bash make test vendor/bin/pest app-modules/events/tests/Feature/CheckIn/QrCheckInActionTest.php make pint ``` Results: - 74 tests passing - Pint clean --- | SHA | Message | | --- | --- | | `920f0c8` | `dep(events): adiciona biblioteca bacon/bacon-qr-code para geração de QR SVG` | | `1db9d28` | `feat(events/check-in): implementa geração e validação de token QR por inscrição` | | `1bd4eb1` | `feat(events): registra listener GenerateQrTokenOnConfirmed no EventsServiceProvider` | | `22b920f` | `feat(panel-admin): adiciona ação de scan QR contínuo na página de edição de evento` | | `d6be299` | `feat(panel-app): exibe QR code, histórico de check-ins e badge de presença hoje` | | `ca9da88` | `test(events/check-in): adiciona suite de testes para geração de token QR e check-in` | | `1607145` | `fix(panel-admin): corrige reabertura contínua do modal de scan QR` | --------- Co-authored-by: Daniel Reis <danielhe4rt@gmail.com>
- EditEvent: drop unnecessary nullsafe on enrollment->user->name (relation is non-null). - EventDetail/NumericCodeCheckIn: add @property-read annotations so PHPStan understands the #[Computed] Livewire properties (matches ProfilePage).
The events table declared tenant_id as foreignId() (bigint) while tenants.id is a uuid, so migrate:fresh failed with a datatype-mismatch on the FK constraint. Switch it to foreignUuid() to match the uuid primary key the Event model already expects. - CheckIn: @Property checked_in_at is nullable -> Carbon|null (phpdoc sync). - EnrollUserActionTest: drop redundant uses(RefreshDatabase) that collided with the project-wide LazilyRefreshDatabase and fataled the whole suite. - EventResourceTest: recycle the acting tenant and loadTable() on deferred tables so factory events are visible to the tenant-scoped list.
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.
No description provided.