Skip to content

feat: events module#309

Draft
danielhe4rt wants to merge 10 commits into
4.xfrom
feat/events
Draft

feat: events module#309
danielhe4rt wants to merge 10 commits into
4.xfrom
feat/events

Conversation

@danielhe4rt
Copy link
Copy Markdown
Contributor

No description provided.

danielhe4rt and others added 7 commits June 4, 2026 18:00
- 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.
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.

5 participants