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/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/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/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/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/events/src/Enrollment/Actions/EnrollUserAction.php b/app-modules/events/src/Enrollment/Actions/EnrollUserAction.php new file mode 100644 index 00000000..e2443cd3 --- /dev/null +++ b/app-modules/events/src/Enrollment/Actions/EnrollUserAction.php @@ -0,0 +1,92 @@ +validate($dto); + + return DB::transaction(function () use ($dto): Enrollment { + throw_if( + Enrollment::query() + ->where('event_id', $dto->eventId) + ->where('user_id', $dto->userId) + ->exists(), + EnrollmentException::alreadyEnrolled(), + ); + + $now = now(); + + $enrollment = new Enrollment([ + 'event_id' => $dto->eventId, + 'user_id' => $dto->userId, + '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' => $dto->userId, + 'triggered_by' => TriggeredBy::User, + ]); + + event(new EnrollmentConfirmed( + enrollmentId: $enrollment->id, + eventId: $dto->eventId, + userId: $dto->userId, + xpRewardRsvp: $dto->xpRewardOnConfirmed, + )); + + return $enrollment->fresh(['event.enrollmentPolicy']); + }); + } + + private function validate(EnrollUserDTO $dto): void + { + throw_unless( + $dto->eventStatus === EventStatus::Published, + EnrollmentException::eventNotActive(), + ); + + throw_if( + $dto->eventStartsAt->lte(now()), + EnrollmentException::eventPast(), + ); + + throw_unless( + in_array( + $dto->enrollmentMethod, + [EnrollmentMethod::Rsvp, EnrollmentMethod::RsvpCheckin], + strict: true, + ), + EnrollmentException::invalidEnrollmentMethod(), + ); + + throw_if( + Enrollment::query() + ->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/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 @@ +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->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(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 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 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 { 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 1b0853d0..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 @@ -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 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(), 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 @@ + +
+ + + {{ __('events::pages.back_to_events') }} + + + +
+
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..605c1731 --- /dev/null +++ b/app-modules/panel-app/src/Livewire/Events/EventDetail.php @@ -0,0 +1,93 @@ +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( + EnrollUserDTO::fromModels($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([