From 73bd3b589af38ddda1f7e0185a95a826feb7f934 Mon Sep 17 00:00:00 2001 From: BrunaDomingues Date: Wed, 20 May 2026 11:08:18 -0300 Subject: [PATCH 1/7] fix(admin): use status instead of active on event resource --- .../Filament/Resources/Events/Schemas/EventForm.php | 9 ++++++--- .../Resources/Events/Schemas/EventInfolist.php | 6 +++--- .../Resources/Events/Tables/EventsTable.php | 13 +++++++++---- 3 files changed, 18 insertions(+), 10 deletions(-) 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 1b0853d0..1f186ded 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,6 +16,7 @@ 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; final class EventForm @@ -65,9 +66,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 192d1299..e138d7fb 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 70889e13..a9cf7ec9 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(), From be352a35faa216b1f20c83831ea46710807960b6 Mon Sep 17 00:00:00 2001 From: BrunaDomingues Date: Wed, 20 May 2026 11:08:50 -0300 Subject: [PATCH 2/7] fix(events): assert event_date in check-in factory test --- app-modules/events/tests/Feature/EventFactoriesTest.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app-modules/events/tests/Feature/EventFactoriesTest.php b/app-modules/events/tests/Feature/EventFactoriesTest.php index 00a85200..94db57e4 100644 --- a/app-modules/events/tests/Feature/EventFactoriesTest.php +++ b/app-modules/events/tests/Feature/EventFactoriesTest.php @@ -49,7 +49,7 @@ expect($checkIn->id)->not->toBeNull() ->and($checkIn->enrollment_id)->not->toBeNull() - ->and($checkIn->check_in)->not->toBeNull(); + ->and($checkIn->check_in_date)->not->toBeNull(); }); test('when creating a check-in code via factory, then it is linked to an event with valid dates', function (): void { From 64f54ee4c9aec3b26cbf7bf0d826ccca36ba2c58 Mon Sep 17 00:00:00 2001 From: BrunaDomingues Date: Wed, 20 May 2026 11:11:15 -0300 Subject: [PATCH 3/7] feat(events): add RSVP enroll action and domain event --- app-modules/events/lang/en/exceptions.php | 10 ++ app-modules/events/lang/pt_BR/exceptions.php | 10 ++ .../Enrollment/Actions/EnrollUserAction.php | 98 +++++++++++++++++++ .../Enrollment/Events/EnrollmentConfirmed.php | 20 ++++ .../Exceptions/EnrollmentException.php | 43 ++++++++ 5 files changed, 181 insertions(+) create mode 100644 app-modules/events/lang/en/exceptions.php create mode 100644 app-modules/events/lang/pt_BR/exceptions.php create mode 100644 app-modules/events/src/Enrollment/Actions/EnrollUserAction.php create mode 100644 app-modules/events/src/Enrollment/Events/EnrollmentConfirmed.php create mode 100644 app-modules/events/src/Enrollment/Exceptions/EnrollmentException.php diff --git a/app-modules/events/lang/en/exceptions.php b/app-modules/events/lang/en/exceptions.php new file mode 100644 index 00000000..04961c79 --- /dev/null +++ b/app-modules/events/lang/en/exceptions.php @@ -0,0 +1,10 @@ + '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 does not accept RSVP enrollments.', +]; 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 00000000..728c7615 --- /dev/null +++ b/app-modules/events/lang/pt_BR/exceptions.php @@ -0,0 +1,10 @@ + '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' => 'Este evento não aceita inscrições via RSVP.', +]; 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 00000000..760e3f86 --- /dev/null +++ b/app-modules/events/src/Enrollment/Actions/EnrollUserAction.php @@ -0,0 +1,98 @@ +validate($event, $user); + + return DB::transaction(function () use ($event, $user): Enrollment { + throw_if( + Enrollment::query() + ->where('event_id', $event->id) + ->where('user_id', $user->id) + ->exists(), + EnrollmentException::alreadyEnrolled(), + ); + + $event->loadMissing('enrollmentPolicy'); + $policy = $event->enrollmentPolicy; + + $now = now(); + + $enrollment = new Enrollment([ + 'event_id' => $event->id, + 'user_id' => $user->id, + 'enrolled_at' => $now, + 'confirmed_at' => $now, + ]); + $enrollment->status = EnrollmentStatus::Confirmed; + $enrollment->save(); + + EnrollmentTransition::query()->create([ + 'enrollment_id' => $enrollment->id, + 'from_status' => null, + 'to_status' => EnrollmentStatus::Confirmed, + 'actor_id' => $user->id, + 'triggered_by' => TriggeredBy::User, + ]); + + event(new EnrollmentConfirmed( + enrollmentId: $enrollment->id, + eventId: $event->id, + userId: $user->id, + xpRewardRsvp: $policy->xp_on_confirmed, + )); + + return $enrollment->fresh(['event.enrollmentPolicy']); + }); + } + + private function validate(Event $event, User $user): void + { + throw_unless( + $event->status === EventStatus::Published, + EnrollmentException::eventNotActive(), + ); + + throw_if( + $event->starts_at->lte(now()), + EnrollmentException::eventPast(), + ); + + $event->loadMissing('enrollmentPolicy'); + + throw_unless( + in_array( + $event->enrollmentPolicy?->enrollment_method, + [EnrollmentMethod::Rsvp, EnrollmentMethod::RsvpCheckin], + strict: true, + ), + EnrollmentException::invalidEnrollmentMethod(), + ); + + throw_if( + Enrollment::query() + ->where('event_id', $event->id) + ->where('user_id', $user->id) + ->exists(), + EnrollmentException::alreadyEnrolled(), + ); + } +} 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 00000000..ecb4a02a --- /dev/null +++ b/app-modules/events/src/Enrollment/Events/EnrollmentConfirmed.php @@ -0,0 +1,20 @@ + Date: Wed, 20 May 2026 11:11:36 -0300 Subject: [PATCH 4/7] test(events): add EnrollUserAction coverage and factory states --- .../factories/EnrollmentPolicyFactory.php | 7 + .../database/factories/EventFactory.php | 27 ++++ .../tests/Feature/EnrollUserActionTest.php | 120 ++++++++++++++++++ 3 files changed, 154 insertions(+) create mode 100644 app-modules/events/tests/Feature/EnrollUserActionTest.php diff --git a/app-modules/events/database/factories/EnrollmentPolicyFactory.php b/app-modules/events/database/factories/EnrollmentPolicyFactory.php index 152d5ffe..45add3df 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 9dc8eb7b..3d939b9d 100644 --- a/app-modules/events/database/factories/EventFactory.php +++ b/app-modules/events/database/factories/EventFactory.php @@ -32,4 +32,31 @@ 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), + ]); + } } diff --git a/app-modules/events/tests/Feature/EnrollUserActionTest.php b/app-modules/events/tests/Feature/EnrollUserActionTest.php new file mode 100644 index 00000000..04d9a19e --- /dev/null +++ b/app-modules/events/tests/Feature/EnrollUserActionTest.php @@ -0,0 +1,120 @@ +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($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->xpRewardRsvp === 50); +}); + +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($event, $user); + + resolve(EnrollUserAction::class)->handle($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($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($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($event, $user); +})->throws(EnrollmentException::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($event, $user); + + expect(Enrollment::query()->where('event_id', $event->id)->where('user_id', $user->id)->count())->toBe(1); +}); From 1cafa8afce1f098f4c18da0f21c67e36fd913536 Mon Sep 17 00:00:00 2001 From: BrunaDomingues Date: Wed, 20 May 2026 11:12:06 -0300 Subject: [PATCH 5/7] feat(panel-app): add participant events and RSVP UI --- app-modules/events/lang/en/pages.php | 14 +++ app-modules/events/lang/pt_BR/pages.php | 14 +++ .../livewire/events/event-detail.blade.php | 49 +++++++++ .../livewire/events/events-list.blade.php | 43 ++++++++ .../livewire/events/my-events-list.blade.php | 41 +++++++ .../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 | 90 +++++++++++++++ .../src/Livewire/Events/EventsList.php | 35 ++++++ .../src/Livewire/Events/MyEventsList.php | 36 ++++++ app-modules/panel-app/src/Pages/EventPage.php | 38 +++++++ .../panel-app/src/Pages/EventsPage.php | 21 ++++ .../panel-app/src/Pages/MyEventsPage.php | 21 ++++ .../panel-app/src/PanelAppServiceProvider.php | 7 ++ .../Feature/Events/RsvpEnrollmentTest.php | 104 ++++++++++++++++++ app/Providers/Filament/AppPanelProvider.php | 6 + 17 files changed, 542 insertions(+) create mode 100644 app-modules/events/lang/en/pages.php create mode 100644 app-modules/events/lang/pt_BR/pages.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/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/lang/en/pages.php b/app-modules/events/lang/en/pages.php new file mode 100644 index 00000000..2659b53f --- /dev/null +++ b/app-modules/events/lang/en/pages.php @@ -0,0 +1,14 @@ + 'Back to Events', + 'confirm_presence' => 'Confirm Presence', + 'confirm_presence_hint' => 'Confirm your attendance to this event.', + 'confirm_presence_success' => 'Your presence has been confirmed!', + '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/pages.php b/app-modules/events/lang/pt_BR/pages.php new file mode 100644 index 00000000..835e2a53 --- /dev/null +++ b/app-modules/events/lang/pt_BR/pages.php @@ -0,0 +1,14 @@ + '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!', + '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/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 00000000..f94987fe --- /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 00000000..70858da7 --- /dev/null +++ b/app-modules/panel-app/resources/views/livewire/events/events-list.blade.php @@ -0,0 +1,43 @@ +
+
+ @forelse ($this->events as $event) + +
+
+

+ {{ $event->title }} +

+ + @if ($event->description) +

+ {{ $event->description }} +

+ @endif + +
+ {{ $event->starts_at->format('d/m/Y H:i') }} + + @if ($event->location) + {{ $event->location }} + @endif +
+
+ + + {{ $event->event_type->getLabel() }} + +
+
+ @empty +
+

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

+
+ @endforelse +
+
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 00000000..f8fd621d --- /dev/null +++ b/app-modules/panel-app/resources/views/livewire/events/my-events-list.blade.php @@ -0,0 +1,41 @@ +
+
+ @forelse ($this->enrollments as $enrollment) + +
+
+

+ {{ $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() }} + +
+
+ @empty +
+

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

+
+ @endforelse +
+
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 00000000..d30cbd90 --- /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 00000000..b277f96c --- /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 00000000..d60ad02c --- /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 00000000..e97ec43d --- /dev/null +++ b/app-modules/panel-app/src/Livewire/Events/EventDetail.php @@ -0,0 +1,90 @@ +eventId = $eventId; + } + + #[Computed] + public function event(): Event + { + return Event::query() + ->with('enrollmentPolicy') + ->where('id', $this->eventId) + ->where('tenant_id', filament()->getTenant()->getKey()) + ->where('status', EventStatus::Published) + ->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->enrollment !== null) { + return false; + } + + $method = $this->event->enrollmentPolicy?->enrollment_method; + + if (!in_array($method, [EnrollmentMethod::Rsvp, EnrollmentMethod::RsvpCheckin], strict: true)) { + return false; + } + + return $this->event->starts_at->isFuture(); + } + + public function confirmPresence(): void + { + /** @var User $user */ + $user = auth()->user(); + + try { + resolve(EnrollUserAction::class)->handle($this->event, $user); + + unset($this->enrollment, $this->canConfirmPresence); + + Notification::make() + ->success() + ->title(__('events::pages.confirm_presence_success')) + ->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 00000000..4d3ba3f0 --- /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 00000000..124f0dbe --- /dev/null +++ b/app-modules/panel-app/src/Livewire/Events/MyEventsList.php @@ -0,0 +1,36 @@ + */ + 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 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 00000000..dde72799 --- /dev/null +++ b/app-modules/panel-app/src/Pages/EventPage.php @@ -0,0 +1,38 @@ +where('id', $record) + ->where('tenant_id', filament()->getTenant()->getKey()) + ->where('status', EventStatus::Published) + ->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 00000000..b309db0e --- /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 00000000..fc8be8ae --- /dev/null +++ b/app-modules/panel-app/tests/Feature/Events/RsvpEnrollmentTest.php @@ -0,0 +1,104 @@ +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('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 5ab62956..e0185d82 100644 --- a/app/Providers/Filament/AppPanelProvider.php +++ b/app/Providers/Filament/AppPanelProvider.php @@ -14,6 +14,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\ThreadPage; use He4rt\PanelApp\Pages\TimelinePage; use Illuminate\Cookie\Middleware\AddQueuedCookiesToResponse; @@ -49,6 +52,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, ]) ->middleware([ From 46b8df1358ae34695a878f9017858b8d887fed51 Mon Sep 17 00:00:00 2001 From: BrunaDomingues Date: Wed, 20 May 2026 11:44:47 -0300 Subject: [PATCH 6/7] fix(admin): validate unique event slug per tenant --- .../tests/Feature/EventResourceTest.php | 27 +++++++++++++++++++ .../Resources/Events/Schemas/EventForm.php | 16 ++++++++++- 2 files changed, 42 insertions(+), 1 deletion(-) diff --git a/app-modules/events/tests/Feature/EventResourceTest.php b/app-modules/events/tests/Feature/EventResourceTest.php index 3c855688..4fbb7f2e 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/panel-admin/src/Filament/Resources/Events/Schemas/EventForm.php b/app-modules/panel-admin/src/Filament/Resources/Events/Schemas/EventForm.php index 1f186ded..e4d353af 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 @@ -18,6 +18,8 @@ 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 { @@ -35,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') From e3bfc4ac5565db1954b35b9e1542affccbd28d09 Mon Sep 17 00:00:00 2001 From: BrunaDomingues Date: Wed, 20 May 2026 12:18:55 -0300 Subject: [PATCH 7/7] refactor(events): use EnrollUserDTO in enroll action Pass only enrollment inputs into EnrollUserAction instead of Event and User models, mapping at the UI and test boundaries. Co-authored-by: Cursor --- .../Enrollment/Actions/EnrollUserAction.php | 42 ++++++++----------- .../src/Enrollment/DTOs/EnrollUserDTO.php | 37 ++++++++++++++++ .../tests/Feature/EnrollUserActionTest.php | 15 +++---- .../src/Livewire/Events/EventDetail.php | 5 ++- 4 files changed, 67 insertions(+), 32 deletions(-) create mode 100644 app-modules/events/src/Enrollment/DTOs/EnrollUserDTO.php diff --git a/app-modules/events/src/Enrollment/Actions/EnrollUserAction.php b/app-modules/events/src/Enrollment/Actions/EnrollUserAction.php index 760e3f86..e2443cd3 100644 --- a/app-modules/events/src/Enrollment/Actions/EnrollUserAction.php +++ b/app-modules/events/src/Enrollment/Actions/EnrollUserAction.php @@ -4,6 +4,7 @@ namespace He4rt\Events\Enrollment\Actions; +use He4rt\Events\Enrollment\DTOs\EnrollUserDTO; use He4rt\Events\Enrollment\Enums\EnrollmentMethod; use He4rt\Events\Enrollment\Enums\EnrollmentStatus; use He4rt\Events\Enrollment\Enums\TriggeredBy; @@ -12,33 +13,28 @@ use He4rt\Events\Enrollment\Models\Enrollment; use He4rt\Events\Enrollment\Models\EnrollmentTransition; use He4rt\Events\Event\Enums\EventStatus; -use He4rt\Events\Event\Models\Event; -use He4rt\Identity\User\Models\User; use Illuminate\Support\Facades\DB; final readonly class EnrollUserAction { - public function handle(Event $event, User $user): Enrollment + public function handle(EnrollUserDTO $dto): Enrollment { - $this->validate($event, $user); + $this->validate($dto); - return DB::transaction(function () use ($event, $user): Enrollment { + return DB::transaction(function () use ($dto): Enrollment { throw_if( Enrollment::query() - ->where('event_id', $event->id) - ->where('user_id', $user->id) + ->where('event_id', $dto->eventId) + ->where('user_id', $dto->userId) ->exists(), EnrollmentException::alreadyEnrolled(), ); - $event->loadMissing('enrollmentPolicy'); - $policy = $event->enrollmentPolicy; - $now = now(); $enrollment = new Enrollment([ - 'event_id' => $event->id, - 'user_id' => $user->id, + 'event_id' => $dto->eventId, + 'user_id' => $dto->userId, 'enrolled_at' => $now, 'confirmed_at' => $now, ]); @@ -49,38 +45,36 @@ public function handle(Event $event, User $user): Enrollment 'enrollment_id' => $enrollment->id, 'from_status' => null, 'to_status' => EnrollmentStatus::Confirmed, - 'actor_id' => $user->id, + 'actor_id' => $dto->userId, 'triggered_by' => TriggeredBy::User, ]); event(new EnrollmentConfirmed( enrollmentId: $enrollment->id, - eventId: $event->id, - userId: $user->id, - xpRewardRsvp: $policy->xp_on_confirmed, + eventId: $dto->eventId, + userId: $dto->userId, + xpRewardRsvp: $dto->xpRewardOnConfirmed, )); return $enrollment->fresh(['event.enrollmentPolicy']); }); } - private function validate(Event $event, User $user): void + private function validate(EnrollUserDTO $dto): void { throw_unless( - $event->status === EventStatus::Published, + $dto->eventStatus === EventStatus::Published, EnrollmentException::eventNotActive(), ); throw_if( - $event->starts_at->lte(now()), + $dto->eventStartsAt->lte(now()), EnrollmentException::eventPast(), ); - $event->loadMissing('enrollmentPolicy'); - throw_unless( in_array( - $event->enrollmentPolicy?->enrollment_method, + $dto->enrollmentMethod, [EnrollmentMethod::Rsvp, EnrollmentMethod::RsvpCheckin], strict: true, ), @@ -89,8 +83,8 @@ private function validate(Event $event, User $user): void throw_if( Enrollment::query() - ->where('event_id', $event->id) - ->where('user_id', $user->id) + ->where('event_id', $dto->eventId) + ->where('user_id', $dto->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 00000000..ea97f949 --- /dev/null +++ b/app-modules/events/src/Enrollment/DTOs/EnrollUserDTO.php @@ -0,0 +1,37 @@ +loadMissing('enrollmentPolicy'); + + return new self( + eventId: $event->id, + userId: $user->id, + eventStatus: $event->status, + eventStartsAt: $event->starts_at, + enrollmentMethod: $event->enrollmentPolicy?->enrollment_method, + xpRewardOnConfirmed: $event->enrollmentPolicy?->xp_on_confirmed ?? 0, + ); + } +} diff --git a/app-modules/events/tests/Feature/EnrollUserActionTest.php b/app-modules/events/tests/Feature/EnrollUserActionTest.php index 04d9a19e..e79f9961 100644 --- a/app-modules/events/tests/Feature/EnrollUserActionTest.php +++ b/app-modules/events/tests/Feature/EnrollUserActionTest.php @@ -3,6 +3,7 @@ declare(strict_types=1); 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\Enums\TriggeredBy; @@ -37,7 +38,7 @@ function createRsvpEvent(Tenant $tenant, array $eventAttributes = [], array $pol $tenant = Tenant::factory()->create(); $event = createRsvpEvent($tenant, [], ['xp_on_confirmed' => 50]); - $enrollment = resolve(EnrollUserAction::class)->handle($event, $user); + $enrollment = resolve(EnrollUserAction::class)->handle(EnrollUserDTO::fromModels($event, $user)); expect($enrollment->status)->toBe(EnrollmentStatus::Confirmed) ->and($enrollment->enrolled_at)->not->toBeNull() @@ -64,9 +65,9 @@ function createRsvpEvent(Tenant $tenant, array $eventAttributes = [], array $pol $tenant = Tenant::factory()->create(); $event = createRsvpEvent($tenant); - resolve(EnrollUserAction::class)->handle($event, $user); + resolve(EnrollUserAction::class)->handle(EnrollUserDTO::fromModels($event, $user)); - resolve(EnrollUserAction::class)->handle($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 { @@ -79,7 +80,7 @@ function createRsvpEvent(Tenant $tenant, array $eventAttributes = [], array $pol ->has(EnrollmentPolicy::factory()->rsvp(), 'enrollmentPolicy') ->create(); - resolve(EnrollUserAction::class)->handle($event, $user); + 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 { @@ -91,7 +92,7 @@ function createRsvpEvent(Tenant $tenant, array $eventAttributes = [], array $pol ->has(EnrollmentPolicy::factory()->rsvp(), 'enrollmentPolicy') ->create(['status' => EventStatus::Draft]); - resolve(EnrollUserAction::class)->handle($event, $user); + 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 { @@ -106,7 +107,7 @@ function createRsvpEvent(Tenant $tenant, array $eventAttributes = [], array $pol ]), 'enrollmentPolicy') ->create(); - resolve(EnrollUserAction::class)->handle($event, $user); + resolve(EnrollUserAction::class)->handle(EnrollUserDTO::fromModels($event, $user)); })->throws(EnrollmentException::class); test('when duplicate enrollment exists in database, then only one enrollment record is kept', function (): void { @@ -114,7 +115,7 @@ function createRsvpEvent(Tenant $tenant, array $eventAttributes = [], array $pol $tenant = Tenant::factory()->create(); $event = createRsvpEvent($tenant); - resolve(EnrollUserAction::class)->handle($event, $user); + 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/panel-app/src/Livewire/Events/EventDetail.php b/app-modules/panel-app/src/Livewire/Events/EventDetail.php index e97ec43d..605c1731 100644 --- a/app-modules/panel-app/src/Livewire/Events/EventDetail.php +++ b/app-modules/panel-app/src/Livewire/Events/EventDetail.php @@ -6,6 +6,7 @@ use Filament\Notifications\Notification; use He4rt\Events\Enrollment\Actions\EnrollUserAction; +use He4rt\Events\Enrollment\DTOs\EnrollUserDTO; use He4rt\Events\Enrollment\Enums\EnrollmentMethod; use He4rt\Events\Enrollment\Exceptions\EnrollmentException; use He4rt\Events\Enrollment\Models\Enrollment; @@ -67,7 +68,9 @@ public function confirmPresence(): void $user = auth()->user(); try { - resolve(EnrollUserAction::class)->handle($this->event, $user); + resolve(EnrollUserAction::class)->handle( + EnrollUserDTO::fromModels($this->event, $user), + ); unset($this->enrollment, $this->canConfirmPresence);