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 @@ +
+
+ @forelse ($this->enrollments as $enrollment) + @if ($this->canOpenEvent($enrollment)) + + @include ('panel-app::livewire.events.partials.my-events-list-item', ['enrollment' => $enrollment]) + + @else +
+ @include ('panel-app::livewire.events.partials.my-events-list-item', ['enrollment' => $enrollment]) +
+ @endif + @empty +
+

{{ __('events::pages.no_enrollments') }}

+
+ @endforelse +
+
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",