From 0ec3d42ba19c8576f3e1ab3ef37d0ac6f397bdf2 Mon Sep 17 00:00:00 2001
From: danielhe4rt
Date: Sat, 16 May 2026 21:13:40 -0300
Subject: [PATCH 01/11] 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/11] 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/11] 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/11] 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/11] 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/11] 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/11] 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/11] 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/11] 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/11] 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/11] 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",