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->description }}
+ @endif ++ {{ __('events::pages.enrollment_status_label') }} +
+ +{{ __('events::pages.confirm_presence_hint') }}
+ ++ {{ $event->description }} +
+ @endif + +{{ __('events::pages.no_upcoming_events') }}
+{{ __('events::pages.no_enrollments') }}
+