From 0ec3d42ba19c8576f3e1ab3ef37d0ac6f397bdf2 Mon Sep 17 00:00:00 2001
From: danielhe4rt
Date: Sat, 16 May 2026 21:13:40 -0300
Subject: [PATCH 01/18] docs(events): adrs and context map
---
CONTEXT-MAP.md | 40 +++++++---
app-modules/events/CONTEXT.md | 74 +++++++++++++++++++
.../0001-state-machine-as-enum-not-package.md | 20 +++++
.../0002-xp-via-domain-events-not-ledger.md | 24 ++++++
...-database-triggers-all-logic-in-actions.md | 26 +++++++
...-check-in-as-separate-table-one-to-many.md | 21 ++++++
...-communicates-via-domain-events-not-api.md | 23 ++++++
7 files changed, 217 insertions(+), 11 deletions(-)
create mode 100644 app-modules/events/CONTEXT.md
create mode 100644 app-modules/events/docs/adr/0001-state-machine-as-enum-not-package.md
create mode 100644 app-modules/events/docs/adr/0002-xp-via-domain-events-not-ledger.md
create mode 100644 app-modules/events/docs/adr/0003-no-database-triggers-all-logic-in-actions.md
create mode 100644 app-modules/events/docs/adr/0004-check-in-as-separate-table-one-to-many.md
create mode 100644 app-modules/events/docs/adr/0005-bot-communicates-via-domain-events-not-api.md
diff --git a/CONTEXT-MAP.md b/CONTEXT-MAP.md
index ca3be0e7d..17040d848 100644
--- a/CONTEXT-MAP.md
+++ b/CONTEXT-MAP.md
@@ -4,12 +4,14 @@ This is a modular monorepo (`internachi/modular`). Each bounded context lives un
## Contexts
-| Context | Path | Description |
-| ------------------- | ---------------------------------- | --------------------------------------------------------------------------- |
-| Moderation | `app-modules/moderation/` | Content moderation pipeline — classification, routing, enforcement, appeals |
-| Bot Discord | `app-modules/bot-discord/` | Discord bot runtime (Laracord websocket, slash commands, event handlers) |
-| Integration Discord | `app-modules/integration-discord/` | Discord platform transport (REST API via Saloon), OAuth, ETL |
-| Identity | `app-modules/identity/` | Users, tenants, external identities, authentication |
+| Context | Path | Description |
+| ------------------- | ---------------------------------- | ----------------------------------------------------------------------------- |
+| Moderation | `app-modules/moderation/` | Content moderation pipeline — classification, routing, enforcement, appeals |
+| Bot Discord | `app-modules/bot-discord/` | Discord bot runtime (Laracord websocket, slash commands, event handlers) |
+| Integration Discord | `app-modules/integration-discord/` | Discord platform transport (REST API via Saloon), OAuth, ETL |
+| Identity | `app-modules/identity/` | Users, tenants, external identities, authentication |
+| Events | `app-modules/events/` | Event participation lifecycle — enrollment, check-in, attendance, XP dispatch |
+| Gamification | `app-modules/gamification/` | Character progression — XP, levels, badges, seasons, daily bonuses |
| Panel Admin | `app-modules/panel-admin/` | Filament admin panel — dashboards, resources, moderation UI, marketing |
| Integration Twitch | `app-modules/integration-twitch/` | Twitch platform transport (Helix API via Saloon), OAuth, EventSub webhooks |
@@ -19,10 +21,24 @@ This is a modular monorepo (`internachi/modular`). Each bounded context lives un
┌─────────────────┐ ┌──────────────────────┐
│ Bot Discord │ │ Integration Discord │
│ (runtime/ws) │────────▶│ (transport/rest) │
-└────────┬────────┘ └──────────┬───────────┘
- │ │
- │ listens to events │ provides DiscordConnector
- ▼ │
+└───┬─────────┬───┘ └──────────┬───────────┘
+ │ │ │
+ │ │ dispatches │ provides DiscordConnector
+ │ │ CheckInRequested │
+ │ ▼ │
+ │ ┌─────────────────┐ │
+ │ │ Events │ │
+ │ │ (participation) │────────────┼───── publishes domain events
+ │ └────────┬────────┘ │ │
+ │ │ │ ▼
+ │ │ reads users │ ┌─────────────────┐
+ │ │ │ │ Gamification │
+ │ │ │ │ (XP/levels) │
+ │ │ │ └────────┬────────┘
+ │ │ │ │
+ │ listens │ │ │ reads users
+ │ to events │ │ │
+ ▼ ▼ │ ▼
┌─────────────────┐ │
│ Moderation │◀───────────────────┘
│ (domain core) │
@@ -38,7 +54,9 @@ This is a modular monorepo (`internachi/modular`). Each bounded context lives un
### Dependency rules
- **Moderation** is platform-agnostic. It never imports from `bot-discord`, `integration-discord`, or `integration-twitch`.
-- **Bot Discord** depends on Moderation (listens to domain events) and Integration Discord (uses transport).
+- **Bot Discord** depends on Moderation (listens to domain events) and Integration Discord (uses transport) and Events (dispatches check-in domain events)..
- **Integration Discord** depends on Identity (OAuth user resolution). It never imports from Moderation.
- **Integration Twitch** depends on Identity (OAuth user resolution, ExternalIdentity for tenant linking). It never imports from Moderation, Integration Discord, or Bot Discord.
- **Identity** has no upstream dependencies on other contexts listed here.
+- **Events** depends on Identity (reads Users and Tenants). Publishes domain events consumed by Gamification.
+- **Gamification** depends on Identity (Character belongs to User). Listens to Events domain events for XP.
diff --git a/app-modules/events/CONTEXT.md b/app-modules/events/CONTEXT.md
new file mode 100644
index 000000000..657b68d29
--- /dev/null
+++ b/app-modules/events/CONTEXT.md
@@ -0,0 +1,74 @@
+# Events — Bounded Context
+
+## Purpose
+
+Manages event participation lifecycle: enrollment, check-in, attendance tracking, and XP reward dispatching. Covers in-person meetups, workshops, and multi-day conferences.
+
+## Glossary
+
+| Term | Definition | Not to be confused with |
+| -------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------- |
+| **Event** | A scheduled gathering (meetup, workshop, or conference) owned by a tenant. Defined by type, date range, location, and an enrollment policy. | "Event" as in Laravel Event (domain event) — use "domain event" for those. |
+| **Enrollment** | A single record representing one user's relationship with one event. Progresses through a strict state machine from entry to terminal state. One enrollment per (user, event). | "Registration" — we don't use this term. |
+| **Enrollment Policy** | A 1:1 configuration record attached to an event. Defines enrollment method, capacity, check-in method, waitlist behavior, cancellation deadline, XP rewards, and application form schema. | "Event settings" — policy specifically governs participation rules. |
+| **Enrollment Method** | How a user enters an event. One of: `rsvp` (1-click), `rsvp_checkin` (1-click + mandatory presence verification), `application` (form submission + organizer approval). | |
+| **Check-in** | Verification of physical/virtual presence at an event. One record per (enrollment, date). Methods: `manual`, `numeric_code`, `qr_code`. | "Enrollment" — check-in proves presence, enrollment proves intent. |
+| **Numeric Code** | A short-lived code announced by the organizer (projected on screen or spoken in stream). Bound to a specific event date. Has expiration window and optional max uses. Also used by Discord/Twitch bots as check-in trigger. | |
+| **QR Token** | A unique token generated per enrollment. Encoded in a QR code on the participant's badge/screen. Reusable across event days — each scan creates a new check-in record for that day. | |
+| **Waitlist** | Ordered queue of enrollments when event capacity is full. FIFO promotion when a confirmed participant cancels. | |
+| **Attendance Requirement** | Policy rule defining how many days of check-in are needed to achieve `attended` status. Values: `all_days`, `any_day`, `minimum_days(N)`. | |
+| **Transition** | An auditable state change on an enrollment. Every transition is recorded with actor, timestamp, and reason. Written by application code (Actions), never by database triggers. | |
+| **No-show** | Terminal state for a participant who confirmed but never checked in. Assigned automatically by a scheduled job after the event ends. No systemic consequences in MVP (future: may affect eligibility). | |
+| **Application** | An enrollment method where the participant submits a dynamic form (JSONB schema defined in policy) and waits for organizer approval. Results in `pending → confirmed` or `pending → rejected`. | "Submission" (CFP) — application is for participation, submission is for presenting (out of MVP scope). |
+
+## State Machine — Enrollment
+
+```
+[entry]
+ ├─ application → pending
+ │ ├─ approve() → confirmed
+ │ ├─ reject() → rejected (TERMINAL)
+ │ └─ cancel() → cancelled (TERMINAL)
+ │
+ ├─ rsvp/rsvp_checkin + capacity available → confirmed
+ └─ rsvp/rsvp_checkin + full → waitlisted
+ ├─ slot_opens() → confirmed
+ └─ cancel() → cancelled (TERMINAL)
+
+confirmed
+ ├─ check_in() → checked_in
+ ├─ cancel(pre-deadline) → cancelled (TERMINAL)
+ └─ [job] event_ended → no_show (TERMINAL)
+
+checked_in
+ └─ [job] event_ended + attendance_requirement met → attended (TERMINAL · SUCCESS)
+```
+
+**States:** `pending`, `confirmed`, `waitlisted`, `checked_in`, `attended`, `cancelled`, `rejected`, `no_show`
+
+**Terminal states:** `attended` (success), `cancelled`, `rejected`, `no_show`
+
+## Actors
+
+| Actor | Panel | Capabilities |
+| --------------- | ---------------- | ---------------------------------------------------------------------------------------------------- |
+| **Organizer** | Admin (`/admin`) | Creates events, configures policies, approves/rejects applications, manual check-in, status override |
+| **Participant** | App (`/app`) | Enrolls (RSVP/application), checks in (code/QR), cancels, views own events |
+
+## Module Boundaries
+
+- **Events → Gamification**: Events dispatches domain events (`EnrollmentConfirmed`, `ParticipantCheckedIn`, `ParticipantAttended`). Gamification listens and awards XP. Events does not know how XP works.
+- **Bot Discord → Events**: Bot dispatches domain events (e.g., `CheckInRequested`). Events module listens and processes. Bot is transport, Events owns the rules.
+- **Events → Identity**: Events reads User and Tenant models. No writes to Identity.
+
+## Out of Scope (MVP)
+
+- Networking between participants
+- Referral / invite links
+- Magic link and geolocation check-in
+- Sponsors association
+- Timeline / activity feed (listeners ready, no consumer yet)
+- Call for Papers / Submissions (CFP)
+- Agenda / schedule display
+- Paid events / payment integration
+- No-show penalties
diff --git a/app-modules/events/docs/adr/0001-state-machine-as-enum-not-package.md b/app-modules/events/docs/adr/0001-state-machine-as-enum-not-package.md
new file mode 100644
index 000000000..b699b547e
--- /dev/null
+++ b/app-modules/events/docs/adr/0001-state-machine-as-enum-not-package.md
@@ -0,0 +1,20 @@
+# ADR-0001: Enrollment state machine as PHP enum, not spatie/laravel-model-states
+
+## Status
+
+Accepted — 2026-05-16
+
+## Context
+
+The enrollment lifecycle has 8 states with well-defined transitions. We considered `spatie/laravel-model-states` (dedicated state classes, automatic transition validation) vs. a plain PHP enum with a `canTransitionTo()` method and validation in Actions.
+
+## Decision
+
+Use a backed PHP enum (`EnrollmentStatusEnum`) with an explicit `canTransitionTo(self $target): bool` method. Transition side-effects (XP dispatch, waitlist promotion, audit trail) live in Action classes.
+
+## Consequences
+
+- **No extra dependency** — one less package to maintain and version-match.
+- **All business logic is explicit** — transitions, guards, and side-effects visible in Action code, not hidden in package config/hooks.
+- **Trade-off**: if the state graph grows significantly or requires per-tenant customization, we lose the declarative config that `model-states` provides. Acceptable given the graph is static and small.
+- **Testing**: state transitions are tested by calling Actions directly, asserting status + transition records.
diff --git a/app-modules/events/docs/adr/0002-xp-via-domain-events-not-ledger.md b/app-modules/events/docs/adr/0002-xp-via-domain-events-not-ledger.md
new file mode 100644
index 000000000..64bf7a7bb
--- /dev/null
+++ b/app-modules/events/docs/adr/0002-xp-via-domain-events-not-ledger.md
@@ -0,0 +1,24 @@
+# ADR-0002: XP awarded via domain events, not an in-module ledger
+
+## Status
+
+Accepted — 2026-05-16
+
+## Context
+
+We considered three approaches for XP integration:
+
+- (A) Events dispatches domain events, Gamification listens and increments `Character.experience`.
+- (B) Events maintains its own `events_xp_rewards` ledger and calls Gamification.
+- (C) Centralized ledger in Gamification with `source_type`/`source_id`.
+
+## Decision
+
+Option A — fire-and-forget domain events. Events module publishes `EnrollmentConfirmed`, `ParticipantCheckedIn`, `ParticipantAttended` with XP amounts from the enrollment policy. Gamification module subscribes and increments.
+
+## Consequences
+
+- **Module boundary respected** — Events has zero knowledge of how XP is stored or calculated.
+- **No ledger in Events** — auditability comes from the `events_enrollment_transitions` table (which records every state change) + Gamification's own records.
+- **Trade-off**: no single "XP history per event" query without joining across modules. Acceptable for MVP; a cross-module read model can be added later if needed.
+- **Idempotency**: the listener must be idempotent (check if XP was already granted for this enrollment+reason). This responsibility moves to Gamification.
diff --git a/app-modules/events/docs/adr/0003-no-database-triggers-all-logic-in-actions.md b/app-modules/events/docs/adr/0003-no-database-triggers-all-logic-in-actions.md
new file mode 100644
index 000000000..e9be83d2f
--- /dev/null
+++ b/app-modules/events/docs/adr/0003-no-database-triggers-all-logic-in-actions.md
@@ -0,0 +1,26 @@
+# ADR-0003: No database triggers — all business logic explicit in application code
+
+## Status
+
+Accepted — 2026-05-16
+
+## Context
+
+The original spec proposed PostgreSQL triggers for:
+
+- Automatically recording enrollment transitions (audit trail)
+- Awarding XP on state changes
+- Promoting waitlisted participants
+
+Triggers are invisible to application code, hard to test, and bypass Laravel's event system.
+
+## Decision
+
+All business logic lives in PHP Action classes. No database triggers. The audit trail (`events_enrollment_transitions`) is written explicitly by the Action that performs the transition.
+
+## Consequences
+
+- **Testable** — every behavior can be tested via Action unit/feature tests without database-level mocking.
+- **Visible** — reading an Action tells you everything that happens on a transition (audit write, event dispatch, waitlist promotion).
+- **Debuggable** — no hidden side-effects outside of Laravel's control.
+- **Trade-off**: a raw SQL update bypassing Actions would skip audit/XP/promotion. Mitigated by never updating enrollment status outside of Actions (enforced by code review, not by the database).
diff --git a/app-modules/events/docs/adr/0004-check-in-as-separate-table-one-to-many.md b/app-modules/events/docs/adr/0004-check-in-as-separate-table-one-to-many.md
new file mode 100644
index 000000000..1940ca941
--- /dev/null
+++ b/app-modules/events/docs/adr/0004-check-in-as-separate-table-one-to-many.md
@@ -0,0 +1,21 @@
+# ADR-0004: Check-ins as separate 1:N table per enrollment
+
+## Status
+
+Accepted — 2026-05-16
+
+## Context
+
+Initially considered storing check-in data as columns on the enrollment record (1:1). However, multi-day events (e.g., 2-day conference) require multiple check-ins per enrollment — one per day attended.
+
+## Decision
+
+`events_check_ins` is a separate table with a many-to-one relationship to enrollments. Each record represents one check-in on one specific date. The enrollment policy's `attendance_requirement` (`all_days` | `any_day` | `minimum_days(N)`) determines how many check-ins are needed for the `attended` terminal state.
+
+## Consequences
+
+- **Multi-day supported** — conferences spanning N days work naturally.
+- **Event days derived from date range** — no separate `events_days` table. System validates `check_in_date BETWEEN event.starts_at::date AND event.ends_at::date`.
+- **QR token reuse** — one QR per enrollment, each scan on a different day creates a new check-in record.
+- **Numeric codes bound to date** — `events_check_in_codes.event_date` ensures codes are day-specific.
+- **Trade-off**: if an event skips a day (Friday + Sunday, no Saturday), the system can't distinguish "valid day" from "gap day" without manual intervention. Acceptable for current use cases (consecutive days).
diff --git a/app-modules/events/docs/adr/0005-bot-communicates-via-domain-events-not-api.md b/app-modules/events/docs/adr/0005-bot-communicates-via-domain-events-not-api.md
new file mode 100644
index 000000000..3cc600172
--- /dev/null
+++ b/app-modules/events/docs/adr/0005-bot-communicates-via-domain-events-not-api.md
@@ -0,0 +1,23 @@
+# ADR-0005: Bot communicates with Events via domain events, not REST API
+
+## Status
+
+Accepted — 2026-05-16
+
+## Context
+
+Discord/Twitch bots need to trigger check-ins (e.g., user types `!checkin 4829` in chat). Two options:
+
+- (A) Bot calls a REST endpoint on the Events module.
+- (B) Bot dispatches a Laravel domain event, Events module listens.
+
+## Decision
+
+Option B — Bot dispatches a domain event (e.g., `CheckInRequested`) containing user identifier, event context, and code. Events module has a listener that validates and processes. Bot is pure transport.
+
+## Consequences
+
+- **Consistent with existing architecture** — Bot Discord already communicates with Moderation via domain events (see CONTEXT-MAP.md).
+- **Module boundaries respected** — Bot doesn't import Events classes or know enrollment logic.
+- **Testable in isolation** — Events listener can be tested by dispatching the event directly, without HTTP layer.
+- **Trade-off**: no synchronous HTTP response to the bot. Bot must listen for a response event (e.g., `CheckInProcessed`) to reply to the user in chat. Adds async complexity but maintains decoupling.
From a2e0a803bdb182ad660db671499d9e1855694722 Mon Sep 17 00:00:00 2001
From: Gabriel
Date: Tue, 19 May 2026 13:47:34 -0300
Subject: [PATCH 02/18] feat(events): module structure (#249)
- 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
---
.../database/factories/CheckInCodeFactory.php | 31 ++++
.../database/factories/CheckInFactory.php | 27 ++++
.../database/factories/EnrollmentFactory.php | 35 +++++
.../factories/EnrollmentPolicyFactory.php | 36 +++++
.../factories/EnrollmentTransitionFactory.php | 30 ++++
.../database/factories/EventAgendaFactory.php | 49 ------
.../database/factories/EventFactory.php | 57 ++-----
.../factories/EventSubmissionFactory.php | 36 -----
.../EventSubmissionSpeakerFactory.php | 24 ---
.../database/factories/QrTokenFactory.php | 25 +++
.../database/factories/SponsorFactory.php | 29 ----
...05_16_195205_drop_events_module_tables.php | 5 +
.../2026_05_16_200001_create_events_table.php | 35 +++++
...reate_events_enrollment_policies_table.php | 35 +++++
...200003_create_events_enrollments_table.php | 40 +++++
...te_events_enrollment_transitions_table.php | 33 ++++
...6_200005_create_events_check_ins_table.php | 29 ++++
...006_create_events_check_in_codes_table.php | 33 ++++
...6_200007_create_events_qr_tokens_table.php | 28 ++++
app-modules/events/lang/en/enums.php | 53 +++++++
app-modules/events/lang/pt_BR/enums.php | 53 +++++++
.../src/CheckIn/Enums/CheckInMethod.php | 41 +++++
.../events/src/CheckIn/Models/CheckIn.php | 60 ++++++++
.../events/src/CheckIn/Models/CheckInCode.php | 65 ++++++++
.../events/src/CheckIn/Models/QrToken.php | 55 +++++++
.../Enums/AttendanceRequirement.php | 41 +++++
.../src/Enrollment/Enums/EnrollmentMethod.php | 41 +++++
.../src/Enrollment/Enums/EnrollmentStatus.php | 72 +++++++++
.../src/Enrollment/Enums/TriggeredBy.php | 41 +++++
.../src/Enrollment/Models/Enrollment.php | 133 ++++++++++++++++
.../Enrollment/Models/EnrollmentPolicy.php | 88 +++++++++++
.../Models/EnrollmentTransition.php | 76 ++++++++++
.../events/src/Event/Enums/EventStatus.php | 58 +++++++
.../events/src/Event/Enums/EventType.php | 41 +++++
app-modules/events/src/Event/Models/Event.php | 126 +++++++++++++++
.../events/src/EventsServiceProvider.php | 2 +
.../tests/Feature/EventFactoriesTest.php | 98 ++++++++++++
.../tests/Feature/EventResourceTest.php | 112 ++++++++++++++
.../tests/Unit/EnrollmentStatusTest.php | 61 ++++++++
.../events/tests/Unit/EventStatusTest.php | 39 +++++
.../Resources/Events/EventResource.php | 61 ++++++++
.../Resources/Events/Pages/CreateEvent.php | 13 ++
.../Resources/Events/Pages/EditEvent.php | 21 +++
.../Resources/Events/Pages/ListEvents.php | 21 +++
.../EnrollmentsRelationManager.php | 59 ++++++++
.../Resources/Events/Schemas/EventForm.php | 143 ++++++++++++++++++
.../Events/Schemas/EventInfolist.php | 96 ++++++++++++
.../Resources/Events/Tables/EventsTable.php | 73 +++++++++
.../src/PanelAdminServiceProvider.php | 3 +
49 files changed, 2282 insertions(+), 181 deletions(-)
create mode 100644 app-modules/events/database/factories/CheckInCodeFactory.php
create mode 100644 app-modules/events/database/factories/CheckInFactory.php
create mode 100644 app-modules/events/database/factories/EnrollmentFactory.php
create mode 100644 app-modules/events/database/factories/EnrollmentPolicyFactory.php
create mode 100644 app-modules/events/database/factories/EnrollmentTransitionFactory.php
delete mode 100644 app-modules/events/database/factories/EventAgendaFactory.php
delete mode 100644 app-modules/events/database/factories/EventSubmissionFactory.php
delete mode 100644 app-modules/events/database/factories/EventSubmissionSpeakerFactory.php
create mode 100644 app-modules/events/database/factories/QrTokenFactory.php
delete mode 100644 app-modules/events/database/factories/SponsorFactory.php
create mode 100644 app-modules/events/database/migrations/2026_05_16_200001_create_events_table.php
create mode 100644 app-modules/events/database/migrations/2026_05_16_200002_create_events_enrollment_policies_table.php
create mode 100644 app-modules/events/database/migrations/2026_05_16_200003_create_events_enrollments_table.php
create mode 100644 app-modules/events/database/migrations/2026_05_16_200004_create_events_enrollment_transitions_table.php
create mode 100644 app-modules/events/database/migrations/2026_05_16_200005_create_events_check_ins_table.php
create mode 100644 app-modules/events/database/migrations/2026_05_16_200006_create_events_check_in_codes_table.php
create mode 100644 app-modules/events/database/migrations/2026_05_16_200007_create_events_qr_tokens_table.php
create mode 100644 app-modules/events/lang/en/enums.php
create mode 100644 app-modules/events/lang/pt_BR/enums.php
create mode 100644 app-modules/events/src/CheckIn/Enums/CheckInMethod.php
create mode 100644 app-modules/events/src/CheckIn/Models/CheckIn.php
create mode 100644 app-modules/events/src/CheckIn/Models/CheckInCode.php
create mode 100644 app-modules/events/src/CheckIn/Models/QrToken.php
create mode 100644 app-modules/events/src/Enrollment/Enums/AttendanceRequirement.php
create mode 100644 app-modules/events/src/Enrollment/Enums/EnrollmentMethod.php
create mode 100644 app-modules/events/src/Enrollment/Enums/EnrollmentStatus.php
create mode 100644 app-modules/events/src/Enrollment/Enums/TriggeredBy.php
create mode 100644 app-modules/events/src/Enrollment/Models/Enrollment.php
create mode 100644 app-modules/events/src/Enrollment/Models/EnrollmentPolicy.php
create mode 100644 app-modules/events/src/Enrollment/Models/EnrollmentTransition.php
create mode 100644 app-modules/events/src/Event/Enums/EventStatus.php
create mode 100644 app-modules/events/src/Event/Enums/EventType.php
create mode 100644 app-modules/events/src/Event/Models/Event.php
create mode 100644 app-modules/events/tests/Feature/EventFactoriesTest.php
create mode 100644 app-modules/events/tests/Feature/EventResourceTest.php
create mode 100644 app-modules/events/tests/Unit/EnrollmentStatusTest.php
create mode 100644 app-modules/events/tests/Unit/EventStatusTest.php
create mode 100644 app-modules/panel-admin/src/Filament/Resources/Events/EventResource.php
create mode 100644 app-modules/panel-admin/src/Filament/Resources/Events/Pages/CreateEvent.php
create mode 100644 app-modules/panel-admin/src/Filament/Resources/Events/Pages/EditEvent.php
create mode 100644 app-modules/panel-admin/src/Filament/Resources/Events/Pages/ListEvents.php
create mode 100644 app-modules/panel-admin/src/Filament/Resources/Events/RelationManagers/EnrollmentsRelationManager.php
create mode 100644 app-modules/panel-admin/src/Filament/Resources/Events/Schemas/EventForm.php
create mode 100644 app-modules/panel-admin/src/Filament/Resources/Events/Schemas/EventInfolist.php
create mode 100644 app-modules/panel-admin/src/Filament/Resources/Events/Tables/EventsTable.php
diff --git a/app-modules/events/database/factories/CheckInCodeFactory.php b/app-modules/events/database/factories/CheckInCodeFactory.php
new file mode 100644
index 000000000..c2fc5e085
--- /dev/null
+++ b/app-modules/events/database/factories/CheckInCodeFactory.php
@@ -0,0 +1,31 @@
+ */
+final class CheckInCodeFactory extends Factory
+{
+ protected $model = CheckInCode::class;
+
+ public function definition(): array
+ {
+ $startsAt = Date::now();
+
+ return [
+ 'event_id' => Event::factory(),
+ 'event_date' => Date::today(),
+ 'code' => fake()->numerify('######'),
+ 'starts_at' => $startsAt,
+ 'expires_at' => $startsAt->clone()->addHours(2),
+ 'max_uses' => null,
+ 'uses_count' => 0,
+ ];
+ }
+}
diff --git a/app-modules/events/database/factories/CheckInFactory.php b/app-modules/events/database/factories/CheckInFactory.php
new file mode 100644
index 000000000..fa621af9f
--- /dev/null
+++ b/app-modules/events/database/factories/CheckInFactory.php
@@ -0,0 +1,27 @@
+ */
+final class CheckInFactory extends Factory
+{
+ protected $model = CheckIn::class;
+
+ public function definition(): array
+ {
+ return [
+ 'enrollment_id' => Enrollment::factory(),
+ 'event_date' => Date::today(),
+ 'method' => fake()->randomElement(CheckInMethod::cases()),
+ 'payload' => null,
+ ];
+ }
+}
diff --git a/app-modules/events/database/factories/EnrollmentFactory.php b/app-modules/events/database/factories/EnrollmentFactory.php
new file mode 100644
index 000000000..d832f045a
--- /dev/null
+++ b/app-modules/events/database/factories/EnrollmentFactory.php
@@ -0,0 +1,35 @@
+ */
+final class EnrollmentFactory extends Factory
+{
+ protected $model = Enrollment::class;
+
+ public function definition(): array
+ {
+ return [
+ 'event_id' => Event::factory(),
+ 'user_id' => User::factory(),
+ 'status' => EnrollmentStatus::Pending,
+ 'is_public' => true,
+ 'waitlist_position' => null,
+ 'application_data' => null,
+ 'rejection_reason' => null,
+ 'enrolled_at' => null,
+ 'confirmed_at' => null,
+ 'checked_in_at' => null,
+ 'attended_at' => null,
+ 'cancelled_at' => null,
+ ];
+ }
+}
diff --git a/app-modules/events/database/factories/EnrollmentPolicyFactory.php b/app-modules/events/database/factories/EnrollmentPolicyFactory.php
new file mode 100644
index 000000000..152d5ffe4
--- /dev/null
+++ b/app-modules/events/database/factories/EnrollmentPolicyFactory.php
@@ -0,0 +1,36 @@
+ */
+final class EnrollmentPolicyFactory extends Factory
+{
+ protected $model = EnrollmentPolicy::class;
+
+ public function definition(): array
+ {
+ return [
+ 'event_id' => Event::factory(),
+ 'enrollment_method' => fake()->randomElement(EnrollmentMethod::cases()),
+ 'check_in_method' => fake()->randomElement(CheckInMethod::cases()),
+ 'capacity' => fake()->optional()->numberBetween(10, 200),
+ 'has_waitlist' => false,
+ 'attendance_requirement' => AttendanceRequirement::AllDays,
+ 'minimum_days' => null,
+ 'cancellation_deadline_hours' => null,
+ 'xp_on_confirmed' => 0,
+ 'xp_on_checked_in' => 0,
+ 'xp_on_attended' => 0,
+ 'application_schema' => null,
+ ];
+ }
+}
diff --git a/app-modules/events/database/factories/EnrollmentTransitionFactory.php b/app-modules/events/database/factories/EnrollmentTransitionFactory.php
new file mode 100644
index 000000000..6430c49d8
--- /dev/null
+++ b/app-modules/events/database/factories/EnrollmentTransitionFactory.php
@@ -0,0 +1,30 @@
+ */
+final class EnrollmentTransitionFactory extends Factory
+{
+ protected $model = EnrollmentTransition::class;
+
+ public function definition(): array
+ {
+ return [
+ 'enrollment_id' => Enrollment::factory(),
+ 'from_status' => null,
+ 'to_status' => EnrollmentStatus::Pending,
+ 'actor_id' => null,
+ 'triggered_by' => TriggeredBy::User,
+ 'reason' => null,
+ 'metadata' => null,
+ ];
+ }
+}
diff --git a/app-modules/events/database/factories/EventAgendaFactory.php b/app-modules/events/database/factories/EventAgendaFactory.php
deleted file mode 100644
index 070249108..000000000
--- a/app-modules/events/database/factories/EventAgendaFactory.php
+++ /dev/null
@@ -1,49 +0,0 @@
-
- */
-final class EventAgendaFactory extends Factory
-{
- protected $model = EventAgenda::class;
-
- public function definition(): array
- {
- return [
- 'tenant_id' => Tenant::factory(),
- 'event_id' => EventModel::factory(),
- 'start_at' => Date::now(),
- 'end_at' => Date::now()->addHour(),
- 'created_at' => Date::now(),
- 'updated_at' => Date::now(),
- ];
- }
-
- public function forSegment(?EventSegment $segment = null)
- {
- return $this->state(fn () => [
- 'schedulable_type' => (new EventSegment)->getMorphClass(),
- 'schedulable_id' => $segment ?? EventSegment::query()->inRandomOrder()->first()->getKey(),
- ]);
- }
-
- public function forTalk(?EventSubmission $talk = null)
- {
- return $this->state(fn () => [
- 'schedulable_type' => (new EventSubmission)->getMorphClass(),
- 'schedulable_id' => $talk ?? EventSubmission::factory(),
- ]);
- }
-}
diff --git a/app-modules/events/database/factories/EventFactory.php b/app-modules/events/database/factories/EventFactory.php
index 531100821..9dc8eb7bd 100644
--- a/app-modules/events/database/factories/EventFactory.php
+++ b/app-modules/events/database/factories/EventFactory.php
@@ -4,61 +4,32 @@
namespace He4rt\Events\Database\Factories;
-use Exception;
-use He4rt\Events\Enums\AttendingStatusEnum;
-use He4rt\Events\Enums\EventTypeEnum;
-use He4rt\Events\Models\EventModel;
+use He4rt\Events\Event\Enums\EventStatus;
+use He4rt\Events\Event\Enums\EventType;
+use He4rt\Events\Event\Models\Event;
use He4rt\Identity\Tenant\Models\Tenant;
-use He4rt\Identity\User\Models\User;
use Illuminate\Database\Eloquent\Factories\Factory;
use Illuminate\Support\Facades\Date;
-/**
- * @extends Factory
- */
+/** @extends Factory */
final class EventFactory extends Factory
{
- protected $model = EventModel::class;
+ protected $model = Event::class;
public function definition(): array
{
+ $startsAt = Date::now()->addDays(fake()->numberBetween(1, 30));
+
return [
'tenant_id' => Tenant::factory(),
- 'event_type' => fake()->randomElement(EventTypeEnum::cases()),
- 'slug' => fake()->slug(),
- 'active' => true,
+ 'slug' => fake()->unique()->slug(),
'title' => fake()->sentence(4),
- 'description' => fake()->text(),
- 'event_at' => Date::today(),
- 'start_at' => Date::now(),
- 'end_at' => Date::today()->endOfDay(),
- 'location' => fake()->sentence(3),
- 'max_attendees' => fake()->numberBetween(10, 100),
- 'attendees_count' => 0,
- 'waitlist_count' => 0,
- 'created_at' => Date::now(),
- 'updated_at' => Date::now(),
+ 'description' => fake()->optional()->text(),
+ 'event_type' => fake()->randomElement(EventType::cases()),
+ 'location' => fake()->optional()->address(),
+ 'starts_at' => $startsAt,
+ 'ends_at' => $startsAt->clone()->addHours(fake()->numberBetween(2, 8)),
+ 'status' => EventStatus::Draft,
];
}
-
- public function withStatus(AttendingStatusEnum $status = AttendingStatusEnum::Attending): self
- {
- return $this->afterCreating(function (EventModel $model) use ($status): void {
- $attendees = User::factory()->count(fake()->numberBetween(3, 10))->create();
- $column = match ($status) {
- AttendingStatusEnum::Attending => 'attendees_count',
- AttendingStatusEnum::Waitlist => 'waitlist_count',
- AttendingStatusEnum::NotAttending => throw new Exception('Event is not attending anymore'),
- };
- $model->update([
- $column => $attendees->count(),
- ]);
-
- foreach ($attendees as $user) {
- $model->attendees()->attach($user->getKey(), [
- 'status' => $status,
- ]);
- }
- });
- }
}
diff --git a/app-modules/events/database/factories/EventSubmissionFactory.php b/app-modules/events/database/factories/EventSubmissionFactory.php
deleted file mode 100644
index dbdc2e8e0..000000000
--- a/app-modules/events/database/factories/EventSubmissionFactory.php
+++ /dev/null
@@ -1,36 +0,0 @@
-
- */
-final class EventSubmissionFactory extends Factory
-{
- protected $model = EventSubmission::class;
-
- public function definition(): array
- {
- return [
- 'tenant_id' => Tenant::factory(),
- 'event_id' => EventModel::factory(),
- 'user_id' => User::factory(),
- 'status' => fake()->randomElement(TalkStatusEnum::cases()),
- 'field_type' => fake()->word(),
- 'title' => fake()->word(),
- 'description' => fake()->text(),
- 'created_at' => Date::now(),
- 'updated_at' => Date::now(),
- ];
- }
-}
diff --git a/app-modules/events/database/factories/EventSubmissionSpeakerFactory.php b/app-modules/events/database/factories/EventSubmissionSpeakerFactory.php
deleted file mode 100644
index a5e5e5b2f..000000000
--- a/app-modules/events/database/factories/EventSubmissionSpeakerFactory.php
+++ /dev/null
@@ -1,24 +0,0 @@
- */
-class EventSubmissionSpeakerFactory extends Factory
-{
- protected $model = EventSubmissionSpeaker::class;
-
- public function definition(): array
- {
- return [
- 'event_id' => EventModel::factory(),
- 'user_id' => User::factory(),
- ];
- }
-}
diff --git a/app-modules/events/database/factories/QrTokenFactory.php b/app-modules/events/database/factories/QrTokenFactory.php
new file mode 100644
index 000000000..0950d7bfe
--- /dev/null
+++ b/app-modules/events/database/factories/QrTokenFactory.php
@@ -0,0 +1,25 @@
+ */
+final class QrTokenFactory extends Factory
+{
+ protected $model = QrToken::class;
+
+ public function definition(): array
+ {
+ return [
+ 'enrollment_id' => Enrollment::factory(),
+ 'token' => Str::random(64),
+ 'expires_at' => null,
+ ];
+ }
+}
diff --git a/app-modules/events/database/factories/SponsorFactory.php b/app-modules/events/database/factories/SponsorFactory.php
deleted file mode 100644
index 4f360c65e..000000000
--- a/app-modules/events/database/factories/SponsorFactory.php
+++ /dev/null
@@ -1,29 +0,0 @@
-
- */
-final class SponsorFactory extends Factory
-{
- protected $model = Sponsor::class;
-
- public function definition(): array
- {
- return [
- 'tenant_id' => Tenant::factory(),
- 'name' => fake()->name(),
- 'homepage_url' => fake()->url(),
- 'created_at' => Date::now(),
- 'updated_at' => Date::now(),
- ];
- }
-}
diff --git a/app-modules/events/database/migrations/2026_05_16_195205_drop_events_module_tables.php b/app-modules/events/database/migrations/2026_05_16_195205_drop_events_module_tables.php
index 341443023..4eba8fe63 100644
--- a/app-modules/events/database/migrations/2026_05_16_195205_drop_events_module_tables.php
+++ b/app-modules/events/database/migrations/2026_05_16_195205_drop_events_module_tables.php
@@ -17,4 +17,9 @@ public function up(): void
Schema::dropIfExists('sponsors');
Schema::dropIfExists('events');
}
+
+ public function down(): void
+ {
+ // Irreversible: removes legacy events module tables replaced by the new schema.
+ }
};
diff --git a/app-modules/events/database/migrations/2026_05_16_200001_create_events_table.php b/app-modules/events/database/migrations/2026_05_16_200001_create_events_table.php
new file mode 100644
index 000000000..6a481c803
--- /dev/null
+++ b/app-modules/events/database/migrations/2026_05_16_200001_create_events_table.php
@@ -0,0 +1,35 @@
+uuid('id')->primary();
+ $table->foreignId('tenant_id')->nullable()->constrained('tenants')->nullOnDelete();
+ $table->string('slug', 120);
+ $table->string('title', 200);
+ $table->longText('description')->nullable();
+ $table->string('event_type', 20);
+ $table->string('location')->nullable();
+ $table->timestampTz('starts_at');
+ $table->timestampTz('ends_at');
+ $table->string('status', 20)->default('draft');
+ $table->timestampsTz();
+ $table->unique(['tenant_id', 'slug'], 'idx_events_tenant_slug');
+ $table->index(['tenant_id', 'starts_at'], 'idx_events_tenant_window');
+ $table->index(['event_type', 'starts_at'], 'idx_events_type_window');
+ });
+ }
+
+ public function down(): void
+ {
+ Schema::dropIfExists('events');
+ }
+};
diff --git a/app-modules/events/database/migrations/2026_05_16_200002_create_events_enrollment_policies_table.php b/app-modules/events/database/migrations/2026_05_16_200002_create_events_enrollment_policies_table.php
new file mode 100644
index 000000000..570807e53
--- /dev/null
+++ b/app-modules/events/database/migrations/2026_05_16_200002_create_events_enrollment_policies_table.php
@@ -0,0 +1,35 @@
+uuid('id')->primary();
+ $table->foreignUuid('event_id')->unique()->constrained('events')->cascadeOnDelete();
+ $table->string('enrollment_method', 20);
+ $table->string('check_in_method', 20);
+ $table->unsignedInteger('capacity')->nullable();
+ $table->boolean('has_waitlist')->default(false);
+ $table->string('attendance_requirement', 20)->default('all_days');
+ $table->unsignedSmallInteger('minimum_days')->nullable();
+ $table->unsignedSmallInteger('cancellation_deadline_hours')->nullable();
+ $table->unsignedInteger('xp_on_confirmed')->default(0);
+ $table->unsignedInteger('xp_on_checked_in')->default(0);
+ $table->unsignedInteger('xp_on_attended')->default(0);
+ $table->jsonb('application_schema')->nullable();
+ $table->timestampsTz();
+ });
+ }
+
+ public function down(): void
+ {
+ Schema::dropIfExists('events_enrollment_policies');
+ }
+};
diff --git a/app-modules/events/database/migrations/2026_05_16_200003_create_events_enrollments_table.php b/app-modules/events/database/migrations/2026_05_16_200003_create_events_enrollments_table.php
new file mode 100644
index 000000000..2764ffc4d
--- /dev/null
+++ b/app-modules/events/database/migrations/2026_05_16_200003_create_events_enrollments_table.php
@@ -0,0 +1,40 @@
+uuid('id')->primary();
+ $table->foreignUuid('event_id')->constrained('events')->cascadeOnDelete();
+ $table->foreignUuid('user_id')->constrained('users')->cascadeOnDelete();
+ $table->string('status', 20)->default('pending');
+ $table->boolean('is_public')->default(true);
+ $table->unsignedInteger('waitlist_position')->nullable();
+ $table->jsonb('application_data')->nullable();
+ $table->string('rejection_reason', 500)->nullable();
+ $table->timestampTz('enrolled_at')->nullable();
+ $table->timestampTz('confirmed_at')->nullable();
+ $table->timestampTz('checked_in_at')->nullable();
+ $table->timestampTz('attended_at')->nullable();
+ $table->timestampTz('cancelled_at')->nullable();
+ $table->timestampsTz();
+
+ $table->unique(['event_id', 'user_id'], 'idx_enrollments_unique');
+ $table->index(['event_id', 'status'], 'idx_enrollments_event_status');
+ $table->index(['status', 'waitlist_position'], 'idx_enrollments_waitlist');
+ $table->index(['user_id', 'status'], 'idx_enrollments_user_status');
+ });
+ }
+
+ public function down(): void
+ {
+ Schema::dropIfExists('events_enrollments');
+ }
+};
diff --git a/app-modules/events/database/migrations/2026_05_16_200004_create_events_enrollment_transitions_table.php b/app-modules/events/database/migrations/2026_05_16_200004_create_events_enrollment_transitions_table.php
new file mode 100644
index 000000000..5ece6443a
--- /dev/null
+++ b/app-modules/events/database/migrations/2026_05_16_200004_create_events_enrollment_transitions_table.php
@@ -0,0 +1,33 @@
+uuid('id')->primary();
+ $table->foreignUuid('enrollment_id')->constrained('events_enrollments')->cascadeOnDelete();
+ $table->string('from_status', 20)->nullable();
+ $table->string('to_status', 20);
+ $table->foreignUuid('actor_id')->nullable()->constrained('users')->nullOnDelete();
+ $table->string('triggered_by', 20);
+ $table->string('reason', 500)->nullable();
+ // contexto adicional para audit trail: notas do admin, job ID, código de razão, etc.
+ $table->jsonb('metadata')->nullable();
+ $table->timestampTz('created_at')->useCurrent();
+
+ $table->index(['enrollment_id', 'created_at'], 'idx_transitions_enrollment_time');
+ });
+ }
+
+ public function down(): void
+ {
+ Schema::dropIfExists('events_enrollment_transitions');
+ }
+};
diff --git a/app-modules/events/database/migrations/2026_05_16_200005_create_events_check_ins_table.php b/app-modules/events/database/migrations/2026_05_16_200005_create_events_check_ins_table.php
new file mode 100644
index 000000000..6b91f2246
--- /dev/null
+++ b/app-modules/events/database/migrations/2026_05_16_200005_create_events_check_ins_table.php
@@ -0,0 +1,29 @@
+uuid('id')->primary();
+ $table->foreignUuid('enrollment_id')->constrained('events_enrollments')->cascadeOnDelete();
+ $table->date('event_date');
+ $table->string('method', 20);
+ $table->jsonb('payload')->nullable();
+ $table->timestampsTz();
+ $table->unique(['enrollment_id', 'event_date'], 'idx_check_ins_unique_per_day');
+ $table->index('event_date', 'idx_check_ins_date');
+ });
+ }
+
+ public function down(): void
+ {
+ Schema::dropIfExists('events_check_ins');
+ }
+};
diff --git a/app-modules/events/database/migrations/2026_05_16_200006_create_events_check_in_codes_table.php b/app-modules/events/database/migrations/2026_05_16_200006_create_events_check_in_codes_table.php
new file mode 100644
index 000000000..467a841df
--- /dev/null
+++ b/app-modules/events/database/migrations/2026_05_16_200006_create_events_check_in_codes_table.php
@@ -0,0 +1,33 @@
+uuid('id')->primary();
+ $table->foreignUuid('event_id')->constrained('events')->cascadeOnDelete();
+ $table->date('event_date');
+ $table->string('code', 16);
+ $table->timestampTz('starts_at');
+ $table->timestampTz('expires_at');
+ $table->unsignedInteger('max_uses')->nullable();
+ $table->unsignedInteger('uses_count')->default(0);
+ $table->timestampsTz();
+
+ $table->unique(['event_id', 'event_date', 'code'], 'idx_check_in_codes_unique');
+ $table->index(['code', 'expires_at'], 'idx_check_in_codes_lookup');
+ });
+ }
+
+ public function down(): void
+ {
+ Schema::dropIfExists('events_check_in_codes');
+ }
+};
diff --git a/app-modules/events/database/migrations/2026_05_16_200007_create_events_qr_tokens_table.php b/app-modules/events/database/migrations/2026_05_16_200007_create_events_qr_tokens_table.php
new file mode 100644
index 000000000..ca5cab714
--- /dev/null
+++ b/app-modules/events/database/migrations/2026_05_16_200007_create_events_qr_tokens_table.php
@@ -0,0 +1,28 @@
+uuid('id')->primary();
+ $table->foreignUuid('enrollment_id')->unique()->constrained('events_enrollments')->cascadeOnDelete();
+ $table->string('token', 64);
+ $table->timestampTz('expires_at')->nullable();
+ $table->timestampsTz();
+
+ $table->unique('token', 'idx_qr_tokens_token');
+ });
+ }
+
+ public function down(): void
+ {
+ Schema::dropIfExists('events_qr_tokens');
+ }
+};
diff --git a/app-modules/events/lang/en/enums.php b/app-modules/events/lang/en/enums.php
new file mode 100644
index 000000000..22a3c5871
--- /dev/null
+++ b/app-modules/events/lang/en/enums.php
@@ -0,0 +1,53 @@
+ [
+ 'draft' => 'Draft',
+ 'published' => 'Published',
+ 'completed' => 'Completed',
+ 'cancelled' => 'Cancelled',
+ ],
+
+ 'event_type' => [
+ 'meetup' => 'Meetup',
+ 'workshop' => 'Workshop',
+ 'conference' => 'Conference',
+ ],
+
+ 'enrollment_method' => [
+ 'rsvp' => 'RSVP',
+ 'rsvp_checkin' => 'RSVP + Check-in',
+ 'application' => 'Application',
+ ],
+
+ 'attendance_requirement' => [
+ 'all_days' => 'All Days',
+ 'any_day' => 'Any Day',
+ 'minimum_days' => 'Minimum Days',
+ ],
+
+ 'enrollment_status' => [
+ 'pending' => 'Pending',
+ 'confirmed' => 'Confirmed',
+ 'waitlisted' => 'Waitlisted',
+ 'checked_in' => 'Checked In',
+ 'attended' => 'Attended',
+ 'cancelled' => 'Cancelled',
+ 'rejected' => 'Rejected',
+ 'no_show' => 'No Show',
+ ],
+
+ 'check_in_method' => [
+ 'manual' => 'Manual',
+ 'numeric_code' => 'Numeric Code',
+ 'qr_code' => 'QR Code',
+ ],
+
+ 'triggered_by' => [
+ 'user' => 'User',
+ 'admin' => 'Admin',
+ 'system' => 'System',
+ ],
+];
diff --git a/app-modules/events/lang/pt_BR/enums.php b/app-modules/events/lang/pt_BR/enums.php
new file mode 100644
index 000000000..9304596d1
--- /dev/null
+++ b/app-modules/events/lang/pt_BR/enums.php
@@ -0,0 +1,53 @@
+ [
+ 'draft' => 'Rascunho',
+ 'published' => 'Publicado',
+ 'completed' => 'Concluído',
+ 'cancelled' => 'Cancelado',
+ ],
+
+ 'event_type' => [
+ 'meetup' => 'Meetup',
+ 'workshop' => 'Workshop',
+ 'conference' => 'Conferência',
+ ],
+
+ 'enrollment_method' => [
+ 'rsvp' => 'RSVP',
+ 'rsvp_checkin' => 'RSVP + Check-in',
+ 'application' => 'Inscrição',
+ ],
+
+ 'attendance_requirement' => [
+ 'all_days' => 'Todos os dias',
+ 'any_day' => 'Qualquer dia',
+ 'minimum_days' => 'Dias mínimos',
+ ],
+
+ 'enrollment_status' => [
+ 'pending' => 'Pendente',
+ 'confirmed' => 'Confirmado',
+ 'waitlisted' => 'Lista de espera',
+ 'checked_in' => 'Check-in realizado',
+ 'attended' => 'Presente',
+ 'cancelled' => 'Cancelado',
+ 'rejected' => 'Rejeitado',
+ 'no_show' => 'Não compareceu',
+ ],
+
+ 'check_in_method' => [
+ 'manual' => 'Manual',
+ 'numeric_code' => 'Código numérico',
+ 'qr_code' => 'QR Code',
+ ],
+
+ 'triggered_by' => [
+ 'user' => 'Usuário',
+ 'admin' => 'Administrador',
+ 'system' => 'Sistema',
+ ],
+];
diff --git a/app-modules/events/src/CheckIn/Enums/CheckInMethod.php b/app-modules/events/src/CheckIn/Enums/CheckInMethod.php
new file mode 100644
index 000000000..fc63c3e0b
--- /dev/null
+++ b/app-modules/events/src/CheckIn/Enums/CheckInMethod.php
@@ -0,0 +1,41 @@
+value);
+ }
+
+ public function getColor(): array
+ {
+ return match ($this) {
+ self::Manual => Color::Gray,
+ self::NumericCode => Color::Blue,
+ self::QrCode => Color::Purple,
+ };
+ }
+
+ public function getIcon(): Heroicon
+ {
+ return match ($this) {
+ self::Manual => Heroicon::HandRaised,
+ self::NumericCode => Heroicon::InformationCircle,
+ self::QrCode => Heroicon::QrCode,
+ };
+ }
+}
diff --git a/app-modules/events/src/CheckIn/Models/CheckIn.php b/app-modules/events/src/CheckIn/Models/CheckIn.php
new file mode 100644
index 000000000..aa95cde7a
--- /dev/null
+++ b/app-modules/events/src/CheckIn/Models/CheckIn.php
@@ -0,0 +1,60 @@
+|null $payload
+ * @property Carbon $created_at
+ * @property Carbon $updated_at
+ */
+#[Table('events_check_ins')]
+final class CheckIn extends Model
+{
+ /** @use HasFactory */
+ use HasFactory;
+ use HasUuids;
+
+ protected $fillable = [
+ 'enrollment_id',
+ 'event_date',
+ 'method',
+ 'payload',
+ ];
+
+ /** @return BelongsTo */
+ public function enrollment(): BelongsTo
+ {
+ return $this->belongsTo(Enrollment::class);
+ }
+
+ protected static function newFactory(): CheckInFactory
+ {
+ return CheckInFactory::new();
+ }
+
+ /** @return array */
+ protected function casts(): array
+ {
+ return [
+ 'event_date' => 'date',
+ 'method' => CheckInMethod::class,
+ 'payload' => 'array',
+ ];
+ }
+}
diff --git a/app-modules/events/src/CheckIn/Models/CheckInCode.php b/app-modules/events/src/CheckIn/Models/CheckInCode.php
new file mode 100644
index 000000000..0abff5163
--- /dev/null
+++ b/app-modules/events/src/CheckIn/Models/CheckInCode.php
@@ -0,0 +1,65 @@
+ */
+ use HasFactory;
+ use HasUuids;
+
+ protected $fillable = [
+ 'event_id',
+ 'event_date',
+ 'code',
+ 'starts_at',
+ 'expires_at',
+ 'max_uses',
+ 'uses_count',
+ ];
+
+ /** @return BelongsTo */
+ public function event(): BelongsTo
+ {
+ return $this->belongsTo(Event::class);
+ }
+
+ protected static function newFactory(): CheckInCodeFactory
+ {
+ return CheckInCodeFactory::new();
+ }
+
+ /** @return array */
+ protected function casts(): array
+ {
+ return [
+ 'event_date' => 'date',
+ 'starts_at' => 'datetime',
+ 'expires_at' => 'datetime',
+ ];
+ }
+}
diff --git a/app-modules/events/src/CheckIn/Models/QrToken.php b/app-modules/events/src/CheckIn/Models/QrToken.php
new file mode 100644
index 000000000..e52d255a2
--- /dev/null
+++ b/app-modules/events/src/CheckIn/Models/QrToken.php
@@ -0,0 +1,55 @@
+ */
+ use HasFactory;
+ use HasUuids;
+
+ protected $fillable = [
+ 'enrollment_id',
+ 'token',
+ 'expires_at',
+ ];
+
+ /** @return BelongsTo */
+ public function enrollment(): BelongsTo
+ {
+ return $this->belongsTo(Enrollment::class);
+ }
+
+ protected static function newFactory(): QrTokenFactory
+ {
+ return QrTokenFactory::new();
+ }
+
+ /** @return array */
+ protected function casts(): array
+ {
+ return [
+ 'expires_at' => 'datetime',
+ ];
+ }
+}
diff --git a/app-modules/events/src/Enrollment/Enums/AttendanceRequirement.php b/app-modules/events/src/Enrollment/Enums/AttendanceRequirement.php
new file mode 100644
index 000000000..a41fd5f15
--- /dev/null
+++ b/app-modules/events/src/Enrollment/Enums/AttendanceRequirement.php
@@ -0,0 +1,41 @@
+value);
+ }
+
+ public function getColor(): array
+ {
+ return match ($this) {
+ self::AllDays => Color::Blue,
+ self::AnyDay => Color::Green,
+ self::MinimumDays => Color::Amber,
+ };
+ }
+
+ public function getIcon(): Heroicon
+ {
+ return match ($this) {
+ self::AllDays => Heroicon::Fire,
+ self::AnyDay => Heroicon::CheckCircle,
+ self::MinimumDays => Heroicon::OutlinedChartBar,
+ };
+ }
+}
diff --git a/app-modules/events/src/Enrollment/Enums/EnrollmentMethod.php b/app-modules/events/src/Enrollment/Enums/EnrollmentMethod.php
new file mode 100644
index 000000000..0007fb6d5
--- /dev/null
+++ b/app-modules/events/src/Enrollment/Enums/EnrollmentMethod.php
@@ -0,0 +1,41 @@
+value);
+ }
+
+ public function getColor(): array
+ {
+ return match ($this) {
+ self::Rsvp => Color::Green,
+ self::RsvpCheckin => Color::Blue,
+ self::Application => Color::Amber,
+ };
+ }
+
+ public function getIcon(): Heroicon
+ {
+ return match ($this) {
+ self::Rsvp => Heroicon::CheckCircle,
+ self::RsvpCheckin => Heroicon::Flag,
+ self::Application => Heroicon::ClipboardDocumentList,
+ };
+ }
+}
diff --git a/app-modules/events/src/Enrollment/Enums/EnrollmentStatus.php b/app-modules/events/src/Enrollment/Enums/EnrollmentStatus.php
new file mode 100644
index 000000000..131eadf56
--- /dev/null
+++ b/app-modules/events/src/Enrollment/Enums/EnrollmentStatus.php
@@ -0,0 +1,72 @@
+value);
+ }
+
+ public function getColor(): array
+ {
+ return match ($this) {
+ self::Pending => Color::Gray,
+ self::Confirmed => Color::Blue,
+ self::Waitlisted => Color::Amber,
+ self::CheckedIn => Color::Teal,
+ self::Attended => Color::Green,
+ self::Cancelled => Color::Gray,
+ self::Rejected => Color::Red,
+ self::NoShow => Color::Orange,
+ };
+ }
+
+ public function getIcon(): Heroicon
+ {
+ return match ($this) {
+ self::Pending => Heroicon::Clock,
+ self::Confirmed => Heroicon::CheckCircle,
+ self::Waitlisted => Heroicon::OutlinedQueueList,
+ self::CheckedIn => Heroicon::Flag,
+ self::Attended => Heroicon::Bolt,
+ self::Cancelled => Heroicon::XCircle,
+ self::Rejected => Heroicon::NoSymbol,
+ self::NoShow => Heroicon::ExclamationCircle,
+ };
+ }
+
+ public function canTransitionTo(self $target): bool
+ {
+ return match ($this) {
+ self::Pending => in_array($target, [self::Confirmed, self::Waitlisted, self::Rejected, self::Cancelled], strict: true),
+ self::Waitlisted => in_array($target, [self::Confirmed, self::Cancelled], strict: true),
+ self::Confirmed => in_array($target, [self::CheckedIn, self::Cancelled, self::NoShow], strict: true),
+ self::CheckedIn => in_array($target, [self::Attended, self::NoShow], strict: true),
+ self::Attended, self::Cancelled, self::Rejected, self::NoShow => false,
+ };
+ }
+
+ public function isTerminal(): bool
+ {
+ return in_array($this, [self::Attended, self::Cancelled, self::Rejected, self::NoShow], strict: true);
+ }
+}
diff --git a/app-modules/events/src/Enrollment/Enums/TriggeredBy.php b/app-modules/events/src/Enrollment/Enums/TriggeredBy.php
new file mode 100644
index 000000000..3410729d8
--- /dev/null
+++ b/app-modules/events/src/Enrollment/Enums/TriggeredBy.php
@@ -0,0 +1,41 @@
+value);
+ }
+
+ public function getColor(): array
+ {
+ return match ($this) {
+ self::User => Color::Blue,
+ self::Admin => Color::Amber,
+ self::System => Color::Gray,
+ };
+ }
+
+ public function getIcon(): Heroicon
+ {
+ return match ($this) {
+ self::User => Heroicon::User,
+ self::Admin => Heroicon::ShieldCheck,
+ self::System => Heroicon::Cog6Tooth,
+ };
+ }
+}
diff --git a/app-modules/events/src/Enrollment/Models/Enrollment.php b/app-modules/events/src/Enrollment/Models/Enrollment.php
new file mode 100644
index 000000000..3d163885e
--- /dev/null
+++ b/app-modules/events/src/Enrollment/Models/Enrollment.php
@@ -0,0 +1,133 @@
+|null $application_data
+ * @property string|null $rejection_reason
+ * @property Carbon|null $enrolled_at
+ * @property Carbon|null $confirmed_at
+ * @property Carbon|null $checked_in_at
+ * @property Carbon|null $attended_at
+ * @property Carbon|null $cancelled_at
+ * @property Carbon $created_at
+ * @property Carbon $updated_at
+ */
+#[Table('events_enrollments')]
+final class Enrollment extends Model
+{
+ /** @use HasFactory */
+ use HasFactory;
+ use HasUuids;
+
+ protected $fillable = [
+ 'event_id',
+ 'user_id',
+ 'is_public',
+ 'application_data',
+ 'rejection_reason',
+ 'enrolled_at',
+ 'confirmed_at',
+ 'checked_in_at',
+ 'attended_at',
+ 'cancelled_at',
+ ];
+
+ protected $attributes = [
+ 'status' => 'pending',
+ 'is_public' => true,
+ ];
+
+ /** @return BelongsTo */
+ public function event(): BelongsTo
+ {
+ return $this->belongsTo(Event::class);
+ }
+
+ /** @return BelongsTo */
+ public function user(): BelongsTo
+ {
+ return $this->belongsTo(User::class);
+ }
+
+ /** @return HasMany */
+ public function transitions(): HasMany
+ {
+ return $this->hasMany(EnrollmentTransition::class);
+ }
+
+ /** @return HasMany */
+ public function checkIns(): HasMany
+ {
+ return $this->hasMany(CheckIn::class);
+ }
+
+ /** @return HasOne */
+ public function qrToken(): HasOne
+ {
+ return $this->hasOne(QrToken::class);
+ }
+
+ protected static function newFactory(): EnrollmentFactory
+ {
+ return EnrollmentFactory::new();
+ }
+
+ protected function scopeConfirmed(Builder $query): void
+ {
+ $query->where('status', EnrollmentStatus::Confirmed);
+ }
+
+ protected function scopeWaitlisted(Builder $query): void
+ {
+ $query->where('status', EnrollmentStatus::Waitlisted);
+ }
+
+ protected function scopeActive(Builder $query): void
+ {
+ $query->whereNotIn('status', [
+ EnrollmentStatus::Cancelled,
+ EnrollmentStatus::Rejected,
+ EnrollmentStatus::NoShow,
+ ]);
+ }
+
+ /** @return array */
+ protected function casts(): array
+ {
+ return [
+ 'status' => EnrollmentStatus::class,
+ 'is_public' => 'boolean',
+ 'application_data' => 'array',
+ 'enrolled_at' => 'datetime',
+ 'confirmed_at' => 'datetime',
+ 'checked_in_at' => 'datetime',
+ 'attended_at' => 'datetime',
+ 'cancelled_at' => 'datetime',
+ ];
+ }
+}
diff --git a/app-modules/events/src/Enrollment/Models/EnrollmentPolicy.php b/app-modules/events/src/Enrollment/Models/EnrollmentPolicy.php
new file mode 100644
index 000000000..015370905
--- /dev/null
+++ b/app-modules/events/src/Enrollment/Models/EnrollmentPolicy.php
@@ -0,0 +1,88 @@
+|null $application_schema
+ * @property Carbon $created_at
+ * @property Carbon $updated_at
+ */
+#[Table('events_enrollment_policies')]
+final class EnrollmentPolicy extends Model
+{
+ /** @use HasFactory */
+ use HasFactory;
+ use HasUuids;
+
+ protected $fillable = [
+ 'event_id',
+ 'enrollment_method',
+ 'check_in_method',
+ 'capacity',
+ 'has_waitlist',
+ 'attendance_requirement',
+ 'minimum_days',
+ 'cancellation_deadline_hours',
+ 'xp_on_confirmed',
+ 'xp_on_checked_in',
+ 'xp_on_attended',
+ 'application_schema',
+ ];
+
+ protected $attributes = [
+ 'has_waitlist' => false,
+ 'attendance_requirement' => 'all_days',
+ 'xp_on_confirmed' => 0,
+ 'xp_on_checked_in' => 0,
+ 'xp_on_attended' => 0,
+ ];
+
+ /** @return BelongsTo */
+ public function event(): BelongsTo
+ {
+ return $this->belongsTo(Event::class);
+ }
+
+ protected static function newFactory(): EnrollmentPolicyFactory
+ {
+ return EnrollmentPolicyFactory::new();
+ }
+
+ /** @return array */
+ protected function casts(): array
+ {
+ return [
+ 'enrollment_method' => EnrollmentMethod::class,
+ 'check_in_method' => CheckInMethod::class,
+ 'attendance_requirement' => AttendanceRequirement::class,
+ 'has_waitlist' => 'boolean',
+ 'application_schema' => 'array',
+ ];
+ }
+}
diff --git a/app-modules/events/src/Enrollment/Models/EnrollmentTransition.php b/app-modules/events/src/Enrollment/Models/EnrollmentTransition.php
new file mode 100644
index 000000000..2a49d1674
--- /dev/null
+++ b/app-modules/events/src/Enrollment/Models/EnrollmentTransition.php
@@ -0,0 +1,76 @@
+|null $metadata
+ * @property Carbon $created_at
+ */
+#[Table('events_enrollment_transitions')]
+final class EnrollmentTransition extends Model
+{
+ /** @use HasFactory */
+ use HasFactory;
+ use HasUuids;
+
+ public const UPDATED_AT = null;
+
+ protected $fillable = [
+ 'enrollment_id',
+ 'from_status',
+ 'to_status',
+ 'actor_id',
+ 'triggered_by',
+ 'reason',
+ 'metadata',
+ ];
+
+ /** @return BelongsTo */
+ public function enrollment(): BelongsTo
+ {
+ return $this->belongsTo(Enrollment::class);
+ }
+
+ /** @return BelongsTo */
+ public function actor(): BelongsTo
+ {
+ return $this->belongsTo(User::class, 'actor_id');
+ }
+
+ protected static function newFactory(): EnrollmentTransitionFactory
+ {
+ return EnrollmentTransitionFactory::new();
+ }
+
+ /** @return array */
+ protected function casts(): array
+ {
+ return [
+ 'from_status' => EnrollmentStatus::class,
+ 'to_status' => EnrollmentStatus::class,
+ 'triggered_by' => TriggeredBy::class,
+ 'metadata' => 'array',
+ 'created_at' => 'datetime',
+ ];
+ }
+}
diff --git a/app-modules/events/src/Event/Enums/EventStatus.php b/app-modules/events/src/Event/Enums/EventStatus.php
new file mode 100644
index 000000000..f28a147de
--- /dev/null
+++ b/app-modules/events/src/Event/Enums/EventStatus.php
@@ -0,0 +1,58 @@
+value);
+ }
+
+ public function getColor(): array
+ {
+ return match ($this) {
+ self::Draft => Color::Gray,
+ self::Published => Color::Green,
+ self::Completed => Color::Blue,
+ self::Cancelled => Color::Red,
+ };
+ }
+
+ public function getIcon(): Heroicon
+ {
+ return match ($this) {
+ self::Draft => Heroicon::PencilSquare,
+ self::Published => Heroicon::GlobeAlt,
+ self::Completed => Heroicon::CheckBadge,
+ self::Cancelled => Heroicon::XCircle,
+ };
+ }
+
+ public function canTransitionTo(self $target): bool
+ {
+ return match ($this) {
+ self::Draft => in_array($target, [self::Published, self::Cancelled], strict: true),
+ self::Published => in_array($target, [self::Completed, self::Cancelled], strict: true),
+ self::Completed, self::Cancelled => false,
+ };
+ }
+
+ public function isTerminal(): bool
+ {
+ return in_array($this, [self::Completed, self::Cancelled], strict: true);
+ }
+}
diff --git a/app-modules/events/src/Event/Enums/EventType.php b/app-modules/events/src/Event/Enums/EventType.php
new file mode 100644
index 000000000..ac3b39e6e
--- /dev/null
+++ b/app-modules/events/src/Event/Enums/EventType.php
@@ -0,0 +1,41 @@
+value);
+ }
+
+ public function getColor(): array
+ {
+ return match ($this) {
+ self::Meetup => Color::Blue,
+ self::Workshop => Color::Amber,
+ self::Conference => Color::Purple,
+ };
+ }
+
+ public function getIcon(): Heroicon
+ {
+ return match ($this) {
+ self::Meetup => Heroicon::UserGroup,
+ self::Workshop => Heroicon::CodeBracket,
+ self::Conference => Heroicon::Megaphone,
+ };
+ }
+}
diff --git a/app-modules/events/src/Event/Models/Event.php b/app-modules/events/src/Event/Models/Event.php
new file mode 100644
index 000000000..0715349cd
--- /dev/null
+++ b/app-modules/events/src/Event/Models/Event.php
@@ -0,0 +1,126 @@
+ */
+ use HasFactory;
+ use HasUuids;
+
+ protected $fillable = [
+ 'tenant_id',
+ 'slug',
+ 'title',
+ 'description',
+ 'event_type',
+ 'location',
+ 'starts_at',
+ 'ends_at',
+ 'status',
+ ];
+
+ protected $attributes = [
+ 'status' => 'draft',
+ ];
+
+ /** @return BelongsTo */
+ public function tenant(): BelongsTo
+ {
+ return $this->belongsTo(Tenant::class);
+ }
+
+ /** @return HasOne */
+ public function enrollmentPolicy(): HasOne
+ {
+ return $this->hasOne(EnrollmentPolicy::class);
+ }
+
+ /** @return HasMany */
+ public function enrollments(): HasMany
+ {
+ return $this->hasMany(Enrollment::class);
+ }
+
+ /** @return HasMany */
+ public function checkInCodes(): HasMany
+ {
+ return $this->hasMany(CheckInCode::class);
+ }
+
+ public function isPast(): bool
+ {
+ return $this->ends_at->isPast();
+ }
+
+ public function totalDays(): int
+ {
+ return (int) $this->starts_at->startOfDay()->diffInDays($this->ends_at->startOfDay()) + 1;
+ }
+
+ protected static function newFactory(): EventFactory
+ {
+ return EventFactory::new();
+ }
+
+ protected function scopePublished(Builder $query): void
+ {
+ $query->where('status', EventStatus::Published);
+ }
+
+ protected function scopeUpcoming(Builder $query): void
+ {
+ $query->where('starts_at', '>', now());
+ }
+
+ protected function scopeActive(Builder $query): void
+ {
+ $query->where('status', EventStatus::Published)
+ ->where('starts_at', '>', now());
+ }
+
+ /** @return array */
+ protected function casts(): array
+ {
+ return [
+ 'event_type' => EventType::class,
+ 'starts_at' => 'datetime',
+ 'ends_at' => 'datetime',
+ 'status' => EventStatus::class,
+ ];
+ }
+}
diff --git a/app-modules/events/src/EventsServiceProvider.php b/app-modules/events/src/EventsServiceProvider.php
index 42bbe5d1d..8c205a82b 100644
--- a/app-modules/events/src/EventsServiceProvider.php
+++ b/app-modules/events/src/EventsServiceProvider.php
@@ -11,5 +11,7 @@ class EventsServiceProvider extends ServiceProvider
public function boot(): void
{
$this->loadMigrationsFrom(__DIR__.'/../database/migrations');
+ $this->loadViewsFrom(__DIR__.'/../resources/views', 'events');
+ $this->loadTranslationsFrom(__DIR__.'/../lang', 'events');
}
}
diff --git a/app-modules/events/tests/Feature/EventFactoriesTest.php b/app-modules/events/tests/Feature/EventFactoriesTest.php
new file mode 100644
index 000000000..00a852005
--- /dev/null
+++ b/app-modules/events/tests/Feature/EventFactoriesTest.php
@@ -0,0 +1,98 @@
+create();
+
+ expect($event->id)->not->toBeNull()
+ ->and($event->title)->not->toBeEmpty()
+ ->and($event->starts_at)->not->toBeNull()
+ ->and($event->ends_at->isAfter($event->starts_at))->toBeTrue();
+});
+
+test('when creating an enrollment policy via factory, then it is linked to an event', function (): void {
+ $policy = EnrollmentPolicy::factory()->create();
+
+ expect($policy->id)->not->toBeNull()
+ ->and($policy->event_id)->not->toBeNull()
+ ->and($policy->event)->toBeInstanceOf(Event::class);
+});
+
+test('when creating an enrollment via factory, then it is linked to an event and user', function (): void {
+ $enrollment = Enrollment::factory()->create();
+
+ expect($enrollment->id)->not->toBeNull()
+ ->and($enrollment->event_id)->not->toBeNull()
+ ->and($enrollment->user_id)->not->toBeNull();
+});
+
+test('when creating an enrollment transition via factory, then it is linked to an enrollment', function (): void {
+ $transition = EnrollmentTransition::factory()->create();
+
+ expect($transition->id)->not->toBeNull()
+ ->and($transition->enrollment_id)->not->toBeNull()
+ ->and($transition->to_status)->not->toBeNull();
+});
+
+test('when creating a check-in via factory, then it is linked to an enrollment', function (): void {
+ $checkIn = CheckIn::factory()->create();
+
+ expect($checkIn->id)->not->toBeNull()
+ ->and($checkIn->enrollment_id)->not->toBeNull()
+ ->and($checkIn->check_in)->not->toBeNull();
+});
+
+test('when creating a check-in code via factory, then it is linked to an event with valid dates', function (): void {
+ $code = CheckInCode::factory()->create();
+
+ expect($code->id)->not->toBeNull()
+ ->and($code->event_id)->not->toBeNull()
+ ->and($code->code)->not->toBeEmpty()
+ ->and($code->expires_at->isAfter($code->starts_at))->toBeTrue();
+});
+
+test('when creating a qr token via factory, then it is linked to an enrollment with a 64-char token', function (): void {
+ $token = QrToken::factory()->create();
+
+ expect($token->id)->not->toBeNull()
+ ->and($token->enrollment_id)->not->toBeNull()
+ ->and(mb_strlen($token->token))->toBe(64);
+});
+
+test('when an event has an enrollment policy, then it is accessible via the relationship', function (): void {
+ $event = Event::factory()
+ ->has(EnrollmentPolicy::factory(), 'enrollmentPolicy')
+ ->create();
+
+ expect($event->enrollmentPolicy)->toBeInstanceOf(EnrollmentPolicy::class)
+ ->and($event->enrollmentPolicy->event_id)->toBe($event->id);
+});
+
+test('when an event has enrollments, then they are accessible via the relationship', function (): void {
+ $event = Event::factory()
+ ->has(Enrollment::factory()->count(3), 'enrollments')
+ ->create();
+
+ expect($event->enrollments)->toBeInstanceOf(Collection::class)
+ ->and($event->enrollments)->toHaveCount(3)
+ ->and($event->enrollments->first()->event_id)->toBe($event->id);
+});
+
+test('when an event has check-in codes, then they are accessible via the relationship', function (): void {
+ $event = Event::factory()
+ ->has(CheckInCode::factory()->count(2), 'checkInCodes')
+ ->create();
+
+ expect($event->checkInCodes)->toHaveCount(2)
+ ->and($event->checkInCodes->first()->event_id)->toBe($event->id);
+});
diff --git a/app-modules/events/tests/Feature/EventResourceTest.php b/app-modules/events/tests/Feature/EventResourceTest.php
new file mode 100644
index 000000000..3c8556881
--- /dev/null
+++ b/app-modules/events/tests/Feature/EventResourceTest.php
@@ -0,0 +1,112 @@
+create(['username' => 'events-test-admin']);
+ $tenant = Tenant::factory()->create(['slug' => 'he4rt-dev']);
+ $tenant->members()->attach($admin);
+
+ config(['he4rt.admins' => 'events-test-admin']);
+ $this->actingAs($admin);
+
+ Filament::setCurrentPanel(Filament::getPanel('admin'));
+ Filament::setTenant($tenant);
+});
+
+test('when visiting the events list page, then it renders successfully', function (): void {
+ livewire(ListEvents::class)
+ ->assertSuccessful();
+});
+
+test('when an event exists, then it appears in the events list', function (): void {
+ $event = Event::factory()->create(['title' => 'He4rt Meetup #42']);
+
+ livewire(ListEvents::class)
+ ->assertSee($event->title);
+});
+
+test('when visiting the create event page, then it renders successfully', function (): void {
+ livewire(CreateEvent::class)
+ ->assertSuccessful();
+});
+
+test('when visiting the edit event page, then it renders successfully', function (): void {
+ $event = Event::factory()->create();
+
+ livewire(EditEvent::class, ['record' => $event->getRouteKey()])
+ ->assertSuccessful();
+});
+
+test('when checking the event resource model, then it points to Event', function (): void {
+ expect(EventResource::getModel())->toBe(Event::class);
+});
+
+test('when submitting the create form with valid data, then event and enrollment policy are persisted', function (): void {
+ $startsAt = now()->addDay();
+
+ livewire(CreateEvent::class)
+ ->fillForm([
+ 'title' => 'He4rt Meetup #42',
+ 'slug' => 'he4rt-meetup-42',
+ 'event_type' => EventType::Meetup,
+ 'starts_at' => $startsAt,
+ 'ends_at' => $startsAt->clone()->addHours(3),
+ 'enrollmentPolicy' => [
+ 'enrollment_method' => EnrollmentMethod::Rsvp,
+ 'check_in_method' => CheckInMethod::Manual,
+ 'attendance_requirement' => AttendanceRequirement::AllDays,
+ 'xp_on_confirmed' => 0,
+ 'xp_on_checked_in' => 0,
+ 'xp_on_attended' => 0,
+ ],
+ ])
+ ->call('create')
+ ->assertHasNoFormErrors();
+
+ $event = Event::query()->where('slug', 'he4rt-meetup-42')->first();
+ expect($event)->not->toBeNull()
+ ->and($event->title)->toBe('He4rt Meetup #42')
+ ->and($event->enrollmentPolicy)->not->toBeNull()
+ ->and($event->enrollmentPolicy->enrollment_method)->toBe(EnrollmentMethod::Rsvp);
+});
+
+test('when submitting the edit form with a new title, then it is updated in the database', function (): void {
+ $event = Event::factory()
+ ->has(EnrollmentPolicy::factory(), 'enrollmentPolicy')
+ ->create(['title' => 'Old Title']);
+
+ livewire(EditEvent::class, ['record' => $event->getRouteKey()])
+ ->fillForm(['title' => 'New Title'])
+ ->call('save')
+ ->assertHasNoFormErrors();
+
+ expect($event->fresh()->title)->toBe('New Title');
+});
+
+test('when visiting the enrollments relation manager, then it renders successfully', function (): void {
+ $event = Event::factory()->create();
+
+ livewire(EnrollmentsRelationManager::class, [
+ 'ownerRecord' => $event,
+ 'pageClass' => EditEvent::class,
+ ])
+ ->assertSuccessful();
+});
diff --git a/app-modules/events/tests/Unit/EnrollmentStatusTest.php b/app-modules/events/tests/Unit/EnrollmentStatusTest.php
new file mode 100644
index 000000000..8d4930dd7
--- /dev/null
+++ b/app-modules/events/tests/Unit/EnrollmentStatusTest.php
@@ -0,0 +1,61 @@
+canTransitionTo(EnrollmentStatus::Confirmed))->toBeTrue()
+ ->and(EnrollmentStatus::Pending->canTransitionTo(EnrollmentStatus::Rejected))->toBeTrue()
+ ->and(EnrollmentStatus::Pending->canTransitionTo(EnrollmentStatus::Cancelled))->toBeTrue();
+});
+
+test('when a pending enrollment is evaluated, then it can transition to waitlisted when capacity is full', function (): void {
+ expect(EnrollmentStatus::Pending->canTransitionTo(EnrollmentStatus::Waitlisted))->toBeTrue();
+});
+
+test('when a pending enrollment is evaluated, then it cannot transition to mid-flow or terminal states', function (): void {
+ expect(EnrollmentStatus::Pending->canTransitionTo(EnrollmentStatus::CheckedIn))->toBeFalse()
+ ->and(EnrollmentStatus::Pending->canTransitionTo(EnrollmentStatus::Attended))->toBeFalse()
+ ->and(EnrollmentStatus::Pending->canTransitionTo(EnrollmentStatus::NoShow))->toBeFalse();
+});
+
+test('when a waitlisted enrollment is evaluated, then it can transition to confirmed or cancelled', function (): void {
+ expect(EnrollmentStatus::Waitlisted->canTransitionTo(EnrollmentStatus::Confirmed))->toBeTrue()
+ ->and(EnrollmentStatus::Waitlisted->canTransitionTo(EnrollmentStatus::Cancelled))->toBeTrue();
+});
+
+test('when a confirmed enrollment is evaluated, then it can transition to checked_in, cancelled, or no_show', function (): void {
+ expect(EnrollmentStatus::Confirmed->canTransitionTo(EnrollmentStatus::CheckedIn))->toBeTrue()
+ ->and(EnrollmentStatus::Confirmed->canTransitionTo(EnrollmentStatus::Cancelled))->toBeTrue()
+ ->and(EnrollmentStatus::Confirmed->canTransitionTo(EnrollmentStatus::NoShow))->toBeTrue();
+});
+
+test('when a checked_in enrollment is evaluated, then it can transition to attended or no_show', function (): void {
+ expect(EnrollmentStatus::CheckedIn->canTransitionTo(EnrollmentStatus::Attended))->toBeTrue()
+ ->and(EnrollmentStatus::CheckedIn->canTransitionTo(EnrollmentStatus::NoShow))->toBeTrue()
+ ->and(EnrollmentStatus::CheckedIn->canTransitionTo(EnrollmentStatus::Confirmed))->toBeFalse()
+ ->and(EnrollmentStatus::CheckedIn->canTransitionTo(EnrollmentStatus::Cancelled))->toBeFalse();
+});
+
+test('when a terminal enrollment status is evaluated, then it cannot transition to any state', function (EnrollmentStatus $status): void {
+ foreach (EnrollmentStatus::cases() as $target) {
+ expect($status->canTransitionTo($target))->toBeFalse();
+ }
+})->with([
+ EnrollmentStatus::Attended,
+ EnrollmentStatus::Cancelled,
+ EnrollmentStatus::Rejected,
+ EnrollmentStatus::NoShow,
+]);
+
+test('when enrollment statuses are evaluated, then terminal states are correctly identified', function (): void {
+ expect(EnrollmentStatus::Attended->isTerminal())->toBeTrue()
+ ->and(EnrollmentStatus::Cancelled->isTerminal())->toBeTrue()
+ ->and(EnrollmentStatus::Rejected->isTerminal())->toBeTrue()
+ ->and(EnrollmentStatus::NoShow->isTerminal())->toBeTrue()
+ ->and(EnrollmentStatus::Pending->isTerminal())->toBeFalse()
+ ->and(EnrollmentStatus::Confirmed->isTerminal())->toBeFalse()
+ ->and(EnrollmentStatus::Waitlisted->isTerminal())->toBeFalse()
+ ->and(EnrollmentStatus::CheckedIn->isTerminal())->toBeFalse();
+});
diff --git a/app-modules/events/tests/Unit/EventStatusTest.php b/app-modules/events/tests/Unit/EventStatusTest.php
new file mode 100644
index 000000000..2704b146d
--- /dev/null
+++ b/app-modules/events/tests/Unit/EventStatusTest.php
@@ -0,0 +1,39 @@
+canTransitionTo(EventStatus::Published))->toBeTrue()
+ ->and(EventStatus::Draft->canTransitionTo(EventStatus::Cancelled))->toBeTrue();
+});
+
+test('when a draft event is evaluated, then it cannot transition to completed', function (): void {
+ expect(EventStatus::Draft->canTransitionTo(EventStatus::Completed))->toBeFalse();
+});
+
+test('when a published event is evaluated, then it can transition to completed or cancelled', function (): void {
+ expect(EventStatus::Published->canTransitionTo(EventStatus::Completed))->toBeTrue()
+ ->and(EventStatus::Published->canTransitionTo(EventStatus::Cancelled))->toBeTrue();
+});
+
+test('when a published event is evaluated, then it cannot transition to draft', function (): void {
+ expect(EventStatus::Published->canTransitionTo(EventStatus::Draft))->toBeFalse();
+});
+
+test('when a terminal event status is evaluated, then it cannot transition to any state', function (EventStatus $status): void {
+ foreach (EventStatus::cases() as $target) {
+ expect($status->canTransitionTo($target))->toBeFalse();
+ }
+})->with([
+ EventStatus::Completed,
+ EventStatus::Cancelled,
+]);
+
+test('when event statuses are evaluated, then terminal states are correctly identified', function (): void {
+ expect(EventStatus::Completed->isTerminal())->toBeTrue()
+ ->and(EventStatus::Cancelled->isTerminal())->toBeTrue()
+ ->and(EventStatus::Draft->isTerminal())->toBeFalse()
+ ->and(EventStatus::Published->isTerminal())->toBeFalse();
+});
diff --git a/app-modules/panel-admin/src/Filament/Resources/Events/EventResource.php b/app-modules/panel-admin/src/Filament/Resources/Events/EventResource.php
new file mode 100644
index 000000000..0fde383b8
--- /dev/null
+++ b/app-modules/panel-admin/src/Filament/Resources/Events/EventResource.php
@@ -0,0 +1,61 @@
+ ListEvents::route('/'),
+ 'create' => CreateEvent::route('/create'),
+ 'edit' => EditEvent::route('/{record}/edit'),
+ ];
+ }
+}
diff --git a/app-modules/panel-admin/src/Filament/Resources/Events/Pages/CreateEvent.php b/app-modules/panel-admin/src/Filament/Resources/Events/Pages/CreateEvent.php
new file mode 100644
index 000000000..44e70e1c0
--- /dev/null
+++ b/app-modules/panel-admin/src/Filament/Resources/Events/Pages/CreateEvent.php
@@ -0,0 +1,13 @@
+recordTitleAttribute('id')
+ ->columns([
+ TextColumn::make('user.name')
+ ->label('Participant')
+ ->searchable()
+ ->sortable(),
+
+ TextColumn::make('status')
+ ->label('Status')
+ ->badge()
+ ->sortable(),
+
+ TextColumn::make('enrolled_at')
+ ->label('Enrolled At')
+ ->dateTime()
+ ->sortable(),
+
+ TextColumn::make('confirmed_at')
+ ->label('Confirmed At')
+ ->dateTime()
+ ->sortable(),
+
+ TextColumn::make('cancelled_at')
+ ->label('Cancelled At')
+ ->dateTime()
+ ->sortable()
+ ->toggleable(isToggledHiddenByDefault: true),
+ ])
+ ->filters([
+ SelectFilter::make('status')
+ ->label('Status')
+ ->options(EnrollmentStatus::class),
+ ]);
+ }
+}
diff --git a/app-modules/panel-admin/src/Filament/Resources/Events/Schemas/EventForm.php b/app-modules/panel-admin/src/Filament/Resources/Events/Schemas/EventForm.php
new file mode 100644
index 000000000..1b0853d09
--- /dev/null
+++ b/app-modules/panel-admin/src/Filament/Resources/Events/Schemas/EventForm.php
@@ -0,0 +1,143 @@
+columns(2)
+ ->components([
+ TextInput::make('title')
+ ->label('Title')
+ ->required()
+ ->maxLength(200)
+ ->columnSpanFull(),
+
+ TextInput::make('slug')
+ ->label('Slug')
+ ->required()
+ ->maxLength(120),
+
+ Select::make('event_type')
+ ->label('Type')
+ ->options(EventType::class)
+ ->required(),
+
+ Select::make('tenant_id')
+ ->label('Tenant')
+ ->relationship('tenant', 'name')
+ ->searchable()
+ ->nullable(),
+
+ TextInput::make('location')
+ ->label('Location')
+ ->nullable(),
+
+ Textarea::make('description')
+ ->label('Description')
+ ->nullable()
+ ->columnSpanFull(),
+
+ DateTimePicker::make('starts_at')
+ ->label('Starts At')
+ ->required(),
+
+ DateTimePicker::make('ends_at')
+ ->label('Ends At')
+ ->required()
+ ->after('starts_at'),
+
+ Toggle::make('active')
+ ->label('Published')
+ ->default(false)
+ ->columnSpanFull(),
+
+ Section::make('Enrollment Policy')
+ ->relationship('enrollmentPolicy')
+ ->columns(2)
+ ->schema([
+ Select::make('enrollment_method')
+ ->label('Enrollment Method')
+ ->options(EnrollmentMethod::class)
+ ->live()
+ ->required(),
+
+ Select::make('check_in_method')
+ ->label('Check-in Method')
+ ->options(CheckInMethod::class)
+ ->required(),
+
+ TextInput::make('capacity')
+ ->label('Capacity')
+ ->integer()
+ ->minValue(1)
+ ->nullable(),
+
+ Toggle::make('has_waitlist')
+ ->label('Waitlist Enabled')
+ ->default(false),
+
+ Select::make('attendance_requirement')
+ ->label('Attendance Requirement')
+ ->options(AttendanceRequirement::class)
+ ->required(),
+
+ TextInput::make('minimum_days')
+ ->label('Minimum Days')
+ ->integer()
+ ->minValue(1)
+ ->nullable(),
+
+ TextInput::make('cancellation_deadline_hours')
+ ->label('Cancellation Deadline (hours before event)')
+ ->integer()
+ ->minValue(0)
+ ->nullable(),
+
+ TextInput::make('xp_on_confirmed')
+ ->label('XP on Confirmed')
+ ->integer()
+ ->minValue(0)
+ ->default(0),
+
+ TextInput::make('xp_on_checked_in')
+ ->label('XP on Checked-in')
+ ->integer()
+ ->minValue(0)
+ ->default(0),
+
+ TextInput::make('xp_on_attended')
+ ->label('XP on Attended')
+ ->integer()
+ ->minValue(0)
+ ->default(0),
+
+ KeyValue::make('application_schema')
+ ->label('Application Form Schema')
+ ->keyLabel('Field name')
+ ->valueLabel('Field type / label')
+ ->nullable()
+ ->columnSpanFull()
+ ->visible(fn (Get $get): bool => $get('enrollment_method') === EnrollmentMethod::Application->value),
+ ]),
+ ]);
+ }
+}
diff --git a/app-modules/panel-admin/src/Filament/Resources/Events/Schemas/EventInfolist.php b/app-modules/panel-admin/src/Filament/Resources/Events/Schemas/EventInfolist.php
new file mode 100644
index 000000000..192d12994
--- /dev/null
+++ b/app-modules/panel-admin/src/Filament/Resources/Events/Schemas/EventInfolist.php
@@ -0,0 +1,96 @@
+columns(2)
+ ->components([
+ TextEntry::make('title')
+ ->label('Title')
+ ->columnSpanFull(),
+
+ TextEntry::make('slug')
+ ->label('Slug'),
+
+ TextEntry::make('event_type')
+ ->label('Type')
+ ->badge(),
+
+ TextEntry::make('tenant.name')
+ ->label('Tenant'),
+
+ TextEntry::make('location')
+ ->label('Location'),
+
+ TextEntry::make('description')
+ ->label('Description')
+ ->columnSpanFull(),
+
+ TextEntry::make('starts_at')
+ ->label('Starts At')
+ ->dateTime(),
+
+ TextEntry::make('ends_at')
+ ->label('Ends At')
+ ->dateTime(),
+
+ IconEntry::make('active')
+ ->label('Published')
+ ->boolean(),
+
+ TextEntry::make('created_at')
+ ->label('Created At')
+ ->dateTime(),
+
+ Section::make('Enrollment Policy')
+ ->relationship('enrollmentPolicy')
+ ->columns(2)
+ ->schema([
+ TextEntry::make('enrollment_method')
+ ->label('Enrollment Method')
+ ->badge(),
+
+ TextEntry::make('check_in_method')
+ ->label('Check-in Method')
+ ->badge(),
+
+ TextEntry::make('capacity')
+ ->label('Capacity'),
+
+ IconEntry::make('has_waitlist')
+ ->label('Waitlist Enabled')
+ ->boolean(),
+
+ TextEntry::make('attendance_requirement')
+ ->label('Attendance Requirement')
+ ->badge(),
+
+ TextEntry::make('minimum_days')
+ ->label('Minimum Days'),
+
+ TextEntry::make('cancellation_deadline_hours')
+ ->label('Cancellation Deadline (hours before event)'),
+
+ TextEntry::make('xp_on_confirmed')
+ ->label('XP on Confirmed'),
+
+ TextEntry::make('xp_on_checked_in')
+ ->label('XP on Checked-in'),
+
+ TextEntry::make('xp_on_attended')
+ ->label('XP on Attended'),
+ ]),
+ ]);
+ }
+}
diff --git a/app-modules/panel-admin/src/Filament/Resources/Events/Tables/EventsTable.php b/app-modules/panel-admin/src/Filament/Resources/Events/Tables/EventsTable.php
new file mode 100644
index 000000000..70889e13b
--- /dev/null
+++ b/app-modules/panel-admin/src/Filament/Resources/Events/Tables/EventsTable.php
@@ -0,0 +1,73 @@
+columns([
+ TextColumn::make('title')
+ ->label('Title')
+ ->searchable()
+ ->sortable(),
+
+ TextColumn::make('event_type')
+ ->label('Type')
+ ->badge()
+ ->sortable(),
+
+ TextColumn::make('tenant.name')
+ ->label('Tenant')
+ ->searchable()
+ ->sortable(),
+
+ TextColumn::make('starts_at')
+ ->label('Starts At')
+ ->dateTime()
+ ->sortable(),
+
+ TextColumn::make('ends_at')
+ ->label('Ends At')
+ ->dateTime()
+ ->sortable(),
+
+ IconColumn::make('active')
+ ->label('Published')
+ ->boolean(),
+
+ TextColumn::make('created_at')
+ ->label('Created At')
+ ->dateTime()
+ ->sortable()
+ ->toggleable(isToggledHiddenByDefault: true),
+ ])
+ ->filters([
+ SelectFilter::make('event_type')
+ ->label('Type')
+ ->options(EventType::class),
+ ])
+ ->recordActions([
+ EditAction::make(),
+ DeleteAction::make(),
+ ])
+ ->toolbarActions([
+ BulkActionGroup::make([
+ DeleteBulkAction::make(),
+ ]),
+ ]);
+ }
+}
diff --git a/app-modules/panel-admin/src/PanelAdminServiceProvider.php b/app-modules/panel-admin/src/PanelAdminServiceProvider.php
index a8ff60d69..532d5a8ee 100644
--- a/app-modules/panel-admin/src/PanelAdminServiceProvider.php
+++ b/app-modules/panel-admin/src/PanelAdminServiceProvider.php
@@ -7,6 +7,7 @@
use Filament\Navigation\NavigationBuilder;
use Filament\Navigation\NavigationItem;
use Filament\Panel;
+use He4rt\PanelAdmin\Filament\Resources\Events\EventResource;
use He4rt\PanelAdmin\Filament\Resources\ExternalIdentities\ExternalIdentityResource;
use He4rt\PanelAdmin\Marketing\MarketingCluster;
use He4rt\PanelAdmin\Moderation\Livewire\AppealQueue;
@@ -34,6 +35,7 @@ public function register(): void
->navigation($this->buildNavigation(...))
->resources([
ExternalIdentityResource::class,
+ EventResource::class,
])
->discoverResources(
in: __DIR__.'/Moderation/Resources',
@@ -104,6 +106,7 @@ private function defaultNavigation(NavigationBuilder $builder): NavigationBuilde
...MarketingCluster::getNavigationItems(),
...TwitchCluster::getNavigationItems(),
...ExternalIdentityResource::getNavigationItems(),
+ ...EventResource::getNavigationItems(),
]);
}
From 779f7598868703cffd1c2c6f7eb00d5277b33d33 Mon Sep 17 00:00:00 2001
From: Bruna Domingues Leite
Date: Fri, 22 May 2026 14:33:10 -0300
Subject: [PATCH 03/18] feat(events): rsvp enrollment end-to-end (#275)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
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=`,
`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
Co-authored-by: GabrielFV
Co-authored-by: Daniel Reis
---
.../factories/EnrollmentPolicyFactory.php | 7 +
.../database/factories/EventFactory.php | 93 +++++++
app-modules/events/lang/en/exceptions.php | 11 +
app-modules/events/lang/en/pages.php | 15 ++
app-modules/events/lang/pt_BR/exceptions.php | 11 +
app-modules/events/lang/pt_BR/pages.php | 15 ++
.../Enrollment/Actions/EnrollUserAction.php | 164 +++++++++++++
.../src/Enrollment/DTOs/EnrollUserDTO.php | 24 ++
.../src/Enrollment/Enums/EnrollmentStatus.php | 23 ++
.../Enrollment/Events/EnrollmentConfirmed.php | 20 ++
.../Exceptions/EnrollmentException.php | 51 ++++
.../events/src/Event/Enums/EventStatus.php | 18 ++
app-modules/events/src/Event/Models/Event.php | 7 +-
.../tests/Feature/EnrollUserActionTest.php | 230 ++++++++++++++++++
.../tests/Feature/EventFactoriesTest.php | 63 ++++-
.../tests/Feature/EventResourceTest.php | 27 ++
.../tests/Unit/EnrollmentStatusTest.php | 15 ++
.../Resources/Events/Schemas/EventForm.php | 25 +-
.../Events/Schemas/EventInfolist.php | 6 +-
.../Resources/Events/Tables/EventsTable.php | 13 +-
.../livewire/events/event-detail.blade.php | 49 ++++
.../livewire/events/events-list.blade.php | 43 ++++
.../livewire/events/my-events-list.blade.php | 26 ++
.../partials/my-events-list-item.blade.php | 27 ++
.../resources/views/pages/event.blade.php | 13 +
.../resources/views/pages/events.blade.php | 5 +
.../resources/views/pages/my-events.blade.php | 5 +
.../src/Livewire/Events/EventDetail.php | 115 +++++++++
.../src/Livewire/Events/EventsList.php | 35 +++
.../src/Livewire/Events/MyEventsList.php | 45 ++++
app-modules/panel-app/src/Pages/EventPage.php | 37 +++
.../panel-app/src/Pages/EventsPage.php | 21 ++
.../panel-app/src/Pages/MyEventsPage.php | 21 ++
.../panel-app/src/PanelAppServiceProvider.php | 7 +
.../Feature/Events/RsvpEnrollmentTest.php | 182 ++++++++++++++
app/Providers/Filament/AppPanelProvider.php | 6 +
36 files changed, 1462 insertions(+), 13 deletions(-)
create mode 100644 app-modules/events/lang/en/exceptions.php
create mode 100644 app-modules/events/lang/en/pages.php
create mode 100644 app-modules/events/lang/pt_BR/exceptions.php
create mode 100644 app-modules/events/lang/pt_BR/pages.php
create mode 100644 app-modules/events/src/Enrollment/Actions/EnrollUserAction.php
create mode 100644 app-modules/events/src/Enrollment/DTOs/EnrollUserDTO.php
create mode 100644 app-modules/events/src/Enrollment/Events/EnrollmentConfirmed.php
create mode 100644 app-modules/events/src/Enrollment/Exceptions/EnrollmentException.php
create mode 100644 app-modules/events/tests/Feature/EnrollUserActionTest.php
create mode 100644 app-modules/panel-app/resources/views/livewire/events/event-detail.blade.php
create mode 100644 app-modules/panel-app/resources/views/livewire/events/events-list.blade.php
create mode 100644 app-modules/panel-app/resources/views/livewire/events/my-events-list.blade.php
create mode 100644 app-modules/panel-app/resources/views/livewire/events/partials/my-events-list-item.blade.php
create mode 100644 app-modules/panel-app/resources/views/pages/event.blade.php
create mode 100644 app-modules/panel-app/resources/views/pages/events.blade.php
create mode 100644 app-modules/panel-app/resources/views/pages/my-events.blade.php
create mode 100644 app-modules/panel-app/src/Livewire/Events/EventDetail.php
create mode 100644 app-modules/panel-app/src/Livewire/Events/EventsList.php
create mode 100644 app-modules/panel-app/src/Livewire/Events/MyEventsList.php
create mode 100644 app-modules/panel-app/src/Pages/EventPage.php
create mode 100644 app-modules/panel-app/src/Pages/EventsPage.php
create mode 100644 app-modules/panel-app/src/Pages/MyEventsPage.php
create mode 100644 app-modules/panel-app/tests/Feature/Events/RsvpEnrollmentTest.php
diff --git a/app-modules/events/database/factories/EnrollmentPolicyFactory.php b/app-modules/events/database/factories/EnrollmentPolicyFactory.php
index 152d5ffe4..45add3dfc 100644
--- a/app-modules/events/database/factories/EnrollmentPolicyFactory.php
+++ b/app-modules/events/database/factories/EnrollmentPolicyFactory.php
@@ -33,4 +33,11 @@ public function definition(): array
'application_schema' => null,
];
}
+
+ public function rsvp(): static
+ {
+ return $this->state(fn (): array => [
+ 'enrollment_method' => EnrollmentMethod::Rsvp,
+ ]);
+ }
}
diff --git a/app-modules/events/database/factories/EventFactory.php b/app-modules/events/database/factories/EventFactory.php
index 9dc8eb7bd..8dfb4f44f 100644
--- a/app-modules/events/database/factories/EventFactory.php
+++ b/app-modules/events/database/factories/EventFactory.php
@@ -4,6 +4,10 @@
namespace He4rt\Events\Database\Factories;
+use He4rt\Events\CheckIn\Enums\CheckInMethod;
+use He4rt\Events\Enrollment\Enums\AttendanceRequirement;
+use He4rt\Events\Enrollment\Enums\EnrollmentMethod;
+use He4rt\Events\Enrollment\Models\EnrollmentPolicy;
use He4rt\Events\Event\Enums\EventStatus;
use He4rt\Events\Event\Enums\EventType;
use He4rt\Events\Event\Models\Event;
@@ -32,4 +36,93 @@ public function definition(): array
'status' => EventStatus::Draft,
];
}
+
+ public function published(): static
+ {
+ return $this->state(fn (): array => [
+ 'status' => EventStatus::Published,
+ ]);
+ }
+
+ public function upcoming(): static
+ {
+ $startsAt = Date::now()->addDays(7);
+
+ return $this->state(fn (): array => [
+ 'starts_at' => $startsAt,
+ 'ends_at' => $startsAt->clone()->addHours(3),
+ ]);
+ }
+
+ public function past(): static
+ {
+ $startsAt = Date::now()->subDays(7);
+
+ return $this->state(fn (): array => [
+ 'starts_at' => $startsAt,
+ 'ends_at' => $startsAt->clone()->addHours(3),
+ ]);
+ }
+
+ public function asWorkshop(): static
+ {
+ return $this->state(fn (): array => [
+ 'event_type' => EventType::Workshop,
+ 'status' => EventStatus::Published,
+ ])->afterCreating(function (Event $event): void {
+ EnrollmentPolicy::factory()->create([
+ 'event_id' => $event->id,
+ 'enrollment_method' => EnrollmentMethod::RsvpCheckin,
+ 'check_in_method' => CheckInMethod::NumericCode,
+ 'capacity' => 50,
+ 'has_waitlist' => true,
+ 'attendance_requirement' => AttendanceRequirement::AllDays,
+ 'xp_on_confirmed' => 100,
+ 'xp_on_checked_in' => 200,
+ 'xp_on_attended' => 500,
+ ]);
+ });
+ }
+
+ public function asMeetup(): static
+ {
+ return $this->state(fn (): array => [
+ 'event_type' => EventType::Meetup,
+ 'status' => EventStatus::Published,
+ ])->afterCreating(function (Event $event): void {
+ EnrollmentPolicy::factory()->create([
+ 'event_id' => $event->id,
+ 'enrollment_method' => EnrollmentMethod::Rsvp,
+ 'check_in_method' => CheckInMethod::Manual,
+ 'capacity' => null,
+ 'has_waitlist' => false,
+ 'attendance_requirement' => AttendanceRequirement::AllDays,
+ 'xp_on_confirmed' => 50,
+ 'xp_on_checked_in' => 100,
+ 'xp_on_attended' => 250,
+ ]);
+ });
+ }
+
+ public function asConference(): static
+ {
+ return $this->state(fn (): array => [
+ 'event_type' => EventType::Conference,
+ 'status' => EventStatus::Published,
+ ])->afterCreating(function (Event $event): void {
+ EnrollmentPolicy::factory()->create([
+ 'event_id' => $event->id,
+ 'enrollment_method' => EnrollmentMethod::Application,
+ 'check_in_method' => CheckInMethod::QrCode,
+ 'capacity' => 200,
+ 'has_waitlist' => true,
+ 'attendance_requirement' => AttendanceRequirement::MinimumDays,
+ 'minimum_days' => 2,
+ 'cancellation_deadline_hours' => 48,
+ 'xp_on_confirmed' => 200,
+ 'xp_on_checked_in' => 300,
+ 'xp_on_attended' => 1000,
+ ]);
+ });
+ }
}
diff --git a/app-modules/events/lang/en/exceptions.php b/app-modules/events/lang/en/exceptions.php
new file mode 100644
index 000000000..056dd6539
--- /dev/null
+++ b/app-modules/events/lang/en/exceptions.php
@@ -0,0 +1,11 @@
+ 'You are already enrolled in this event.',
+ 'event_past' => 'This event has already started and is no longer accepting enrollments.',
+ 'event_not_active' => 'This event is not available for enrollment.',
+ 'invalid_enrollment_method' => 'This event requires application enrollment, not RSVP.',
+ 'event_full' => 'This event has reached maximum capacity and does not have a waitlist.',
+];
diff --git a/app-modules/events/lang/en/pages.php b/app-modules/events/lang/en/pages.php
new file mode 100644
index 000000000..dc5f2f51a
--- /dev/null
+++ b/app-modules/events/lang/en/pages.php
@@ -0,0 +1,15 @@
+ 'Back to Events',
+ 'confirm_presence' => 'Confirm Presence',
+ 'confirm_presence_hint' => 'Confirm your attendance to this event.',
+ 'confirm_presence_success' => 'Your presence has been confirmed!',
+ 'waitlist_success' => 'You have been added to the waitlist for this event.',
+ 'enrollment_status_label' => 'Your enrollment status',
+ 'enrolled_at' => 'Enrolled on :date',
+ 'no_enrollments' => 'You are not enrolled in any events yet.',
+ 'no_upcoming_events' => 'No upcoming events available.',
+];
diff --git a/app-modules/events/lang/pt_BR/exceptions.php b/app-modules/events/lang/pt_BR/exceptions.php
new file mode 100644
index 000000000..27d80f367
--- /dev/null
+++ b/app-modules/events/lang/pt_BR/exceptions.php
@@ -0,0 +1,11 @@
+ 'Você já está inscrito neste evento.',
+ 'event_past' => 'Este evento já começou e não aceita mais inscrições.',
+ 'event_not_active' => 'Este evento não está disponível para inscrição.',
+ 'invalid_enrollment_method' => 'Esse evento exige inscrição via formulário, não RSVP.',
+ 'event_full' => 'Este evento atingiu a capacidade máxima e não possui lista de espera.',
+];
diff --git a/app-modules/events/lang/pt_BR/pages.php b/app-modules/events/lang/pt_BR/pages.php
new file mode 100644
index 000000000..42243fa86
--- /dev/null
+++ b/app-modules/events/lang/pt_BR/pages.php
@@ -0,0 +1,15 @@
+ 'Voltar para Eventos',
+ 'confirm_presence' => 'Confirmar Presença',
+ 'confirm_presence_hint' => 'Confirme sua presença neste evento.',
+ 'confirm_presence_success' => 'Sua presença foi confirmada!',
+ 'waitlist_success' => 'Você entrou na lista de espera deste evento.',
+ 'enrollment_status_label' => 'Status da sua inscrição',
+ 'enrolled_at' => 'Inscrito em :date',
+ 'no_enrollments' => 'Você ainda não está inscrito em nenhum evento.',
+ 'no_upcoming_events' => 'Nenhum evento disponível no momento.',
+];
diff --git a/app-modules/events/src/Enrollment/Actions/EnrollUserAction.php b/app-modules/events/src/Enrollment/Actions/EnrollUserAction.php
new file mode 100644
index 000000000..a43493e6a
--- /dev/null
+++ b/app-modules/events/src/Enrollment/Actions/EnrollUserAction.php
@@ -0,0 +1,164 @@
+loadLockedEnrollmentContext($dto->eventId);
+
+ $this->validate($event, $policy, $dto->userId);
+
+ $initial = $this->resolveInitialEnrollment($dto->eventId, $policy);
+
+ try {
+ $enrollment = Enrollment::query()->create([
+ 'event_id' => $dto->eventId,
+ 'user_id' => $dto->userId,
+ 'status' => $initial['status'],
+ 'enrolled_at' => now(),
+ 'confirmed_at' => $initial['confirmedAt'],
+ 'waitlist_position' => $initial['waitlistPosition'],
+ ]);
+
+ EnrollmentTransition::query()->create([
+ 'enrollment_id' => $enrollment->id,
+ 'from_status' => null,
+ 'to_status' => $initial['status'],
+ 'actor_id' => $dto->userId,
+ 'triggered_by' => TriggeredBy::User,
+ ]);
+
+ if ($initial['status']->isConfirmed()) {
+ event(new EnrollmentConfirmed(
+ enrollmentId: $enrollment->id,
+ eventId: $dto->eventId,
+ userId: $dto->userId,
+ xpRewardOnConfirmed: $policy?->xp_on_confirmed ?? 0,
+ ));
+ }
+
+ return $enrollment->fresh(['event.enrollmentPolicy']);
+ } catch (UniqueConstraintViolationException) {
+ throw EnrollmentException::alreadyEnrolled();
+ }
+ });
+ }
+
+ /**
+ * Load event and policy for validation and capacity checks.
+ *
+ * lockForUpdate() runs SELECT ... FOR UPDATE. Row locks are held until this
+ * DB::transaction commits or rolls back — there is no explicit unlock in code.
+ * Returned models are reused below so rules run on fresh DB state, not caller snapshots.
+ *
+ * @return array{0: Event, 1: ?EnrollmentPolicy}
+ */
+ private function loadLockedEnrollmentContext(string $eventId): array
+ {
+ $event = Event::query()
+ ->whereKey($eventId)
+ ->lockForUpdate()
+ ->firstOrFail();
+
+ $policy = EnrollmentPolicy::query()
+ ->where('event_id', $eventId)
+ ->lockForUpdate()
+ ->first();
+
+ return [$event, $policy];
+ }
+
+ /**
+ * @return array{status: EnrollmentStatus, waitlistPosition: ?int, confirmedAt: ?CarbonInterface}
+ */
+ private function resolveInitialEnrollment(string $eventId, ?EnrollmentPolicy $policy): array
+ {
+ $capacity = $policy?->capacity;
+
+ if ($capacity === null) {
+ return [
+ 'status' => EnrollmentStatus::Confirmed,
+ 'waitlistPosition' => null,
+ 'confirmedAt' => now(),
+ ];
+ }
+
+ $confirmedCount = Enrollment::query()
+ ->where('event_id', $eventId)
+ ->confirmed()
+ ->count();
+
+ if ($confirmedCount < $capacity) {
+ return [
+ 'status' => EnrollmentStatus::Confirmed,
+ 'waitlistPosition' => null,
+ 'confirmedAt' => now(),
+ ];
+ }
+
+ if ($policy->has_waitlist) {
+ $nextPosition = (int) Enrollment::query()
+ ->where('event_id', $eventId)
+ ->waitlisted()
+ ->max('waitlist_position') + 1;
+
+ return [
+ 'status' => EnrollmentStatus::Waitlisted,
+ 'waitlistPosition' => $nextPosition,
+ 'confirmedAt' => null,
+ ];
+ }
+
+ throw EnrollmentException::eventFull();
+ }
+
+ private function validate(Event $event, ?EnrollmentPolicy $policy, string $userId): void
+ {
+ throw_unless(
+ $event->status === EventStatus::Published,
+ EnrollmentException::eventNotActive(),
+ );
+
+ throw_if(
+ $event->starts_at->lte(now()),
+ EnrollmentException::eventPast(),
+ );
+
+ throw_unless(
+ in_array(
+ $policy?->enrollment_method,
+ [EnrollmentMethod::Rsvp, EnrollmentMethod::RsvpCheckin],
+ strict: true,
+ ),
+ EnrollmentException::invalidEnrollmentMethod(),
+ );
+
+ throw_if(
+ Enrollment::query()
+ ->where('event_id', $event->id)
+ ->where('user_id', $userId)
+ ->exists(),
+ EnrollmentException::alreadyEnrolled(),
+ );
+ }
+}
diff --git a/app-modules/events/src/Enrollment/DTOs/EnrollUserDTO.php b/app-modules/events/src/Enrollment/DTOs/EnrollUserDTO.php
new file mode 100644
index 000000000..6078e1e91
--- /dev/null
+++ b/app-modules/events/src/Enrollment/DTOs/EnrollUserDTO.php
@@ -0,0 +1,24 @@
+id,
+ userId: $user->id,
+ );
+ }
+}
diff --git a/app-modules/events/src/Enrollment/Enums/EnrollmentStatus.php b/app-modules/events/src/Enrollment/Enums/EnrollmentStatus.php
index 131eadf56..70d1e9930 100644
--- a/app-modules/events/src/Enrollment/Enums/EnrollmentStatus.php
+++ b/app-modules/events/src/Enrollment/Enums/EnrollmentStatus.php
@@ -69,4 +69,27 @@ public function isTerminal(): bool
{
return in_array($this, [self::Attended, self::Cancelled, self::Rejected, self::NoShow], strict: true);
}
+
+ public function is(self ...$statuses): bool
+ {
+ return in_array($this, $statuses, strict: true);
+ }
+
+ public function isConfirmed(): bool
+ {
+ return $this->is(self::Confirmed);
+ }
+
+ public function isWaitlisted(): bool
+ {
+ return $this->is(self::Waitlisted);
+ }
+
+ public function getResponseMessage(): string
+ {
+ return match ($this) {
+ self::Confirmed => __('events::pages.confirm_presence_success'),
+ self::Waitlisted => __('events::pages.waitlist_success'),
+ };
+ }
}
diff --git a/app-modules/events/src/Enrollment/Events/EnrollmentConfirmed.php b/app-modules/events/src/Enrollment/Events/EnrollmentConfirmed.php
new file mode 100644
index 000000000..a500e3b20
--- /dev/null
+++ b/app-modules/events/src/Enrollment/Events/EnrollmentConfirmed.php
@@ -0,0 +1,20 @@
+ */
+ public static function viewableByParticipant(): array
+ {
+ return [
+ self::Published,
+ self::Completed,
+ self::Cancelled,
+ ];
+ }
+
public function getLabel(): string
{
return __('events::enums.event_status.'.$this->value);
@@ -55,4 +65,12 @@ public function isTerminal(): bool
{
return in_array($this, [self::Completed, self::Cancelled], strict: true);
}
+
+ public function isViewableByParticipant(): bool
+ {
+ return match ($this) {
+ self::Published, self::Completed, self::Cancelled => true,
+ self::Draft => false,
+ };
+ }
}
diff --git a/app-modules/events/src/Event/Models/Event.php b/app-modules/events/src/Event/Models/Event.php
index 0715349cd..c6c61e216 100644
--- a/app-modules/events/src/Event/Models/Event.php
+++ b/app-modules/events/src/Event/Models/Event.php
@@ -102,6 +102,11 @@ protected function scopePublished(Builder $query): void
$query->where('status', EventStatus::Published);
}
+ protected function scopeViewableByParticipant(Builder $query): void
+ {
+ $query->whereIn('status', EventStatus::viewableByParticipant());
+ }
+
protected function scopeUpcoming(Builder $query): void
{
$query->where('starts_at', '>', now());
@@ -110,7 +115,7 @@ protected function scopeUpcoming(Builder $query): void
protected function scopeActive(Builder $query): void
{
$query->where('status', EventStatus::Published)
- ->where('starts_at', '>', now());
+ ->where('ends_at', '>', now());
}
/** @return array */
diff --git a/app-modules/events/tests/Feature/EnrollUserActionTest.php b/app-modules/events/tests/Feature/EnrollUserActionTest.php
new file mode 100644
index 000000000..9c125e7e1
--- /dev/null
+++ b/app-modules/events/tests/Feature/EnrollUserActionTest.php
@@ -0,0 +1,230 @@
+published()
+ ->upcoming()
+ ->for($tenant)
+ ->has(EnrollmentPolicy::factory()->rsvp()->state($policyAttributes), 'enrollmentPolicy')
+ ->create($eventAttributes);
+}
+
+test('when a user enrolls in an rsvp event, then enrollment is confirmed with audit trail', function (): void {
+ EventFacade::fake([EnrollmentConfirmed::class]);
+
+ $user = User::factory()->create();
+ $tenant = Tenant::factory()->create();
+ $event = createRsvpEvent($tenant, [], ['xp_on_confirmed' => 50]);
+
+ $enrollment = resolve(EnrollUserAction::class)->handle(EnrollUserDTO::fromModels($event, $user));
+
+ expect($enrollment->status)->toBe(EnrollmentStatus::Confirmed)
+ ->and($enrollment->enrolled_at)->not->toBeNull()
+ ->and($enrollment->confirmed_at)->not->toBeNull();
+
+ $transition = EnrollmentTransition::query()
+ ->where('enrollment_id', $enrollment->id)
+ ->first();
+
+ expect($transition)->not->toBeNull()
+ ->and($transition->from_status)->toBeNull()
+ ->and($transition->to_status)->toBe(EnrollmentStatus::Confirmed)
+ ->and($transition->actor_id)->toBe($user->id)
+ ->and($transition->triggered_by)->toBe(TriggeredBy::User);
+
+ EventFacade::assertDispatched(fn (EnrollmentConfirmed $event): bool => $event->enrollmentId === $enrollment->id
+ && $event->eventId === $enrollment->event_id
+ && $event->userId === $user->id
+ && $event->xpRewardOnConfirmed === 50);
+});
+
+test('when concurrent enrollment hits unique index, then already enrolled exception is thrown', function (): void {
+ $user = User::factory()->create();
+ $tenant = Tenant::factory()->create();
+ $event = createRsvpEvent($tenant);
+ $dto = EnrollUserDTO::fromModels($event, $user);
+
+ $raced = false;
+
+ Enrollment::creating(function (Enrollment $enrollment) use (&$raced): void {
+ if ($raced) {
+ return;
+ }
+
+ $raced = true;
+
+ Enrollment::withoutEvents(function () use ($enrollment): void {
+ Enrollment::factory()->create([
+ 'event_id' => $enrollment->event_id,
+ 'user_id' => $enrollment->user_id,
+ 'status' => EnrollmentStatus::Confirmed,
+ 'enrolled_at' => now(),
+ 'confirmed_at' => now(),
+ ]);
+ });
+ });
+
+ try {
+ expect(fn (): Enrollment => resolve(EnrollUserAction::class)->handle($dto))
+ ->toThrow(EnrollmentException::class);
+ } finally {
+ Enrollment::flushEventListeners();
+ }
+});
+
+test('when a user enrolls twice in the same event, then duplicate enrollment is rejected', function (): void {
+ $user = User::factory()->create();
+ $tenant = Tenant::factory()->create();
+ $event = createRsvpEvent($tenant);
+
+ resolve(EnrollUserAction::class)->handle(EnrollUserDTO::fromModels($event, $user));
+
+ resolve(EnrollUserAction::class)->handle(EnrollUserDTO::fromModels($event, $user));
+})->throws(EnrollmentException::class);
+
+test('when a user enrolls in a past event, then enrollment is rejected', function (): void {
+ $user = User::factory()->create();
+ $tenant = Tenant::factory()->create();
+ $event = Event::factory()
+ ->published()
+ ->past()
+ ->for($tenant)
+ ->has(EnrollmentPolicy::factory()->rsvp(), 'enrollmentPolicy')
+ ->create();
+
+ resolve(EnrollUserAction::class)->handle(EnrollUserDTO::fromModels($event, $user));
+})->throws(EnrollmentException::class);
+
+test('when a user enrolls in a draft event, then enrollment is rejected', function (): void {
+ $user = User::factory()->create();
+ $tenant = Tenant::factory()->create();
+ $event = Event::factory()
+ ->upcoming()
+ ->for($tenant)
+ ->has(EnrollmentPolicy::factory()->rsvp(), 'enrollmentPolicy')
+ ->create(['status' => EventStatus::Draft]);
+
+ resolve(EnrollUserAction::class)->handle(EnrollUserDTO::fromModels($event, $user));
+})->throws(EnrollmentException::class);
+
+test('when an event uses application enrollment method, then rsvp enrollment is rejected', function (): void {
+ $user = User::factory()->create();
+ $tenant = Tenant::factory()->create();
+ $event = Event::factory()
+ ->published()
+ ->upcoming()
+ ->for($tenant)
+ ->has(EnrollmentPolicy::factory()->state([
+ 'enrollment_method' => EnrollmentMethod::Application,
+ ]), 'enrollmentPolicy')
+ ->create();
+
+ resolve(EnrollUserAction::class)->handle(EnrollUserDTO::fromModels($event, $user));
+})->throws(EnrollmentException::class);
+
+test('when event is at capacity with waitlist enabled, then enrollment is waitlisted', function (): void {
+ EventFacade::fake([EnrollmentConfirmed::class]);
+
+ $user = User::factory()->create();
+ $tenant = Tenant::factory()->create();
+ $event = createRsvpEvent($tenant, [], [
+ 'capacity' => 1,
+ 'has_waitlist' => true,
+ ]);
+
+ $existingUser = User::factory()->create();
+ Enrollment::factory()->create([
+ 'event_id' => $event->id,
+ 'user_id' => $existingUser->id,
+ 'status' => EnrollmentStatus::Confirmed,
+ 'enrolled_at' => now(),
+ 'confirmed_at' => now(),
+ ]);
+
+ $enrollment = resolve(EnrollUserAction::class)->handle(EnrollUserDTO::fromModels($event, $user));
+
+ expect($enrollment->status)->toBe(EnrollmentStatus::Waitlisted)
+ ->and($enrollment->waitlist_position)->toBe(1)
+ ->and($enrollment->confirmed_at)->toBeNull();
+
+ EventFacade::assertNotDispatched(EnrollmentConfirmed::class);
+});
+
+test('when event is at capacity without waitlist, then enrollment is rejected', function (): void {
+ $user = User::factory()->create();
+ $tenant = Tenant::factory()->create();
+ $event = createRsvpEvent($tenant, [], [
+ 'capacity' => 1,
+ 'has_waitlist' => false,
+ ]);
+
+ $existingUser = User::factory()->create();
+ Enrollment::factory()->create([
+ 'event_id' => $event->id,
+ 'user_id' => $existingUser->id,
+ 'status' => EnrollmentStatus::Confirmed,
+ 'enrolled_at' => now(),
+ 'confirmed_at' => now(),
+ ]);
+
+ resolve(EnrollUserAction::class)->handle(EnrollUserDTO::fromModels($event, $user));
+})->throws(EnrollmentException::class);
+
+test('when event has available capacity, then enrollment is confirmed', function (): void {
+ EventFacade::fake([EnrollmentConfirmed::class]);
+
+ $user = User::factory()->create();
+ $tenant = Tenant::factory()->create();
+ $event = createRsvpEvent($tenant, [], [
+ 'capacity' => 2,
+ 'has_waitlist' => true,
+ ]);
+
+ $existingUser = User::factory()->create();
+ Enrollment::factory()->create([
+ 'event_id' => $event->id,
+ 'user_id' => $existingUser->id,
+ 'status' => EnrollmentStatus::Confirmed,
+ 'enrolled_at' => now(),
+ 'confirmed_at' => now(),
+ ]);
+
+ $enrollment = resolve(EnrollUserAction::class)->handle(EnrollUserDTO::fromModels($event, $user));
+
+ expect($enrollment->status)->toBe(EnrollmentStatus::Confirmed)
+ ->and($enrollment->confirmed_at)->not->toBeNull();
+
+ EventFacade::assertDispatched(EnrollmentConfirmed::class);
+});
+
+test('when duplicate enrollment exists in database, then only one enrollment record is kept', function (): void {
+ $user = User::factory()->create();
+ $tenant = Tenant::factory()->create();
+ $event = createRsvpEvent($tenant);
+
+ resolve(EnrollUserAction::class)->handle(EnrollUserDTO::fromModels($event, $user));
+
+ expect(Enrollment::query()->where('event_id', $event->id)->where('user_id', $user->id)->count())->toBe(1);
+});
diff --git a/app-modules/events/tests/Feature/EventFactoriesTest.php b/app-modules/events/tests/Feature/EventFactoriesTest.php
index 00a852005..bfa4ad261 100644
--- a/app-modules/events/tests/Feature/EventFactoriesTest.php
+++ b/app-modules/events/tests/Feature/EventFactoriesTest.php
@@ -2,12 +2,17 @@
declare(strict_types=1);
+use He4rt\Events\CheckIn\Enums\CheckInMethod;
use He4rt\Events\CheckIn\Models\CheckIn;
use He4rt\Events\CheckIn\Models\CheckInCode;
use He4rt\Events\CheckIn\Models\QrToken;
+use He4rt\Events\Enrollment\Enums\AttendanceRequirement;
+use He4rt\Events\Enrollment\Enums\EnrollmentMethod;
use He4rt\Events\Enrollment\Models\Enrollment;
use He4rt\Events\Enrollment\Models\EnrollmentPolicy;
use He4rt\Events\Enrollment\Models\EnrollmentTransition;
+use He4rt\Events\Event\Enums\EventStatus;
+use He4rt\Events\Event\Enums\EventType;
use He4rt\Events\Event\Models\Event;
use Illuminate\Database\Eloquent\Collection;
@@ -49,7 +54,7 @@
expect($checkIn->id)->not->toBeNull()
->and($checkIn->enrollment_id)->not->toBeNull()
- ->and($checkIn->check_in)->not->toBeNull();
+ ->and($checkIn->event_date)->not->toBeNull();
});
test('when creating a check-in code via factory, then it is linked to an event with valid dates', function (): void {
@@ -96,3 +101,59 @@
expect($event->checkInCodes)->toHaveCount(2)
->and($event->checkInCodes->first()->event_id)->toBe($event->id);
});
+
+test('when creating a workshop via factory preset, then event and enrollment policy match community defaults', function (): void {
+ $event = Event::factory()->asWorkshop()->upcoming()->create();
+
+ $event->load('enrollmentPolicy');
+
+ expect($event->event_type)->toBe(EventType::Workshop)
+ ->and($event->status)->toBe(EventStatus::Published)
+ ->and($event->enrollmentPolicy)->not->toBeNull()
+ ->and($event->enrollmentPolicy->enrollment_method)->toBe(EnrollmentMethod::RsvpCheckin)
+ ->and($event->enrollmentPolicy->check_in_method)->toBe(CheckInMethod::NumericCode)
+ ->and($event->enrollmentPolicy->capacity)->toBe(50)
+ ->and($event->enrollmentPolicy->has_waitlist)->toBeTrue()
+ ->and($event->enrollmentPolicy->attendance_requirement)->toBe(AttendanceRequirement::AllDays)
+ ->and($event->enrollmentPolicy->xp_on_confirmed)->toBe(100);
+});
+
+test('when creating a meetup via factory preset, then event has open rsvp enrollment policy', function (): void {
+ $event = Event::factory()->asMeetup()->create();
+
+ $event->load('enrollmentPolicy');
+
+ expect($event->event_type)->toBe(EventType::Meetup)
+ ->and($event->enrollmentPolicy->enrollment_method)->toBe(EnrollmentMethod::Rsvp)
+ ->and($event->enrollmentPolicy->check_in_method)->toBe(CheckInMethod::Manual)
+ ->and($event->enrollmentPolicy->capacity)->toBeNull()
+ ->and($event->enrollmentPolicy->has_waitlist)->toBeFalse();
+});
+
+test('when creating a conference via factory preset, then event has application enrollment with waitlist', function (): void {
+ $event = Event::factory()->asConference()->past()->create();
+
+ $event->load('enrollmentPolicy');
+
+ expect($event->event_type)->toBe(EventType::Conference)
+ ->and($event->enrollmentPolicy->enrollment_method)->toBe(EnrollmentMethod::Application)
+ ->and($event->enrollmentPolicy->check_in_method)->toBe(CheckInMethod::QrCode)
+ ->and($event->enrollmentPolicy->capacity)->toBe(200)
+ ->and($event->enrollmentPolicy->has_waitlist)->toBeTrue()
+ ->and($event->enrollmentPolicy->attendance_requirement)->toBe(AttendanceRequirement::MinimumDays)
+ ->and($event->enrollmentPolicy->minimum_days)->toBe(2)
+ ->and($event->enrollmentPolicy->cancellation_deadline_hours)->toBe(48);
+});
+
+test('when creating multiple meetup presets, then each event gets its own enrollment policy', function (): void {
+ $events = Event::factory()->count(3)->asMeetup()->create();
+
+ expect($events)->toHaveCount(3);
+
+ foreach ($events as $event) {
+ $event->load('enrollmentPolicy');
+
+ expect($event->enrollmentPolicy)->not->toBeNull()
+ ->and($event->enrollmentPolicy->enrollment_method)->toBe(EnrollmentMethod::Rsvp);
+ }
+});
diff --git a/app-modules/events/tests/Feature/EventResourceTest.php b/app-modules/events/tests/Feature/EventResourceTest.php
index 3c8556881..4fbb7f2ea 100644
--- a/app-modules/events/tests/Feature/EventResourceTest.php
+++ b/app-modules/events/tests/Feature/EventResourceTest.php
@@ -88,6 +88,33 @@
->and($event->enrollmentPolicy->enrollment_method)->toBe(EnrollmentMethod::Rsvp);
});
+test('when submitting the create form with a duplicate slug for the same tenant, then validation fails', function (): void {
+ $tenant = Filament::getTenant();
+ $startsAt = now()->addDay();
+
+ Event::factory()->for($tenant)->create(['slug' => 'duplicate-slug']);
+
+ livewire(CreateEvent::class)
+ ->fillForm([
+ 'title' => 'Another Event',
+ 'slug' => 'duplicate-slug',
+ 'tenant_id' => $tenant->getKey(),
+ 'event_type' => EventType::Meetup,
+ 'starts_at' => $startsAt,
+ 'ends_at' => $startsAt->clone()->addHours(3),
+ 'enrollmentPolicy' => [
+ 'enrollment_method' => EnrollmentMethod::Rsvp,
+ 'check_in_method' => CheckInMethod::Manual,
+ 'attendance_requirement' => AttendanceRequirement::AllDays,
+ 'xp_on_confirmed' => 0,
+ 'xp_on_checked_in' => 0,
+ 'xp_on_attended' => 0,
+ ],
+ ])
+ ->call('create')
+ ->assertHasFormErrors(['slug' => 'unique']);
+});
+
test('when submitting the edit form with a new title, then it is updated in the database', function (): void {
$event = Event::factory()
->has(EnrollmentPolicy::factory(), 'enrollmentPolicy')
diff --git a/app-modules/events/tests/Unit/EnrollmentStatusTest.php b/app-modules/events/tests/Unit/EnrollmentStatusTest.php
index 8d4930dd7..1f0f934e7 100644
--- a/app-modules/events/tests/Unit/EnrollmentStatusTest.php
+++ b/app-modules/events/tests/Unit/EnrollmentStatusTest.php
@@ -59,3 +59,18 @@
->and(EnrollmentStatus::Waitlisted->isTerminal())->toBeFalse()
->and(EnrollmentStatus::CheckedIn->isTerminal())->toBeFalse();
});
+
+test('when enrollment status helpers are evaluated, then is and named checks work', function (): void {
+ expect(EnrollmentStatus::Confirmed->isConfirmed())->toBeTrue()
+ ->and(EnrollmentStatus::Confirmed->is(EnrollmentStatus::Confirmed))->toBeTrue()
+ ->and(EnrollmentStatus::Confirmed->is(EnrollmentStatus::Waitlisted))->toBeFalse()
+ ->and(EnrollmentStatus::Waitlisted->isWaitlisted())->toBeTrue()
+ ->and(EnrollmentStatus::Pending->is(EnrollmentStatus::Confirmed, EnrollmentStatus::Waitlisted))->toBeFalse();
+});
+
+test('when rsvp enrollment status is evaluated, then response message matches status', function (EnrollmentStatus $status, string $expectedKey): void {
+ expect($status->getResponseMessage())->toBe(__($expectedKey));
+})->with([
+ [EnrollmentStatus::Confirmed, 'events::pages.confirm_presence_success'],
+ [EnrollmentStatus::Waitlisted, 'events::pages.waitlist_success'],
+]);
diff --git a/app-modules/panel-admin/src/Filament/Resources/Events/Schemas/EventForm.php b/app-modules/panel-admin/src/Filament/Resources/Events/Schemas/EventForm.php
index 1b0853d09..e4d353aff 100644
--- a/app-modules/panel-admin/src/Filament/Resources/Events/Schemas/EventForm.php
+++ b/app-modules/panel-admin/src/Filament/Resources/Events/Schemas/EventForm.php
@@ -16,7 +16,10 @@
use He4rt\Events\CheckIn\Enums\CheckInMethod;
use He4rt\Events\Enrollment\Enums\AttendanceRequirement;
use He4rt\Events\Enrollment\Enums\EnrollmentMethod;
+use He4rt\Events\Event\Enums\EventStatus;
use He4rt\Events\Event\Enums\EventType;
+use He4rt\Events\Event\Models\Event;
+use Illuminate\Validation\Rules\Unique;
final class EventForm
{
@@ -34,7 +37,19 @@ public static function configure(Schema $schema): Schema
TextInput::make('slug')
->label('Slug')
->required()
- ->maxLength(120),
+ ->maxLength(120)
+ ->unique(
+ table: Event::class,
+ column: 'slug',
+ ignoreRecord: true,
+ modifyRuleUsing: function (Unique $rule, Get $get): Unique {
+ $tenantId = $get('tenant_id');
+
+ return filled($tenantId)
+ ? $rule->where('tenant_id', $tenantId)
+ : $rule->whereNull('tenant_id');
+ },
+ ),
Select::make('event_type')
->label('Type')
@@ -65,9 +80,11 @@ public static function configure(Schema $schema): Schema
->required()
->after('starts_at'),
- Toggle::make('active')
- ->label('Published')
- ->default(false)
+ Select::make('status')
+ ->label('Status')
+ ->options(EventStatus::class)
+ ->default(EventStatus::Draft)
+ ->required()
->columnSpanFull(),
Section::make('Enrollment Policy')
diff --git a/app-modules/panel-admin/src/Filament/Resources/Events/Schemas/EventInfolist.php b/app-modules/panel-admin/src/Filament/Resources/Events/Schemas/EventInfolist.php
index 192d12994..e138d7fba 100644
--- a/app-modules/panel-admin/src/Filament/Resources/Events/Schemas/EventInfolist.php
+++ b/app-modules/panel-admin/src/Filament/Resources/Events/Schemas/EventInfolist.php
@@ -45,9 +45,9 @@ public static function configure(Schema $schema): Schema
->label('Ends At')
->dateTime(),
- IconEntry::make('active')
- ->label('Published')
- ->boolean(),
+ TextEntry::make('status')
+ ->label('Status')
+ ->badge(),
TextEntry::make('created_at')
->label('Created At')
diff --git a/app-modules/panel-admin/src/Filament/Resources/Events/Tables/EventsTable.php b/app-modules/panel-admin/src/Filament/Resources/Events/Tables/EventsTable.php
index 70889e13b..a9cf7ec98 100644
--- a/app-modules/panel-admin/src/Filament/Resources/Events/Tables/EventsTable.php
+++ b/app-modules/panel-admin/src/Filament/Resources/Events/Tables/EventsTable.php
@@ -8,10 +8,10 @@
use Filament\Actions\DeleteAction;
use Filament\Actions\DeleteBulkAction;
use Filament\Actions\EditAction;
-use Filament\Tables\Columns\IconColumn;
use Filament\Tables\Columns\TextColumn;
use Filament\Tables\Filters\SelectFilter;
use Filament\Tables\Table;
+use He4rt\Events\Event\Enums\EventStatus;
use He4rt\Events\Event\Enums\EventType;
final class EventsTable
@@ -45,9 +45,10 @@ public static function table(Table $table): Table
->dateTime()
->sortable(),
- IconColumn::make('active')
- ->label('Published')
- ->boolean(),
+ TextColumn::make('status')
+ ->label('Status')
+ ->badge()
+ ->sortable(),
TextColumn::make('created_at')
->label('Created At')
@@ -59,6 +60,10 @@ public static function table(Table $table): Table
SelectFilter::make('event_type')
->label('Type')
->options(EventType::class),
+
+ SelectFilter::make('status')
+ ->label('Status')
+ ->options(EventStatus::class),
])
->recordActions([
EditAction::make(),
diff --git a/app-modules/panel-app/resources/views/livewire/events/event-detail.blade.php b/app-modules/panel-app/resources/views/livewire/events/event-detail.blade.php
new file mode 100644
index 000000000..f94987fee
--- /dev/null
+++ b/app-modules/panel-app/resources/views/livewire/events/event-detail.blade.php
@@ -0,0 +1,49 @@
+
+
+
+
+
{{ $this->event->title }}
+
+
+ {{ $this->event->starts_at->format('d/m/Y H:i') }}
+ —
+ {{ $this->event->ends_at->format('d/m/Y H:i') }}
+
+ @if ($this->event->location)
+ {{ $this->event->location }}
+ @endif
+
+
+
+
+ {{ $this->event->event_type->getLabel() }}
+
+
+
+ @if ($this->event->description)
+
{{ $this->event->description }}
+ @endif
+
+
+ @if ($this->enrollment)
+
+
+
+ {{ __('events::pages.enrollment_status_label') }}
+
+
+
+ {{ $this->enrollment->status->getLabel() }}
+
+
+
+ @elseif ($this->canConfirmPresence)
+
+
{{ __('events::pages.confirm_presence_hint') }}
+
+
+ {{ __('events::pages.confirm_presence') }}
+
+
+ @endif
+
diff --git a/app-modules/panel-app/resources/views/livewire/events/events-list.blade.php b/app-modules/panel-app/resources/views/livewire/events/events-list.blade.php
new file mode 100644
index 000000000..70858da7c
--- /dev/null
+++ b/app-modules/panel-app/resources/views/livewire/events/events-list.blade.php
@@ -0,0 +1,43 @@
+
diff --git a/app-modules/panel-app/resources/views/livewire/events/my-events-list.blade.php b/app-modules/panel-app/resources/views/livewire/events/my-events-list.blade.php
new file mode 100644
index 000000000..f86fa00b7
--- /dev/null
+++ b/app-modules/panel-app/resources/views/livewire/events/my-events-list.blade.php
@@ -0,0 +1,26 @@
+
diff --git a/app-modules/panel-app/resources/views/livewire/events/partials/my-events-list-item.blade.php b/app-modules/panel-app/resources/views/livewire/events/partials/my-events-list-item.blade.php
new file mode 100644
index 000000000..216b46b70
--- /dev/null
+++ b/app-modules/panel-app/resources/views/livewire/events/partials/my-events-list-item.blade.php
@@ -0,0 +1,27 @@
+
+
+
{{ $enrollment->event->title }}
+
+
+ {{ $enrollment->event->starts_at->format('d/m/Y H:i') }}
+
+ @if ($enrollment->enrolled_at)
+ {{
+ __('events::pages.enrolled_at', [
+ 'date' => $enrollment->enrolled_at->format('d/m/Y H:i'),
+ ])
+ }}
+ @endif
+
+
+
+
+
+ {{ $enrollment->status->getLabel() }}
+
+
+
+ {{ $enrollment->event->status->getLabel() }}
+
+
+
diff --git a/app-modules/panel-app/resources/views/pages/event.blade.php b/app-modules/panel-app/resources/views/pages/event.blade.php
new file mode 100644
index 000000000..d30cbd90f
--- /dev/null
+++ b/app-modules/panel-app/resources/views/pages/event.blade.php
@@ -0,0 +1,13 @@
+
+
+
diff --git a/app-modules/panel-app/resources/views/pages/events.blade.php b/app-modules/panel-app/resources/views/pages/events.blade.php
new file mode 100644
index 000000000..b277f96c0
--- /dev/null
+++ b/app-modules/panel-app/resources/views/pages/events.blade.php
@@ -0,0 +1,5 @@
+
+
+
+
+
diff --git a/app-modules/panel-app/resources/views/pages/my-events.blade.php b/app-modules/panel-app/resources/views/pages/my-events.blade.php
new file mode 100644
index 000000000..d60ad02ca
--- /dev/null
+++ b/app-modules/panel-app/resources/views/pages/my-events.blade.php
@@ -0,0 +1,5 @@
+
+
+
+
+
diff --git a/app-modules/panel-app/src/Livewire/Events/EventDetail.php b/app-modules/panel-app/src/Livewire/Events/EventDetail.php
new file mode 100644
index 000000000..a303091ac
--- /dev/null
+++ b/app-modules/panel-app/src/Livewire/Events/EventDetail.php
@@ -0,0 +1,115 @@
+eventId = $eventId;
+ }
+
+ #[Computed]
+ public function event(): Event
+ {
+ return Event::query()
+ ->with('enrollmentPolicy')
+ ->where('id', $this->eventId)
+ ->where('tenant_id', filament()->getTenant()->getKey())
+ ->viewableByParticipant()
+ ->firstOrFail();
+ }
+
+ #[Computed]
+ public function enrollment(): ?Enrollment
+ {
+ return Enrollment::query()
+ ->where('event_id', $this->eventId)
+ ->where('user_id', auth()->id())
+ ->first();
+ }
+
+ #[Computed]
+ public function canConfirmPresence(): bool
+ {
+ if ($this->event->status !== EventStatus::Published) {
+ return false;
+ }
+
+ if ($this->enrollment !== null) {
+ return false;
+ }
+
+ $policy = $this->event->enrollmentPolicy;
+
+ if (!in_array($policy?->enrollment_method, [EnrollmentMethod::Rsvp, EnrollmentMethod::RsvpCheckin], strict: true)) {
+ return false;
+ }
+
+ if (!$this->event->starts_at->isFuture()) {
+ return false;
+ }
+
+ if ($policy->capacity === null) {
+ return true;
+ }
+
+ $confirmedCount = Enrollment::query()
+ ->where('event_id', $this->eventId)
+ ->where('status', EnrollmentStatus::Confirmed)
+ ->count();
+
+ if ($confirmedCount < $policy->capacity) {
+ return true;
+ }
+
+ return $policy->has_waitlist;
+ }
+
+ public function confirmPresence(): void
+ {
+ /** @var User $user */
+ $user = auth()->user();
+
+ try {
+ $enrollment = resolve(EnrollUserAction::class)->handle(
+ EnrollUserDTO::fromModels($this->event, $user),
+ );
+
+ unset($this->enrollment, $this->canConfirmPresence);
+
+ Notification::make()
+ ->success()
+ ->title($enrollment->status->getResponseMessage())
+ ->send();
+ } catch (EnrollmentException $enrollmentException) {
+ Notification::make()
+ ->danger()
+ ->title($enrollmentException->getMessage())
+ ->send();
+ }
+ }
+
+ public function render(): View
+ {
+ return view('panel-app::livewire.events.event-detail');
+ }
+}
diff --git a/app-modules/panel-app/src/Livewire/Events/EventsList.php b/app-modules/panel-app/src/Livewire/Events/EventsList.php
new file mode 100644
index 000000000..4d3ba3f05
--- /dev/null
+++ b/app-modules/panel-app/src/Livewire/Events/EventsList.php
@@ -0,0 +1,35 @@
+ */
+ public function getEventsProperty(): Collection
+ {
+ return Event::query()
+ ->with('enrollmentPolicy')
+ ->where('tenant_id', filament()->getTenant()->getKey())
+ ->active()
+ ->orderBy('starts_at')
+ ->get();
+ }
+
+ public function eventUrl(Event $event): string
+ {
+ return EventPage::getUrl(['record' => $event->getKey()]);
+ }
+
+ public function render(): View
+ {
+ return view('panel-app::livewire.events.events-list');
+ }
+}
diff --git a/app-modules/panel-app/src/Livewire/Events/MyEventsList.php b/app-modules/panel-app/src/Livewire/Events/MyEventsList.php
new file mode 100644
index 000000000..45af6f442
--- /dev/null
+++ b/app-modules/panel-app/src/Livewire/Events/MyEventsList.php
@@ -0,0 +1,45 @@
+ */
+ public function getEnrollmentsProperty(): Collection
+ {
+ return Enrollment::query()
+ ->with(['event'])
+ ->where('user_id', auth()->id())
+ ->whereHas('event', fn (Builder $query) => $query->where('tenant_id', filament()->getTenant()->getKey()))
+ ->latest('enrolled_at')
+ ->get();
+ }
+
+ public function canOpenEvent(Enrollment $enrollment): bool
+ {
+ /** @var Event $event */
+ $event = $enrollment->event;
+
+ return $event->status->isViewableByParticipant();
+ }
+
+ public function eventUrl(Enrollment $enrollment): string
+ {
+ return EventPage::getUrl(['record' => $enrollment->event_id]);
+ }
+
+ public function render(): View
+ {
+ return view('panel-app::livewire.events.my-events-list');
+ }
+}
diff --git a/app-modules/panel-app/src/Pages/EventPage.php b/app-modules/panel-app/src/Pages/EventPage.php
new file mode 100644
index 000000000..c5b2f0ad9
--- /dev/null
+++ b/app-modules/panel-app/src/Pages/EventPage.php
@@ -0,0 +1,37 @@
+where('id', $record)
+ ->where('tenant_id', filament()->getTenant()->getKey())
+ ->viewableByParticipant()
+ ->exists();
+
+ abort_unless($exists, 404);
+
+ $this->record = $record;
+ }
+}
diff --git a/app-modules/panel-app/src/Pages/EventsPage.php b/app-modules/panel-app/src/Pages/EventsPage.php
new file mode 100644
index 000000000..b309db0e6
--- /dev/null
+++ b/app-modules/panel-app/src/Pages/EventsPage.php
@@ -0,0 +1,21 @@
+loadViewsFrom(__DIR__.'/../resources/views', 'panel-app');
$this->loadTranslationsFrom(__DIR__.'/../lang', 'panel-app');
+ Livewire::component('events-list', EventsList::class);
+ Livewire::component('my-events-list', MyEventsList::class);
+ Livewire::component('event-detail', EventDetail::class);
+
Livewire::component('timeline-composer', Composer::class);
Livewire::component('timeline-feed', Feed::class);
Livewire::component('timeline-post-show', PostShow::class);
diff --git a/app-modules/panel-app/tests/Feature/Events/RsvpEnrollmentTest.php b/app-modules/panel-app/tests/Feature/Events/RsvpEnrollmentTest.php
new file mode 100644
index 000000000..24052ebd4
--- /dev/null
+++ b/app-modules/panel-app/tests/Feature/Events/RsvpEnrollmentTest.php
@@ -0,0 +1,182 @@
+user = User::factory()->create();
+ $this->tenant = Tenant::factory()->create(['slug' => 'test-tenant']);
+ $this->tenant->members()->attach($this->user);
+
+ $this->actingAs($this->user);
+
+ Filament::setCurrentPanel(Filament::getPanel('app'));
+ Filament::setTenant($this->tenant);
+
+ $this->event = Event::factory()
+ ->published()
+ ->upcoming()
+ ->for($this->tenant)
+ ->has(EnrollmentPolicy::factory()->rsvp(), 'enrollmentPolicy')
+ ->create(['title' => 'He4rt Meetup RSVP']);
+});
+
+test('events page renders successfully', function (): void {
+ $this->get(EventsPage::getUrl())
+ ->assertSuccessful()
+ ->assertSee('He4rt Meetup RSVP');
+});
+
+test('event page renders confirm presence button for rsvp event', function (): void {
+ $this->get(EventPage::getUrl(['record' => $this->event->id]))
+ ->assertSuccessful()
+ ->assertSee('He4rt Meetup RSVP')
+ ->assertSee(__('events::pages.confirm_presence'));
+});
+
+test('when user confirms presence, then enrollment is created and shown in my events', function (): void {
+ livewire(EventDetail::class, ['eventId' => $this->event->id])
+ ->call('confirmPresence')
+ ->assertHasNoErrors();
+
+ $enrollment = Enrollment::query()
+ ->where('event_id', $this->event->id)
+ ->where('user_id', $this->user->id)
+ ->first();
+
+ expect($enrollment)->not->toBeNull()
+ ->and($enrollment->status)->toBe(EnrollmentStatus::Confirmed);
+
+ $this->get(MyEventsPage::getUrl())
+ ->assertSuccessful()
+ ->assertSee('He4rt Meetup RSVP')
+ ->assertSee(EnrollmentStatus::Confirmed->getLabel());
+});
+
+test('when user tries to confirm presence twice, then duplicate enrollment is rejected', function (): void {
+ livewire(EventDetail::class, ['eventId' => $this->event->id])
+ ->call('confirmPresence');
+
+ livewire(EventDetail::class, ['eventId' => $this->event->id])
+ ->call('confirmPresence')
+ ->assertNotified();
+
+ expect(Enrollment::query()->where('event_id', $this->event->id)->count())->toBe(1);
+});
+
+test('event page returns 404 for event from another tenant', function (): void {
+ $otherTenant = Tenant::factory()->create(['slug' => 'other-tenant']);
+ $otherEvent = Event::factory()
+ ->published()
+ ->upcoming()
+ ->for($otherTenant)
+ ->has(EnrollmentPolicy::factory()->rsvp(), 'enrollmentPolicy')
+ ->create();
+
+ $this->get(EventPage::getUrl(['record' => $otherEvent->id]))
+ ->assertNotFound();
+});
+
+test('when event is at capacity without waitlist, then confirm presence button is hidden', function (): void {
+ $this->event->enrollmentPolicy->update([
+ 'capacity' => 1,
+ 'has_waitlist' => false,
+ ]);
+
+ $otherUser = User::factory()->create();
+ Enrollment::factory()->create([
+ 'event_id' => $this->event->id,
+ 'user_id' => $otherUser->id,
+ 'status' => EnrollmentStatus::Confirmed,
+ 'enrolled_at' => now(),
+ 'confirmed_at' => now(),
+ ]);
+
+ livewire(EventDetail::class, ['eventId' => $this->event->id])
+ ->assertSet('canConfirmPresence', false)
+ ->assertDontSee(__('events::pages.confirm_presence'));
+});
+
+test('when event is at capacity with waitlist, then confirm presence button is still shown', function (): void {
+ $this->event->enrollmentPolicy->update([
+ 'capacity' => 1,
+ 'has_waitlist' => true,
+ ]);
+
+ $otherUser = User::factory()->create();
+ Enrollment::factory()->create([
+ 'event_id' => $this->event->id,
+ 'user_id' => $otherUser->id,
+ 'status' => EnrollmentStatus::Confirmed,
+ 'enrolled_at' => now(),
+ 'confirmed_at' => now(),
+ ]);
+
+ livewire(EventDetail::class, ['eventId' => $this->event->id])
+ ->assertSet('canConfirmPresence', true)
+ ->assertSee(__('events::pages.confirm_presence'));
+});
+
+test('when enrolled event is completed, then event detail page is accessible', function (): void {
+ $this->event->update(['status' => EventStatus::Completed]);
+
+ Enrollment::factory()->create([
+ 'event_id' => $this->event->id,
+ 'user_id' => $this->user->id,
+ 'status' => EnrollmentStatus::Confirmed,
+ 'enrolled_at' => now(),
+ 'confirmed_at' => now(),
+ ]);
+
+ $this->get(EventPage::getUrl(['record' => $this->event->id]))
+ ->assertSuccessful()
+ ->assertSee('He4rt Meetup RSVP');
+
+ livewire(EventDetail::class, ['eventId' => $this->event->id])
+ ->assertSet('canConfirmPresence', false);
+});
+
+test('when enrolled event is draft, then my events list does not link to detail', function (): void {
+ $this->event->update(['status' => EventStatus::Draft]);
+
+ Enrollment::factory()->create([
+ 'event_id' => $this->event->id,
+ 'user_id' => $this->user->id,
+ 'status' => EnrollmentStatus::Confirmed,
+ 'enrolled_at' => now(),
+ 'confirmed_at' => now(),
+ ]);
+
+ livewire(MyEventsList::class)
+ ->assertSet('enrollments', fn ($enrollments): bool => $enrollments->count() === 1)
+ ->assertSee('He4rt Meetup RSVP')
+ ->assertDontSee(EventPage::getUrl(['record' => $this->event->id]), false);
+});
+
+test('past event does not show confirm presence button', function (): void {
+ $pastEvent = Event::factory()
+ ->published()
+ ->past()
+ ->for($this->tenant)
+ ->has(EnrollmentPolicy::factory()->rsvp(), 'enrollmentPolicy')
+ ->create(['title' => 'Past Meetup']);
+
+ livewire(EventDetail::class, ['eventId' => $pastEvent->id])
+ ->assertSet('canConfirmPresence', false)
+ ->assertDontSee(__('events::pages.confirm_presence'));
+});
diff --git a/app/Providers/Filament/AppPanelProvider.php b/app/Providers/Filament/AppPanelProvider.php
index a11c6adaf..ea8894985 100644
--- a/app/Providers/Filament/AppPanelProvider.php
+++ b/app/Providers/Filament/AppPanelProvider.php
@@ -13,6 +13,9 @@
use Filament\PanelProvider;
use Filament\Support\Colors\Color;
use He4rt\Identity\Tenant\Models\Tenant;
+use He4rt\PanelApp\Pages\EventPage;
+use He4rt\PanelApp\Pages\EventsPage;
+use He4rt\PanelApp\Pages\MyEventsPage;
use He4rt\PanelApp\Pages\LoginPage;
use He4rt\PanelApp\Pages\ProfilePage;
use He4rt\PanelApp\Pages\ThreadPage;
@@ -46,6 +49,9 @@ public function panel(Panel $panel): Panel
->discoverWidgets(in: app_path('Filament/App/Widgets'), for: 'App\Filament\App\Widgets')
->pages([
TimelinePage::class,
+ EventsPage::class,
+ MyEventsPage::class,
+ EventPage::class,
ThreadPage::class,
ProfilePage::class,
])
From 0febd142c60dd2b7d2da15168f689712e49cd884 Mon Sep 17 00:00:00 2001
From: Davi Castello Branco Tavares de Oliveira
Date: Sat, 23 May 2026 16:37:00 -0400
Subject: [PATCH 04/18] feat(events): implement manual check-in (#278)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
## 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 |
---
.../database/factories/CheckInFactory.php | 1 +
...hecked_in_at_to_events_check_ins_table.php | 24 +++
app-modules/events/lang/en/check_in.php | 10 +
app-modules/events/lang/en/exceptions.php | 2 +
app-modules/events/lang/pt_BR/check_in.php | 10 +
app-modules/events/lang/pt_BR/exceptions.php | 2 +
.../src/CheckIn/Actions/CheckInAction.php | 105 +++++++++++
.../CheckIn/Actions/ManualCheckInAction.php | 28 +++
.../events/src/CheckIn/DTOs/CheckInDTO.php | 25 +++
.../src/CheckIn/DTOs/ManualCheckInDTO.php | 20 ++
.../CheckIn/Events/ParticipantCheckedIn.php | 22 +++
.../CheckIn/Exceptions/CheckInException.php | 43 +++++
.../events/src/CheckIn/Models/CheckIn.php | 3 +
.../Enrollment/Actions/EnrollUserAction.php | 2 +-
.../Actions/TransitionEnrollmentAction.php | 43 +++++
.../DTOs/TransitionEnrollmentDTO.php | 26 +++
.../src/Enrollment/Enums/EnrollmentStatus.php | 10 +
.../Exceptions/EnrollmentException.php | 17 ++
.../src/Enrollment/Models/Enrollment.php | 3 +
app-modules/events/src/Event/Models/Event.php | 4 +
.../tests/Feature/CheckInActionTest.php | 174 ++++++++++++++++++
.../tests/Feature/EventResourceTest.php | 87 +++++++++
.../tests/Unit/EnrollmentStatusTest.php | 6 +
.../EnrollmentsRelationManager.php | 106 +++++++++++
24 files changed, 772 insertions(+), 1 deletion(-)
create mode 100644 app-modules/events/database/migrations/2026_05_22_000001_add_checked_in_at_to_events_check_ins_table.php
create mode 100644 app-modules/events/lang/en/check_in.php
create mode 100644 app-modules/events/lang/pt_BR/check_in.php
create mode 100644 app-modules/events/src/CheckIn/Actions/CheckInAction.php
create mode 100644 app-modules/events/src/CheckIn/Actions/ManualCheckInAction.php
create mode 100644 app-modules/events/src/CheckIn/DTOs/CheckInDTO.php
create mode 100644 app-modules/events/src/CheckIn/DTOs/ManualCheckInDTO.php
create mode 100644 app-modules/events/src/CheckIn/Events/ParticipantCheckedIn.php
create mode 100644 app-modules/events/src/CheckIn/Exceptions/CheckInException.php
create mode 100644 app-modules/events/src/Enrollment/Actions/TransitionEnrollmentAction.php
create mode 100644 app-modules/events/src/Enrollment/DTOs/TransitionEnrollmentDTO.php
create mode 100644 app-modules/events/tests/Feature/CheckInActionTest.php
diff --git a/app-modules/events/database/factories/CheckInFactory.php b/app-modules/events/database/factories/CheckInFactory.php
index fa621af9f..2392e1fec 100644
--- a/app-modules/events/database/factories/CheckInFactory.php
+++ b/app-modules/events/database/factories/CheckInFactory.php
@@ -22,6 +22,7 @@ public function definition(): array
'event_date' => Date::today(),
'method' => fake()->randomElement(CheckInMethod::cases()),
'payload' => null,
+ 'checked_in_at' => Date::now(),
];
}
}
diff --git a/app-modules/events/database/migrations/2026_05_22_000001_add_checked_in_at_to_events_check_ins_table.php b/app-modules/events/database/migrations/2026_05_22_000001_add_checked_in_at_to_events_check_ins_table.php
new file mode 100644
index 000000000..8b7967eb8
--- /dev/null
+++ b/app-modules/events/database/migrations/2026_05_22_000001_add_checked_in_at_to_events_check_ins_table.php
@@ -0,0 +1,24 @@
+timestampTz('checked_in_at')->nullable()->after('payload');
+ });
+ }
+
+ public function down(): void
+ {
+ Schema::table('events_check_ins', function (Blueprint $table): void {
+ $table->dropColumn('checked_in_at');
+ });
+ }
+};
diff --git a/app-modules/events/lang/en/check_in.php b/app-modules/events/lang/en/check_in.php
new file mode 100644
index 000000000..c163fc1fe
--- /dev/null
+++ b/app-modules/events/lang/en/check_in.php
@@ -0,0 +1,10 @@
+ 'Only confirmed or checked-in enrollments can be checked in.',
+ 'check_in_outside_event_date_range' => 'Check-in date must be within the event date range.',
+ 'already_checked_in_for_date' => 'This enrollment has already checked in for this date.',
+ 'invalid_check_in_actor' => 'Manual check-in requires the organizer user id.',
+];
diff --git a/app-modules/events/lang/en/exceptions.php b/app-modules/events/lang/en/exceptions.php
index 056dd6539..58511678b 100644
--- a/app-modules/events/lang/en/exceptions.php
+++ b/app-modules/events/lang/en/exceptions.php
@@ -3,9 +3,11 @@
declare(strict_types=1);
return [
+ 'invalid_transition' => 'Cannot transition enrollment from :from to :to.',
'already_enrolled' => 'You are already enrolled in this event.',
'event_past' => 'This event has already started and is no longer accepting enrollments.',
'event_not_active' => 'This event is not available for enrollment.',
'invalid_enrollment_method' => 'This event requires application enrollment, not RSVP.',
'event_full' => 'This event has reached maximum capacity and does not have a waitlist.',
+ 'response_message_not_implemented' => 'Response message is not implemented for enrollment status: :status.',
];
diff --git a/app-modules/events/lang/pt_BR/check_in.php b/app-modules/events/lang/pt_BR/check_in.php
new file mode 100644
index 000000000..46a65b78e
--- /dev/null
+++ b/app-modules/events/lang/pt_BR/check_in.php
@@ -0,0 +1,10 @@
+ 'Apenas inscrições confirmadas ou com check-in realizado podem receber check-in.',
+ 'check_in_outside_event_date_range' => 'A data do check-in deve estar dentro do período do evento.',
+ 'already_checked_in_for_date' => 'Esta inscrição já possui check-in para essa data.',
+ 'invalid_check_in_actor' => 'Check-in manual exige o ID do usuário organizador.',
+];
diff --git a/app-modules/events/lang/pt_BR/exceptions.php b/app-modules/events/lang/pt_BR/exceptions.php
index 27d80f367..fdd707908 100644
--- a/app-modules/events/lang/pt_BR/exceptions.php
+++ b/app-modules/events/lang/pt_BR/exceptions.php
@@ -3,9 +3,11 @@
declare(strict_types=1);
return [
+ 'invalid_transition' => 'Não é possível transicionar inscrição de :from para :to.',
'already_enrolled' => 'Você já está inscrito neste evento.',
'event_past' => 'Este evento já começou e não aceita mais inscrições.',
'event_not_active' => 'Este evento não está disponível para inscrição.',
'invalid_enrollment_method' => 'Esse evento exige inscrição via formulário, não RSVP.',
'event_full' => 'Este evento atingiu a capacidade máxima e não possui lista de espera.',
+ 'response_message_not_implemented' => 'Mensagem de resposta não implementada para o status de inscrição: :status.',
];
diff --git a/app-modules/events/src/CheckIn/Actions/CheckInAction.php b/app-modules/events/src/CheckIn/Actions/CheckInAction.php
new file mode 100644
index 000000000..5739fb856
--- /dev/null
+++ b/app-modules/events/src/CheckIn/Actions/CheckInAction.php
@@ -0,0 +1,105 @@
+loadLockedEnrollment($dto->enrollment);
+ $event = $enrollment->event;
+ $normalizedEventDate = Date::parse($dto->eventDate->toDateString())->startOfDay();
+
+ $this->validateCommonRules($enrollment, $normalizedEventDate);
+ $isFirstCheckIn = !CheckIn::query()
+ ->where('enrollment_id', $enrollment->id)
+ ->exists();
+
+ try {
+ $checkIn = CheckIn::query()->create([
+ 'enrollment_id' => $enrollment->id,
+ 'method' => $dto->method,
+ 'payload' => $dto->payload,
+ 'event_date' => $normalizedEventDate,
+ 'checked_in_at' => now(),
+ ]);
+ } catch (UniqueConstraintViolationException) {
+ throw CheckInException::alreadyCheckedInForDate();
+ }
+
+ if ($isFirstCheckIn && $enrollment->status === EnrollmentStatus::Confirmed) {
+ resolve(TransitionEnrollmentAction::class)->handle(
+ new TransitionEnrollmentDTO(
+ enrollment: $enrollment,
+ toStatus: EnrollmentStatus::CheckedIn,
+ triggeredBy: $dto->triggeredBy,
+ actorId: $dto->actorUserId,
+ timestamp: $checkIn->checked_in_at,
+ ),
+ );
+ }
+
+ $xpRewardOnCheckedIn = (int) ($event->enrollmentPolicy->xp_on_checked_in ?? 0);
+
+ event(new ParticipantCheckedIn(
+ checkInId: $checkIn->id,
+ enrollmentId: $enrollment->id,
+ eventId: $enrollment->event_id,
+ userId: $enrollment->user_id,
+ eventDate: $normalizedEventDate->toDateString(),
+ xpRewardOnCheckedIn: $xpRewardOnCheckedIn,
+ ));
+
+ return $checkIn->load('enrollment');
+ });
+ }
+
+ private function loadLockedEnrollment(Enrollment $enrollment): Enrollment
+ {
+ return Enrollment::query()
+ ->with(['event.enrollmentPolicy'])
+ ->whereKey($enrollment->id)
+ ->lockForUpdate()
+ ->firstOrFail();
+ }
+
+ private function validateCommonRules(Enrollment $enrollment, CarbonInterface $eventDate): void
+ {
+ throw_unless(
+ in_array($enrollment->status, [EnrollmentStatus::Confirmed, EnrollmentStatus::CheckedIn], strict: true),
+ CheckInException::invalidCheckInStatus(),
+ );
+
+ $event = $enrollment->event;
+ $eventDateString = $eventDate->toDateString();
+
+ throw_unless(
+ $eventDateString >= $event->starts_at->toDateString() && $eventDateString <= $event->ends_at->toDateString(),
+ CheckInException::checkInOutsideEventDateRange(),
+ );
+
+ throw_if(
+ CheckIn::query()
+ ->where('enrollment_id', $enrollment->id)
+ ->whereDate('event_date', $eventDate->toDateString())
+ ->exists(),
+ CheckInException::alreadyCheckedInForDate(),
+ );
+ }
+}
diff --git a/app-modules/events/src/CheckIn/Actions/ManualCheckInAction.php b/app-modules/events/src/CheckIn/Actions/ManualCheckInAction.php
new file mode 100644
index 000000000..8f82399e1
--- /dev/null
+++ b/app-modules/events/src/CheckIn/Actions/ManualCheckInAction.php
@@ -0,0 +1,28 @@
+handle(
+ new CheckInDTO(
+ enrollment: $dto->enrollment,
+ method: CheckInMethod::Manual,
+ payload: ['actor_user_id' => $dto->actorUserId],
+ eventDate: $dto->eventDate,
+ actorUserId: $dto->actorUserId,
+ triggeredBy: TriggeredBy::Admin,
+ ),
+ );
+ }
+}
diff --git a/app-modules/events/src/CheckIn/DTOs/CheckInDTO.php b/app-modules/events/src/CheckIn/DTOs/CheckInDTO.php
new file mode 100644
index 000000000..becb5dcd6
--- /dev/null
+++ b/app-modules/events/src/CheckIn/DTOs/CheckInDTO.php
@@ -0,0 +1,25 @@
+ $payload
+ */
+ public function __construct(
+ public Enrollment $enrollment,
+ public CheckInMethod $method,
+ public array $payload,
+ public CarbonInterface $eventDate,
+ public string $actorUserId,
+ public TriggeredBy $triggeredBy,
+ ) {}
+}
diff --git a/app-modules/events/src/CheckIn/DTOs/ManualCheckInDTO.php b/app-modules/events/src/CheckIn/DTOs/ManualCheckInDTO.php
new file mode 100644
index 000000000..261a00d3f
--- /dev/null
+++ b/app-modules/events/src/CheckIn/DTOs/ManualCheckInDTO.php
@@ -0,0 +1,20 @@
+actorUserId), CheckInException::invalidCheckInActor());
+ }
+}
diff --git a/app-modules/events/src/CheckIn/Events/ParticipantCheckedIn.php b/app-modules/events/src/CheckIn/Events/ParticipantCheckedIn.php
new file mode 100644
index 000000000..8ddddc728
--- /dev/null
+++ b/app-modules/events/src/CheckIn/Events/ParticipantCheckedIn.php
@@ -0,0 +1,22 @@
+|null $payload
+ * @property Carbon $checked_in_at
* @property Carbon $created_at
* @property Carbon $updated_at
*/
@@ -35,6 +36,7 @@ final class CheckIn extends Model
'event_date',
'method',
'payload',
+ 'checked_in_at',
];
/** @return BelongsTo */
@@ -55,6 +57,7 @@ protected function casts(): array
'event_date' => 'date',
'method' => CheckInMethod::class,
'payload' => 'array',
+ 'checked_in_at' => 'datetime',
];
}
}
diff --git a/app-modules/events/src/Enrollment/Actions/EnrollUserAction.php b/app-modules/events/src/Enrollment/Actions/EnrollUserAction.php
index a43493e6a..bff9b7318 100644
--- a/app-modules/events/src/Enrollment/Actions/EnrollUserAction.php
+++ b/app-modules/events/src/Enrollment/Actions/EnrollUserAction.php
@@ -53,7 +53,7 @@ public function handle(EnrollUserDTO $dto): Enrollment
enrollmentId: $enrollment->id,
eventId: $dto->eventId,
userId: $dto->userId,
- xpRewardOnConfirmed: $policy?->xp_on_confirmed ?? 0,
+ xpRewardOnConfirmed: $policy->xp_on_confirmed ?? 0,
));
}
diff --git a/app-modules/events/src/Enrollment/Actions/TransitionEnrollmentAction.php b/app-modules/events/src/Enrollment/Actions/TransitionEnrollmentAction.php
new file mode 100644
index 000000000..a68420708
--- /dev/null
+++ b/app-modules/events/src/Enrollment/Actions/TransitionEnrollmentAction.php
@@ -0,0 +1,43 @@
+enrollment->status;
+ $toStatus = $dto->toStatus;
+
+ throw_unless(
+ $fromStatus->canTransitionTo($toStatus),
+ EnrollmentException::invalidTransition($fromStatus, $toStatus),
+ );
+
+ $attributes = [
+ 'status' => $toStatus,
+ ];
+
+ if ($timestampColumn = $toStatus->timestampColumn()) {
+ $attributes[$timestampColumn] = $dto->timestamp ?? now();
+ }
+
+ $dto->enrollment->update($attributes);
+
+ return EnrollmentTransition::query()->create([
+ 'enrollment_id' => $dto->enrollment->id,
+ 'from_status' => $fromStatus,
+ 'to_status' => $toStatus,
+ 'actor_id' => $dto->actorId,
+ 'triggered_by' => $dto->triggeredBy,
+ 'reason' => $dto->reason,
+ 'metadata' => $dto->metadata ?: null,
+ ]);
+ }
+}
diff --git a/app-modules/events/src/Enrollment/DTOs/TransitionEnrollmentDTO.php b/app-modules/events/src/Enrollment/DTOs/TransitionEnrollmentDTO.php
new file mode 100644
index 000000000..c84a27bf7
--- /dev/null
+++ b/app-modules/events/src/Enrollment/DTOs/TransitionEnrollmentDTO.php
@@ -0,0 +1,26 @@
+ $metadata
+ */
+ public function __construct(
+ public Enrollment $enrollment,
+ public EnrollmentStatus $toStatus,
+ public TriggeredBy $triggeredBy,
+ public ?string $actorId = null,
+ public ?string $reason = null,
+ public array $metadata = [],
+ public ?CarbonInterface $timestamp = null,
+ ) {}
+}
diff --git a/app-modules/events/src/Enrollment/Enums/EnrollmentStatus.php b/app-modules/events/src/Enrollment/Enums/EnrollmentStatus.php
index 70d1e9930..500b9bb88 100644
--- a/app-modules/events/src/Enrollment/Enums/EnrollmentStatus.php
+++ b/app-modules/events/src/Enrollment/Enums/EnrollmentStatus.php
@@ -9,6 +9,7 @@
use Filament\Support\Contracts\HasIcon;
use Filament\Support\Contracts\HasLabel;
use Filament\Support\Icons\Heroicon;
+use He4rt\Events\Enrollment\Exceptions\EnrollmentException;
enum EnrollmentStatus: string implements HasColor, HasIcon, HasLabel
{
@@ -65,6 +66,14 @@ public function canTransitionTo(self $target): bool
};
}
+ public function timestampColumn(): ?string
+ {
+ return match ($this) {
+ self::Confirmed, self::CheckedIn, self::Attended, self::Cancelled => $this->value.'_at',
+ default => null,
+ };
+ }
+
public function isTerminal(): bool
{
return in_array($this, [self::Attended, self::Cancelled, self::Rejected, self::NoShow], strict: true);
@@ -90,6 +99,7 @@ public function getResponseMessage(): string
return match ($this) {
self::Confirmed => __('events::pages.confirm_presence_success'),
self::Waitlisted => __('events::pages.waitlist_success'),
+ default => throw EnrollmentException::responseMessageNotImplemented($this),
};
}
}
diff --git a/app-modules/events/src/Enrollment/Exceptions/EnrollmentException.php b/app-modules/events/src/Enrollment/Exceptions/EnrollmentException.php
index 8ffc2cff5..e72ce0a8c 100644
--- a/app-modules/events/src/Enrollment/Exceptions/EnrollmentException.php
+++ b/app-modules/events/src/Enrollment/Exceptions/EnrollmentException.php
@@ -5,10 +5,19 @@
namespace He4rt\Events\Enrollment\Exceptions;
use Exception;
+use He4rt\Events\Enrollment\Enums\EnrollmentStatus;
use Symfony\Component\HttpFoundation\Response;
final class EnrollmentException extends Exception
{
+ public static function invalidTransition(EnrollmentStatus $from, EnrollmentStatus $to): self
+ {
+ return new self(
+ __('events::exceptions.invalid_transition', ['from' => $from->value, 'to' => $to->value]),
+ Response::HTTP_UNPROCESSABLE_ENTITY,
+ );
+ }
+
public static function alreadyEnrolled(): self
{
return new self(
@@ -48,4 +57,12 @@ public static function eventFull(): self
Response::HTTP_UNPROCESSABLE_ENTITY,
);
}
+
+ public static function responseMessageNotImplemented(EnrollmentStatus $status): self
+ {
+ return new self(
+ __('events::exceptions.response_message_not_implemented', ['status' => $status->value]),
+ Response::HTTP_INTERNAL_SERVER_ERROR,
+ );
+ }
}
diff --git a/app-modules/events/src/Enrollment/Models/Enrollment.php b/app-modules/events/src/Enrollment/Models/Enrollment.php
index 3d163885e..093d44f42 100644
--- a/app-modules/events/src/Enrollment/Models/Enrollment.php
+++ b/app-modules/events/src/Enrollment/Models/Enrollment.php
@@ -97,16 +97,19 @@ protected static function newFactory(): EnrollmentFactory
return EnrollmentFactory::new();
}
+ /** @param Builder $query */
protected function scopeConfirmed(Builder $query): void
{
$query->where('status', EnrollmentStatus::Confirmed);
}
+ /** @param Builder $query */
protected function scopeWaitlisted(Builder $query): void
{
$query->where('status', EnrollmentStatus::Waitlisted);
}
+ /** @param Builder $query */
protected function scopeActive(Builder $query): void
{
$query->whereNotIn('status', [
diff --git a/app-modules/events/src/Event/Models/Event.php b/app-modules/events/src/Event/Models/Event.php
index c6c61e216..5f6006fd4 100644
--- a/app-modules/events/src/Event/Models/Event.php
+++ b/app-modules/events/src/Event/Models/Event.php
@@ -97,21 +97,25 @@ protected static function newFactory(): EventFactory
return EventFactory::new();
}
+ /** @param Builder $query */
protected function scopePublished(Builder $query): void
{
$query->where('status', EventStatus::Published);
}
+ /** @param Builder $query */
protected function scopeViewableByParticipant(Builder $query): void
{
$query->whereIn('status', EventStatus::viewableByParticipant());
}
+ /** @param Builder $query */
protected function scopeUpcoming(Builder $query): void
{
$query->where('starts_at', '>', now());
}
+ /** @param Builder $query */
protected function scopeActive(Builder $query): void
{
$query->where('status', EventStatus::Published)
diff --git a/app-modules/events/tests/Feature/CheckInActionTest.php b/app-modules/events/tests/Feature/CheckInActionTest.php
new file mode 100644
index 000000000..01a5dc38b
--- /dev/null
+++ b/app-modules/events/tests/Feature/CheckInActionTest.php
@@ -0,0 +1,174 @@
+create();
+ $participant = User::factory()->create();
+ $startsAt = now()->setTime(9, 0);
+
+ $event = Event::factory()
+ ->for($tenant)
+ ->create(array_merge([
+ 'starts_at' => $startsAt,
+ 'ends_at' => $startsAt->clone()->setTime(18, 0),
+ 'status' => EventStatus::Published,
+ ], $eventAttributes));
+
+ return Enrollment::factory()->create(array_merge([
+ 'event_id' => $event->id,
+ 'user_id' => $participant->id,
+ 'status' => EnrollmentStatus::Confirmed,
+ 'confirmed_at' => now(),
+ ], $enrollmentAttributes));
+}
+
+test('when organizer checks in confirmed enrollment, then check-in is recorded and enrollment transitions', function (): void {
+ EventFacade::fake([ParticipantCheckedIn::class]);
+
+ $organizer = User::factory()->create();
+ $enrollment = createConfirmedEnrollmentForCheckIn();
+
+ $checkIn = resolve(ManualCheckInAction::class)->handle(
+ new ManualCheckInDTO(
+ enrollment: $enrollment,
+ actorUserId: $organizer->id,
+ eventDate: now(),
+ ),
+ );
+
+ expect($checkIn)->toBeInstanceOf(CheckIn::class)
+ ->and($checkIn->enrollment_id)->toBe($enrollment->id)
+ ->and($checkIn->method)->toBe(CheckInMethod::Manual)
+ ->and($checkIn->event_date->isSameDay(now()))->toBeTrue()
+ ->and($checkIn->checked_in_at)->not->toBeNull()
+ ->and($checkIn->payload)->toBe(['actor_user_id' => $organizer->id])
+ ->and($enrollment->fresh()->status)->toBe(EnrollmentStatus::CheckedIn)
+ ->and($enrollment->fresh()->checked_in_at)->not->toBeNull();
+
+ $transition = EnrollmentTransition::query()
+ ->where('enrollment_id', $enrollment->id)
+ ->where('to_status', EnrollmentStatus::CheckedIn)
+ ->first();
+
+ expect($transition)->not->toBeNull()
+ ->and($transition->from_status)->toBe(EnrollmentStatus::Confirmed)
+ ->and($transition->actor_id)->toBe($organizer->id)
+ ->and($transition->triggered_by)->toBe(TriggeredBy::Admin);
+
+ EventFacade::assertDispatched(fn (ParticipantCheckedIn $event): bool => $event->enrollmentId === $enrollment->id
+ && $event->eventId === $enrollment->event_id
+ && $event->userId === $enrollment->user_id
+ && $event->checkInId === $checkIn->id);
+});
+
+test('when checked-in enrollment checks in on another event date, then only check-in record is created', function (): void {
+ $organizer = User::factory()->create();
+ $startsAt = now()->setTime(9, 0);
+ $enrollment = createConfirmedEnrollmentForCheckIn([
+ 'starts_at' => $startsAt,
+ 'ends_at' => $startsAt->clone()->addDay()->setTime(18, 0),
+ ], [
+ 'status' => EnrollmentStatus::CheckedIn,
+ ]);
+
+ CheckIn::factory()->create([
+ 'enrollment_id' => $enrollment->id,
+ 'event_date' => $startsAt->toDateString(),
+ 'method' => CheckInMethod::Manual,
+ 'checked_in_at' => now(),
+ ]);
+
+ $checkIn = resolve(ManualCheckInAction::class)->handle(
+ new ManualCheckInDTO(
+ enrollment: $enrollment,
+ actorUserId: $organizer->id,
+ eventDate: $startsAt->clone()->addDay(),
+ ),
+ );
+
+ expect($checkIn->event_date->isSameDay($startsAt->clone()->addDay()))->toBeTrue()
+ ->and(CheckIn::query()->where('enrollment_id', $enrollment->id)->count())->toBe(2)
+ ->and(EnrollmentTransition::query()
+ ->where('enrollment_id', $enrollment->id)
+ ->where('to_status', EnrollmentStatus::CheckedIn)
+ ->count())->toBe(0);
+});
+
+test('when enrollment already has check-in for event date, then duplicate is rejected', function (): void {
+ $enrollment = createConfirmedEnrollmentForCheckIn();
+
+ CheckIn::factory()->create([
+ 'enrollment_id' => $enrollment->id,
+ 'event_date' => now()->toDateString(),
+ 'method' => CheckInMethod::Manual,
+ ]);
+
+ expect(fn (): CheckIn => resolve(ManualCheckInAction::class)->handle(
+ new ManualCheckInDTO(
+ enrollment: $enrollment,
+ actorUserId: User::factory()->create()->id,
+ eventDate: now(),
+ ),
+ ))->toThrow(CheckInException::class);
+});
+
+test('when check-in date is outside event date range, then check-in is rejected', function (): void {
+ $startsAt = now()->setTime(9, 0);
+ $enrollment = createConfirmedEnrollmentForCheckIn([
+ 'starts_at' => $startsAt,
+ 'ends_at' => $startsAt->clone()->setTime(18, 0),
+ ]);
+
+ expect(fn (): CheckIn => resolve(ManualCheckInAction::class)->handle(
+ new ManualCheckInDTO(
+ enrollment: $enrollment,
+ actorUserId: User::factory()->create()->id,
+ eventDate: $startsAt->clone()->addDay(),
+ ),
+ ))->toThrow(CheckInException::class);
+});
+
+test('when enrollment status is not confirmed or checked in, then check-in is rejected', function (): void {
+ $enrollment = createConfirmedEnrollmentForCheckIn(enrollmentAttributes: [
+ 'status' => EnrollmentStatus::Waitlisted,
+ 'confirmed_at' => null,
+ ]);
+
+ expect(fn (): CheckIn => resolve(ManualCheckInAction::class)->handle(
+ new ManualCheckInDTO(
+ enrollment: $enrollment,
+ actorUserId: User::factory()->create()->id,
+ eventDate: now(),
+ ),
+ ))->toThrow(CheckInException::class);
+});
+
+test('when manual check-in has no actor user id, then check-in is rejected', function (): void {
+ $enrollment = createConfirmedEnrollmentForCheckIn();
+
+ expect(fn (): CheckIn => resolve(ManualCheckInAction::class)->handle(
+ new ManualCheckInDTO(
+ enrollment: $enrollment,
+ actorUserId: '',
+ eventDate: now(),
+ ),
+ ))->toThrow(CheckInException::class);
+});
diff --git a/app-modules/events/tests/Feature/EventResourceTest.php b/app-modules/events/tests/Feature/EventResourceTest.php
index 4fbb7f2ea..8d0763ab9 100644
--- a/app-modules/events/tests/Feature/EventResourceTest.php
+++ b/app-modules/events/tests/Feature/EventResourceTest.php
@@ -4,8 +4,11 @@
use Filament\Facades\Filament;
use He4rt\Events\CheckIn\Enums\CheckInMethod;
+use He4rt\Events\CheckIn\Models\CheckIn;
use He4rt\Events\Enrollment\Enums\AttendanceRequirement;
use He4rt\Events\Enrollment\Enums\EnrollmentMethod;
+use He4rt\Events\Enrollment\Enums\EnrollmentStatus;
+use He4rt\Events\Enrollment\Models\Enrollment;
use He4rt\Events\Enrollment\Models\EnrollmentPolicy;
use He4rt\Events\Event\Enums\EventType;
use He4rt\Events\Event\Models\Event;
@@ -137,3 +140,87 @@
])
->assertSuccessful();
});
+
+test('when admin checks in an enrollment from relation manager, then participant is checked in', function (): void {
+ $event = Event::factory()->create([
+ 'starts_at' => now()->setTime(9, 0),
+ 'ends_at' => now()->setTime(18, 0),
+ ]);
+
+ $enrollment = Enrollment::factory()->create([
+ 'event_id' => $event->id,
+ 'status' => EnrollmentStatus::Confirmed,
+ 'confirmed_at' => now(),
+ ]);
+
+ livewire(EnrollmentsRelationManager::class, [
+ 'ownerRecord' => $event,
+ 'pageClass' => EditEvent::class,
+ ])
+ ->callTableAction('checkIn', $enrollment, data: [
+ 'event_date' => now()->toDateString(),
+ ])
+ ->assertHasNoTableActionErrors();
+
+ expect($enrollment->fresh()->status)->toBe(EnrollmentStatus::CheckedIn)
+ ->and(CheckIn::query()->where('enrollment_id', $enrollment->id)->count())->toBe(1);
+});
+
+test('when admin bulk checks in selected enrollments, then all selected participants are checked in', function (): void {
+ $event = Event::factory()->create([
+ 'starts_at' => now()->setTime(9, 0),
+ 'ends_at' => now()->setTime(18, 0),
+ ]);
+
+ $enrollments = Enrollment::factory()
+ ->count(2)
+ ->create([
+ 'event_id' => $event->id,
+ 'status' => EnrollmentStatus::Confirmed,
+ 'confirmed_at' => now(),
+ ]);
+
+ livewire(EnrollmentsRelationManager::class, [
+ 'ownerRecord' => $event,
+ 'pageClass' => EditEvent::class,
+ ])
+ ->callTableBulkAction('checkInSelected', $enrollments, data: [
+ 'event_date' => now()->toDateString(),
+ ])
+ ->assertHasNoTableBulkActionErrors();
+
+ expect(Enrollment::query()->whereKey($enrollments->pluck('id'))->where('status', EnrollmentStatus::CheckedIn)->count())->toBe(2)
+ ->and(CheckIn::query()->whereIn('enrollment_id', $enrollments->pluck('id'))->count())->toBe(2);
+});
+
+test('when enrollment has check-ins, then relation manager shows check-in history', function (): void {
+ $startsAt = now()->setTime(9, 0);
+ $event = Event::factory()->create([
+ 'starts_at' => $startsAt,
+ 'ends_at' => $startsAt->clone()->addDay()->setTime(18, 0),
+ ]);
+
+ $enrollment = Enrollment::factory()->create([
+ 'event_id' => $event->id,
+ 'status' => EnrollmentStatus::CheckedIn,
+ ]);
+
+ CheckIn::factory()->create([
+ 'enrollment_id' => $enrollment->id,
+ 'event_date' => $startsAt->toDateString(),
+ 'method' => CheckInMethod::Manual,
+ ]);
+
+ CheckIn::factory()->create([
+ 'enrollment_id' => $enrollment->id,
+ 'event_date' => $startsAt->clone()->addDay()->toDateString(),
+ 'method' => CheckInMethod::Manual,
+ ]);
+
+ livewire(EnrollmentsRelationManager::class, [
+ 'ownerRecord' => $event,
+ 'pageClass' => EditEvent::class,
+ ])
+ ->assertSee($startsAt->toDateString())
+ ->assertSee($startsAt->clone()->addDay()->toDateString());
+});
diff --git a/app-modules/events/tests/Unit/EnrollmentStatusTest.php b/app-modules/events/tests/Unit/EnrollmentStatusTest.php
index 1f0f934e7..35afe8448 100644
--- a/app-modules/events/tests/Unit/EnrollmentStatusTest.php
+++ b/app-modules/events/tests/Unit/EnrollmentStatusTest.php
@@ -3,6 +3,7 @@
declare(strict_types=1);
use He4rt\Events\Enrollment\Enums\EnrollmentStatus;
+use He4rt\Events\Enrollment\Exceptions\EnrollmentException;
test('when a pending enrollment is evaluated, then it can transition to confirmed, rejected, or cancelled', function (): void {
expect(EnrollmentStatus::Pending->canTransitionTo(EnrollmentStatus::Confirmed))->toBeTrue()
@@ -74,3 +75,8 @@
[EnrollmentStatus::Confirmed, 'events::pages.confirm_presence_success'],
[EnrollmentStatus::Waitlisted, 'events::pages.waitlist_success'],
]);
+
+test('when response message is requested for unsupported enrollment status, then it fails explicitly', function (): void {
+ expect(fn (): string => EnrollmentStatus::Pending->getResponseMessage())
+ ->toThrow(EnrollmentException::class);
+});
diff --git a/app-modules/panel-admin/src/Filament/Resources/Events/RelationManagers/EnrollmentsRelationManager.php b/app-modules/panel-admin/src/Filament/Resources/Events/RelationManagers/EnrollmentsRelationManager.php
index fed021bc8..edb1020aa 100644
--- a/app-modules/panel-admin/src/Filament/Resources/Events/RelationManagers/EnrollmentsRelationManager.php
+++ b/app-modules/panel-admin/src/Filament/Resources/Events/RelationManagers/EnrollmentsRelationManager.php
@@ -4,11 +4,25 @@
namespace He4rt\PanelAdmin\Filament\Resources\Events\RelationManagers;
+use Filament\Actions\Action;
+use Filament\Actions\BulkAction;
+use Filament\Actions\BulkActionGroup;
+use Filament\Forms\Components\DatePicker;
+use Filament\Notifications\Notification;
use Filament\Resources\RelationManagers\RelationManager;
+use Filament\Support\Icons\Heroicon;
use Filament\Tables\Columns\TextColumn;
use Filament\Tables\Filters\SelectFilter;
use Filament\Tables\Table;
+use He4rt\Events\CheckIn\Actions\ManualCheckInAction;
+use He4rt\Events\CheckIn\DTOs\ManualCheckInDTO;
+use He4rt\Events\CheckIn\Models\CheckIn;
use He4rt\Events\Enrollment\Enums\EnrollmentStatus;
+use He4rt\Events\Enrollment\Models\Enrollment;
+use He4rt\Events\Event\Models\Event;
+use Illuminate\Database\Eloquent\Builder;
+use Illuminate\Database\Eloquent\Collection;
+use Illuminate\Support\Facades\Date;
final class EnrollmentsRelationManager extends RelationManager
{
@@ -23,6 +37,7 @@ public function table(Table $table): Table
{
return $table
->recordTitleAttribute('id')
+ ->modifyQueryUsing(fn (Builder $query): Builder => $query->with(['checkIns', 'user']))
->columns([
TextColumn::make('user.name')
->label('Participant')
@@ -44,6 +59,15 @@ public function table(Table $table): Table
->dateTime()
->sortable(),
+ TextColumn::make('check_in_history')
+ ->label('Check-in History')
+ ->state(fn (Enrollment $record): string => $record->checkIns
+ ->sortBy('event_date')
+ ->map(fn (CheckIn $checkIn): string => $checkIn->event_date->toDateString())
+ ->implode(', '))
+ ->placeholder('-')
+ ->toggleable(),
+
TextColumn::make('cancelled_at')
->label('Cancelled At')
->dateTime()
@@ -54,6 +78,88 @@ public function table(Table $table): Table
SelectFilter::make('status')
->label('Status')
->options(EnrollmentStatus::class),
+ ])
+ ->recordActions([
+ $this->checkInAction(),
+ ])
+ ->toolbarActions([
+ BulkActionGroup::make([
+ $this->bulkCheckInAction(),
+ ]),
]);
}
+
+ private function checkInAction(): Action
+ {
+ return Action::make('checkIn')
+ ->label('Check In')
+ ->icon(Heroicon::OutlinedCheckCircle)
+ ->color('success')
+ ->visible(fn (Enrollment $record): bool => $record->status->is(EnrollmentStatus::Confirmed, EnrollmentStatus::CheckedIn))
+ ->schema($this->checkInSchema())
+ ->action(function (Enrollment $record, array $data): void {
+ $this->checkIn($record, $data);
+
+ Notification::make()
+ ->success()
+ ->title('Participant checked in.')
+ ->send();
+ });
+ }
+
+ private function bulkCheckInAction(): BulkAction
+ {
+ return BulkAction::make('checkInSelected')
+ ->label('Check In Selected')
+ ->icon(Heroicon::OutlinedCheckCircle)
+ ->color('success')
+ ->schema($this->checkInSchema())
+ ->action(function (Collection $records, array $data): void {
+ foreach ($records as $record) {
+ if (!$record instanceof Enrollment) {
+ continue;
+ }
+
+ $this->checkIn($record, $data);
+ }
+
+ Notification::make()
+ ->success()
+ ->title('Selected participants checked in.')
+ ->send();
+ })
+ ->deselectRecordsAfterCompletion();
+ }
+
+ /**
+ * @return array
+ */
+ private function checkInSchema(): array
+ {
+ /** @var Event $event */
+ $event = $this->getOwnerRecord();
+
+ return [
+ DatePicker::make('event_date')
+ ->label('Date')
+ ->default(now())
+ ->minDate($event->starts_at->toDateString())
+ ->maxDate($event->ends_at->toDateString())
+ ->required(),
+ ];
+ }
+
+ /**
+ * @param array $data
+ */
+ private function checkIn(Enrollment $enrollment, array $data): CheckIn
+ {
+ return resolve(ManualCheckInAction::class)->handle(
+ new ManualCheckInDTO(
+ enrollment: $enrollment,
+ actorUserId: (string) auth()->id(),
+ eventDate: Date::parse($data['event_date']),
+ ),
+ );
+ }
}
From f631ea47ee37f1806ac511396b80026103d6a010 Mon Sep 17 00:00:00 2001
From: Bruna Domingues Leite
Date: Thu, 4 Jun 2026 15:53:41 -0300
Subject: [PATCH 05/18] feat(events): waitlist and capacity enforcement (#294)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
## 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=`, `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)
---
app-modules/events/lang/en/pages.php | 4 +-
app-modules/events/lang/pt_BR/pages.php | 4 +-
.../Enrollment/Actions/EnrollUserAction.php | 16 ++-
.../src/Enrollment/Enums/EnrollmentStatus.php | 6 +-
.../Events/EnrollmentWaitlisted.php | 20 +++
.../src/Enrollment/Models/Enrollment.php | 8 +-
.../tests/Feature/EnrollUserActionTest.php | 116 +++++++++++++++++-
.../events/tests/Unit/EnrollmentScopeTest.php | 38 ++++++
.../EnrollmentsRelationManager.php | 6 +
.../livewire/events/event-detail.blade.php | 14 +++
.../src/Livewire/Events/EventDetail.php | 32 ++++-
.../Feature/Events/RsvpEnrollmentTest.php | 25 +++-
12 files changed, 267 insertions(+), 22 deletions(-)
create mode 100644 app-modules/events/src/Enrollment/Events/EnrollmentWaitlisted.php
create mode 100644 app-modules/events/tests/Unit/EnrollmentScopeTest.php
diff --git a/app-modules/events/lang/en/pages.php b/app-modules/events/lang/en/pages.php
index dc5f2f51a..18f97b18e 100644
--- a/app-modules/events/lang/en/pages.php
+++ b/app-modules/events/lang/en/pages.php
@@ -7,7 +7,9 @@
'confirm_presence' => 'Confirm Presence',
'confirm_presence_hint' => 'Confirm your attendance to this event.',
'confirm_presence_success' => 'Your presence has been confirmed!',
- 'waitlist_success' => 'You have been added to the waitlist for this event.',
+ 'waitlist_success' => 'You are on the waitlist (position :position).',
+ 'waitlist_status' => 'You are on the waitlist (position :position).',
+ 'event_full' => 'This event is full.',
'enrollment_status_label' => 'Your enrollment status',
'enrolled_at' => 'Enrolled on :date',
'no_enrollments' => 'You are not enrolled in any events yet.',
diff --git a/app-modules/events/lang/pt_BR/pages.php b/app-modules/events/lang/pt_BR/pages.php
index 42243fa86..44a375066 100644
--- a/app-modules/events/lang/pt_BR/pages.php
+++ b/app-modules/events/lang/pt_BR/pages.php
@@ -7,7 +7,9 @@
'confirm_presence' => 'Confirmar Presença',
'confirm_presence_hint' => 'Confirme sua presença neste evento.',
'confirm_presence_success' => 'Sua presença foi confirmada!',
- 'waitlist_success' => 'Você entrou na lista de espera deste evento.',
+ 'waitlist_success' => 'Você está na lista de espera (posição :position).',
+ 'waitlist_status' => 'Você está na lista de espera (posição :position).',
+ 'event_full' => 'Este evento está lotado.',
'enrollment_status_label' => 'Status da sua inscrição',
'enrolled_at' => 'Inscrito em :date',
'no_enrollments' => 'Você ainda não está inscrito em nenhum evento.',
diff --git a/app-modules/events/src/Enrollment/Actions/EnrollUserAction.php b/app-modules/events/src/Enrollment/Actions/EnrollUserAction.php
index bff9b7318..255e73199 100644
--- a/app-modules/events/src/Enrollment/Actions/EnrollUserAction.php
+++ b/app-modules/events/src/Enrollment/Actions/EnrollUserAction.php
@@ -10,6 +10,7 @@
use He4rt\Events\Enrollment\Enums\EnrollmentStatus;
use He4rt\Events\Enrollment\Enums\TriggeredBy;
use He4rt\Events\Enrollment\Events\EnrollmentConfirmed;
+use He4rt\Events\Enrollment\Events\EnrollmentWaitlisted;
use He4rt\Events\Enrollment\Exceptions\EnrollmentException;
use He4rt\Events\Enrollment\Models\Enrollment;
use He4rt\Events\Enrollment\Models\EnrollmentPolicy;
@@ -57,6 +58,15 @@ public function handle(EnrollUserDTO $dto): Enrollment
));
}
+ if ($initial['status']->isWaitlisted()) {
+ event(new EnrollmentWaitlisted(
+ enrollmentId: $enrollment->id,
+ eventId: $dto->eventId,
+ userId: $dto->userId,
+ waitlistPosition: $initial['waitlistPosition'],
+ ));
+ }
+
return $enrollment->fresh(['event.enrollmentPolicy']);
} catch (UniqueConstraintViolationException) {
throw EnrollmentException::alreadyEnrolled();
@@ -103,12 +113,12 @@ private function resolveInitialEnrollment(string $eventId, ?EnrollmentPolicy $po
];
}
- $confirmedCount = Enrollment::query()
+ $occupiedCount = Enrollment::query()
->where('event_id', $eventId)
- ->confirmed()
+ ->active()
->count();
- if ($confirmedCount < $capacity) {
+ if ($occupiedCount < $capacity) {
return [
'status' => EnrollmentStatus::Confirmed,
'waitlistPosition' => null,
diff --git a/app-modules/events/src/Enrollment/Enums/EnrollmentStatus.php b/app-modules/events/src/Enrollment/Enums/EnrollmentStatus.php
index 500b9bb88..91026cbc0 100644
--- a/app-modules/events/src/Enrollment/Enums/EnrollmentStatus.php
+++ b/app-modules/events/src/Enrollment/Enums/EnrollmentStatus.php
@@ -94,11 +94,13 @@ public function isWaitlisted(): bool
return $this->is(self::Waitlisted);
}
- public function getResponseMessage(): string
+ public function getResponseMessage(?int $waitlistPosition = null): string
{
return match ($this) {
self::Confirmed => __('events::pages.confirm_presence_success'),
- self::Waitlisted => __('events::pages.waitlist_success'),
+ self::Waitlisted => __('events::pages.waitlist_success', [
+ 'position' => $waitlistPosition,
+ ]),
default => throw EnrollmentException::responseMessageNotImplemented($this),
};
}
diff --git a/app-modules/events/src/Enrollment/Events/EnrollmentWaitlisted.php b/app-modules/events/src/Enrollment/Events/EnrollmentWaitlisted.php
new file mode 100644
index 000000000..89525d328
--- /dev/null
+++ b/app-modules/events/src/Enrollment/Events/EnrollmentWaitlisted.php
@@ -0,0 +1,20 @@
+ $query */
protected function scopeActive(Builder $query): void
{
- $query->whereNotIn('status', [
- EnrollmentStatus::Cancelled,
- EnrollmentStatus::Rejected,
- EnrollmentStatus::NoShow,
+ $query->whereIn('status', [
+ EnrollmentStatus::Confirmed,
+ EnrollmentStatus::CheckedIn,
+ EnrollmentStatus::Attended,
]);
}
diff --git a/app-modules/events/tests/Feature/EnrollUserActionTest.php b/app-modules/events/tests/Feature/EnrollUserActionTest.php
index 9c125e7e1..61b8b0137 100644
--- a/app-modules/events/tests/Feature/EnrollUserActionTest.php
+++ b/app-modules/events/tests/Feature/EnrollUserActionTest.php
@@ -8,6 +8,7 @@
use He4rt\Events\Enrollment\Enums\EnrollmentStatus;
use He4rt\Events\Enrollment\Enums\TriggeredBy;
use He4rt\Events\Enrollment\Events\EnrollmentConfirmed;
+use He4rt\Events\Enrollment\Events\EnrollmentWaitlisted;
use He4rt\Events\Enrollment\Exceptions\EnrollmentException;
use He4rt\Events\Enrollment\Models\Enrollment;
use He4rt\Events\Enrollment\Models\EnrollmentPolicy;
@@ -18,6 +19,7 @@
use He4rt\Identity\User\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Event as EventFacade;
+use Symfony\Component\HttpFoundation\Response;
uses(RefreshDatabase::class);
@@ -145,7 +147,7 @@ function createRsvpEvent(Tenant $tenant, array $eventAttributes = [], array $pol
})->throws(EnrollmentException::class);
test('when event is at capacity with waitlist enabled, then enrollment is waitlisted', function (): void {
- EventFacade::fake([EnrollmentConfirmed::class]);
+ EventFacade::fake([EnrollmentConfirmed::class, EnrollmentWaitlisted::class]);
$user = User::factory()->create();
$tenant = Tenant::factory()->create();
@@ -170,9 +172,14 @@ function createRsvpEvent(Tenant $tenant, array $eventAttributes = [], array $pol
->and($enrollment->confirmed_at)->toBeNull();
EventFacade::assertNotDispatched(EnrollmentConfirmed::class);
+
+ EventFacade::assertDispatched(fn (EnrollmentWaitlisted $event): bool => $event->enrollmentId === $enrollment->id
+ && $event->eventId === $enrollment->event_id
+ && $event->userId === $user->id
+ && $event->waitlistPosition === 1);
});
-test('when event is at capacity without waitlist, then enrollment is rejected', function (): void {
+test('when event is at capacity without waitlist, then enrollment is rejected with 422', function (): void {
$user = User::factory()->create();
$tenant = Tenant::factory()->create();
$event = createRsvpEvent($tenant, [], [
@@ -189,8 +196,109 @@ function createRsvpEvent(Tenant $tenant, array $eventAttributes = [], array $pol
'confirmed_at' => now(),
]);
- resolve(EnrollUserAction::class)->handle(EnrollUserDTO::fromModels($event, $user));
-})->throws(EnrollmentException::class);
+ try {
+ resolve(EnrollUserAction::class)->handle(EnrollUserDTO::fromModels($event, $user));
+ expect(false)->toBeTrue('Expected EnrollmentException was not thrown');
+ } catch (EnrollmentException $enrollmentException) {
+ expect($enrollmentException->getCode())->toBe(Response::HTTP_UNPROCESSABLE_ENTITY);
+ }
+});
+
+test('when event has unlimited capacity, then all enrollments are confirmed', function (): void {
+ EventFacade::fake([EnrollmentConfirmed::class, EnrollmentWaitlisted::class]);
+
+ $tenant = Tenant::factory()->create();
+ $event = createRsvpEvent($tenant, [], [
+ 'capacity' => null,
+ 'has_waitlist' => true,
+ ]);
+
+ $users = User::factory()->count(3)->create();
+
+ foreach ($users as $user) {
+ $enrollment = resolve(EnrollUserAction::class)->handle(EnrollUserDTO::fromModels($event, $user));
+
+ expect($enrollment->status)->toBe(EnrollmentStatus::Confirmed);
+ }
+
+ expect(Enrollment::query()->where('event_id', $event->id)->active()->count())->toBe(3);
+
+ EventFacade::assertNotDispatched(EnrollmentWaitlisted::class);
+});
+
+test('when multiple users enroll beyond capacity with waitlist, then fifo waitlist positions are assigned', function (): void {
+ EventFacade::fake([EnrollmentConfirmed::class, EnrollmentWaitlisted::class]);
+
+ $tenant = Tenant::factory()->create();
+ $event = createRsvpEvent($tenant, [], [
+ 'capacity' => 2,
+ 'has_waitlist' => true,
+ ]);
+
+ $users = User::factory()->count(4)->create();
+ $results = [];
+
+ foreach ($users as $user) {
+ $results[] = resolve(EnrollUserAction::class)->handle(EnrollUserDTO::fromModels($event, $user));
+ }
+
+ expect(Enrollment::query()->where('event_id', $event->id)->active()->count())->toBe(2)
+ ->and($results[0]->status)->toBe(EnrollmentStatus::Confirmed)
+ ->and($results[1]->status)->toBe(EnrollmentStatus::Confirmed)
+ ->and($results[2]->status)->toBe(EnrollmentStatus::Waitlisted)
+ ->and($results[2]->waitlist_position)->toBe(1)
+ ->and($results[3]->status)->toBe(EnrollmentStatus::Waitlisted)
+ ->and($results[3]->waitlist_position)->toBe(2);
+});
+
+test('when enrollments are processed in rapid succession, then active count never exceeds capacity', function (): void {
+ EventFacade::fake([EnrollmentConfirmed::class, EnrollmentWaitlisted::class]);
+
+ $tenant = Tenant::factory()->create();
+ $event = createRsvpEvent($tenant, [], [
+ 'capacity' => 2,
+ 'has_waitlist' => true,
+ ]);
+
+ $users = User::factory()->count(5)->create();
+
+ foreach ($users as $user) {
+ resolve(EnrollUserAction::class)->handle(EnrollUserDTO::fromModels($event, $user));
+
+ expect(Enrollment::query()->where('event_id', $event->id)->active()->count())->toBeLessThanOrEqual(2);
+ }
+
+ expect(Enrollment::query()->where('event_id', $event->id)->active()->count())->toBe(2)
+ ->and(Enrollment::query()->where('event_id', $event->id)->waitlisted()->count())->toBe(3);
+});
+
+test('when checked-in enrollment occupies the last seat, then new enrollment is waitlisted', function (): void {
+ EventFacade::fake([EnrollmentConfirmed::class]);
+
+ $user = User::factory()->create();
+ $tenant = Tenant::factory()->create();
+ $event = createRsvpEvent($tenant, [], [
+ 'capacity' => 1,
+ 'has_waitlist' => true,
+ ]);
+
+ $existingUser = User::factory()->create();
+ Enrollment::factory()->create([
+ 'event_id' => $event->id,
+ 'user_id' => $existingUser->id,
+ 'status' => EnrollmentStatus::CheckedIn,
+ 'enrolled_at' => now(),
+ 'confirmed_at' => now(),
+ 'checked_in_at' => now(),
+ ]);
+
+ $enrollment = resolve(EnrollUserAction::class)->handle(EnrollUserDTO::fromModels($event, $user));
+
+ expect($enrollment->status)->toBe(EnrollmentStatus::Waitlisted)
+ ->and($enrollment->waitlist_position)->toBe(1);
+
+ EventFacade::assertNotDispatched(EnrollmentConfirmed::class);
+});
test('when event has available capacity, then enrollment is confirmed', function (): void {
EventFacade::fake([EnrollmentConfirmed::class]);
diff --git a/app-modules/events/tests/Unit/EnrollmentScopeTest.php b/app-modules/events/tests/Unit/EnrollmentScopeTest.php
new file mode 100644
index 000000000..54e8bed31
--- /dev/null
+++ b/app-modules/events/tests/Unit/EnrollmentScopeTest.php
@@ -0,0 +1,38 @@
+create();
+ $event = Event::factory()->published()->upcoming()->for($tenant)->create();
+
+ $occupying = [
+ EnrollmentStatus::Confirmed,
+ EnrollmentStatus::CheckedIn,
+ EnrollmentStatus::Attended,
+ ];
+
+ foreach ($occupying as $status) {
+ Enrollment::factory()->create([
+ 'event_id' => $event->id,
+ 'status' => $status,
+ ]);
+ }
+
+ foreach ([EnrollmentStatus::Pending, EnrollmentStatus::Waitlisted, EnrollmentStatus::Cancelled] as $status) {
+ Enrollment::factory()->create([
+ 'event_id' => $event->id,
+ 'status' => $status,
+ ]);
+ }
+
+ expect(Enrollment::query()->where('event_id', $event->id)->active()->count())->toBe(3);
+});
diff --git a/app-modules/panel-admin/src/Filament/Resources/Events/RelationManagers/EnrollmentsRelationManager.php b/app-modules/panel-admin/src/Filament/Resources/Events/RelationManagers/EnrollmentsRelationManager.php
index edb1020aa..778d31fc0 100644
--- a/app-modules/panel-admin/src/Filament/Resources/Events/RelationManagers/EnrollmentsRelationManager.php
+++ b/app-modules/panel-admin/src/Filament/Resources/Events/RelationManagers/EnrollmentsRelationManager.php
@@ -49,6 +49,12 @@ public function table(Table $table): Table
->badge()
->sortable(),
+ TextColumn::make('waitlist_position')
+ ->label('Waitlist')
+ ->sortable()
+ ->placeholder('-')
+ ->toggleable(),
+
TextColumn::make('enrolled_at')
->label('Enrolled At')
->dateTime()
diff --git a/app-modules/panel-app/resources/views/livewire/events/event-detail.blade.php b/app-modules/panel-app/resources/views/livewire/events/event-detail.blade.php
index f94987fee..e03c7f2bf 100644
--- a/app-modules/panel-app/resources/views/livewire/events/event-detail.blade.php
+++ b/app-modules/panel-app/resources/views/livewire/events/event-detail.blade.php
@@ -36,6 +36,16 @@
{{ $this->enrollment->status->getLabel() }}
+
+ @if ($this->enrollment->status->isWaitlisted() && $this->enrollment->waitlist_position)
+
+ {{
+ __('events::pages.waitlist_status', [
+ 'position' => $this->enrollment->waitlist_position,
+ ])
+ }}
+
+ @endif
@elseif ($this->canConfirmPresence)
@@ -45,5 +55,9 @@
{{ __('events::pages.confirm_presence') }}
+ @elseif ($this->isEventFull)
+
+
{{ __('events::pages.event_full') }}
+
@endif
diff --git a/app-modules/panel-app/src/Livewire/Events/EventDetail.php b/app-modules/panel-app/src/Livewire/Events/EventDetail.php
index a303091ac..5d56b6534 100644
--- a/app-modules/panel-app/src/Livewire/Events/EventDetail.php
+++ b/app-modules/panel-app/src/Livewire/Events/EventDetail.php
@@ -8,7 +8,6 @@
use He4rt\Events\Enrollment\Actions\EnrollUserAction;
use He4rt\Events\Enrollment\DTOs\EnrollUserDTO;
use He4rt\Events\Enrollment\Enums\EnrollmentMethod;
-use He4rt\Events\Enrollment\Enums\EnrollmentStatus;
use He4rt\Events\Enrollment\Exceptions\EnrollmentException;
use He4rt\Events\Enrollment\Models\Enrollment;
use He4rt\Events\Event\Enums\EventStatus;
@@ -72,18 +71,39 @@ public function canConfirmPresence(): bool
return true;
}
- $confirmedCount = Enrollment::query()
+ $occupiedCount = Enrollment::query()
->where('event_id', $this->eventId)
- ->where('status', EnrollmentStatus::Confirmed)
+ ->active()
->count();
- if ($confirmedCount < $policy->capacity) {
+ if ($occupiedCount < $policy->capacity) {
return true;
}
return $policy->has_waitlist;
}
+ #[Computed]
+ public function isEventFull(): bool
+ {
+ if ($this->enrollment !== null) {
+ return false;
+ }
+
+ $policy = $this->event->enrollmentPolicy;
+
+ if ($policy?->capacity === null || $policy->has_waitlist) {
+ return false;
+ }
+
+ $occupiedCount = Enrollment::query()
+ ->where('event_id', $this->eventId)
+ ->active()
+ ->count();
+
+ return $occupiedCount >= $policy->capacity;
+ }
+
public function confirmPresence(): void
{
/** @var User $user */
@@ -94,11 +114,11 @@ public function confirmPresence(): void
EnrollUserDTO::fromModels($this->event, $user),
);
- unset($this->enrollment, $this->canConfirmPresence);
+ unset($this->enrollment, $this->canConfirmPresence, $this->isEventFull);
Notification::make()
->success()
- ->title($enrollment->status->getResponseMessage())
+ ->title($enrollment->status->getResponseMessage($enrollment->waitlist_position))
->send();
} catch (EnrollmentException $enrollmentException) {
Notification::make()
diff --git a/app-modules/panel-app/tests/Feature/Events/RsvpEnrollmentTest.php b/app-modules/panel-app/tests/Feature/Events/RsvpEnrollmentTest.php
index 24052ebd4..212f39cab 100644
--- a/app-modules/panel-app/tests/Feature/Events/RsvpEnrollmentTest.php
+++ b/app-modules/panel-app/tests/Feature/Events/RsvpEnrollmentTest.php
@@ -92,7 +92,7 @@
->assertNotFound();
});
-test('when event is at capacity without waitlist, then confirm presence button is hidden', function (): void {
+test('when event is at capacity without waitlist, then event full message is shown', function (): void {
$this->event->enrollmentPolicy->update([
'capacity' => 1,
'has_waitlist' => false,
@@ -109,9 +109,32 @@
livewire(EventDetail::class, ['eventId' => $this->event->id])
->assertSet('canConfirmPresence', false)
+ ->assertSet('isEventFull', true)
+ ->assertSee(__('events::pages.event_full'))
->assertDontSee(__('events::pages.confirm_presence'));
});
+test('when user is waitlisted, then waitlist position is shown on event page', function (): void {
+ $this->event->enrollmentPolicy->update([
+ 'capacity' => 1,
+ 'has_waitlist' => true,
+ ]);
+
+ $otherUser = User::factory()->create();
+ Enrollment::factory()->create([
+ 'event_id' => $this->event->id,
+ 'user_id' => $otherUser->id,
+ 'status' => EnrollmentStatus::Confirmed,
+ 'enrolled_at' => now(),
+ 'confirmed_at' => now(),
+ ]);
+
+ livewire(EventDetail::class, ['eventId' => $this->event->id])
+ ->call('confirmPresence')
+ ->assertHasNoErrors()
+ ->assertSee(__('events::pages.waitlist_status', ['position' => 1]));
+});
+
test('when event is at capacity with waitlist, then confirm presence button is still shown', function (): void {
$this->event->enrollmentPolicy->update([
'capacity' => 1,
From a51f4b7b58422e3fda976a23b8b88b1dc05c03ba Mon Sep 17 00:00:00 2001
From: Davi Castello Branco Tavares de Oliveira
Date: Thu, 4 Jun 2026 16:15:00 -0400
Subject: [PATCH 06/18] feat(events): implement numeric code check-in (#297)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
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
Co-authored-by: danielhe4rt
---
.../database/factories/CheckInCodeFactory.php | 1 +
...oked_at_to_events_check_in_codes_table.php | 24 +
.../events/database/seeders/EventsSeeder.php | 433 ++++++++++++++++++
.../docs/adr/0006-numeric-code-check-in.md | 54 +++
app-modules/events/lang/en/check_in.php | 6 +
app-modules/events/lang/en/pages.php | 2 +
app-modules/events/lang/pt_BR/check_in.php | 6 +
app-modules/events/lang/pt_BR/pages.php | 2 +
.../Actions/NumericCodeCheckInAction.php | 63 +++
.../CheckIn/DTOs/NumericCodeCheckInDTO.php | 17 +
.../CheckIn/Exceptions/CheckInException.php | 48 ++
.../events/src/CheckIn/Models/CheckInCode.php | 3 +
.../src/CheckIn/Rules/CheckInCodeRule.php | 19 +
.../tests/Feature/EventResourceTest.php | 26 ++
.../Feature/NumericCodeCheckInActionTest.php | 298 ++++++++++++
.../Resources/Events/EventResource.php | 2 +
.../Actions/GenerateCheckInCodeAction.php | 127 +++++
.../Actions/RevokeCheckInCodeAction.php | 37 ++
.../CheckInCodesRelationManager.php | 72 +++
.../livewire/events/event-detail.blade.php | 6 +
.../events/numeric-code-check-in.blade.php | 35 ++
.../Livewire/Events/NumericCodeCheckIn.php | 129 ++++++
.../panel-app/src/PanelAppServiceProvider.php | 2 +
.../Feature/Events/NumericCodeCheckInTest.php | 67 +++
database/seeders/DatabaseSeeder.php | 2 +
25 files changed, 1481 insertions(+)
create mode 100644 app-modules/events/database/migrations/2026_05_31_000001_add_revoked_at_to_events_check_in_codes_table.php
create mode 100644 app-modules/events/database/seeders/EventsSeeder.php
create mode 100644 app-modules/events/docs/adr/0006-numeric-code-check-in.md
create mode 100644 app-modules/events/src/CheckIn/Actions/NumericCodeCheckInAction.php
create mode 100644 app-modules/events/src/CheckIn/DTOs/NumericCodeCheckInDTO.php
create mode 100644 app-modules/events/src/CheckIn/Rules/CheckInCodeRule.php
create mode 100644 app-modules/events/tests/Feature/NumericCodeCheckInActionTest.php
create mode 100644 app-modules/panel-admin/src/Filament/Resources/Events/RelationManagers/Actions/GenerateCheckInCodeAction.php
create mode 100644 app-modules/panel-admin/src/Filament/Resources/Events/RelationManagers/Actions/RevokeCheckInCodeAction.php
create mode 100644 app-modules/panel-admin/src/Filament/Resources/Events/RelationManagers/CheckInCodesRelationManager.php
create mode 100644 app-modules/panel-app/resources/views/livewire/events/numeric-code-check-in.blade.php
create mode 100644 app-modules/panel-app/src/Livewire/Events/NumericCodeCheckIn.php
create mode 100644 app-modules/panel-app/tests/Feature/Events/NumericCodeCheckInTest.php
diff --git a/app-modules/events/database/factories/CheckInCodeFactory.php b/app-modules/events/database/factories/CheckInCodeFactory.php
index c2fc5e085..084da95a8 100644
--- a/app-modules/events/database/factories/CheckInCodeFactory.php
+++ b/app-modules/events/database/factories/CheckInCodeFactory.php
@@ -26,6 +26,7 @@ public function definition(): array
'expires_at' => $startsAt->clone()->addHours(2),
'max_uses' => null,
'uses_count' => 0,
+ 'revoked_at' => null,
];
}
}
diff --git a/app-modules/events/database/migrations/2026_05_31_000001_add_revoked_at_to_events_check_in_codes_table.php b/app-modules/events/database/migrations/2026_05_31_000001_add_revoked_at_to_events_check_in_codes_table.php
new file mode 100644
index 000000000..981d1cef3
--- /dev/null
+++ b/app-modules/events/database/migrations/2026_05_31_000001_add_revoked_at_to_events_check_in_codes_table.php
@@ -0,0 +1,24 @@
+timestampTz('revoked_at')->nullable()->after('uses_count');
+ });
+ }
+
+ public function down(): void
+ {
+ Schema::table('events_check_in_codes', function (Blueprint $table): void {
+ $table->dropColumn('revoked_at');
+ });
+ }
+};
diff --git a/app-modules/events/database/seeders/EventsSeeder.php b/app-modules/events/database/seeders/EventsSeeder.php
new file mode 100644
index 000000000..592f58748
--- /dev/null
+++ b/app-modules/events/database/seeders/EventsSeeder.php
@@ -0,0 +1,433 @@
+resolveTenant();
+ $participants = $this->resolveParticipants($tenant);
+
+ foreach ($this->matrix() as $spec) {
+ $event = Event::query()->firstOrCreate(
+ ['slug' => Str::slug($spec['title'])],
+ [
+ 'tenant_id' => $tenant->id,
+ 'title' => $spec['title'],
+ 'description' => $spec['description'],
+ 'event_type' => $spec['type'],
+ 'location' => $spec['location'],
+ 'status' => $spec['status'],
+ ...$this->schedule($spec['schedule'], $spec['days']),
+ ],
+ );
+
+ if (!$event->wasRecentlyCreated) {
+ continue;
+ }
+
+ $this->seedPolicy($event, $spec);
+ $this->seedEnrollments($event, $spec, $participants);
+ $this->seedCheckInCodes($event, $spec);
+ }
+ }
+
+ /**
+ * The event matrix. Each row is a self-contained scenario with a
+ * human-readable title so it is easy to recognise in the panels.
+ *
+ * @return list>
+ */
+ private function matrix(): array
+ {
+ return [
+ // RSVP-only meetup, no capacity, manual check-in, happening soon.
+ [
+ 'title' => 'He4rt Meetup #42 — Carreira em Tech',
+ 'description' => 'Bate-papo aberto sobre transição de carreira e primeiro emprego em tecnologia.',
+ 'type' => EventType::Meetup,
+ 'status' => EventStatus::Published,
+ 'schedule' => 'upcoming',
+ 'days' => 1,
+ 'location' => 'Online — Discord He4rt',
+ 'enrollment_method' => EnrollmentMethod::Rsvp,
+ 'check_in_method' => CheckInMethod::Manual,
+ 'capacity' => null,
+ 'has_waitlist' => false,
+ 'attendance_requirement' => AttendanceRequirement::AnyDay,
+ 'minimum_days' => null,
+ 'cancellation_deadline_hours' => null,
+ 'xp' => [50, 0, 100],
+ 'enrollments' => [
+ EnrollmentStatus::Confirmed->value => 8,
+ EnrollmentStatus::Pending->value => 2,
+ EnrollmentStatus::Cancelled->value => 1,
+ ],
+ ],
+
+ // The primary numeric-code target: ongoing single-day workshop.
+ [
+ 'title' => 'Workshop: Rust do Zero ao Deploy',
+ 'description' => 'Mão na massa construindo e publicando uma API em Rust em uma tarde.',
+ 'type' => EventType::Workshop,
+ 'status' => EventStatus::Published,
+ 'schedule' => 'ongoing',
+ 'days' => 1,
+ 'location' => 'Sede He4rt — Sala 1',
+ 'enrollment_method' => EnrollmentMethod::RsvpCheckin,
+ 'check_in_method' => CheckInMethod::NumericCode,
+ 'capacity' => 50,
+ 'has_waitlist' => true,
+ 'attendance_requirement' => AttendanceRequirement::AllDays,
+ 'minimum_days' => null,
+ 'cancellation_deadline_hours' => 24,
+ 'xp' => [100, 200, 500],
+ 'enrollments' => [
+ EnrollmentStatus::Confirmed->value => 10,
+ EnrollmentStatus::CheckedIn->value => 6,
+ EnrollmentStatus::Waitlisted->value => 4,
+ EnrollmentStatus::Cancelled->value => 2,
+ ],
+ 'check_in_codes' => true,
+ ],
+
+ // Small-capacity numeric-code meetup, ongoing, to test waitlist + check-in.
+ [
+ 'title' => 'Mentoria em Grupo: Primeiro Emprego',
+ 'description' => 'Sessão de mentoria com vagas limitadas e lista de espera.',
+ 'type' => EventType::Meetup,
+ 'status' => EventStatus::Published,
+ 'schedule' => 'ongoing',
+ 'days' => 1,
+ 'location' => 'Online — Discord He4rt',
+ 'enrollment_method' => EnrollmentMethod::RsvpCheckin,
+ 'check_in_method' => CheckInMethod::NumericCode,
+ 'capacity' => 10,
+ 'has_waitlist' => true,
+ 'attendance_requirement' => AttendanceRequirement::AnyDay,
+ 'minimum_days' => null,
+ 'cancellation_deadline_hours' => 12,
+ 'xp' => [30, 80, 150],
+ 'enrollments' => [
+ EnrollmentStatus::CheckedIn->value => 7,
+ EnrollmentStatus::Confirmed->value => 3,
+ EnrollmentStatus::Waitlisted->value => 5,
+ EnrollmentStatus::NoShow->value => 1,
+ ],
+ 'check_in_codes' => true,
+ ],
+
+ // Application-based, multi-day conference with QR check-in, upcoming.
+ [
+ 'title' => 'He4rt Conf 2026',
+ 'description' => 'Três dias de palestras, trilhas e networking da comunidade He4rt.',
+ 'type' => EventType::Conference,
+ 'status' => EventStatus::Published,
+ 'schedule' => 'upcoming',
+ 'days' => 3,
+ 'location' => 'São Paulo — Centro de Convenções',
+ 'enrollment_method' => EnrollmentMethod::Application,
+ 'check_in_method' => CheckInMethod::QrCode,
+ 'capacity' => 200,
+ 'has_waitlist' => true,
+ 'attendance_requirement' => AttendanceRequirement::MinimumDays,
+ 'minimum_days' => 2,
+ 'cancellation_deadline_hours' => 48,
+ 'xp' => [200, 300, 1000],
+ 'enrollments' => [
+ EnrollmentStatus::Pending->value => 6,
+ EnrollmentStatus::Confirmed->value => 12,
+ EnrollmentStatus::Waitlisted->value => 4,
+ EnrollmentStatus::Rejected->value => 2,
+ ],
+ ],
+
+ // Past, completed meetup with attendance recorded.
+ [
+ 'title' => 'Live: PHP 8.5 — O que há de novo',
+ 'description' => 'Retrospectiva das novidades do PHP 8.5 com exemplos práticos.',
+ 'type' => EventType::Meetup,
+ 'status' => EventStatus::Completed,
+ 'schedule' => 'past',
+ 'days' => 1,
+ 'location' => 'Online — YouTube He4rt',
+ 'enrollment_method' => EnrollmentMethod::Rsvp,
+ 'check_in_method' => CheckInMethod::Manual,
+ 'capacity' => null,
+ 'has_waitlist' => false,
+ 'attendance_requirement' => AttendanceRequirement::AnyDay,
+ 'minimum_days' => null,
+ 'cancellation_deadline_hours' => null,
+ 'xp' => [40, 0, 120],
+ 'enrollments' => [
+ EnrollmentStatus::Attended->value => 9,
+ EnrollmentStatus::NoShow->value => 3,
+ EnrollmentStatus::Cancelled->value => 2,
+ ],
+ ],
+
+ // Upcoming numeric-code workshop with any-day attendance.
+ [
+ 'title' => 'Workshop: Docker para Desenvolvedores',
+ 'description' => 'Do build à orquestração: containers na prática para o dia a dia.',
+ 'type' => EventType::Workshop,
+ 'status' => EventStatus::Published,
+ 'schedule' => 'upcoming',
+ 'days' => 1,
+ 'location' => 'Sede He4rt — Sala 2',
+ 'enrollment_method' => EnrollmentMethod::RsvpCheckin,
+ 'check_in_method' => CheckInMethod::NumericCode,
+ 'capacity' => 40,
+ 'has_waitlist' => false,
+ 'attendance_requirement' => AttendanceRequirement::AnyDay,
+ 'minimum_days' => null,
+ 'cancellation_deadline_hours' => 24,
+ 'xp' => [80, 150, 400],
+ 'enrollments' => [
+ EnrollmentStatus::Confirmed->value => 14,
+ EnrollmentStatus::Pending->value => 1,
+ ],
+ ],
+
+ // Draft (unpublished) workshop — should be invisible to participants.
+ [
+ 'title' => 'Bootcamp Laravel — Turma 1',
+ 'description' => 'Rascunho do bootcamp intensivo de Laravel (ainda não publicado).',
+ 'type' => EventType::Workshop,
+ 'status' => EventStatus::Draft,
+ 'schedule' => 'upcoming',
+ 'days' => 5,
+ 'location' => 'Sede He4rt — Auditório',
+ 'enrollment_method' => EnrollmentMethod::RsvpCheckin,
+ 'check_in_method' => CheckInMethod::NumericCode,
+ 'capacity' => 30,
+ 'has_waitlist' => true,
+ 'attendance_requirement' => AttendanceRequirement::MinimumDays,
+ 'minimum_days' => 4,
+ 'cancellation_deadline_hours' => 72,
+ 'xp' => [150, 250, 1500],
+ 'enrollments' => [],
+ ],
+
+ // Cancelled conference — terminal status, still viewable by participants.
+ [
+ 'title' => 'Hackathon He4rt — Edição Inverno',
+ 'description' => 'Hackathon cancelado por falta de patrocínio (mantido para histórico).',
+ 'type' => EventType::Conference,
+ 'status' => EventStatus::Cancelled,
+ 'schedule' => 'upcoming',
+ 'days' => 2,
+ 'location' => 'Online — Discord He4rt',
+ 'enrollment_method' => EnrollmentMethod::Application,
+ 'check_in_method' => CheckInMethod::QrCode,
+ 'capacity' => 100,
+ 'has_waitlist' => false,
+ 'attendance_requirement' => AttendanceRequirement::AllDays,
+ 'minimum_days' => null,
+ 'cancellation_deadline_hours' => 48,
+ 'xp' => [100, 200, 800],
+ 'enrollments' => [
+ EnrollmentStatus::Cancelled->value => 8,
+ ],
+ ],
+ ];
+ }
+
+ private function seedPolicy(Event $event, array $spec): void
+ {
+ EnrollmentPolicy::factory()->create([
+ 'event_id' => $event->id,
+ 'enrollment_method' => $spec['enrollment_method'],
+ 'check_in_method' => $spec['check_in_method'],
+ 'capacity' => $spec['capacity'],
+ 'has_waitlist' => $spec['has_waitlist'],
+ 'attendance_requirement' => $spec['attendance_requirement'],
+ 'minimum_days' => $spec['minimum_days'],
+ 'cancellation_deadline_hours' => $spec['cancellation_deadline_hours'],
+ 'xp_on_confirmed' => $spec['xp'][0],
+ 'xp_on_checked_in' => $spec['xp'][1],
+ 'xp_on_attended' => $spec['xp'][2],
+ ]);
+ }
+
+ /**
+ * @param Collection $participants
+ */
+ private function seedEnrollments(Event $event, array $spec, Collection $participants): void
+ {
+ $pool = $participants->shuffle()->values();
+ $cursor = 0;
+ $waitlistPosition = 1;
+
+ foreach ($spec['enrollments'] as $statusValue => $count) {
+ $status = EnrollmentStatus::from($statusValue);
+
+ for ($i = 0; $i < $count; $i++) {
+ $user = $pool->get($cursor++);
+
+ if ($user === null) {
+ return; // pool exhausted for this event
+ }
+
+ Enrollment::factory()->create([
+ 'event_id' => $event->id,
+ 'user_id' => $user->id,
+ 'status' => $status,
+ 'waitlist_position' => $status === EnrollmentStatus::Waitlisted ? $waitlistPosition++ : null,
+ 'rejection_reason' => $status === EnrollmentStatus::Rejected ? 'Vagas esgotadas.' : null,
+ ...$this->enrollmentTimestamps($status),
+ ]);
+ }
+ }
+ }
+
+ /**
+ * Creates a representative set of codes for numeric-code events so every
+ * branch of the check-in flow (valid / expired / revoked / exhausted) is
+ * reachable from the admin panel and the participant component.
+ */
+ private function seedCheckInCodes(Event $event, array $spec): void
+ {
+ if (!($spec['check_in_codes'] ?? false)) {
+ return;
+ }
+
+ $now = Date::now();
+ $today = Date::today();
+
+ // Memorable, currently-valid code for manual testing.
+ CheckInCode::factory()->create([
+ 'event_id' => $event->id,
+ 'event_date' => $today,
+ 'code' => '424242',
+ 'starts_at' => $now->clone()->subHour(),
+ 'expires_at' => $now->clone()->addHours(3),
+ 'max_uses' => null,
+ 'uses_count' => 0,
+ ]);
+
+ // Expired window.
+ CheckInCode::factory()->create([
+ 'event_id' => $event->id,
+ 'event_date' => $today,
+ 'code' => '100001',
+ 'starts_at' => $now->clone()->subHours(5),
+ 'expires_at' => $now->clone()->subHour(),
+ ]);
+
+ // Manually revoked.
+ CheckInCode::factory()->create([
+ 'event_id' => $event->id,
+ 'event_date' => $today,
+ 'code' => '100002',
+ 'starts_at' => $now->clone()->subHour(),
+ 'expires_at' => $now->clone()->addHours(3),
+ 'revoked_at' => $now->clone()->subMinutes(10),
+ ]);
+
+ // Exhausted (single use already consumed).
+ CheckInCode::factory()->create([
+ 'event_id' => $event->id,
+ 'event_date' => $today,
+ 'code' => '100003',
+ 'starts_at' => $now->clone()->subHour(),
+ 'expires_at' => $now->clone()->addHours(3),
+ 'max_uses' => 1,
+ 'uses_count' => 1,
+ ]);
+ }
+
+ /**
+ * @return array{starts_at: CarbonInterface, ends_at: CarbonInterface}
+ */
+ private function schedule(string $schedule, int $days): array
+ {
+ $start = match ($schedule) {
+ 'past' => Date::today()->subDays(10),
+ 'ongoing' => Date::today(),
+ default => Date::today()->addDays(14),
+ };
+
+ return [
+ 'starts_at' => $start->clone()->setTime(9, 0),
+ 'ends_at' => $start->clone()->addDays($days - 1)->setTime(18, 0),
+ ];
+ }
+
+ /**
+ * @return array
+ */
+ private function enrollmentTimestamps(EnrollmentStatus $status): array
+ {
+ $enrolledAt = Date::now()->subDays(3);
+ $confirmedAt = Date::now()->subDays(2);
+ $checkedInAt = Date::now()->subDay();
+
+ return match ($status) {
+ EnrollmentStatus::Pending,
+ EnrollmentStatus::Waitlisted => ['enrolled_at' => $enrolledAt],
+ EnrollmentStatus::Confirmed => ['enrolled_at' => $enrolledAt, 'confirmed_at' => $confirmedAt],
+ EnrollmentStatus::CheckedIn => ['enrolled_at' => $enrolledAt, 'confirmed_at' => $confirmedAt, 'checked_in_at' => $checkedInAt],
+ EnrollmentStatus::Attended => ['enrolled_at' => $enrolledAt, 'confirmed_at' => $confirmedAt, 'checked_in_at' => $checkedInAt, 'attended_at' => $checkedInAt],
+ EnrollmentStatus::NoShow => ['enrolled_at' => $enrolledAt, 'confirmed_at' => $confirmedAt],
+ EnrollmentStatus::Cancelled => ['enrolled_at' => $enrolledAt, 'cancelled_at' => Date::now()->subDay()],
+ EnrollmentStatus::Rejected => ['enrolled_at' => $enrolledAt],
+ };
+ }
+
+ private function resolveTenant(): Tenant
+ {
+ return Tenant::query()->where('slug', 'he4rt')->first()
+ ?? Tenant::query()->first()
+ ?? Tenant::factory()->create(['name' => 'He4rt Developers', 'slug' => 'he4rt']);
+ }
+
+ /**
+ * @return Collection
+ */
+ private function resolveParticipants(Tenant $tenant): Collection
+ {
+ $existing = $tenant->members()->limit(self::PARTICIPANT_POOL)->get();
+ $missing = self::PARTICIPANT_POOL - $existing->count();
+
+ if ($missing > 0) {
+ $created = User::factory()->count($missing)->create();
+ $tenant->members()->attach($created->pluck('id'));
+ $existing = $existing->concat($created);
+ }
+
+ return $existing;
+ }
+}
diff --git a/app-modules/events/docs/adr/0006-numeric-code-check-in.md b/app-modules/events/docs/adr/0006-numeric-code-check-in.md
new file mode 100644
index 000000000..1109eb7e4
--- /dev/null
+++ b/app-modules/events/docs/adr/0006-numeric-code-check-in.md
@@ -0,0 +1,54 @@
+# ADR-0006: Numeric code check-in
+
+## Status
+
+Accepted — 2026-05-31
+
+## Context
+
+Participants need a check-in method beyond manual organizer intervention. Numeric codes — short-lived digit strings announced by the organizer — allow self-service check-in during live events. The code is bound to a specific event date, has a validity window and optional max uses, and supports bot-triggered check-in via domain events (ADR-0005).
+
+## Decision
+
+### Action pattern
+
+Introduce `NumericCodeCheckInAction` following the same delegation pattern as `ManualCheckInAction`. The new action validates the code (existence, date match, time window, max uses, revoked status), atomically increments `uses_count` on the code record, then delegates to the core `CheckInAction` with `CheckInMethod::NumericCode` and `TriggeredBy::User`. This keeps all core check-in logic (status transition, duplicate detection, domain event dispatch) in the single `CheckInAction`.
+
+### Soft revoke instead of delete
+
+Codes receive a nullable `revoked_at` timestamp. Revoked codes fail validation identically to expired codes. This preserves the audit trail — an organizer can see which codes were used before revocation, which aligns with ADR-0003 (no data destruction).
+
+### Admin UI as RelationManager
+
+A `CheckInCodesRelationManager` on the Event edit page (alongside the existing `EnrollmentsRelationManager`). No separate Filament resource — codes are always scoped to an event. The form includes a digit length selector (4 or 6), auto-generates a random read-only code, defaults `event_date` to the event's `starts_at` date (organizer can override, must be within event range), and provides a revoke action.
+
+### Participant UI as dedicated Livewire component
+
+A `NumericCodeCheckIn` Livewire component embedded in the event detail page. Shown only when the user's enrollment status is `confirmed` or `checked_in`. A text input for the code plus a submit button; validation errors surface as inline messages.
+
+### Error messages
+
+Each failure mode gets a distinct error message for clear diagnostics:
+
+| Condition | Message | HTTP |
+| ------------------------------------------------------------- | ------------------------------- | ---- |
+| Code not found in database | "Invalid check-in code" | 422 |
+| Code found but `event_date` does not match check-in date | "Code is not valid for today" | 422 |
+| Code found but `expires_at` has passed or `revoked_at` is set | "Code has expired" | 422 |
+| Code found but `uses_count >= max_uses` | "Code has reached maximum uses" | 422 |
+
+### Code validation order
+
+1. Existence (code not found → invalid)
+2. Date binding (found but wrong date → not valid for today)
+3. Expiry/revocation (found, right date, but expired/revoked → expired)
+4. Max uses (found, right date, still valid, but exhausted → max uses)
+5. Uses increment (found, right date, valid, has capacity → atomically increment and proceed)
+
+## Consequences
+
+- **Separate concerns** — code validation is isolated from check-in mechanics; `CheckInAction` remains untouched.
+- **Self-service** — participants check in without organizer intervention; scales to large events.
+- **Concurrency-safe** — `uses_count` increment is atomic inside a DB transaction with locked rows.
+- **Bot compatible** — Discord/Twitch bot can validate codes by dispatching `CheckInRequested` domain events (future).
+- **Multi-day** — organizer creates one code per day with the appropriate `event_date`.
diff --git a/app-modules/events/lang/en/check_in.php b/app-modules/events/lang/en/check_in.php
index c163fc1fe..f57dea4ee 100644
--- a/app-modules/events/lang/en/check_in.php
+++ b/app-modules/events/lang/en/check_in.php
@@ -7,4 +7,10 @@
'check_in_outside_event_date_range' => 'Check-in date must be within the event date range.',
'already_checked_in_for_date' => 'This enrollment has already checked in for this date.',
'invalid_check_in_actor' => 'Manual check-in requires the organizer user id.',
+ 'invalid_check_in_code' => 'Invalid check-in code.',
+ 'invalid_check_in_code_format' => 'Enter a 4 or 6 digit check-in code.',
+ 'check_in_code_expired' => 'Code has expired.',
+ 'check_in_code_exhausted' => 'Code has reached maximum uses.',
+ 'check_in_code_wrong_date' => 'Code is not valid for today.',
+ 'check_in_code_rate_limited' => 'Too many attempts. Try again in :seconds seconds.',
];
diff --git a/app-modules/events/lang/en/pages.php b/app-modules/events/lang/en/pages.php
index 18f97b18e..3b68dc910 100644
--- a/app-modules/events/lang/en/pages.php
+++ b/app-modules/events/lang/en/pages.php
@@ -14,4 +14,6 @@
'enrolled_at' => 'Enrolled on :date',
'no_enrollments' => 'You are not enrolled in any events yet.',
'no_upcoming_events' => 'No upcoming events available.',
+ 'enter_check_in_code_hint' => 'Enter the check-in code announced by the organizer.',
+ 'check_in' => 'Check In',
];
diff --git a/app-modules/events/lang/pt_BR/check_in.php b/app-modules/events/lang/pt_BR/check_in.php
index 46a65b78e..539a3f667 100644
--- a/app-modules/events/lang/pt_BR/check_in.php
+++ b/app-modules/events/lang/pt_BR/check_in.php
@@ -7,4 +7,10 @@
'check_in_outside_event_date_range' => 'A data do check-in deve estar dentro do período do evento.',
'already_checked_in_for_date' => 'Esta inscrição já possui check-in para essa data.',
'invalid_check_in_actor' => 'Check-in manual exige o ID do usuário organizador.',
+ 'invalid_check_in_code' => 'Código de check-in inválido.',
+ 'invalid_check_in_code_format' => 'Informe um código de check-in com 4 ou 6 dígitos.',
+ 'check_in_code_expired' => 'O código expirou.',
+ 'check_in_code_exhausted' => 'O código atingiu o limite máximo de usos.',
+ 'check_in_code_wrong_date' => 'O código não é válido para hoje.',
+ 'check_in_code_rate_limited' => 'Muitas tentativas. Tente novamente em :seconds segundos.',
];
diff --git a/app-modules/events/lang/pt_BR/pages.php b/app-modules/events/lang/pt_BR/pages.php
index 44a375066..5ebe27bc6 100644
--- a/app-modules/events/lang/pt_BR/pages.php
+++ b/app-modules/events/lang/pt_BR/pages.php
@@ -14,4 +14,6 @@
'enrolled_at' => 'Inscrito em :date',
'no_enrollments' => 'Você ainda não está inscrito em nenhum evento.',
'no_upcoming_events' => 'Nenhum evento disponível no momento.',
+ 'enter_check_in_code_hint' => 'Insira o código de check-in informado pelo organizador.',
+ 'check_in' => 'Fazer Check-in',
];
diff --git a/app-modules/events/src/CheckIn/Actions/NumericCodeCheckInAction.php b/app-modules/events/src/CheckIn/Actions/NumericCodeCheckInAction.php
new file mode 100644
index 000000000..e5f87d5c5
--- /dev/null
+++ b/app-modules/events/src/CheckIn/Actions/NumericCodeCheckInAction.php
@@ -0,0 +1,63 @@
+where('code', $dto->code)
+ ->where('event_id', $dto->enrollment->event_id)
+ ->whereDate('event_date', $dto->eventDate->toDateString())
+ ->lockForUpdate()
+ ->first();
+
+ if ($codeRecord === null) {
+ $codeExistsForAnotherDate = CheckInCode::query()
+ ->where('code', $dto->code)
+ ->where('event_id', $dto->enrollment->event_id)
+ ->exists();
+
+ if ($codeExistsForAnotherDate) {
+ throw CheckInException::checkInCodeWrongDate();
+ }
+
+ throw CheckInException::invalidCheckInCode();
+ }
+
+ if ($codeRecord->starts_at->isFuture() || $codeRecord->expires_at->isPast() || $codeRecord->revoked_at !== null) {
+ throw CheckInException::checkInCodeExpired();
+ }
+
+ if ($codeRecord->max_uses !== null && $codeRecord->uses_count >= $codeRecord->max_uses) {
+ throw CheckInException::checkInCodeExhausted();
+ }
+
+ $codeRecord->increment('uses_count');
+
+ return resolve(CheckInAction::class)->handle(
+ new CheckInDTO(
+ enrollment: $dto->enrollment,
+ method: CheckInMethod::NumericCode,
+ payload: ['code' => $dto->code],
+ eventDate: $dto->eventDate,
+ actorUserId: $dto->enrollment->user_id,
+ triggeredBy: TriggeredBy::User,
+ ),
+ );
+ });
+ }
+}
diff --git a/app-modules/events/src/CheckIn/DTOs/NumericCodeCheckInDTO.php b/app-modules/events/src/CheckIn/DTOs/NumericCodeCheckInDTO.php
new file mode 100644
index 000000000..f5967de1e
--- /dev/null
+++ b/app-modules/events/src/CheckIn/DTOs/NumericCodeCheckInDTO.php
@@ -0,0 +1,17 @@
+ $seconds]),
+ Response::HTTP_TOO_MANY_REQUESTS,
+ );
+ }
}
diff --git a/app-modules/events/src/CheckIn/Models/CheckInCode.php b/app-modules/events/src/CheckIn/Models/CheckInCode.php
index 0abff5163..fc8a52239 100644
--- a/app-modules/events/src/CheckIn/Models/CheckInCode.php
+++ b/app-modules/events/src/CheckIn/Models/CheckInCode.php
@@ -22,6 +22,7 @@
* @property Carbon $expires_at
* @property int|null $max_uses
* @property int $uses_count
+ * @property Carbon|null $revoked_at
* @property Carbon $created_at
* @property Carbon $updated_at
*/
@@ -40,6 +41,7 @@ final class CheckInCode extends Model
'expires_at',
'max_uses',
'uses_count',
+ 'revoked_at',
];
/** @return BelongsTo */
@@ -60,6 +62,7 @@ protected function casts(): array
'event_date' => 'date',
'starts_at' => 'datetime',
'expires_at' => 'datetime',
+ 'revoked_at' => 'datetime',
];
}
}
diff --git a/app-modules/events/src/CheckIn/Rules/CheckInCodeRule.php b/app-modules/events/src/CheckIn/Rules/CheckInCodeRule.php
new file mode 100644
index 000000000..971627e74
--- /dev/null
+++ b/app-modules/events/src/CheckIn/Rules/CheckInCodeRule.php
@@ -0,0 +1,19 @@
+getMessage());
+ }
+ }
+}
diff --git a/app-modules/events/tests/Feature/EventResourceTest.php b/app-modules/events/tests/Feature/EventResourceTest.php
index 8d0763ab9..b3c66e954 100644
--- a/app-modules/events/tests/Feature/EventResourceTest.php
+++ b/app-modules/events/tests/Feature/EventResourceTest.php
@@ -5,6 +5,7 @@
use Filament\Facades\Filament;
use He4rt\Events\CheckIn\Enums\CheckInMethod;
use He4rt\Events\CheckIn\Models\CheckIn;
+use He4rt\Events\CheckIn\Models\CheckInCode;
use He4rt\Events\Enrollment\Enums\AttendanceRequirement;
use He4rt\Events\Enrollment\Enums\EnrollmentMethod;
use He4rt\Events\Enrollment\Enums\EnrollmentStatus;
@@ -18,6 +19,7 @@
use He4rt\PanelAdmin\Filament\Resources\Events\Pages\CreateEvent;
use He4rt\PanelAdmin\Filament\Resources\Events\Pages\EditEvent;
use He4rt\PanelAdmin\Filament\Resources\Events\Pages\ListEvents;
+use He4rt\PanelAdmin\Filament\Resources\Events\RelationManagers\CheckInCodesRelationManager;
use He4rt\PanelAdmin\Filament\Resources\Events\RelationManagers\EnrollmentsRelationManager;
use function Pest\Livewire\livewire;
@@ -141,6 +143,30 @@
->assertSuccessful();
});
+test('when admin generates a check-in code, then persisted code matches preview value', function (): void {
+ $startsAt = now()->setTime(9, 0);
+ $event = Event::factory()->create([
+ 'starts_at' => $startsAt,
+ 'ends_at' => $startsAt->clone()->setTime(18, 0),
+ ]);
+
+ livewire(CheckInCodesRelationManager::class, [
+ 'ownerRecord' => $event,
+ 'pageClass' => EditEvent::class,
+ ])
+ ->callTableAction('generateCode', data: [
+ 'digits' => '4',
+ 'code_preview' => '1234',
+ 'event_date' => now()->toDateString(),
+ 'starts_at' => now(),
+ 'expires_at' => now()->addHours(2),
+ 'max_uses' => '',
+ ])
+ ->assertHasNoTableActionErrors();
+
+ expect(CheckInCode::query()->where('event_id', $event->id)->sole()->code)->toBe('1234');
+});
+
test('when admin checks in an enrollment from relation manager, then participant is checked in', function (): void {
$event = Event::factory()->create([
'starts_at' => now()->setTime(9, 0),
diff --git a/app-modules/events/tests/Feature/NumericCodeCheckInActionTest.php b/app-modules/events/tests/Feature/NumericCodeCheckInActionTest.php
new file mode 100644
index 000000000..bfd07712b
--- /dev/null
+++ b/app-modules/events/tests/Feature/NumericCodeCheckInActionTest.php
@@ -0,0 +1,298 @@
+setTime(12, 0));
+});
+
+afterEach(function (): void {
+ Date::setTestNow();
+});
+
+function createScenarioWithCode(array $codeOverrides = []): array
+{
+ $tenant = Tenant::factory()->create();
+ $participant = User::factory()->create();
+ $startsAt = now()->setTime(9, 0);
+
+ $event = Event::factory()
+ ->for($tenant)
+ ->create([
+ 'starts_at' => $startsAt,
+ 'ends_at' => $startsAt->clone()->setTime(18, 0),
+ 'status' => EventStatus::Published,
+ ]);
+
+ $enrollment = Enrollment::factory()->create([
+ 'event_id' => $event->id,
+ 'user_id' => $participant->id,
+ 'status' => EnrollmentStatus::Confirmed,
+ 'confirmed_at' => now(),
+ ]);
+
+ $code = CheckInCode::factory()->create(array_merge([
+ 'event_id' => $event->id,
+ 'event_date' => now()->toDateString(),
+ 'code' => '123456',
+ 'starts_at' => now()->subHour(),
+ 'expires_at' => now()->addHours(2),
+ 'max_uses' => null,
+ 'uses_count' => 0,
+ ], $codeOverrides));
+
+ return ['enrollment' => $enrollment, 'code' => $code, 'event' => $event, 'participant' => $participant];
+}
+
+test('when participant checks in with valid numeric code, then check-in is recorded and enrollment transitions', function (): void {
+ EventFacade::fake([ParticipantCheckedIn::class]);
+
+ $scenario = createScenarioWithCode();
+ $enrollment = $scenario['enrollment'];
+ $code = $scenario['code'];
+
+ $checkIn = resolve(NumericCodeCheckInAction::class)->handle(
+ new NumericCodeCheckInDTO(
+ enrollment: $enrollment,
+ code: '123456',
+ eventDate: now(),
+ ),
+ );
+
+ expect($checkIn)->toBeInstanceOf(CheckIn::class)
+ ->and($checkIn->enrollment_id)->toBe($enrollment->id)
+ ->and($checkIn->method)->toBe(CheckInMethod::NumericCode)
+ ->and($checkIn->event_date->isSameDay(now()))->toBeTrue()
+ ->and($checkIn->checked_in_at)->not->toBeNull()
+ ->and($checkIn->payload)->toBe(['code' => '123456'])
+ ->and($enrollment->fresh()->status)->toBe(EnrollmentStatus::CheckedIn)
+ ->and($enrollment->fresh()->checked_in_at)->not->toBeNull();
+
+ $code->refresh();
+ expect($code->uses_count)->toBe(1);
+
+ EventFacade::assertDispatched(fn (ParticipantCheckedIn $event): bool => $event->enrollmentId === $enrollment->id);
+});
+
+test('when code does not exist, then invalid check-in code error is thrown', function (): void {
+ $scenario = createScenarioWithCode();
+
+ expect(fn (): CheckIn => resolve(NumericCodeCheckInAction::class)->handle(
+ new NumericCodeCheckInDTO(
+ enrollment: $scenario['enrollment'],
+ code: '999999',
+ eventDate: now(),
+ ),
+ ))->toThrow(fn (CheckInException $e): bool => $e->getMessage() === __('events::check_in.invalid_check_in_code'));
+});
+
+test('when code is for a different date, then wrong date error is thrown', function (): void {
+ $scenario = createScenarioWithCode();
+
+ expect(fn (): CheckIn => resolve(NumericCodeCheckInAction::class)->handle(
+ new NumericCodeCheckInDTO(
+ enrollment: $scenario['enrollment'],
+ code: '123456',
+ eventDate: now()->addDay(),
+ ),
+ ))->toThrow(fn (CheckInException $e): bool => $e->getMessage() === __('events::check_in.check_in_code_wrong_date'));
+});
+
+test('when code is expired, then expired error is thrown', function (): void {
+ $scenario = createScenarioWithCode([
+ 'expires_at' => now()->subDay(),
+ ]);
+
+ expect(fn (): CheckIn => resolve(NumericCodeCheckInAction::class)->handle(
+ new NumericCodeCheckInDTO(
+ enrollment: $scenario['enrollment'],
+ code: '123456',
+ eventDate: now(),
+ ),
+ ))->toThrow(fn (CheckInException $e): bool => $e->getMessage() === __('events::check_in.check_in_code_expired'));
+});
+
+test('when code validity window has not started, then expired error is thrown', function (): void {
+ $scenario = createScenarioWithCode([
+ 'starts_at' => now()->addHour(),
+ 'expires_at' => now()->addHours(2),
+ ]);
+
+ expect(fn (): CheckIn => resolve(NumericCodeCheckInAction::class)->handle(
+ new NumericCodeCheckInDTO(
+ enrollment: $scenario['enrollment'],
+ code: '123456',
+ eventDate: now(),
+ ),
+ ))->toThrow(fn (CheckInException $e): bool => $e->getMessage() === __('events::check_in.check_in_code_expired'));
+});
+
+test('when code is revoked, then expired error is thrown', function (): void {
+ $scenario = createScenarioWithCode([
+ 'revoked_at' => now(),
+ ]);
+
+ expect(fn (): CheckIn => resolve(NumericCodeCheckInAction::class)->handle(
+ new NumericCodeCheckInDTO(
+ enrollment: $scenario['enrollment'],
+ code: '123456',
+ eventDate: now(),
+ ),
+ ))->toThrow(fn (CheckInException $e): bool => $e->getMessage() === __('events::check_in.check_in_code_expired'));
+});
+
+test('when code has reached max uses, then exhausted error is thrown', function (): void {
+ $scenario = createScenarioWithCode([
+ 'max_uses' => 3,
+ 'uses_count' => 3,
+ ]);
+
+ expect(fn (): CheckIn => resolve(NumericCodeCheckInAction::class)->handle(
+ new NumericCodeCheckInDTO(
+ enrollment: $scenario['enrollment'],
+ code: '123456',
+ eventDate: now(),
+ ),
+ ))->toThrow(fn (CheckInException $e): bool => $e->getMessage() === __('events::check_in.check_in_code_exhausted'));
+});
+
+test('uses_count is atomically incremented on each successful check-in', function (): void {
+ $tenant = Tenant::factory()->create();
+ $startsAt = now()->setTime(9, 0);
+
+ $event = Event::factory()
+ ->for($tenant)
+ ->create([
+ 'starts_at' => $startsAt,
+ 'ends_at' => $startsAt->clone()->setTime(18, 0),
+ 'status' => EventStatus::Published,
+ ]);
+
+ $participant1 = User::factory()->create();
+ $enrollment1 = Enrollment::factory()->create([
+ 'event_id' => $event->id,
+ 'user_id' => $participant1->id,
+ 'status' => EnrollmentStatus::Confirmed,
+ 'confirmed_at' => now(),
+ ]);
+
+ $participant2 = User::factory()->create();
+ $enrollment2 = Enrollment::factory()->create([
+ 'event_id' => $event->id,
+ 'user_id' => $participant2->id,
+ 'status' => EnrollmentStatus::Confirmed,
+ 'confirmed_at' => now(),
+ ]);
+
+ $code = CheckInCode::factory()->create([
+ 'event_id' => $event->id,
+ 'event_date' => now()->toDateString(),
+ 'code' => '123456',
+ 'starts_at' => now()->subDay(),
+ 'expires_at' => now()->addDay(),
+ 'max_uses' => 5,
+ ]);
+
+ resolve(NumericCodeCheckInAction::class)->handle(
+ new NumericCodeCheckInDTO(
+ enrollment: $enrollment1,
+ code: '123456',
+ eventDate: now(),
+ ),
+ );
+
+ expect($code->fresh()->uses_count)->toBe(1);
+
+ resolve(NumericCodeCheckInAction::class)->handle(
+ new NumericCodeCheckInDTO(
+ enrollment: $enrollment2,
+ code: '123456',
+ eventDate: now(),
+ ),
+ );
+
+ expect($code->fresh()->uses_count)->toBe(2);
+});
+
+test('when same numeric code exists for multiple event dates, then current date code is used', function (): void {
+ $tenant = Tenant::factory()->create();
+ $participant = User::factory()->create();
+ $startsAt = now()->setTime(9, 0);
+
+ $event = Event::factory()
+ ->for($tenant)
+ ->create([
+ 'starts_at' => $startsAt,
+ 'ends_at' => $startsAt->clone()->addDay()->setTime(18, 0),
+ 'status' => EventStatus::Published,
+ ]);
+
+ $enrollment = Enrollment::factory()->create([
+ 'event_id' => $event->id,
+ 'user_id' => $participant->id,
+ 'status' => EnrollmentStatus::Confirmed,
+ 'confirmed_at' => now(),
+ ]);
+
+ $tomorrowCode = CheckInCode::factory()->create([
+ 'event_id' => $event->id,
+ 'event_date' => now()->addDay()->toDateString(),
+ 'code' => '123456',
+ 'starts_at' => now()->subHour(),
+ 'expires_at' => now()->addHours(2),
+ ]);
+
+ $todayCode = CheckInCode::factory()->create([
+ 'event_id' => $event->id,
+ 'event_date' => now()->toDateString(),
+ 'code' => '123456',
+ 'starts_at' => now()->subHour(),
+ 'expires_at' => now()->addHours(2),
+ ]);
+
+ resolve(NumericCodeCheckInAction::class)->handle(
+ new NumericCodeCheckInDTO(
+ enrollment: $enrollment,
+ code: '123456',
+ eventDate: now(),
+ ),
+ );
+
+ expect($todayCode->fresh()->uses_count)->toBe(1)
+ ->and($tomorrowCode->fresh()->uses_count)->toBe(0);
+});
+
+test('when check-in date is outside event date range, then check-in is rejected by core action', function (): void {
+ $startsAt = now()->setTime(9, 0);
+ $scenario = createScenarioWithCode([
+ 'event_date' => $startsAt->clone()->addDays(2)->toDateString(),
+ ]);
+ $scenario['event']->update([
+ 'starts_at' => $startsAt,
+ 'ends_at' => $startsAt->clone()->setTime(18, 0),
+ ]);
+
+ expect(fn (): CheckIn => resolve(NumericCodeCheckInAction::class)->handle(
+ new NumericCodeCheckInDTO(
+ enrollment: $scenario['enrollment'],
+ code: '123456',
+ eventDate: $startsAt->clone()->addDays(2),
+ ),
+ ))->toThrow(CheckInException::class);
+});
diff --git a/app-modules/panel-admin/src/Filament/Resources/Events/EventResource.php b/app-modules/panel-admin/src/Filament/Resources/Events/EventResource.php
index 0fde383b8..36d0cef76 100644
--- a/app-modules/panel-admin/src/Filament/Resources/Events/EventResource.php
+++ b/app-modules/panel-admin/src/Filament/Resources/Events/EventResource.php
@@ -13,6 +13,7 @@
use He4rt\PanelAdmin\Filament\Resources\Events\Pages\CreateEvent;
use He4rt\PanelAdmin\Filament\Resources\Events\Pages\EditEvent;
use He4rt\PanelAdmin\Filament\Resources\Events\Pages\ListEvents;
+use He4rt\PanelAdmin\Filament\Resources\Events\RelationManagers\CheckInCodesRelationManager;
use He4rt\PanelAdmin\Filament\Resources\Events\RelationManagers\EnrollmentsRelationManager;
use He4rt\PanelAdmin\Filament\Resources\Events\Schemas\EventForm;
use He4rt\PanelAdmin\Filament\Resources\Events\Schemas\EventInfolist;
@@ -47,6 +48,7 @@ public static function getRelations(): array
{
return [
EnrollmentsRelationManager::class,
+ CheckInCodesRelationManager::class,
];
}
diff --git a/app-modules/panel-admin/src/Filament/Resources/Events/RelationManagers/Actions/GenerateCheckInCodeAction.php b/app-modules/panel-admin/src/Filament/Resources/Events/RelationManagers/Actions/GenerateCheckInCodeAction.php
new file mode 100644
index 000000000..cd320331b
--- /dev/null
+++ b/app-modules/panel-admin/src/Filament/Resources/Events/RelationManagers/Actions/GenerateCheckInCodeAction.php
@@ -0,0 +1,127 @@
+label('Generate Code')
+ ->icon(Heroicon::OutlinedPlusCircle)
+ ->color('success')
+ ->schema($this->generateFormSchema(...))
+ ->action($this->persistCheckInCode(...));
+ }
+
+ public static function getDefaultName(): string
+ {
+ return 'generateCode';
+ }
+
+ /**
+ * @param array{digits?: string, code_preview: string, event_date: string, starts_at: string, expires_at: string, max_uses?: string|int|null} $data
+ */
+ public function persistCheckInCode(array $data, RelationManager $livewire): CheckInCode
+ {
+ /** @var Event $event */
+ $event = $livewire->getOwnerRecord();
+
+ $code = (string) $data['code_preview'];
+
+ if (!preg_match('/^\d{4}$|^\d{6}$/', $code)) {
+ $code = $this->generateNumericCode((int) ($data['digits'] ?? 6));
+ }
+
+ return CheckInCode::query()->create([
+ 'event_id' => $event->id,
+ 'event_date' => $data['event_date'],
+ 'code' => $code,
+ 'starts_at' => $data['starts_at'],
+ 'expires_at' => $data['expires_at'],
+ 'max_uses' => filled($data['max_uses'] ?? null) ? (int) $data['max_uses'] : null,
+ ]);
+ }
+
+ /**
+ * @return array
+ */
+ private function generateFormSchema(RelationManager $livewire): array
+ {
+ /** @var Event $event */
+ $event = $livewire->getOwnerRecord();
+
+ return [
+ Section::make()
+ ->columns(2)
+ ->schema([
+ Select::make('digits')
+ ->label('Code Length')
+ ->options([
+ '4' => '4 digits',
+ '6' => '6 digits',
+ ])
+ ->default('6')
+ ->live()
+ ->afterStateUpdated(function (Set $set, ?string $state): void {
+ $set('code_preview', $this->generateNumericCode((int) ($state ?: 6)));
+ })
+ ->selectablePlaceholder(false)
+ ->required(),
+
+ TextInput::make('code_preview')
+ ->label('Generated Code')
+ ->readOnly()
+ ->default(fn (): string => $this->generateNumericCode(6))
+ ->dehydrated()
+ ->required(),
+
+ DatePicker::make('event_date')
+ ->label('Event Date')
+ ->default($event->starts_at->toDateString())
+ ->minDate($event->starts_at->toDateString())
+ ->maxDate($event->ends_at->toDateString())
+ ->required(),
+
+ DateTimePicker::make('starts_at')
+ ->label('Valid From')
+ ->default(now())
+ ->required(),
+
+ DateTimePicker::make('expires_at')
+ ->label('Expires At')
+ ->default(now()->addHours(2))
+ ->afterOrEqual('starts_at')
+ ->required(),
+
+ TextInput::make('max_uses')
+ ->label('Max Uses (optional)')
+ ->numeric()
+ ->minValue(1)
+ ->placeholder('Unlimited'),
+ ]),
+ ];
+ }
+
+ private function generateNumericCode(int $digits): string
+ {
+ $min = 10 ** ($digits - 1);
+ $max = 10 ** $digits - 1;
+
+ return (string) random_int($min, $max);
+ }
+}
diff --git a/app-modules/panel-admin/src/Filament/Resources/Events/RelationManagers/Actions/RevokeCheckInCodeAction.php b/app-modules/panel-admin/src/Filament/Resources/Events/RelationManagers/Actions/RevokeCheckInCodeAction.php
new file mode 100644
index 000000000..9bd424d07
--- /dev/null
+++ b/app-modules/panel-admin/src/Filament/Resources/Events/RelationManagers/Actions/RevokeCheckInCodeAction.php
@@ -0,0 +1,37 @@
+label('Revoke')
+ ->icon(Heroicon::OutlinedNoSymbol)
+ ->color('danger')
+ ->visible(fn (CheckInCode $record): bool => $record->revoked_at === null)
+ ->requiresConfirmation()
+ ->action(function (CheckInCode $record): void {
+ $record->update(['revoked_at' => now()]);
+
+ Notification::make()
+ ->success()
+ ->title('Code revoked.')
+ ->send();
+ });
+ }
+
+ public static function getDefaultName(): string
+ {
+ return 'revoke-check-in-code-action';
+ }
+}
diff --git a/app-modules/panel-admin/src/Filament/Resources/Events/RelationManagers/CheckInCodesRelationManager.php b/app-modules/panel-admin/src/Filament/Resources/Events/RelationManagers/CheckInCodesRelationManager.php
new file mode 100644
index 000000000..82f0bb7a5
--- /dev/null
+++ b/app-modules/panel-admin/src/Filament/Resources/Events/RelationManagers/CheckInCodesRelationManager.php
@@ -0,0 +1,72 @@
+checkInCodes()->count();
+ }
+
+ public function table(Table $table): Table
+ {
+ return $table
+ ->recordTitleAttribute('code')
+ ->modifyQueryUsing(fn (Builder $query): Builder => $query->latest())
+ ->columns([
+ TextColumn::make('code')
+ ->label('Code')
+ ->badge()
+ ->color(fn (CheckInCode $record): string => $record->revoked_at !== null ? 'gray' : ($record->expires_at->isPast() ? 'warning' : 'success'))
+ ->searchable(),
+
+ TextColumn::make('event_date')
+ ->label('Event Date')
+ ->date()
+ ->sortable(),
+
+ TextColumn::make('starts_at')
+ ->label('Valid From')
+ ->dateTime()
+ ->sortable(),
+
+ TextColumn::make('expires_at')
+ ->label('Expires At')
+ ->dateTime()
+ ->sortable(),
+
+ TextColumn::make('uses_count')
+ ->label('Uses')
+ ->state(fn (CheckInCode $record): string => $record->uses_count.($record->max_uses !== null ? '/'.$record->max_uses : '')),
+
+ TextColumn::make('revoked_at')
+ ->label('Revoked At')
+ ->dateTime()
+ ->placeholder('-'),
+ ])
+ ->headerActions([
+ GenerateCheckInCodeAction::make(),
+ ])
+ ->recordActions([
+ RevokeCheckInCodeAction::make(),
+ ]);
+ }
+}
diff --git a/app-modules/panel-app/resources/views/livewire/events/event-detail.blade.php b/app-modules/panel-app/resources/views/livewire/events/event-detail.blade.php
index e03c7f2bf..5b6d6d719 100644
--- a/app-modules/panel-app/resources/views/livewire/events/event-detail.blade.php
+++ b/app-modules/panel-app/resources/views/livewire/events/event-detail.blade.php
@@ -60,4 +60,10 @@
{{ __('events::pages.event_full') }}
@endif
+
+
diff --git a/app-modules/panel-app/resources/views/livewire/events/numeric-code-check-in.blade.php b/app-modules/panel-app/resources/views/livewire/events/numeric-code-check-in.blade.php
new file mode 100644
index 000000000..bab7e26cf
--- /dev/null
+++ b/app-modules/panel-app/resources/views/livewire/events/numeric-code-check-in.blade.php
@@ -0,0 +1,35 @@
+
+ @if ($this->canCheckIn)
+
+
+ {{ __('events::pages.enter_check_in_code_hint') }}
+
+
+
+
+
+
+
+
+ @error ('code')
+
{{ $message }}
+ @enderror
+
+ @if ($error)
+
{{ $error }}
+ @endif
+
+
+
+ {{ __('events::pages.check_in') }}
+
+
+
+ @endif
+
diff --git a/app-modules/panel-app/src/Livewire/Events/NumericCodeCheckIn.php b/app-modules/panel-app/src/Livewire/Events/NumericCodeCheckIn.php
new file mode 100644
index 000000000..abbe51bae
--- /dev/null
+++ b/app-modules/panel-app/src/Livewire/Events/NumericCodeCheckIn.php
@@ -0,0 +1,129 @@
+eventId = $eventId;
+ }
+
+ #[Computed]
+ public function enrollment(): ?Enrollment
+ {
+ return Enrollment::query()
+ ->with('event.enrollmentPolicy')
+ ->where('event_id', $this->eventId)
+ ->where('user_id', auth()->id())
+ ->first();
+ }
+
+ #[Computed]
+ public function canCheckIn(): bool
+ {
+ if ($this->enrollment === null) {
+ return false;
+ }
+
+ if ($this->enrollment->event->enrollmentPolicy?->check_in_method !== CheckInMethod::NumericCode) {
+ return false;
+ }
+
+ return in_array($this->enrollment->status, [EnrollmentStatus::Confirmed, EnrollmentStatus::CheckedIn], strict: true);
+ }
+
+ public function checkIn(): void
+ {
+ $this->error = null;
+ $this->code = mb_trim($this->code);
+
+ if ($this->enrollment === null || !$this->canCheckIn) {
+ return;
+ }
+
+ $this->validate([
+ 'code' => ['required', 'string', new CheckInCodeRule()],
+ ], [
+ 'code.required' => CheckInException::invalidCheckInCodeFormat()->getMessage(),
+ ]);
+
+ if (!$this->ensureNotRateLimited()) {
+ return;
+ }
+
+ try {
+ resolve(NumericCodeCheckInAction::class)->handle(
+ new NumericCodeCheckInDTO(
+ enrollment: $this->enrollment,
+ code: $this->code,
+ eventDate: Date::today(),
+ ),
+ );
+
+ RateLimiter::clear($this->getRateLimitKey());
+ $this->code = '';
+
+ Notification::make()
+ ->success()
+ ->title('Check-in confirmed!')
+ ->send();
+ } catch (CheckInException $checkInException) {
+ $this->error = $checkInException->getMessage();
+ }
+ }
+
+ public function render(): View
+ {
+ return view('panel-app::livewire.events.numeric-code-check-in');
+ }
+
+ private function ensureNotRateLimited(): bool
+ {
+ $rateLimitKey = $this->getRateLimitKey();
+
+ if (RateLimiter::tooManyAttempts($rateLimitKey, 5)) {
+ $this->error = CheckInException::checkInCodeRateLimited(
+ RateLimiter::availableIn($rateLimitKey)
+ )->getMessage();
+
+ return false;
+ }
+
+ RateLimiter::hit($rateLimitKey, 60);
+
+ return true;
+ }
+
+ private function getRateLimitKey(): string
+ {
+ return sprintf(
+ 'numeric-code-check-in:%s:%s:%s',
+ auth()->id() ?? 'guest',
+ $this->eventId,
+ md5((string) (request()->ip() ?? 'unknown')),
+ );
+ }
+}
diff --git a/app-modules/panel-app/src/PanelAppServiceProvider.php b/app-modules/panel-app/src/PanelAppServiceProvider.php
index f425f90e6..8a0da2def 100644
--- a/app-modules/panel-app/src/PanelAppServiceProvider.php
+++ b/app-modules/panel-app/src/PanelAppServiceProvider.php
@@ -7,6 +7,7 @@
use He4rt\PanelApp\Livewire\Events\EventDetail;
use He4rt\PanelApp\Livewire\Events\EventsList;
use He4rt\PanelApp\Livewire\Events\MyEventsList;
+use He4rt\PanelApp\Livewire\Events\NumericCodeCheckIn;
use He4rt\PanelApp\Livewire\Timeline\Composer;
use He4rt\PanelApp\Livewire\Timeline\Feed;
use He4rt\PanelApp\Livewire\Timeline\PostShow;
@@ -27,6 +28,7 @@ public function boot(): void
Livewire::component('events-list', EventsList::class);
Livewire::component('my-events-list', MyEventsList::class);
Livewire::component('event-detail', EventDetail::class);
+ Livewire::component('numeric-code-check-in', NumericCodeCheckIn::class);
Livewire::component('timeline-composer', Composer::class);
Livewire::component('timeline-feed', Feed::class);
diff --git a/app-modules/panel-app/tests/Feature/Events/NumericCodeCheckInTest.php b/app-modules/panel-app/tests/Feature/Events/NumericCodeCheckInTest.php
new file mode 100644
index 000000000..fcd5529f6
--- /dev/null
+++ b/app-modules/panel-app/tests/Feature/Events/NumericCodeCheckInTest.php
@@ -0,0 +1,67 @@
+user = User::factory()->create();
+ $this->tenant = Tenant::factory()->create(['slug' => 'numeric-code-test-tenant']);
+ $this->tenant->members()->attach($this->user);
+
+ $this->actingAs($this->user);
+
+ Filament::setCurrentPanel(Filament::getPanel('app'));
+ Filament::setTenant($this->tenant);
+
+ $this->event = Event::factory()
+ ->published()
+ ->upcoming()
+ ->for($this->tenant)
+ ->has(EnrollmentPolicy::factory()->rsvp()->state([
+ 'check_in_method' => CheckInMethod::NumericCode,
+ ]), 'enrollmentPolicy')
+ ->create();
+
+ Enrollment::factory()->create([
+ 'event_id' => $this->event->id,
+ 'user_id' => $this->user->id,
+ 'status' => EnrollmentStatus::Confirmed,
+ 'confirmed_at' => now(),
+ ]);
+
+ RateLimiter::clear(sprintf('numeric-code-check-in:%s:%s:%s', $this->user->id, $this->event->id, md5('127.0.0.1')));
+});
+
+test('when check-in code format is invalid, then component rejects it before action', function (): void {
+ livewire(NumericCodeCheckIn::class, ['eventId' => $this->event->id])
+ ->set('code', 'abc123')
+ ->call('checkIn')
+ ->assertHasErrors(['code']);
+});
+
+test('when participant repeats invalid numeric codes, then component rate limits attempts', function (): void {
+ $component = livewire(NumericCodeCheckIn::class, ['eventId' => $this->event->id])
+ ->set('code', '999999');
+
+ foreach (range(1, 5) as $_) {
+ $component
+ ->call('checkIn')
+ ->assertSet('error', __('events::check_in.invalid_check_in_code'));
+ }
+
+ $component
+ ->call('checkIn')
+ ->assertSet('error', fn (string $error): bool => str_contains($error, 'Too many attempts.'));
+});
diff --git a/database/seeders/DatabaseSeeder.php b/database/seeders/DatabaseSeeder.php
index 0f56a1814..361e9e762 100644
--- a/database/seeders/DatabaseSeeder.php
+++ b/database/seeders/DatabaseSeeder.php
@@ -5,6 +5,7 @@
namespace Database\Seeders;
// use Illuminate\Database\Console\Seeds\WithoutModelEvents;
+use He4rt\Events\Database\Seeders\EventsSeeder;
use Illuminate\Database\Seeder;
final class DatabaseSeeder extends Seeder
@@ -16,6 +17,7 @@ public function run(): void
{
$this->call([
BaseSeeder::class,
+ EventsSeeder::class,
]);
}
}
From 8b3d3c41ba38e7319e0357a87195f3995fa223bb Mon Sep 17 00:00:00 2001
From: Yuri Souza
Date: Thu, 4 Jun 2026 17:56:10 -0300
Subject: [PATCH 07/18] feat(events): implement qr code check-in for event
enrollments (#237) (#298)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
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
---
app-modules/events/lang/en/check_in.php | 2 +
app-modules/events/lang/en/pages.php | 12 +
app-modules/events/lang/pt_BR/check_in.php | 2 +
app-modules/events/lang/pt_BR/pages.php | 12 +
.../CheckIn/Actions/GenerateQrTokenAction.php | 26 ++
.../src/CheckIn/Actions/QrCheckInAction.php | 45 ++++
.../events/src/CheckIn/DTOs/QrCheckInDTO.php | 22 ++
.../CheckIn/Exceptions/CheckInException.php | 15 ++
.../Listeners/GenerateQrTokenOnConfirmed.php | 19 ++
.../events/src/EventsServiceProvider.php | 5 +
.../tests/Feature/QrCheckInActionTest.php | 254 ++++++++++++++++++
.../tests/Unit/EnrollmentStatusTest.php | 4 +-
.../Resources/Events/Pages/EditEvent.php | 68 +++++
.../EnrollmentsRelationManager.php | 9 +-
.../livewire/events/event-detail.blade.php | 102 ++++++-
.../src/Livewire/Events/EventDetail.php | 62 +++++
composer.json | 1 +
17 files changed, 650 insertions(+), 10 deletions(-)
create mode 100644 app-modules/events/src/CheckIn/Actions/GenerateQrTokenAction.php
create mode 100644 app-modules/events/src/CheckIn/Actions/QrCheckInAction.php
create mode 100644 app-modules/events/src/CheckIn/DTOs/QrCheckInDTO.php
create mode 100644 app-modules/events/src/CheckIn/Listeners/GenerateQrTokenOnConfirmed.php
create mode 100644 app-modules/events/tests/Feature/QrCheckInActionTest.php
diff --git a/app-modules/events/lang/en/check_in.php b/app-modules/events/lang/en/check_in.php
index f57dea4ee..9a8c5e3d8 100644
--- a/app-modules/events/lang/en/check_in.php
+++ b/app-modules/events/lang/en/check_in.php
@@ -7,6 +7,8 @@
'check_in_outside_event_date_range' => 'Check-in date must be within the event date range.',
'already_checked_in_for_date' => 'This enrollment has already checked in for this date.',
'invalid_check_in_actor' => 'Manual check-in requires the organizer user id.',
+ 'qr_token_not_found' => 'QR token not found or does not belong to this event.',
+ 'qr_token_expired' => 'This QR token has expired.',
'invalid_check_in_code' => 'Invalid check-in code.',
'invalid_check_in_code_format' => 'Enter a 4 or 6 digit check-in code.',
'check_in_code_expired' => 'Code has expired.',
diff --git a/app-modules/events/lang/en/pages.php b/app-modules/events/lang/en/pages.php
index 3b68dc910..0368bf5d5 100644
--- a/app-modules/events/lang/en/pages.php
+++ b/app-modules/events/lang/en/pages.php
@@ -6,6 +6,9 @@
'back_to_events' => 'Back to Events',
'confirm_presence' => 'Confirm Presence',
'confirm_presence_hint' => 'Confirm your attendance to this event.',
+ 'confirm_presence_prompt' => 'Are you sure you want to enroll in this event?',
+ 'confirm_presence_yes' => 'Yes, enroll me',
+ 'confirm_presence_cancel' => 'Cancel',
'confirm_presence_success' => 'Your presence has been confirmed!',
'waitlist_success' => 'You are on the waitlist (position :position).',
'waitlist_status' => 'You are on the waitlist (position :position).',
@@ -14,6 +17,15 @@
'enrolled_at' => 'Enrolled on :date',
'no_enrollments' => 'You are not enrolled in any events yet.',
'no_upcoming_events' => 'No upcoming events available.',
+ 'my_qr_code' => 'My QR Code',
+ 'qr_code_hint' => 'Present this QR code to the organizer for check-in.',
+ 'copy_token' => 'Copy Token',
+ 'token_copied' => 'Copied!',
+ 'download_qr' => 'Download QR',
+ 'checked_in_today' => 'Check-in done today',
+ 'check_in_history' => 'Check-in History',
+ 'check_in_history_date' => ':date',
+ 'no_check_ins_yet' => 'No check-ins recorded yet.',
'enter_check_in_code_hint' => 'Enter the check-in code announced by the organizer.',
'check_in' => 'Check In',
];
diff --git a/app-modules/events/lang/pt_BR/check_in.php b/app-modules/events/lang/pt_BR/check_in.php
index 539a3f667..d30c8916f 100644
--- a/app-modules/events/lang/pt_BR/check_in.php
+++ b/app-modules/events/lang/pt_BR/check_in.php
@@ -7,6 +7,8 @@
'check_in_outside_event_date_range' => 'A data do check-in deve estar dentro do período do evento.',
'already_checked_in_for_date' => 'Esta inscrição já possui check-in para essa data.',
'invalid_check_in_actor' => 'Check-in manual exige o ID do usuário organizador.',
+ 'qr_token_not_found' => 'Token QR não encontrado ou não pertence a este evento.',
+ 'qr_token_expired' => 'Este token QR expirou.',
'invalid_check_in_code' => 'Código de check-in inválido.',
'invalid_check_in_code_format' => 'Informe um código de check-in com 4 ou 6 dígitos.',
'check_in_code_expired' => 'O código expirou.',
diff --git a/app-modules/events/lang/pt_BR/pages.php b/app-modules/events/lang/pt_BR/pages.php
index 5ebe27bc6..abec54537 100644
--- a/app-modules/events/lang/pt_BR/pages.php
+++ b/app-modules/events/lang/pt_BR/pages.php
@@ -6,6 +6,9 @@
'back_to_events' => 'Voltar para Eventos',
'confirm_presence' => 'Confirmar Presença',
'confirm_presence_hint' => 'Confirme sua presença neste evento.',
+ 'confirm_presence_prompt' => 'Tem certeza que deseja se inscrever neste evento?',
+ 'confirm_presence_yes' => 'Sim, me inscrever',
+ 'confirm_presence_cancel' => 'Cancelar',
'confirm_presence_success' => 'Sua presença foi confirmada!',
'waitlist_success' => 'Você está na lista de espera (posição :position).',
'waitlist_status' => 'Você está na lista de espera (posição :position).',
@@ -14,6 +17,15 @@
'enrolled_at' => 'Inscrito em :date',
'no_enrollments' => 'Você ainda não está inscrito em nenhum evento.',
'no_upcoming_events' => 'Nenhum evento disponível no momento.',
+ 'my_qr_code' => 'Meu QR Code',
+ 'qr_code_hint' => 'Apresente este QR code ao organizador para realizar o check-in.',
+ 'copy_token' => 'Copiar Token',
+ 'token_copied' => 'Copiado!',
+ 'download_qr' => 'Baixar QR',
+ 'checked_in_today' => 'Check-in feito hoje',
+ 'check_in_history' => 'Histórico de Check-ins',
+ 'check_in_history_date' => ':date',
+ 'no_check_ins_yet' => 'Nenhum check-in registrado ainda.',
'enter_check_in_code_hint' => 'Insira o código de check-in informado pelo organizador.',
'check_in' => 'Fazer Check-in',
];
diff --git a/app-modules/events/src/CheckIn/Actions/GenerateQrTokenAction.php b/app-modules/events/src/CheckIn/Actions/GenerateQrTokenAction.php
new file mode 100644
index 000000000..e18bbcf10
--- /dev/null
+++ b/app-modules/events/src/CheckIn/Actions/GenerateQrTokenAction.php
@@ -0,0 +1,26 @@
+where('enrollment_id', $enrollment->getKey())->first();
+
+ if ($existing !== null) {
+ return $existing;
+ }
+
+ return QrToken::query()->create([
+ 'enrollment_id' => $enrollment->getKey(),
+ 'token' => Str::uuid7()->toString(),
+ ]);
+ }
+}
diff --git a/app-modules/events/src/CheckIn/Actions/QrCheckInAction.php b/app-modules/events/src/CheckIn/Actions/QrCheckInAction.php
new file mode 100644
index 000000000..aceb22a6a
--- /dev/null
+++ b/app-modules/events/src/CheckIn/Actions/QrCheckInAction.php
@@ -0,0 +1,45 @@
+with('enrollment')
+ ->where('token', $dto->token)
+ ->first();
+
+ throw_unless(
+ $qrToken !== null && $qrToken->enrollment->event_id === $dto->event->id,
+ CheckInException::qrTokenNotFound(),
+ );
+
+ throw_if(
+ $qrToken->expires_at !== null && $qrToken->expires_at->isPast(),
+ CheckInException::qrTokenExpired(),
+ );
+
+ return resolve(CheckInAction::class)->handle(
+ new CheckInDTO(
+ enrollment: $qrToken->enrollment,
+ method: CheckInMethod::QrCode,
+ payload: ['token' => $dto->token],
+ eventDate: $dto->eventDate,
+ actorUserId: $dto->actorUserId,
+ triggeredBy: TriggeredBy::Admin,
+ ),
+ );
+ }
+}
diff --git a/app-modules/events/src/CheckIn/DTOs/QrCheckInDTO.php b/app-modules/events/src/CheckIn/DTOs/QrCheckInDTO.php
new file mode 100644
index 000000000..64775554b
--- /dev/null
+++ b/app-modules/events/src/CheckIn/DTOs/QrCheckInDTO.php
@@ -0,0 +1,22 @@
+token), CheckInException::qrTokenNotFound());
+ throw_if(blank($this->actorUserId), CheckInException::invalidCheckInActor());
+ }
+}
diff --git a/app-modules/events/src/CheckIn/Exceptions/CheckInException.php b/app-modules/events/src/CheckIn/Exceptions/CheckInException.php
index beed79f2e..f379eaa0c 100644
--- a/app-modules/events/src/CheckIn/Exceptions/CheckInException.php
+++ b/app-modules/events/src/CheckIn/Exceptions/CheckInException.php
@@ -41,6 +41,21 @@ public static function invalidCheckInActor(): self
);
}
+ public static function qrTokenNotFound(): self
+ {
+ return new self(
+ __('events::check_in.qr_token_not_found'),
+ Response::HTTP_NOT_FOUND,
+ );
+ }
+
+ public static function qrTokenExpired(): self
+ {
+ return new self(
+ __('events::check_in.qr_token_expired'),
+ Response::HTTP_UNPROCESSABLE_ENTITY,
+ );
+ }
public static function invalidCheckInCode(): self
{
return new self(
diff --git a/app-modules/events/src/CheckIn/Listeners/GenerateQrTokenOnConfirmed.php b/app-modules/events/src/CheckIn/Listeners/GenerateQrTokenOnConfirmed.php
new file mode 100644
index 000000000..8598a0129
--- /dev/null
+++ b/app-modules/events/src/CheckIn/Listeners/GenerateQrTokenOnConfirmed.php
@@ -0,0 +1,19 @@
+findOrFail($event->enrollmentId);
+
+ resolve(GenerateQrTokenAction::class)->handle($enrollment);
+ }
+}
diff --git a/app-modules/events/src/EventsServiceProvider.php b/app-modules/events/src/EventsServiceProvider.php
index 8c205a82b..bfe3c5882 100644
--- a/app-modules/events/src/EventsServiceProvider.php
+++ b/app-modules/events/src/EventsServiceProvider.php
@@ -4,6 +4,9 @@
namespace He4rt\Events;
+use He4rt\Events\CheckIn\Listeners\GenerateQrTokenOnConfirmed;
+use He4rt\Events\Enrollment\Events\EnrollmentConfirmed;
+use Illuminate\Support\Facades\Event;
use Illuminate\Support\ServiceProvider;
class EventsServiceProvider extends ServiceProvider
@@ -13,5 +16,7 @@ public function boot(): void
$this->loadMigrationsFrom(__DIR__.'/../database/migrations');
$this->loadViewsFrom(__DIR__.'/../resources/views', 'events');
$this->loadTranslationsFrom(__DIR__.'/../lang', 'events');
+
+ Event::listen(EnrollmentConfirmed::class, GenerateQrTokenOnConfirmed::class);
}
}
diff --git a/app-modules/events/tests/Feature/QrCheckInActionTest.php b/app-modules/events/tests/Feature/QrCheckInActionTest.php
new file mode 100644
index 000000000..494638381
--- /dev/null
+++ b/app-modules/events/tests/Feature/QrCheckInActionTest.php
@@ -0,0 +1,254 @@
+create();
+ $participant = User::factory()->create();
+ $startsAt = now()->setTime(9, 0);
+
+ $event = Event::factory()
+ ->for($tenant)
+ ->create(array_merge([
+ 'starts_at' => $startsAt,
+ 'ends_at' => $startsAt->clone()->setTime(18, 0),
+ 'status' => EventStatus::Published,
+ ], $eventAttributes));
+
+ $enrollment = Enrollment::factory()->create(array_merge([
+ 'event_id' => $event->id,
+ 'user_id' => $participant->id,
+ 'status' => EnrollmentStatus::Confirmed,
+ 'confirmed_at' => now(),
+ ], $enrollmentAttributes));
+
+ $qrToken = resolve(GenerateQrTokenAction::class)->handle($enrollment);
+
+ return [$event, $enrollment, $qrToken];
+}
+
+test('when enrollment is confirmed, generate qr token action creates a unique token', function (): void {
+ $enrollment = Enrollment::factory()->create([
+ 'status' => EnrollmentStatus::Confirmed,
+ 'confirmed_at' => now(),
+ ]);
+
+ $qrToken = resolve(GenerateQrTokenAction::class)->handle($enrollment);
+
+ expect($qrToken)->toBeInstanceOf(QrToken::class)
+ ->and($qrToken->enrollment_id)->toBe($enrollment->id)
+ ->and(Str::isUuid($qrToken->token))->toBeTrue()
+ ->and($qrToken->expires_at)->toBeNull();
+});
+
+test('when generate qr token is called twice, the same token is returned', function (): void {
+ $enrollment = Enrollment::factory()->create([
+ 'status' => EnrollmentStatus::Confirmed,
+ 'confirmed_at' => now(),
+ ]);
+
+ $first = resolve(GenerateQrTokenAction::class)->handle($enrollment);
+ $second = resolve(GenerateQrTokenAction::class)->handle($enrollment);
+
+ expect($second->id)->toBe($first->id)
+ ->and($second->token)->toBe($first->token);
+});
+
+test('when organizer scans a valid qr token, check-in is recorded and participant checked in event is dispatched', function (): void {
+ EventFacade::fake([ParticipantCheckedIn::class]);
+
+ [$event, $enrollment, $qrToken] = createEnrollmentWithQrToken();
+ $organizer = User::factory()->create();
+
+ $checkIn = resolve(QrCheckInAction::class)->handle(
+ new QrCheckInDTO(
+ token: $qrToken->token,
+ event: $event,
+ eventDate: now(),
+ actorUserId: $organizer->id,
+ ),
+ );
+
+ expect($checkIn)->toBeInstanceOf(CheckIn::class)
+ ->and($checkIn->enrollment_id)->toBe($enrollment->id)
+ ->and($checkIn->method)->toBe(CheckInMethod::QrCode)
+ ->and($checkIn->payload)->toBe(['token' => $qrToken->token])
+ ->and($checkIn->event_date->isSameDay(now()))->toBeTrue()
+ ->and($enrollment->fresh()->status)->toBe(EnrollmentStatus::CheckedIn);
+
+ EventFacade::assertDispatched(fn (ParticipantCheckedIn $e): bool => $e->enrollmentId === $enrollment->id);
+});
+
+test('when token does not exist, qr check-in is rejected', function (): void {
+ [$event] = createEnrollmentWithQrToken();
+
+ expect(fn (): CheckIn => resolve(QrCheckInAction::class)->handle(
+ new QrCheckInDTO(
+ token: 'nonexistent-token-that-does-not-exist-in-the-database-at-all',
+ event: $event,
+ eventDate: now(),
+ actorUserId: User::factory()->create()->id,
+ ),
+ ))->toThrow(CheckInException::class);
+});
+
+test('when token belongs to a different event, qr check-in is rejected', function (): void {
+ [, , $qrToken] = createEnrollmentWithQrToken();
+
+ $otherTenant = Tenant::factory()->create();
+ $startsAt = now()->setTime(9, 0);
+ $otherEvent = Event::factory()->for($otherTenant)->create([
+ 'starts_at' => $startsAt,
+ 'ends_at' => $startsAt->clone()->setTime(18, 0),
+ 'status' => EventStatus::Published,
+ ]);
+
+ expect(fn (): CheckIn => resolve(QrCheckInAction::class)->handle(
+ new QrCheckInDTO(
+ token: $qrToken->token,
+ event: $otherEvent,
+ eventDate: now(),
+ actorUserId: User::factory()->create()->id,
+ ),
+ ))->toThrow(CheckInException::class);
+});
+
+test('when token is expired, qr check-in is rejected', function (): void {
+ $tenant = Tenant::factory()->create();
+ $startsAt = now()->setTime(9, 0);
+
+ $event = Event::factory()->for($tenant)->create([
+ 'starts_at' => $startsAt,
+ 'ends_at' => $startsAt->clone()->setTime(18, 0),
+ 'status' => EventStatus::Published,
+ ]);
+
+ $enrollment = Enrollment::factory()->create([
+ 'event_id' => $event->id,
+ 'status' => EnrollmentStatus::Confirmed,
+ 'confirmed_at' => now(),
+ ]);
+
+ $expiredToken = QrToken::factory()->create([
+ 'enrollment_id' => $enrollment->id,
+ 'expires_at' => now()->subHour(),
+ ]);
+
+ expect(fn (): CheckIn => resolve(QrCheckInAction::class)->handle(
+ new QrCheckInDTO(
+ token: $expiredToken->token,
+ event: $event,
+ eventDate: now(),
+ actorUserId: User::factory()->create()->id,
+ ),
+ ))->toThrow(CheckInException::class);
+});
+
+test('when duplicate scan on same day, qr check-in is rejected', function (): void {
+ [$event, , $qrToken] = createEnrollmentWithQrToken();
+ $organizer = User::factory()->create();
+
+ resolve(QrCheckInAction::class)->handle(
+ new QrCheckInDTO(
+ token: $qrToken->token,
+ event: $event,
+ eventDate: now(),
+ actorUserId: $organizer->id,
+ ),
+ );
+
+ expect(fn (): CheckIn => resolve(QrCheckInAction::class)->handle(
+ new QrCheckInDTO(
+ token: $qrToken->token,
+ event: $event,
+ eventDate: now(),
+ actorUserId: $organizer->id,
+ ),
+ ))->toThrow(CheckInException::class);
+});
+
+test('when cancelled enrollment token is scanned, qr check-in is rejected', function (): void {
+ [$event, $enrollment, $qrToken] = createEnrollmentWithQrToken(
+ enrollmentAttributes: ['status' => EnrollmentStatus::Cancelled, 'cancelled_at' => now()],
+ );
+
+ expect(fn (): CheckIn => resolve(QrCheckInAction::class)->handle(
+ new QrCheckInDTO(
+ token: $qrToken->token,
+ event: $event,
+ eventDate: now(),
+ actorUserId: User::factory()->create()->id,
+ ),
+ ))->toThrow(CheckInException::class);
+});
+
+test('when enrollment is confirmed via enroll user action, qr token is generated by listener', function (): void {
+ $user = User::factory()->create();
+ $tenant = Tenant::factory()->create();
+
+ $event = Event::factory()
+ ->for($tenant)
+ ->published()
+ ->upcoming()
+ ->has(
+ EnrollmentPolicy::factory()->rsvp()->state([
+ 'check_in_method' => CheckInMethod::QrCode,
+ ]),
+ 'enrollmentPolicy',
+ )
+ ->create();
+
+ $enrollment = resolve(EnrollUserAction::class)->handle(EnrollUserDTO::fromModels($event, $user));
+
+ expect(QrToken::query()->where('enrollment_id', $enrollment->id)->exists())->toBeTrue();
+});
+
+test('when same token is scanned on different event days, each scan creates a new check-in record', function (): void {
+ $startsAt = now()->setTime(9, 0);
+ [$event, $enrollment, $qrToken] = createEnrollmentWithQrToken([
+ 'starts_at' => $startsAt,
+ 'ends_at' => $startsAt->clone()->addDay()->setTime(18, 0),
+ ]);
+ $organizer = User::factory()->create();
+
+ resolve(QrCheckInAction::class)->handle(
+ new QrCheckInDTO(
+ token: $qrToken->token,
+ event: $event,
+ eventDate: $startsAt,
+ actorUserId: $organizer->id,
+ ),
+ );
+
+ resolve(QrCheckInAction::class)->handle(
+ new QrCheckInDTO(
+ token: $qrToken->token,
+ event: $event,
+ eventDate: $startsAt->clone()->addDay(),
+ actorUserId: $organizer->id,
+ ),
+ );
+
+ expect(CheckIn::query()->where('enrollment_id', $enrollment->id)->count())->toBe(2);
+});
diff --git a/app-modules/events/tests/Unit/EnrollmentStatusTest.php b/app-modules/events/tests/Unit/EnrollmentStatusTest.php
index 35afe8448..646e01988 100644
--- a/app-modules/events/tests/Unit/EnrollmentStatusTest.php
+++ b/app-modules/events/tests/Unit/EnrollmentStatusTest.php
@@ -70,7 +70,9 @@
});
test('when rsvp enrollment status is evaluated, then response message matches status', function (EnrollmentStatus $status, string $expectedKey): void {
- expect($status->getResponseMessage())->toBe(__($expectedKey));
+ $position = 5;
+
+ expect($status->getResponseMessage($position))->toBe(__($expectedKey, ['position' => $position]));
})->with([
[EnrollmentStatus::Confirmed, 'events::pages.confirm_presence_success'],
[EnrollmentStatus::Waitlisted, 'events::pages.waitlist_success'],
diff --git a/app-modules/panel-admin/src/Filament/Resources/Events/Pages/EditEvent.php b/app-modules/panel-admin/src/Filament/Resources/Events/Pages/EditEvent.php
index e92e20c9c..e2b96ffe6 100644
--- a/app-modules/panel-admin/src/Filament/Resources/Events/Pages/EditEvent.php
+++ b/app-modules/panel-admin/src/Filament/Resources/Events/Pages/EditEvent.php
@@ -4,17 +4,85 @@
namespace He4rt\PanelAdmin\Filament\Resources\Events\Pages;
+use Filament\Actions\Action;
use Filament\Actions\DeleteAction;
+use Filament\Forms\Components\TextInput;
+use Filament\Notifications\Notification;
use Filament\Resources\Pages\EditRecord;
+use Filament\Support\Icons\Heroicon;
+use He4rt\Events\CheckIn\Actions\QrCheckInAction;
+use He4rt\Events\CheckIn\DTOs\QrCheckInDTO;
+use He4rt\Events\CheckIn\Exceptions\CheckInException;
+use He4rt\Events\Event\Models\Event;
use He4rt\PanelAdmin\Filament\Resources\Events\EventResource;
+use Livewire\Attributes\On;
+use Throwable;
final class EditEvent extends EditRecord
{
protected static string $resource = EventResource::class;
+ #[On('reopen-scan-qr')]
+ public function reopenScanQrModal(): void
+ {
+ $this->mountAction('scanQr');
+ }
+
protected function getHeaderActions(): array
{
return [
+ Action::make('scanQr')
+ ->label('Scan QR')
+ ->icon(Heroicon::QrCode)
+ ->color('success')
+ ->schema([
+ TextInput::make('token')
+ ->label('QR Token')
+ ->required()
+ ->autofocus()
+ ->placeholder('Scan or paste the participant token'),
+ ])
+ ->modalSubmitActionLabel('Check In')
+ ->action(function (array $data): void {
+ /** @var Event $event */
+ $event = $this->getRecord();
+
+ try {
+ $checkIn = resolve(QrCheckInAction::class)->handle(
+ new QrCheckInDTO(
+ token: $data['token'],
+ event: $event,
+ eventDate: now(),
+ actorUserId: (string) auth()->id(),
+ ),
+ );
+
+ $checkIn->enrollment->loadMissing('user');
+ $participantName = $checkIn->enrollment->user?->name ?? 'Participant';
+
+ Notification::make()
+ ->success()
+ ->title('Check-in successful')
+ ->body($participantName.' has been checked in.')
+ ->send();
+ } catch (CheckInException $e) {
+ Notification::make()
+ ->danger()
+ ->title('Check-in failed')
+ ->body($e->getMessage())
+ ->send();
+ } catch (Throwable $e) {
+ Notification::make()
+ ->danger()
+ ->title('Check-in failed')
+ ->body('An unexpected error occurred. Please try again.')
+ ->send();
+
+ report($e);
+ } finally {
+ $this->dispatch('reopen-scan-qr');
+ }
+ }),
DeleteAction::make(),
];
}
diff --git a/app-modules/panel-admin/src/Filament/Resources/Events/RelationManagers/EnrollmentsRelationManager.php b/app-modules/panel-admin/src/Filament/Resources/Events/RelationManagers/EnrollmentsRelationManager.php
index 778d31fc0..e87498d5b 100644
--- a/app-modules/panel-admin/src/Filament/Resources/Events/RelationManagers/EnrollmentsRelationManager.php
+++ b/app-modules/panel-admin/src/Filament/Resources/Events/RelationManagers/EnrollmentsRelationManager.php
@@ -7,6 +7,8 @@
use Filament\Actions\Action;
use Filament\Actions\BulkAction;
use Filament\Actions\BulkActionGroup;
+use Filament\Actions\DeleteAction;
+use Filament\Actions\DeleteBulkAction;
use Filament\Forms\Components\DatePicker;
use Filament\Notifications\Notification;
use Filament\Resources\RelationManagers\RelationManager;
@@ -28,11 +30,6 @@ final class EnrollmentsRelationManager extends RelationManager
{
protected static string $relationship = 'enrollments';
- public function isReadOnly(): bool
- {
- return true;
- }
-
public function table(Table $table): Table
{
return $table
@@ -87,10 +84,12 @@ public function table(Table $table): Table
])
->recordActions([
$this->checkInAction(),
+ DeleteAction::make(),
])
->toolbarActions([
BulkActionGroup::make([
$this->bulkCheckInAction(),
+ DeleteBulkAction::make(),
]),
]);
}
diff --git a/app-modules/panel-app/resources/views/livewire/events/event-detail.blade.php b/app-modules/panel-app/resources/views/livewire/events/event-detail.blade.php
index 5b6d6d719..6dbe8929e 100644
--- a/app-modules/panel-app/resources/views/livewire/events/event-detail.blade.php
+++ b/app-modules/panel-app/resources/views/livewire/events/event-detail.blade.php
@@ -47,13 +47,107 @@
@endif
+ @if ($this->qrToken)
+
+
+
+ {{ __('events::pages.my_qr_code') }}
+
+
+ @if ($this->hasCheckedInToday)
+
+ {{ __('events::pages.checked_in_today') }}
+
+ @endif
+
+
+
{!! $this->qrCodeSvg !!}
+
+
+ {{ __('events::pages.qr_code_hint') }}
+
+
+
+
+ {{ __('events::pages.copy_token') }}
+ {{ __('events::pages.token_copied') }}
+
+
+
+ {{ __('events::pages.download_qr') }}
+
+
+
+ @endif
+ @if ($this->checkIns->isNotEmpty())
+
+
+ {{ __('events::pages.check_in_history') }}
+
+
+
+ @foreach ($this->checkIns as $checkIn)
+ -
+
+ {{ $checkIn->event_date->format('d/m/Y') }}
+
+ @endforeach
+
+
+ @endif
@elseif ($this->canConfirmPresence)
-
+
{{ __('events::pages.confirm_presence_hint') }}
-
- {{ __('events::pages.confirm_presence') }}
-
+
+
+ {{ __('events::pages.confirm_presence') }}
+
+
+
+
+
+ {{ __('events::pages.confirm_presence_prompt') }}
+
+
+
+
+ {{ __('events::pages.confirm_presence_yes') }}
+
+
+
+ {{ __('events::pages.confirm_presence_cancel') }}
+
+
+
@elseif ($this->isEventFull)
diff --git a/app-modules/panel-app/src/Livewire/Events/EventDetail.php b/app-modules/panel-app/src/Livewire/Events/EventDetail.php
index 5d56b6534..a2f3750b1 100644
--- a/app-modules/panel-app/src/Livewire/Events/EventDetail.php
+++ b/app-modules/panel-app/src/Livewire/Events/EventDetail.php
@@ -4,16 +4,25 @@
namespace He4rt\PanelApp\Livewire\Events;
+use BaconQrCode\Renderer\Image\SvgImageBackEnd;
+use BaconQrCode\Renderer\ImageRenderer;
+use BaconQrCode\Renderer\RendererStyle\RendererStyle;
+use BaconQrCode\Writer;
use Filament\Notifications\Notification;
+use He4rt\Events\CheckIn\Enums\CheckInMethod;
+use He4rt\Events\CheckIn\Models\CheckIn;
+use He4rt\Events\CheckIn\Models\QrToken;
use He4rt\Events\Enrollment\Actions\EnrollUserAction;
use He4rt\Events\Enrollment\DTOs\EnrollUserDTO;
use He4rt\Events\Enrollment\Enums\EnrollmentMethod;
+use He4rt\Events\Enrollment\Enums\EnrollmentStatus;
use He4rt\Events\Enrollment\Exceptions\EnrollmentException;
use He4rt\Events\Enrollment\Models\Enrollment;
use He4rt\Events\Event\Enums\EventStatus;
use He4rt\Events\Event\Models\Event;
use He4rt\Identity\User\Models\User;
use Illuminate\Contracts\View\View;
+use Illuminate\Support\Collection;
use Livewire\Attributes\Computed;
use Livewire\Component;
@@ -41,11 +50,64 @@ public function event(): Event
public function enrollment(): ?Enrollment
{
return Enrollment::query()
+ ->with('qrToken')
->where('event_id', $this->eventId)
->where('user_id', auth()->id())
->first();
}
+ #[Computed]
+ public function qrToken(): ?QrToken
+ {
+ if ($this->enrollment === null) {
+ return null;
+ }
+
+ if (!in_array($this->enrollment->status, [EnrollmentStatus::Confirmed, EnrollmentStatus::CheckedIn], strict: true)) {
+ return null;
+ }
+
+ if ($this->event->enrollmentPolicy?->check_in_method !== CheckInMethod::QrCode) {
+ return null;
+ }
+
+ return $this->enrollment->qrToken;
+ }
+
+ #[Computed]
+ public function qrCodeSvg(): ?string
+ {
+ if ($this->qrToken === null) {
+ return null;
+ }
+
+ $writer = new Writer(new ImageRenderer(new RendererStyle(200), new SvgImageBackEnd()));
+
+ return $writer->writeString($this->qrToken->token);
+ }
+
+ /** @return Collection
*/
+ #[Computed]
+ public function checkIns(): Collection
+ {
+ if ($this->enrollment === null) {
+ return collect();
+ }
+
+ return CheckIn::query()
+ ->where('enrollment_id', $this->enrollment->id)
+ ->oldest('event_date')
+ ->get();
+ }
+
+ #[Computed]
+ public function hasCheckedInToday(): bool
+ {
+ return $this->checkIns->contains(
+ fn (CheckIn $c): bool => $c->event_date->isToday(),
+ );
+ }
+
#[Computed]
public function canConfirmPresence(): bool
{
diff --git a/composer.json b/composer.json
index c1de77519..96dc42aa8 100644
--- a/composer.json
+++ b/composer.json
@@ -9,6 +9,7 @@
"license": "MIT",
"require": {
"php": "^8.4",
+ "bacon/bacon-qr-code": "^2.0",
"calebporzio/sushi": "^2.5.4",
"dedoc/scramble": "^0.13.26",
"filament/filament": "^5.6.6",
From c08f5042ec5b9f2e1f156796e727713d18cef97c Mon Sep 17 00:00:00 2001
From: danielhe4rt
Date: Thu, 4 Jun 2026 18:05:19 -0300
Subject: [PATCH 08/18] pint + deps
---
.../CheckIn/Exceptions/CheckInException.php | 1 +
app/Providers/Filament/AppPanelProvider.php | 2 +-
composer.json | 4 +-
composer.lock | 116 +++++++++++++++++-
4 files changed, 114 insertions(+), 9 deletions(-)
diff --git a/app-modules/events/src/CheckIn/Exceptions/CheckInException.php b/app-modules/events/src/CheckIn/Exceptions/CheckInException.php
index f379eaa0c..561fe6d7d 100644
--- a/app-modules/events/src/CheckIn/Exceptions/CheckInException.php
+++ b/app-modules/events/src/CheckIn/Exceptions/CheckInException.php
@@ -56,6 +56,7 @@ public static function qrTokenExpired(): self
Response::HTTP_UNPROCESSABLE_ENTITY,
);
}
+
public static function invalidCheckInCode(): self
{
return new self(
diff --git a/app/Providers/Filament/AppPanelProvider.php b/app/Providers/Filament/AppPanelProvider.php
index ea8894985..215770f23 100644
--- a/app/Providers/Filament/AppPanelProvider.php
+++ b/app/Providers/Filament/AppPanelProvider.php
@@ -15,8 +15,8 @@
use He4rt\Identity\Tenant\Models\Tenant;
use He4rt\PanelApp\Pages\EventPage;
use He4rt\PanelApp\Pages\EventsPage;
-use He4rt\PanelApp\Pages\MyEventsPage;
use He4rt\PanelApp\Pages\LoginPage;
+use He4rt\PanelApp\Pages\MyEventsPage;
use He4rt\PanelApp\Pages\ProfilePage;
use He4rt\PanelApp\Pages\ThreadPage;
use He4rt\PanelApp\Pages\TimelinePage;
diff --git a/composer.json b/composer.json
index 96dc42aa8..a97617fbb 100644
--- a/composer.json
+++ b/composer.json
@@ -9,7 +9,7 @@
"license": "MIT",
"require": {
"php": "^8.4",
- "bacon/bacon-qr-code": "^2.0",
+ "bacon/bacon-qr-code": "^2.0.8",
"calebporzio/sushi": "^2.5.4",
"dedoc/scramble": "^0.13.26",
"filament/filament": "^5.6.6",
@@ -35,7 +35,7 @@
"he4rt/profile": ">=1",
"internachi/modular": "^3.0.2",
"laracord/framework": "dev-next#e7b64d6",
- "laravel/framework": "^13.13.0",
+ "laravel/framework": "^13.14.0",
"laravel/nightwatch": "^1.28.0",
"laravel/sanctum": "^4.3.2",
"laravel/telescope": "^5.20.0",
diff --git a/composer.lock b/composer.lock
index cbfe501c4..be96f6d5c 100644
--- a/composer.lock
+++ b/composer.lock
@@ -4,8 +4,62 @@
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
"This file is @generated automatically"
],
- "content-hash": "36167e0f649a4d5090ff6da3d5f7b4de",
+ "content-hash": "ba8ec9c9c3c06e4f2f5e08b7c40e35d4",
"packages": [
+ {
+ "name": "bacon/bacon-qr-code",
+ "version": "2.0.8",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/Bacon/BaconQrCode.git",
+ "reference": "8674e51bb65af933a5ffaf1c308a660387c35c22"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/Bacon/BaconQrCode/zipball/8674e51bb65af933a5ffaf1c308a660387c35c22",
+ "reference": "8674e51bb65af933a5ffaf1c308a660387c35c22",
+ "shasum": ""
+ },
+ "require": {
+ "dasprid/enum": "^1.0.3",
+ "ext-iconv": "*",
+ "php": "^7.1 || ^8.0"
+ },
+ "require-dev": {
+ "phly/keep-a-changelog": "^2.1",
+ "phpunit/phpunit": "^7 | ^8 | ^9",
+ "spatie/phpunit-snapshot-assertions": "^4.2.9",
+ "squizlabs/php_codesniffer": "^3.4"
+ },
+ "suggest": {
+ "ext-imagick": "to generate QR code images"
+ },
+ "type": "library",
+ "autoload": {
+ "psr-4": {
+ "BaconQrCode\\": "src/"
+ }
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "BSD-2-Clause"
+ ],
+ "authors": [
+ {
+ "name": "Ben Scholzen 'DASPRiD'",
+ "email": "mail@dasprids.de",
+ "homepage": "https://dasprids.de/",
+ "role": "Developer"
+ }
+ ],
+ "description": "BaconQrCode is a QR code generator for PHP.",
+ "homepage": "https://github.com/Bacon/BaconQrCode",
+ "support": {
+ "issues": "https://github.com/Bacon/BaconQrCode/issues",
+ "source": "https://github.com/Bacon/BaconQrCode/tree/2.0.8"
+ },
+ "time": "2022-12-07T17:46:57+00:00"
+ },
{
"name": "blade-ui-kit/blade-heroicons",
"version": "2.7.0",
@@ -1227,6 +1281,56 @@
],
"time": "2026-03-16T11:29:23+00:00"
},
+ {
+ "name": "dasprid/enum",
+ "version": "1.0.7",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/DASPRiD/Enum.git",
+ "reference": "b5874fa9ed0043116c72162ec7f4fb50e02e7cce"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/DASPRiD/Enum/zipball/b5874fa9ed0043116c72162ec7f4fb50e02e7cce",
+ "reference": "b5874fa9ed0043116c72162ec7f4fb50e02e7cce",
+ "shasum": ""
+ },
+ "require": {
+ "php": ">=7.1 <9.0"
+ },
+ "require-dev": {
+ "phpunit/phpunit": "^7 || ^8 || ^9 || ^10 || ^11",
+ "squizlabs/php_codesniffer": "*"
+ },
+ "type": "library",
+ "autoload": {
+ "psr-4": {
+ "DASPRiD\\Enum\\": "src/"
+ }
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "BSD-2-Clause"
+ ],
+ "authors": [
+ {
+ "name": "Ben Scholzen 'DASPRiD'",
+ "email": "mail@dasprids.de",
+ "homepage": "https://dasprids.de/",
+ "role": "Developer"
+ }
+ ],
+ "description": "PHP 7.1 enum implementation",
+ "keywords": [
+ "enum",
+ "map"
+ ],
+ "support": {
+ "issues": "https://github.com/DASPRiD/Enum/issues",
+ "source": "https://github.com/DASPRiD/Enum/tree/1.0.7"
+ },
+ "time": "2025-09-16T12:23:56+00:00"
+ },
{
"name": "dedoc/scramble",
"version": "v0.13.26",
@@ -4129,16 +4233,16 @@
},
{
"name": "laravel/framework",
- "version": "v13.13.0",
+ "version": "v13.14.0",
"source": {
"type": "git",
"url": "https://github.com/laravel/framework.git",
- "reference": "1daa6d3b4defe46976ccfa4fb0a7ab62717712a2"
+ "reference": "e60b1c817a9ef7da319e4007de6cfda5301a58c0"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/laravel/framework/zipball/1daa6d3b4defe46976ccfa4fb0a7ab62717712a2",
- "reference": "1daa6d3b4defe46976ccfa4fb0a7ab62717712a2",
+ "url": "https://api.github.com/repos/laravel/framework/zipball/e60b1c817a9ef7da319e4007de6cfda5301a58c0",
+ "reference": "e60b1c817a9ef7da319e4007de6cfda5301a58c0",
"shasum": ""
},
"require": {
@@ -4349,7 +4453,7 @@
"issues": "https://github.com/laravel/framework/issues",
"source": "https://github.com/laravel/framework"
},
- "time": "2026-06-02T14:28:17+00:00"
+ "time": "2026-06-04T18:46:35+00:00"
},
{
"name": "laravel/nightwatch",
From 30ef42b3d2b83e12f07d1eab117c08cc9a6d6afd Mon Sep 17 00:00:00 2001
From: danielhe4rt
Date: Thu, 4 Jun 2026 18:13:03 -0300
Subject: [PATCH 09/18] fix(events): resolve phpstan errors in check-in UI
- 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).
---
.../src/Filament/Resources/Events/Pages/EditEvent.php | 2 +-
.../panel-app/src/Livewire/Events/EventDetail.php | 10 ++++++++++
.../src/Livewire/Events/NumericCodeCheckIn.php | 4 ++++
3 files changed, 15 insertions(+), 1 deletion(-)
diff --git a/app-modules/panel-admin/src/Filament/Resources/Events/Pages/EditEvent.php b/app-modules/panel-admin/src/Filament/Resources/Events/Pages/EditEvent.php
index e2b96ffe6..bc809978d 100644
--- a/app-modules/panel-admin/src/Filament/Resources/Events/Pages/EditEvent.php
+++ b/app-modules/panel-admin/src/Filament/Resources/Events/Pages/EditEvent.php
@@ -58,7 +58,7 @@ protected function getHeaderActions(): array
);
$checkIn->enrollment->loadMissing('user');
- $participantName = $checkIn->enrollment->user?->name ?? 'Participant';
+ $participantName = $checkIn->enrollment->user->name ?? 'Participant';
Notification::make()
->success()
diff --git a/app-modules/panel-app/src/Livewire/Events/EventDetail.php b/app-modules/panel-app/src/Livewire/Events/EventDetail.php
index a2f3750b1..20df4f220 100644
--- a/app-modules/panel-app/src/Livewire/Events/EventDetail.php
+++ b/app-modules/panel-app/src/Livewire/Events/EventDetail.php
@@ -26,6 +26,16 @@
use Livewire\Attributes\Computed;
use Livewire\Component;
+/**
+ * @property-read Event $event
+ * @property-read Enrollment|null $enrollment
+ * @property-read QrToken|null $qrToken
+ * @property-read string|null $qrCodeSvg
+ * @property-read Collection $checkIns
+ * @property-read bool $hasCheckedInToday
+ * @property-read bool $canConfirmPresence
+ * @property-read bool $isEventFull
+ */
final class EventDetail extends Component
{
public string $eventId;
diff --git a/app-modules/panel-app/src/Livewire/Events/NumericCodeCheckIn.php b/app-modules/panel-app/src/Livewire/Events/NumericCodeCheckIn.php
index abbe51bae..f63d212d3 100644
--- a/app-modules/panel-app/src/Livewire/Events/NumericCodeCheckIn.php
+++ b/app-modules/panel-app/src/Livewire/Events/NumericCodeCheckIn.php
@@ -18,6 +18,10 @@
use Livewire\Attributes\Computed;
use Livewire\Component;
+/**
+ * @property-read Enrollment|null $enrollment
+ * @property-read bool $canCheckIn
+ */
final class NumericCodeCheckIn extends Component
{
public string $eventId;
From d9a7f098fe57435c537edf0b0eab25b5ef45a6f2 Mon Sep 17 00:00:00 2001
From: danielhe4rt
Date: Thu, 4 Jun 2026 18:31:11 -0300
Subject: [PATCH 10/18] fix(events): correct tenant_id FK to UUID and unblock
test suite
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.
---
.../migrations/2026_05_16_200001_create_events_table.php | 2 +-
app-modules/events/src/CheckIn/Models/CheckIn.php | 2 +-
app-modules/events/tests/Feature/EnrollUserActionTest.php | 3 ---
app-modules/events/tests/Feature/EventResourceTest.php | 8 ++++++--
4 files changed, 8 insertions(+), 7 deletions(-)
diff --git a/app-modules/events/database/migrations/2026_05_16_200001_create_events_table.php b/app-modules/events/database/migrations/2026_05_16_200001_create_events_table.php
index 6a481c803..5226d8392 100644
--- a/app-modules/events/database/migrations/2026_05_16_200001_create_events_table.php
+++ b/app-modules/events/database/migrations/2026_05_16_200001_create_events_table.php
@@ -12,7 +12,7 @@ public function up(): void
{
Schema::create('events', function (Blueprint $table): void {
$table->uuid('id')->primary();
- $table->foreignId('tenant_id')->nullable()->constrained('tenants')->nullOnDelete();
+ $table->foreignUuid('tenant_id')->nullable()->constrained('tenants')->nullOnDelete();
$table->string('slug', 120);
$table->string('title', 200);
$table->longText('description')->nullable();
diff --git a/app-modules/events/src/CheckIn/Models/CheckIn.php b/app-modules/events/src/CheckIn/Models/CheckIn.php
index 9c7c146dd..3ab2e6f72 100644
--- a/app-modules/events/src/CheckIn/Models/CheckIn.php
+++ b/app-modules/events/src/CheckIn/Models/CheckIn.php
@@ -20,7 +20,7 @@
* @property Carbon $event_date
* @property CheckInMethod $method
* @property array|null $payload
- * @property Carbon $checked_in_at
+ * @property Carbon|null $checked_in_at
* @property Carbon $created_at
* @property Carbon $updated_at
*/
diff --git a/app-modules/events/tests/Feature/EnrollUserActionTest.php b/app-modules/events/tests/Feature/EnrollUserActionTest.php
index 61b8b0137..1be9dff4e 100644
--- a/app-modules/events/tests/Feature/EnrollUserActionTest.php
+++ b/app-modules/events/tests/Feature/EnrollUserActionTest.php
@@ -17,12 +17,9 @@
use He4rt\Events\Event\Models\Event;
use He4rt\Identity\Tenant\Models\Tenant;
use He4rt\Identity\User\Models\User;
-use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Event as EventFacade;
use Symfony\Component\HttpFoundation\Response;
-uses(RefreshDatabase::class);
-
function createRsvpEvent(Tenant $tenant, array $eventAttributes = [], array $policyAttributes = []): Event
{
return Event::factory()
diff --git a/app-modules/events/tests/Feature/EventResourceTest.php b/app-modules/events/tests/Feature/EventResourceTest.php
index b3c66e954..229254d9d 100644
--- a/app-modules/events/tests/Feature/EventResourceTest.php
+++ b/app-modules/events/tests/Feature/EventResourceTest.php
@@ -34,6 +34,8 @@
Filament::setCurrentPanel(Filament::getPanel('admin'));
Filament::setTenant($tenant);
+
+ $this->tenant = $tenant;
});
test('when visiting the events list page, then it renders successfully', function (): void {
@@ -42,10 +44,11 @@
});
test('when an event exists, then it appears in the events list', function (): void {
- $event = Event::factory()->create(['title' => 'He4rt Meetup #42']);
+ $event = Event::factory()->recycle($this->tenant)->create(['title' => 'He4rt Meetup #42']);
livewire(ListEvents::class)
- ->assertSee($event->title);
+ ->loadTable()
+ ->assertCanSeeTableRecords([$event]);
});
test('when visiting the create event page, then it renders successfully', function (): void {
@@ -247,6 +250,7 @@
'ownerRecord' => $event,
'pageClass' => EditEvent::class,
])
+ ->loadTable()
->assertSee($startsAt->toDateString())
->assertSee($startsAt->clone()->addDay()->toDateString());
});
From c2fa725badddf11f4c795e11b71521ea641d652b Mon Sep 17 00:00:00 2001
From: Davi Castello Branco Tavares de Oliveira
Date: Sun, 7 Jun 2026 14:03:35 -0400
Subject: [PATCH 11/18] feat(events): event closure job and admin status
override (#312)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
## Summary
Implements automated event closure and admin enrollment status override.
After an event's `ends_at`, a scheduled job resolves all non-terminal
enrollments to `attended` or `no_show`. Organizers can manually override
`no_show → attended` or `confirmed → checked_in` via the admin panel
with a required reason.
Closes #247
---
## What changed
### Event closure pipeline
| File | Role |
|---|---|
| `src/Closure/Actions/CloseEventAction.php` | Core loop: per-enrollment
`DB::transaction` with `lockForUpdate()`. Resolves checked-in
enrollments via `EnrollmentPolicy::resolveAttendance()`, confirmed →
`no_show`. Dispatches `ParticipantAttended` on success. Idempotent —
skips terminal enrollments. |
| `src/Closure/Jobs/ProcessEventClosureJob.php` | `ShouldBeUnique` per
`eventId` (30min lock), 4 tries, backoff `[1,5,10]`. Calls
`CloseEventAction`. |
| `src/Console/Commands/ClosePendingEventsCommand.php` |
`events:close-pending` — queries past events with non-terminal
enrollments, dispatches one job per event. |
| `src/EventsServiceProvider.php` | Schedules command every 15 min with
`withoutOverlapping()`. |
| `src/Closure/Events/ParticipantAttended.php` | Domain event consumed
by gamification. `ShouldDispatchAfterCommit`. Payload: enrollmentId,
eventId, userId, xpRewardOnAttended. |
### Admin status override
| File | Role |
|---|---|
| `src/Closure/Actions/OverrideEnrollmentStatusAction.php` | Separate
action from `TransitionEnrollmentAction`. Allowlist: `no_show→attended`,
`confirmed→checked_in`. Reason required. `triggered_by=admin` in audit
trail. No domain event re-dispatch. |
| `src/Closure/DTOs/OverrideEnrollmentStatusDTO.php` | Input DTO:
enrollment, toStatus, actorId, reason. |
| `src/Closure/Exceptions/OverrideEnrollmentStatusException.php` |
`reasonRequired()`, `overrideNotAllowed(from, to)`. |
| `panel-admin/…/EnrollmentsRelationManager.php` |
`overrideStatusAction()` — warning-colored action visible for
no_show/confirmed. Modal with to_status select (dynamically populated
from the Action's allowlist) + reason textarea. |
### EnrollmentPolicy improvements
| File | Role |
|---|---|
| `src/Enrollment/Models/EnrollmentPolicy.php` |
`resolveAttendance(Enrollment): EnrollmentStatus` — counts check-in days
against `attendance_requirement` (all_days, any_day, minimum_days).
Returns `Attended` or `NoShow`. |
| `src/Enrollment/Enums/EnrollmentStatus.php` | Removed unused
`getIcon()` + `HasIcon` interface — dead code. |
### EventForm (panel-admin)
| File | Role |
|---|---|
| `panel-admin/…/EventForm.php` | Attendance requirement fields: radio
for requirement type, conditional minimum_days slider. Hidden for
single-day events. |
### Architecture improvements
- Override allowlist now exposed as
`OverrideEnrollmentStatusAction::allowedTargetsFor(EnrollmentStatus):
list` — single source of truth consumed by both
validation and UI. `EnrollmentsRelationManager` no longer hardcodes
allowed transitions.
---
## ADRs
- `app-modules/events/docs/adr/0007-event-closure-as-scheduled-job.md`
-
`app-modules/events/docs/adr/0008-admin-status-override-as-separate-action.md`
---
## Test plan
- **CloseEventActionTest** (6 tests): all_days requirement, any_day,
no-show, idempotency, multi-enrollment
- **ClosePendingEventsCommandTest** (4 tests): past/future event job
dispatch, multiple events
- **ProcessEventClosureJobTest** (5 tests): closure, config
(tries/backoff/uniqueFor via reflection), non-existent event, failure
logging
- **OverrideEnrollmentStatusActionTest** (8 tests): allowed transitions,
reason required, invalid pairs, `allowedTargetsFor()`
- **EnrollmentPolicyTest** (5 tests): all_days, any_day, minimum_days
- **EventResourceTest**: EventForm attendance requirement fields
- [x] PHPStan: 0 errors
- [x] Pint: clean
---
## Commits
| SHA | Message |
|---|---|
| `0ec3d42b` | `docs(events): adrs and context map` |
| `a2e0a803` | `feat(events): module structure (#249)` |
| `779f7598` | `feat(events): rsvp enrollment end-to-end (#275)` |
| `f631ea47` | `feat(events): waitlist and capacity enforcement (#294)`
|
| `a51f4b7b` | `feat(events): numeric code check-in (#297)` |
| `8b3d3c41` | `feat(events): qr code check-in (#298)` |
| `c08f5042` | `pint + deps` |
| `30ef42b3` | `fix(events): resolve phpstan errors in check-in UI` |
| `d9a7f098` | `fix(events): correct tenant_id FK to UUID` |
| `022f471d` | `feat(enrollment): resolveAttendance()` |
| `da47e5c1` | `feat(panel_admin): override EnrollmentStatus` |
| `747dc31d` | `feat(events): schedule ClosePendingEventsCommand` |
| `4f6c111f` | `chore(exceptions): translations` |
| `d47fe97a` | `docs(adr): ADRs for issue #247` |
| `36ca23b4` | `feat(events): close-pending command` |
| `325d2840` | `feat(panel-admin): attendance requirements on EventForm`
|
| `5d389511` | `feat(events): EnrollmentPolicyFactory fields` |
| `0fdf456e` | `feat(events): CloseEventAction` |
| `87e959a6` | `feat(events): OverrideEnrollmentStatusAction` |
| `821ef139` | `feat(events): OverrideEnrollmentStatusDTO` |
| `534a2840` | `feat(events): ProcessEventClosureJob` |
| `ed4b5b60` | `feat(events): OverrideEnrollment exceptions` |
| `f9dfa7fd` | `feat(events): ParticipantAttended event + tests` |
| `601efcca` | `chore(deps): package-lock.json` |
| `2e5f05fb` | `test(events): job config via reflection` |
| `4000f2ee` | `refactor(events): expose allowlist as queryable method`
|
| `06703c7b` | `chore(events): remove unused getIcon()` |
---
.../factories/EnrollmentPolicyFactory.php | 6 +-
.../0007-event-closure-as-scheduled-job.md | 52 ++++
...dmin-status-override-as-separate-action.md | 69 +++++
app-modules/events/lang/en/exceptions.php | 3 +
app-modules/events/lang/pt_BR/exceptions.php | 3 +
.../src/Closure/Actions/CloseEventAction.php | 76 ++++++
.../OverrideEnrollmentStatusAction.php | 80 ++++++
.../DTOs/OverrideEnrollmentStatusDTO.php | 27 ++
.../Closure/Events/ParticipantAttended.php | 21 ++
.../OverrideEnrollmentStatusException.php | 39 +++
.../Closure/Jobs/ProcessEventClosureJob.php | 56 ++++
.../Commands/ClosePendingEventsCommand.php | 38 +++
.../src/Enrollment/Enums/EnrollmentStatus.php | 18 +-
.../Enrollment/Models/EnrollmentPolicy.php | 14 +
.../events/src/EventsServiceProvider.php | 13 +
.../Feature/Closure/CloseEventActionTest.php | 247 ++++++++++++++++++
.../Closure/ClosePendingEventsCommandTest.php | 108 ++++++++
.../OverrideEnrollmentStatusActionTest.php | 203 ++++++++++++++
.../Closure/ProcessEventClosureJobTest.php | 102 ++++++++
.../Enrollment/EnrollmentPolicyTest.php | 142 ++++++++++
.../tests/Feature/EventResourceTest.php | 42 ++-
.../OverrideEnrollmentStatusAction.php | 62 +++++
.../EnrollmentsRelationManager.php | 2 +
.../Resources/Events/Schemas/EventForm.php | 54 +++-
package-lock.json | 9 +-
25 files changed, 1454 insertions(+), 32 deletions(-)
create mode 100644 app-modules/events/docs/adr/0007-event-closure-as-scheduled-job.md
create mode 100644 app-modules/events/docs/adr/0008-admin-status-override-as-separate-action.md
create mode 100644 app-modules/events/src/Closure/Actions/CloseEventAction.php
create mode 100644 app-modules/events/src/Closure/Actions/OverrideEnrollmentStatusAction.php
create mode 100644 app-modules/events/src/Closure/DTOs/OverrideEnrollmentStatusDTO.php
create mode 100644 app-modules/events/src/Closure/Events/ParticipantAttended.php
create mode 100644 app-modules/events/src/Closure/Exceptions/OverrideEnrollmentStatusException.php
create mode 100644 app-modules/events/src/Closure/Jobs/ProcessEventClosureJob.php
create mode 100644 app-modules/events/src/Console/Commands/ClosePendingEventsCommand.php
create mode 100644 app-modules/events/tests/Feature/Closure/CloseEventActionTest.php
create mode 100644 app-modules/events/tests/Feature/Closure/ClosePendingEventsCommandTest.php
create mode 100644 app-modules/events/tests/Feature/Closure/OverrideEnrollmentStatusActionTest.php
create mode 100644 app-modules/events/tests/Feature/Closure/ProcessEventClosureJobTest.php
create mode 100644 app-modules/events/tests/Feature/Enrollment/EnrollmentPolicyTest.php
create mode 100644 app-modules/panel-admin/src/Filament/Resources/Events/RelationManagers/Actions/OverrideEnrollmentStatusAction.php
diff --git a/app-modules/events/database/factories/EnrollmentPolicyFactory.php b/app-modules/events/database/factories/EnrollmentPolicyFactory.php
index 45add3dfc..b4b771f71 100644
--- a/app-modules/events/database/factories/EnrollmentPolicyFactory.php
+++ b/app-modules/events/database/factories/EnrollmentPolicyFactory.php
@@ -18,14 +18,16 @@ final class EnrollmentPolicyFactory extends Factory
public function definition(): array
{
+ $requirement = fake()->randomElement(AttendanceRequirement::cases());
+
return [
'event_id' => Event::factory(),
'enrollment_method' => fake()->randomElement(EnrollmentMethod::cases()),
'check_in_method' => fake()->randomElement(CheckInMethod::cases()),
'capacity' => fake()->optional()->numberBetween(10, 200),
'has_waitlist' => false,
- 'attendance_requirement' => AttendanceRequirement::AllDays,
- 'minimum_days' => null,
+ 'attendance_requirement' => $requirement,
+ 'minimum_days' => $requirement === AttendanceRequirement::MinimumDays ? 1 : null,
'cancellation_deadline_hours' => null,
'xp_on_confirmed' => 0,
'xp_on_checked_in' => 0,
diff --git a/app-modules/events/docs/adr/0007-event-closure-as-scheduled-job.md b/app-modules/events/docs/adr/0007-event-closure-as-scheduled-job.md
new file mode 100644
index 000000000..431d29652
--- /dev/null
+++ b/app-modules/events/docs/adr/0007-event-closure-as-scheduled-job.md
@@ -0,0 +1,52 @@
+# ADR-0007: Event closure as scheduled job
+
+## Status
+
+Accepted — 2026-06-04
+
+## Context
+
+After an event's `ends_at`, enrollments must be resolved to terminal states: `attended` (success, when `attendance_requirement` is satisfied per the policy) or `no_show` (failure, when not). Without automated closure, enrollments stay in `confirmed` or `checked_in` indefinitely, blocking `ParticipantAttended` dispatch and the XP reward that Gamification awards on that event (ADR-0002). Manual organizer action does not scale, and real-time closure on `ends_at` is fragile (clock drift, job queue downtime, multi-day events whose `ends_at` is the final day).
+
+The closure step is also the natural place to mark `confirmed` participants who never checked in as `no_show`. This is a system-driven consequence, not an organizer decision.
+
+## Decision
+
+### Closure is a post-event scheduled sweep
+
+A scheduled job runs after events end and resolves every non-terminal enrollment to a terminal state. This is decoupled from any real-time trigger on `ends_at` so that queue downtime, clock drift, or off-by-one date math cannot leave enrollments stranded.
+
+### Per-event job granularity
+
+`ProcessEventClosureJob` handles one event at a time (constructor takes `string $eventId`). The job implements `ShouldQueue` and `ShouldBeUnique` with `uniqueId = $eventId` and `uniqueFor = 1800` seconds. This means concurrent dispatches for the same event collapse into a single execution, and the lock covers the full retry window (backoff sum ≈ 16 minutes, plus buffer).
+
+`$backoff = [1, 5, 10]` and `tries = 4` (1 initial + 3 retries). After exhaustion, `failed()` runs and logs the `event_id` plus the exception message.
+
+### Per-enrollment transactions, not per-event
+
+`CloseEventAction::handle(Event $event): int` opens one `DB::transaction` per enrollment inside its loop, not one transaction wrapping the entire event. This is a deliberate departure from the "all-or-nothing per operation" default in favour of **partial commits that preserve audit trails on failure**.
+
+The loop is:
+
+1. Re-load the enrollment with `lockForUpdate()`.
+2. If the enrollment is already terminal (`isTerminal()` returns true — e.g. a previous attempt succeeded, or an admin override landed between attempts), skip it.
+3. Resolve the target status: `EnrollmentPolicy::resolveAttendance($enrollment)` for `checked_in` (returns `Attended` or `NoShow`), or `NoShow` directly for `confirmed`.
+4. Call the existing `TransitionEnrollmentAction` (ADR-0001, ADR-0003) with `triggered_by = System`. This writes the audit row and updates the status + timestamp atomically.
+5. If the target is `Attended`, dispatch `ParticipantAttended` (mirroring `ParticipantCheckedIn` payload shape — IDs, not models).
+
+Idempotency is therefore structural: the `isTerminal()` re-check + `canTransitionTo()` guard + `lockForUpdate()` make a second run a no-op for already-closed enrollments.
+
+### Discovery via artisan command
+
+A `ClosePendingEventsCommand` (`events:close-pending`) queries `Event` where `ends_at < now()` AND has at least one enrollment in `confirmed` or `checked_in`, then dispatches one `ProcessEventClosureJob` per match. Scheduled every 15 minutes in `EventsServiceProvider::boot()` via `Schedule::command(...)->everyFifteenMinutes()->withoutOverlapping()`.
+
+This is the same pattern `IntegrationDevToServiceProvider` uses for its own scheduled command, so the discovery mechanism is testable (`Artisan::call`) and manually runnable.
+
+## Consequences
+
+- **Audit trail on partial failure** — if the 3rd enrollment in a 100-enrollment event throws mid-loop, the first two keep their `EnrollmentTransition` records. The retry can pick up from the 3rd without re-doing work or losing history.
+- **Concurrency-safe** — `ShouldBeUnique` prevents two jobs processing the same event, even if the scheduler fires twice in a race.
+- **Retry-friendly** — completed enrollments are skipped via the `isTerminal()` re-check, so retries do not re-dispatch `ParticipantAttended` or write duplicate transitions.
+- **Worst-case latency** — `ends_at` to closure is at most 15 minutes (one scheduler tick) plus job queue latency. Acceptable for terminal-state assignment, which is not user-facing in real time.
+- **Trade-off** — a 3-retry ceiling means persistent infrastructure failures (DB outage spanning >16 minutes) end up in the `failed()` log instead of auto-recovery. The command will redispatch on the next scheduler tick, so eventual closure is still likely without human intervention.
+- **Trade-off** — the 15-minute cadence means a `ParticipantAttended` event may fire up to 15 minutes after the event ends. Gamification's XP grant follows the same window. Documented in `EnrollmentPolicy::$xp_on_attended` consumers (Gamification listeners).
diff --git a/app-modules/events/docs/adr/0008-admin-status-override-as-separate-action.md b/app-modules/events/docs/adr/0008-admin-status-override-as-separate-action.md
new file mode 100644
index 000000000..33df28589
--- /dev/null
+++ b/app-modules/events/docs/adr/0008-admin-status-override-as-separate-action.md
@@ -0,0 +1,69 @@
+# ADR-0008: Admin status override as separate Action
+
+## Status
+
+Accepted — 2026-06-04
+
+## Context
+
+The enrollment state machine (ADR-0001) blocks all transitions out of terminal states. This is correct for the normal lifecycle: an `attended` enrollment should never revert to `checked_in`, and `no_show` should never silently become `attended`. But real-world organizer needs include:
+
+- A participant marked `no_show` who actually attended (organizer missed a check-in, code expired, manual check-in failed silently).
+- A late arrival who confirmed but missed the formal check-in window and the organizer wants to mark them `checked_in` to record the presence.
+
+These are **admin corrections**, not normal lifecycle transitions. They need a different validation surface, a stronger audit story, and a clear rule that they bypass the state machine for a tightly-scoped set of cases.
+
+## Decision
+
+### Override is a separate Action
+
+Introduce `OverrideEnrollmentStatusAction` as a sibling to `TransitionEnrollmentAction`, not a force-flag on it. The two actions have different validation, different intent, and different side effects. A force flag on `TransitionEnrollmentAction` would have to be threaded through `TransitionEnrollmentDTO`, the transition audit, and any future caller — and would silently widen the surface that can bypass the state machine.
+
+### Allowed overrides are explicit and narrow
+
+The Action validates the `(fromStatus, toStatus)` pair against an explicit allowlist:
+
+| From | To | Use case |
+| ----------- | ------------ | ---------------------------------------- |
+| `no_show` | `attended` | Organizer corrects a missed check-in |
+| `confirmed` | `checked_in` | Late arrival that missed formal check-in |
+
+Any other pair — including `attended → no_show`, `cancelled → attended`, or any path starting from `attended`/`cancelled`/`rejected` — is rejected with `OverrideEnrollmentStatusException::overrideNotAllowed(from, to)`.
+
+### Reason is required and recorded
+
+`OverrideEnrollmentStatusDTO` takes a non-empty `reason` string and rejects empty input with `OverrideEnrollmentStatusException::reasonRequired()`. The reason is written to `EnrollmentTransition::$reason` for accountability, alongside `triggered_by = admin` and `actor_id = $organizer->id`.
+
+### Stale status is a conflict, not a generic invalid override
+
+The DTO carries the enrollment status observed by the admin UI. The Action still re-loads the enrollment inside a transaction with a row lock before applying the correction. If the current status no longer matches the observed status, it throws `OverrideEnrollmentStatusException::statusChanged(expected, actual)` instead of `overrideNotAllowed(from, to)`.
+
+This separates two cases:
+
+- `overrideNotAllowed` — the requested pair is not part of the correction allowlist.
+- `statusChanged` — the requested pair may have been valid when the admin opened the modal, but another process changed the enrollment before save.
+
+### Override does not re-dispatch domain events
+
+No `ParticipantAttended` or `ParticipantCheckedIn` is dispatched on override. The override is a correction of the audit trail, not a fresh occurrence. XP implications:
+
+- `no_show → attended` does not grant XP retroactively. The participant was marked absent by the closure job; admin correction updates the record but the system-level "first time attended" event has already passed.
+- `confirmed → checked_in` does not dispatch `ParticipantCheckedIn`. Same reasoning — the check-in did not happen through the normal flow, and the system event has semantic meaning tied to actual check-in mechanics.
+
+If retroactive XP is ever needed, it is a separate decision (likely a `ParticipantManuallyAttended` event, with its own audience in Gamification).
+
+### Filament UI is a dedicated Action class
+
+`OverrideEnrollmentStatusAction` lives under the Event relation manager `Actions/` directory, matching the existing `GenerateCheckInCodeAction` / `RevokeCheckInCodeAction` pattern. It opens a modal with a reason textarea and a status select pre-populated with the allowed targets. The action is visible only when the current status is in the allowlist source set (`no_show`, `confirmed`) — anything else hides the action entirely.
+
+### No runtime inconsistency checks
+
+The form and factory enforce that `minimum_days` is set when `attendance_requirement = MinimumDays`. There is no save-time guard on `EnrollmentPolicy` for inconsistent `(requirement, minimum_days)` — this is defense at the edges only. See ADR-0007 for the closure-side evaluator that trusts the data.
+
+## Consequences
+
+- **Strong accountability** — every override writes an audit row with the organizer's identity and their stated reason.
+- **Tightly scoped bypass** — the state machine is still the gatekeeper for all non-override transitions. The override path is the _only_ way to leave a terminal state, and it is one line in one Action.
+- **No XP duplication risk** — by not re-dispatching domain events, an admin override cannot grant XP twice for the same participant-event pair.
+- **Trade-off** — overriding a status that has downstream side effects beyond XP (e.g. waitlist promotion triggered by `confirmed → cancelled`) does not replay those side effects. Acceptable for the current scope; flag if a new side-effect is added.
+- **Trade-off** — the Filament action is now another class to navigate, but `EnrollmentsRelationManager` stays focused on table composition while action-specific form and callback wiring live next to the other Event relation manager actions.
diff --git a/app-modules/events/lang/en/exceptions.php b/app-modules/events/lang/en/exceptions.php
index 58511678b..116dd963b 100644
--- a/app-modules/events/lang/en/exceptions.php
+++ b/app-modules/events/lang/en/exceptions.php
@@ -10,4 +10,7 @@
'invalid_enrollment_method' => 'This event requires application enrollment, not RSVP.',
'event_full' => 'This event has reached maximum capacity and does not have a waitlist.',
'response_message_not_implemented' => 'Response message is not implemented for enrollment status: :status.',
+ 'override_reason_required' => 'A reason is required when overriding an enrollment status.',
+ 'override_not_allowed' => 'Override from :from to :to is not allowed.',
+ 'override_status_changed' => 'Enrollment status changed from :expected to :actual before the override was saved. Review the current status and try again.',
];
diff --git a/app-modules/events/lang/pt_BR/exceptions.php b/app-modules/events/lang/pt_BR/exceptions.php
index fdd707908..f2a6eaefc 100644
--- a/app-modules/events/lang/pt_BR/exceptions.php
+++ b/app-modules/events/lang/pt_BR/exceptions.php
@@ -10,4 +10,7 @@
'invalid_enrollment_method' => 'Esse evento exige inscrição via formulário, não RSVP.',
'event_full' => 'Este evento atingiu a capacidade máxima e não possui lista de espera.',
'response_message_not_implemented' => 'Mensagem de resposta não implementada para o status de inscrição: :status.',
+ 'override_reason_required' => 'É obrigatório informar um motivo ao sobrescrever o status de uma inscrição.',
+ 'override_not_allowed' => 'Sobrescrita de :from para :to não é permitida.',
+ 'override_status_changed' => 'O status da inscrição mudou de :expected para :actual antes da sobrescrita ser salva. Revise o status atual e tente novamente.',
];
diff --git a/app-modules/events/src/Closure/Actions/CloseEventAction.php b/app-modules/events/src/Closure/Actions/CloseEventAction.php
new file mode 100644
index 000000000..ed104df33
--- /dev/null
+++ b/app-modules/events/src/Closure/Actions/CloseEventAction.php
@@ -0,0 +1,76 @@
+enrollments()
+ ->whereIn('status', [EnrollmentStatus::Confirmed, EnrollmentStatus::CheckedIn])
+ ->eachById(function (Enrollment $enrollment) use (&$attendances): void {
+ $attendances += $this->closeOne($enrollment);
+ });
+
+ return $attendances;
+ }
+
+ /**
+ * @throws Throwable
+ */
+ private function closeOne(Enrollment $enrollment): int
+ {
+ return DB::transaction(static function () use ($enrollment): int {
+ $locked = Enrollment::query()
+ ->with('event.enrollmentPolicy')
+ ->whereKey($enrollment->id)
+ ->lockForUpdate()
+ ->first();
+
+ if ($locked === null || $locked->status->isTerminal()) {
+ return 0;
+ }
+
+ $toStatus = $locked->status === EnrollmentStatus::CheckedIn
+ ? $locked->event->enrollmentPolicy->resolveAttendance($locked)
+ : EnrollmentStatus::NoShow;
+
+ resolve(TransitionEnrollmentAction::class)->handle(
+ new TransitionEnrollmentDTO(
+ enrollment: $locked,
+ toStatus: $toStatus,
+ triggeredBy: TriggeredBy::System,
+ ),
+ );
+
+ if ($toStatus === EnrollmentStatus::Attended) {
+ $xpReward = ($locked->event->enrollmentPolicy->xp_on_attended ?? 0);
+
+ event(new ParticipantAttended(
+ enrollmentId: $locked->id,
+ eventId: $locked->event_id,
+ userId: $locked->user_id,
+ xpRewardOnAttended: $xpReward,
+ ));
+
+ return 1;
+ }
+
+ return 0;
+ });
+ }
+}
diff --git a/app-modules/events/src/Closure/Actions/OverrideEnrollmentStatusAction.php b/app-modules/events/src/Closure/Actions/OverrideEnrollmentStatusAction.php
new file mode 100644
index 000000000..3b5e09afe
--- /dev/null
+++ b/app-modules/events/src/Closure/Actions/OverrideEnrollmentStatusAction.php
@@ -0,0 +1,80 @@
+ EnrollmentStatus::NoShow, 'to' => EnrollmentStatus::Attended],
+ ['from' => EnrollmentStatus::Confirmed, 'to' => EnrollmentStatus::CheckedIn],
+ ];
+
+ /** @return list */
+ public static function allowedTargetsFor(EnrollmentStatus $from): array
+ {
+ return collect(self::ALLOWED_OVERRIDES)
+ ->whereStrict('from', $from)
+ ->pluck('to')
+ ->values()
+ ->all();
+ }
+
+ /**
+ * @throws OverrideEnrollmentStatusException
+ * @throws Throwable
+ */
+ public function handle(OverrideEnrollmentStatusDTO $dto): EnrollmentTransition
+ {
+ return DB::transaction(function () use ($dto): EnrollmentTransition {
+ $enrollment = Enrollment::query()
+ ->whereKey($dto->enrollment->id)
+ ->lockForUpdate()
+ ->firstOrFail();
+
+ $fromStatus = $enrollment->status;
+
+ throw_unless(
+ $fromStatus === $dto->fromStatus,
+ OverrideEnrollmentStatusException::statusChanged($dto->fromStatus, $fromStatus),
+ );
+
+ throw_unless(
+ $this->isAllowed($fromStatus, $dto->toStatus),
+ OverrideEnrollmentStatusException::overrideNotAllowed($fromStatus, $dto->toStatus),
+ );
+
+ $attributes = ['status' => $dto->toStatus];
+
+ if ($timestampColumn = $dto->toStatus->timestampColumn()) {
+ $attributes[$timestampColumn] = now();
+ }
+
+ $enrollment->update($attributes);
+
+ return EnrollmentTransition::query()->create([
+ 'enrollment_id' => $enrollment->id,
+ 'from_status' => $fromStatus,
+ 'to_status' => $dto->toStatus,
+ 'actor_id' => $dto->actorId,
+ 'triggered_by' => TriggeredBy::Admin,
+ 'reason' => $dto->reason,
+ ]);
+ });
+ }
+
+ private function isAllowed(EnrollmentStatus $from, EnrollmentStatus $to): bool
+ {
+ return array_any(self::ALLOWED_OVERRIDES, fn ($pair) => $pair['from'] === $from && $pair['to'] === $to);
+ }
+}
diff --git a/app-modules/events/src/Closure/DTOs/OverrideEnrollmentStatusDTO.php b/app-modules/events/src/Closure/DTOs/OverrideEnrollmentStatusDTO.php
new file mode 100644
index 000000000..62ab5948c
--- /dev/null
+++ b/app-modules/events/src/Closure/DTOs/OverrideEnrollmentStatusDTO.php
@@ -0,0 +1,27 @@
+reason)) {
+ throw OverrideEnrollmentStatusException::reasonRequired();
+ }
+ }
+}
diff --git a/app-modules/events/src/Closure/Events/ParticipantAttended.php b/app-modules/events/src/Closure/Events/ParticipantAttended.php
new file mode 100644
index 000000000..a19ab6eb7
--- /dev/null
+++ b/app-modules/events/src/Closure/Events/ParticipantAttended.php
@@ -0,0 +1,21 @@
+ $from->value, 'to' => $to->value]),
+ Response::HTTP_UNPROCESSABLE_ENTITY,
+ );
+ }
+
+ public static function statusChanged(EnrollmentStatus $expected, EnrollmentStatus $actual): self
+ {
+ return new self(
+ __('events::exceptions.override_status_changed', [
+ 'expected' => $expected->value,
+ 'actual' => $actual->value,
+ ]),
+ Response::HTTP_CONFLICT,
+ );
+ }
+}
diff --git a/app-modules/events/src/Closure/Jobs/ProcessEventClosureJob.php b/app-modules/events/src/Closure/Jobs/ProcessEventClosureJob.php
new file mode 100644
index 000000000..7a68f1a95
--- /dev/null
+++ b/app-modules/events/src/Closure/Jobs/ProcessEventClosureJob.php
@@ -0,0 +1,56 @@
+eventId;
+ }
+
+ public function handle(CloseEventAction $action): void
+ {
+ $event = Event::query()->find($this->eventId);
+
+ if ($event === null) {
+ return;
+ }
+
+ $action->handle($event);
+ }
+
+ public function failed(Throwable $e): void
+ {
+ Log::error('Event closure failed', [
+ 'event_id' => $this->eventId,
+ 'error' => $e->getMessage(),
+ ]);
+ }
+}
diff --git a/app-modules/events/src/Console/Commands/ClosePendingEventsCommand.php b/app-modules/events/src/Console/Commands/ClosePendingEventsCommand.php
new file mode 100644
index 000000000..e1d3aac7f
--- /dev/null
+++ b/app-modules/events/src/Console/Commands/ClosePendingEventsCommand.php
@@ -0,0 +1,38 @@
+where('ends_at', '<', now())
+ ->whereHas('enrollments', function (Builder $query): void {
+ $query->whereIn('status', [EnrollmentStatus::Confirmed, EnrollmentStatus::CheckedIn]);
+ })
+ ->select('id')
+ ->each(function (Event $event) use (&$count): void {
+ dispatch(new ProcessEventClosureJob($event->id));
+ $count++;
+ });
+
+ $this->info(sprintf('Dispatched %d closure job(s).', $count));
+
+ return self::SUCCESS;
+ }
+}
diff --git a/app-modules/events/src/Enrollment/Enums/EnrollmentStatus.php b/app-modules/events/src/Enrollment/Enums/EnrollmentStatus.php
index 91026cbc0..d42f75dcd 100644
--- a/app-modules/events/src/Enrollment/Enums/EnrollmentStatus.php
+++ b/app-modules/events/src/Enrollment/Enums/EnrollmentStatus.php
@@ -6,12 +6,10 @@
use Filament\Support\Colors\Color;
use Filament\Support\Contracts\HasColor;
-use Filament\Support\Contracts\HasIcon;
use Filament\Support\Contracts\HasLabel;
-use Filament\Support\Icons\Heroicon;
use He4rt\Events\Enrollment\Exceptions\EnrollmentException;
-enum EnrollmentStatus: string implements HasColor, HasIcon, HasLabel
+enum EnrollmentStatus: string implements HasColor, HasLabel
{
case Pending = 'pending';
case Confirmed = 'confirmed';
@@ -41,20 +39,6 @@ public function getColor(): array
};
}
- public function getIcon(): Heroicon
- {
- return match ($this) {
- self::Pending => Heroicon::Clock,
- self::Confirmed => Heroicon::CheckCircle,
- self::Waitlisted => Heroicon::OutlinedQueueList,
- self::CheckedIn => Heroicon::Flag,
- self::Attended => Heroicon::Bolt,
- self::Cancelled => Heroicon::XCircle,
- self::Rejected => Heroicon::NoSymbol,
- self::NoShow => Heroicon::ExclamationCircle,
- };
- }
-
public function canTransitionTo(self $target): bool
{
return match ($this) {
diff --git a/app-modules/events/src/Enrollment/Models/EnrollmentPolicy.php b/app-modules/events/src/Enrollment/Models/EnrollmentPolicy.php
index 015370905..297cbf6db 100644
--- a/app-modules/events/src/Enrollment/Models/EnrollmentPolicy.php
+++ b/app-modules/events/src/Enrollment/Models/EnrollmentPolicy.php
@@ -9,6 +9,7 @@
use He4rt\Events\Database\Factories\EnrollmentPolicyFactory;
use He4rt\Events\Enrollment\Enums\AttendanceRequirement;
use He4rt\Events\Enrollment\Enums\EnrollmentMethod;
+use He4rt\Events\Enrollment\Enums\EnrollmentStatus;
use He4rt\Events\Event\Models\Event;
use Illuminate\Database\Eloquent\Attributes\Table;
use Illuminate\Database\Eloquent\Concerns\HasUuids;
@@ -69,6 +70,19 @@ public function event(): BelongsTo
return $this->belongsTo(Event::class);
}
+ public function resolveAttendance(Enrollment $enrollment): EnrollmentStatus
+ {
+ $checkInDays = $enrollment->checkIns()->count();
+
+ $meets = match ($this->attendance_requirement) {
+ AttendanceRequirement::AllDays => $checkInDays === $enrollment->event->totalDays(),
+ AttendanceRequirement::AnyDay => $checkInDays >= 1,
+ AttendanceRequirement::MinimumDays => $checkInDays >= ($this->minimum_days ?? 0),
+ };
+
+ return $meets ? EnrollmentStatus::Attended : EnrollmentStatus::NoShow;
+ }
+
protected static function newFactory(): EnrollmentPolicyFactory
{
return EnrollmentPolicyFactory::new();
diff --git a/app-modules/events/src/EventsServiceProvider.php b/app-modules/events/src/EventsServiceProvider.php
index bfe3c5882..0aa2f2e6b 100644
--- a/app-modules/events/src/EventsServiceProvider.php
+++ b/app-modules/events/src/EventsServiceProvider.php
@@ -5,12 +5,18 @@
namespace He4rt\Events;
use He4rt\Events\CheckIn\Listeners\GenerateQrTokenOnConfirmed;
+use He4rt\Events\Console\Commands\ClosePendingEventsCommand;
use He4rt\Events\Enrollment\Events\EnrollmentConfirmed;
+use Illuminate\Console\Scheduling\Schedule;
+use Illuminate\Contracts\Container\BindingResolutionException;
use Illuminate\Support\Facades\Event;
use Illuminate\Support\ServiceProvider;
class EventsServiceProvider extends ServiceProvider
{
+ /**
+ * @throws BindingResolutionException
+ */
public function boot(): void
{
$this->loadMigrationsFrom(__DIR__.'/../database/migrations');
@@ -18,5 +24,12 @@ public function boot(): void
$this->loadTranslationsFrom(__DIR__.'/../lang', 'events');
Event::listen(EnrollmentConfirmed::class, GenerateQrTokenOnConfirmed::class);
+
+ if ($this->app->runningInConsole()) {
+ $this->app->make(Schedule::class)
+ ->command(ClosePendingEventsCommand::class)
+ ->everyFifteenMinutes()
+ ->withoutOverlapping();
+ }
}
}
diff --git a/app-modules/events/tests/Feature/Closure/CloseEventActionTest.php b/app-modules/events/tests/Feature/Closure/CloseEventActionTest.php
new file mode 100644
index 000000000..61aa2f298
--- /dev/null
+++ b/app-modules/events/tests/Feature/Closure/CloseEventActionTest.php
@@ -0,0 +1,247 @@
+create();
+ $startsAt = now()->subDays(3)->setTime(9, 0);
+
+ return Event::factory()
+ ->for($tenant)
+ ->has(EnrollmentPolicy::factory()->state(array_merge([
+ 'attendance_requirement' => AttendanceRequirement::AllDays,
+ 'minimum_days' => null,
+ 'xp_on_attended' => 0,
+ ], $policyAttributes)), 'enrollmentPolicy')
+ ->create(array_merge([
+ 'starts_at' => $startsAt,
+ 'ends_at' => $startsAt->clone()->addDays(2)->setTime(18, 0),
+ 'status' => EventStatus::Published,
+ ], $eventAttributes));
+}
+
+test('when checked_in enrollment meets all_days, then action transitions to Attended and dispatches ParticipantAttended', function (): void {
+ EventFacade::fake([ParticipantAttended::class]);
+
+ $startsAt = now()->subDays(3)->setTime(9, 0);
+ $event = createEventForClosure([
+ 'starts_at' => $startsAt,
+ 'ends_at' => $startsAt->clone()->addDays(2)->setTime(18, 0),
+ ], [
+ 'attendance_requirement' => AttendanceRequirement::AllDays,
+ 'xp_on_attended' => 75,
+ ]);
+
+ $enrollment = Enrollment::factory()->create([
+ 'event_id' => $event->id,
+ 'user_id' => User::factory()->create()->id,
+ 'status' => EnrollmentStatus::CheckedIn,
+ 'checked_in_at' => now(),
+ ]);
+
+ CheckIn::factory()->create(['enrollment_id' => $enrollment->id, 'event_date' => $startsAt->toDateString(), 'method' => CheckInMethod::Manual]);
+ CheckIn::factory()->create(['enrollment_id' => $enrollment->id, 'event_date' => $startsAt->clone()->addDay()->toDateString(), 'method' => CheckInMethod::Manual]);
+ CheckIn::factory()->create(['enrollment_id' => $enrollment->id, 'event_date' => $startsAt->clone()->addDays(2)->toDateString(), 'method' => CheckInMethod::Manual]);
+
+ $attended = resolve(CloseEventAction::class)->handle($event);
+
+ expect($attended)->toBe(1)
+ ->and($enrollment->fresh()->status)->toBe(EnrollmentStatus::Attended)
+ ->and($enrollment->fresh()->attended_at)->not->toBeNull();
+
+ $transition = EnrollmentTransition::query()
+ ->where('enrollment_id', $enrollment->id)
+ ->where('to_status', EnrollmentStatus::Attended)
+ ->first();
+
+ expect($transition)->not->toBeNull()
+ ->and($transition->from_status)->toBe(EnrollmentStatus::CheckedIn)
+ ->and($transition->triggered_by)->toBe(TriggeredBy::System);
+
+ EventFacade::assertDispatched(fn (ParticipantAttended $e): bool => $e->enrollmentId === $enrollment->id
+ && $e->eventId === $event->id
+ && $e->xpRewardOnAttended === 75);
+});
+
+test('when checked_in enrollment is missing check-ins for all_days, then action transitions to NoShow without dispatching event', function (): void {
+ EventFacade::fake([ParticipantAttended::class]);
+
+ $startsAt = now()->subDays(3)->setTime(9, 0);
+ $event = createEventForClosure([
+ 'starts_at' => $startsAt,
+ 'ends_at' => $startsAt->clone()->addDays(2)->setTime(18, 0),
+ ], [
+ 'attendance_requirement' => AttendanceRequirement::AllDays,
+ ]);
+
+ $enrollment = Enrollment::factory()->create([
+ 'event_id' => $event->id,
+ 'user_id' => User::factory()->create()->id,
+ 'status' => EnrollmentStatus::CheckedIn,
+ 'checked_in_at' => now(),
+ ]);
+
+ CheckIn::factory()->create(['enrollment_id' => $enrollment->id, 'event_date' => $startsAt->toDateString(), 'method' => CheckInMethod::Manual]);
+
+ $attended = resolve(CloseEventAction::class)->handle($event);
+
+ expect($attended)->toBe(0)
+ ->and($enrollment->fresh()->status)->toBe(EnrollmentStatus::NoShow);
+
+ EventFacade::assertNotDispatched(ParticipantAttended::class);
+});
+
+test('when confirmed enrollment never checked in, then action transitions to NoShow', function (): void {
+ EventFacade::fake([ParticipantAttended::class]);
+
+ $event = createEventForClosure();
+ $enrollment = Enrollment::factory()->create([
+ 'event_id' => $event->id,
+ 'user_id' => User::factory()->create()->id,
+ 'status' => EnrollmentStatus::Confirmed,
+ 'confirmed_at' => now()->subDay(),
+ ]);
+
+ $attended = resolve(CloseEventAction::class)->handle($event);
+
+ expect($attended)->toBe(0)
+ ->and($enrollment->fresh()->status)->toBe(EnrollmentStatus::NoShow);
+
+ EventFacade::assertNotDispatched(ParticipantAttended::class);
+});
+
+test('when terminal enrollment is present, then action skips it', function (): void {
+ EventFacade::fake([ParticipantAttended::class]);
+
+ $event = createEventForClosure();
+ $enrollment = Enrollment::factory()->create([
+ 'event_id' => $event->id,
+ 'user_id' => User::factory()->create()->id,
+ 'status' => EnrollmentStatus::Cancelled,
+ 'cancelled_at' => now()->subDay(),
+ ]);
+
+ $attended = resolve(CloseEventAction::class)->handle($event);
+
+ expect($attended)->toBe(0)
+ ->and($enrollment->fresh()->status)->toBe(EnrollmentStatus::Cancelled);
+
+ $transitionCount = EnrollmentTransition::query()
+ ->where('enrollment_id', $enrollment->id)
+ ->count();
+
+ expect($transitionCount)->toBe(0);
+});
+
+test('when action runs twice, then second run is no-op (idempotent)', function (): void {
+ EventFacade::fake([ParticipantAttended::class]);
+
+ $event = createEventForClosure();
+ $enrollment = Enrollment::factory()->create([
+ 'event_id' => $event->id,
+ 'user_id' => User::factory()->create()->id,
+ 'status' => EnrollmentStatus::Confirmed,
+ 'confirmed_at' => now()->subDay(),
+ ]);
+
+ $first = resolve(CloseEventAction::class)->handle($event);
+ $second = resolve(CloseEventAction::class)->handle($event);
+
+ expect($first)->toBe(0)
+ ->and($second)->toBe(0);
+
+ $transitions = EnrollmentTransition::query()
+ ->where('enrollment_id', $enrollment->id)
+ ->where('to_status', EnrollmentStatus::NoShow)
+ ->count();
+
+ expect($transitions)->toBe(1);
+});
+
+test('when action processes multiple enrollments, then each is closed independently with system audit trail', function (): void {
+ EventFacade::fake([ParticipantAttended::class]);
+
+ $startsAt = now()->subDays(3)->setTime(9, 0);
+ $event = createEventForClosure([
+ 'starts_at' => $startsAt,
+ 'ends_at' => $startsAt->clone()->addDays(2)->setTime(18, 0),
+ ], [
+ 'attendance_requirement' => AttendanceRequirement::AnyDay,
+ ]);
+ $confirmed = Enrollment::factory()->create([
+ 'event_id' => $event->id,
+ 'user_id' => User::factory()->create()->id,
+ 'status' => EnrollmentStatus::Confirmed,
+ 'confirmed_at' => now()->subDay(),
+ ]);
+ $checkedIn = Enrollment::factory()->create([
+ 'event_id' => $event->id,
+ 'user_id' => User::factory()->create()->id,
+ 'status' => EnrollmentStatus::CheckedIn,
+ 'checked_in_at' => now()->subDay(),
+ ]);
+
+ CheckIn::factory()->create(['enrollment_id' => $checkedIn->id, 'event_date' => $startsAt->toDateString(), 'method' => CheckInMethod::Manual]);
+
+ resolve(CloseEventAction::class)->handle($event);
+
+ expect($confirmed->fresh()->status)->toBe(EnrollmentStatus::NoShow)
+ ->and($checkedIn->fresh()->status)->toBe(EnrollmentStatus::Attended)
+ ->and($checkedIn->fresh()->attended_at)->not->toBeNull();
+
+ foreach ([$confirmed->id, $checkedIn->id] as $enrollmentId) {
+ $transition = EnrollmentTransition::query()
+ ->where('enrollment_id', $enrollmentId)
+ ->first();
+
+ expect($transition)->not->toBeNull()
+ ->and($transition->triggered_by)->toBe(TriggeredBy::System);
+ }
+});
+
+test('when action processes more than one chunk of eligible enrollments, then all are closed in one run', function (): void {
+ EventFacade::fake([ParticipantAttended::class]);
+
+ $event = createEventForClosure();
+
+ Enrollment::factory()
+ ->count(1001)
+ ->create([
+ 'event_id' => $event->id,
+ 'status' => EnrollmentStatus::Confirmed,
+ 'confirmed_at' => now()->subDay(),
+ ]);
+
+ resolve(CloseEventAction::class)->handle($event);
+
+ $remainingEligible = Enrollment::query()
+ ->where('event_id', $event->id)
+ ->whereIn('status', [EnrollmentStatus::Confirmed, EnrollmentStatus::CheckedIn])
+ ->count();
+
+ $noShowTransitions = EnrollmentTransition::query()
+ ->whereHas('enrollment', fn (Builder $query) => $query->where('event_id', $event->id))
+ ->where('to_status', EnrollmentStatus::NoShow)
+ ->count();
+
+ expect($remainingEligible)->toBe(0)
+ ->and($noShowTransitions)->toBe(1001);
+});
diff --git a/app-modules/events/tests/Feature/Closure/ClosePendingEventsCommandTest.php b/app-modules/events/tests/Feature/Closure/ClosePendingEventsCommandTest.php
new file mode 100644
index 000000000..650c2a0f4
--- /dev/null
+++ b/app-modules/events/tests/Feature/Closure/ClosePendingEventsCommandTest.php
@@ -0,0 +1,108 @@
+create();
+ $startsAt = now()->subDays(3)->setTime(9, 0);
+
+ return Event::factory()
+ ->for($tenant)
+ ->has(EnrollmentPolicy::factory()->state(array_merge([
+ 'attendance_requirement' => AttendanceRequirement::AllDays,
+ 'minimum_days' => null,
+ ], $policyAttributes)), 'enrollmentPolicy')
+ ->create(array_merge([
+ 'starts_at' => $startsAt,
+ 'ends_at' => $startsAt->clone()->setTime(18, 0),
+ 'status' => EventStatus::Published,
+ ], $eventAttributes));
+}
+
+test('when command runs and past event has non-terminal enrollments, then it dispatches a closure job', function (): void {
+ Bus::fake([ProcessEventClosureJob::class]);
+
+ $event = makeEventForCommandTest();
+ Enrollment::factory()->create([
+ 'event_id' => $event->id,
+ 'user_id' => User::factory()->create()->id,
+ 'status' => EnrollmentStatus::Confirmed,
+ 'confirmed_at' => now()->subDay(),
+ ]);
+
+ $this->artisan('events:close-pending')
+ ->expectsOutputToContain('Dispatched 1 closure job(s).')
+ ->assertSuccessful();
+
+ Bus::assertDispatched(fn (ProcessEventClosureJob $job): bool => $job->eventId === $event->id);
+});
+
+test('when command runs and event is in the future, then no job is dispatched', function (): void {
+ Bus::fake([ProcessEventClosureJob::class]);
+
+ $futureStartsAt = now()->addDays(5)->setTime(9, 0);
+ $event = makeEventForCommandTest([
+ 'starts_at' => $futureStartsAt,
+ 'ends_at' => $futureStartsAt->clone()->setTime(18, 0),
+ ]);
+ Enrollment::factory()->create([
+ 'event_id' => $event->id,
+ 'user_id' => User::factory()->create()->id,
+ 'status' => EnrollmentStatus::Confirmed,
+ 'confirmed_at' => now(),
+ ]);
+
+ $this->artisan('events:close-pending');
+
+ Bus::assertNotDispatched(ProcessEventClosureJob::class);
+});
+
+test('when command runs and past event has only terminal enrollments, then no job is dispatched', function (): void {
+ Bus::fake([ProcessEventClosureJob::class]);
+
+ $event = makeEventForCommandTest();
+ Enrollment::factory()->create([
+ 'event_id' => $event->id,
+ 'user_id' => User::factory()->create()->id,
+ 'status' => EnrollmentStatus::Attended,
+ 'attended_at' => now()->subDay(),
+ ]);
+
+ $this->artisan('events:close-pending');
+
+ Bus::assertNotDispatched(ProcessEventClosureJob::class);
+});
+
+test('when command runs and multiple past events need closure, then it dispatches one job per event', function (): void {
+ Bus::fake([ProcessEventClosureJob::class]);
+
+ $eventA = makeEventForCommandTest();
+ $eventB = makeEventForCommandTest();
+ Enrollment::factory()->create([
+ 'event_id' => $eventA->id,
+ 'user_id' => User::factory()->create()->id,
+ 'status' => EnrollmentStatus::Confirmed,
+ ]);
+ Enrollment::factory()->create([
+ 'event_id' => $eventB->id,
+ 'user_id' => User::factory()->create()->id,
+ 'status' => EnrollmentStatus::CheckedIn,
+ 'checked_in_at' => now()->subDay(),
+ ]);
+
+ $this->artisan('events:close-pending');
+
+ Bus::assertDispatchedTimes(ProcessEventClosureJob::class, 2);
+});
diff --git a/app-modules/events/tests/Feature/Closure/OverrideEnrollmentStatusActionTest.php b/app-modules/events/tests/Feature/Closure/OverrideEnrollmentStatusActionTest.php
new file mode 100644
index 000000000..aac3fec0e
--- /dev/null
+++ b/app-modules/events/tests/Feature/Closure/OverrideEnrollmentStatusActionTest.php
@@ -0,0 +1,203 @@
+create();
+ $startsAt = now()->subDays(2)->setTime(9, 0);
+
+ return Event::factory()
+ ->for($tenant)
+ ->create([
+ 'starts_at' => $startsAt,
+ 'ends_at' => $startsAt->clone()->setTime(18, 0),
+ 'status' => EventStatus::Published,
+ ]);
+}
+
+test('when admin overrides no_show enrollment to attended with reason, then transition is recorded with admin trigger', function (): void {
+ $event = createPastEvent();
+ $organizer = User::factory()->create();
+ $enrollment = Enrollment::factory()->create([
+ 'event_id' => $event->id,
+ 'user_id' => User::factory()->create()->id,
+ 'status' => EnrollmentStatus::NoShow,
+ 'confirmed_at' => now()->subDay(),
+ ]);
+
+ $transition = resolve(OverrideEnrollmentStatusAction::class)->handle(
+ new OverrideEnrollmentStatusDTO(
+ enrollment: $enrollment,
+ fromStatus: EnrollmentStatus::NoShow,
+ toStatus: EnrollmentStatus::Attended,
+ actorId: $organizer->id,
+ reason: 'Participant was actually present',
+ ),
+ );
+
+ expect($transition->from_status)->toBe(EnrollmentStatus::NoShow)
+ ->and($transition->to_status)->toBe(EnrollmentStatus::Attended)
+ ->and($transition->triggered_by)->toBe(TriggeredBy::Admin)
+ ->and($transition->actor_id)->toBe($organizer->id)
+ ->and($transition->reason)->toBe('Participant was actually present')
+ ->and($enrollment->fresh()->status)->toBe(EnrollmentStatus::Attended)
+ ->and($enrollment->fresh()->attended_at)->not->toBeNull();
+});
+
+test('when admin overrides confirmed enrollment to checked_in with reason, then status transitions and timestamp is set', function (): void {
+ $event = createPastEvent();
+ $organizer = User::factory()->create();
+ $enrollment = Enrollment::factory()->create([
+ 'event_id' => $event->id,
+ 'user_id' => User::factory()->create()->id,
+ 'status' => EnrollmentStatus::Confirmed,
+ 'confirmed_at' => now()->subDay(),
+ ]);
+
+ resolve(OverrideEnrollmentStatusAction::class)->handle(
+ new OverrideEnrollmentStatusDTO(
+ enrollment: $enrollment,
+ fromStatus: EnrollmentStatus::Confirmed,
+ toStatus: EnrollmentStatus::CheckedIn,
+ actorId: $organizer->id,
+ reason: 'Late arrival',
+ ),
+ );
+
+ $enrollment->refresh();
+
+ expect($enrollment->status)->toBe(EnrollmentStatus::CheckedIn)
+ ->and($enrollment->checked_in_at)->not->toBeNull();
+
+ $transition = EnrollmentTransition::query()
+ ->where('enrollment_id', $enrollment->id)
+ ->where('to_status', EnrollmentStatus::CheckedIn)
+ ->first();
+
+ expect($transition)->not->toBeNull()
+ ->and($transition->triggered_by)->toBe(TriggeredBy::Admin)
+ ->and($transition->reason)->toBe('Late arrival');
+});
+
+test('when admin tries to override without reason, then reason required exception is thrown', function (): void {
+ $event = createPastEvent();
+ $enrollment = Enrollment::factory()->create([
+ 'event_id' => $event->id,
+ 'user_id' => User::factory()->create()->id,
+ 'status' => EnrollmentStatus::NoShow,
+ ]);
+
+ expect(fn (): EnrollmentTransition => resolve(OverrideEnrollmentStatusAction::class)->handle(
+ new OverrideEnrollmentStatusDTO(
+ enrollment: $enrollment,
+ fromStatus: EnrollmentStatus::NoShow,
+ toStatus: EnrollmentStatus::Attended,
+ actorId: User::factory()->create()->id,
+ reason: ' ',
+ ),
+ ))->toThrow(OverrideEnrollmentStatusException::class);
+});
+
+test('when admin tries to override from cancelled to attended, then override not allowed exception is thrown', function (): void {
+ $event = createPastEvent();
+ $enrollment = Enrollment::factory()->create([
+ 'event_id' => $event->id,
+ 'user_id' => User::factory()->create()->id,
+ 'status' => EnrollmentStatus::Cancelled,
+ ]);
+
+ expect(fn (): EnrollmentTransition => resolve(OverrideEnrollmentStatusAction::class)->handle(
+ new OverrideEnrollmentStatusDTO(
+ enrollment: $enrollment,
+ fromStatus: EnrollmentStatus::Cancelled,
+ toStatus: EnrollmentStatus::Attended,
+ actorId: User::factory()->create()->id,
+ reason: 'Should not work',
+ ),
+ ))->toThrow(OverrideEnrollmentStatusException::class);
+});
+
+test('when allowedTargetsFor is queried for NoShow, then it returns only Attended', function (): void {
+ $targets = OverrideEnrollmentStatusAction::allowedTargetsFor(EnrollmentStatus::NoShow);
+
+ expect($targets)->toBe([EnrollmentStatus::Attended]);
+});
+
+test('when allowedTargetsFor is queried for Confirmed, then it returns only CheckedIn', function (): void {
+ $targets = OverrideEnrollmentStatusAction::allowedTargetsFor(EnrollmentStatus::Confirmed);
+
+ expect($targets)->toBe([EnrollmentStatus::CheckedIn]);
+});
+
+test('when allowedTargetsFor is queried for a terminal or unrelated status, then it returns an empty array', function (): void {
+ expect(OverrideEnrollmentStatusAction::allowedTargetsFor(EnrollmentStatus::Attended))->toBeEmpty()
+ ->and(OverrideEnrollmentStatusAction::allowedTargetsFor(EnrollmentStatus::Cancelled))->toBeEmpty()
+ ->and(OverrideEnrollmentStatusAction::allowedTargetsFor(EnrollmentStatus::Rejected))->toBeEmpty()
+ ->and(OverrideEnrollmentStatusAction::allowedTargetsFor(EnrollmentStatus::NoShow))->not->toBeEmpty();
+});
+
+test('when admin tries to override from no_show to confirmed, then override not allowed exception is thrown', function (): void {
+ $event = createPastEvent();
+ $enrollment = Enrollment::factory()->create([
+ 'event_id' => $event->id,
+ 'user_id' => User::factory()->create()->id,
+ 'status' => EnrollmentStatus::NoShow,
+ ]);
+
+ expect(fn (): EnrollmentTransition => resolve(OverrideEnrollmentStatusAction::class)->handle(
+ new OverrideEnrollmentStatusDTO(
+ enrollment: $enrollment,
+ fromStatus: EnrollmentStatus::NoShow,
+ toStatus: EnrollmentStatus::Confirmed,
+ actorId: User::factory()->create()->id,
+ reason: 'Should not work',
+ ),
+ ))->toThrow(OverrideEnrollmentStatusException::class);
+});
+
+test('when admin overrides a stale confirmed enrollment that is already no_show, then current status is enforced', function (): void {
+ $event = createPastEvent();
+ $enrollment = Enrollment::factory()->create([
+ 'event_id' => $event->id,
+ 'user_id' => User::factory()->create()->id,
+ 'status' => EnrollmentStatus::Confirmed,
+ 'confirmed_at' => now()->subDay(),
+ ]);
+
+ resolve(TransitionEnrollmentAction::class)->handle(
+ new TransitionEnrollmentDTO(
+ enrollment: $enrollment->fresh(),
+ toStatus: EnrollmentStatus::NoShow,
+ triggeredBy: TriggeredBy::System,
+ ),
+ );
+
+ expect(fn (): EnrollmentTransition => resolve(OverrideEnrollmentStatusAction::class)->handle(
+ new OverrideEnrollmentStatusDTO(
+ enrollment: $enrollment,
+ fromStatus: EnrollmentStatus::Confirmed,
+ toStatus: EnrollmentStatus::CheckedIn,
+ actorId: User::factory()->create()->id,
+ reason: 'Late arrival',
+ ),
+ ))->toThrow(function (OverrideEnrollmentStatusException $exception): void {
+ expect($exception->getCode())->toBe(409);
+ });
+
+ expect($enrollment->fresh()->status)->toBe(EnrollmentStatus::NoShow);
+});
diff --git a/app-modules/events/tests/Feature/Closure/ProcessEventClosureJobTest.php b/app-modules/events/tests/Feature/Closure/ProcessEventClosureJobTest.php
new file mode 100644
index 000000000..7eb8e8242
--- /dev/null
+++ b/app-modules/events/tests/Feature/Closure/ProcessEventClosureJobTest.php
@@ -0,0 +1,102 @@
+create();
+ $startsAt = now()->subDays(3)->setTime(9, 0);
+
+ return Event::factory()
+ ->for($tenant)
+ ->has(EnrollmentPolicy::factory()->state(array_merge([
+ 'attendance_requirement' => AttendanceRequirement::AllDays,
+ 'minimum_days' => null,
+ ], $policyAttributes)), 'enrollmentPolicy')
+ ->create(array_merge([
+ 'starts_at' => $startsAt,
+ 'ends_at' => $startsAt->clone()->setTime(18, 0),
+ 'status' => EventStatus::Published,
+ ], $eventAttributes));
+}
+
+test('when job runs for an event with non-terminal enrollments, then it closes them', function (): void {
+ EventFacade::fake([ParticipantAttended::class]);
+
+ $event = makeEventForJobTest();
+ $enrollment = Enrollment::factory()->create([
+ 'event_id' => $event->id,
+ 'user_id' => User::factory()->create()->id,
+ 'status' => EnrollmentStatus::Confirmed,
+ 'confirmed_at' => now()->subDay(),
+ ]);
+
+ new ProcessEventClosureJob($event->id)->handle(resolve(CloseEventAction::class));
+
+ expect($enrollment->fresh()->status)->toBe(EnrollmentStatus::NoShow);
+
+ $transition = EnrollmentTransition::query()
+ ->where('enrollment_id', $enrollment->id)
+ ->first();
+
+ expect($transition)->not->toBeNull()
+ ->and($transition->triggered_by)->toBe(TriggeredBy::System);
+});
+
+test('when job unique id is queried, then it returns the event id', function (): void {
+ $job = new ProcessEventClosureJob('event-123');
+
+ expect($job->uniqueId())->toBe('event-123');
+});
+
+test('when job runs for a non-existent event id, then it silently returns without throwing', function (): void {
+ EventFacade::fake([ParticipantAttended::class]);
+
+ new ProcessEventClosureJob(Str::uuid()->toString())->handle(resolve(CloseEventAction::class));
+
+ EventFacade::assertNotDispatched(ParticipantAttended::class);
+});
+
+test('when job fails, then it logs the event id and error', function (): void {
+ Log::spy();
+
+ $exception = new RuntimeException('db down');
+
+ new ProcessEventClosureJob('event-xyz')->failed($exception);
+
+ Log::shouldHaveReceived('error')->once()->withArgs(fn (string $message, array $context): bool => $message === 'Event closure failed'
+ && $context['event_id'] === 'event-xyz'
+ && $context['error'] === 'db down');
+});
+
+test('when job is configured, then it has correct backoff, tries, and unique ttl', function (): void {
+ $ref = new ReflectionClass(ProcessEventClosureJob::class);
+
+ $tries = $ref->getAttributes(Tries::class)[0]->newInstance()->tries;
+ $backoff = $ref->getAttributes(Backoff::class)[0]->newInstance()->backoff;
+ $uniqueFor = $ref->getAttributes(UniqueFor::class)[0]->newInstance()->uniqueFor;
+
+ expect($tries)->toBe(4)
+ ->and($backoff)->toBe([1, 5, 10])
+ ->and($uniqueFor)->toBe(1800);
+});
diff --git a/app-modules/events/tests/Feature/Enrollment/EnrollmentPolicyTest.php b/app-modules/events/tests/Feature/Enrollment/EnrollmentPolicyTest.php
new file mode 100644
index 000000000..a7ff4be33
--- /dev/null
+++ b/app-modules/events/tests/Feature/Enrollment/EnrollmentPolicyTest.php
@@ -0,0 +1,142 @@
+create();
+ $startsAt = now()->setTime(9, 0);
+
+ return Event::factory()
+ ->for($tenant)
+ ->has(EnrollmentPolicy::factory()->state(array_merge([
+ 'attendance_requirement' => AttendanceRequirement::AllDays,
+ 'minimum_days' => null,
+ ], $policyAttributes)), 'enrollmentPolicy')
+ ->create(array_merge([
+ 'starts_at' => $startsAt,
+ 'ends_at' => $startsAt->clone()->setTime(18, 0),
+ 'status' => EventStatus::Published,
+ ], $eventAttributes));
+}
+
+test('when checked_in enrollment meets all_days requirement, then resolveAttendance returns Attended', function (): void {
+ $startsAt = now()->setTime(9, 0);
+ $event = createEventWithPolicy([
+ 'starts_at' => $startsAt,
+ 'ends_at' => $startsAt->clone()->addDays(2)->setTime(18, 0),
+ ], [
+ 'attendance_requirement' => AttendanceRequirement::AllDays,
+ ]);
+
+ $enrollment = Enrollment::factory()->create([
+ 'event_id' => $event->id,
+ 'user_id' => User::factory()->create()->id,
+ 'status' => EnrollmentStatus::CheckedIn,
+ 'checked_in_at' => now(),
+ ]);
+
+ CheckIn::factory()->create(['enrollment_id' => $enrollment->id, 'event_date' => $startsAt->toDateString(), 'method' => CheckInMethod::Manual]);
+ CheckIn::factory()->create(['enrollment_id' => $enrollment->id, 'event_date' => $startsAt->clone()->addDay()->toDateString(), 'method' => CheckInMethod::Manual]);
+ CheckIn::factory()->create(['enrollment_id' => $enrollment->id, 'event_date' => $startsAt->clone()->addDays(2)->toDateString(), 'method' => CheckInMethod::Manual]);
+
+ expect($event->enrollmentPolicy->resolveAttendance($enrollment))->toBe(EnrollmentStatus::Attended);
+});
+
+test('when checked_in enrollment is missing days for all_days requirement, then resolveAttendance returns NoShow', function (): void {
+ $startsAt = now()->setTime(9, 0);
+ $event = createEventWithPolicy([
+ 'starts_at' => $startsAt,
+ 'ends_at' => $startsAt->clone()->addDays(2)->setTime(18, 0),
+ ], [
+ 'attendance_requirement' => AttendanceRequirement::AllDays,
+ ]);
+
+ $enrollment = Enrollment::factory()->create([
+ 'event_id' => $event->id,
+ 'user_id' => User::factory()->create()->id,
+ 'status' => EnrollmentStatus::CheckedIn,
+ 'checked_in_at' => now(),
+ ]);
+
+ CheckIn::factory()->create(['enrollment_id' => $enrollment->id, 'event_date' => $startsAt->toDateString(), 'method' => CheckInMethod::Manual]);
+
+ expect($event->enrollmentPolicy->resolveAttendance($enrollment))->toBe(EnrollmentStatus::NoShow);
+});
+
+test('when checked_in enrollment has at least one check-in and requirement is any_day, then resolveAttendance returns Attended', function (): void {
+ $startsAt = now()->setTime(9, 0);
+ $event = createEventWithPolicy([
+ 'starts_at' => $startsAt,
+ 'ends_at' => $startsAt->clone()->addDays(2)->setTime(18, 0),
+ ], [
+ 'attendance_requirement' => AttendanceRequirement::AnyDay,
+ ]);
+
+ $enrollment = Enrollment::factory()->create([
+ 'event_id' => $event->id,
+ 'user_id' => User::factory()->create()->id,
+ 'status' => EnrollmentStatus::CheckedIn,
+ 'checked_in_at' => now(),
+ ]);
+
+ CheckIn::factory()->create(['enrollment_id' => $enrollment->id, 'event_date' => $startsAt->toDateString(), 'method' => CheckInMethod::Manual]);
+
+ expect($event->enrollmentPolicy->resolveAttendance($enrollment))->toBe(EnrollmentStatus::Attended);
+});
+
+test('when checked_in enrollment meets minimum_days requirement, then resolveAttendance returns Attended', function (): void {
+ $startsAt = now()->setTime(9, 0);
+ $event = createEventWithPolicy([
+ 'starts_at' => $startsAt,
+ 'ends_at' => $startsAt->clone()->addDays(2)->setTime(18, 0),
+ ], [
+ 'attendance_requirement' => AttendanceRequirement::MinimumDays,
+ 'minimum_days' => 2,
+ ]);
+
+ $enrollment = Enrollment::factory()->create([
+ 'event_id' => $event->id,
+ 'user_id' => User::factory()->create()->id,
+ 'status' => EnrollmentStatus::CheckedIn,
+ 'checked_in_at' => now(),
+ ]);
+
+ CheckIn::factory()->create(['enrollment_id' => $enrollment->id, 'event_date' => $startsAt->toDateString(), 'method' => CheckInMethod::Manual]);
+ CheckIn::factory()->create(['enrollment_id' => $enrollment->id, 'event_date' => $startsAt->clone()->addDay()->toDateString(), 'method' => CheckInMethod::Manual]);
+
+ expect($event->enrollmentPolicy->resolveAttendance($enrollment))->toBe(EnrollmentStatus::Attended);
+});
+
+test('when checked_in enrollment is below minimum_days requirement, then resolveAttendance returns NoShow', function (): void {
+ $startsAt = now()->setTime(9, 0);
+ $event = createEventWithPolicy([
+ 'starts_at' => $startsAt,
+ 'ends_at' => $startsAt->clone()->addDays(2)->setTime(18, 0),
+ ], [
+ 'attendance_requirement' => AttendanceRequirement::MinimumDays,
+ 'minimum_days' => 2,
+ ]);
+
+ $enrollment = Enrollment::factory()->create([
+ 'event_id' => $event->id,
+ 'user_id' => User::factory()->create()->id,
+ 'status' => EnrollmentStatus::CheckedIn,
+ 'checked_in_at' => now(),
+ ]);
+
+ CheckIn::factory()->create(['enrollment_id' => $enrollment->id, 'event_date' => $startsAt->toDateString(), 'method' => CheckInMethod::Manual]);
+
+ expect($event->enrollmentPolicy->resolveAttendance($enrollment))->toBe(EnrollmentStatus::NoShow);
+});
diff --git a/app-modules/events/tests/Feature/EventResourceTest.php b/app-modules/events/tests/Feature/EventResourceTest.php
index 229254d9d..f787d8e62 100644
--- a/app-modules/events/tests/Feature/EventResourceTest.php
+++ b/app-modules/events/tests/Feature/EventResourceTest.php
@@ -3,6 +3,8 @@
declare(strict_types=1);
use Filament\Facades\Filament;
+use Filament\Forms\Components\Select;
+use Filament\Forms\Components\TextInput;
use He4rt\Events\CheckIn\Enums\CheckInMethod;
use He4rt\Events\CheckIn\Models\CheckIn;
use He4rt\Events\CheckIn\Models\CheckInCode;
@@ -80,7 +82,7 @@
'enrollmentPolicy' => [
'enrollment_method' => EnrollmentMethod::Rsvp,
'check_in_method' => CheckInMethod::Manual,
- 'attendance_requirement' => AttendanceRequirement::AllDays,
+ 'attendance_requirement' => AttendanceRequirement::AnyDay,
'xp_on_confirmed' => 0,
'xp_on_checked_in' => 0,
'xp_on_attended' => 0,
@@ -96,6 +98,37 @@
->and($event->enrollmentPolicy->enrollment_method)->toBe(EnrollmentMethod::Rsvp);
});
+test('when creating a same-day event, then attendance requirement options only allow any day', function (): void {
+ $startsAt = now()->addDay()->setTime(9, 0);
+
+ livewire(CreateEvent::class)
+ ->fillForm([
+ 'starts_at' => $startsAt,
+ 'ends_at' => $startsAt->clone()->setTime(18, 0),
+ ])
+ ->assertFormFieldExists(
+ 'enrollmentPolicy.attendance_requirement',
+ fn (Select $field): bool => array_keys($field->getOptions()) === [AttendanceRequirement::AnyDay->value],
+ );
+});
+
+test('when creating a multi-day event, then minimum days cannot exceed event days', function (): void {
+ $startsAt = now()->addDay()->setTime(9, 0);
+
+ livewire(CreateEvent::class)
+ ->fillForm([
+ 'starts_at' => $startsAt,
+ 'ends_at' => $startsAt->clone()->addDays(2)->setTime(18, 0),
+ 'enrollmentPolicy' => [
+ 'attendance_requirement' => AttendanceRequirement::MinimumDays,
+ ],
+ ])
+ ->assertFormFieldExists(
+ 'enrollmentPolicy.minimum_days',
+ fn (TextInput $field): bool => $field->getMaxValue() === 3,
+ );
+});
+
test('when submitting the create form with a duplicate slug for the same tenant, then validation fails', function (): void {
$tenant = Filament::getTenant();
$startsAt = now()->addDay();
@@ -113,7 +146,7 @@
'enrollmentPolicy' => [
'enrollment_method' => EnrollmentMethod::Rsvp,
'check_in_method' => CheckInMethod::Manual,
- 'attendance_requirement' => AttendanceRequirement::AllDays,
+ 'attendance_requirement' => AttendanceRequirement::AnyDay,
'xp_on_confirmed' => 0,
'xp_on_checked_in' => 0,
'xp_on_attended' => 0,
@@ -125,7 +158,10 @@
test('when submitting the edit form with a new title, then it is updated in the database', function (): void {
$event = Event::factory()
- ->has(EnrollmentPolicy::factory(), 'enrollmentPolicy')
+ ->has(EnrollmentPolicy::factory()->state([
+ 'attendance_requirement' => AttendanceRequirement::AnyDay,
+ 'minimum_days' => null,
+ ]), 'enrollmentPolicy')
->create(['title' => 'Old Title']);
livewire(EditEvent::class, ['record' => $event->getRouteKey()])
diff --git a/app-modules/panel-admin/src/Filament/Resources/Events/RelationManagers/Actions/OverrideEnrollmentStatusAction.php b/app-modules/panel-admin/src/Filament/Resources/Events/RelationManagers/Actions/OverrideEnrollmentStatusAction.php
new file mode 100644
index 000000000..9d1f7938d
--- /dev/null
+++ b/app-modules/panel-admin/src/Filament/Resources/Events/RelationManagers/Actions/OverrideEnrollmentStatusAction.php
@@ -0,0 +1,62 @@
+label('Override Status')
+ ->icon(Heroicon::OutlinedPencilSquare)
+ ->color('warning')
+ ->visible(fn (Enrollment $record): bool => OverrideEnrollmentStatusDomainAction::allowedTargetsFor($record->status) !== [])
+ ->schema([
+ Select::make('to_status')
+ ->label('New Status')
+ ->options(fn (Enrollment $record): array => collect(OverrideEnrollmentStatusDomainAction::allowedTargetsFor($record->status))
+ ->mapWithKeys(fn (EnrollmentStatus $s): array => [$s->value => $s->getLabel()])
+ ->all())
+ ->required(),
+ Textarea::make('reason')
+ ->label('Reason')
+ ->required()
+ ->minLength(3)
+ ->rows(3),
+ ])
+ ->action(function (Enrollment $record, array $data): void {
+ resolve(OverrideEnrollmentStatusDomainAction::class)->handle(
+ new OverrideEnrollmentStatusDTO(
+ enrollment: $record,
+ fromStatus: $record->status,
+ toStatus: EnrollmentStatus::from($data['to_status']),
+ actorId: (string) auth()->id(),
+ reason: $data['reason'],
+ ),
+ );
+
+ Notification::make()
+ ->success()
+ ->title('Enrollment status overridden.')
+ ->send();
+ });
+ }
+
+ public static function getDefaultName(): string
+ {
+ return 'overrideStatus';
+ }
+}
diff --git a/app-modules/panel-admin/src/Filament/Resources/Events/RelationManagers/EnrollmentsRelationManager.php b/app-modules/panel-admin/src/Filament/Resources/Events/RelationManagers/EnrollmentsRelationManager.php
index e87498d5b..9b322d5d8 100644
--- a/app-modules/panel-admin/src/Filament/Resources/Events/RelationManagers/EnrollmentsRelationManager.php
+++ b/app-modules/panel-admin/src/Filament/Resources/Events/RelationManagers/EnrollmentsRelationManager.php
@@ -22,6 +22,7 @@
use He4rt\Events\Enrollment\Enums\EnrollmentStatus;
use He4rt\Events\Enrollment\Models\Enrollment;
use He4rt\Events\Event\Models\Event;
+use He4rt\PanelAdmin\Filament\Resources\Events\RelationManagers\Actions\OverrideEnrollmentStatusAction;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Collection;
use Illuminate\Support\Facades\Date;
@@ -84,6 +85,7 @@ public function table(Table $table): Table
])
->recordActions([
$this->checkInAction(),
+ OverrideEnrollmentStatusAction::make(),
DeleteAction::make(),
])
->toolbarActions([
diff --git a/app-modules/panel-admin/src/Filament/Resources/Events/Schemas/EventForm.php b/app-modules/panel-admin/src/Filament/Resources/Events/Schemas/EventForm.php
index e4d353aff..c55277a3f 100644
--- a/app-modules/panel-admin/src/Filament/Resources/Events/Schemas/EventForm.php
+++ b/app-modules/panel-admin/src/Filament/Resources/Events/Schemas/EventForm.php
@@ -19,6 +19,7 @@
use He4rt\Events\Event\Enums\EventStatus;
use He4rt\Events\Event\Enums\EventType;
use He4rt\Events\Event\Models\Event;
+use Illuminate\Support\Facades\Date;
use Illuminate\Validation\Rules\Unique;
final class EventForm
@@ -114,14 +115,19 @@ public static function configure(Schema $schema): Schema
Select::make('attendance_requirement')
->label('Attendance Requirement')
- ->options(AttendanceRequirement::class)
+ ->options(fn (Get $get): array => self::attendanceRequirementOptions($get))
+ ->live()
->required(),
TextInput::make('minimum_days')
->label('Minimum Days')
+ ->helperText('Required when attendance requirement is "Minimum Days". Default 1, max = event days.')
->integer()
->minValue(1)
- ->nullable(),
+ ->maxValue(fn (Get $get): ?int => self::minimumDaysMaxValue($get))
+ ->default(1)
+ ->required(fn (Get $get): bool => $get('attendance_requirement') === AttendanceRequirement::MinimumDays->value)
+ ->visible(fn (Get $get): bool => $get('attendance_requirement') === AttendanceRequirement::MinimumDays->value),
TextInput::make('cancellation_deadline_hours')
->label('Cancellation Deadline (hours before event)')
@@ -157,4 +163,48 @@ public static function configure(Schema $schema): Schema
]),
]);
}
+
+ /**
+ * @return array
+ */
+ private static function attendanceRequirementOptions(Get $get): array
+ {
+ $all = [
+ AttendanceRequirement::AllDays->value => __('events::enums.attendance_requirement.all_days'),
+ AttendanceRequirement::AnyDay->value => __('events::enums.attendance_requirement.any_day'),
+ AttendanceRequirement::MinimumDays->value => __('events::enums.attendance_requirement.minimum_days'),
+ ];
+
+ $eventDays = self::eventDays($get);
+
+ if ($eventDays === null) {
+ return $all;
+ }
+
+ if ($eventDays === 1) {
+ return [AttendanceRequirement::AnyDay->value => $all[AttendanceRequirement::AnyDay->value]];
+ }
+
+ return $all;
+ }
+
+ private static function minimumDaysMaxValue(Get $get): ?int
+ {
+ return self::eventDays($get);
+ }
+
+ private static function eventDays(Get $get): ?int
+ {
+ $startsAt = $get('../starts_at');
+ $endsAt = $get('../ends_at');
+
+ if ($startsAt === null || $endsAt === null) {
+ return null;
+ }
+
+ $startsDay = Date::parse($startsAt)->startOfDay();
+ $endsDay = Date::parse($endsAt)->startOfDay();
+
+ return (int) $startsDay->diffInDays($endsDay) + 1;
+ }
}
diff --git a/package-lock.json b/package-lock.json
index 710f058a1..9c0973c6a 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -36,7 +36,6 @@
"integrity": "sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==",
"dev": true,
"license": "MIT",
- "peer": true,
"dependencies": {
"@emnapi/wasi-threads": "1.2.1",
"tslib": "^2.4.0"
@@ -48,7 +47,6 @@
"integrity": "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==",
"dev": true,
"license": "MIT",
- "peer": true,
"dependencies": {
"tslib": "^2.4.0"
}
@@ -162,7 +160,6 @@
"integrity": "sha512-x9l65fCE/pgoET6RQowgdgG8Xmzs44z6j6Hhg3coINCyCw9JBGJ5ZzMR2XHAM2jmAdbJAIgqB6cUn4/3W3XLTA==",
"dev": true,
"license": "MIT",
- "peer": true,
"dependencies": {
"linguist-languages": "^8.0.0",
"php-parser": "^3.2.5"
@@ -1644,7 +1641,6 @@
"integrity": "sha512-7igPTM53cGHMW8xWuVTydi2KO233VFiTNyF5hLJqpilHfmn8C8gPf+PS7dUT64YcXFbiMGZxS9pCSxL/Dxm/Jw==",
"dev": true,
"license": "MIT",
- "peer": true,
"bin": {
"prettier": "bin/prettier.cjs"
},
@@ -1689,7 +1685,6 @@
"integrity": "sha512-UKii4RjY05SNt/WQi6/NcOn/LsT0/ILLXsxygjbRg5/YZelsSu5jTqorYHPDGq4nZy5q5hpCu+XdGZ1xaJEQgw==",
"dev": true,
"license": "MIT",
- "peer": true,
"engines": {
"node": ">=20.19"
},
@@ -1945,8 +1940,7 @@
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.3.0.tgz",
"integrity": "sha512-y6nxMGB1nMW9R6k96e5gdIFzcfL/gTJRNaqGes1YvkLnPVXzWgbqFF2yLC0T8G774n24cx3Pe8XrKoniCOAH+Q==",
"dev": true,
- "license": "MIT",
- "peer": true
+ "license": "MIT"
},
"node_modules/tapable": {
"version": "2.3.3",
@@ -2029,7 +2023,6 @@
"integrity": "sha512-h9bXPmJichP5fLmVQo3PyaGSDE2n3aPuomeAlVRm0JLmt4rY6zmPKd59HYI4LNW8oTK7tlTsuC7l/m7awx9Jcw==",
"dev": true,
"license": "MIT",
- "peer": true,
"dependencies": {
"lightningcss": "^1.32.0",
"picomatch": "^4.0.4",
From 98342e949e11e9b6d150bfb00088383c3d282576 Mon Sep 17 00:00:00 2001
From: Gabriel
Date: Sun, 14 Jun 2026 18:08:47 -0300
Subject: [PATCH 12/18] feat(events): bot check-in integration (#326)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
## Summary
Implements the **Events-module side** of bot-triggered check-in
(Discord/Twitch). The bot dispatches a `CheckInRequested` domain event;
Events listens (`HandleBotCheckIn`), resolves the user and enrollment,
processes the check-in by reusing the numeric-code validation, and
dispatches a `CheckInProcessed` response so the bot can reply to the
member in chat. The bot is pure transport; Events owns the rules
(ADR-0005).
**Flow:** `CheckInRequested` → `HandleBotCheckIn` →
`NumericCodeCheckInAction` → `CheckInProcessed`
Closes #248
---
## What changed — `app-modules/events`
| File | Role |
|---|---|
| `src/CheckIn/Events/CheckInRequested.php` | Inbound domain event
dispatched by the bot. Payload: `provider`, `externalUserId`, `code`,
`tenantId`, `eventId?` (reserved, see below), `channelContext`. |
| `src/CheckIn/Events/CheckInProcessed.php` | Outbound response event
for the bot to reply in chat. Payload: `provider`, `externalUserId`,
`status`, `message`, `channelContext`, `enrollmentId?`. |
| `src/CheckIn/Enums/BotCheckInStatus.php` | `Success` / `Error` status
for the response event. |
| `src/CheckIn/Listeners/HandleBotCheckIn.php` | Resolves the user via
Identity (`FindExternalIdentity`), resolves the enrollment, delegates to
`NumericCodeCheckInAction`, dispatches `CheckInProcessed`. Catches
`ExternalIdentityException` / `CheckInException` → error response. |
| `src/CheckIn/Exceptions/CheckInException.php` | Adds
`botNoActiveEnrollment()` and `botMultipleActiveEvents()` factories. |
| `lang/en/check_in.php`, `lang/pt_BR/check_in.php` | Bot-facing
messages (user not linked, no active enrollment, success) + reworded
multiple-events message. |
| `src/EventsServiceProvider.php` | Registers `CheckInRequested →
HandleBotCheckIn`. |
### Event resolution (the important bit)
The listener finds the user's check-in-able enrollments
(`confirmed`/`checked_in`) for a published event happening today, scoped
to the tenant. When the user has **more than one** active event the same
day (e.g. a morning and an evening online event), the **typed code
identifies which event** — each `CheckInCode` belongs to a single event.
This replaces an earlier **date-only** resolution that errored on *any*
2-same-day case, which would have wrongly blocked the legitimate
morning+evening scenario. The single-event flow is unchanged; only a
genuine collision (same code value on two same-day events the user is
enrolled in) returns an ambiguity error pointing to the panel.
---
## Acceptance criteria
- [x] `CheckInRequested` defined with proper payload
- [x] Events listener resolves the user and processes the check-in
- [x] `CheckInProcessed` response dispatched with success/error status
- [x] Numeric code validation applies the same rules as the web flow
(delegates to `NumericCodeCheckInAction`)
- [x] Error cases handled: user not linked, not enrolled, invalid code,
already checked in, ambiguous code
- [x] Listener registered via `EventsServiceProvider` — **manual
registration** (Laravel event auto-discovery is disabled app-wide;
matches the existing `GenerateQrTokenOnConfirmed` pattern)
- [x] Feature tests (see Test plan)
- [x] Pint passes
---
## Test plan
```bash
# Bot check-in tests
vendor/bin/pest
app-modules/events/tests/Feature/HandleBotCheckInTest.php
# Full events module (must stay green)
vendor/bin/pest app-modules/events/tests
# Style + static analysis
make pint && make phpstan
```
Results:
- `HandleBotCheckInTest`: **11/11**
- Events module: **133/133** (0 regressions)
- Pint: clean · PHPStan: **0 errors** · Rector (dry-run): clean
Scenarios covered: success + success response · unlinked user · invalid
code · no active enrollment · event not today · multiple events → typed
code selects the right one · multiple events + non-matching code →
invalid · same code on two events → ambiguity error · already checked in
today · waitlisted enrollment ignored.
---
## Out of scope (separate issues)
- The **bot-side command** that dispatches `CheckInRequested`
(`app-modules/bot-discord/`) — separate concern, per the issue.
- **Rate limiting** on the bot path — deferred to the bot command (the
web flow already throttles 5/60s).
- **Explicit event selection** (`eventId` / "path A") — the field stays
reserved in the payload but is not honored; the typed code already
resolves the event.
---
app-modules/events/lang/en/check_in.php | 4 +
app-modules/events/lang/pt_BR/check_in.php | 4 +
.../src/CheckIn/Enums/BotCheckInStatus.php | 11 +
.../src/CheckIn/Events/CheckInProcessed.php | 26 ++
.../src/CheckIn/Events/CheckInRequested.php | 25 ++
.../CheckIn/Exceptions/CheckInException.php | 16 +
.../CheckIn/Listeners/HandleBotCheckIn.php | 118 ++++++
.../events/src/EventsServiceProvider.php | 3 +
.../tests/Feature/HandleBotCheckInTest.php | 371 ++++++++++++++++++
9 files changed, 578 insertions(+)
create mode 100644 app-modules/events/src/CheckIn/Enums/BotCheckInStatus.php
create mode 100644 app-modules/events/src/CheckIn/Events/CheckInProcessed.php
create mode 100644 app-modules/events/src/CheckIn/Events/CheckInRequested.php
create mode 100644 app-modules/events/src/CheckIn/Listeners/HandleBotCheckIn.php
create mode 100644 app-modules/events/tests/Feature/HandleBotCheckInTest.php
diff --git a/app-modules/events/lang/en/check_in.php b/app-modules/events/lang/en/check_in.php
index 9a8c5e3d8..0966e9c45 100644
--- a/app-modules/events/lang/en/check_in.php
+++ b/app-modules/events/lang/en/check_in.php
@@ -15,4 +15,8 @@
'check_in_code_exhausted' => 'Code has reached maximum uses.',
'check_in_code_wrong_date' => 'Code is not valid for today.',
'check_in_code_rate_limited' => 'Too many attempts. Try again in :seconds seconds.',
+ 'bot_user_not_linked' => 'Your account is not linked to this platform.',
+ 'bot_no_active_enrollment' => 'No active enrollment found for an event happening today.',
+ 'bot_multiple_active_events' => 'More than one event uses this code today. Please check in through the panel.',
+ 'bot_check_in_success' => 'Check-in confirmed. See you there!',
];
diff --git a/app-modules/events/lang/pt_BR/check_in.php b/app-modules/events/lang/pt_BR/check_in.php
index d30c8916f..5c730a408 100644
--- a/app-modules/events/lang/pt_BR/check_in.php
+++ b/app-modules/events/lang/pt_BR/check_in.php
@@ -15,4 +15,8 @@
'check_in_code_exhausted' => 'O código atingiu o limite máximo de usos.',
'check_in_code_wrong_date' => 'O código não é válido para hoje.',
'check_in_code_rate_limited' => 'Muitas tentativas. Tente novamente em :seconds segundos.',
+ 'bot_user_not_linked' => 'Sua conta não está vinculada a esta plataforma.',
+ 'bot_no_active_enrollment' => 'Nenhuma inscrição ativa encontrada para um evento de hoje.',
+ 'bot_multiple_active_events' => 'Mais de um evento usa esse código hoje. Faça o check-in pelo painel.',
+ 'bot_check_in_success' => 'Check-in confirmado. Até lá!',
];
diff --git a/app-modules/events/src/CheckIn/Enums/BotCheckInStatus.php b/app-modules/events/src/CheckIn/Enums/BotCheckInStatus.php
new file mode 100644
index 000000000..81c5065f6
--- /dev/null
+++ b/app-modules/events/src/CheckIn/Enums/BotCheckInStatus.php
@@ -0,0 +1,11 @@
+ $channelContext Echoed back from the request for reply routing.
+ */
+ public function __construct(
+ public IdentityProvider $provider,
+ public string $externalUserId,
+ public BotCheckInStatus $status,
+ public string $message,
+ public array $channelContext = [],
+ public ?string $enrollmentId = null,
+ ) {}
+}
diff --git a/app-modules/events/src/CheckIn/Events/CheckInRequested.php b/app-modules/events/src/CheckIn/Events/CheckInRequested.php
new file mode 100644
index 000000000..db4eb94f0
--- /dev/null
+++ b/app-modules/events/src/CheckIn/Events/CheckInRequested.php
@@ -0,0 +1,25 @@
+ $channelContext Opaque routing data the bot uses to reply to the member.
+ */
+ public function __construct(
+ public IdentityProvider $provider,
+ public string $externalUserId,
+ public string $code,
+ public string $tenantId,
+ public ?string $eventId = null,
+ public array $channelContext = [],
+ ) {}
+}
diff --git a/app-modules/events/src/CheckIn/Exceptions/CheckInException.php b/app-modules/events/src/CheckIn/Exceptions/CheckInException.php
index 561fe6d7d..2cc097fc0 100644
--- a/app-modules/events/src/CheckIn/Exceptions/CheckInException.php
+++ b/app-modules/events/src/CheckIn/Exceptions/CheckInException.php
@@ -104,4 +104,20 @@ public static function checkInCodeRateLimited(int $seconds): self
Response::HTTP_TOO_MANY_REQUESTS,
);
}
+
+ public static function botNoActiveEnrollment(): self
+ {
+ return new self(
+ __('events::check_in.bot_no_active_enrollment'),
+ Response::HTTP_NOT_FOUND,
+ );
+ }
+
+ public static function botMultipleActiveEvents(): self
+ {
+ return new self(
+ __('events::check_in.bot_multiple_active_events'),
+ Response::HTTP_UNPROCESSABLE_ENTITY,
+ );
+ }
}
diff --git a/app-modules/events/src/CheckIn/Listeners/HandleBotCheckIn.php b/app-modules/events/src/CheckIn/Listeners/HandleBotCheckIn.php
new file mode 100644
index 000000000..3b614d0a5
--- /dev/null
+++ b/app-modules/events/src/CheckIn/Listeners/HandleBotCheckIn.php
@@ -0,0 +1,118 @@
+resolveUserId($event);
+ $enrollment = $this->resolveEnrollment($event, $userId);
+
+ $checkIn = $this->numericCodeCheckIn->handle(
+ new NumericCodeCheckInDTO(
+ enrollment: $enrollment,
+ code: $event->code,
+ eventDate: now(),
+ ),
+ );
+
+ $this->dispatchSuccess($event, $checkIn->enrollment_id);
+ } catch (ExternalIdentityException) {
+ $this->dispatchError($event, __('events::check_in.bot_user_not_linked'));
+ } catch (CheckInException $exception) {
+ $this->dispatchError($event, $exception->getMessage());
+ }
+ }
+
+ private function resolveUserId(CheckInRequested $event): string
+ {
+ return $this->findExternalIdentity->handle(
+ provider: $event->provider->value,
+ providerId: $event->externalUserId,
+ tenantId: $event->tenantId,
+ )->model_id;
+ }
+
+ private function resolveEnrollment(CheckInRequested $event, string $userId): Enrollment
+ {
+ $today = now()->toDateString();
+
+ $enrollments = Enrollment::query()
+ ->where('user_id', $userId)
+ ->whereIn('status', [EnrollmentStatus::Confirmed, EnrollmentStatus::CheckedIn])
+ ->whereHas('event', function (Builder $query) use ($event, $today): void {
+ $query->where('tenant_id', $event->tenantId)
+ ->where('status', EventStatus::Published)
+ ->whereDate('starts_at', '<=', $today)
+ ->whereDate('ends_at', '>=', $today);
+ })
+ ->get();
+
+ $enrollment = $enrollments->first();
+ throw_unless($enrollment, CheckInException::botNoActiveEnrollment());
+
+ if ($enrollments->count() === 1) {
+ return $enrollment;
+ }
+
+ $matchingEventIds = CheckInCode::query()
+ ->whereIn('event_id', $enrollments->pluck('event_id'))
+ ->where('code', $event->code)
+ ->whereDate('event_date', $today)
+ ->pluck('event_id');
+
+ $matching = $enrollments->whereIn('event_id', $matchingEventIds);
+
+ $matched = $matching->first();
+ throw_unless($matched, CheckInException::invalidCheckInCode());
+ throw_if($matching->count() > 1, CheckInException::botMultipleActiveEvents());
+
+ return $matched;
+ }
+
+ private function dispatchSuccess(CheckInRequested $event, string $enrollmentId): void
+ {
+ event(new CheckInProcessed(
+ provider: $event->provider,
+ externalUserId: $event->externalUserId,
+ status: BotCheckInStatus::Success,
+ message: __('events::check_in.bot_check_in_success'),
+ channelContext: $event->channelContext,
+ enrollmentId: $enrollmentId,
+ ));
+ }
+
+ private function dispatchError(CheckInRequested $event, string $message): void
+ {
+ event(new CheckInProcessed(
+ provider: $event->provider,
+ externalUserId: $event->externalUserId,
+ status: BotCheckInStatus::Error,
+ message: $message,
+ channelContext: $event->channelContext,
+ ));
+ }
+}
diff --git a/app-modules/events/src/EventsServiceProvider.php b/app-modules/events/src/EventsServiceProvider.php
index 0aa2f2e6b..55745965b 100644
--- a/app-modules/events/src/EventsServiceProvider.php
+++ b/app-modules/events/src/EventsServiceProvider.php
@@ -4,7 +4,9 @@
namespace He4rt\Events;
+use He4rt\Events\CheckIn\Events\CheckInRequested;
use He4rt\Events\CheckIn\Listeners\GenerateQrTokenOnConfirmed;
+use He4rt\Events\CheckIn\Listeners\HandleBotCheckIn;
use He4rt\Events\Console\Commands\ClosePendingEventsCommand;
use He4rt\Events\Enrollment\Events\EnrollmentConfirmed;
use Illuminate\Console\Scheduling\Schedule;
@@ -24,6 +26,7 @@ public function boot(): void
$this->loadTranslationsFrom(__DIR__.'/../lang', 'events');
Event::listen(EnrollmentConfirmed::class, GenerateQrTokenOnConfirmed::class);
+ Event::listen(CheckInRequested::class, HandleBotCheckIn::class);
if ($this->app->runningInConsole()) {
$this->app->make(Schedule::class)
diff --git a/app-modules/events/tests/Feature/HandleBotCheckInTest.php b/app-modules/events/tests/Feature/HandleBotCheckInTest.php
new file mode 100644
index 000000000..52e3ba059
--- /dev/null
+++ b/app-modules/events/tests/Feature/HandleBotCheckInTest.php
@@ -0,0 +1,371 @@
+setTime(12, 0));
+});
+
+afterEach(function (): void {
+ Date::setTestNow();
+});
+
+function createBotCheckInScenario(array $codeOverrides = []): array
+{
+ $tenant = Tenant::factory()->create();
+ $participant = User::factory()->create();
+ $externalId = '900900900';
+ $startsAt = now()->setTime(9, 0);
+
+ $event = Event::factory()
+ ->for($tenant)
+ ->create([
+ 'starts_at' => $startsAt,
+ 'ends_at' => $startsAt->clone()->setTime(18, 0),
+ 'status' => EventStatus::Published,
+ ]);
+
+ $enrollment = Enrollment::factory()->create([
+ 'event_id' => $event->id,
+ 'user_id' => $participant->id,
+ 'status' => EnrollmentStatus::Confirmed,
+ 'confirmed_at' => now(),
+ ]);
+
+ ExternalIdentity::factory()->create([
+ 'tenant_id' => $tenant->id,
+ 'provider' => IdentityProvider::Discord,
+ 'external_account_id' => $externalId,
+ 'model_type' => (new User)->getMorphClass(),
+ 'model_id' => $participant->id,
+ ]);
+
+ $code = CheckInCode::factory()->create(array_merge([
+ 'event_id' => $event->id,
+ 'event_date' => now()->toDateString(),
+ 'code' => '123456',
+ 'starts_at' => now()->subHour(),
+ 'expires_at' => now()->addHours(2),
+ 'max_uses' => null,
+ 'uses_count' => 0,
+ ], $codeOverrides));
+
+ return ['tenant' => $tenant, 'participant' => $participant, 'externalId' => $externalId, 'event' => $event, 'enrollment' => $enrollment, 'code' => $code];
+}
+
+test('dispatching CheckInRequested is handled by the registered listener', function (): void {
+ $scenario = createBotCheckInScenario();
+
+ event(new CheckInRequested(
+ provider: IdentityProvider::Discord,
+ externalUserId: $scenario['externalId'],
+ code: '123456',
+ tenantId: $scenario['tenant']->id,
+ ));
+
+ expect($scenario['enrollment']->fresh()->status)->toBe(EnrollmentStatus::CheckedIn);
+ $this->assertDatabaseHas('events_check_ins', [
+ 'enrollment_id' => $scenario['enrollment']->id,
+ 'method' => CheckInMethod::NumericCode->value,
+ ]);
+});
+
+test('successful bot check-in records the check-in and dispatches a success response', function (): void {
+ EventFacade::fake([CheckInProcessed::class]);
+ $scenario = createBotCheckInScenario();
+
+ resolve(HandleBotCheckIn::class)->handle(new CheckInRequested(
+ provider: IdentityProvider::Discord,
+ externalUserId: $scenario['externalId'],
+ code: '123456',
+ tenantId: $scenario['tenant']->id,
+ channelContext: ['channel_id' => 'C1'],
+ ));
+
+ expect($scenario['enrollment']->fresh()->status)->toBe(EnrollmentStatus::CheckedIn)
+ ->and($scenario['code']->fresh()->uses_count)->toBe(1);
+
+ $this->assertDatabaseHas('events_check_ins', [
+ 'enrollment_id' => $scenario['enrollment']->id,
+ 'method' => CheckInMethod::NumericCode->value,
+ ]);
+
+ EventFacade::assertDispatched(fn (CheckInProcessed $event): bool => $event->status === BotCheckInStatus::Success
+ && $event->enrollmentId === $scenario['enrollment']->id
+ && $event->externalUserId === $scenario['externalId']
+ && $event->channelContext === ['channel_id' => 'C1']
+ && $event->message === __('events::check_in.bot_check_in_success'));
+});
+
+test('bot check-in for an unlinked external user dispatches an error and records nothing', function (): void {
+ EventFacade::fake([CheckInProcessed::class]);
+ $scenario = createBotCheckInScenario();
+
+ resolve(HandleBotCheckIn::class)->handle(new CheckInRequested(
+ provider: IdentityProvider::Discord,
+ externalUserId: 'unlinked-user',
+ code: '123456',
+ tenantId: $scenario['tenant']->id,
+ ));
+
+ expect($scenario['enrollment']->fresh()->status)->toBe(EnrollmentStatus::Confirmed);
+ $this->assertDatabaseCount('events_check_ins', 0);
+
+ EventFacade::assertDispatched(fn (CheckInProcessed $event): bool => $event->status === BotCheckInStatus::Error
+ && $event->enrollmentId === null
+ && $event->message === __('events::check_in.bot_user_not_linked'));
+});
+
+test('bot check-in with an invalid code dispatches an error', function (): void {
+ EventFacade::fake([CheckInProcessed::class]);
+ $scenario = createBotCheckInScenario();
+
+ resolve(HandleBotCheckIn::class)->handle(new CheckInRequested(
+ provider: IdentityProvider::Discord,
+ externalUserId: $scenario['externalId'],
+ code: '000000',
+ tenantId: $scenario['tenant']->id,
+ ));
+
+ expect($scenario['enrollment']->fresh()->status)->toBe(EnrollmentStatus::Confirmed);
+
+ EventFacade::assertDispatched(fn (CheckInProcessed $event): bool => $event->status === BotCheckInStatus::Error
+ && $event->message === __('events::check_in.invalid_check_in_code'));
+});
+
+test('bot check-in for a user with no enrollment dispatches no active enrollment error', function (): void {
+ EventFacade::fake([CheckInProcessed::class]);
+ $scenario = createBotCheckInScenario();
+ $scenario['enrollment']->delete();
+
+ resolve(HandleBotCheckIn::class)->handle(new CheckInRequested(
+ provider: IdentityProvider::Discord,
+ externalUserId: $scenario['externalId'],
+ code: '123456',
+ tenantId: $scenario['tenant']->id,
+ ));
+
+ EventFacade::assertDispatched(fn (CheckInProcessed $event): bool => $event->status === BotCheckInStatus::Error
+ && $event->message === __('events::check_in.bot_no_active_enrollment'));
+});
+
+test('bot check-in is rejected when the enrollment event is not happening today', function (): void {
+ EventFacade::fake([CheckInProcessed::class]);
+ $scenario = createBotCheckInScenario();
+ $futureStart = now()->addDays(5)->setTime(9, 0);
+ $scenario['event']->update([
+ 'starts_at' => $futureStart,
+ 'ends_at' => $futureStart->clone()->setTime(18, 0),
+ ]);
+
+ resolve(HandleBotCheckIn::class)->handle(new CheckInRequested(
+ provider: IdentityProvider::Discord,
+ externalUserId: $scenario['externalId'],
+ code: '123456',
+ tenantId: $scenario['tenant']->id,
+ ));
+
+ EventFacade::assertDispatched(fn (CheckInProcessed $event): bool => $event->status === BotCheckInStatus::Error
+ && $event->message === __('events::check_in.bot_no_active_enrollment'));
+});
+
+test('bot check-in with multiple events today checks into the one matching the typed code', function (): void {
+ EventFacade::fake([CheckInProcessed::class]);
+ $scenario = createBotCheckInScenario();
+
+ $afternoonStart = now()->setTime(14, 0);
+ $afternoonEvent = Event::factory()
+ ->for($scenario['tenant'])
+ ->create([
+ 'starts_at' => $afternoonStart,
+ 'ends_at' => $afternoonStart->clone()->setTime(20, 0),
+ 'status' => EventStatus::Published,
+ ]);
+
+ $afternoonEnrollment = Enrollment::factory()->create([
+ 'event_id' => $afternoonEvent->id,
+ 'user_id' => $scenario['participant']->id,
+ 'status' => EnrollmentStatus::Confirmed,
+ 'confirmed_at' => now(),
+ ]);
+
+ CheckInCode::factory()->create([
+ 'event_id' => $afternoonEvent->id,
+ 'event_date' => now()->toDateString(),
+ 'code' => '654321',
+ 'starts_at' => now()->subHour(),
+ 'expires_at' => now()->addHours(8),
+ ]);
+
+ // User types the MORNING code — only that event owns it.
+ resolve(HandleBotCheckIn::class)->handle(new CheckInRequested(
+ provider: IdentityProvider::Discord,
+ externalUserId: $scenario['externalId'],
+ code: '123456',
+ tenantId: $scenario['tenant']->id,
+ ));
+
+ expect($scenario['enrollment']->fresh()->status)->toBe(EnrollmentStatus::CheckedIn)
+ ->and($afternoonEnrollment->fresh()->status)->toBe(EnrollmentStatus::Confirmed);
+
+ EventFacade::assertDispatched(fn (CheckInProcessed $event): bool => $event->status === BotCheckInStatus::Success
+ && $event->enrollmentId === $scenario['enrollment']->id);
+});
+
+test('bot check-in with multiple events today and a non-matching code errors as invalid', function (): void {
+ EventFacade::fake([CheckInProcessed::class]);
+ $scenario = createBotCheckInScenario();
+
+ $startsAt = now()->setTime(9, 0);
+ $secondEvent = Event::factory()
+ ->for($scenario['tenant'])
+ ->create([
+ 'starts_at' => $startsAt,
+ 'ends_at' => $startsAt->clone()->setTime(18, 0),
+ 'status' => EventStatus::Published,
+ ]);
+
+ Enrollment::factory()->create([
+ 'event_id' => $secondEvent->id,
+ 'user_id' => $scenario['participant']->id,
+ 'status' => EnrollmentStatus::Confirmed,
+ 'confirmed_at' => now(),
+ ]);
+
+ CheckInCode::factory()->create([
+ 'event_id' => $secondEvent->id,
+ 'event_date' => now()->toDateString(),
+ 'code' => '654321',
+ 'starts_at' => now()->subHour(),
+ 'expires_at' => now()->addHours(2),
+ ]);
+
+ resolve(HandleBotCheckIn::class)->handle(new CheckInRequested(
+ provider: IdentityProvider::Discord,
+ externalUserId: $scenario['externalId'],
+ code: '999999',
+ tenantId: $scenario['tenant']->id,
+ ));
+
+ expect($scenario['enrollment']->fresh()->status)->toBe(EnrollmentStatus::Confirmed);
+
+ EventFacade::assertDispatched(fn (CheckInProcessed $event): bool => $event->status === BotCheckInStatus::Error
+ && $event->message === __('events::check_in.invalid_check_in_code'));
+});
+
+test('bot check-in errors when the same code exists on multiple events today', function (): void {
+ EventFacade::fake([CheckInProcessed::class]);
+ $scenario = createBotCheckInScenario();
+
+ $startsAt = now()->setTime(9, 0);
+ $secondEvent = Event::factory()
+ ->for($scenario['tenant'])
+ ->create([
+ 'starts_at' => $startsAt,
+ 'ends_at' => $startsAt->clone()->setTime(18, 0),
+ 'status' => EventStatus::Published,
+ ]);
+
+ Enrollment::factory()->create([
+ 'event_id' => $secondEvent->id,
+ 'user_id' => $scenario['participant']->id,
+ 'status' => EnrollmentStatus::Confirmed,
+ 'confirmed_at' => now(),
+ ]);
+
+ CheckInCode::factory()->create([
+ 'event_id' => $secondEvent->id,
+ 'event_date' => now()->toDateString(),
+ 'code' => '123456',
+ 'starts_at' => now()->subHour(),
+ 'expires_at' => now()->addHours(2),
+ ]);
+
+ resolve(HandleBotCheckIn::class)->handle(new CheckInRequested(
+ provider: IdentityProvider::Discord,
+ externalUserId: $scenario['externalId'],
+ code: '123456',
+ tenantId: $scenario['tenant']->id,
+ ));
+
+ EventFacade::assertDispatched(fn (CheckInProcessed $event): bool => $event->status === BotCheckInStatus::Error
+ && $event->message === __('events::check_in.bot_multiple_active_events'));
+});
+
+test('bot check-in when already checked in today dispatches an error', function (): void {
+ EventFacade::fake([CheckInProcessed::class]);
+ $scenario = createBotCheckInScenario();
+
+ $request = new CheckInRequested(
+ provider: IdentityProvider::Discord,
+ externalUserId: $scenario['externalId'],
+ code: '123456',
+ tenantId: $scenario['tenant']->id,
+ );
+
+ resolve(HandleBotCheckIn::class)->handle($request);
+ resolve(HandleBotCheckIn::class)->handle($request);
+
+ EventFacade::assertDispatched(fn (CheckInProcessed $event): bool => $event->status === BotCheckInStatus::Error
+ && $event->message === __('events::check_in.already_checked_in_for_date'));
+});
+
+test('bot check-in ignores a waitlisted enrollment and checks in the confirmed one', function (): void {
+ EventFacade::fake([CheckInProcessed::class]);
+ $scenario = createBotCheckInScenario();
+ $scenario['enrollment']->update(['status' => EnrollmentStatus::Waitlisted]);
+
+ $startsAt = now()->setTime(9, 0);
+ $confirmedEvent = Event::factory()
+ ->for($scenario['tenant'])
+ ->create([
+ 'starts_at' => $startsAt,
+ 'ends_at' => $startsAt->clone()->setTime(18, 0),
+ 'status' => EventStatus::Published,
+ ]);
+
+ $confirmedEnrollment = Enrollment::factory()->create([
+ 'event_id' => $confirmedEvent->id,
+ 'user_id' => $scenario['participant']->id,
+ 'status' => EnrollmentStatus::Confirmed,
+ 'confirmed_at' => now(),
+ ]);
+
+ CheckInCode::factory()->create([
+ 'event_id' => $confirmedEvent->id,
+ 'event_date' => now()->toDateString(),
+ 'code' => '123456',
+ 'starts_at' => now()->subHour(),
+ 'expires_at' => now()->addHours(2),
+ ]);
+
+ resolve(HandleBotCheckIn::class)->handle(new CheckInRequested(
+ provider: IdentityProvider::Discord,
+ externalUserId: $scenario['externalId'],
+ code: '123456',
+ tenantId: $scenario['tenant']->id,
+ ));
+
+ expect($confirmedEnrollment->fresh()->status)->toBe(EnrollmentStatus::CheckedIn)
+ ->and($scenario['enrollment']->fresh()->status)->toBe(EnrollmentStatus::Waitlisted);
+
+ EventFacade::assertDispatched(fn (CheckInProcessed $event): bool => $event->status === BotCheckInStatus::Success
+ && $event->enrollmentId === $confirmedEnrollment->id);
+});
From c8a1cc4608235bd286c5623dbb606936a4e62423 Mon Sep 17 00:00:00 2001
From: danielhe4rt
Date: Sun, 21 Jun 2026 14:34:35 -0300
Subject: [PATCH 13/18] chore(events): enum tricks
---
.../2026_05_16_200001_create_events_table.php | 8 ++++++--
...reate_events_enrollment_policies_table.php | 11 ++++++++---
...200003_create_events_enrollments_table.php | 5 ++++-
...te_events_enrollment_transitions_table.php | 8 +++++---
...6_200005_create_events_check_ins_table.php | 3 ++-
.../src/CheckIn/Enums/BotCheckInStatus.php | 4 ++++
.../src/CheckIn/Enums/CheckInMethod.php | 3 +++
.../Enums/AttendanceRequirement.php | 3 +++
.../src/Enrollment/Enums/EnrollmentMethod.php | 3 +++
.../src/Enrollment/Enums/EnrollmentStatus.php | 3 +++
.../src/Enrollment/Enums/TriggeredBy.php | 3 +++
.../events/src/Event/Enums/EventStatus.php | 3 +++
.../events/src/Event/Enums/EventType.php | 3 +++
app/Enums/Concerns/StringifyEnum.php | 19 +++++++++++++++++++
14 files changed, 69 insertions(+), 10 deletions(-)
create mode 100644 app/Enums/Concerns/StringifyEnum.php
diff --git a/app-modules/events/database/migrations/2026_05_16_200001_create_events_table.php b/app-modules/events/database/migrations/2026_05_16_200001_create_events_table.php
index 5226d8392..35380f507 100644
--- a/app-modules/events/database/migrations/2026_05_16_200001_create_events_table.php
+++ b/app-modules/events/database/migrations/2026_05_16_200001_create_events_table.php
@@ -2,6 +2,8 @@
declare(strict_types=1);
+use He4rt\Events\Event\Enums\EventStatus;
+use He4rt\Events\Event\Enums\EventType;
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
@@ -16,11 +18,13 @@ public function up(): void
$table->string('slug', 120);
$table->string('title', 200);
$table->longText('description')->nullable();
- $table->string('event_type', 20);
+ $table->string('event_type', 20)->comment(EventType::stringifyCases());
$table->string('location')->nullable();
$table->timestampTz('starts_at');
$table->timestampTz('ends_at');
- $table->string('status', 20)->default('draft');
+ $table->string('status', 20)
+ ->comment(EventStatus::stringifyCases())
+ ->default(EventStatus::Draft);
$table->timestampsTz();
$table->unique(['tenant_id', 'slug'], 'idx_events_tenant_slug');
$table->index(['tenant_id', 'starts_at'], 'idx_events_tenant_window');
diff --git a/app-modules/events/database/migrations/2026_05_16_200002_create_events_enrollment_policies_table.php b/app-modules/events/database/migrations/2026_05_16_200002_create_events_enrollment_policies_table.php
index 570807e53..7030d61b0 100644
--- a/app-modules/events/database/migrations/2026_05_16_200002_create_events_enrollment_policies_table.php
+++ b/app-modules/events/database/migrations/2026_05_16_200002_create_events_enrollment_policies_table.php
@@ -2,6 +2,9 @@
declare(strict_types=1);
+use He4rt\Events\CheckIn\Enums\CheckInMethod;
+use He4rt\Events\Enrollment\Enums\AttendanceRequirement;
+use He4rt\Events\Enrollment\Enums\EnrollmentMethod;
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
@@ -13,11 +16,13 @@ public function up(): void
Schema::create('events_enrollment_policies', function (Blueprint $table): void {
$table->uuid('id')->primary();
$table->foreignUuid('event_id')->unique()->constrained('events')->cascadeOnDelete();
- $table->string('enrollment_method', 20);
- $table->string('check_in_method', 20);
+ $table->string('enrollment_method', 20)->comment(EnrollmentMethod::stringifyCases());
+ $table->string('check_in_method', 20)->comment(CheckInMethod::stringifyCases());
$table->unsignedInteger('capacity')->nullable();
$table->boolean('has_waitlist')->default(false);
- $table->string('attendance_requirement', 20)->default('all_days');
+ $table->string('attendance_requirement', 20)
+ ->comment(AttendanceRequirement::stringifyCases())
+ ->default(AttendanceRequirement::AllDays);
$table->unsignedSmallInteger('minimum_days')->nullable();
$table->unsignedSmallInteger('cancellation_deadline_hours')->nullable();
$table->unsignedInteger('xp_on_confirmed')->default(0);
diff --git a/app-modules/events/database/migrations/2026_05_16_200003_create_events_enrollments_table.php b/app-modules/events/database/migrations/2026_05_16_200003_create_events_enrollments_table.php
index 2764ffc4d..aa4df76e4 100644
--- a/app-modules/events/database/migrations/2026_05_16_200003_create_events_enrollments_table.php
+++ b/app-modules/events/database/migrations/2026_05_16_200003_create_events_enrollments_table.php
@@ -2,6 +2,7 @@
declare(strict_types=1);
+use He4rt\Events\Enrollment\Enums\EnrollmentStatus;
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
@@ -14,7 +15,9 @@ public function up(): void
$table->uuid('id')->primary();
$table->foreignUuid('event_id')->constrained('events')->cascadeOnDelete();
$table->foreignUuid('user_id')->constrained('users')->cascadeOnDelete();
- $table->string('status', 20)->default('pending');
+ $table->string('status', 20)
+ ->comment(EnrollmentStatus::stringifyCases())
+ ->default(EnrollmentStatus::Pending);
$table->boolean('is_public')->default(true);
$table->unsignedInteger('waitlist_position')->nullable();
$table->jsonb('application_data')->nullable();
diff --git a/app-modules/events/database/migrations/2026_05_16_200004_create_events_enrollment_transitions_table.php b/app-modules/events/database/migrations/2026_05_16_200004_create_events_enrollment_transitions_table.php
index 5ece6443a..8959198da 100644
--- a/app-modules/events/database/migrations/2026_05_16_200004_create_events_enrollment_transitions_table.php
+++ b/app-modules/events/database/migrations/2026_05_16_200004_create_events_enrollment_transitions_table.php
@@ -2,6 +2,8 @@
declare(strict_types=1);
+use He4rt\Events\Enrollment\Enums\EnrollmentStatus;
+use He4rt\Events\Enrollment\Enums\TriggeredBy;
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
@@ -13,10 +15,10 @@ public function up(): void
Schema::create('events_enrollment_transitions', function (Blueprint $table): void {
$table->uuid('id')->primary();
$table->foreignUuid('enrollment_id')->constrained('events_enrollments')->cascadeOnDelete();
- $table->string('from_status', 20)->nullable();
- $table->string('to_status', 20);
+ $table->string('from_status', 20)->nullable()->comment(EnrollmentStatus::stringifyCases());
+ $table->string('to_status', 20)->comment(EnrollmentStatus::stringifyCases());
$table->foreignUuid('actor_id')->nullable()->constrained('users')->nullOnDelete();
- $table->string('triggered_by', 20);
+ $table->string('triggered_by', 20)->comment(TriggeredBy::stringifyCases());
$table->string('reason', 500)->nullable();
// contexto adicional para audit trail: notas do admin, job ID, código de razão, etc.
$table->jsonb('metadata')->nullable();
diff --git a/app-modules/events/database/migrations/2026_05_16_200005_create_events_check_ins_table.php b/app-modules/events/database/migrations/2026_05_16_200005_create_events_check_ins_table.php
index 6b91f2246..976766174 100644
--- a/app-modules/events/database/migrations/2026_05_16_200005_create_events_check_ins_table.php
+++ b/app-modules/events/database/migrations/2026_05_16_200005_create_events_check_ins_table.php
@@ -2,6 +2,7 @@
declare(strict_types=1);
+use He4rt\Events\CheckIn\Enums\CheckInMethod;
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
@@ -14,7 +15,7 @@ public function up(): void
$table->uuid('id')->primary();
$table->foreignUuid('enrollment_id')->constrained('events_enrollments')->cascadeOnDelete();
$table->date('event_date');
- $table->string('method', 20);
+ $table->string('method', 20)->comment(CheckInMethod::stringifyCases());
$table->jsonb('payload')->nullable();
$table->timestampsTz();
$table->unique(['enrollment_id', 'event_date'], 'idx_check_ins_unique_per_day');
diff --git a/app-modules/events/src/CheckIn/Enums/BotCheckInStatus.php b/app-modules/events/src/CheckIn/Enums/BotCheckInStatus.php
index 81c5065f6..3cef5dfa2 100644
--- a/app-modules/events/src/CheckIn/Enums/BotCheckInStatus.php
+++ b/app-modules/events/src/CheckIn/Enums/BotCheckInStatus.php
@@ -4,8 +4,12 @@
namespace He4rt\Events\CheckIn\Enums;
+use App\Enums\Concerns\StringifyEnum;
+
enum BotCheckInStatus: string
{
+ use StringifyEnum;
+
case Success = 'success';
case Error = 'error';
}
diff --git a/app-modules/events/src/CheckIn/Enums/CheckInMethod.php b/app-modules/events/src/CheckIn/Enums/CheckInMethod.php
index fc63c3e0b..87aea0b4d 100644
--- a/app-modules/events/src/CheckIn/Enums/CheckInMethod.php
+++ b/app-modules/events/src/CheckIn/Enums/CheckInMethod.php
@@ -4,6 +4,7 @@
namespace He4rt\Events\CheckIn\Enums;
+use App\Enums\Concerns\StringifyEnum;
use Filament\Support\Colors\Color;
use Filament\Support\Contracts\HasColor;
use Filament\Support\Contracts\HasIcon;
@@ -12,6 +13,8 @@
enum CheckInMethod: string implements HasColor, HasIcon, HasLabel
{
+ use StringifyEnum;
+
case Manual = 'manual';
case NumericCode = 'numeric_code';
case QrCode = 'qr_code';
diff --git a/app-modules/events/src/Enrollment/Enums/AttendanceRequirement.php b/app-modules/events/src/Enrollment/Enums/AttendanceRequirement.php
index a41fd5f15..64fb8ea49 100644
--- a/app-modules/events/src/Enrollment/Enums/AttendanceRequirement.php
+++ b/app-modules/events/src/Enrollment/Enums/AttendanceRequirement.php
@@ -4,6 +4,7 @@
namespace He4rt\Events\Enrollment\Enums;
+use App\Enums\Concerns\StringifyEnum;
use Filament\Support\Colors\Color;
use Filament\Support\Contracts\HasColor;
use Filament\Support\Contracts\HasIcon;
@@ -12,6 +13,8 @@
enum AttendanceRequirement: string implements HasColor, HasIcon, HasLabel
{
+ use StringifyEnum;
+
case AllDays = 'all_days';
case AnyDay = 'any_day';
case MinimumDays = 'minimum_days';
diff --git a/app-modules/events/src/Enrollment/Enums/EnrollmentMethod.php b/app-modules/events/src/Enrollment/Enums/EnrollmentMethod.php
index 0007fb6d5..878aa2d07 100644
--- a/app-modules/events/src/Enrollment/Enums/EnrollmentMethod.php
+++ b/app-modules/events/src/Enrollment/Enums/EnrollmentMethod.php
@@ -4,6 +4,7 @@
namespace He4rt\Events\Enrollment\Enums;
+use App\Enums\Concerns\StringifyEnum;
use Filament\Support\Colors\Color;
use Filament\Support\Contracts\HasColor;
use Filament\Support\Contracts\HasIcon;
@@ -12,6 +13,8 @@
enum EnrollmentMethod: string implements HasColor, HasIcon, HasLabel
{
+ use StringifyEnum;
+
case Rsvp = 'rsvp';
case RsvpCheckin = 'rsvp_checkin';
case Application = 'application';
diff --git a/app-modules/events/src/Enrollment/Enums/EnrollmentStatus.php b/app-modules/events/src/Enrollment/Enums/EnrollmentStatus.php
index d42f75dcd..75636baa4 100644
--- a/app-modules/events/src/Enrollment/Enums/EnrollmentStatus.php
+++ b/app-modules/events/src/Enrollment/Enums/EnrollmentStatus.php
@@ -4,6 +4,7 @@
namespace He4rt\Events\Enrollment\Enums;
+use App\Enums\Concerns\StringifyEnum;
use Filament\Support\Colors\Color;
use Filament\Support\Contracts\HasColor;
use Filament\Support\Contracts\HasLabel;
@@ -11,6 +12,8 @@
enum EnrollmentStatus: string implements HasColor, HasLabel
{
+ use StringifyEnum;
+
case Pending = 'pending';
case Confirmed = 'confirmed';
case Waitlisted = 'waitlisted';
diff --git a/app-modules/events/src/Enrollment/Enums/TriggeredBy.php b/app-modules/events/src/Enrollment/Enums/TriggeredBy.php
index 3410729d8..d0daa24d5 100644
--- a/app-modules/events/src/Enrollment/Enums/TriggeredBy.php
+++ b/app-modules/events/src/Enrollment/Enums/TriggeredBy.php
@@ -4,6 +4,7 @@
namespace He4rt\Events\Enrollment\Enums;
+use App\Enums\Concerns\StringifyEnum;
use Filament\Support\Colors\Color;
use Filament\Support\Contracts\HasColor;
use Filament\Support\Contracts\HasIcon;
@@ -12,6 +13,8 @@
enum TriggeredBy: string implements HasColor, HasIcon, HasLabel
{
+ use StringifyEnum;
+
case User = 'user';
case Admin = 'admin';
case System = 'system';
diff --git a/app-modules/events/src/Event/Enums/EventStatus.php b/app-modules/events/src/Event/Enums/EventStatus.php
index d5006cb5a..4671543d0 100644
--- a/app-modules/events/src/Event/Enums/EventStatus.php
+++ b/app-modules/events/src/Event/Enums/EventStatus.php
@@ -4,6 +4,7 @@
namespace He4rt\Events\Event\Enums;
+use App\Enums\Concerns\StringifyEnum;
use Filament\Support\Colors\Color;
use Filament\Support\Contracts\HasColor;
use Filament\Support\Contracts\HasIcon;
@@ -12,6 +13,8 @@
enum EventStatus: string implements HasColor, HasIcon, HasLabel
{
+ use StringifyEnum;
+
case Draft = 'draft';
case Published = 'published';
case Completed = 'completed';
diff --git a/app-modules/events/src/Event/Enums/EventType.php b/app-modules/events/src/Event/Enums/EventType.php
index ac3b39e6e..02614170a 100644
--- a/app-modules/events/src/Event/Enums/EventType.php
+++ b/app-modules/events/src/Event/Enums/EventType.php
@@ -4,6 +4,7 @@
namespace He4rt\Events\Event\Enums;
+use App\Enums\Concerns\StringifyEnum;
use Filament\Support\Colors\Color;
use Filament\Support\Contracts\HasColor;
use Filament\Support\Contracts\HasIcon;
@@ -12,6 +13,8 @@
enum EventType: string implements HasColor, HasIcon, HasLabel
{
+ use StringifyEnum;
+
case Meetup = 'meetup';
case Workshop = 'workshop';
case Conference = 'conference';
diff --git a/app/Enums/Concerns/StringifyEnum.php b/app/Enums/Concerns/StringifyEnum.php
new file mode 100644
index 000000000..78fc24242
--- /dev/null
+++ b/app/Enums/Concerns/StringifyEnum.php
@@ -0,0 +1,19 @@
+implode(fn (BackedEnum|UnitEnum $enum) => $enum->value, ', ');
+
+ return sprintf('%s: %s', $context, $cases);
+ }
+}
From 7b9f85b2183dc87462d931513b35050df9c04153 Mon Sep 17 00:00:00 2001
From: Bruna Domingues Leite
Date: Tue, 7 Jul 2026 13:16:04 -0300
Subject: [PATCH 14/18] =?UTF-8?q?POC:=20i18n=20no=20admin=20de=20eventos?=
=?UTF-8?q?=20(locale=20switcher=20+=20tradu=C3=A7=C3=B5es)=20(#384)?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
## Summary
POC para validar traduções no admin de eventos, com commits separados
por escopo.
- Adiciona seletor de idioma EN/PT-BR no menu do usuário do Filament
(estilo theme switcher), com persistência em sessão
- Formata datas do Filament conforme o locale ativo (ex.: `29/06/2026`
em PT-BR)
- Traduz labels do resource de eventos no `panel-admin` (tabelas,
formulários, relation managers e notificações)
## Commits
1. `feat(i18n): add Filament user menu locale switcher` — menu +
infraestrutura de locale (revertível isoladamente)
2. `feat(i18n): format Filament dates by application locale` — Carbon +
formatos no Filament
3. `feat(panel-admin): translate events Filament resource labels` —
arquivos `lang/events.php` e resources
## Notas
- O commit do locale switcher foi isolado de propósito: se não for para
produção, dá para reverter só ele com `git revert `.
- Enum values (ex.: status da inscrição) já usavam `events::enums.*`;
este PR cobre principalmente strings hardcoded do admin.
- Requer assets do Vite atualizados (`npm run dev` ou `npm run build`)
para o estilo do seletor de idioma.
## Test plan
- [ ] Trocar idioma no menu do usuário (admin e app) e recarregar a
página
- [ ] Verificar labels do resource de eventos em PT-BR (Inscrições,
Participante, etc.)
- [ ] Verificar formato de datas em PT-BR (`d/m/Y H:i:s`) vs EN (`M j, Y
H:i:s`)
- [ ] `php artisan test tests/Feature/LocaleSwitcherTest.php`
- [ ] `php artisan test
app-modules/events/tests/Feature/EventResourceTest.php`
---------
Co-authored-by: Cursor
---
app-modules/panel-admin/lang/en/events.php | 113 +++++++++++++++
app-modules/panel-admin/lang/pt_BR/events.php | 113 +++++++++++++++
.../Resources/Events/EventResource.php | 15 ++
.../Resources/Events/Pages/EditEvent.php | 22 +--
.../Actions/GenerateCheckInCodeAction.php | 22 +--
.../OverrideEnrollmentStatusAction.php | 8 +-
.../Actions/RevokeCheckInCodeAction.php | 4 +-
.../CheckInCodesRelationManager.php | 17 ++-
.../EnrollmentsRelationManager.php | 32 ++--
.../Resources/Events/Schemas/EventForm.php | 48 +++---
.../Events/Schemas/EventInfolist.php | 42 +++---
.../Resources/Events/Tables/EventsTable.php | 18 +--
.../Controllers/SwitchLocaleController.php | 20 +++
app/Http/Middleware/SetApplicationLocale.php | 23 +++
app/Providers/Filament/AdminPanelProvider.php | 2 +
app/Providers/Filament/AppPanelProvider.php | 2 +
app/Providers/FilamentServiceProvider.php | 18 ++-
app/Support/ApplicationLocale.php | 70 +++++++++
bootstrap/app.php | 5 +
lang/en/app.php | 12 ++
lang/pt_BR/app.php | 12 ++
resources/css/filament/admin/theme.css | 1 +
resources/css/filament/app/theme.css | 1 +
resources/css/filament/locale-switcher.css | 15 ++
.../filament/locale-switcher/button.blade.php | 26 ++++
.../locale-switcher/dropdown.blade.php | 3 +
.../filament/locale-switcher/index.blade.php | 19 +++
.../components/user-menu.blade.php | 137 ++++++++++++++++++
routes/web.php | 17 +--
tests/Feature/LocaleSwitcherTest.php | 63 ++++++++
30 files changed, 787 insertions(+), 113 deletions(-)
create mode 100644 app-modules/panel-admin/lang/en/events.php
create mode 100644 app-modules/panel-admin/lang/pt_BR/events.php
create mode 100644 app/Http/Controllers/SwitchLocaleController.php
create mode 100644 app/Http/Middleware/SetApplicationLocale.php
create mode 100644 app/Support/ApplicationLocale.php
create mode 100644 lang/en/app.php
create mode 100644 lang/pt_BR/app.php
create mode 100644 resources/css/filament/locale-switcher.css
create mode 100644 resources/views/components/filament/locale-switcher/button.blade.php
create mode 100644 resources/views/components/filament/locale-switcher/dropdown.blade.php
create mode 100644 resources/views/components/filament/locale-switcher/index.blade.php
create mode 100644 resources/views/vendor/filament-panels/components/user-menu.blade.php
create mode 100644 tests/Feature/LocaleSwitcherTest.php
diff --git a/app-modules/panel-admin/lang/en/events.php b/app-modules/panel-admin/lang/en/events.php
new file mode 100644
index 000000000..865227d09
--- /dev/null
+++ b/app-modules/panel-admin/lang/en/events.php
@@ -0,0 +1,113 @@
+ 'Event',
+ 'plural' => 'Events',
+
+ 'columns' => [
+ 'title' => 'Title',
+ 'slug' => 'Slug',
+ 'type' => 'Type',
+ 'tenant' => 'Tenant',
+ 'location' => 'Location',
+ 'description' => 'Description',
+ 'starts_at' => 'Starts At',
+ 'ends_at' => 'Ends At',
+ 'status' => 'Status',
+ 'created_at' => 'Created At',
+ 'date' => 'Date',
+ 'code' => 'Code',
+ 'event_date' => 'Event Date',
+ 'valid_from' => 'Valid From',
+ 'expires_at' => 'Expires At',
+ 'uses' => 'Uses',
+ 'revoked_at' => 'Revoked At',
+ ],
+
+ 'sections' => [
+ 'enrollment_policy' => 'Enrollment Policy',
+ ],
+
+ 'form' => [
+ 'enrollment_method' => 'Enrollment Method',
+ 'check_in_method' => 'Check-in Method',
+ 'capacity' => 'Capacity',
+ 'waitlist_enabled' => 'Waitlist Enabled',
+ 'attendance_requirement' => 'Attendance Requirement',
+ 'minimum_days' => 'Minimum Days',
+ 'cancellation_deadline_hours' => 'Cancellation Deadline (hours before event)',
+ 'xp_on_confirmed' => 'XP on Confirmed',
+ 'xp_on_checked_in' => 'XP on Checked-in',
+ 'xp_on_attended' => 'XP on Attended',
+ 'application_form_schema' => 'Application Form Schema',
+ 'application_schema_key' => 'Field name',
+ 'application_schema_value' => 'Field type / label',
+ 'helpers' => [
+ 'minimum_days' => 'Required when attendance requirement is "Minimum Days". Default 1, max = event days.',
+ ],
+ ],
+
+ 'relations' => [
+ 'enrollments' => 'Enrollments',
+ 'check_in_codes' => 'Check-in Codes',
+ ],
+
+ 'enrollments' => [
+ 'columns' => [
+ 'participant' => 'Participant',
+ 'waitlist' => 'Waitlist',
+ 'enrolled_at' => 'Enrolled At',
+ 'confirmed_at' => 'Confirmed At',
+ 'check_in_history' => 'Check-in History',
+ 'cancelled_at' => 'Cancelled At',
+ ],
+ 'actions' => [
+ 'check_in' => 'Check In',
+ 'check_in_selected' => 'Check In Selected',
+ 'override_status' => 'Override Status',
+ 'new_status' => 'New Status',
+ 'reason' => 'Reason',
+ ],
+ 'notifications' => [
+ 'participant_checked_in' => 'Participant checked in.',
+ 'selected_participants_checked_in' => 'Selected participants checked in.',
+ 'status_overridden' => 'Enrollment status overridden.',
+ ],
+ ],
+
+ 'check_in_codes' => [
+ 'actions' => [
+ 'generate_code' => 'Generate Code',
+ 'revoke' => 'Revoke',
+ ],
+ 'fields' => [
+ 'code_length' => 'Code Length',
+ 'generated_code' => 'Generated Code',
+ 'max_uses' => 'Max Uses (optional)',
+ ],
+ 'digits' => [
+ 'four' => '4 digits',
+ 'six' => '6 digits',
+ ],
+ 'unlimited' => 'Unlimited',
+ 'notifications' => [
+ 'code_revoked' => 'Code revoked.',
+ ],
+ ],
+
+ 'edit' => [
+ 'scan_qr' => 'Scan QR',
+ 'qr_token' => 'QR Token',
+ 'qr_token_placeholder' => 'Scan or paste the participant token',
+ 'check_in_submit' => 'Check In',
+ 'participant_fallback' => 'Participant',
+ 'notifications' => [
+ 'check_in_success_title' => 'Check-in successful',
+ 'check_in_success_body' => ':name has been checked in.',
+ 'check_in_failed_title' => 'Check-in failed',
+ 'check_in_unexpected_error' => 'An unexpected error occurred. Please try again.',
+ ],
+ ],
+];
diff --git a/app-modules/panel-admin/lang/pt_BR/events.php b/app-modules/panel-admin/lang/pt_BR/events.php
new file mode 100644
index 000000000..e4ee75ed0
--- /dev/null
+++ b/app-modules/panel-admin/lang/pt_BR/events.php
@@ -0,0 +1,113 @@
+ 'Evento',
+ 'plural' => 'Eventos',
+
+ 'columns' => [
+ 'title' => 'Título',
+ 'slug' => 'Slug',
+ 'type' => 'Tipo',
+ 'tenant' => 'Tenant',
+ 'location' => 'Local',
+ 'description' => 'Descrição',
+ 'starts_at' => 'Início',
+ 'ends_at' => 'Término',
+ 'status' => 'Status',
+ 'created_at' => 'Criado em',
+ 'date' => 'Data',
+ 'code' => 'Código',
+ 'event_date' => 'Data do evento',
+ 'valid_from' => 'Válido de',
+ 'expires_at' => 'Expira em',
+ 'uses' => 'Usos',
+ 'revoked_at' => 'Revogado em',
+ ],
+
+ 'sections' => [
+ 'enrollment_policy' => 'Política de inscrição',
+ ],
+
+ 'form' => [
+ 'enrollment_method' => 'Método de inscrição',
+ 'check_in_method' => 'Método de check-in',
+ 'capacity' => 'Capacidade',
+ 'waitlist_enabled' => 'Lista de espera habilitada',
+ 'attendance_requirement' => 'Requisito de presença',
+ 'minimum_days' => 'Dias mínimos',
+ 'cancellation_deadline_hours' => 'Prazo de cancelamento (horas antes do evento)',
+ 'xp_on_confirmed' => 'XP ao confirmar',
+ 'xp_on_checked_in' => 'XP no check-in',
+ 'xp_on_attended' => 'XP ao comparecer',
+ 'application_form_schema' => 'Schema do formulário de inscrição',
+ 'application_schema_key' => 'Nome do campo',
+ 'application_schema_value' => 'Tipo / rótulo do campo',
+ 'helpers' => [
+ 'minimum_days' => 'Obrigatório quando o requisito de presença é "Dias mínimos". Padrão 1, máximo = dias do evento.',
+ ],
+ ],
+
+ 'relations' => [
+ 'enrollments' => 'Inscrições',
+ 'check_in_codes' => 'Códigos de check-in',
+ ],
+
+ 'enrollments' => [
+ 'columns' => [
+ 'participant' => 'Participante',
+ 'waitlist' => 'Lista de espera',
+ 'enrolled_at' => 'Inscrito em',
+ 'confirmed_at' => 'Confirmado em',
+ 'check_in_history' => 'Histórico de check-in',
+ 'cancelled_at' => 'Cancelado em',
+ ],
+ 'actions' => [
+ 'check_in' => 'Fazer check-in',
+ 'check_in_selected' => 'Check-in selecionados',
+ 'override_status' => 'Alterar status',
+ 'new_status' => 'Novo status',
+ 'reason' => 'Motivo',
+ ],
+ 'notifications' => [
+ 'participant_checked_in' => 'Participante com check-in realizado.',
+ 'selected_participants_checked_in' => 'Participantes selecionados com check-in realizado.',
+ 'status_overridden' => 'Status da inscrição alterado.',
+ ],
+ ],
+
+ 'check_in_codes' => [
+ 'actions' => [
+ 'generate_code' => 'Gerar código',
+ 'revoke' => 'Revogar',
+ ],
+ 'fields' => [
+ 'code_length' => 'Tamanho do código',
+ 'generated_code' => 'Código gerado',
+ 'max_uses' => 'Máximo de usos (opcional)',
+ ],
+ 'digits' => [
+ 'four' => '4 dígitos',
+ 'six' => '6 dígitos',
+ ],
+ 'unlimited' => 'Ilimitado',
+ 'notifications' => [
+ 'code_revoked' => 'Código revogado.',
+ ],
+ ],
+
+ 'edit' => [
+ 'scan_qr' => 'Escanear QR',
+ 'qr_token' => 'Token QR',
+ 'qr_token_placeholder' => 'Escaneie ou cole o token do participante',
+ 'check_in_submit' => 'Fazer check-in',
+ 'participant_fallback' => 'Participante',
+ 'notifications' => [
+ 'check_in_success_title' => 'Check-in realizado',
+ 'check_in_success_body' => ':name fez check-in com sucesso.',
+ 'check_in_failed_title' => 'Falha no check-in',
+ 'check_in_unexpected_error' => 'Ocorreu um erro inesperado. Tente novamente.',
+ ],
+ ],
+];
diff --git a/app-modules/panel-admin/src/Filament/Resources/Events/EventResource.php b/app-modules/panel-admin/src/Filament/Resources/Events/EventResource.php
index 36d0cef76..217aa62e1 100644
--- a/app-modules/panel-admin/src/Filament/Resources/Events/EventResource.php
+++ b/app-modules/panel-admin/src/Filament/Resources/Events/EventResource.php
@@ -29,6 +29,21 @@ final class EventResource extends Resource
protected static string|BackedEnum|null $navigationIcon = Heroicon::OutlinedCalendarDays;
+ public static function getNavigationLabel(): string
+ {
+ return __('panel-admin::events.plural');
+ }
+
+ public static function getModelLabel(): string
+ {
+ return __('panel-admin::events.label');
+ }
+
+ public static function getPluralModelLabel(): string
+ {
+ return __('panel-admin::events.plural');
+ }
+
public static function form(Schema $schema): Schema
{
return EventForm::configure($schema);
diff --git a/app-modules/panel-admin/src/Filament/Resources/Events/Pages/EditEvent.php b/app-modules/panel-admin/src/Filament/Resources/Events/Pages/EditEvent.php
index bc809978d..3db3059f5 100644
--- a/app-modules/panel-admin/src/Filament/Resources/Events/Pages/EditEvent.php
+++ b/app-modules/panel-admin/src/Filament/Resources/Events/Pages/EditEvent.php
@@ -32,17 +32,17 @@ protected function getHeaderActions(): array
{
return [
Action::make('scanQr')
- ->label('Scan QR')
+ ->label(__('panel-admin::events.edit.scan_qr'))
->icon(Heroicon::QrCode)
->color('success')
->schema([
TextInput::make('token')
- ->label('QR Token')
+ ->label(__('panel-admin::events.edit.qr_token'))
->required()
->autofocus()
- ->placeholder('Scan or paste the participant token'),
+ ->placeholder(__('panel-admin::events.edit.qr_token_placeholder')),
])
- ->modalSubmitActionLabel('Check In')
+ ->modalSubmitActionLabel(__('panel-admin::events.edit.check_in_submit'))
->action(function (array $data): void {
/** @var Event $event */
$event = $this->getRecord();
@@ -58,24 +58,26 @@ protected function getHeaderActions(): array
);
$checkIn->enrollment->loadMissing('user');
- $participantName = $checkIn->enrollment->user->name ?? 'Participant';
+ $participantName = $checkIn->enrollment->user->name ?? __('panel-admin::events.edit.participant_fallback');
Notification::make()
->success()
- ->title('Check-in successful')
- ->body($participantName.' has been checked in.')
+ ->title(__('panel-admin::events.edit.notifications.check_in_success_title'))
+ ->body(__('panel-admin::events.edit.notifications.check_in_success_body', [
+ 'name' => $participantName,
+ ]))
->send();
} catch (CheckInException $e) {
Notification::make()
->danger()
- ->title('Check-in failed')
+ ->title(__('panel-admin::events.edit.notifications.check_in_failed_title'))
->body($e->getMessage())
->send();
} catch (Throwable $e) {
Notification::make()
->danger()
- ->title('Check-in failed')
- ->body('An unexpected error occurred. Please try again.')
+ ->title(__('panel-admin::events.edit.notifications.check_in_failed_title'))
+ ->body(__('panel-admin::events.edit.notifications.check_in_unexpected_error'))
->send();
report($e);
diff --git a/app-modules/panel-admin/src/Filament/Resources/Events/RelationManagers/Actions/GenerateCheckInCodeAction.php b/app-modules/panel-admin/src/Filament/Resources/Events/RelationManagers/Actions/GenerateCheckInCodeAction.php
index cd320331b..2d02bdd89 100644
--- a/app-modules/panel-admin/src/Filament/Resources/Events/RelationManagers/Actions/GenerateCheckInCodeAction.php
+++ b/app-modules/panel-admin/src/Filament/Resources/Events/RelationManagers/Actions/GenerateCheckInCodeAction.php
@@ -21,7 +21,7 @@ final class GenerateCheckInCodeAction extends Action
protected function setUp(): void
{
parent::setUp();
- $this->label('Generate Code')
+ $this->label(__('panel-admin::events.check_in_codes.actions.generate_code'))
->icon(Heroicon::OutlinedPlusCircle)
->color('success')
->schema($this->generateFormSchema(...))
@@ -70,10 +70,10 @@ private function generateFormSchema(RelationManager $livewire): array
->columns(2)
->schema([
Select::make('digits')
- ->label('Code Length')
+ ->label(__('panel-admin::events.check_in_codes.fields.code_length'))
->options([
- '4' => '4 digits',
- '6' => '6 digits',
+ '4' => __('panel-admin::events.check_in_codes.digits.four'),
+ '6' => __('panel-admin::events.check_in_codes.digits.six'),
])
->default('6')
->live()
@@ -84,35 +84,35 @@ private function generateFormSchema(RelationManager $livewire): array
->required(),
TextInput::make('code_preview')
- ->label('Generated Code')
+ ->label(__('panel-admin::events.check_in_codes.fields.generated_code'))
->readOnly()
->default(fn (): string => $this->generateNumericCode(6))
->dehydrated()
->required(),
DatePicker::make('event_date')
- ->label('Event Date')
+ ->label(__('panel-admin::events.columns.event_date'))
->default($event->starts_at->toDateString())
->minDate($event->starts_at->toDateString())
->maxDate($event->ends_at->toDateString())
->required(),
DateTimePicker::make('starts_at')
- ->label('Valid From')
+ ->label(__('panel-admin::events.columns.valid_from'))
->default(now())
->required(),
DateTimePicker::make('expires_at')
- ->label('Expires At')
- ->default(now()->addHours(2))
+ ->label(__('panel-admin::events.columns.expires_at'))
->afterOrEqual('starts_at')
+ ->default(now()->addHours(2))
->required(),
TextInput::make('max_uses')
- ->label('Max Uses (optional)')
+ ->label(__('panel-admin::events.check_in_codes.fields.max_uses'))
->numeric()
->minValue(1)
- ->placeholder('Unlimited'),
+ ->placeholder(__('panel-admin::events.check_in_codes.unlimited')),
]),
];
}
diff --git a/app-modules/panel-admin/src/Filament/Resources/Events/RelationManagers/Actions/OverrideEnrollmentStatusAction.php b/app-modules/panel-admin/src/Filament/Resources/Events/RelationManagers/Actions/OverrideEnrollmentStatusAction.php
index 9d1f7938d..b7426845e 100644
--- a/app-modules/panel-admin/src/Filament/Resources/Events/RelationManagers/Actions/OverrideEnrollmentStatusAction.php
+++ b/app-modules/panel-admin/src/Filament/Resources/Events/RelationManagers/Actions/OverrideEnrollmentStatusAction.php
@@ -20,19 +20,19 @@ protected function setUp(): void
{
parent::setUp();
- $this->label('Override Status')
+ $this->label(__('panel-admin::events.enrollments.actions.override_status'))
->icon(Heroicon::OutlinedPencilSquare)
->color('warning')
->visible(fn (Enrollment $record): bool => OverrideEnrollmentStatusDomainAction::allowedTargetsFor($record->status) !== [])
->schema([
Select::make('to_status')
- ->label('New Status')
+ ->label(__('panel-admin::events.enrollments.actions.new_status'))
->options(fn (Enrollment $record): array => collect(OverrideEnrollmentStatusDomainAction::allowedTargetsFor($record->status))
->mapWithKeys(fn (EnrollmentStatus $s): array => [$s->value => $s->getLabel()])
->all())
->required(),
Textarea::make('reason')
- ->label('Reason')
+ ->label(__('panel-admin::events.enrollments.actions.reason'))
->required()
->minLength(3)
->rows(3),
@@ -50,7 +50,7 @@ protected function setUp(): void
Notification::make()
->success()
- ->title('Enrollment status overridden.')
+ ->title(__('panel-admin::events.enrollments.notifications.status_overridden'))
->send();
});
}
diff --git a/app-modules/panel-admin/src/Filament/Resources/Events/RelationManagers/Actions/RevokeCheckInCodeAction.php b/app-modules/panel-admin/src/Filament/Resources/Events/RelationManagers/Actions/RevokeCheckInCodeAction.php
index 9bd424d07..6bfd3cf2b 100644
--- a/app-modules/panel-admin/src/Filament/Resources/Events/RelationManagers/Actions/RevokeCheckInCodeAction.php
+++ b/app-modules/panel-admin/src/Filament/Resources/Events/RelationManagers/Actions/RevokeCheckInCodeAction.php
@@ -15,7 +15,7 @@ protected function setUp(): void
{
parent::setUp();
- $this->label('Revoke')
+ $this->label(__('panel-admin::events.check_in_codes.actions.revoke'))
->icon(Heroicon::OutlinedNoSymbol)
->color('danger')
->visible(fn (CheckInCode $record): bool => $record->revoked_at === null)
@@ -25,7 +25,7 @@ protected function setUp(): void
Notification::make()
->success()
- ->title('Code revoked.')
+ ->title(__('panel-admin::events.check_in_codes.notifications.code_revoked'))
->send();
});
}
diff --git a/app-modules/panel-admin/src/Filament/Resources/Events/RelationManagers/CheckInCodesRelationManager.php b/app-modules/panel-admin/src/Filament/Resources/Events/RelationManagers/CheckInCodesRelationManager.php
index 82f0bb7a5..d63daffc2 100644
--- a/app-modules/panel-admin/src/Filament/Resources/Events/RelationManagers/CheckInCodesRelationManager.php
+++ b/app-modules/panel-admin/src/Filament/Resources/Events/RelationManagers/CheckInCodesRelationManager.php
@@ -18,7 +18,10 @@ final class CheckInCodesRelationManager extends RelationManager
{
protected static string $relationship = 'checkInCodes';
- protected static ?string $title = 'Check-in Codes';
+ public static function getTitle(Model $ownerRecord, string $pageClass): string
+ {
+ return __('panel-admin::events.relations.check_in_codes');
+ }
public static function getBadge(Model $ownerRecord, string $pageClass): string
{
@@ -33,32 +36,32 @@ public function table(Table $table): Table
->modifyQueryUsing(fn (Builder $query): Builder => $query->latest())
->columns([
TextColumn::make('code')
- ->label('Code')
+ ->label(__('panel-admin::events.columns.code'))
->badge()
->color(fn (CheckInCode $record): string => $record->revoked_at !== null ? 'gray' : ($record->expires_at->isPast() ? 'warning' : 'success'))
->searchable(),
TextColumn::make('event_date')
- ->label('Event Date')
+ ->label(__('panel-admin::events.columns.event_date'))
->date()
->sortable(),
TextColumn::make('starts_at')
- ->label('Valid From')
+ ->label(__('panel-admin::events.columns.valid_from'))
->dateTime()
->sortable(),
TextColumn::make('expires_at')
- ->label('Expires At')
+ ->label(__('panel-admin::events.columns.expires_at'))
->dateTime()
->sortable(),
TextColumn::make('uses_count')
- ->label('Uses')
+ ->label(__('panel-admin::events.columns.uses'))
->state(fn (CheckInCode $record): string => $record->uses_count.($record->max_uses !== null ? '/'.$record->max_uses : '')),
TextColumn::make('revoked_at')
- ->label('Revoked At')
+ ->label(__('panel-admin::events.columns.revoked_at'))
->dateTime()
->placeholder('-'),
])
diff --git a/app-modules/panel-admin/src/Filament/Resources/Events/RelationManagers/EnrollmentsRelationManager.php b/app-modules/panel-admin/src/Filament/Resources/Events/RelationManagers/EnrollmentsRelationManager.php
index 9b322d5d8..c6a462328 100644
--- a/app-modules/panel-admin/src/Filament/Resources/Events/RelationManagers/EnrollmentsRelationManager.php
+++ b/app-modules/panel-admin/src/Filament/Resources/Events/RelationManagers/EnrollmentsRelationManager.php
@@ -25,12 +25,18 @@
use He4rt\PanelAdmin\Filament\Resources\Events\RelationManagers\Actions\OverrideEnrollmentStatusAction;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Collection;
+use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Facades\Date;
final class EnrollmentsRelationManager extends RelationManager
{
protected static string $relationship = 'enrollments';
+ public static function getTitle(Model $ownerRecord, string $pageClass): string
+ {
+ return __('panel-admin::events.relations.enrollments');
+ }
+
public function table(Table $table): Table
{
return $table
@@ -38,33 +44,33 @@ public function table(Table $table): Table
->modifyQueryUsing(fn (Builder $query): Builder => $query->with(['checkIns', 'user']))
->columns([
TextColumn::make('user.name')
- ->label('Participant')
+ ->label(__('panel-admin::events.enrollments.columns.participant'))
->searchable()
->sortable(),
TextColumn::make('status')
- ->label('Status')
+ ->label(__('panel-admin::events.columns.status'))
->badge()
->sortable(),
TextColumn::make('waitlist_position')
- ->label('Waitlist')
+ ->label(__('panel-admin::events.enrollments.columns.waitlist'))
->sortable()
->placeholder('-')
->toggleable(),
TextColumn::make('enrolled_at')
- ->label('Enrolled At')
+ ->label(__('panel-admin::events.enrollments.columns.enrolled_at'))
->dateTime()
->sortable(),
TextColumn::make('confirmed_at')
- ->label('Confirmed At')
+ ->label(__('panel-admin::events.enrollments.columns.confirmed_at'))
->dateTime()
->sortable(),
TextColumn::make('check_in_history')
- ->label('Check-in History')
+ ->label(__('panel-admin::events.enrollments.columns.check_in_history'))
->state(fn (Enrollment $record): string => $record->checkIns
->sortBy('event_date')
->map(fn (CheckIn $checkIn): string => $checkIn->event_date->toDateString())
@@ -73,14 +79,14 @@ public function table(Table $table): Table
->toggleable(),
TextColumn::make('cancelled_at')
- ->label('Cancelled At')
+ ->label(__('panel-admin::events.enrollments.columns.cancelled_at'))
->dateTime()
->sortable()
->toggleable(isToggledHiddenByDefault: true),
])
->filters([
SelectFilter::make('status')
- ->label('Status')
+ ->label(__('panel-admin::events.columns.status'))
->options(EnrollmentStatus::class),
])
->recordActions([
@@ -99,7 +105,7 @@ public function table(Table $table): Table
private function checkInAction(): Action
{
return Action::make('checkIn')
- ->label('Check In')
+ ->label(__('panel-admin::events.enrollments.actions.check_in'))
->icon(Heroicon::OutlinedCheckCircle)
->color('success')
->visible(fn (Enrollment $record): bool => $record->status->is(EnrollmentStatus::Confirmed, EnrollmentStatus::CheckedIn))
@@ -109,7 +115,7 @@ private function checkInAction(): Action
Notification::make()
->success()
- ->title('Participant checked in.')
+ ->title(__('panel-admin::events.enrollments.notifications.participant_checked_in'))
->send();
});
}
@@ -117,7 +123,7 @@ private function checkInAction(): Action
private function bulkCheckInAction(): BulkAction
{
return BulkAction::make('checkInSelected')
- ->label('Check In Selected')
+ ->label(__('panel-admin::events.enrollments.actions.check_in_selected'))
->icon(Heroicon::OutlinedCheckCircle)
->color('success')
->schema($this->checkInSchema())
@@ -132,7 +138,7 @@ private function bulkCheckInAction(): BulkAction
Notification::make()
->success()
- ->title('Selected participants checked in.')
+ ->title(__('panel-admin::events.enrollments.notifications.selected_participants_checked_in'))
->send();
})
->deselectRecordsAfterCompletion();
@@ -148,7 +154,7 @@ private function checkInSchema(): array
return [
DatePicker::make('event_date')
- ->label('Date')
+ ->label(__('panel-admin::events.columns.date'))
->default(now())
->minDate($event->starts_at->toDateString())
->maxDate($event->ends_at->toDateString())
diff --git a/app-modules/panel-admin/src/Filament/Resources/Events/Schemas/EventForm.php b/app-modules/panel-admin/src/Filament/Resources/Events/Schemas/EventForm.php
index c55277a3f..47ae0b0a2 100644
--- a/app-modules/panel-admin/src/Filament/Resources/Events/Schemas/EventForm.php
+++ b/app-modules/panel-admin/src/Filament/Resources/Events/Schemas/EventForm.php
@@ -30,13 +30,13 @@ public static function configure(Schema $schema): Schema
->columns(2)
->components([
TextInput::make('title')
- ->label('Title')
+ ->label(__('panel-admin::events.columns.title'))
->required()
->maxLength(200)
->columnSpanFull(),
TextInput::make('slug')
- ->label('Slug')
+ ->label(__('panel-admin::events.columns.slug'))
->required()
->maxLength(120)
->unique(
@@ -53,75 +53,75 @@ public static function configure(Schema $schema): Schema
),
Select::make('event_type')
- ->label('Type')
+ ->label(__('panel-admin::events.columns.type'))
->options(EventType::class)
->required(),
Select::make('tenant_id')
- ->label('Tenant')
+ ->label(__('panel-admin::events.columns.tenant'))
->relationship('tenant', 'name')
->searchable()
->nullable(),
TextInput::make('location')
- ->label('Location')
+ ->label(__('panel-admin::events.columns.location'))
->nullable(),
Textarea::make('description')
- ->label('Description')
+ ->label(__('panel-admin::events.columns.description'))
->nullable()
->columnSpanFull(),
DateTimePicker::make('starts_at')
- ->label('Starts At')
+ ->label(__('panel-admin::events.columns.starts_at'))
->required(),
DateTimePicker::make('ends_at')
- ->label('Ends At')
+ ->label(__('panel-admin::events.columns.ends_at'))
->required()
->after('starts_at'),
Select::make('status')
- ->label('Status')
+ ->label(__('panel-admin::events.columns.status'))
->options(EventStatus::class)
->default(EventStatus::Draft)
->required()
->columnSpanFull(),
- Section::make('Enrollment Policy')
+ Section::make(__('panel-admin::events.sections.enrollment_policy'))
->relationship('enrollmentPolicy')
->columns(2)
->schema([
Select::make('enrollment_method')
- ->label('Enrollment Method')
+ ->label(__('panel-admin::events.form.enrollment_method'))
->options(EnrollmentMethod::class)
->live()
->required(),
Select::make('check_in_method')
- ->label('Check-in Method')
+ ->label(__('panel-admin::events.form.check_in_method'))
->options(CheckInMethod::class)
->required(),
TextInput::make('capacity')
- ->label('Capacity')
+ ->label(__('panel-admin::events.form.capacity'))
->integer()
->minValue(1)
->nullable(),
Toggle::make('has_waitlist')
- ->label('Waitlist Enabled')
+ ->label(__('panel-admin::events.form.waitlist_enabled'))
->default(false),
Select::make('attendance_requirement')
- ->label('Attendance Requirement')
+ ->label(__('panel-admin::events.form.attendance_requirement'))
->options(fn (Get $get): array => self::attendanceRequirementOptions($get))
->live()
->required(),
TextInput::make('minimum_days')
- ->label('Minimum Days')
- ->helperText('Required when attendance requirement is "Minimum Days". Default 1, max = event days.')
+ ->label(__('panel-admin::events.form.minimum_days'))
+ ->helperText(__('panel-admin::events.form.helpers.minimum_days'))
->integer()
->minValue(1)
->maxValue(fn (Get $get): ?int => self::minimumDaysMaxValue($get))
@@ -130,33 +130,33 @@ public static function configure(Schema $schema): Schema
->visible(fn (Get $get): bool => $get('attendance_requirement') === AttendanceRequirement::MinimumDays->value),
TextInput::make('cancellation_deadline_hours')
- ->label('Cancellation Deadline (hours before event)')
+ ->label(__('panel-admin::events.form.cancellation_deadline_hours'))
->integer()
->minValue(0)
->nullable(),
TextInput::make('xp_on_confirmed')
- ->label('XP on Confirmed')
+ ->label(__('panel-admin::events.form.xp_on_confirmed'))
->integer()
->minValue(0)
->default(0),
TextInput::make('xp_on_checked_in')
- ->label('XP on Checked-in')
+ ->label(__('panel-admin::events.form.xp_on_checked_in'))
->integer()
->minValue(0)
->default(0),
TextInput::make('xp_on_attended')
- ->label('XP on Attended')
+ ->label(__('panel-admin::events.form.xp_on_attended'))
->integer()
->minValue(0)
->default(0),
KeyValue::make('application_schema')
- ->label('Application Form Schema')
- ->keyLabel('Field name')
- ->valueLabel('Field type / label')
+ ->label(__('panel-admin::events.form.application_form_schema'))
+ ->keyLabel(__('panel-admin::events.form.application_schema_key'))
+ ->valueLabel(__('panel-admin::events.form.application_schema_value'))
->nullable()
->columnSpanFull()
->visible(fn (Get $get): bool => $get('enrollment_method') === EnrollmentMethod::Application->value),
diff --git a/app-modules/panel-admin/src/Filament/Resources/Events/Schemas/EventInfolist.php b/app-modules/panel-admin/src/Filament/Resources/Events/Schemas/EventInfolist.php
index e138d7fba..23e7e7d21 100644
--- a/app-modules/panel-admin/src/Filament/Resources/Events/Schemas/EventInfolist.php
+++ b/app-modules/panel-admin/src/Filament/Resources/Events/Schemas/EventInfolist.php
@@ -17,79 +17,79 @@ public static function configure(Schema $schema): Schema
->columns(2)
->components([
TextEntry::make('title')
- ->label('Title')
+ ->label(__('panel-admin::events.columns.title'))
->columnSpanFull(),
TextEntry::make('slug')
- ->label('Slug'),
+ ->label(__('panel-admin::events.columns.slug')),
TextEntry::make('event_type')
- ->label('Type')
+ ->label(__('panel-admin::events.columns.type'))
->badge(),
TextEntry::make('tenant.name')
- ->label('Tenant'),
+ ->label(__('panel-admin::events.columns.tenant')),
TextEntry::make('location')
- ->label('Location'),
+ ->label(__('panel-admin::events.columns.location')),
TextEntry::make('description')
- ->label('Description')
+ ->label(__('panel-admin::events.columns.description'))
->columnSpanFull(),
TextEntry::make('starts_at')
- ->label('Starts At')
+ ->label(__('panel-admin::events.columns.starts_at'))
->dateTime(),
TextEntry::make('ends_at')
- ->label('Ends At')
+ ->label(__('panel-admin::events.columns.ends_at'))
->dateTime(),
TextEntry::make('status')
- ->label('Status')
+ ->label(__('panel-admin::events.columns.status'))
->badge(),
TextEntry::make('created_at')
- ->label('Created At')
+ ->label(__('panel-admin::events.columns.created_at'))
->dateTime(),
- Section::make('Enrollment Policy')
+ Section::make(__('panel-admin::events.sections.enrollment_policy'))
->relationship('enrollmentPolicy')
->columns(2)
->schema([
TextEntry::make('enrollment_method')
- ->label('Enrollment Method')
+ ->label(__('panel-admin::events.form.enrollment_method'))
->badge(),
TextEntry::make('check_in_method')
- ->label('Check-in Method')
+ ->label(__('panel-admin::events.form.check_in_method'))
->badge(),
TextEntry::make('capacity')
- ->label('Capacity'),
+ ->label(__('panel-admin::events.form.capacity')),
IconEntry::make('has_waitlist')
- ->label('Waitlist Enabled')
+ ->label(__('panel-admin::events.form.waitlist_enabled'))
->boolean(),
TextEntry::make('attendance_requirement')
- ->label('Attendance Requirement')
+ ->label(__('panel-admin::events.form.attendance_requirement'))
->badge(),
TextEntry::make('minimum_days')
- ->label('Minimum Days'),
+ ->label(__('panel-admin::events.form.minimum_days')),
TextEntry::make('cancellation_deadline_hours')
- ->label('Cancellation Deadline (hours before event)'),
+ ->label(__('panel-admin::events.form.cancellation_deadline_hours')),
TextEntry::make('xp_on_confirmed')
- ->label('XP on Confirmed'),
+ ->label(__('panel-admin::events.form.xp_on_confirmed')),
TextEntry::make('xp_on_checked_in')
- ->label('XP on Checked-in'),
+ ->label(__('panel-admin::events.form.xp_on_checked_in')),
TextEntry::make('xp_on_attended')
- ->label('XP on Attended'),
+ ->label(__('panel-admin::events.form.xp_on_attended')),
]),
]);
}
diff --git a/app-modules/panel-admin/src/Filament/Resources/Events/Tables/EventsTable.php b/app-modules/panel-admin/src/Filament/Resources/Events/Tables/EventsTable.php
index a9cf7ec98..827ba8d06 100644
--- a/app-modules/panel-admin/src/Filament/Resources/Events/Tables/EventsTable.php
+++ b/app-modules/panel-admin/src/Filament/Resources/Events/Tables/EventsTable.php
@@ -21,48 +21,48 @@ public static function table(Table $table): Table
return $table
->columns([
TextColumn::make('title')
- ->label('Title')
+ ->label(__('panel-admin::events.columns.title'))
->searchable()
->sortable(),
TextColumn::make('event_type')
- ->label('Type')
+ ->label(__('panel-admin::events.columns.type'))
->badge()
->sortable(),
TextColumn::make('tenant.name')
- ->label('Tenant')
+ ->label(__('panel-admin::events.columns.tenant'))
->searchable()
->sortable(),
TextColumn::make('starts_at')
- ->label('Starts At')
+ ->label(__('panel-admin::events.columns.starts_at'))
->dateTime()
->sortable(),
TextColumn::make('ends_at')
- ->label('Ends At')
+ ->label(__('panel-admin::events.columns.ends_at'))
->dateTime()
->sortable(),
TextColumn::make('status')
- ->label('Status')
+ ->label(__('panel-admin::events.columns.status'))
->badge()
->sortable(),
TextColumn::make('created_at')
- ->label('Created At')
+ ->label(__('panel-admin::events.columns.created_at'))
->dateTime()
->sortable()
->toggleable(isToggledHiddenByDefault: true),
])
->filters([
SelectFilter::make('event_type')
- ->label('Type')
+ ->label(__('panel-admin::events.columns.type'))
->options(EventType::class),
SelectFilter::make('status')
- ->label('Status')
+ ->label(__('panel-admin::events.columns.status'))
->options(EventStatus::class),
])
->recordActions([
diff --git a/app/Http/Controllers/SwitchLocaleController.php b/app/Http/Controllers/SwitchLocaleController.php
new file mode 100644
index 000000000..283ae17d4
--- /dev/null
+++ b/app/Http/Controllers/SwitchLocaleController.php
@@ -0,0 +1,20 @@
+ $locale]);
+
+ return back();
+ }
+}
diff --git a/app/Http/Middleware/SetApplicationLocale.php b/app/Http/Middleware/SetApplicationLocale.php
new file mode 100644
index 000000000..6f202800b
--- /dev/null
+++ b/app/Http/Middleware/SetApplicationLocale.php
@@ -0,0 +1,23 @@
+configureBuilder();
$this->configureSelectFilter();
$this->configureTable();
+ $this->configureSchema();
}
public function register(): void
@@ -123,7 +126,18 @@ private function configureTable(): void
->defaultPaginationPageOption(10)
->filtersFormWidth(Width::Medium)
->paginated([10, 25, 50])
- ->emptyStateIcon(Heroicon::OutlinedExclamationTriangle));
+ ->emptyStateIcon(Heroicon::OutlinedExclamationTriangle)
+ ->defaultDateDisplayFormat(fn (): string => ApplicationLocale::dateFormat())
+ ->defaultDateTimeDisplayFormat(fn (): string => ApplicationLocale::dateTimeFormat())
+ ->defaultTimeDisplayFormat('H:i:s'));
+ }
+
+ private function configureSchema(): void
+ {
+ Schema::configureUsing(fn (Schema $schema): Schema => $schema
+ ->defaultDateDisplayFormat(fn (): string => ApplicationLocale::dateFormat())
+ ->defaultDateTimeDisplayFormat(fn (): string => ApplicationLocale::dateTimeFormat())
+ ->defaultTimeDisplayFormat('H:i:s'));
}
private function configureSelect(): void
@@ -143,6 +157,8 @@ private function configureDateTimePicker(): void
->seconds(false)
->minDate(now()->subYears(25))
->maxDate(now()->addYears(25))
+ ->defaultDateDisplayFormat(fn (): string => ApplicationLocale::dateFormat())
+ ->defaultDateTimeDisplayFormat(fn (): string => ApplicationLocale::dateTimeFormat())
->translateLabel());
}
diff --git a/app/Support/ApplicationLocale.php b/app/Support/ApplicationLocale.php
new file mode 100644
index 000000000..e39be1a7e
--- /dev/null
+++ b/app/Support/ApplicationLocale.php
@@ -0,0 +1,70 @@
+
+ */
+ public const array SUPPORTED = [
+ self::EN,
+ self::PT_BR,
+ ];
+
+ public static function isSupported(string $locale): bool
+ {
+ return in_array($locale, self::SUPPORTED, strict: true);
+ }
+
+ public static function resolve(): string
+ {
+ $locale = session(self::SESSION_KEY, config('app.locale', self::EN));
+
+ if (!is_string($locale) || !self::isSupported($locale)) {
+ return self::EN;
+ }
+
+ return $locale;
+ }
+
+ public static function apply(string $locale): void
+ {
+ app()->setLocale($locale);
+ Date::setLocale(self::carbonLocale($locale));
+ }
+
+ public static function carbonLocale(string $locale): string
+ {
+ return match ($locale) {
+ self::PT_BR => 'pt_BR',
+ default => 'en',
+ };
+ }
+
+ public static function dateFormat(): string
+ {
+ return match (app()->getLocale()) {
+ self::PT_BR => 'd/m/Y',
+ default => 'M j, Y',
+ };
+ }
+
+ public static function dateTimeFormat(): string
+ {
+ return match (app()->getLocale()) {
+ self::PT_BR => 'd/m/Y H:i:s',
+ default => 'M j, Y H:i:s',
+ };
+ }
+}
diff --git a/bootstrap/app.php b/bootstrap/app.php
index d31b71406..eb45bc175 100644
--- a/bootstrap/app.php
+++ b/bootstrap/app.php
@@ -2,6 +2,7 @@
declare(strict_types=1);
+use App\Http\Middleware\SetApplicationLocale;
use Illuminate\Foundation\Application;
use Illuminate\Foundation\Configuration\Exceptions;
use Illuminate\Foundation\Configuration\Middleware;
@@ -19,6 +20,10 @@
TrustProxies::class,
Monicahq\Cloudflare\Http\Middleware\TrustProxies::class
);
+
+ $middleware->web(append: [
+ SetApplicationLocale::class,
+ ]);
})
->withExceptions(function (Exceptions $exceptions): void {})
->create();
diff --git a/lang/en/app.php b/lang/en/app.php
new file mode 100644
index 000000000..8f8917c0e
--- /dev/null
+++ b/lang/en/app.php
@@ -0,0 +1,12 @@
+ [
+ 'english' => 'English',
+ 'english_short' => 'EN',
+ 'portuguese' => 'Português (Brasil)',
+ 'portuguese_short' => 'PT-BR',
+ ],
+];
diff --git a/lang/pt_BR/app.php b/lang/pt_BR/app.php
new file mode 100644
index 000000000..8f8917c0e
--- /dev/null
+++ b/lang/pt_BR/app.php
@@ -0,0 +1,12 @@
+ [
+ 'english' => 'English',
+ 'english_short' => 'EN',
+ 'portuguese' => 'Português (Brasil)',
+ 'portuguese_short' => 'PT-BR',
+ ],
+];
diff --git a/resources/css/filament/admin/theme.css b/resources/css/filament/admin/theme.css
index 6b2c0eb56..40308c886 100644
--- a/resources/css/filament/admin/theme.css
+++ b/resources/css/filament/admin/theme.css
@@ -1,5 +1,6 @@
@import '../../../../vendor/filament/filament/resources/css/theme.css';
@import '../../../../vendor/livewire/flux/dist/flux.css';
+@import '../locale-switcher.css' layer(components);
@source '../../../../app/Filament/**/*';
@source '../../../../resources/views/**/*';
diff --git a/resources/css/filament/app/theme.css b/resources/css/filament/app/theme.css
index 8bc6c0311..6f76cb5a7 100644
--- a/resources/css/filament/app/theme.css
+++ b/resources/css/filament/app/theme.css
@@ -1,4 +1,5 @@
@import '../../../../vendor/filament/filament/resources/css/theme.css';
+@import '../locale-switcher.css' layer(components);
@source '../../../../app/Filament/**/*';
@source '../../../../app/Filament/**/*';
diff --git a/resources/css/filament/locale-switcher.css b/resources/css/filament/locale-switcher.css
new file mode 100644
index 000000000..9b89b86c8
--- /dev/null
+++ b/resources/css/filament/locale-switcher.css
@@ -0,0 +1,15 @@
+.fi-locale-switcher {
+ @apply grid grid-flow-col gap-x-1;
+}
+
+.fi-locale-switcher-btn {
+ @apply flex justify-center rounded-md px-3 py-2 text-xs font-semibold tracking-wide outline-hidden transition duration-75 hover:bg-gray-50 focus-visible:bg-gray-50 dark:hover:bg-white/5 dark:focus-visible:bg-white/5;
+
+ &.fi-active {
+ @apply text-primary-500 dark:text-primary-400 bg-gray-50 dark:bg-white/5;
+ }
+
+ &:not(.fi-active) {
+ @apply text-gray-400 hover:text-gray-500 focus-visible:text-gray-500 dark:text-gray-500 dark:hover:text-gray-400 dark:focus-visible:text-gray-400;
+ }
+}
diff --git a/resources/views/components/filament/locale-switcher/button.blade.php b/resources/views/components/filament/locale-switcher/button.blade.php
new file mode 100644
index 000000000..84c581405
--- /dev/null
+++ b/resources/views/components/filament/locale-switcher/button.blade.php
@@ -0,0 +1,26 @@
+@props (['label', 'locale', 'active' => false])
+
+@php
+ $ariaLabel = match ($locale) {
+ \App\Support\ApplicationLocale::EN => __('app.locale.english'),
+ \App\Support\ApplicationLocale::PT_BR => __('app.locale.portuguese'),
+ default => $label,
+ };
+@endphp
+
+ $active,
+ 'text-gray-400 hover:bg-gray-50 hover:text-gray-500 focus-visible:bg-gray-50 focus-visible:text-gray-500 dark:text-gray-500 dark:hover:bg-white/5 dark:hover:text-gray-400 dark:focus-visible:bg-white/5' => !$active
+ ])
+ x-on:click="close()"
+>
+ {{ $label }}
+
diff --git a/resources/views/components/filament/locale-switcher/dropdown.blade.php b/resources/views/components/filament/locale-switcher/dropdown.blade.php
new file mode 100644
index 000000000..e029ec636
--- /dev/null
+++ b/resources/views/components/filament/locale-switcher/dropdown.blade.php
@@ -0,0 +1,3 @@
+
+
+
diff --git a/resources/views/components/filament/locale-switcher/index.blade.php b/resources/views/components/filament/locale-switcher/index.blade.php
new file mode 100644
index 000000000..35c89c614
--- /dev/null
+++ b/resources/views/components/filament/locale-switcher/index.blade.php
@@ -0,0 +1,19 @@
+@php
+ use App\Support\ApplicationLocale;
+
+ $currentLocale = app()->getLocale();
+@endphp
+
+
+
+
+
+
diff --git a/resources/views/vendor/filament-panels/components/user-menu.blade.php b/resources/views/vendor/filament-panels/components/user-menu.blade.php
new file mode 100644
index 000000000..3d958e341
--- /dev/null
+++ b/resources/views/vendor/filament-panels/components/user-menu.blade.php
@@ -0,0 +1,137 @@
+@props([
+ 'position' => null,
+])
+
+@php
+ use Filament\Actions\Action;
+ use Filament\Enums\UserMenuPosition;
+ use Illuminate\Support\Arr;
+
+ $user = filament()->auth()->user();
+
+ $items = $this->getUserMenuItems();
+
+ $itemsBeforeAndAfterThemeSwitcher = collect($items)
+ ->groupBy(fn (Action $item): bool => $item->getSort() < 0, preserveKeys: true)
+ ->all();
+ $itemsBeforeThemeSwitcher = $itemsBeforeAndAfterThemeSwitcher[true] ?? collect();
+ $itemsAfterThemeSwitcher = $itemsBeforeAndAfterThemeSwitcher[false] ?? collect();
+
+ $hasProfileHeader = $itemsBeforeThemeSwitcher->has('profile') &&
+ blank(($item = Arr::first($itemsBeforeThemeSwitcher))->getUrl()) &&
+ (! $item->hasAction());
+
+ if ($itemsBeforeThemeSwitcher->has('profile')) {
+ $itemsBeforeThemeSwitcher = $itemsBeforeThemeSwitcher->prepend($itemsBeforeThemeSwitcher->pull('profile'), 'profile');
+ }
+
+ $position ??= filament()->getUserMenuPosition();
+
+ $isSidebarCollapsibleOnDesktop = filament()->isSidebarCollapsibleOnDesktop();
+@endphp
+
+{{ \Filament\Support\Facades\FilamentView::renderHook(\Filament\View\PanelsRenderHook::USER_MENU_BEFORE) }}
+
+
+
+ @if ($position === UserMenuPosition::Topbar)
+
+ @else
+
+ @endif
+
+
+ @if ($hasProfileHeader)
+ @php
+ $item = $itemsBeforeThemeSwitcher['profile'];
+ $itemColor = $item->getColor();
+ $itemIcon = $item->getIcon();
+
+ unset($itemsBeforeThemeSwitcher['profile']);
+ @endphp
+
+ {{ \Filament\Support\Facades\FilamentView::renderHook(\Filament\View\PanelsRenderHook::USER_MENU_PROFILE_BEFORE) }}
+
+
+ {{ $item->getLabel() }}
+
+
+ {{ \Filament\Support\Facades\FilamentView::renderHook(\Filament\View\PanelsRenderHook::USER_MENU_PROFILE_AFTER) }}
+ @endif
+
+ @if ($itemsBeforeThemeSwitcher->isNotEmpty())
+
+ @foreach ($itemsBeforeThemeSwitcher as $key => $item)
+ @if ($key === 'profile')
+ {{ \Filament\Support\Facades\FilamentView::renderHook(\Filament\View\PanelsRenderHook::USER_MENU_PROFILE_BEFORE) }}
+
+ {{ $item }}
+
+ {{ \Filament\Support\Facades\FilamentView::renderHook(\Filament\View\PanelsRenderHook::USER_MENU_PROFILE_AFTER) }}
+ @else
+ {{ $item }}
+ @endif
+ @endforeach
+
+ @endif
+
+ @if (filament()->hasDarkMode() && (! filament()->hasDarkModeForced()))
+
+
+
+ @endif
+
+
+
+ @if ($itemsAfterThemeSwitcher->isNotEmpty())
+
+ @foreach ($itemsAfterThemeSwitcher as $key => $item)
+ @if ($key === 'profile')
+ {{ \Filament\Support\Facades\FilamentView::renderHook(\Filament\View\PanelsRenderHook::USER_MENU_PROFILE_BEFORE) }}
+
+ {{ $item }}
+
+ {{ \Filament\Support\Facades\FilamentView::renderHook(\Filament\View\PanelsRenderHook::USER_MENU_PROFILE_AFTER) }}
+ @else
+ {{ $item }}
+ @endif
+ @endforeach
+
+ @endif
+
+
+{{ \Filament\Support\Facades\FilamentView::renderHook(\Filament\View\PanelsRenderHook::USER_MENU_AFTER) }}
diff --git a/routes/web.php b/routes/web.php
index 51d13a585..68bccfd9e 100644
--- a/routes/web.php
+++ b/routes/web.php
@@ -2,15 +2,10 @@
declare(strict_types=1);
-/*
-|--------------------------------------------------------------------------
-| Web Routes
-|--------------------------------------------------------------------------
-|
-| Here is where you can register web routes for your application. These
-| routes are loaded by the RouteServiceProvider within a group which
-| contains the "web" middleware group. Now create something great!
-|
-*/
+use App\Http\Controllers\SwitchLocaleController;
+use App\Support\ApplicationLocale;
+use Illuminate\Support\Facades\Route;
-// Route::get('/', fn () => view('welcome'));
+Route::get('/locale/{locale}', SwitchLocaleController::class)
+ ->whereIn('locale', ApplicationLocale::SUPPORTED)
+ ->name('locale.switch');
diff --git a/tests/Feature/LocaleSwitcherTest.php b/tests/Feature/LocaleSwitcherTest.php
new file mode 100644
index 000000000..4242c62b9
--- /dev/null
+++ b/tests/Feature/LocaleSwitcherTest.php
@@ -0,0 +1,63 @@
+from('/admin')
+ ->get(route('locale.switch', ['locale' => ApplicationLocale::PT_BR]))
+ ->assertRedirect('/admin');
+
+ expect(session(ApplicationLocale::SESSION_KEY))->toBe(ApplicationLocale::PT_BR);
+});
+
+it('rejects unsupported locale', function (): void {
+ $this->get('/locale/fr')->assertNotFound();
+});
+
+it('applies locale from session via middleware', function (): void {
+ session([ApplicationLocale::SESSION_KEY => ApplicationLocale::PT_BR]);
+
+ $middleware = new SetApplicationLocale;
+ $request = Request::create('/');
+
+ $middleware->handle($request, function (): ResponseFactory|Response {
+ expect(app()->getLocale())->toBe(ApplicationLocale::PT_BR)
+ ->and(Date::getLocale())->toBe('pt_BR');
+
+ return response('ok');
+ });
+});
+
+it('formats datetimes according to the active locale', function (): void {
+ ApplicationLocale::apply(ApplicationLocale::PT_BR);
+
+ expect(ApplicationLocale::dateTimeFormat())->toBe('d/m/Y H:i:s');
+
+ ApplicationLocale::apply(ApplicationLocale::EN);
+
+ expect(ApplicationLocale::dateTimeFormat())->toBe('M j, Y H:i:s');
+});
+
+it('falls back to english for invalid session locale', function (): void {
+ session([ApplicationLocale::SESSION_KEY => 'invalid']);
+
+ expect(ApplicationLocale::resolve())->toBe(ApplicationLocale::EN);
+});
+
+it('renders locale switcher with supported locales', function (): void {
+ $html = Blade::render('');
+
+ expect($html)
+ ->toContain('EN')
+ ->toContain('PT-BR')
+ ->toContain(route('locale.switch', ['locale' => ApplicationLocale::EN]))
+ ->toContain(route('locale.switch', ['locale' => ApplicationLocale::PT_BR]));
+});
From 162c4dedabbc2609dee6c846f1b9901cc94826b1 Mon Sep 17 00:00:00 2001
From: Adryanne Kelly <72768888+AdryanneKelly@users.noreply.github.com>
Date: Tue, 7 Jul 2026 13:28:01 -0300
Subject: [PATCH 15/18] feat(events): application enrollment with form builder
(#383)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
## Summary
Implements the Application enrollment method: the participant fills a
dynamic form defined by the organizer, the enrollment lands as
`pending`, and the organizer approves or rejects it from the Admin
panel. Reuses `EnrollUserAction` as the shared entrypoint (RSVP path
untouched) and adds two new domain actions for the approval/rejection
decision.
Flow: participant submits form → `EnrollUserAction` (pending, no
capacity check) → Admin approves/rejects → `ApproveApplicationAction`
(capacity check + confirm/waitlist) or `RejectApplicationAction` (reason
+ rejected)
Closes #237
## What changed — app-modules/events
| File | Role |
|---|---|
| `src/Enrollment/Actions/EnrollUserAction.php` | Application path:
creates `pending` enrollment, validates `application_data` against the
policy's schema (per-type: `required`, `select`/`checkbox` options),
skips capacity check on submission. |
| `src/Enrollment/Actions/ApproveApplicationAction.php` | Validates
status is `pending`, capacity check with `lockForUpdate`, transitions to
`confirmed`/`waitlisted` (or throws if full with no waitlist),
dispatches `EnrollmentConfirmed`/`EnrollmentWaitlisted`, audit trail
(`triggered_by=admin`, `actor_id`). |
| `src/Enrollment/Actions/RejectApplicationAction.php` | Validates
status is `pending`, stores `rejection_reason`, transitions to
`rejected`, audit trail with reason. |
| `src/Enrollment/DTOs/ApproveApplicationDTO.php`,
`RejectApplicationDTO.php` | New DTOs for the two admin actions. |
| `src/Enrollment/DTOs/EnrollUserDTO.php` | Adds `applicationData`
payload. |
| `src/Enrollment/Exceptions/EnrollmentException.php` | Adds
`enrollmentNotPending()` and `applicationDataInvalid()` factories. |
| `src/Enrollment/Models/Enrollment.php`, `EnrollmentPolicy.php` |
Corrects `@property` typing for `application_data` /
`application_schema` (list, not associative array) — removes a PHPStan
type-mismatch cascade in the new validation code. |
| `database/factories/EnrollmentPolicyFactory.php`,
`database/seeders/EventsSeeder.php` | `application()` factory state +
seed data covering all 4 field types
(`text`/`textarea`/`select`/`checkbox`) across two Application-method
events. |
| `lang/{en,pt_BR}/{exceptions,pages}.php` | New user-facing strings. |
## What changed — app-modules/panel-admin
| File | Role |
|---|---|
| `Filament/Resources/Events/Schemas/EventForm.php` | Repeater-based
form builder for `application_schema` (type/label/required/options),
visible only when `enrollment_method = Application`. |
|
`Filament/Resources/Events/RelationManagers/Actions/ApproveApplicationAction.php`,
`RejectApplicationAction.php` | Table row actions — approve
(confirmation modal) / reject (reason textarea) — call into the domain
actions. |
|
`Filament/Resources/Events/RelationManagers/EnrollmentsRelationManager.php`
| Wires the two actions plus a "View Application" modal on
pending/decided rows. |
| `resources/views/enrollments/application-data.blade.php` | Read-only
rendering of submitted answers (boolean, array, and scalar aware). |
## What changed — app-modules/panel-app
| File | Role |
|---|---|
| `Livewire/Events/EventDetail.php` | `canApply` computed prop,
`apply()` action, pre-initializes checkbox-group state as arrays in
`mount()`. |
| `resources/views/livewire/events/event-detail.blade.php` | Dynamic
form rendered per field type; `pending`/`rejected` status views showing
the submitted answers / rejection reason. |
## Design notes (the non-obvious bits)
**`checkbox` = multi-select group, not a single boolean.** The issue's
schema (`{type, label, required, options?}`) doesn't spell this out, so
it was ambiguous whether `checkbox` meant a single yes/no toggle or a
group of checkboxes sharing one `options` list (like `select`, but
multi-value). Went with the multi-select reading — `checkbox` now shows
the same `options` builder as `select` in the Admin, and stores an array
of selected values. Validation treats `required` as "at least one
selected" and checks each selection against `options`.
**Livewire needs the array pre-seeded.** Multiple ``
bound to the same array path only get grouped into an array by
Livewire's front end if that path is *already* an array before the first
interaction — otherwise every checkbox in the group ends up sharing one
boolean and checking one checks them all. `EventDetail::mount()` now
seeds `applicationFormData[$index] = []` for every `checkbox` field up
front.
**Filament enum-cast gotcha.**
`Select::make('enrollment_method')->options(EnrollmentMethod::class)`
auto-applies an `EnumStateCast`, so `$get('enrollment_method')` inside
`visible()` closures returns the enum *instance*, not `->value`. The
Repeater's visibility check was comparing against the string value and
silently never showed — fixed by comparing against the enum case
directly.
## Acceptance criteria
- [x] Organizer can define a dynamic form schema via Filament form
builder in Admin
- [x] Participant sees and fills the dynamic form in App panel
- [x] Submission creates enrollment with status `pending` and
`application_data` populated
- [x] Organizer can approve → enrollment transitions to `confirmed` (or
`waitlisted` if full)
- [x] Organizer can reject with reason → enrollment transitions to
`rejected` with reason stored
- [x] Capacity is checked on approval, not on submission
- [x] Application data is displayed in Admin enrollment detail
- [x] Transition audit trail records approver/rejector identity
- [x] Feature tests: submit application, approve, reject, approve when
full (waitlist), approve when full (no waitlist)
- [x] Pint passes
## Test plan
```
# Application enrollment tests
vendor/bin/pest app-modules/events/tests/Feature/EnrollUserActionTest.php \
app-modules/events/tests/Feature/Enrollment/ApproveApplicationActionTest.php \
app-modules/events/tests/Feature/Enrollment/RejectApplicationActionTest.php \
app-modules/panel-app/tests/Feature/Events/ApplicationEnrollmentTest.php
# Full events + panel-app suite (must stay green)
vendor/bin/pest app-modules/events/tests app-modules/panel-app/tests
# Style
vendor/bin/pint --test app-modules/events app-modules/panel-admin app-modules/panel-app
```
Results:
- Application enrollment tests: 34/34
- events + panel-app suite: 181/181 (0 regressions)
- Pint: clean
Scenarios covered: submit → pending + audit trail · missing
`applicationData` → exception · missing required field → exception · no
schema defined → pending · event past → rejected · approve → confirmed +
audit trail · approve at capacity + waitlist → waitlisted · approve at
capacity, no waitlist → event full exception · approve non-pending →
exception · no capacity limit → always confirms · reject with reason →
rejected · reject non-pending → exception · reject preserves
`application_data` · apply button rendering ·
`canApply`/`canConfirmPresence` flags · pending status shows submitted
answers · rejected shows reason · already applied → no apply button ·
missing required field → error notification.
**Note on PHPStan:** the module already carried ~172 pre-existing errors
before this branch (untyped test-helper array params, Pest fluent-call
false positives from Larastan) — out of scope here. This PR's own new
type mismatch (`EnrollUserAction::validateApplicationData` against the
`application_schema`/`application_data` docblocks) was fixed; remaining
new findings are the same pre-existing categories, consistent with every
other test file in the suite.
## Out of scope (separate issues)
- Admin bulk actions on pending applications (filter/view exist; bulk
approve/reject not implemented).
- Editing a submitted application before it's reviewed.
---------
Co-authored-by: Claude Sonnet 5
Co-authored-by: danielhe4rt
---
.../factories/EnrollmentPolicyFactory.php | 8 +
.../events/database/seeders/EventsSeeder.php | 36 ++++
app-modules/events/lang/en/exceptions.php | 2 +
app-modules/events/lang/en/pages.php | 19 +++
app-modules/events/lang/pt_BR/exceptions.php | 2 +
app-modules/events/lang/pt_BR/pages.php | 19 +++
.../Actions/ApproveApplicationAction.php | 106 ++++++++++++
.../Enrollment/Actions/EnrollUserAction.php | 73 +++++++-
.../Actions/RejectApplicationAction.php | 47 +++++
.../Enrollment/DTOs/ApproveApplicationDTO.php | 13 ++
.../src/Enrollment/DTOs/EnrollUserDTO.php | 4 +
.../Enrollment/DTOs/RejectApplicationDTO.php | 14 ++
.../Exceptions/EnrollmentException.php | 16 ++
.../src/Enrollment/Models/Enrollment.php | 2 +-
.../Enrollment/Models/EnrollmentPolicy.php | 2 +-
.../tests/Feature/EnrollUserActionTest.php | 114 +++++++++++++
.../ApproveApplicationActionTest.php | 160 ++++++++++++++++++
.../RejectApplicationActionTest.php | 112 ++++++++++++
.../enrollments/application-data.blade.php | 51 ++++++
.../Actions/ApproveApplicationAction.php | 55 ++++++
.../Actions/RejectApplicationAction.php | 62 +++++++
.../EnrollmentsRelationManager.php | 35 +++-
.../Resources/Events/Schemas/EventForm.php | 38 ++++-
.../livewire/events/event-detail.blade.php | 103 +++++++++++
.../src/Livewire/Events/EventDetail.php | 59 ++++++-
.../Events/ApplicationEnrollmentTest.php | 121 +++++++++++++
26 files changed, 1259 insertions(+), 14 deletions(-)
create mode 100644 app-modules/events/src/Enrollment/Actions/ApproveApplicationAction.php
create mode 100644 app-modules/events/src/Enrollment/Actions/RejectApplicationAction.php
create mode 100644 app-modules/events/src/Enrollment/DTOs/ApproveApplicationDTO.php
create mode 100644 app-modules/events/src/Enrollment/DTOs/RejectApplicationDTO.php
create mode 100644 app-modules/events/tests/Feature/Enrollment/ApproveApplicationActionTest.php
create mode 100644 app-modules/events/tests/Feature/Enrollment/RejectApplicationActionTest.php
create mode 100644 app-modules/panel-admin/resources/views/enrollments/application-data.blade.php
create mode 100644 app-modules/panel-admin/src/Filament/Resources/Events/RelationManagers/Actions/ApproveApplicationAction.php
create mode 100644 app-modules/panel-admin/src/Filament/Resources/Events/RelationManagers/Actions/RejectApplicationAction.php
create mode 100644 app-modules/panel-app/tests/Feature/Events/ApplicationEnrollmentTest.php
diff --git a/app-modules/events/database/factories/EnrollmentPolicyFactory.php b/app-modules/events/database/factories/EnrollmentPolicyFactory.php
index b4b771f71..96f1ab197 100644
--- a/app-modules/events/database/factories/EnrollmentPolicyFactory.php
+++ b/app-modules/events/database/factories/EnrollmentPolicyFactory.php
@@ -42,4 +42,12 @@ public function rsvp(): static
'enrollment_method' => EnrollmentMethod::Rsvp,
]);
}
+
+ public function application(?array $schema = null): static
+ {
+ return $this->state(fn (): array => [
+ 'enrollment_method' => EnrollmentMethod::Application,
+ 'application_schema' => $schema,
+ ]);
+ }
}
diff --git a/app-modules/events/database/seeders/EventsSeeder.php b/app-modules/events/database/seeders/EventsSeeder.php
index 592f58748..0f57c2ced 100644
--- a/app-modules/events/database/seeders/EventsSeeder.php
+++ b/app-modules/events/database/seeders/EventsSeeder.php
@@ -166,6 +166,12 @@ private function matrix(): array
'minimum_days' => 2,
'cancellation_deadline_hours' => 48,
'xp' => [200, 300, 1000],
+ 'application_schema' => [
+ ['type' => 'text', 'label' => 'Por que quer participar da He4rt Conf?', 'required' => true],
+ ['type' => 'select', 'label' => 'Nível de experiência em desenvolvimento', 'required' => true, 'options' => ['Iniciante', 'Intermediário', 'Avançado']],
+ ['type' => 'textarea', 'label' => 'Descreva um projeto pessoal ou open source relevante', 'required' => false],
+ ['type' => 'checkbox', 'label' => 'Em quais dias você pode comparecer?', 'required' => false, 'options' => ['Dia 1', 'Dia 2', 'Dia 3']],
+ ],
'enrollments' => [
EnrollmentStatus::Pending->value => 6,
EnrollmentStatus::Confirmed->value => 12,
@@ -258,6 +264,10 @@ private function matrix(): array
'minimum_days' => null,
'cancellation_deadline_hours' => 48,
'xp' => [100, 200, 800],
+ 'application_schema' => [
+ ['type' => 'text', 'label' => 'Qual ideia de projeto você traria para o hackathon?', 'required' => true],
+ ['type' => 'select', 'label' => 'Stack principal', 'required' => true, 'options' => ['PHP', 'JavaScript', 'Python', 'Go', 'Rust', 'Outra']],
+ ],
'enrollments' => [
EnrollmentStatus::Cancelled->value => 8,
],
@@ -279,6 +289,7 @@ private function seedPolicy(Event $event, array $spec): void
'xp_on_confirmed' => $spec['xp'][0],
'xp_on_checked_in' => $spec['xp'][1],
'xp_on_attended' => $spec['xp'][2],
+ 'application_schema' => $spec['application_schema'] ?? null,
]);
}
@@ -307,6 +318,7 @@ private function seedEnrollments(Event $event, array $spec, Collection $particip
'status' => $status,
'waitlist_position' => $status === EnrollmentStatus::Waitlisted ? $waitlistPosition++ : null,
'rejection_reason' => $status === EnrollmentStatus::Rejected ? 'Vagas esgotadas.' : null,
+ 'application_data' => $this->fakeApplicationData($spec['application_schema'] ?? null),
...$this->enrollmentTimestamps($status),
]);
}
@@ -407,6 +419,30 @@ private function enrollmentTimestamps(EnrollmentStatus $status): array
};
}
+ /**
+ * @param array>|null $schema
+ * @return array|null
+ */
+ private function fakeApplicationData(?array $schema): ?array
+ {
+ if ($schema === null) {
+ return null;
+ }
+
+ $data = [];
+
+ foreach ($schema as $index => $field) {
+ $data[$index] = match ($field['type'] ?? 'text') {
+ 'select' => fake()->randomElement($field['options'] ?? ['Opção A']),
+ 'checkbox' => blank($field['options']) ? [] : fake()->randomElements($field['options'], fake()->numberBetween(1, count($field['options']))),
+ 'textarea' => fake()->paragraph(),
+ default => fake()->sentence(),
+ };
+ }
+
+ return $data;
+ }
+
private function resolveTenant(): Tenant
{
return Tenant::query()->where('slug', 'he4rt')->first()
diff --git a/app-modules/events/lang/en/exceptions.php b/app-modules/events/lang/en/exceptions.php
index 116dd963b..b3f40e330 100644
--- a/app-modules/events/lang/en/exceptions.php
+++ b/app-modules/events/lang/en/exceptions.php
@@ -13,4 +13,6 @@
'override_reason_required' => 'A reason is required when overriding an enrollment status.',
'override_not_allowed' => 'Override from :from to :to is not allowed.',
'override_status_changed' => 'Enrollment status changed from :expected to :actual before the override was saved. Review the current status and try again.',
+ 'enrollment_not_pending' => 'This enrollment is not pending and cannot be approved or rejected.',
+ 'application_data_invalid' => 'The submitted application data is invalid or missing required answers.',
];
diff --git a/app-modules/events/lang/en/pages.php b/app-modules/events/lang/en/pages.php
index 0368bf5d5..a45bb3d2a 100644
--- a/app-modules/events/lang/en/pages.php
+++ b/app-modules/events/lang/en/pages.php
@@ -28,4 +28,23 @@
'no_check_ins_yet' => 'No check-ins recorded yet.',
'enter_check_in_code_hint' => 'Enter the check-in code announced by the organizer.',
'check_in' => 'Check In',
+ 'apply_hint' => 'Fill in the form below to apply for this event.',
+ 'apply_submit' => 'Submit Application',
+ 'application_submitted' => 'Your application has been submitted and is pending review.',
+ 'application_pending_hint' => 'Your application is under review. You will be notified once it is evaluated.',
+ 'application_your_answers' => 'Your Answers',
+ 'application_rejected_hint' => 'Your application was not accepted.',
+ 'application_rejection_reason' => 'Reason: :reason',
+ 'apply_select_option_placeholder' => 'Select an option',
+ 'admin_approve_application' => 'Approve',
+ 'admin_approve_application_modal_heading' => 'Approve Application',
+ 'admin_approve_application_modal_description' => 'Approve the application from :name?',
+ 'admin_approve_application_success' => 'Application approved.',
+ 'admin_reject_application' => 'Reject',
+ 'admin_reject_application_reason_label' => 'Rejection Reason',
+ 'admin_reject_application_success' => 'Application rejected.',
+ 'admin_application_no_data' => 'No application data available.',
+ 'admin_application_answer_yes' => 'Yes',
+ 'admin_application_answer_no' => 'No',
+ 'admin_application_no_answer' => 'No answer',
];
diff --git a/app-modules/events/lang/pt_BR/exceptions.php b/app-modules/events/lang/pt_BR/exceptions.php
index f2a6eaefc..9742ca094 100644
--- a/app-modules/events/lang/pt_BR/exceptions.php
+++ b/app-modules/events/lang/pt_BR/exceptions.php
@@ -13,4 +13,6 @@
'override_reason_required' => 'É obrigatório informar um motivo ao sobrescrever o status de uma inscrição.',
'override_not_allowed' => 'Sobrescrita de :from para :to não é permitida.',
'override_status_changed' => 'O status da inscrição mudou de :expected para :actual antes da sobrescrita ser salva. Revise o status atual e tente novamente.',
+ 'enrollment_not_pending' => 'Esta inscrição não está pendente e não pode ser aprovada ou rejeitada.',
+ 'application_data_invalid' => 'Os dados enviados na candidatura são inválidos ou faltam respostas obrigatórias.',
];
diff --git a/app-modules/events/lang/pt_BR/pages.php b/app-modules/events/lang/pt_BR/pages.php
index abec54537..a26e43e59 100644
--- a/app-modules/events/lang/pt_BR/pages.php
+++ b/app-modules/events/lang/pt_BR/pages.php
@@ -28,4 +28,23 @@
'no_check_ins_yet' => 'Nenhum check-in registrado ainda.',
'enter_check_in_code_hint' => 'Insira o código de check-in informado pelo organizador.',
'check_in' => 'Fazer Check-in',
+ 'apply_hint' => 'Preencha o formulário abaixo para se candidatar a este evento.',
+ 'apply_submit' => 'Enviar Candidatura',
+ 'application_submitted' => 'Sua candidatura foi enviada e está aguardando avaliação.',
+ 'application_pending_hint' => 'Sua candidatura está em análise. Você será notificado quando for avaliada.',
+ 'application_your_answers' => 'Suas Respostas',
+ 'application_rejected_hint' => 'Sua candidatura não foi aceita.',
+ 'application_rejection_reason' => 'Motivo: :reason',
+ 'apply_select_option_placeholder' => 'Selecione uma opção',
+ 'admin_approve_application' => 'Aprovar',
+ 'admin_approve_application_modal_heading' => 'Aprovar Candidatura',
+ 'admin_approve_application_modal_description' => 'Aprovar a candidatura de :name?',
+ 'admin_approve_application_success' => 'Candidatura aprovada.',
+ 'admin_reject_application' => 'Rejeitar',
+ 'admin_reject_application_reason_label' => 'Motivo da Rejeição',
+ 'admin_reject_application_success' => 'Candidatura rejeitada.',
+ 'admin_application_no_data' => 'Nenhum dado de candidatura disponível.',
+ 'admin_application_answer_yes' => 'Sim',
+ 'admin_application_answer_no' => 'Não',
+ 'admin_application_no_answer' => 'Sem resposta',
];
diff --git a/app-modules/events/src/Enrollment/Actions/ApproveApplicationAction.php b/app-modules/events/src/Enrollment/Actions/ApproveApplicationAction.php
new file mode 100644
index 000000000..c83ff2e57
--- /dev/null
+++ b/app-modules/events/src/Enrollment/Actions/ApproveApplicationAction.php
@@ -0,0 +1,106 @@
+whereKey($dto->enrollmentId)
+ ->lockForUpdate()
+ ->firstOrFail();
+
+ throw_unless(
+ $enrollment->status === EnrollmentStatus::Pending,
+ EnrollmentException::enrollmentNotPending(),
+ );
+
+ $policy = EnrollmentPolicy::query()
+ ->where('event_id', $enrollment->event_id)
+ ->lockForUpdate()
+ ->firstOrFail();
+
+ $toStatus = $this->resolveApprovalStatus($enrollment->event_id, $policy);
+
+ $waitlistPosition = null;
+ if ($toStatus === EnrollmentStatus::Waitlisted) {
+ $waitlistPosition = (int) Enrollment::query()
+ ->where('event_id', $enrollment->event_id)
+ ->waitlisted()
+ ->max('waitlist_position') + 1;
+
+ $enrollment->update(['waitlist_position' => $waitlistPosition]);
+ }
+
+ $this->transitionEnrollment->handle(new TransitionEnrollmentDTO(
+ enrollment: $enrollment,
+ toStatus: $toStatus,
+ triggeredBy: TriggeredBy::Admin,
+ actorId: $dto->actorId,
+ ));
+
+ if ($toStatus->isConfirmed()) {
+ event(new EnrollmentConfirmed(
+ enrollmentId: $enrollment->id,
+ eventId: $enrollment->event_id,
+ userId: $enrollment->user_id,
+ xpRewardOnConfirmed: $policy->xp_on_confirmed ?? 0,
+ ));
+ }
+
+ if ($toStatus->isWaitlisted()) {
+ event(new EnrollmentWaitlisted(
+ enrollmentId: $enrollment->id,
+ eventId: $enrollment->event_id,
+ userId: $enrollment->user_id,
+ waitlistPosition: $waitlistPosition,
+ ));
+ }
+
+ return $enrollment->fresh(['event.enrollmentPolicy']);
+ });
+ }
+
+ private function resolveApprovalStatus(string $eventId, EnrollmentPolicy $policy): EnrollmentStatus
+ {
+ $capacity = $policy->capacity;
+
+ if ($capacity === null) {
+ return EnrollmentStatus::Confirmed;
+ }
+
+ $occupiedCount = Enrollment::query()
+ ->where('event_id', $eventId)
+ ->active()
+ ->count();
+
+ if ($occupiedCount < $capacity) {
+ return EnrollmentStatus::Confirmed;
+ }
+
+ if ($policy->has_waitlist) {
+ return EnrollmentStatus::Waitlisted;
+ }
+
+ throw EnrollmentException::eventFull();
+ }
+}
diff --git a/app-modules/events/src/Enrollment/Actions/EnrollUserAction.php b/app-modules/events/src/Enrollment/Actions/EnrollUserAction.php
index 255e73199..915a16668 100644
--- a/app-modules/events/src/Enrollment/Actions/EnrollUserAction.php
+++ b/app-modules/events/src/Enrollment/Actions/EnrollUserAction.php
@@ -27,7 +27,7 @@ public function handle(EnrollUserDTO $dto): Enrollment
return DB::transaction(function () use ($dto): Enrollment {
[$event, $policy] = $this->loadLockedEnrollmentContext($dto->eventId);
- $this->validate($event, $policy, $dto->userId);
+ $this->validate($event, $policy, $dto->userId, $dto->applicationData);
$initial = $this->resolveInitialEnrollment($dto->eventId, $policy);
@@ -39,6 +39,7 @@ public function handle(EnrollUserDTO $dto): Enrollment
'enrolled_at' => now(),
'confirmed_at' => $initial['confirmedAt'],
'waitlist_position' => $initial['waitlistPosition'],
+ 'application_data' => $dto->applicationData,
]);
EnrollmentTransition::query()->create([
@@ -103,6 +104,14 @@ private function loadLockedEnrollmentContext(string $eventId): array
*/
private function resolveInitialEnrollment(string $eventId, ?EnrollmentPolicy $policy): array
{
+ if ($policy?->enrollment_method === EnrollmentMethod::Application) {
+ return [
+ 'status' => EnrollmentStatus::Pending,
+ 'waitlistPosition' => null,
+ 'confirmedAt' => null,
+ ];
+ }
+
$capacity = $policy?->capacity;
if ($capacity === null) {
@@ -142,7 +151,10 @@ private function resolveInitialEnrollment(string $eventId, ?EnrollmentPolicy $po
throw EnrollmentException::eventFull();
}
- private function validate(Event $event, ?EnrollmentPolicy $policy, string $userId): void
+ /**
+ * @param array|null $applicationData
+ */
+ private function validate(Event $event, ?EnrollmentPolicy $policy, string $userId, ?array $applicationData): void
{
throw_unless(
$event->status === EventStatus::Published,
@@ -154,10 +166,12 @@ private function validate(Event $event, ?EnrollmentPolicy $policy, string $userI
EnrollmentException::eventPast(),
);
+ $method = $policy?->enrollment_method;
+
throw_unless(
in_array(
- $policy?->enrollment_method,
- [EnrollmentMethod::Rsvp, EnrollmentMethod::RsvpCheckin],
+ $method,
+ [EnrollmentMethod::Rsvp, EnrollmentMethod::RsvpCheckin, EnrollmentMethod::Application],
strict: true,
),
EnrollmentException::invalidEnrollmentMethod(),
@@ -170,5 +184,56 @@ private function validate(Event $event, ?EnrollmentPolicy $policy, string $userI
->exists(),
EnrollmentException::alreadyEnrolled(),
);
+
+ if ($method === EnrollmentMethod::Application) {
+ throw_if(
+ $applicationData === null,
+ EnrollmentException::applicationDataInvalid(),
+ );
+
+ $this->validateApplicationData($applicationData, $policy->application_schema ?? []);
+ }
+ }
+
+ /**
+ * @param array $data
+ * @param array> $schema
+ */
+ private function validateApplicationData(array $data, array $schema): void
+ {
+ foreach ($schema as $index => $field) {
+ $value = $data[$index] ?? null;
+ $type = $field['type'] ?? '';
+
+ if ($type === 'checkbox') {
+ $selected = is_array($value) ? $value : [];
+
+ throw_if(
+ ($field['required'] ?? false) && $selected === [],
+ EnrollmentException::applicationDataInvalid(),
+ );
+
+ $options = $field['options'] ?? [];
+ foreach ($selected as $option) {
+ throw_unless(
+ in_array($option, $options, strict: true),
+ EnrollmentException::applicationDataInvalid(),
+ );
+ }
+
+ continue;
+ }
+
+ if (($field['required'] ?? false) && (in_array($value, [null, '', false], true))) {
+ throw EnrollmentException::applicationDataInvalid();
+ }
+
+ if ($type === 'select' && $value !== null) {
+ $options = $field['options'] ?? [];
+ if (!in_array($value, $options, strict: true)) {
+ throw EnrollmentException::applicationDataInvalid();
+ }
+ }
+ }
}
}
diff --git a/app-modules/events/src/Enrollment/Actions/RejectApplicationAction.php b/app-modules/events/src/Enrollment/Actions/RejectApplicationAction.php
new file mode 100644
index 000000000..0511347c7
--- /dev/null
+++ b/app-modules/events/src/Enrollment/Actions/RejectApplicationAction.php
@@ -0,0 +1,47 @@
+whereKey($dto->enrollmentId)
+ ->lockForUpdate()
+ ->firstOrFail();
+
+ throw_unless(
+ $enrollment->status === EnrollmentStatus::Pending,
+ EnrollmentException::enrollmentNotPending(),
+ );
+
+ $enrollment->update(['rejection_reason' => $dto->reason]);
+
+ $this->transitionEnrollment->handle(new TransitionEnrollmentDTO(
+ enrollment: $enrollment,
+ toStatus: EnrollmentStatus::Rejected,
+ triggeredBy: TriggeredBy::Admin,
+ actorId: $dto->actorId,
+ reason: $dto->reason,
+ ));
+
+ return $enrollment->fresh();
+ });
+ }
+}
diff --git a/app-modules/events/src/Enrollment/DTOs/ApproveApplicationDTO.php b/app-modules/events/src/Enrollment/DTOs/ApproveApplicationDTO.php
new file mode 100644
index 000000000..ca7f83e82
--- /dev/null
+++ b/app-modules/events/src/Enrollment/DTOs/ApproveApplicationDTO.php
@@ -0,0 +1,13 @@
+|null $applicationData
+ */
public function __construct(
public string $eventId,
public string $userId,
+ public ?array $applicationData = null,
) {}
public static function fromModels(Event $event, User $user): self
diff --git a/app-modules/events/src/Enrollment/DTOs/RejectApplicationDTO.php b/app-modules/events/src/Enrollment/DTOs/RejectApplicationDTO.php
new file mode 100644
index 000000000..9364973a7
--- /dev/null
+++ b/app-modules/events/src/Enrollment/DTOs/RejectApplicationDTO.php
@@ -0,0 +1,14 @@
+|null $application_data
+ * @property array|null $application_data
* @property string|null $rejection_reason
* @property Carbon|null $enrolled_at
* @property Carbon|null $confirmed_at
diff --git a/app-modules/events/src/Enrollment/Models/EnrollmentPolicy.php b/app-modules/events/src/Enrollment/Models/EnrollmentPolicy.php
index 297cbf6db..00d95fd14 100644
--- a/app-modules/events/src/Enrollment/Models/EnrollmentPolicy.php
+++ b/app-modules/events/src/Enrollment/Models/EnrollmentPolicy.php
@@ -30,7 +30,7 @@
* @property int $xp_on_confirmed
* @property int $xp_on_checked_in
* @property int $xp_on_attended
- * @property array|null $application_schema
+ * @property array>|null $application_schema
* @property Carbon $created_at
* @property Carbon $updated_at
*/
diff --git a/app-modules/events/tests/Feature/EnrollUserActionTest.php b/app-modules/events/tests/Feature/EnrollUserActionTest.php
index 1be9dff4e..15391701b 100644
--- a/app-modules/events/tests/Feature/EnrollUserActionTest.php
+++ b/app-modules/events/tests/Feature/EnrollUserActionTest.php
@@ -333,3 +333,117 @@ function createRsvpEvent(Tenant $tenant, array $eventAttributes = [], array $pol
expect(Enrollment::query()->where('event_id', $event->id)->where('user_id', $user->id)->count())->toBe(1);
});
+
+// ── Application enrollment ────────────────────────────────────────────────────
+
+test('when a user submits an application, then enrollment is pending with application_data and audit trail', function (): void {
+ EventFacade::fake([EnrollmentConfirmed::class]);
+
+ $user = User::factory()->create();
+ $tenant = Tenant::factory()->create();
+ $event = Event::factory()
+ ->published()
+ ->upcoming()
+ ->for($tenant)
+ ->has(EnrollmentPolicy::factory()->application([
+ ['type' => 'text', 'label' => 'Why do you want to join?', 'required' => true],
+ ]), 'enrollmentPolicy')
+ ->create();
+
+ $dto = new EnrollUserDTO(
+ eventId: $event->id,
+ userId: $user->id,
+ applicationData: [0 => 'I love PHP!'],
+ );
+
+ $enrollment = resolve(EnrollUserAction::class)->handle($dto);
+
+ expect($enrollment->status)->toBe(EnrollmentStatus::Pending)
+ ->and($enrollment->confirmed_at)->toBeNull()
+ ->and($enrollment->application_data)->toBe([0 => 'I love PHP!']);
+
+ $transition = EnrollmentTransition::query()
+ ->where('enrollment_id', $enrollment->id)
+ ->first();
+
+ expect($transition)->not->toBeNull()
+ ->and($transition->from_status)->toBeNull()
+ ->and($transition->to_status)->toBe(EnrollmentStatus::Pending)
+ ->and($transition->triggered_by)->toBe(TriggeredBy::User);
+
+ EventFacade::assertNotDispatched(EnrollmentConfirmed::class);
+});
+
+test('when application is submitted without applicationData, then exception is thrown', function (): void {
+ $user = User::factory()->create();
+ $tenant = Tenant::factory()->create();
+ $event = Event::factory()
+ ->published()
+ ->upcoming()
+ ->for($tenant)
+ ->has(EnrollmentPolicy::factory()->application(), 'enrollmentPolicy')
+ ->create();
+
+ resolve(EnrollUserAction::class)->handle(EnrollUserDTO::fromModels($event, $user));
+})->throws(EnrollmentException::class);
+
+test('when application is missing a required field, then exception is thrown', function (): void {
+ $user = User::factory()->create();
+ $tenant = Tenant::factory()->create();
+ $event = Event::factory()
+ ->published()
+ ->upcoming()
+ ->for($tenant)
+ ->has(EnrollmentPolicy::factory()->application([
+ ['type' => 'text', 'label' => 'Why?', 'required' => true],
+ ]), 'enrollmentPolicy')
+ ->create();
+
+ $dto = new EnrollUserDTO(
+ eventId: $event->id,
+ userId: $user->id,
+ applicationData: [0 => ''],
+ );
+
+ resolve(EnrollUserAction::class)->handle($dto);
+})->throws(EnrollmentException::class);
+
+test('when application is submitted with no schema defined, then enrollment is pending', function (): void {
+ $user = User::factory()->create();
+ $tenant = Tenant::factory()->create();
+ $event = Event::factory()
+ ->published()
+ ->upcoming()
+ ->for($tenant)
+ ->has(EnrollmentPolicy::factory()->application(), 'enrollmentPolicy')
+ ->create();
+
+ $dto = new EnrollUserDTO(
+ eventId: $event->id,
+ userId: $user->id,
+ applicationData: [],
+ );
+
+ $enrollment = resolve(EnrollUserAction::class)->handle($dto);
+
+ expect($enrollment->status)->toBe(EnrollmentStatus::Pending);
+});
+
+test('when application event is past, then application enrollment is rejected', function (): void {
+ $user = User::factory()->create();
+ $tenant = Tenant::factory()->create();
+ $event = Event::factory()
+ ->published()
+ ->past()
+ ->for($tenant)
+ ->has(EnrollmentPolicy::factory()->application(), 'enrollmentPolicy')
+ ->create();
+
+ $dto = new EnrollUserDTO(
+ eventId: $event->id,
+ userId: $user->id,
+ applicationData: [],
+ );
+
+ resolve(EnrollUserAction::class)->handle($dto);
+})->throws(EnrollmentException::class);
diff --git a/app-modules/events/tests/Feature/Enrollment/ApproveApplicationActionTest.php b/app-modules/events/tests/Feature/Enrollment/ApproveApplicationActionTest.php
new file mode 100644
index 000000000..15c359cf2
--- /dev/null
+++ b/app-modules/events/tests/Feature/Enrollment/ApproveApplicationActionTest.php
@@ -0,0 +1,160 @@
+create();
+
+ return Enrollment::factory()->create([
+ 'event_id' => $event->id,
+ 'user_id' => $user->id,
+ 'status' => EnrollmentStatus::Pending,
+ 'enrolled_at' => now(),
+ 'application_data' => [0 => 'My answer'],
+ ]);
+}
+
+function createApplicationEvent(Tenant $tenant, array $policyAttributes = []): Event
+{
+ return Event::factory()
+ ->published()
+ ->upcoming()
+ ->for($tenant)
+ ->has(EnrollmentPolicy::factory()->application()->state($policyAttributes), 'enrollmentPolicy')
+ ->create();
+}
+
+test('when an application is approved, then enrollment transitions to confirmed with audit trail', function (): void {
+ EventFacade::fake([EnrollmentConfirmed::class]);
+
+ $organizer = User::factory()->create();
+ $tenant = Tenant::factory()->create();
+ $event = createApplicationEvent($tenant, ['xp_on_confirmed' => 100]);
+ $enrollment = createPendingApplicationEnrollment($event);
+
+ $result = resolve(ApproveApplicationAction::class)->handle(
+ new ApproveApplicationDTO(enrollmentId: $enrollment->id, actorId: $organizer->id),
+ );
+
+ expect($result->status)->toBe(EnrollmentStatus::Confirmed)
+ ->and($result->confirmed_at)->not->toBeNull();
+
+ $transition = EnrollmentTransition::query()
+ ->where('enrollment_id', $enrollment->id)
+ ->latest()
+ ->first();
+
+ expect($transition->from_status)->toBe(EnrollmentStatus::Pending)
+ ->and($transition->to_status)->toBe(EnrollmentStatus::Confirmed)
+ ->and($transition->triggered_by)->toBe(TriggeredBy::Admin)
+ ->and($transition->actor_id)->toBe($organizer->id);
+
+ EventFacade::assertDispatched(fn (EnrollmentConfirmed $e): bool => $e->enrollmentId === $enrollment->id
+ && $e->xpRewardOnConfirmed === 100);
+});
+
+test('when event is at capacity and has waitlist, then approval results in waitlisted', function (): void {
+ EventFacade::fake([EnrollmentConfirmed::class, EnrollmentWaitlisted::class]);
+
+ $organizer = User::factory()->create();
+ $tenant = Tenant::factory()->create();
+ $event = createApplicationEvent($tenant, ['capacity' => 1, 'has_waitlist' => true]);
+
+ $existingUser = User::factory()->create();
+ Enrollment::factory()->create([
+ 'event_id' => $event->id,
+ 'user_id' => $existingUser->id,
+ 'status' => EnrollmentStatus::Confirmed,
+ 'enrolled_at' => now(),
+ 'confirmed_at' => now(),
+ ]);
+
+ $enrollment = createPendingApplicationEnrollment($event);
+
+ $result = resolve(ApproveApplicationAction::class)->handle(
+ new ApproveApplicationDTO(enrollmentId: $enrollment->id, actorId: $organizer->id),
+ );
+
+ expect($result->status)->toBe(EnrollmentStatus::Waitlisted)
+ ->and($result->waitlist_position)->toBe(1);
+
+ EventFacade::assertNotDispatched(EnrollmentConfirmed::class);
+ EventFacade::assertDispatched(fn (EnrollmentWaitlisted $e): bool => $e->enrollmentId === $enrollment->id
+ && $e->waitlistPosition === 1);
+});
+
+test('when event is at capacity without waitlist, then approval throws event full exception', function (): void {
+ $organizer = User::factory()->create();
+ $tenant = Tenant::factory()->create();
+ $event = createApplicationEvent($tenant, ['capacity' => 1, 'has_waitlist' => false]);
+
+ $existingUser = User::factory()->create();
+ Enrollment::factory()->create([
+ 'event_id' => $event->id,
+ 'user_id' => $existingUser->id,
+ 'status' => EnrollmentStatus::Confirmed,
+ 'enrolled_at' => now(),
+ 'confirmed_at' => now(),
+ ]);
+
+ $enrollment = createPendingApplicationEnrollment($event);
+
+ resolve(ApproveApplicationAction::class)->handle(
+ new ApproveApplicationDTO(enrollmentId: $enrollment->id, actorId: $organizer->id),
+ );
+})->throws(EnrollmentException::class);
+
+test('when approving a non-pending enrollment, then exception is thrown', function (): void {
+ $organizer = User::factory()->create();
+ $tenant = Tenant::factory()->create();
+ $event = createApplicationEvent($tenant);
+
+ $enrollment = Enrollment::factory()->create([
+ 'event_id' => $event->id,
+ 'user_id' => User::factory()->create()->id,
+ 'status' => EnrollmentStatus::Confirmed,
+ 'enrolled_at' => now(),
+ 'confirmed_at' => now(),
+ ]);
+
+ resolve(ApproveApplicationAction::class)->handle(
+ new ApproveApplicationDTO(enrollmentId: $enrollment->id, actorId: $organizer->id),
+ );
+})->throws(EnrollmentException::class);
+
+test('when event has no capacity limit, then approval always confirms', function (): void {
+ EventFacade::fake([EnrollmentConfirmed::class]);
+
+ $organizer = User::factory()->create();
+ $tenant = Tenant::factory()->create();
+ $event = createApplicationEvent($tenant, ['capacity' => null]);
+
+ $enrollments = collect(range(1, 3))->map(
+ fn (): Enrollment => createPendingApplicationEnrollment($event),
+ );
+
+ foreach ($enrollments as $enrollment) {
+ $result = resolve(ApproveApplicationAction::class)->handle(
+ new ApproveApplicationDTO(enrollmentId: $enrollment->id, actorId: $organizer->id),
+ );
+ expect($result->status)->toBe(EnrollmentStatus::Confirmed);
+ }
+
+ EventFacade::assertDispatchedTimes(EnrollmentConfirmed::class, 3);
+});
diff --git a/app-modules/events/tests/Feature/Enrollment/RejectApplicationActionTest.php b/app-modules/events/tests/Feature/Enrollment/RejectApplicationActionTest.php
new file mode 100644
index 000000000..1dc647382
--- /dev/null
+++ b/app-modules/events/tests/Feature/Enrollment/RejectApplicationActionTest.php
@@ -0,0 +1,112 @@
+create();
+ $tenant = Tenant::factory()->create();
+ $event = Event::factory()
+ ->published()
+ ->upcoming()
+ ->for($tenant)
+ ->has(EnrollmentPolicy::factory()->application(), 'enrollmentPolicy')
+ ->create();
+
+ $enrollment = Enrollment::factory()->create([
+ 'event_id' => $event->id,
+ 'user_id' => User::factory()->create()->id,
+ 'status' => EnrollmentStatus::Pending,
+ 'enrolled_at' => now(),
+ 'application_data' => [0 => 'My answer'],
+ ]);
+
+ $result = resolve(RejectApplicationAction::class)->handle(
+ new RejectApplicationDTO(
+ enrollmentId: $enrollment->id,
+ actorId: $organizer->id,
+ reason: 'Not enough experience.',
+ ),
+ );
+
+ expect($result->status)->toBe(EnrollmentStatus::Rejected)
+ ->and($result->rejection_reason)->toBe('Not enough experience.');
+
+ $transition = EnrollmentTransition::query()
+ ->where('enrollment_id', $enrollment->id)
+ ->latest()
+ ->first();
+
+ expect($transition->from_status)->toBe(EnrollmentStatus::Pending)
+ ->and($transition->to_status)->toBe(EnrollmentStatus::Rejected)
+ ->and($transition->triggered_by)->toBe(TriggeredBy::Admin)
+ ->and($transition->actor_id)->toBe($organizer->id)
+ ->and($transition->reason)->toBe('Not enough experience.');
+});
+
+test('when rejecting a non-pending enrollment, then exception is thrown', function (): void {
+ $organizer = User::factory()->create();
+ $tenant = Tenant::factory()->create();
+ $event = Event::factory()
+ ->published()
+ ->upcoming()
+ ->for($tenant)
+ ->has(EnrollmentPolicy::factory()->application(), 'enrollmentPolicy')
+ ->create();
+
+ $enrollment = Enrollment::factory()->create([
+ 'event_id' => $event->id,
+ 'user_id' => User::factory()->create()->id,
+ 'status' => EnrollmentStatus::Confirmed,
+ 'enrolled_at' => now(),
+ 'confirmed_at' => now(),
+ ]);
+
+ resolve(RejectApplicationAction::class)->handle(
+ new RejectApplicationDTO(
+ enrollmentId: $enrollment->id,
+ actorId: $organizer->id,
+ reason: 'Test reason.',
+ ),
+ );
+})->throws(EnrollmentException::class);
+
+test('when application is rejected, then application_data is preserved', function (): void {
+ $organizer = User::factory()->create();
+ $tenant = Tenant::factory()->create();
+ $event = Event::factory()
+ ->published()
+ ->upcoming()
+ ->for($tenant)
+ ->has(EnrollmentPolicy::factory()->application(), 'enrollmentPolicy')
+ ->create();
+
+ $enrollment = Enrollment::factory()->create([
+ 'event_id' => $event->id,
+ 'user_id' => User::factory()->create()->id,
+ 'status' => EnrollmentStatus::Pending,
+ 'enrolled_at' => now(),
+ 'application_data' => [0 => 'Original answer'],
+ ]);
+
+ $result = resolve(RejectApplicationAction::class)->handle(
+ new RejectApplicationDTO(
+ enrollmentId: $enrollment->id,
+ actorId: $organizer->id,
+ reason: 'Rejected.',
+ ),
+ );
+
+ expect($result->application_data)->toBe([0 => 'Original answer']);
+});
diff --git a/app-modules/panel-admin/resources/views/enrollments/application-data.blade.php b/app-modules/panel-admin/resources/views/enrollments/application-data.blade.php
new file mode 100644
index 000000000..2ee884dfb
--- /dev/null
+++ b/app-modules/panel-admin/resources/views/enrollments/application-data.blade.php
@@ -0,0 +1,51 @@
+
+ @if ($record->rejection_reason)
+
+
{{
+ __(
+ 'events::pages.admin_reject_application_reason_label',
+ )
+ }}
+
{{ $record->rejection_reason }}
+
+ @endif
+
+ @if (count($answers) > 0)
+
+ @foreach ($answers as $answer)
+
+
-
+ {{ $answer['label'] }}
+
+ -
+ @if (is_bool($answer['value']))
+ {{
+ $answer['value']
+ ? __('events::pages.admin_application_answer_yes')
+ : __('events::pages.admin_application_answer_no')
+ }}
+ @elseif (is_array($answer['value']))
+ @if ($answer['value'] === [])
+ {{ __('events::pages.admin_application_no_answer') }}
+ @else
+ {{ implode(', ', $answer['value']) }}
+ @endif
+ @elseif ($answer['value'] === '—' || $answer['value'] === null || $answer['value'] === '')
+ {{ __('events::pages.admin_application_no_answer') }}
+ @else
+ {{ $answer['value'] }}
+ @endif
+
+
+ @endforeach
+
+ @else
+
{{ __('events::pages.admin_application_no_data') }}
+ @endif
+
diff --git a/app-modules/panel-admin/src/Filament/Resources/Events/RelationManagers/Actions/ApproveApplicationAction.php b/app-modules/panel-admin/src/Filament/Resources/Events/RelationManagers/Actions/ApproveApplicationAction.php
new file mode 100644
index 000000000..b75cfa5a9
--- /dev/null
+++ b/app-modules/panel-admin/src/Filament/Resources/Events/RelationManagers/Actions/ApproveApplicationAction.php
@@ -0,0 +1,55 @@
+label(__('events::pages.admin_approve_application'))
+ ->icon(Heroicon::OutlinedCheckCircle)
+ ->color('success')
+ ->visible(fn (Enrollment $record): bool => $record->status === EnrollmentStatus::Pending)
+ ->requiresConfirmation()
+ ->modalHeading(__('events::pages.admin_approve_application_modal_heading'))
+ ->modalDescription(fn (Enrollment $record): string => __('events::pages.admin_approve_application_modal_description', ['name' => $record->user?->name]))
+ ->action(function (Enrollment $record): void {
+ try {
+ resolve(ApproveApplicationDomainAction::class)->handle(
+ new ApproveApplicationDTO(
+ enrollmentId: $record->id,
+ actorId: (string) auth()->id(),
+ ),
+ );
+
+ Notification::make()
+ ->success()
+ ->title(__('events::pages.admin_approve_application_success'))
+ ->send();
+ } catch (EnrollmentException $enrollmentException) {
+ Notification::make()
+ ->danger()
+ ->title($enrollmentException->getMessage())
+ ->send();
+ }
+ });
+ }
+
+ public static function getDefaultName(): string
+ {
+ return 'approveApplication';
+ }
+}
diff --git a/app-modules/panel-admin/src/Filament/Resources/Events/RelationManagers/Actions/RejectApplicationAction.php b/app-modules/panel-admin/src/Filament/Resources/Events/RelationManagers/Actions/RejectApplicationAction.php
new file mode 100644
index 000000000..4360f8ae5
--- /dev/null
+++ b/app-modules/panel-admin/src/Filament/Resources/Events/RelationManagers/Actions/RejectApplicationAction.php
@@ -0,0 +1,62 @@
+label(__('events::pages.admin_reject_application'))
+ ->icon(Heroicon::OutlinedXCircle)
+ ->color('danger')
+ ->visible(fn (Enrollment $record): bool => $record->status === EnrollmentStatus::Pending)
+ ->schema([
+ Textarea::make('reason')
+ ->label(__('events::pages.admin_reject_application_reason_label'))
+ ->required()
+ ->minLength(3)
+ ->maxLength(500)
+ ->rows(3),
+ ])
+ ->action(function (Enrollment $record, array $data): void {
+ try {
+ resolve(RejectApplicationDomainAction::class)->handle(
+ new RejectApplicationDTO(
+ enrollmentId: $record->id,
+ actorId: (string) auth()->id(),
+ reason: $data['reason'],
+ ),
+ );
+
+ Notification::make()
+ ->success()
+ ->title(__('events::pages.admin_reject_application_success'))
+ ->send();
+ } catch (EnrollmentException $enrollmentException) {
+ Notification::make()
+ ->danger()
+ ->title($enrollmentException->getMessage())
+ ->send();
+ }
+ });
+ }
+
+ public static function getDefaultName(): string
+ {
+ return 'rejectApplication';
+ }
+}
diff --git a/app-modules/panel-admin/src/Filament/Resources/Events/RelationManagers/EnrollmentsRelationManager.php b/app-modules/panel-admin/src/Filament/Resources/Events/RelationManagers/EnrollmentsRelationManager.php
index c6a462328..f7442fb9e 100644
--- a/app-modules/panel-admin/src/Filament/Resources/Events/RelationManagers/EnrollmentsRelationManager.php
+++ b/app-modules/panel-admin/src/Filament/Resources/Events/RelationManagers/EnrollmentsRelationManager.php
@@ -22,7 +22,10 @@
use He4rt\Events\Enrollment\Enums\EnrollmentStatus;
use He4rt\Events\Enrollment\Models\Enrollment;
use He4rt\Events\Event\Models\Event;
+use He4rt\PanelAdmin\Filament\Resources\Events\RelationManagers\Actions\ApproveApplicationAction;
use He4rt\PanelAdmin\Filament\Resources\Events\RelationManagers\Actions\OverrideEnrollmentStatusAction;
+use He4rt\PanelAdmin\Filament\Resources\Events\RelationManagers\Actions\RejectApplicationAction;
+use Illuminate\Contracts\View\View;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Collection;
use Illuminate\Database\Eloquent\Model;
@@ -40,7 +43,7 @@ public static function getTitle(Model $ownerRecord, string $pageClass): string
public function table(Table $table): Table
{
return $table
- ->recordTitleAttribute('id')
+ ->recordTitle(fn (Enrollment $record): string => $record->user?->name ?? $record->id)
->modifyQueryUsing(fn (Builder $query): Builder => $query->with(['checkIns', 'user']))
->columns([
TextColumn::make('user.name')
@@ -90,6 +93,9 @@ public function table(Table $table): Table
->options(EnrollmentStatus::class),
])
->recordActions([
+ $this->viewApplicationAction(),
+ ApproveApplicationAction::make(),
+ RejectApplicationAction::make(),
$this->checkInAction(),
OverrideEnrollmentStatusAction::make(),
DeleteAction::make(),
@@ -102,6 +108,33 @@ public function table(Table $table): Table
]);
}
+ private function viewApplicationAction(): Action
+ {
+ return Action::make('viewApplication')
+ ->label('View Application')
+ ->icon(Heroicon::OutlinedDocumentText)
+ ->color('gray')
+ ->visible(fn (Enrollment $record): bool => $record->application_data !== null)
+ ->modalContent(function (Enrollment $record): View {
+ $schema = $record->event?->enrollmentPolicy?->application_schema ?? [];
+ $data = $record->application_data ?? [];
+
+ $answers = collect($schema)
+ ->map(fn (array $field, int $index): array => [
+ 'label' => $field['label'] ?? 'Question '.$index,
+ 'value' => $data[$index] ?? '—',
+ ])
+ ->values()
+ ->all();
+
+ return view('panel-admin::enrollments.application-data', [
+ 'answers' => $answers,
+ 'record' => $record,
+ ]);
+ })
+ ->modalSubmitAction(false);
+ }
+
private function checkInAction(): Action
{
return Action::make('checkIn')
diff --git a/app-modules/panel-admin/src/Filament/Resources/Events/Schemas/EventForm.php b/app-modules/panel-admin/src/Filament/Resources/Events/Schemas/EventForm.php
index 47ae0b0a2..da6800187 100644
--- a/app-modules/panel-admin/src/Filament/Resources/Events/Schemas/EventForm.php
+++ b/app-modules/panel-admin/src/Filament/Resources/Events/Schemas/EventForm.php
@@ -5,8 +5,9 @@
namespace He4rt\PanelAdmin\Filament\Resources\Events\Schemas;
use Filament\Forms\Components\DateTimePicker;
-use Filament\Forms\Components\KeyValue;
+use Filament\Forms\Components\Repeater;
use Filament\Forms\Components\Select;
+use Filament\Forms\Components\TagsInput;
use Filament\Forms\Components\Textarea;
use Filament\Forms\Components\TextInput;
use Filament\Forms\Components\Toggle;
@@ -153,13 +154,38 @@ public static function configure(Schema $schema): Schema
->minValue(0)
->default(0),
- KeyValue::make('application_schema')
- ->label(__('panel-admin::events.form.application_form_schema'))
- ->keyLabel(__('panel-admin::events.form.application_schema_key'))
- ->valueLabel(__('panel-admin::events.form.application_schema_value'))
+ Repeater::make('application_schema')
+ ->label('Application Form Questions')
->nullable()
->columnSpanFull()
- ->visible(fn (Get $get): bool => $get('enrollment_method') === EnrollmentMethod::Application->value),
+ ->visible(fn (Get $get): bool => $get('enrollment_method') === EnrollmentMethod::Application)
+ ->addActionLabel('Add question')
+ ->reorderable()
+ ->collapsible()
+ ->schema([
+ Select::make('type')
+ ->label('Type')
+ ->options([
+ 'text' => 'Short text',
+ 'textarea' => 'Long text',
+ 'select' => 'Single choice',
+ 'checkbox' => 'Multiple choice',
+ ])
+ ->required()
+ ->live(),
+ TextInput::make('label')
+ ->label('Question')
+ ->required()
+ ->maxLength(255),
+ Toggle::make('required')
+ ->label('Required')
+ ->default(false),
+ TagsInput::make('options')
+ ->label('Options')
+ ->placeholder('Type an option and press Enter')
+ ->visible(fn (Get $get): bool => in_array($get('type'), ['select', 'checkbox'], strict: true))
+ ->required(fn (Get $get): bool => in_array($get('type'), ['select', 'checkbox'], strict: true)),
+ ]),
]),
]);
}
diff --git a/app-modules/panel-app/resources/views/livewire/events/event-detail.blade.php b/app-modules/panel-app/resources/views/livewire/events/event-detail.blade.php
index 6dbe8929e..5b5644cf5 100644
--- a/app-modules/panel-app/resources/views/livewire/events/event-detail.blade.php
+++ b/app-modules/panel-app/resources/views/livewire/events/event-detail.blade.php
@@ -46,6 +46,47 @@
}}
@endif
+
+ @if ($this->enrollment->status === \He4rt\Events\Enrollment\Enums\EnrollmentStatus::Pending)
+
+ {{ __('events::pages.application_pending_hint') }}
+
+ @if ($this->enrollment->application_data && $this->event->enrollmentPolicy?->application_schema)
+
+
+ {{ __('events::pages.application_your_answers') }}
+
+ @foreach ($this->event->enrollmentPolicy->application_schema as $index => $field)
+
+
{{ $field['label'] ?? '' }}
+
+ @php $answer = $this->enrollment->application_data[$index] ?? null; @endphp
+ @if (is_array($answer))
+ {{ $answer !== [] ? implode(', ', $answer) : '—' }}
+ @else
+ {{ $answer ?? '—' }}
+ @endif
+
+
+ @endforeach
+
+ @endif
+ @endif
+
+ @if ($this->enrollment->status === \He4rt\Events\Enrollment\Enums\EnrollmentStatus::Rejected)
+
+ {{ __('events::pages.application_rejected_hint') }}
+
+ @if ($this->enrollment->rejection_reason)
+
+ {{
+ __('events::pages.application_rejection_reason', [
+ 'reason' => $this->enrollment->rejection_reason,
+ ])
+ }}
+
+ @endif
+ @endif
@if ($this->qrToken)
@@ -123,6 +164,68 @@ class="text-success-500 h-4 w-4 shrink-0"
@endif
+ @elseif ($this->canApply)
+
+
{{ __('events::pages.apply_hint') }}
+
+
+
@elseif ($this->canConfirmPresence)
{{ __('events::pages.confirm_presence_hint') }}
diff --git a/app-modules/panel-app/src/Livewire/Events/EventDetail.php b/app-modules/panel-app/src/Livewire/Events/EventDetail.php
index 20df4f220..5ad2fab72 100644
--- a/app-modules/panel-app/src/Livewire/Events/EventDetail.php
+++ b/app-modules/panel-app/src/Livewire/Events/EventDetail.php
@@ -34,15 +34,25 @@
* @property-read Collection
$checkIns
* @property-read bool $hasCheckedInToday
* @property-read bool $canConfirmPresence
+ * @property-read bool $canApply
* @property-read bool $isEventFull
*/
final class EventDetail extends Component
{
public string $eventId;
+ /** @var array */
+ public array $applicationFormData = [];
+
public function mount(string $eventId): void
{
$this->eventId = $eventId;
+
+ foreach ($this->event->enrollmentPolicy?->application_schema ?? [] as $index => $field) {
+ if (($field['type'] ?? null) === 'checkbox') {
+ $this->applicationFormData[$index] = [];
+ }
+ }
}
#[Computed]
@@ -155,6 +165,24 @@ public function canConfirmPresence(): bool
return $policy->has_waitlist;
}
+ #[Computed]
+ public function canApply(): bool
+ {
+ if ($this->event->status !== EventStatus::Published) {
+ return false;
+ }
+
+ if ($this->enrollment !== null) {
+ return false;
+ }
+
+ if ($this->event->enrollmentPolicy?->enrollment_method !== EnrollmentMethod::Application) {
+ return false;
+ }
+
+ return $this->event->starts_at->isFuture();
+ }
+
#[Computed]
public function isEventFull(): bool
{
@@ -186,7 +214,7 @@ public function confirmPresence(): void
EnrollUserDTO::fromModels($this->event, $user),
);
- unset($this->enrollment, $this->canConfirmPresence, $this->isEventFull);
+ unset($this->enrollment, $this->canConfirmPresence, $this->canApply, $this->isEventFull);
Notification::make()
->success()
@@ -200,6 +228,35 @@ public function confirmPresence(): void
}
}
+ public function apply(): void
+ {
+ /** @var User $user */
+ $user = auth()->user();
+
+ try {
+ resolve(EnrollUserAction::class)->handle(
+ new EnrollUserDTO(
+ eventId: $this->event->id,
+ userId: $user->id,
+ applicationData: $this->applicationFormData,
+ ),
+ );
+
+ unset($this->enrollment, $this->canApply);
+ $this->applicationFormData = [];
+
+ Notification::make()
+ ->success()
+ ->title(__('events::pages.application_submitted'))
+ ->send();
+ } catch (EnrollmentException $enrollmentException) {
+ Notification::make()
+ ->danger()
+ ->title($enrollmentException->getMessage())
+ ->send();
+ }
+ }
+
public function render(): View
{
return view('panel-app::livewire.events.event-detail');
diff --git a/app-modules/panel-app/tests/Feature/Events/ApplicationEnrollmentTest.php b/app-modules/panel-app/tests/Feature/Events/ApplicationEnrollmentTest.php
new file mode 100644
index 000000000..0dc33371f
--- /dev/null
+++ b/app-modules/panel-app/tests/Feature/Events/ApplicationEnrollmentTest.php
@@ -0,0 +1,121 @@
+user = User::factory()->create();
+ $this->tenant = Tenant::factory()->create(['slug' => 'test-tenant']);
+ $this->tenant->members()->attach($this->user);
+
+ $this->actingAs($this->user);
+
+ Filament::setCurrentPanel(Filament::getPanel('app'));
+ Filament::setTenant($this->tenant);
+
+ $this->schema = [
+ ['type' => 'text', 'label' => 'Why do you want to join?', 'required' => true],
+ ['type' => 'select', 'label' => 'Experience level', 'required' => false, 'options' => ['Beginner', 'Intermediate', 'Advanced']],
+ ];
+
+ $this->event = Event::factory()
+ ->published()
+ ->upcoming()
+ ->for($this->tenant)
+ ->has(EnrollmentPolicy::factory()->application($this->schema), 'enrollmentPolicy')
+ ->create(['title' => 'He4rt Conf Application']);
+});
+
+test('event page renders apply button for application event', function (): void {
+ $this->get(EventPage::getUrl(['record' => $this->event->id]))
+ ->assertSuccessful()
+ ->assertSee('He4rt Conf Application')
+ ->assertSee(__('events::pages.apply_submit'));
+});
+
+test('event detail shows canApply true and canConfirmPresence false for application event', function (): void {
+ livewire(EventDetail::class, ['eventId' => $this->event->id])
+ ->assertSet('canApply', true)
+ ->assertSet('canConfirmPresence', false);
+});
+
+test('when user submits application, then enrollment is created as pending', function (): void {
+ livewire(EventDetail::class, ['eventId' => $this->event->id])
+ ->set('applicationFormData', [0 => 'I love Laravel!', 1 => 'Intermediate'])
+ ->call('apply')
+ ->assertHasNoErrors()
+ ->assertNotified();
+
+ $enrollment = Enrollment::query()
+ ->where('event_id', $this->event->id)
+ ->where('user_id', $this->user->id)
+ ->first();
+
+ expect($enrollment)->not->toBeNull()
+ ->and($enrollment->status)->toBe(EnrollmentStatus::Pending)
+ ->and($enrollment->confirmed_at)->toBeNull()
+ ->and($enrollment->application_data[0])->toBe('I love Laravel!');
+});
+
+test('after submitting application, event detail shows pending status with answers', function (): void {
+ livewire(EventDetail::class, ['eventId' => $this->event->id])
+ ->set('applicationFormData', [0 => 'I love Laravel!', 1 => 'Intermediate'])
+ ->call('apply');
+
+ livewire(EventDetail::class, ['eventId' => $this->event->id])
+ ->assertSet('canApply', false)
+ ->assertSee(EnrollmentStatus::Pending->getLabel())
+ ->assertSee(__('events::pages.application_pending_hint'))
+ ->assertSee('I love Laravel!');
+});
+
+test('when application is rejected, then rejection reason is shown', function (): void {
+ Enrollment::factory()->create([
+ 'event_id' => $this->event->id,
+ 'user_id' => $this->user->id,
+ 'status' => EnrollmentStatus::Rejected,
+ 'enrolled_at' => now(),
+ 'rejection_reason' => 'Not enough experience.',
+ 'application_data' => [0 => 'I am new'],
+ ]);
+
+ livewire(EventDetail::class, ['eventId' => $this->event->id])
+ ->assertSet('canApply', false)
+ ->assertSee(EnrollmentStatus::Rejected->getLabel())
+ ->assertSee(__('events::pages.application_rejected_hint'))
+ ->assertSee('Not enough experience.');
+});
+
+test('when user has already applied, then apply button is not shown', function (): void {
+ Enrollment::factory()->create([
+ 'event_id' => $this->event->id,
+ 'user_id' => $this->user->id,
+ 'status' => EnrollmentStatus::Pending,
+ 'enrolled_at' => now(),
+ 'application_data' => [0 => 'My answer'],
+ ]);
+
+ livewire(EventDetail::class, ['eventId' => $this->event->id])
+ ->assertSet('canApply', false)
+ ->assertDontSee(__('events::pages.apply_submit'));
+});
+
+test('when application data is missing required field, then error notification is shown', function (): void {
+ livewire(EventDetail::class, ['eventId' => $this->event->id])
+ ->set('applicationFormData', [0 => '', 1 => 'Beginner'])
+ ->call('apply')
+ ->assertNotified();
+
+ expect(Enrollment::query()->where('event_id', $this->event->id)->count())->toBe(0);
+});
From f3acdc28ae84811b4218c0a5881e8b488430dc26 Mon Sep 17 00:00:00 2001
From: danielhe4rt
Date: Tue, 7 Jul 2026 14:59:15 -0300
Subject: [PATCH 16/18] style(events): apply pint formatting and phpstan type
fixes post-merge
The 4.x merge brought stricter Pint config and PHPStan level 7. Adapt the
events module code accordingly:
- Fix 12 PHPStan errors: typed form-data extraction via Arr::string(),
list return, redundant nullsafe removal, typed
check-in date, aligned application data key types.
- Apply Pint fixes carried over from the merge hook (static closures,
named boolean args, numeric separators, missing @param/@return docs).
---
.../database/factories/EventFactory.php | 8 +++----
.../2026_05_16_200001_create_events_table.php | 2 +-
...reate_events_enrollment_policies_table.php | 4 ++--
...200003_create_events_enrollments_table.php | 4 ++--
...te_events_enrollment_transitions_table.php | 2 +-
...6_200005_create_events_check_ins_table.php | 2 +-
...006_create_events_check_in_codes_table.php | 2 +-
...6_200007_create_events_qr_tokens_table.php | 2 +-
...hecked_in_at_to_events_check_ins_table.php | 4 ++--
...oked_at_to_events_check_in_codes_table.php | 4 ++--
.../events/database/seeders/EventsSeeder.php | 10 +++++++--
.../Actions/NumericCodeCheckInAction.php | 2 +-
.../CheckIn/Listeners/HandleBotCheckIn.php | 2 +-
.../OverrideEnrollmentStatusAction.php | 17 +++++++++------
.../Closure/Jobs/ProcessEventClosureJob.php | 2 +-
.../Commands/ClosePendingEventsCommand.php | 4 ++--
.../Enrollment/Actions/EnrollUserAction.php | 2 +-
.../Feature/Closure/CloseEventActionTest.php | 4 ++--
.../OverrideEnrollmentStatusActionTest.php | 2 +-
.../Closure/ProcessEventClosureJobTest.php | 2 +-
.../tests/Feature/EnrollUserActionTest.php | 6 +++---
.../tests/Feature/HandleBotCheckInTest.php | 4 ++++
.../Feature/NumericCodeCheckInActionTest.php | 4 ++++
.../tests/Feature/QrCheckInActionTest.php | 4 ++++
.../Resources/Events/EventResource.php | 4 ++++
.../Resources/Events/Pages/EditEvent.php | 7 ++++++-
.../Resources/Events/Pages/ListEvents.php | 3 +++
.../Actions/ApproveApplicationAction.php | 2 +-
.../Actions/GenerateCheckInCodeAction.php | 2 +-
.../OverrideEnrollmentStatusAction.php | 7 ++++---
.../Actions/RejectApplicationAction.php | 5 +++--
.../Actions/RevokeCheckInCodeAction.php | 2 +-
.../EnrollmentsRelationManager.php | 21 +++++++++----------
.../Resources/Events/Schemas/EventForm.php | 6 +++---
.../src/Livewire/Events/EventDetail.php | 6 +++---
.../Events/ApplicationEnrollmentTest.php | 10 ++++-----
.../Feature/Events/RsvpEnrollmentTest.php | 12 +++++------
tests/Feature/LocaleSwitcherTest.php | 2 +-
38 files changed, 112 insertions(+), 76 deletions(-)
diff --git a/app-modules/events/database/factories/EventFactory.php b/app-modules/events/database/factories/EventFactory.php
index 8dfb4f44f..ed8ce12ee 100644
--- a/app-modules/events/database/factories/EventFactory.php
+++ b/app-modules/events/database/factories/EventFactory.php
@@ -69,7 +69,7 @@ public function asWorkshop(): static
return $this->state(fn (): array => [
'event_type' => EventType::Workshop,
'status' => EventStatus::Published,
- ])->afterCreating(function (Event $event): void {
+ ])->afterCreating(static function (Event $event): void {
EnrollmentPolicy::factory()->create([
'event_id' => $event->id,
'enrollment_method' => EnrollmentMethod::RsvpCheckin,
@@ -89,7 +89,7 @@ public function asMeetup(): static
return $this->state(fn (): array => [
'event_type' => EventType::Meetup,
'status' => EventStatus::Published,
- ])->afterCreating(function (Event $event): void {
+ ])->afterCreating(static function (Event $event): void {
EnrollmentPolicy::factory()->create([
'event_id' => $event->id,
'enrollment_method' => EnrollmentMethod::Rsvp,
@@ -109,7 +109,7 @@ public function asConference(): static
return $this->state(fn (): array => [
'event_type' => EventType::Conference,
'status' => EventStatus::Published,
- ])->afterCreating(function (Event $event): void {
+ ])->afterCreating(static function (Event $event): void {
EnrollmentPolicy::factory()->create([
'event_id' => $event->id,
'enrollment_method' => EnrollmentMethod::Application,
@@ -121,7 +121,7 @@ public function asConference(): static
'cancellation_deadline_hours' => 48,
'xp_on_confirmed' => 200,
'xp_on_checked_in' => 300,
- 'xp_on_attended' => 1000,
+ 'xp_on_attended' => 1_000,
]);
});
}
diff --git a/app-modules/events/database/migrations/2026_05_16_200001_create_events_table.php b/app-modules/events/database/migrations/2026_05_16_200001_create_events_table.php
index 35380f507..215b4e446 100644
--- a/app-modules/events/database/migrations/2026_05_16_200001_create_events_table.php
+++ b/app-modules/events/database/migrations/2026_05_16_200001_create_events_table.php
@@ -12,7 +12,7 @@
{
public function up(): void
{
- Schema::create('events', function (Blueprint $table): void {
+ Schema::create('events', static function (Blueprint $table): void {
$table->uuid('id')->primary();
$table->foreignUuid('tenant_id')->nullable()->constrained('tenants')->nullOnDelete();
$table->string('slug', 120);
diff --git a/app-modules/events/database/migrations/2026_05_16_200002_create_events_enrollment_policies_table.php b/app-modules/events/database/migrations/2026_05_16_200002_create_events_enrollment_policies_table.php
index 7030d61b0..0fbd3d6cc 100644
--- a/app-modules/events/database/migrations/2026_05_16_200002_create_events_enrollment_policies_table.php
+++ b/app-modules/events/database/migrations/2026_05_16_200002_create_events_enrollment_policies_table.php
@@ -13,13 +13,13 @@
{
public function up(): void
{
- Schema::create('events_enrollment_policies', function (Blueprint $table): void {
+ Schema::create('events_enrollment_policies', static function (Blueprint $table): void {
$table->uuid('id')->primary();
$table->foreignUuid('event_id')->unique()->constrained('events')->cascadeOnDelete();
$table->string('enrollment_method', 20)->comment(EnrollmentMethod::stringifyCases());
$table->string('check_in_method', 20)->comment(CheckInMethod::stringifyCases());
$table->unsignedInteger('capacity')->nullable();
- $table->boolean('has_waitlist')->default(false);
+ $table->boolean('has_waitlist')->default(value: false);
$table->string('attendance_requirement', 20)
->comment(AttendanceRequirement::stringifyCases())
->default(AttendanceRequirement::AllDays);
diff --git a/app-modules/events/database/migrations/2026_05_16_200003_create_events_enrollments_table.php b/app-modules/events/database/migrations/2026_05_16_200003_create_events_enrollments_table.php
index aa4df76e4..82a66a1ff 100644
--- a/app-modules/events/database/migrations/2026_05_16_200003_create_events_enrollments_table.php
+++ b/app-modules/events/database/migrations/2026_05_16_200003_create_events_enrollments_table.php
@@ -11,14 +11,14 @@
{
public function up(): void
{
- Schema::create('events_enrollments', function (Blueprint $table): void {
+ Schema::create('events_enrollments', static function (Blueprint $table): void {
$table->uuid('id')->primary();
$table->foreignUuid('event_id')->constrained('events')->cascadeOnDelete();
$table->foreignUuid('user_id')->constrained('users')->cascadeOnDelete();
$table->string('status', 20)
->comment(EnrollmentStatus::stringifyCases())
->default(EnrollmentStatus::Pending);
- $table->boolean('is_public')->default(true);
+ $table->boolean('is_public')->default(value: true);
$table->unsignedInteger('waitlist_position')->nullable();
$table->jsonb('application_data')->nullable();
$table->string('rejection_reason', 500)->nullable();
diff --git a/app-modules/events/database/migrations/2026_05_16_200004_create_events_enrollment_transitions_table.php b/app-modules/events/database/migrations/2026_05_16_200004_create_events_enrollment_transitions_table.php
index 8959198da..937ab0f1a 100644
--- a/app-modules/events/database/migrations/2026_05_16_200004_create_events_enrollment_transitions_table.php
+++ b/app-modules/events/database/migrations/2026_05_16_200004_create_events_enrollment_transitions_table.php
@@ -12,7 +12,7 @@
{
public function up(): void
{
- Schema::create('events_enrollment_transitions', function (Blueprint $table): void {
+ Schema::create('events_enrollment_transitions', static function (Blueprint $table): void {
$table->uuid('id')->primary();
$table->foreignUuid('enrollment_id')->constrained('events_enrollments')->cascadeOnDelete();
$table->string('from_status', 20)->nullable()->comment(EnrollmentStatus::stringifyCases());
diff --git a/app-modules/events/database/migrations/2026_05_16_200005_create_events_check_ins_table.php b/app-modules/events/database/migrations/2026_05_16_200005_create_events_check_ins_table.php
index 976766174..bf6ff5fd0 100644
--- a/app-modules/events/database/migrations/2026_05_16_200005_create_events_check_ins_table.php
+++ b/app-modules/events/database/migrations/2026_05_16_200005_create_events_check_ins_table.php
@@ -11,7 +11,7 @@
{
public function up(): void
{
- Schema::create('events_check_ins', function (Blueprint $table): void {
+ Schema::create('events_check_ins', static function (Blueprint $table): void {
$table->uuid('id')->primary();
$table->foreignUuid('enrollment_id')->constrained('events_enrollments')->cascadeOnDelete();
$table->date('event_date');
diff --git a/app-modules/events/database/migrations/2026_05_16_200006_create_events_check_in_codes_table.php b/app-modules/events/database/migrations/2026_05_16_200006_create_events_check_in_codes_table.php
index 467a841df..ba7ae6504 100644
--- a/app-modules/events/database/migrations/2026_05_16_200006_create_events_check_in_codes_table.php
+++ b/app-modules/events/database/migrations/2026_05_16_200006_create_events_check_in_codes_table.php
@@ -10,7 +10,7 @@
{
public function up(): void
{
- Schema::create('events_check_in_codes', function (Blueprint $table): void {
+ Schema::create('events_check_in_codes', static function (Blueprint $table): void {
$table->uuid('id')->primary();
$table->foreignUuid('event_id')->constrained('events')->cascadeOnDelete();
$table->date('event_date');
diff --git a/app-modules/events/database/migrations/2026_05_16_200007_create_events_qr_tokens_table.php b/app-modules/events/database/migrations/2026_05_16_200007_create_events_qr_tokens_table.php
index ca5cab714..8b24e300a 100644
--- a/app-modules/events/database/migrations/2026_05_16_200007_create_events_qr_tokens_table.php
+++ b/app-modules/events/database/migrations/2026_05_16_200007_create_events_qr_tokens_table.php
@@ -10,7 +10,7 @@
{
public function up(): void
{
- Schema::create('events_qr_tokens', function (Blueprint $table): void {
+ Schema::create('events_qr_tokens', static function (Blueprint $table): void {
$table->uuid('id')->primary();
$table->foreignUuid('enrollment_id')->unique()->constrained('events_enrollments')->cascadeOnDelete();
$table->string('token', 64);
diff --git a/app-modules/events/database/migrations/2026_05_22_000001_add_checked_in_at_to_events_check_ins_table.php b/app-modules/events/database/migrations/2026_05_22_000001_add_checked_in_at_to_events_check_ins_table.php
index 8b7967eb8..49ab92908 100644
--- a/app-modules/events/database/migrations/2026_05_22_000001_add_checked_in_at_to_events_check_ins_table.php
+++ b/app-modules/events/database/migrations/2026_05_22_000001_add_checked_in_at_to_events_check_ins_table.php
@@ -10,14 +10,14 @@
{
public function up(): void
{
- Schema::table('events_check_ins', function (Blueprint $table): void {
+ Schema::table('events_check_ins', static function (Blueprint $table): void {
$table->timestampTz('checked_in_at')->nullable()->after('payload');
});
}
public function down(): void
{
- Schema::table('events_check_ins', function (Blueprint $table): void {
+ Schema::table('events_check_ins', static function (Blueprint $table): void {
$table->dropColumn('checked_in_at');
});
}
diff --git a/app-modules/events/database/migrations/2026_05_31_000001_add_revoked_at_to_events_check_in_codes_table.php b/app-modules/events/database/migrations/2026_05_31_000001_add_revoked_at_to_events_check_in_codes_table.php
index 981d1cef3..eab4a3a53 100644
--- a/app-modules/events/database/migrations/2026_05_31_000001_add_revoked_at_to_events_check_in_codes_table.php
+++ b/app-modules/events/database/migrations/2026_05_31_000001_add_revoked_at_to_events_check_in_codes_table.php
@@ -10,14 +10,14 @@
{
public function up(): void
{
- Schema::table('events_check_in_codes', function (Blueprint $table): void {
+ Schema::table('events_check_in_codes', static function (Blueprint $table): void {
$table->timestampTz('revoked_at')->nullable()->after('uses_count');
});
}
public function down(): void
{
- Schema::table('events_check_in_codes', function (Blueprint $table): void {
+ Schema::table('events_check_in_codes', static function (Blueprint $table): void {
$table->dropColumn('revoked_at');
});
}
diff --git a/app-modules/events/database/seeders/EventsSeeder.php b/app-modules/events/database/seeders/EventsSeeder.php
index 0f57c2ced..ec663fa24 100644
--- a/app-modules/events/database/seeders/EventsSeeder.php
+++ b/app-modules/events/database/seeders/EventsSeeder.php
@@ -165,7 +165,7 @@ private function matrix(): array
'attendance_requirement' => AttendanceRequirement::MinimumDays,
'minimum_days' => 2,
'cancellation_deadline_hours' => 48,
- 'xp' => [200, 300, 1000],
+ 'xp' => [200, 300, 1_000],
'application_schema' => [
['type' => 'text', 'label' => 'Por que quer participar da He4rt Conf?', 'required' => true],
['type' => 'select', 'label' => 'Nível de experiência em desenvolvimento', 'required' => true, 'options' => ['Iniciante', 'Intermediário', 'Avançado']],
@@ -243,7 +243,7 @@ private function matrix(): array
'attendance_requirement' => AttendanceRequirement::MinimumDays,
'minimum_days' => 4,
'cancellation_deadline_hours' => 72,
- 'xp' => [150, 250, 1500],
+ 'xp' => [150, 250, 1_500],
'enrollments' => [],
],
@@ -275,6 +275,9 @@ private function matrix(): array
];
}
+ /**
+ * @param array $spec
+ */
private function seedPolicy(Event $event, array $spec): void
{
EnrollmentPolicy::factory()->create([
@@ -295,6 +298,7 @@ private function seedPolicy(Event $event, array $spec): void
/**
* @param Collection $participants
+ * @param array $spec
*/
private function seedEnrollments(Event $event, array $spec, Collection $participants): void
{
@@ -329,6 +333,8 @@ private function seedEnrollments(Event $event, array $spec, Collection $particip
* Creates a representative set of codes for numeric-code events so every
* branch of the check-in flow (valid / expired / revoked / exhausted) is
* reachable from the admin panel and the participant component.
+ *
+ * @param array $spec
*/
private function seedCheckInCodes(Event $event, array $spec): void
{
diff --git a/app-modules/events/src/CheckIn/Actions/NumericCodeCheckInAction.php b/app-modules/events/src/CheckIn/Actions/NumericCodeCheckInAction.php
index e5f87d5c5..956510842 100644
--- a/app-modules/events/src/CheckIn/Actions/NumericCodeCheckInAction.php
+++ b/app-modules/events/src/CheckIn/Actions/NumericCodeCheckInAction.php
@@ -17,7 +17,7 @@
{
public function handle(NumericCodeCheckInDTO $dto): CheckIn
{
- return DB::transaction(function () use ($dto): CheckIn {
+ return DB::transaction(static function () use ($dto): CheckIn {
$codeRecord = CheckInCode::query()
->where('code', $dto->code)
->where('event_id', $dto->enrollment->event_id)
diff --git a/app-modules/events/src/CheckIn/Listeners/HandleBotCheckIn.php b/app-modules/events/src/CheckIn/Listeners/HandleBotCheckIn.php
index 3b614d0a5..a3d4afbc9 100644
--- a/app-modules/events/src/CheckIn/Listeners/HandleBotCheckIn.php
+++ b/app-modules/events/src/CheckIn/Listeners/HandleBotCheckIn.php
@@ -63,7 +63,7 @@ private function resolveEnrollment(CheckInRequested $event, string $userId): Enr
$enrollments = Enrollment::query()
->where('user_id', $userId)
->whereIn('status', [EnrollmentStatus::Confirmed, EnrollmentStatus::CheckedIn])
- ->whereHas('event', function (Builder $query) use ($event, $today): void {
+ ->whereHas('event', static function (Builder $query) use ($event, $today): void {
$query->where('tenant_id', $event->tenantId)
->where('status', EventStatus::Published)
->whereDate('starts_at', '<=', $today)
diff --git a/app-modules/events/src/Closure/Actions/OverrideEnrollmentStatusAction.php b/app-modules/events/src/Closure/Actions/OverrideEnrollmentStatusAction.php
index 3b5e09afe..5ddadb5ec 100644
--- a/app-modules/events/src/Closure/Actions/OverrideEnrollmentStatusAction.php
+++ b/app-modules/events/src/Closure/Actions/OverrideEnrollmentStatusAction.php
@@ -15,6 +15,7 @@
final readonly class OverrideEnrollmentStatusAction
{
+ /** @var list */
private const array ALLOWED_OVERRIDES = [
['from' => EnrollmentStatus::NoShow, 'to' => EnrollmentStatus::Attended],
['from' => EnrollmentStatus::Confirmed, 'to' => EnrollmentStatus::CheckedIn],
@@ -23,11 +24,15 @@
/** @return list */
public static function allowedTargetsFor(EnrollmentStatus $from): array
{
- return collect(self::ALLOWED_OVERRIDES)
- ->whereStrict('from', $from)
- ->pluck('to')
- ->values()
- ->all();
+ $targets = [];
+
+ foreach (self::ALLOWED_OVERRIDES as $pair) {
+ if ($pair['from'] === $from) {
+ $targets[] = $pair['to'];
+ }
+ }
+
+ return $targets;
}
/**
@@ -75,6 +80,6 @@ public function handle(OverrideEnrollmentStatusDTO $dto): EnrollmentTransition
private function isAllowed(EnrollmentStatus $from, EnrollmentStatus $to): bool
{
- return array_any(self::ALLOWED_OVERRIDES, fn ($pair) => $pair['from'] === $from && $pair['to'] === $to);
+ return array_any(self::ALLOWED_OVERRIDES, fn (array $pair) => $pair['from'] === $from && $pair['to'] === $to);
}
}
diff --git a/app-modules/events/src/Closure/Jobs/ProcessEventClosureJob.php b/app-modules/events/src/Closure/Jobs/ProcessEventClosureJob.php
index 7a68f1a95..ea3b8c806 100644
--- a/app-modules/events/src/Closure/Jobs/ProcessEventClosureJob.php
+++ b/app-modules/events/src/Closure/Jobs/ProcessEventClosureJob.php
@@ -20,7 +20,7 @@
#[Backoff([1, 5, 10])]
#[Tries(4)]
-#[UniqueFor(1800)]
+#[UniqueFor(1_800)]
final class ProcessEventClosureJob implements ShouldBeUnique, ShouldQueue
{
use Dispatchable;
diff --git a/app-modules/events/src/Console/Commands/ClosePendingEventsCommand.php b/app-modules/events/src/Console/Commands/ClosePendingEventsCommand.php
index e1d3aac7f..85a161791 100644
--- a/app-modules/events/src/Console/Commands/ClosePendingEventsCommand.php
+++ b/app-modules/events/src/Console/Commands/ClosePendingEventsCommand.php
@@ -22,11 +22,11 @@ public function handle(): int
Event::query()
->where('ends_at', '<', now())
- ->whereHas('enrollments', function (Builder $query): void {
+ ->whereHas('enrollments', static function (Builder $query): void {
$query->whereIn('status', [EnrollmentStatus::Confirmed, EnrollmentStatus::CheckedIn]);
})
->select('id')
- ->each(function (Event $event) use (&$count): void {
+ ->each(static function (Event $event) use (&$count): void {
dispatch(new ProcessEventClosureJob($event->id));
$count++;
});
diff --git a/app-modules/events/src/Enrollment/Actions/EnrollUserAction.php b/app-modules/events/src/Enrollment/Actions/EnrollUserAction.php
index 915a16668..020163a4e 100644
--- a/app-modules/events/src/Enrollment/Actions/EnrollUserAction.php
+++ b/app-modules/events/src/Enrollment/Actions/EnrollUserAction.php
@@ -224,7 +224,7 @@ private function validateApplicationData(array $data, array $schema): void
continue;
}
- if (($field['required'] ?? false) && (in_array($value, [null, '', false], true))) {
+ if (($field['required'] ?? false) && (in_array($value, [null, '', false], strict: true))) {
throw EnrollmentException::applicationDataInvalid();
}
diff --git a/app-modules/events/tests/Feature/Closure/CloseEventActionTest.php b/app-modules/events/tests/Feature/Closure/CloseEventActionTest.php
index 61aa2f298..4c6f60859 100644
--- a/app-modules/events/tests/Feature/Closure/CloseEventActionTest.php
+++ b/app-modules/events/tests/Feature/Closure/CloseEventActionTest.php
@@ -223,7 +223,7 @@ function createEventForClosure(array $eventAttributes = [], array $policyAttribu
$event = createEventForClosure();
Enrollment::factory()
- ->count(1001)
+ ->count(1_001)
->create([
'event_id' => $event->id,
'status' => EnrollmentStatus::Confirmed,
@@ -243,5 +243,5 @@ function createEventForClosure(array $eventAttributes = [], array $policyAttribu
->count();
expect($remainingEligible)->toBe(0)
- ->and($noShowTransitions)->toBe(1001);
+ ->and($noShowTransitions)->toBe(1_001);
});
diff --git a/app-modules/events/tests/Feature/Closure/OverrideEnrollmentStatusActionTest.php b/app-modules/events/tests/Feature/Closure/OverrideEnrollmentStatusActionTest.php
index aac3fec0e..9f86df03d 100644
--- a/app-modules/events/tests/Feature/Closure/OverrideEnrollmentStatusActionTest.php
+++ b/app-modules/events/tests/Feature/Closure/OverrideEnrollmentStatusActionTest.php
@@ -195,7 +195,7 @@ function createPastEvent(): Event
actorId: User::factory()->create()->id,
reason: 'Late arrival',
),
- ))->toThrow(function (OverrideEnrollmentStatusException $exception): void {
+ ))->toThrow(static function (OverrideEnrollmentStatusException $exception): void {
expect($exception->getCode())->toBe(409);
});
diff --git a/app-modules/events/tests/Feature/Closure/ProcessEventClosureJobTest.php b/app-modules/events/tests/Feature/Closure/ProcessEventClosureJobTest.php
index 7eb8e8242..dc361862e 100644
--- a/app-modules/events/tests/Feature/Closure/ProcessEventClosureJobTest.php
+++ b/app-modules/events/tests/Feature/Closure/ProcessEventClosureJobTest.php
@@ -98,5 +98,5 @@ function makeEventForJobTest(array $eventAttributes = [], array $policyAttribute
expect($tries)->toBe(4)
->and($backoff)->toBe([1, 5, 10])
- ->and($uniqueFor)->toBe(1800);
+ ->and($uniqueFor)->toBe(1_800);
});
diff --git a/app-modules/events/tests/Feature/EnrollUserActionTest.php b/app-modules/events/tests/Feature/EnrollUserActionTest.php
index 15391701b..0edd17991 100644
--- a/app-modules/events/tests/Feature/EnrollUserActionTest.php
+++ b/app-modules/events/tests/Feature/EnrollUserActionTest.php
@@ -67,14 +67,14 @@ function createRsvpEvent(Tenant $tenant, array $eventAttributes = [], array $pol
$raced = false;
- Enrollment::creating(function (Enrollment $enrollment) use (&$raced): void {
+ Enrollment::creating(static function (Enrollment $enrollment) use (&$raced): void {
if ($raced) {
return;
}
$raced = true;
- Enrollment::withoutEvents(function () use ($enrollment): void {
+ Enrollment::withoutEvents(static function () use ($enrollment): void {
Enrollment::factory()->create([
'event_id' => $enrollment->event_id,
'user_id' => $enrollment->user_id,
@@ -195,7 +195,7 @@ function createRsvpEvent(Tenant $tenant, array $eventAttributes = [], array $pol
try {
resolve(EnrollUserAction::class)->handle(EnrollUserDTO::fromModels($event, $user));
- expect(false)->toBeTrue('Expected EnrollmentException was not thrown');
+ expect(value: false)->toBeTrue('Expected EnrollmentException was not thrown');
} catch (EnrollmentException $enrollmentException) {
expect($enrollmentException->getCode())->toBe(Response::HTTP_UNPROCESSABLE_ENTITY);
}
diff --git a/app-modules/events/tests/Feature/HandleBotCheckInTest.php b/app-modules/events/tests/Feature/HandleBotCheckInTest.php
index 52e3ba059..6300801d1 100644
--- a/app-modules/events/tests/Feature/HandleBotCheckInTest.php
+++ b/app-modules/events/tests/Feature/HandleBotCheckInTest.php
@@ -16,6 +16,7 @@
use He4rt\Identity\ExternalIdentity\Models\ExternalIdentity;
use He4rt\Identity\Tenant\Models\Tenant;
use He4rt\Identity\User\Models\User;
+use Illuminate\Database\Eloquent\Collection;
use Illuminate\Support\Facades\Date;
use Illuminate\Support\Facades\Event as EventFacade;
@@ -27,6 +28,9 @@
Date::setTestNow();
});
+/**
+ * @return array|User|Collection|Event|Collection|Enrollment|Collection|Collection|CheckInCode|string>
+ */
function createBotCheckInScenario(array $codeOverrides = []): array
{
$tenant = Tenant::factory()->create();
diff --git a/app-modules/events/tests/Feature/NumericCodeCheckInActionTest.php b/app-modules/events/tests/Feature/NumericCodeCheckInActionTest.php
index bfd07712b..9e66b3289 100644
--- a/app-modules/events/tests/Feature/NumericCodeCheckInActionTest.php
+++ b/app-modules/events/tests/Feature/NumericCodeCheckInActionTest.php
@@ -15,6 +15,7 @@
use He4rt\Events\Event\Models\Event;
use He4rt\Identity\Tenant\Models\Tenant;
use He4rt\Identity\User\Models\User;
+use Illuminate\Database\Eloquent\Collection;
use Illuminate\Support\Facades\Date;
use Illuminate\Support\Facades\Event as EventFacade;
@@ -26,6 +27,9 @@
Date::setTestNow();
});
+/**
+ * @return array|Collection|CheckInCode|Event|Collection|User|Collection>
+ */
function createScenarioWithCode(array $codeOverrides = []): array
{
$tenant = Tenant::factory()->create();
diff --git a/app-modules/events/tests/Feature/QrCheckInActionTest.php b/app-modules/events/tests/Feature/QrCheckInActionTest.php
index 494638381..670c5d94b 100644
--- a/app-modules/events/tests/Feature/QrCheckInActionTest.php
+++ b/app-modules/events/tests/Feature/QrCheckInActionTest.php
@@ -19,9 +19,13 @@
use He4rt\Events\Event\Models\Event;
use He4rt\Identity\Tenant\Models\Tenant;
use He4rt\Identity\User\Models\User;
+use Illuminate\Database\Eloquent\Collection;
use Illuminate\Support\Facades\Event as EventFacade;
use Illuminate\Support\Str;
+/**
+ * @return array|Enrollment|Collection|QrToken>
+ */
function createEnrollmentWithQrToken(array $eventAttributes = [], array $enrollmentAttributes = []): array
{
$tenant = Tenant::factory()->create();
diff --git a/app-modules/panel-admin/src/Filament/Resources/Events/EventResource.php b/app-modules/panel-admin/src/Filament/Resources/Events/EventResource.php
index 217aa62e1..169a573e1 100644
--- a/app-modules/panel-admin/src/Filament/Resources/Events/EventResource.php
+++ b/app-modules/panel-admin/src/Filament/Resources/Events/EventResource.php
@@ -5,6 +5,7 @@
namespace He4rt\PanelAdmin\Filament\Resources\Events;
use BackedEnum;
+use Filament\Resources\Pages\PageRegistration;
use Filament\Resources\Resource;
use Filament\Schemas\Schema;
use Filament\Support\Icons\Heroicon;
@@ -67,6 +68,9 @@ public static function getRelations(): array
];
}
+ /**
+ * @return array
+ */
public static function getPages(): array
{
return [
diff --git a/app-modules/panel-admin/src/Filament/Resources/Events/Pages/EditEvent.php b/app-modules/panel-admin/src/Filament/Resources/Events/Pages/EditEvent.php
index 3db3059f5..81cdcb483 100644
--- a/app-modules/panel-admin/src/Filament/Resources/Events/Pages/EditEvent.php
+++ b/app-modules/panel-admin/src/Filament/Resources/Events/Pages/EditEvent.php
@@ -9,12 +9,14 @@
use Filament\Forms\Components\TextInput;
use Filament\Notifications\Notification;
use Filament\Resources\Pages\EditRecord;
+use Filament\Support\Components\ViewComponent;
use Filament\Support\Icons\Heroicon;
use He4rt\Events\CheckIn\Actions\QrCheckInAction;
use He4rt\Events\CheckIn\DTOs\QrCheckInDTO;
use He4rt\Events\CheckIn\Exceptions\CheckInException;
use He4rt\Events\Event\Models\Event;
use He4rt\PanelAdmin\Filament\Resources\Events\EventResource;
+use Illuminate\Support\Arr;
use Livewire\Attributes\On;
use Throwable;
@@ -28,6 +30,9 @@ public function reopenScanQrModal(): void
$this->mountAction('scanQr');
}
+ /**
+ * @return ViewComponent[]
+ */
protected function getHeaderActions(): array
{
return [
@@ -50,7 +55,7 @@ protected function getHeaderActions(): array
try {
$checkIn = resolve(QrCheckInAction::class)->handle(
new QrCheckInDTO(
- token: $data['token'],
+ token: Arr::string($data, 'token'),
event: $event,
eventDate: now(),
actorUserId: (string) auth()->id(),
diff --git a/app-modules/panel-admin/src/Filament/Resources/Events/Pages/ListEvents.php b/app-modules/panel-admin/src/Filament/Resources/Events/Pages/ListEvents.php
index a75d4546d..e9a66dff5 100644
--- a/app-modules/panel-admin/src/Filament/Resources/Events/Pages/ListEvents.php
+++ b/app-modules/panel-admin/src/Filament/Resources/Events/Pages/ListEvents.php
@@ -12,6 +12,9 @@ final class ListEvents extends ListRecords
{
protected static string $resource = EventResource::class;
+ /**
+ * @return CreateAction[]
+ */
protected function getHeaderActions(): array
{
return [
diff --git a/app-modules/panel-admin/src/Filament/Resources/Events/RelationManagers/Actions/ApproveApplicationAction.php b/app-modules/panel-admin/src/Filament/Resources/Events/RelationManagers/Actions/ApproveApplicationAction.php
index b75cfa5a9..0e8ccd669 100644
--- a/app-modules/panel-admin/src/Filament/Resources/Events/RelationManagers/Actions/ApproveApplicationAction.php
+++ b/app-modules/panel-admin/src/Filament/Resources/Events/RelationManagers/Actions/ApproveApplicationAction.php
@@ -26,7 +26,7 @@ protected function setUp(): void
->requiresConfirmation()
->modalHeading(__('events::pages.admin_approve_application_modal_heading'))
->modalDescription(fn (Enrollment $record): string => __('events::pages.admin_approve_application_modal_description', ['name' => $record->user?->name]))
- ->action(function (Enrollment $record): void {
+ ->action(static function (Enrollment $record): void {
try {
resolve(ApproveApplicationDomainAction::class)->handle(
new ApproveApplicationDTO(
diff --git a/app-modules/panel-admin/src/Filament/Resources/Events/RelationManagers/Actions/GenerateCheckInCodeAction.php b/app-modules/panel-admin/src/Filament/Resources/Events/RelationManagers/Actions/GenerateCheckInCodeAction.php
index 2d02bdd89..4539db4b9 100644
--- a/app-modules/panel-admin/src/Filament/Resources/Events/RelationManagers/Actions/GenerateCheckInCodeAction.php
+++ b/app-modules/panel-admin/src/Filament/Resources/Events/RelationManagers/Actions/GenerateCheckInCodeAction.php
@@ -80,7 +80,7 @@ private function generateFormSchema(RelationManager $livewire): array
->afterStateUpdated(function (Set $set, ?string $state): void {
$set('code_preview', $this->generateNumericCode((int) ($state ?: 6)));
})
- ->selectablePlaceholder(false)
+ ->selectablePlaceholder(condition: false)
->required(),
TextInput::make('code_preview')
diff --git a/app-modules/panel-admin/src/Filament/Resources/Events/RelationManagers/Actions/OverrideEnrollmentStatusAction.php b/app-modules/panel-admin/src/Filament/Resources/Events/RelationManagers/Actions/OverrideEnrollmentStatusAction.php
index b7426845e..938d5790e 100644
--- a/app-modules/panel-admin/src/Filament/Resources/Events/RelationManagers/Actions/OverrideEnrollmentStatusAction.php
+++ b/app-modules/panel-admin/src/Filament/Resources/Events/RelationManagers/Actions/OverrideEnrollmentStatusAction.php
@@ -13,6 +13,7 @@
use He4rt\Events\Closure\DTOs\OverrideEnrollmentStatusDTO;
use He4rt\Events\Enrollment\Enums\EnrollmentStatus;
use He4rt\Events\Enrollment\Models\Enrollment;
+use Illuminate\Support\Arr;
final class OverrideEnrollmentStatusAction extends Action
{
@@ -37,14 +38,14 @@ protected function setUp(): void
->minLength(3)
->rows(3),
])
- ->action(function (Enrollment $record, array $data): void {
+ ->action(static function (Enrollment $record, array $data): void {
resolve(OverrideEnrollmentStatusDomainAction::class)->handle(
new OverrideEnrollmentStatusDTO(
enrollment: $record,
fromStatus: $record->status,
- toStatus: EnrollmentStatus::from($data['to_status']),
+ toStatus: EnrollmentStatus::from(Arr::string($data, 'to_status')),
actorId: (string) auth()->id(),
- reason: $data['reason'],
+ reason: Arr::string($data, 'reason'),
),
);
diff --git a/app-modules/panel-admin/src/Filament/Resources/Events/RelationManagers/Actions/RejectApplicationAction.php b/app-modules/panel-admin/src/Filament/Resources/Events/RelationManagers/Actions/RejectApplicationAction.php
index 4360f8ae5..591e73e9e 100644
--- a/app-modules/panel-admin/src/Filament/Resources/Events/RelationManagers/Actions/RejectApplicationAction.php
+++ b/app-modules/panel-admin/src/Filament/Resources/Events/RelationManagers/Actions/RejectApplicationAction.php
@@ -13,6 +13,7 @@
use He4rt\Events\Enrollment\Enums\EnrollmentStatus;
use He4rt\Events\Enrollment\Exceptions\EnrollmentException;
use He4rt\Events\Enrollment\Models\Enrollment;
+use Illuminate\Support\Arr;
final class RejectApplicationAction extends Action
{
@@ -32,13 +33,13 @@ protected function setUp(): void
->maxLength(500)
->rows(3),
])
- ->action(function (Enrollment $record, array $data): void {
+ ->action(static function (Enrollment $record, array $data): void {
try {
resolve(RejectApplicationDomainAction::class)->handle(
new RejectApplicationDTO(
enrollmentId: $record->id,
actorId: (string) auth()->id(),
- reason: $data['reason'],
+ reason: Arr::string($data, 'reason'),
),
);
diff --git a/app-modules/panel-admin/src/Filament/Resources/Events/RelationManagers/Actions/RevokeCheckInCodeAction.php b/app-modules/panel-admin/src/Filament/Resources/Events/RelationManagers/Actions/RevokeCheckInCodeAction.php
index 6bfd3cf2b..e0e6501bf 100644
--- a/app-modules/panel-admin/src/Filament/Resources/Events/RelationManagers/Actions/RevokeCheckInCodeAction.php
+++ b/app-modules/panel-admin/src/Filament/Resources/Events/RelationManagers/Actions/RevokeCheckInCodeAction.php
@@ -20,7 +20,7 @@ protected function setUp(): void
->color('danger')
->visible(fn (CheckInCode $record): bool => $record->revoked_at === null)
->requiresConfirmation()
- ->action(function (CheckInCode $record): void {
+ ->action(static function (CheckInCode $record): void {
$record->update(['revoked_at' => now()]);
Notification::make()
diff --git a/app-modules/panel-admin/src/Filament/Resources/Events/RelationManagers/EnrollmentsRelationManager.php b/app-modules/panel-admin/src/Filament/Resources/Events/RelationManagers/EnrollmentsRelationManager.php
index f7442fb9e..418ee2c08 100644
--- a/app-modules/panel-admin/src/Filament/Resources/Events/RelationManagers/EnrollmentsRelationManager.php
+++ b/app-modules/panel-admin/src/Filament/Resources/Events/RelationManagers/EnrollmentsRelationManager.php
@@ -4,6 +4,7 @@
namespace He4rt\PanelAdmin\Filament\Resources\Events\RelationManagers;
+use Carbon\CarbonInterface;
use Filament\Actions\Action;
use Filament\Actions\BulkAction;
use Filament\Actions\BulkActionGroup;
@@ -29,6 +30,7 @@
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Collection;
use Illuminate\Database\Eloquent\Model;
+use Illuminate\Support\Arr;
use Illuminate\Support\Facades\Date;
final class EnrollmentsRelationManager extends RelationManager
@@ -43,7 +45,7 @@ public static function getTitle(Model $ownerRecord, string $pageClass): string
public function table(Table $table): Table
{
return $table
- ->recordTitle(fn (Enrollment $record): string => $record->user?->name ?? $record->id)
+ ->recordTitle(fn (Enrollment $record): string => $record->user->name ?? $record->id)
->modifyQueryUsing(fn (Builder $query): Builder => $query->with(['checkIns', 'user']))
->columns([
TextColumn::make('user.name')
@@ -115,8 +117,8 @@ private function viewApplicationAction(): Action
->icon(Heroicon::OutlinedDocumentText)
->color('gray')
->visible(fn (Enrollment $record): bool => $record->application_data !== null)
- ->modalContent(function (Enrollment $record): View {
- $schema = $record->event?->enrollmentPolicy?->application_schema ?? [];
+ ->modalContent(static function (Enrollment $record): View {
+ $schema = $record->event?->enrollmentPolicy->application_schema ?? [];
$data = $record->application_data ?? [];
$answers = collect($schema)
@@ -132,7 +134,7 @@ private function viewApplicationAction(): Action
'record' => $record,
]);
})
- ->modalSubmitAction(false);
+ ->modalSubmitAction(action: false);
}
private function checkInAction(): Action
@@ -144,7 +146,7 @@ private function checkInAction(): Action
->visible(fn (Enrollment $record): bool => $record->status->is(EnrollmentStatus::Confirmed, EnrollmentStatus::CheckedIn))
->schema($this->checkInSchema())
->action(function (Enrollment $record, array $data): void {
- $this->checkIn($record, $data);
+ $this->checkIn($record, Date::parse(Arr::string($data, 'event_date')));
Notification::make()
->success()
@@ -166,7 +168,7 @@ private function bulkCheckInAction(): BulkAction
continue;
}
- $this->checkIn($record, $data);
+ $this->checkIn($record, Date::parse(Arr::string($data, 'event_date')));
}
Notification::make()
@@ -195,16 +197,13 @@ private function checkInSchema(): array
];
}
- /**
- * @param array $data
- */
- private function checkIn(Enrollment $enrollment, array $data): CheckIn
+ private function checkIn(Enrollment $enrollment, CarbonInterface $eventDate): CheckIn
{
return resolve(ManualCheckInAction::class)->handle(
new ManualCheckInDTO(
enrollment: $enrollment,
actorUserId: (string) auth()->id(),
- eventDate: Date::parse($data['event_date']),
+ eventDate: $eventDate,
),
);
}
diff --git a/app-modules/panel-admin/src/Filament/Resources/Events/Schemas/EventForm.php b/app-modules/panel-admin/src/Filament/Resources/Events/Schemas/EventForm.php
index da6800187..52f5344de 100644
--- a/app-modules/panel-admin/src/Filament/Resources/Events/Schemas/EventForm.php
+++ b/app-modules/panel-admin/src/Filament/Resources/Events/Schemas/EventForm.php
@@ -44,7 +44,7 @@ public static function configure(Schema $schema): Schema
table: Event::class,
column: 'slug',
ignoreRecord: true,
- modifyRuleUsing: function (Unique $rule, Get $get): Unique {
+ modifyRuleUsing: static function (Unique $rule, Get $get): Unique {
$tenantId = $get('tenant_id');
return filled($tenantId)
@@ -112,7 +112,7 @@ public static function configure(Schema $schema): Schema
Toggle::make('has_waitlist')
->label(__('panel-admin::events.form.waitlist_enabled'))
- ->default(false),
+ ->default(state: false),
Select::make('attendance_requirement')
->label(__('panel-admin::events.form.attendance_requirement'))
@@ -179,7 +179,7 @@ public static function configure(Schema $schema): Schema
->maxLength(255),
Toggle::make('required')
->label('Required')
- ->default(false),
+ ->default(state: false),
TagsInput::make('options')
->label('Options')
->placeholder('Type an option and press Enter')
diff --git a/app-modules/panel-app/src/Livewire/Events/EventDetail.php b/app-modules/panel-app/src/Livewire/Events/EventDetail.php
index 5ad2fab72..4be6c801e 100644
--- a/app-modules/panel-app/src/Livewire/Events/EventDetail.php
+++ b/app-modules/panel-app/src/Livewire/Events/EventDetail.php
@@ -41,14 +41,14 @@ final class EventDetail extends Component
{
public string $eventId;
- /** @var array */
+ /** @var array */
public array $applicationFormData = [];
public function mount(string $eventId): void
{
$this->eventId = $eventId;
- foreach ($this->event->enrollmentPolicy?->application_schema ?? [] as $index => $field) {
+ foreach ($this->event->enrollmentPolicy->application_schema ?? [] as $index => $field) {
if (($field['type'] ?? null) === 'checkbox') {
$this->applicationFormData[$index] = [];
}
@@ -111,7 +111,7 @@ public function qrCodeSvg(): ?string
public function checkIns(): Collection
{
if ($this->enrollment === null) {
- return collect();
+ return new Collection();
}
return CheckIn::query()
diff --git a/app-modules/panel-app/tests/Feature/Events/ApplicationEnrollmentTest.php b/app-modules/panel-app/tests/Feature/Events/ApplicationEnrollmentTest.php
index 0dc33371f..5fd5ec5cd 100644
--- a/app-modules/panel-app/tests/Feature/Events/ApplicationEnrollmentTest.php
+++ b/app-modules/panel-app/tests/Feature/Events/ApplicationEnrollmentTest.php
@@ -46,8 +46,8 @@
test('event detail shows canApply true and canConfirmPresence false for application event', function (): void {
livewire(EventDetail::class, ['eventId' => $this->event->id])
- ->assertSet('canApply', true)
- ->assertSet('canConfirmPresence', false);
+ ->assertSet('canApply', value: true)
+ ->assertSet('canConfirmPresence', value: false);
});
test('when user submits application, then enrollment is created as pending', function (): void {
@@ -74,7 +74,7 @@
->call('apply');
livewire(EventDetail::class, ['eventId' => $this->event->id])
- ->assertSet('canApply', false)
+ ->assertSet('canApply', value: false)
->assertSee(EnrollmentStatus::Pending->getLabel())
->assertSee(__('events::pages.application_pending_hint'))
->assertSee('I love Laravel!');
@@ -91,7 +91,7 @@
]);
livewire(EventDetail::class, ['eventId' => $this->event->id])
- ->assertSet('canApply', false)
+ ->assertSet('canApply', value: false)
->assertSee(EnrollmentStatus::Rejected->getLabel())
->assertSee(__('events::pages.application_rejected_hint'))
->assertSee('Not enough experience.');
@@ -107,7 +107,7 @@
]);
livewire(EventDetail::class, ['eventId' => $this->event->id])
- ->assertSet('canApply', false)
+ ->assertSet('canApply', value: false)
->assertDontSee(__('events::pages.apply_submit'));
});
diff --git a/app-modules/panel-app/tests/Feature/Events/RsvpEnrollmentTest.php b/app-modules/panel-app/tests/Feature/Events/RsvpEnrollmentTest.php
index 212f39cab..1bcc5f39f 100644
--- a/app-modules/panel-app/tests/Feature/Events/RsvpEnrollmentTest.php
+++ b/app-modules/panel-app/tests/Feature/Events/RsvpEnrollmentTest.php
@@ -108,8 +108,8 @@
]);
livewire(EventDetail::class, ['eventId' => $this->event->id])
- ->assertSet('canConfirmPresence', false)
- ->assertSet('isEventFull', true)
+ ->assertSet('canConfirmPresence', value: false)
+ ->assertSet('isEventFull', value: true)
->assertSee(__('events::pages.event_full'))
->assertDontSee(__('events::pages.confirm_presence'));
});
@@ -151,7 +151,7 @@
]);
livewire(EventDetail::class, ['eventId' => $this->event->id])
- ->assertSet('canConfirmPresence', true)
+ ->assertSet('canConfirmPresence', value: true)
->assertSee(__('events::pages.confirm_presence'));
});
@@ -171,7 +171,7 @@
->assertSee('He4rt Meetup RSVP');
livewire(EventDetail::class, ['eventId' => $this->event->id])
- ->assertSet('canConfirmPresence', false);
+ ->assertSet('canConfirmPresence', value: false);
});
test('when enrolled event is draft, then my events list does not link to detail', function (): void {
@@ -188,7 +188,7 @@
livewire(MyEventsList::class)
->assertSet('enrollments', fn ($enrollments): bool => $enrollments->count() === 1)
->assertSee('He4rt Meetup RSVP')
- ->assertDontSee(EventPage::getUrl(['record' => $this->event->id]), false);
+ ->assertDontSee(EventPage::getUrl(['record' => $this->event->id]), escape: false);
});
test('past event does not show confirm presence button', function (): void {
@@ -200,6 +200,6 @@
->create(['title' => 'Past Meetup']);
livewire(EventDetail::class, ['eventId' => $pastEvent->id])
- ->assertSet('canConfirmPresence', false)
+ ->assertSet('canConfirmPresence', value: false)
->assertDontSee(__('events::pages.confirm_presence'));
});
diff --git a/tests/Feature/LocaleSwitcherTest.php b/tests/Feature/LocaleSwitcherTest.php
index 4242c62b9..b635ba8e7 100644
--- a/tests/Feature/LocaleSwitcherTest.php
+++ b/tests/Feature/LocaleSwitcherTest.php
@@ -28,7 +28,7 @@
$middleware = new SetApplicationLocale;
$request = Request::create('/');
- $middleware->handle($request, function (): ResponseFactory|Response {
+ $middleware->handle($request, static function (): ResponseFactory|Response {
expect(app()->getLocale())->toBe(ApplicationLocale::PT_BR)
->and(Date::getLocale())->toBe('pt_BR');
From c45e25245faca39f09d19b1f1d91a198ccbedeb6 Mon Sep 17 00:00:00 2001
From: Adryanne Kelly <72768888+AdryanneKelly@users.noreply.github.com>
Date: Thu, 9 Jul 2026 11:16:17 -0300
Subject: [PATCH 17/18] fix(events): key application answers by a stable field
id (#396)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
## Summary
**The bug:** reordering questions in the Admin's application-form
Repeater silently corrupted already-submitted answers, showing them
under the wrong question.
**Root cause:** an answer in `application_data` was matched to its
question in `application_schema` by array position —
`application_data[0]` was "whatever the answer to schema position 0
was." The Repeater re-indexes `application_schema` to the new visual
order on every save. So if a participant answered while the schema was
`[A, B]` (answer stored at index `0` = "answer to A") and the organizer
later reordered to `[B, A]`, index `0` now points to B — that
participant's answer to A renders as if it were the answer to B, in both
the Admin's "View Application" modal and the App panel's "Your Answers"
section.
**Fix:** each question in the schema now carries a hidden, immutable
`key` (UUID), generated once when the question is created. Filament's
Repeater moves the whole item (including this hidden field) as a unit
when reordering, so the key never changes — only its position does.
Submission, validation, and both answer displays now match answer to
question by that `key` instead of array position.
**Follow-up fix (second commit):** enrollment policies saved before the
`key` field existed (or edited outside the Admin form builder) can still
have schema entries with no key. Instead of crashing with "Undefined
array key" when that happens, those fields are now silently skipped —
consistent with how the answer-display side already treated a missing
key as "no answer."
## What changed
| File | Change |
|---|---|
| `EventForm.php` | Added a hidden `key` (UUID) field to each Repeater
item in the application-schema builder, generated once on creation. |
| `EnrollUserAction.php` | `validateApplicationData()` now matches
submitted answers to schema fields by `key` instead of position;
`application_data`/`applicationData` typed as `array`. |
| `EnrollUserDTO.php`, `Enrollment.php` | Docblock types updated to
match (`array` keyed by field id, not array index). |
| `EventDetail.php` | The apply form's `wire:model` bindings and the
checkbox-group state pre-seed in `mount()` now key off `$field['key']`;
fields missing a key are skipped instead of crashing. |
| `event-detail.blade.php` | Same re-keying for the "Your Answers"
pending-status display and the apply form; skips fields with no key. |
| `EnrollmentsRelationManager.php` | Admin's "View Application" modal
now looks up each answer by `$field['key']`. |
| `EventsSeeder.php` | Seed schemas and fake-answer generation updated
to the key-based shape. |
| `EnrollUserActionTest.php`, `ApproveApplicationActionTest.php`,
`RejectApplicationActionTest.php`, `ApplicationEnrollmentTest.php` |
Updated fixtures/assertions to key-based `application_data`. |
## Test plan
```
vendor/bin/pest app-modules/events/tests app-modules/panel-app/tests app-modules/panel-admin/tests
vendor/bin/pint --test app-modules/events app-modules/panel-admin app-modules/panel-app
```
Results: 230/230 passing, Pint clean.
Manual verification: submitted an application, reordered the questions
in Admin and saved, confirmed the original answers still show under the
correct question in both the App panel ("Your Answers") and Admin ("View
Application").
---------
Co-authored-by: Claude Sonnet 5
---
.../events/database/seeders/EventsSeeder.php | 18 +++++-----
.../Enrollment/Actions/EnrollUserAction.php | 14 +++++---
.../src/Enrollment/DTOs/EnrollUserDTO.php | 2 +-
.../src/Enrollment/Models/Enrollment.php | 2 +-
.../tests/Feature/EnrollUserActionTest.php | 10 +++---
.../ApproveApplicationActionTest.php | 2 +-
.../RejectApplicationActionTest.php | 6 ++--
.../EnrollmentsRelationManager.php | 4 +--
.../Resources/Events/Schemas/EventForm.php | 4 +++
.../livewire/events/event-detail.blade.php | 15 ++++----
.../src/Livewire/Events/EventDetail.php | 8 ++---
.../Events/ApplicationEnrollmentTest.php | 34 ++++++++++++++-----
12 files changed, 74 insertions(+), 45 deletions(-)
diff --git a/app-modules/events/database/seeders/EventsSeeder.php b/app-modules/events/database/seeders/EventsSeeder.php
index ec663fa24..c42a0b8a3 100644
--- a/app-modules/events/database/seeders/EventsSeeder.php
+++ b/app-modules/events/database/seeders/EventsSeeder.php
@@ -167,10 +167,10 @@ private function matrix(): array
'cancellation_deadline_hours' => 48,
'xp' => [200, 300, 1_000],
'application_schema' => [
- ['type' => 'text', 'label' => 'Por que quer participar da He4rt Conf?', 'required' => true],
- ['type' => 'select', 'label' => 'Nível de experiência em desenvolvimento', 'required' => true, 'options' => ['Iniciante', 'Intermediário', 'Avançado']],
- ['type' => 'textarea', 'label' => 'Descreva um projeto pessoal ou open source relevante', 'required' => false],
- ['type' => 'checkbox', 'label' => 'Em quais dias você pode comparecer?', 'required' => false, 'options' => ['Dia 1', 'Dia 2', 'Dia 3']],
+ ['key' => 'why_participate', 'type' => 'text', 'label' => 'Por que quer participar da He4rt Conf?', 'required' => true],
+ ['key' => 'experience_level', 'type' => 'select', 'label' => 'Nível de experiência em desenvolvimento', 'required' => true, 'options' => ['Iniciante', 'Intermediário', 'Avançado']],
+ ['key' => 'personal_project', 'type' => 'textarea', 'label' => 'Descreva um projeto pessoal ou open source relevante', 'required' => false],
+ ['key' => 'attendance_days', 'type' => 'checkbox', 'label' => 'Em quais dias você pode comparecer?', 'required' => false, 'options' => ['Dia 1', 'Dia 2', 'Dia 3']],
],
'enrollments' => [
EnrollmentStatus::Pending->value => 6,
@@ -265,8 +265,8 @@ private function matrix(): array
'cancellation_deadline_hours' => 48,
'xp' => [100, 200, 800],
'application_schema' => [
- ['type' => 'text', 'label' => 'Qual ideia de projeto você traria para o hackathon?', 'required' => true],
- ['type' => 'select', 'label' => 'Stack principal', 'required' => true, 'options' => ['PHP', 'JavaScript', 'Python', 'Go', 'Rust', 'Outra']],
+ ['key' => 'project_idea', 'type' => 'text', 'label' => 'Qual ideia de projeto você traria para o hackathon?', 'required' => true],
+ ['key' => 'main_stack', 'type' => 'select', 'label' => 'Stack principal', 'required' => true, 'options' => ['PHP', 'JavaScript', 'Python', 'Go', 'Rust', 'Outra']],
],
'enrollments' => [
EnrollmentStatus::Cancelled->value => 8,
@@ -427,7 +427,7 @@ private function enrollmentTimestamps(EnrollmentStatus $status): array
/**
* @param array>|null $schema
- * @return array|null
+ * @return array|null
*/
private function fakeApplicationData(?array $schema): ?array
{
@@ -437,8 +437,8 @@ private function fakeApplicationData(?array $schema): ?array
$data = [];
- foreach ($schema as $index => $field) {
- $data[$index] = match ($field['type'] ?? 'text') {
+ foreach ($schema as $field) {
+ $data[$field['key']] = match ($field['type'] ?? 'text') {
'select' => fake()->randomElement($field['options'] ?? ['Opção A']),
'checkbox' => blank($field['options']) ? [] : fake()->randomElements($field['options'], fake()->numberBetween(1, count($field['options']))),
'textarea' => fake()->paragraph(),
diff --git a/app-modules/events/src/Enrollment/Actions/EnrollUserAction.php b/app-modules/events/src/Enrollment/Actions/EnrollUserAction.php
index 020163a4e..5bb286037 100644
--- a/app-modules/events/src/Enrollment/Actions/EnrollUserAction.php
+++ b/app-modules/events/src/Enrollment/Actions/EnrollUserAction.php
@@ -152,7 +152,7 @@ private function resolveInitialEnrollment(string $eventId, ?EnrollmentPolicy $po
}
/**
- * @param array|null $applicationData
+ * @param array|null $applicationData
*/
private function validate(Event $event, ?EnrollmentPolicy $policy, string $userId, ?array $applicationData): void
{
@@ -196,13 +196,19 @@ private function validate(Event $event, ?EnrollmentPolicy $policy, string $userI
}
/**
- * @param array $data
+ * @param array $data
* @param array> $schema
*/
private function validateApplicationData(array $data, array $schema): void
{
- foreach ($schema as $index => $field) {
- $value = $data[$index] ?? null;
+ foreach ($schema as $field) {
+ $key = $field['key'] ?? null;
+
+ if ($key === null) {
+ continue;
+ }
+
+ $value = $data[$key] ?? null;
$type = $field['type'] ?? '';
if ($type === 'checkbox') {
diff --git a/app-modules/events/src/Enrollment/DTOs/EnrollUserDTO.php b/app-modules/events/src/Enrollment/DTOs/EnrollUserDTO.php
index d871a34ab..d45e26e01 100644
--- a/app-modules/events/src/Enrollment/DTOs/EnrollUserDTO.php
+++ b/app-modules/events/src/Enrollment/DTOs/EnrollUserDTO.php
@@ -10,7 +10,7 @@
final readonly class EnrollUserDTO
{
/**
- * @param array|null $applicationData
+ * @param array|null $applicationData
*/
public function __construct(
public string $eventId,
diff --git a/app-modules/events/src/Enrollment/Models/Enrollment.php b/app-modules/events/src/Enrollment/Models/Enrollment.php
index 5ce565d3a..d252ed823 100644
--- a/app-modules/events/src/Enrollment/Models/Enrollment.php
+++ b/app-modules/events/src/Enrollment/Models/Enrollment.php
@@ -27,7 +27,7 @@
* @property EnrollmentStatus $status
* @property bool $is_public
* @property int|null $waitlist_position
- * @property array|null $application_data
+ * @property array|null $application_data
* @property string|null $rejection_reason
* @property Carbon|null $enrolled_at
* @property Carbon|null $confirmed_at
diff --git a/app-modules/events/tests/Feature/EnrollUserActionTest.php b/app-modules/events/tests/Feature/EnrollUserActionTest.php
index 0edd17991..19ad986ff 100644
--- a/app-modules/events/tests/Feature/EnrollUserActionTest.php
+++ b/app-modules/events/tests/Feature/EnrollUserActionTest.php
@@ -346,21 +346,21 @@ function createRsvpEvent(Tenant $tenant, array $eventAttributes = [], array $pol
->upcoming()
->for($tenant)
->has(EnrollmentPolicy::factory()->application([
- ['type' => 'text', 'label' => 'Why do you want to join?', 'required' => true],
+ ['key' => 'why_join', 'type' => 'text', 'label' => 'Why do you want to join?', 'required' => true],
]), 'enrollmentPolicy')
->create();
$dto = new EnrollUserDTO(
eventId: $event->id,
userId: $user->id,
- applicationData: [0 => 'I love PHP!'],
+ applicationData: ['why_join' => 'I love PHP!'],
);
$enrollment = resolve(EnrollUserAction::class)->handle($dto);
expect($enrollment->status)->toBe(EnrollmentStatus::Pending)
->and($enrollment->confirmed_at)->toBeNull()
- ->and($enrollment->application_data)->toBe([0 => 'I love PHP!']);
+ ->and($enrollment->application_data)->toBe(['why_join' => 'I love PHP!']);
$transition = EnrollmentTransition::query()
->where('enrollment_id', $enrollment->id)
@@ -395,14 +395,14 @@ function createRsvpEvent(Tenant $tenant, array $eventAttributes = [], array $pol
->upcoming()
->for($tenant)
->has(EnrollmentPolicy::factory()->application([
- ['type' => 'text', 'label' => 'Why?', 'required' => true],
+ ['key' => 'why', 'type' => 'text', 'label' => 'Why?', 'required' => true],
]), 'enrollmentPolicy')
->create();
$dto = new EnrollUserDTO(
eventId: $event->id,
userId: $user->id,
- applicationData: [0 => ''],
+ applicationData: ['why' => ''],
);
resolve(EnrollUserAction::class)->handle($dto);
diff --git a/app-modules/events/tests/Feature/Enrollment/ApproveApplicationActionTest.php b/app-modules/events/tests/Feature/Enrollment/ApproveApplicationActionTest.php
index 15c359cf2..c1d0bc791 100644
--- a/app-modules/events/tests/Feature/Enrollment/ApproveApplicationActionTest.php
+++ b/app-modules/events/tests/Feature/Enrollment/ApproveApplicationActionTest.php
@@ -26,7 +26,7 @@ function createPendingApplicationEnrollment(Event $event, ?User $user = null): E
'user_id' => $user->id,
'status' => EnrollmentStatus::Pending,
'enrolled_at' => now(),
- 'application_data' => [0 => 'My answer'],
+ 'application_data' => ['q1' => 'My answer'],
]);
}
diff --git a/app-modules/events/tests/Feature/Enrollment/RejectApplicationActionTest.php b/app-modules/events/tests/Feature/Enrollment/RejectApplicationActionTest.php
index 1dc647382..bbf5768d8 100644
--- a/app-modules/events/tests/Feature/Enrollment/RejectApplicationActionTest.php
+++ b/app-modules/events/tests/Feature/Enrollment/RejectApplicationActionTest.php
@@ -29,7 +29,7 @@
'user_id' => User::factory()->create()->id,
'status' => EnrollmentStatus::Pending,
'enrolled_at' => now(),
- 'application_data' => [0 => 'My answer'],
+ 'application_data' => ['q1' => 'My answer'],
]);
$result = resolve(RejectApplicationAction::class)->handle(
@@ -97,7 +97,7 @@
'user_id' => User::factory()->create()->id,
'status' => EnrollmentStatus::Pending,
'enrolled_at' => now(),
- 'application_data' => [0 => 'Original answer'],
+ 'application_data' => ['q1' => 'Original answer'],
]);
$result = resolve(RejectApplicationAction::class)->handle(
@@ -108,5 +108,5 @@
),
);
- expect($result->application_data)->toBe([0 => 'Original answer']);
+ expect($result->application_data)->toBe(['q1' => 'Original answer']);
});
diff --git a/app-modules/panel-admin/src/Filament/Resources/Events/RelationManagers/EnrollmentsRelationManager.php b/app-modules/panel-admin/src/Filament/Resources/Events/RelationManagers/EnrollmentsRelationManager.php
index 418ee2c08..b6f57ae7a 100644
--- a/app-modules/panel-admin/src/Filament/Resources/Events/RelationManagers/EnrollmentsRelationManager.php
+++ b/app-modules/panel-admin/src/Filament/Resources/Events/RelationManagers/EnrollmentsRelationManager.php
@@ -118,13 +118,13 @@ private function viewApplicationAction(): Action
->color('gray')
->visible(fn (Enrollment $record): bool => $record->application_data !== null)
->modalContent(static function (Enrollment $record): View {
- $schema = $record->event?->enrollmentPolicy->application_schema ?? [];
+ $schema = $record->event?->enrollmentPolicy?->application_schema ?? [];
$data = $record->application_data ?? [];
$answers = collect($schema)
->map(fn (array $field, int $index): array => [
'label' => $field['label'] ?? 'Question '.$index,
- 'value' => $data[$index] ?? '—',
+ 'value' => $data[$field['key'] ?? null] ?? '—',
])
->values()
->all();
diff --git a/app-modules/panel-admin/src/Filament/Resources/Events/Schemas/EventForm.php b/app-modules/panel-admin/src/Filament/Resources/Events/Schemas/EventForm.php
index 52f5344de..f8e01731b 100644
--- a/app-modules/panel-admin/src/Filament/Resources/Events/Schemas/EventForm.php
+++ b/app-modules/panel-admin/src/Filament/Resources/Events/Schemas/EventForm.php
@@ -5,6 +5,7 @@
namespace He4rt\PanelAdmin\Filament\Resources\Events\Schemas;
use Filament\Forms\Components\DateTimePicker;
+use Filament\Forms\Components\Hidden;
use Filament\Forms\Components\Repeater;
use Filament\Forms\Components\Select;
use Filament\Forms\Components\TagsInput;
@@ -21,6 +22,7 @@
use He4rt\Events\Event\Enums\EventType;
use He4rt\Events\Event\Models\Event;
use Illuminate\Support\Facades\Date;
+use Illuminate\Support\Str;
use Illuminate\Validation\Rules\Unique;
final class EventForm
@@ -163,6 +165,8 @@ public static function configure(Schema $schema): Schema
->reorderable()
->collapsible()
->schema([
+ Hidden::make('key')
+ ->default(fn (): string => (string) Str::uuid()),
Select::make('type')
->label('Type')
->options([
diff --git a/app-modules/panel-app/resources/views/livewire/events/event-detail.blade.php b/app-modules/panel-app/resources/views/livewire/events/event-detail.blade.php
index 5b5644cf5..75bf8f106 100644
--- a/app-modules/panel-app/resources/views/livewire/events/event-detail.blade.php
+++ b/app-modules/panel-app/resources/views/livewire/events/event-detail.blade.php
@@ -56,11 +56,11 @@
{{ __('events::pages.application_your_answers') }}
- @foreach ($this->event->enrollmentPolicy->application_schema as $index => $field)
+ @foreach ($this->event->enrollmentPolicy->application_schema as $field)
{{ $field['label'] ?? '' }}
- @php $answer = $this->enrollment->application_data[$index] ?? null; @endphp
+ @php $answer = $this->enrollment->application_data[$field['key'] ?? null] ?? null; @endphp
@if (is_array($answer))
{{ $answer !== [] ? implode(', ', $answer) : '—' }}
@else
@@ -169,7 +169,8 @@ class="text-success-500 h-4 w-4 shrink-0"
{{ __('events::pages.apply_hint') }}