feat: notification on filament dashboard
This commit is contained in:
parent
1c472e46db
commit
071d147888
|
|
@ -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')
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,73 +0,0 @@
|
|||
<?php
|
||||
|
||||
namespace App\Notifications;
|
||||
|
||||
use App\Models\MealPosts;
|
||||
use App\Models\ModerationCase;
|
||||
use App\Models\User;
|
||||
use Illuminate\Bus\Queueable;
|
||||
use Illuminate\Notifications\Notification;
|
||||
|
||||
class ModerationThresholdReached extends Notification
|
||||
{
|
||||
use Queueable;
|
||||
|
||||
public function __construct(public ModerationCase $moderationCase) {}
|
||||
|
||||
public function via(object $notifiable): array
|
||||
{
|
||||
return ['database'];
|
||||
}
|
||||
|
||||
public function toArray(object $notifiable): array
|
||||
{
|
||||
return $this->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,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
@ -33,6 +33,7 @@ class DashboardPanelProvider extends PanelProvider
|
|||
->path('dashboard')
|
||||
->viteTheme('resources/css/filament/dashboard/theme.css')
|
||||
->login()
|
||||
->databaseNotifications()
|
||||
->colors([
|
||||
'primary' => Color::Amber,
|
||||
])
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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' => '-',
|
||||
],
|
||||
],
|
||||
|
|
|
|||
|
|
@ -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' => '-',
|
||||
],
|
||||
],
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
Loading…
Reference in New Issue