diff --git a/app/Filament/Pages/ResourceAuditLog.php b/app/Filament/Pages/ResourceAuditLog.php index b467ad875f..c6d049d7ec 100644 --- a/app/Filament/Pages/ResourceAuditLog.php +++ b/app/Filament/Pages/ResourceAuditLog.php @@ -175,6 +175,7 @@ protected function getEventColor(string $event): string return match ($event) { 'approvedScreenshot' => 'success', 'changedScreenshotType' => 'info', + 'clearedScreenshots' => 'danger', 'created' => 'success', 'creditCreated' => 'success', 'creditDeleted' => 'danger', diff --git a/app/Filament/Resources/GameResource/RelationManagers/GameScreenshotsRelationManager.php b/app/Filament/Resources/GameResource/RelationManagers/GameScreenshotsRelationManager.php index 71dc2cec48..a30ab240b4 100644 --- a/app/Filament/Resources/GameResource/RelationManagers/GameScreenshotsRelationManager.php +++ b/app/Filament/Resources/GameResource/RelationManagers/GameScreenshotsRelationManager.php @@ -8,6 +8,7 @@ use App\Models\GameScreenshot; use App\Models\User; use App\Platform\Actions\AddGameScreenshotAction; +use App\Platform\Actions\ClearGameScreenshotsFromGamePageAction; use App\Platform\Enums\GameScreenshotStatus; use App\Platform\Enums\ScreenshotType; use App\Rules\DisallowAnimatedImageRule; @@ -15,7 +16,6 @@ use BackedEnum; use Filament\Actions\Action; use Filament\Actions\ActionGroup; -use Filament\Actions\DeleteAction; use Filament\Forms; use Filament\Notifications\Notification; use Filament\Resources\RelationManagers\RelationManager; @@ -64,6 +64,9 @@ public function table(Table $table): Table /** @var Game $game */ $game = $this->getOwnerRecord(); + /** @var User $user */ + $user = Auth::user(); + return $table ->headerActions([ Action::make('upload_screenshot') @@ -139,6 +142,34 @@ public function table(Table $table): Table ->title("{$label} uploaded successfully") ->send(); }), + + ActionGroup::make([ + Action::make('clear_screenshots_from_game_page') + ->label('Clear Screenshots') + ->icon('heroicon-o-trash') + ->color('danger') + ->requiresConfirmation() + ->modalHeading("Clear this game's screenshots?") + ->modalDescription('Published and pending screenshots will be moved to Rejected and the game will use placeholder images. You can restore them individually from the Rejected filter.') + ->modalSubmitActionLabel('Clear Screenshots') + ->action(function () use ($game): void { + $clearedCount = (new ClearGameScreenshotsFromGamePageAction())->execute($game); + + $this->logScreenshotActivity($game) + ->event('clearedScreenshots') + ->log("Cleared {$clearedCount} screenshot(s)"); + + Notification::make() + ->success() + ->title('Screenshots cleared') + ->send(); + }), + ]) + ->label('More') + ->icon('heroicon-m-ellipsis-vertical') + ->iconButton() + ->tooltip('More actions') + ->visible(fn (): bool => $user->can('clearScreenshots', $game) && $game->gameScreenshots()->exists()), ]) ->modifyQueryUsing(function (Builder $query) { /** @var Builder $query */ @@ -198,10 +229,7 @@ public function table(Table $table): Table GameScreenshotStatus::Rejected => 'danger', GameScreenshotStatus::Replaced => 'gray', }) - ->formatStateUsing(fn (GameScreenshotStatus $state): string => match ($state) { - GameScreenshotStatus::Approved => 'Published', - default => $state->name, - }) + ->formatStateUsing(fn (GameScreenshotStatus $state): string => $state->label()) ->sortable(), Tables\Columns\TextColumn::make('resolution') @@ -222,7 +250,6 @@ public function table(Table $table): Table Tables\Filters\SelectFilter::make('status') ->placeholder('Published + Pending') ->options(fn (): array => $this->getStatusFilterOptions()), - ]) ->emptyStateHeading('No screenshots yet') ->emptyStateDescription('Upload screenshots using the button above.') @@ -340,15 +367,17 @@ public function table(Table $table): Table ->log($oldStatus === GameScreenshotStatus::Approved ? 'Unpublished screenshot' : 'Rejected screenshot'); }), - DeleteAction::make() + Action::make('delete') + ->label('Delete') + ->icon('heroicon-o-trash') + ->color('danger') ->requiresConfirmation() - ->modalDescription(fn (GameScreenshot $record): string => $record->is_primary - ? 'This is a primary screenshot. The next published screenshot of this type will be promoted automatically, or the placeholder will be restored.' - : 'Are you sure you want to delete this screenshot?') - ->using(function (GameScreenshot $record) use ($game): void { + ->modalHeading('Permanently delete screenshot?') + ->modalDescription('This cannot be undone. The image and its history will be removed.') + ->visible(fn (GameScreenshot $record): bool => $record->status === GameScreenshotStatus::Rejected) + ->action(function (GameScreenshot $record) use ($game): void { $screenshotUrl = $record->media?->getUrl(); $type = $record->type->label(); - $wasPrimary = $record->is_primary; $record->media?->delete(); $record->delete(); @@ -357,7 +386,6 @@ public function table(Table $table): Table ->withProperty('attributes', [ 'screenshot' => $screenshotUrl, 'type' => $type, - 'was_primary' => $wasPrimary, ]) ->event('deletedScreenshot') ->log('Deleted screenshot'); @@ -385,21 +413,15 @@ private function getStatusFilterOptions(): array $counts = $game->gameScreenshots() ->when($selectedType, fn (Builder $query) => $query->where('type', $selectedType)) - ->whereIn('status', [ - GameScreenshotStatus::Approved->value, - GameScreenshotStatus::Pending->value, - GameScreenshotStatus::Rejected->value, - GameScreenshotStatus::Replaced->value, - ]) ->select('status', DB::raw('COUNT(*) as aggregate')) ->groupBy('status') ->pluck('aggregate', 'status'); return [ - GameScreenshotStatus::Approved->value => 'Published (' . ($counts[GameScreenshotStatus::Approved->value] ?? 0) . ')', - GameScreenshotStatus::Pending->value => 'Pending (' . ($counts[GameScreenshotStatus::Pending->value] ?? 0) . ')', - GameScreenshotStatus::Rejected->value => 'Rejected (' . ($counts[GameScreenshotStatus::Rejected->value] ?? 0) . ')', - GameScreenshotStatus::Replaced->value => 'Replaced (' . ($counts[GameScreenshotStatus::Replaced->value] ?? 0) . ')', + GameScreenshotStatus::Approved->value => GameScreenshotStatus::Approved->label() . ' (' . ($counts[GameScreenshotStatus::Approved->value] ?? 0) . ')', + GameScreenshotStatus::Pending->value => GameScreenshotStatus::Pending->label() . ' (' . ($counts[GameScreenshotStatus::Pending->value] ?? 0) . ')', + GameScreenshotStatus::Rejected->value => GameScreenshotStatus::Rejected->label() . ' (' . ($counts[GameScreenshotStatus::Rejected->value] ?? 0) . ')', + GameScreenshotStatus::Replaced->value => GameScreenshotStatus::Replaced->label() . ' (' . ($counts[GameScreenshotStatus::Replaced->value] ?? 0) . ')', ]; } diff --git a/app/Platform/Actions/ClearGameScreenshotsFromGamePageAction.php b/app/Platform/Actions/ClearGameScreenshotsFromGamePageAction.php new file mode 100644 index 0000000000..9ddc7c7a60 --- /dev/null +++ b/app/Platform/Actions/ClearGameScreenshotsFromGamePageAction.php @@ -0,0 +1,41 @@ +gameScreenshots() + ->whereIn('status', [ + GameScreenshotStatus::Approved->value, + GameScreenshotStatus::Pending->value, + ]) + ->pluck('id'); + + if ($affectedIds->isEmpty()) { + return 0; + } + + $count = GameScreenshot::whereKey($affectedIds) + ->update([ + 'is_primary' => false, + 'status' => GameScreenshotStatus::Rejected->value, + ]); + + $game->syncLegacyScreenshotFields(ScreenshotType::Title); + $game->syncLegacyScreenshotFields(ScreenshotType::Ingame); + + return $count; + }); + } +} diff --git a/app/Platform/Enums/GameScreenshotStatus.php b/app/Platform/Enums/GameScreenshotStatus.php index 97b5be5113..6dad8b56f3 100644 --- a/app/Platform/Enums/GameScreenshotStatus.php +++ b/app/Platform/Enums/GameScreenshotStatus.php @@ -17,4 +17,14 @@ enum GameScreenshotStatus: string /** This screenshot was previously the primary, but was replaced when a newer submission was approved. */ case Replaced = 'replaced'; + + public function label(): string + { + return match ($this) { + self::Approved => 'Published', + self::Pending => 'Pending', + self::Rejected => 'Rejected', + self::Replaced => 'Replaced', + }; + } } diff --git a/app/Platform/Services/GameScreenshotValidationService.php b/app/Platform/Services/GameScreenshotValidationService.php index 333cd719c1..1e8a49ac20 100644 --- a/app/Platform/Services/GameScreenshotValidationService.php +++ b/app/Platform/Services/GameScreenshotValidationService.php @@ -5,7 +5,6 @@ namespace App\Platform\Services; use App\Models\Game; -use App\Platform\Enums\GameScreenshotStatus; use App\Rules\DisallowAnimatedImageRule; use Illuminate\Http\UploadedFile; use Illuminate\Support\Facades\Validator; @@ -74,9 +73,10 @@ public function validateHash(UploadedFile $file, Game $game): string { $hash = sha1_file($file->getRealPath()); - // Reject duplicates based on SHA1 within this game's non-rejected screenshots. + // Reject duplicates based on SHA1 across all of this game's screenshots, + // including those previously rejected. A rejection is a review decision, + // so re-uploading the same image should not bypass the decision. $isDuplicate = $game->gameScreenshots() - ->where('status', '!=', GameScreenshotStatus::Rejected) ->whereHas('media', function ($query) use ($hash) { $query->where('custom_properties->sha1', $hash); }) diff --git a/app/Policies/GamePolicy.php b/app/Policies/GamePolicy.php index f8d4254b0a..c94540ddcf 100644 --- a/app/Policies/GamePolicy.php +++ b/app/Policies/GamePolicy.php @@ -267,6 +267,15 @@ public function updateField(User $user, Game $game, string $fieldName): bool return in_array($fieldName, $allowedFieldsForUser, true); } + public function clearScreenshots(User $user, Game $game): bool + { + return $user->hasAnyRole([ + Role::ADMINISTRATOR, + Role::GAME_EDITOR, + Role::MEDIA_EDITOR, + ]); + } + public static function canDeveloperJuniorUpdateGame(User $user, Game $game): bool { // If the user has a DEVELOPER_JUNIOR role, they need to have a claim diff --git a/lang/en/filament.php b/lang/en/filament.php index 96073bf5da..8c476e2cca 100755 --- a/lang/en/filament.php +++ b/lang/en/filament.php @@ -16,6 +16,7 @@ 'achievementsAssignedToGroup' => 'Achievements assigned to group', 'approvedScreenshot' => 'Approved screenshot', 'changedScreenshotType' => 'Changed screenshot type', + 'clearedScreenshots' => 'Cleared screenshots', 'created' => 'Created', 'creditCreated' => 'Credit added', 'creditDeleted' => 'Credit removed', diff --git a/tests/Feature/Filament/RelationManagers/GameScreenshotsRelationManagerTest.php b/tests/Feature/Filament/RelationManagers/GameScreenshotsRelationManagerTest.php index bcedbd729c..1c68c39c3d 100644 --- a/tests/Feature/Filament/RelationManagers/GameScreenshotsRelationManagerTest.php +++ b/tests/Feature/Filament/RelationManagers/GameScreenshotsRelationManagerTest.php @@ -5,6 +5,7 @@ use App\Models\Game; use App\Models\GameScreenshot; use App\Models\System; +use App\Platform\Actions\ClearGameScreenshotsFromGamePageAction; use App\Platform\Enums\GameScreenshotStatus; use App\Platform\Enums\ScreenshotType; use Illuminate\Foundation\Testing\RefreshDatabase; @@ -97,7 +98,7 @@ function createScreenshotMedia(Game $game, array $customProperties = []): Media expect($fresh->status)->toEqual(GameScreenshotStatus::Approved); }); -it('given a primary screenshot is deleted, automatically promotes the next approved screenshot to primary', function () { +it('given a primary screenshot is permanently deleted, automatically promotes the next approved screenshot to primary', function () { // ARRANGE $game = Game::factory()->create([ 'system_id' => System::factory(), @@ -119,16 +120,16 @@ function createScreenshotMedia(Game $game, array $customProperties = []): Media ]); // ACT - // ... simulate the delete action (media cleanup + record deletion) ... - $primary->media?->delete(); + $media = $primary->media; $primary->delete(); + $media?->delete(); // ASSERT // ... observer promotes the next approved screenshot ... expect($next->fresh()->is_primary)->toBeTrue(); }); -it('given the last screenshot is deleted, resets the legacy column to the placeholder image', function () { +it('given the last screenshot is permanently deleted, resets the legacy column to the placeholder image', function () { // ARRANGE $game = Game::factory()->create([ 'system_id' => System::factory(), @@ -142,14 +143,15 @@ function createScreenshotMedia(Game $game, array $customProperties = []): Media ]); // ACT - $screenshot->media?->delete(); + $media = $screenshot->media; $screenshot->delete(); + $media?->delete(); // ASSERT expect($game->fresh()->image_ingame_asset_path)->toEqual('/Images/000002.png'); }); -it('given a screenshot is deleted, also cleans up the accompanying Spatie Media record', function () { +it('given a screenshot is permanently deleted, also cleans up the accompanying Spatie Media record', function () { // ARRANGE $game = Game::factory()->create(['system_id' => System::factory()]); @@ -162,15 +164,81 @@ function createScreenshotMedia(Game $game, array $customProperties = []): Media ]); // ACT - // ... simulate the delete action ... - $screenshot->media?->delete(); + $media = $screenshot->media; $screenshot->delete(); + $media?->delete(); // ASSERT expect(Media::find($mediaId))->toBeNull(); expect(GameScreenshot::find($screenshot->id))->toBeNull(); }); +it('given screenshots are cleared from the game page, moves published and pending rows to Rejected and resets legacy paths', function () { + // ARRANGE + $game = Game::factory()->create([ + 'system_id' => System::factory(), + 'image_title_asset_path' => '/Images/088888.png', + 'image_ingame_asset_path' => '/Images/099999.png', + ]); + + $titleMedia = createScreenshotMedia($game, ['legacy_path' => '/Images/011111.png']); + $ingameMedia = createScreenshotMedia($game, ['legacy_path' => '/Images/022222.png']); + $pendingMedia = createScreenshotMedia($game, ['legacy_path' => '/Images/044444.png']); + $rejectedMedia = createScreenshotMedia($game, ['legacy_path' => '/Images/033333.png']); + $replacedMedia = createScreenshotMedia($game, ['legacy_path' => '/Images/055555.png']); + + $title = GameScreenshot::factory()->for($game)->title()->primary()->create([ + 'media_id' => $titleMedia->id, + ]); + + $ingame = GameScreenshot::factory()->for($game)->ingame()->primary()->create([ + 'media_id' => $ingameMedia->id, + ]); + + $pending = GameScreenshot::factory()->for($game)->ingame()->pending()->create([ + 'media_id' => $pendingMedia->id, + 'is_primary' => false, + ]); + + $rejected = GameScreenshot::factory()->for($game)->ingame()->rejected()->create([ + 'media_id' => $rejectedMedia->id, + ]); + + $replaced = GameScreenshot::factory()->for($game)->ingame()->create([ + 'media_id' => $replacedMedia->id, + 'is_primary' => false, + 'status' => GameScreenshotStatus::Replaced, + ]); + + // ACT + $clearedCount = (new ClearGameScreenshotsFromGamePageAction())->execute($game); + + // ASSERT + // ... only the previously approved (2) + pending (1) rows count as cleared ... + expect($clearedCount)->toEqual(3); + expect(GameScreenshot::where('game_id', $game->id)->count())->toEqual(5); + + expect($title->fresh()->status)->toEqual(GameScreenshotStatus::Rejected); + expect($title->fresh()->is_primary)->toBeFalse(); + expect($ingame->fresh()->status)->toEqual(GameScreenshotStatus::Rejected); + expect($ingame->fresh()->is_primary)->toBeFalse(); + expect($pending->fresh()->status)->toEqual(GameScreenshotStatus::Rejected); + expect($pending->fresh()->is_primary)->toBeFalse(); + + // ... already-rejected and replaced rows are left alone ... + expect($rejected->fresh()->status)->toEqual(GameScreenshotStatus::Rejected); + expect($replaced->fresh()->status)->toEqual(GameScreenshotStatus::Replaced); + + // ... media survives so the rows are recoverable via re-approve ... + expect(Media::find($titleMedia->id))->not->toBeNull(); + expect(Media::find($ingameMedia->id))->not->toBeNull(); + expect(Media::find($pendingMedia->id))->not->toBeNull(); + + $freshGame = $game->fresh(); + expect($freshGame->image_title_asset_path)->toEqual(Game::PLACEHOLDER_IMAGE_PATH); + expect($freshGame->image_ingame_asset_path)->toEqual(Game::PLACEHOLDER_IMAGE_PATH); +}); + it('given a screenshot is uploaded as the first of its type, auto-promotes it to the primary of that type', function () { // ARRANGE $game = Game::factory()->create([ diff --git a/tests/Feature/Platform/Actions/AddGameScreenshotActionTest.php b/tests/Feature/Platform/Actions/AddGameScreenshotActionTest.php index 708c46ff98..ad6f05b23f 100644 --- a/tests/Feature/Platform/Actions/AddGameScreenshotActionTest.php +++ b/tests/Feature/Platform/Actions/AddGameScreenshotActionTest.php @@ -89,6 +89,27 @@ $action->execute($game->fresh(), $duplicate, ScreenshotType::Ingame); })->throws(ValidationException::class); +it('rejects re-uploading an image that matches a previously rejected screenshot', function () { + // ARRANGE + $game = Game::factory()->create(['system_id' => System::factory()]); + $action = new AddGameScreenshotAction(); + + $source = UploadedFile::fake()->image('screenshot.png', 256, 224); + $sourceContent = file_get_contents($source->getRealPath()); + + $original = $action->execute($game, $source, ScreenshotType::Ingame); + $original->update([ + 'is_primary' => false, + 'status' => GameScreenshotStatus::Rejected, + ]); + + $duplicate = UploadedFile::fake()->image('duplicate.png', 256, 224); + file_put_contents($duplicate->getRealPath(), $sourceContent); + + // ASSERT + $action->execute($game->fresh(), $duplicate, ScreenshotType::Ingame); +})->throws(ValidationException::class); + it('enforces a cap of 20 approved ingame screenshots', function () { // ARRANGE $game = Game::factory()->create(['system_id' => System::factory()]);