From 071d14788826d9e3428b3e517af94f82e3ec520a Mon Sep 17 00:00:00 2001 From: Leon Morival Date: Fri, 22 May 2026 12:15:23 +0200 Subject: [PATCH] feat: notification on filament dashboard --- .../Resources/Users/Schemas/UserInfolist.php | 7 ++ .../Resources/Users/Tables/UsersTable.php | 48 ++++++++++++ .../ModerationThresholdReached.php | 73 ------------------- .../Filament/DashboardPanelProvider.php | 1 + app/Services/ModerationReportService.php | 11 ++- lang/en/admin.php | 8 ++ lang/fr/admin.php | 8 ++ tests/Feature/ModerationReportsTest.php | 18 ++--- tests/Feature/UserFilamentRoleTest.php | 32 ++++++++ 9 files changed, 121 insertions(+), 85 deletions(-) delete mode 100644 app/Notifications/ModerationThresholdReached.php diff --git a/app/Filament/Resources/Users/Schemas/UserInfolist.php b/app/Filament/Resources/Users/Schemas/UserInfolist.php index aa23b1f..99a8914 100644 --- a/app/Filament/Resources/Users/Schemas/UserInfolist.php +++ b/app/Filament/Resources/Users/Schemas/UserInfolist.php @@ -35,6 +35,13 @@ class UserInfolist ->label(__('admin.users.fields.account_verified_at')) ->dateTime() ->placeholder(__('admin.users.placeholders.not_verified')), + TextEntry::make('suspended_at') + ->label(__('admin.users.fields.suspended_at')) + ->dateTime() + ->placeholder(__('admin.users.placeholders.not_suspended')), + TextEntry::make('suspendedBy.name') + ->label(__('admin.users.fields.suspended_by')) + ->placeholder(__('admin.users.placeholders.empty')), ImageEntry::make('avatar_url') ->label(__('admin.users.fields.avatar')) ->disk('public') diff --git a/app/Filament/Resources/Users/Tables/UsersTable.php b/app/Filament/Resources/Users/Tables/UsersTable.php index 1332298..6a322fc 100644 --- a/app/Filament/Resources/Users/Tables/UsersTable.php +++ b/app/Filament/Resources/Users/Tables/UsersTable.php @@ -7,6 +7,8 @@ use App\Enums\PhysicalActivityLevel; use App\Enums\UserRole; use App\Enums\UserSex; use App\Enums\WeightGoal; +use App\Models\User; +use Filament\Actions\Action; use Filament\Actions\BulkActionGroup; use Filament\Actions\DeleteBulkAction; use Filament\Actions\EditAction; @@ -49,6 +51,11 @@ class UsersTable ->sortable() ->placeholder(__('admin.users.placeholders.not_verified')) ->toggleable(isToggledHiddenByDefault: true), + TextColumn::make('suspended_at') + ->label(__('admin.users.fields.suspended_at')) + ->dateTime() + ->sortable() + ->placeholder(__('admin.users.placeholders.not_suspended')), ImageColumn::make('avatar_url') ->label(__('admin.users.fields.avatar')) ->disk('public') @@ -144,10 +151,25 @@ class UsersTable TernaryFilter::make('account_verified_at') ->label(__('admin.users.filters.account_verified')) ->nullable(), + TernaryFilter::make('suspended_at') + ->label(__('admin.users.filters.suspended')) + ->nullable(), ]) ->recordActions([ ViewAction::make(), EditAction::make(), + Action::make('suspend') + ->label(__('admin.users.actions.suspend')) + ->color('danger') + ->requiresConfirmation() + ->visible(fn (User $record): bool => ! $record->isSuspended() && $record->getKey() !== auth()->id()) + ->action(fn (User $record): bool => self::suspendUser($record)), + Action::make('unsuspend') + ->label(__('admin.users.actions.unsuspend')) + ->color('success') + ->requiresConfirmation() + ->visible(fn (User $record): bool => $record->isSuspended()) + ->action(fn (User $record): bool => self::unsuspendUser($record)), ]) ->toolbarActions([ BulkActionGroup::make([ @@ -155,4 +177,30 @@ class UsersTable ]), ]); } + + private static function suspendUser(User $record): bool + { + if ($record->getKey() === auth()->id()) { + return false; + } + + $record->forceFill([ + 'suspended_at' => now(), + 'suspended_by' => auth()->id(), + 'suspended_reason' => __('admin.users.actions.suspend'), + ])->save(); + + return true; + } + + private static function unsuspendUser(User $record): bool + { + $record->forceFill([ + 'suspended_at' => null, + 'suspended_by' => null, + 'suspended_reason' => null, + ])->save(); + + return true; + } } diff --git a/app/Notifications/ModerationThresholdReached.php b/app/Notifications/ModerationThresholdReached.php deleted file mode 100644 index 5395dd3..0000000 --- a/app/Notifications/ModerationThresholdReached.php +++ /dev/null @@ -1,73 +0,0 @@ -payload() + [ - 'title' => __('api.notifications.moderation_threshold_title'), - 'body' => __('api.notifications.moderation_threshold_body', [ - 'type' => $this->targetLabel(), - 'count' => $this->moderationCase->reports_count, - ]), - 'targetTitle' => $this->targetTitle(), - ]; - } - - private function payload(): array - { - return [ - 'type' => 'moderation_case_threshold', - 'moderationCaseId' => $this->moderationCase->getKey(), - 'reportableType' => $this->targetType(), - 'reportableId' => $this->moderationCase->caseable_id, - 'reportsCount' => $this->moderationCase->reports_count, - ]; - } - - private function targetType(): string - { - return match ($this->moderationCase->caseable_type) { - MealPosts::class => 'meal_post', - User::class => 'user', - default => 'unknown', - }; - } - - private function targetLabel(): string - { - return match ($this->moderationCase->caseable_type) { - MealPosts::class => __('api.reports.targets.meal_post'), - User::class => __('api.reports.targets.user'), - default => __('api.reports.targets.unknown'), - }; - } - - private function targetTitle(): string - { - $target = $this->moderationCase->caseable; - - return match (true) { - $target instanceof MealPosts => $target->title, - $target instanceof User => $target->name, - default => (string) $this->moderationCase->caseable_id, - }; - } -} diff --git a/app/Providers/Filament/DashboardPanelProvider.php b/app/Providers/Filament/DashboardPanelProvider.php index 8ab8814..c5c7cb7 100644 --- a/app/Providers/Filament/DashboardPanelProvider.php +++ b/app/Providers/Filament/DashboardPanelProvider.php @@ -33,6 +33,7 @@ class DashboardPanelProvider extends PanelProvider ->path('dashboard') ->viteTheme('resources/css/filament/dashboard/theme.css') ->login() + ->databaseNotifications() ->colors([ 'primary' => Color::Amber, ]) diff --git a/app/Services/ModerationReportService.php b/app/Services/ModerationReportService.php index 256ec2d..495f10b 100644 --- a/app/Services/ModerationReportService.php +++ b/app/Services/ModerationReportService.php @@ -10,7 +10,7 @@ use App\Models\MealPosts; use App\Models\ModerationCase; use App\Models\Report; use App\Models\User; -use App\Notifications\ModerationThresholdReached; +use Filament\Notifications\Notification as FilamentNotification; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\QueryException; use Illuminate\Support\Facades\DB; @@ -112,7 +112,14 @@ class ModerationReportService User::query() ->whereIn('role', [UserRole::ADMIN->value, UserRole::MODERATOR->value]) ->get() - ->each(fn (User $moderator): mixed => $moderator->notify(new ModerationThresholdReached($freshCase))); + ->whenNotEmpty(fn ($moderators) => FilamentNotification::make() + ->title(__('api.notifications.moderation_threshold_title')) + ->body(__('api.notifications.moderation_threshold_body', [ + 'type' => $freshCase->targetLabel(), + 'count' => $freshCase->reports_count, + ])) + ->warning() + ->sendToDatabase($moderators, isEventDispatched: true)); } private function abortIfInvalidTarget(Model $reportable, User $reporter): void diff --git a/lang/en/admin.php b/lang/en/admin.php index 341d819..b9d94db 100644 --- a/lang/en/admin.php +++ b/lang/en/admin.php @@ -22,6 +22,8 @@ return [ 'role' => 'Role', 'email_verified_at' => 'Email verified at', 'account_verified_at' => 'Account verified at', + 'suspended_at' => 'Suspended at', + 'suspended_by' => 'Suspended by', 'avatar' => 'Avatar', 'password' => 'Password', 'bio' => 'Bio', @@ -47,9 +49,15 @@ return [ 'filters' => [ 'email_verified' => 'Email verified', 'account_verified' => 'Account verified', + 'suspended' => 'Suspended', + ], + 'actions' => [ + 'suspend' => 'Suspend', + 'unsuspend' => 'Reactivate', ], 'placeholders' => [ 'not_verified' => 'Not verified', + 'not_suspended' => 'Not suspended', 'empty' => '-', ], ], diff --git a/lang/fr/admin.php b/lang/fr/admin.php index f5d55fb..d836ab4 100644 --- a/lang/fr/admin.php +++ b/lang/fr/admin.php @@ -22,6 +22,8 @@ return [ 'role' => 'Rôle', 'email_verified_at' => 'Email vérifié le', 'account_verified_at' => 'Compte vérifié le', + 'suspended_at' => 'Suspendu le', + 'suspended_by' => 'Suspendu par', 'avatar' => 'Avatar', 'password' => 'Mot de passe', 'bio' => 'Bio', @@ -47,9 +49,15 @@ return [ 'filters' => [ 'email_verified' => 'Email vérifié', 'account_verified' => 'Compte vérifié', + 'suspended' => 'Suspendu', + ], + 'actions' => [ + 'suspend' => 'Suspendre', + 'unsuspend' => 'Réactiver', ], 'placeholders' => [ 'not_verified' => 'Non vérifié', + 'not_suspended' => 'Non suspendu', 'empty' => '-', ], ], diff --git a/tests/Feature/ModerationReportsTest.php b/tests/Feature/ModerationReportsTest.php index 40f5b61..8651122 100644 --- a/tests/Feature/ModerationReportsTest.php +++ b/tests/Feature/ModerationReportsTest.php @@ -6,9 +6,7 @@ use App\Models\MealPosts; use App\Models\ModerationCase; use App\Models\Report; use App\Models\User; -use App\Notifications\ModerationThresholdReached; use Illuminate\Foundation\Testing\RefreshDatabase; -use Illuminate\Support\Facades\Notification; use Laravel\Sanctum\Sanctum; uses(RefreshDatabase::class); @@ -99,8 +97,6 @@ it('reports users and prevents reporting own profile', function () { }); it('notifies moderators once when a report threshold is reached', function () { - Notification::fake(); - $admin = User::factory()->create(['role' => UserRole::ADMIN]); $moderator = User::factory()->create(['role' => UserRole::MODERATOR]); $regularUser = User::factory()->create(); @@ -119,11 +115,12 @@ it('notifies moderators once when a report threshold is reached', function () { expect($moderationCase->reports_count)->toBe(3) ->and($moderationCase->threshold_notified_at)->not->toBeNull(); - Notification::assertSentTo($admin, ModerationThresholdReached::class); - Notification::assertSentTo($moderator, ModerationThresholdReached::class); - Notification::assertNotSentTo($regularUser, ModerationThresholdReached::class); - Notification::assertSentTimes(ModerationThresholdReached::class, 2); - expect((new ModerationThresholdReached($moderationCase))->via($admin))->toBe(['database']); + expect($admin->notifications)->toHaveCount(1) + ->and($admin->notifications->first()->data['format'])->toBe('filament') + ->and($admin->notifications->first()->data['title'])->toBe(__('api.notifications.moderation_threshold_title')) + ->and($moderator->notifications)->toHaveCount(1) + ->and($moderator->notifications->first()->data['format'])->toBe('filament') + ->and($regularUser->notifications)->toHaveCount(0); Sanctum::actingAs(User::factory()->create()); @@ -131,5 +128,6 @@ it('notifies moderators once when a report threshold is reached', function () { 'reason' => ReportReason::MISLEADING->value, ])->assertCreated(); - Notification::assertSentTimes(ModerationThresholdReached::class, 2); + expect($admin->fresh()->notifications)->toHaveCount(1) + ->and($moderator->fresh()->notifications)->toHaveCount(1); }); diff --git a/tests/Feature/UserFilamentRoleTest.php b/tests/Feature/UserFilamentRoleTest.php index 5c79a3a..a2be004 100644 --- a/tests/Feature/UserFilamentRoleTest.php +++ b/tests/Feature/UserFilamentRoleTest.php @@ -10,6 +10,7 @@ use Filament\Forms\Components\Select; use Filament\Infolists\Components\TextEntry; use Filament\Tables\Columns\TextColumn; use Filament\Tables\Filters\SelectFilter; +use Filament\Tables\Filters\TernaryFilter; use Illuminate\Foundation\Testing\RefreshDatabase; use Livewire\Livewire; @@ -21,6 +22,37 @@ beforeEach(function () { Filament::setCurrentPanel(Filament::getPanel('dashboard')); }); +it('suspends and reactivates users from the filament users table', function () { + $admin = User::factory()->create([ + 'role' => UserRole::ADMIN, + ]); + $user = User::factory()->create(); + + $this->actingAs($admin); + + Livewire::test(ListUsers::class) + ->assertTableColumnExists( + 'suspended_at', + fn (TextColumn $column): bool => $column->getLabel() === 'Suspendu le', + ) + ->assertTableFilterExists( + 'suspended_at', + fn (TernaryFilter $filter): bool => $filter->getLabel() === 'Suspendu', + ) + ->callTableAction('suspend', $user); + + expect($user->fresh()->suspended_at)->not->toBeNull() + ->and($user->fresh()->suspended_by)->toBe($admin->id) + ->and($user->fresh()->suspended_reason)->toBe('Suspendre'); + + Livewire::test(ListUsers::class) + ->callTableAction('unsuspend', $user->fresh()); + + expect($user->fresh()->suspended_at)->toBeNull() + ->and($user->fresh()->suspended_by)->toBeNull() + ->and($user->fresh()->suspended_reason)->toBeNull(); +}); + it('exposes translated user roles in filament user screens', function () { $admin = User::factory()->create([ 'role' => UserRole::ADMIN,