From 2d819d8b43488b73cf77149b312b8b323e335e0c Mon Sep 17 00:00:00 2001 From: Wes Copeland Date: Sun, 3 May 2026 18:32:40 -0400 Subject: [PATCH 1/5] feat(manage/media): let some roles wipe all game screenshots --- app/Filament/Pages/ResourceAuditLog.php | 1 + .../GameScreenshotsRelationManager.php | 82 ++++++++++++++-- app/Models/GameScreenshot.php | 2 + ...ClearGameScreenshotsFromGamePageAction.php | 36 +++++++ .../GameScreenshotValidationService.php | 3 + app/Policies/GamePolicy.php | 9 ++ ...d_deleted_at_to_game_screenshots_table.php | 23 +++++ lang/en/filament.php | 1 + .../GameScreenshotsRelationManagerTest.php | 95 ++++++++++++++++--- .../Actions/AddGameScreenshotActionTest.php | 18 ++++ 10 files changed, 251 insertions(+), 19 deletions(-) create mode 100644 app/Platform/Actions/ClearGameScreenshotsFromGamePageAction.php create mode 100644 database/migrations/2026_05_03_000000_add_deleted_at_to_game_screenshots_table.php 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..4f8649c022 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; @@ -16,6 +17,7 @@ use Filament\Actions\Action; use Filament\Actions\ActionGroup; use Filament\Actions\DeleteAction; +use Filament\Actions\RestoreAction; use Filament\Forms; use Filament\Notifications\Notification; use Filament\Resources\RelationManagers\RelationManager; @@ -64,6 +66,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 +144,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('All screenshots will be moved to the trash and the game will use placeholder images. You can restore them later.') + ->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 */ @@ -146,7 +179,7 @@ public function table(Table $table): Table ->orderByType() ->orderBy('order_column'); - if (!$this->shouldShowArchivedScreenshots()) { + if (!$this->shouldShowRejectedOrReplacedScreenshots()) { $query->whereNotIn('status', [ GameScreenshotStatus::Rejected->value, GameScreenshotStatus::Replaced->value, @@ -212,6 +245,11 @@ public function table(Table $table): Table ) ->color(fn (GameScreenshot $record): ?string => $record->has_wrong_resolution ? 'danger' : null) ->icon(fn (GameScreenshot $record): ?string => $record->has_wrong_resolution ? 'heroicon-o-exclamation-triangle' : null), + + Tables\Columns\TextColumn::make('deleted_at') + ->dateTime() + ->sortable() + ->toggleable(isToggledHiddenByDefault: true), ]) ->filters([ Tables\Filters\SelectFilter::make('type') @@ -223,6 +261,11 @@ public function table(Table $table): Table ->placeholder('Published + Pending') ->options(fn (): array => $this->getStatusFilterOptions()), + Tables\Filters\TrashedFilter::make() + ->label('Deleted screenshots') + ->placeholder('Current screenshots') + ->trueLabel('All screenshots') + ->falseLabel('Deleted screenshots'), ]) ->emptyStateHeading('No screenshots yet') ->emptyStateDescription('Upload screenshots using the button above.') @@ -234,7 +277,7 @@ public function table(Table $table): Table ->iconButton() ->tooltip('Set as Primary') ->requiresConfirmation() - ->hidden(fn (GameScreenshot $record): bool => $record->is_primary) + ->hidden(fn (GameScreenshot $record): bool => $record->is_primary || $record->trashed()) ->action(function (GameScreenshot $record) use ($game): void { DB::transaction(function () use ($record) { // Demote the current primary of the same type via Eloquent @@ -269,7 +312,7 @@ public function table(Table $table): Table ->label('Approve') ->icon('heroicon-o-check-circle') ->color('success') - ->visible(fn (GameScreenshot $record): bool => $record->status !== GameScreenshotStatus::Approved) + ->visible(fn (GameScreenshot $record): bool => !$record->trashed() && $record->status !== GameScreenshotStatus::Approved) ->action(function (GameScreenshot $record) use ($game): void { $oldStatus = $record->status; @@ -288,6 +331,7 @@ public function table(Table $table): Table Action::make('change_type') ->label('Change Type') ->icon('heroicon-o-tag') + ->visible(fn (GameScreenshot $record): bool => !$record->trashed()) ->schema([ Forms\Components\Select::make('type') ->label('Type') @@ -320,7 +364,7 @@ public function table(Table $table): Table ->icon('heroicon-o-x-circle') ->color('danger') ->requiresConfirmation() - ->visible(fn (GameScreenshot $record): bool => !$record->is_primary && $record->status !== GameScreenshotStatus::Rejected) + ->visible(fn (GameScreenshot $record): bool => !$record->trashed() && !$record->is_primary && $record->status !== GameScreenshotStatus::Rejected) ->action(function (GameScreenshot $record) use ($game): void { $oldStatus = $record->status; @@ -344,14 +388,15 @@ public function table(Table $table): Table ->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?') + : 'Are you sure you want to permanently delete this screenshot?') ->using(function (GameScreenshot $record) use ($game): void { $screenshotUrl = $record->media?->getUrl(); + $media = $record->media; $type = $record->type->label(); $wasPrimary = $record->is_primary; - $record->media?->delete(); - $record->delete(); + $record->forceDelete(); + $media?->delete(); $this->logScreenshotActivity($game) ->withProperty('attributes', [ @@ -362,6 +407,8 @@ public function table(Table $table): Table ->event('deletedScreenshot') ->log('Deleted screenshot'); }), + + RestoreAction::make(), ]), ]); } @@ -382,8 +429,11 @@ private function getStatusFilterOptions(): array $game = $this->getOwnerRecord(); $selectedType = data_get($this->getTableFilterState('type'), 'value'); + $selectedTrashed = $this->getSelectedTrashedFilter(); $counts = $game->gameScreenshots() + ->when($selectedTrashed === true, fn (Builder $query) => $query->withTrashed()) + ->when($selectedTrashed === false, fn (Builder $query) => $query->onlyTrashed()) ->when($selectedType, fn (Builder $query) => $query->where('type', $selectedType)) ->whereIn('status', [ GameScreenshotStatus::Approved->value, @@ -475,8 +525,12 @@ private function getScreenshotHelperText(): ?string return $text; } - private function shouldShowArchivedScreenshots(): bool + private function shouldShowRejectedOrReplacedScreenshots(): bool { + if ($this->getSelectedTrashedFilter() === false) { + return true; + } + $selectedStatus = data_get($this->getTableFilterState('status'), 'value'); return in_array($selectedStatus, [ @@ -484,4 +538,16 @@ private function shouldShowArchivedScreenshots(): bool GameScreenshotStatus::Replaced->value, ], true); } + + /** + * Normalize the trashed filter state to a tri-state value. + * Filament serializes booleans as '1'/'0' through the form layer, + * so === true / === false comparisons against the raw value silently fail. + */ + private function getSelectedTrashedFilter(): ?bool + { + $value = data_get($this->getTableFilterState('trashed'), 'value'); + + return filter_var($value, FILTER_VALIDATE_BOOLEAN, FILTER_NULL_ON_FAILURE); + } } diff --git a/app/Models/GameScreenshot.php b/app/Models/GameScreenshot.php index a58db8bc63..eaeaaba838 100644 --- a/app/Models/GameScreenshot.php +++ b/app/Models/GameScreenshot.php @@ -15,6 +15,7 @@ use Illuminate\Database\Eloquent\Casts\Attribute; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Relations\BelongsTo; +use Illuminate\Database\Eloquent\SoftDeletes; use Spatie\EloquentSortable\SortableTrait; use Spatie\MediaLibrary\MediaCollections\Models\Media; @@ -22,6 +23,7 @@ class GameScreenshot extends BaseModel { /** @use HasFactory */ use HasFactory; + use SoftDeletes; use SortableTrait; protected $table = 'game_screenshots'; diff --git a/app/Platform/Actions/ClearGameScreenshotsFromGamePageAction.php b/app/Platform/Actions/ClearGameScreenshotsFromGamePageAction.php new file mode 100644 index 0000000000..911cac2b72 --- /dev/null +++ b/app/Platform/Actions/ClearGameScreenshotsFromGamePageAction.php @@ -0,0 +1,36 @@ +gameScreenshots() + ->pluck('id'); + + if ($screenshotIds->isEmpty()) { + return 0; + } + + GameScreenshot::whereKey($screenshotIds) + ->update(['is_primary' => false]); + + $deletedCount = GameScreenshot::whereKey($screenshotIds) + ->delete(); + + $game->syncLegacyScreenshotFields(ScreenshotType::Title); + $game->syncLegacyScreenshotFields(ScreenshotType::Ingame); + + return $deletedCount; + }); + } +} diff --git a/app/Platform/Services/GameScreenshotValidationService.php b/app/Platform/Services/GameScreenshotValidationService.php index 333cd719c1..710aeb63be 100644 --- a/app/Platform/Services/GameScreenshotValidationService.php +++ b/app/Platform/Services/GameScreenshotValidationService.php @@ -75,7 +75,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. + // Trashed screenshots are included so that re-uploading a cleared screenshot + // can't bypass the check and create a collision when the original is restored. $isDuplicate = $game->gameScreenshots() + ->withTrashed() ->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 c69c2e2603..d8b011e6bc 100644 --- a/app/Policies/GamePolicy.php +++ b/app/Policies/GamePolicy.php @@ -262,6 +262,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/database/migrations/2026_05_03_000000_add_deleted_at_to_game_screenshots_table.php b/database/migrations/2026_05_03_000000_add_deleted_at_to_game_screenshots_table.php new file mode 100644 index 0000000000..86af38ab74 --- /dev/null +++ b/database/migrations/2026_05_03_000000_add_deleted_at_to_game_screenshots_table.php @@ -0,0 +1,23 @@ +softDeletes()->after('updated_at'); + }); + } + + public function down(): void + { + Schema::table('game_screenshots', function (Blueprint $table) { + $table->dropSoftDeletes(); + }); + } +}; 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..3827b05592 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,17 @@ function createScreenshotMedia(Game $game, array $customProperties = []): Media ]); // ACT - // ... simulate the delete action (media cleanup + record deletion) ... - $primary->media?->delete(); - $primary->delete(); + // ... simulate the destructive row delete action ... + $media = $primary->media; + $primary->forceDelete(); + $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 +144,15 @@ function createScreenshotMedia(Game $game, array $customProperties = []): Media ]); // ACT - $screenshot->media?->delete(); - $screenshot->delete(); + $media = $screenshot->media; + $screenshot->forceDelete(); + $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 +165,85 @@ function createScreenshotMedia(Game $game, array $customProperties = []): Media ]); // ACT - // ... simulate the delete action ... - $screenshot->media?->delete(); - $screenshot->delete(); + // ... simulate the destructive row delete action ... + $media = $screenshot->media; + $screenshot->forceDelete(); + $media?->delete(); // ASSERT expect(Media::find($mediaId))->toBeNull(); expect(GameScreenshot::find($screenshot->id))->toBeNull(); }); +it('given screenshots are cleared from the game page, soft deletes every screenshot and preserves media', 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']); + $rejectedMedia = createScreenshotMedia($game, ['legacy_path' => '/Images/033333.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, + ]); + + $rejected = GameScreenshot::factory()->for($game)->ingame()->rejected()->create([ + 'media_id' => $rejectedMedia->id, + ]); + + // ACT + $clearedCount = (new ClearGameScreenshotsFromGamePageAction())->execute($game); + + // ASSERT + expect($clearedCount)->toEqual(3); + expect(GameScreenshot::where('game_id', $game->id)->count())->toEqual(0); + expect(GameScreenshot::withTrashed()->where('game_id', $game->id)->count())->toEqual(3); + + $this->assertSoftDeleted($title); + $this->assertSoftDeleted($ingame); + $this->assertSoftDeleted($rejected); + + expect(Media::find($titleMedia->id))->not->toBeNull(); + expect(Media::find($ingameMedia->id))->not->toBeNull(); + expect(Media::find($rejectedMedia->id))->not->toBeNull(); + + expect(GameScreenshot::withTrashed()->where('game_id', $game->id)->where('is_primary', true)->count())->toEqual(0); + + $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); + expect(GameScreenshot::withTrashed()->findOrFail($rejected->id)->status)->toEqual(GameScreenshotStatus::Rejected); +}); + +it('given a cleared screenshot is restored, restores it with its prior status', function () { + // ARRANGE + $game = Game::factory()->create(['system_id' => System::factory()]); + $media = createScreenshotMedia($game); + + $screenshot = GameScreenshot::factory()->for($game)->ingame()->primary()->create([ + 'media_id' => $media->id, + ]); + + (new ClearGameScreenshotsFromGamePageAction())->execute($game); + + // ACT + $trashedScreenshot = GameScreenshot::onlyTrashed()->findOrFail($screenshot->id); + $trashedScreenshot->restore(); + + // ASSERT + $fresh = GameScreenshot::findOrFail($screenshot->id); + expect($fresh->status)->toEqual(GameScreenshotStatus::Approved); + expect($fresh->is_primary)->toBeFalse(); +}); + 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..f42f776391 100644 --- a/tests/Feature/Platform/Actions/AddGameScreenshotActionTest.php +++ b/tests/Feature/Platform/Actions/AddGameScreenshotActionTest.php @@ -89,6 +89,24 @@ $action->execute($game->fresh(), $duplicate, ScreenshotType::Ingame); })->throws(ValidationException::class); +it('rejects re-uploading an image that matches a soft-deleted 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->delete(); + + $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()]); From 104e4ff136df5dbdf8adf9da613577f697ce6069 Mon Sep 17 00:00:00 2001 From: Wes Copeland Date: Wed, 6 May 2026 20:01:18 -0400 Subject: [PATCH 2/5] fix: address feedback --- .../GameScreenshotsRelationManager.php | 94 ++++++------------- app/Platform/Enums/GameScreenshotStatus.php | 10 ++ .../GameScreenshotsRelationManagerTest.php | 2 - 3 files changed, 40 insertions(+), 66 deletions(-) diff --git a/app/Filament/Resources/GameResource/RelationManagers/GameScreenshotsRelationManager.php b/app/Filament/Resources/GameResource/RelationManagers/GameScreenshotsRelationManager.php index 4f8649c022..2ade130356 100644 --- a/app/Filament/Resources/GameResource/RelationManagers/GameScreenshotsRelationManager.php +++ b/app/Filament/Resources/GameResource/RelationManagers/GameScreenshotsRelationManager.php @@ -16,7 +16,6 @@ use BackedEnum; use Filament\Actions\Action; use Filament\Actions\ActionGroup; -use Filament\Actions\DeleteAction; use Filament\Actions\RestoreAction; use Filament\Forms; use Filament\Notifications\Notification; @@ -34,6 +33,8 @@ class GameScreenshotsRelationManager extends RelationManager { + private const DELETED_STATUS_VALUE = 'deleted'; + protected static string $relationship = 'gameScreenshots'; protected static ?string $recordTitleAttribute = 'id'; protected static string|BackedEnum|null $icon = 'heroicon-s-camera'; @@ -231,10 +232,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') @@ -259,13 +257,20 @@ public function table(Table $table): Table Tables\Filters\SelectFilter::make('status') ->placeholder('Published + Pending') - ->options(fn (): array => $this->getStatusFilterOptions()), + ->options(fn (): array => $this->getStatusFilterOptions()) + ->query(function (Builder $query, array $data): Builder { + $value = $data['value'] ?? null; + + if ($value === self::DELETED_STATUS_VALUE) { + return $query->onlyTrashed(); + } - Tables\Filters\TrashedFilter::make() - ->label('Deleted screenshots') - ->placeholder('Current screenshots') - ->trueLabel('All screenshots') - ->falseLabel('Deleted screenshots'), + if (filled($value)) { + return $query->where('status', $value); + } + + return $query; + }), ]) ->emptyStateHeading('No screenshots yet') ->emptyStateDescription('Upload screenshots using the button above.') @@ -384,30 +389,6 @@ public function table(Table $table): Table ->log($oldStatus === GameScreenshotStatus::Approved ? 'Unpublished screenshot' : 'Rejected screenshot'); }), - DeleteAction::make() - ->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 permanently delete this screenshot?') - ->using(function (GameScreenshot $record) use ($game): void { - $screenshotUrl = $record->media?->getUrl(); - $media = $record->media; - $type = $record->type->label(); - $wasPrimary = $record->is_primary; - - $record->forceDelete(); - $media?->delete(); - - $this->logScreenshotActivity($game) - ->withProperty('attributes', [ - 'screenshot' => $screenshotUrl, - 'type' => $type, - 'was_primary' => $wasPrimary, - ]) - ->event('deletedScreenshot') - ->log('Deleted screenshot'); - }), - RestoreAction::make(), ]), ]); @@ -429,27 +410,24 @@ private function getStatusFilterOptions(): array $game = $this->getOwnerRecord(); $selectedType = data_get($this->getTableFilterState('type'), 'value'); - $selectedTrashed = $this->getSelectedTrashedFilter(); $counts = $game->gameScreenshots() - ->when($selectedTrashed === true, fn (Builder $query) => $query->withTrashed()) - ->when($selectedTrashed === false, fn (Builder $query) => $query->onlyTrashed()) ->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'); + $deletedCount = $game->gameScreenshots() + ->onlyTrashed() + ->when($selectedType, fn (Builder $query) => $query->where('type', $selectedType)) + ->count(); + 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) . ')', + self::DELETED_STATUS_VALUE => 'Deleted (' . $deletedCount . ')', ]; } @@ -527,27 +505,15 @@ private function getScreenshotHelperText(): ?string private function shouldShowRejectedOrReplacedScreenshots(): bool { - if ($this->getSelectedTrashedFilter() === false) { - return true; - } - $selectedStatus = data_get($this->getTableFilterState('status'), 'value'); + // 'deleted' is included because soft-deleted rows retain their prior status, + // and the trashed view needs to show rejected/replaced rows so they remain + // undoable / available for restore decisions. return in_array($selectedStatus, [ GameScreenshotStatus::Rejected->value, GameScreenshotStatus::Replaced->value, + self::DELETED_STATUS_VALUE, ], true); } - - /** - * Normalize the trashed filter state to a tri-state value. - * Filament serializes booleans as '1'/'0' through the form layer, - * so === true / === false comparisons against the raw value silently fail. - */ - private function getSelectedTrashedFilter(): ?bool - { - $value = data_get($this->getTableFilterState('trashed'), 'value'); - - return filter_var($value, FILTER_VALIDATE_BOOLEAN, FILTER_NULL_ON_FAILURE); - } } 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/tests/Feature/Filament/RelationManagers/GameScreenshotsRelationManagerTest.php b/tests/Feature/Filament/RelationManagers/GameScreenshotsRelationManagerTest.php index 3827b05592..3fa565f3b4 100644 --- a/tests/Feature/Filament/RelationManagers/GameScreenshotsRelationManagerTest.php +++ b/tests/Feature/Filament/RelationManagers/GameScreenshotsRelationManagerTest.php @@ -120,7 +120,6 @@ function createScreenshotMedia(Game $game, array $customProperties = []): Media ]); // ACT - // ... simulate the destructive row delete action ... $media = $primary->media; $primary->forceDelete(); $media?->delete(); @@ -165,7 +164,6 @@ function createScreenshotMedia(Game $game, array $customProperties = []): Media ]); // ACT - // ... simulate the destructive row delete action ... $media = $screenshot->media; $screenshot->forceDelete(); $media?->delete(); From 17f513634e9c59e875fa6fb78ed7cac281449456 Mon Sep 17 00:00:00 2001 From: Wes Copeland Date: Wed, 6 May 2026 20:07:55 -0400 Subject: [PATCH 3/5] fix: phpstan --- .../RelationManagers/GameScreenshotsRelationManager.php | 1 + 1 file changed, 1 insertion(+) diff --git a/app/Filament/Resources/GameResource/RelationManagers/GameScreenshotsRelationManager.php b/app/Filament/Resources/GameResource/RelationManagers/GameScreenshotsRelationManager.php index 2ade130356..908577ed02 100644 --- a/app/Filament/Resources/GameResource/RelationManagers/GameScreenshotsRelationManager.php +++ b/app/Filament/Resources/GameResource/RelationManagers/GameScreenshotsRelationManager.php @@ -259,6 +259,7 @@ public function table(Table $table): Table ->placeholder('Published + Pending') ->options(fn (): array => $this->getStatusFilterOptions()) ->query(function (Builder $query, array $data): Builder { + /** @var Builder $query */ $value = $data['value'] ?? null; if ($value === self::DELETED_STATUS_VALUE) { From 8579ed5756e98ea7afc55ec75fbbf8ceaed04fc7 Mon Sep 17 00:00:00 2001 From: Wes Copeland Date: Thu, 7 May 2026 17:26:54 -0400 Subject: [PATCH 4/5] fix: address feedback --- .../GameScreenshotsRelationManager.php | 69 ++++++++----------- app/Models/GameScreenshot.php | 2 - ...ClearGameScreenshotsFromGamePageAction.php | 21 +++--- .../GameScreenshotValidationService.php | 9 +-- ...d_deleted_at_to_game_screenshots_table.php | 23 ------- resources/js/types/generated.d.ts | 7 +- .../GameScreenshotsRelationManagerTest.php | 65 +++++++++-------- .../Actions/AddGameScreenshotActionTest.php | 7 +- 8 files changed, 85 insertions(+), 118 deletions(-) delete mode 100644 database/migrations/2026_05_03_000000_add_deleted_at_to_game_screenshots_table.php diff --git a/app/Filament/Resources/GameResource/RelationManagers/GameScreenshotsRelationManager.php b/app/Filament/Resources/GameResource/RelationManagers/GameScreenshotsRelationManager.php index 908577ed02..1bdc8cbeda 100644 --- a/app/Filament/Resources/GameResource/RelationManagers/GameScreenshotsRelationManager.php +++ b/app/Filament/Resources/GameResource/RelationManagers/GameScreenshotsRelationManager.php @@ -16,7 +16,7 @@ use BackedEnum; use Filament\Actions\Action; use Filament\Actions\ActionGroup; -use Filament\Actions\RestoreAction; +use Filament\Actions\DeleteAction; use Filament\Forms; use Filament\Notifications\Notification; use Filament\Resources\RelationManagers\RelationManager; @@ -33,8 +33,6 @@ class GameScreenshotsRelationManager extends RelationManager { - private const DELETED_STATUS_VALUE = 'deleted'; - protected static string $relationship = 'gameScreenshots'; protected static ?string $recordTitleAttribute = 'id'; protected static string|BackedEnum|null $icon = 'heroicon-s-camera'; @@ -153,7 +151,7 @@ public function table(Table $table): Table ->color('danger') ->requiresConfirmation() ->modalHeading("Clear this game's screenshots?") - ->modalDescription('All screenshots will be moved to the trash and the game will use placeholder images. You can restore them later.') + ->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); @@ -180,7 +178,7 @@ public function table(Table $table): Table ->orderByType() ->orderBy('order_column'); - if (!$this->shouldShowRejectedOrReplacedScreenshots()) { + if (!$this->shouldShowArchivedScreenshots()) { $query->whereNotIn('status', [ GameScreenshotStatus::Rejected->value, GameScreenshotStatus::Replaced->value, @@ -243,11 +241,6 @@ public function table(Table $table): Table ) ->color(fn (GameScreenshot $record): ?string => $record->has_wrong_resolution ? 'danger' : null) ->icon(fn (GameScreenshot $record): ?string => $record->has_wrong_resolution ? 'heroicon-o-exclamation-triangle' : null), - - Tables\Columns\TextColumn::make('deleted_at') - ->dateTime() - ->sortable() - ->toggleable(isToggledHiddenByDefault: true), ]) ->filters([ Tables\Filters\SelectFilter::make('type') @@ -257,21 +250,7 @@ public function table(Table $table): Table Tables\Filters\SelectFilter::make('status') ->placeholder('Published + Pending') - ->options(fn (): array => $this->getStatusFilterOptions()) - ->query(function (Builder $query, array $data): Builder { - /** @var Builder $query */ - $value = $data['value'] ?? null; - - if ($value === self::DELETED_STATUS_VALUE) { - return $query->onlyTrashed(); - } - - if (filled($value)) { - return $query->where('status', $value); - } - - return $query; - }), + ->options(fn (): array => $this->getStatusFilterOptions()), ]) ->emptyStateHeading('No screenshots yet') ->emptyStateDescription('Upload screenshots using the button above.') @@ -283,7 +262,7 @@ public function table(Table $table): Table ->iconButton() ->tooltip('Set as Primary') ->requiresConfirmation() - ->hidden(fn (GameScreenshot $record): bool => $record->is_primary || $record->trashed()) + ->hidden(fn (GameScreenshot $record): bool => $record->is_primary) ->action(function (GameScreenshot $record) use ($game): void { DB::transaction(function () use ($record) { // Demote the current primary of the same type via Eloquent @@ -318,7 +297,7 @@ public function table(Table $table): Table ->label('Approve') ->icon('heroicon-o-check-circle') ->color('success') - ->visible(fn (GameScreenshot $record): bool => !$record->trashed() && $record->status !== GameScreenshotStatus::Approved) + ->visible(fn (GameScreenshot $record): bool => $record->status !== GameScreenshotStatus::Approved) ->action(function (GameScreenshot $record) use ($game): void { $oldStatus = $record->status; @@ -337,7 +316,6 @@ public function table(Table $table): Table Action::make('change_type') ->label('Change Type') ->icon('heroicon-o-tag') - ->visible(fn (GameScreenshot $record): bool => !$record->trashed()) ->schema([ Forms\Components\Select::make('type') ->label('Type') @@ -370,7 +348,7 @@ public function table(Table $table): Table ->icon('heroicon-o-x-circle') ->color('danger') ->requiresConfirmation() - ->visible(fn (GameScreenshot $record): bool => !$record->trashed() && !$record->is_primary && $record->status !== GameScreenshotStatus::Rejected) + ->visible(fn (GameScreenshot $record): bool => !$record->is_primary && $record->status !== GameScreenshotStatus::Rejected) ->action(function (GameScreenshot $record) use ($game): void { $oldStatus = $record->status; @@ -390,7 +368,26 @@ public function table(Table $table): Table ->log($oldStatus === GameScreenshotStatus::Approved ? 'Unpublished screenshot' : 'Rejected screenshot'); }), - RestoreAction::make(), + DeleteAction::make() + ->requiresConfirmation() + ->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) + ->using(function (GameScreenshot $record) use ($game): void { + $screenshotUrl = $record->media?->getUrl(); + $type = $record->type->label(); + + $record->media?->delete(); + $record->delete(); + + $this->logScreenshotActivity($game) + ->withProperty('attributes', [ + 'screenshot' => $screenshotUrl, + 'type' => $type, + ]) + ->event('deletedScreenshot') + ->log('Deleted screenshot'); + }), ]), ]); } @@ -418,17 +415,11 @@ private function getStatusFilterOptions(): array ->groupBy('status') ->pluck('aggregate', 'status'); - $deletedCount = $game->gameScreenshots() - ->onlyTrashed() - ->when($selectedType, fn (Builder $query) => $query->where('type', $selectedType)) - ->count(); - return [ 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) . ')', - self::DELETED_STATUS_VALUE => 'Deleted (' . $deletedCount . ')', ]; } @@ -504,17 +495,13 @@ private function getScreenshotHelperText(): ?string return $text; } - private function shouldShowRejectedOrReplacedScreenshots(): bool + private function shouldShowArchivedScreenshots(): bool { $selectedStatus = data_get($this->getTableFilterState('status'), 'value'); - // 'deleted' is included because soft-deleted rows retain their prior status, - // and the trashed view needs to show rejected/replaced rows so they remain - // undoable / available for restore decisions. return in_array($selectedStatus, [ GameScreenshotStatus::Rejected->value, GameScreenshotStatus::Replaced->value, - self::DELETED_STATUS_VALUE, ], true); } } diff --git a/app/Models/GameScreenshot.php b/app/Models/GameScreenshot.php index eaeaaba838..a58db8bc63 100644 --- a/app/Models/GameScreenshot.php +++ b/app/Models/GameScreenshot.php @@ -15,7 +15,6 @@ use Illuminate\Database\Eloquent\Casts\Attribute; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Relations\BelongsTo; -use Illuminate\Database\Eloquent\SoftDeletes; use Spatie\EloquentSortable\SortableTrait; use Spatie\MediaLibrary\MediaCollections\Models\Media; @@ -23,7 +22,6 @@ class GameScreenshot extends BaseModel { /** @use HasFactory */ use HasFactory; - use SoftDeletes; use SortableTrait; protected $table = 'game_screenshots'; diff --git a/app/Platform/Actions/ClearGameScreenshotsFromGamePageAction.php b/app/Platform/Actions/ClearGameScreenshotsFromGamePageAction.php index 911cac2b72..9ddc7c7a60 100644 --- a/app/Platform/Actions/ClearGameScreenshotsFromGamePageAction.php +++ b/app/Platform/Actions/ClearGameScreenshotsFromGamePageAction.php @@ -6,6 +6,7 @@ use App\Models\Game; use App\Models\GameScreenshot; +use App\Platform\Enums\GameScreenshotStatus; use App\Platform\Enums\ScreenshotType; use Illuminate\Support\Facades\DB; @@ -14,23 +15,27 @@ class ClearGameScreenshotsFromGamePageAction public function execute(Game $game): int { return DB::transaction(function () use ($game): int { - $screenshotIds = $game->gameScreenshots() + $affectedIds = $game->gameScreenshots() + ->whereIn('status', [ + GameScreenshotStatus::Approved->value, + GameScreenshotStatus::Pending->value, + ]) ->pluck('id'); - if ($screenshotIds->isEmpty()) { + if ($affectedIds->isEmpty()) { return 0; } - GameScreenshot::whereKey($screenshotIds) - ->update(['is_primary' => false]); - - $deletedCount = GameScreenshot::whereKey($screenshotIds) - ->delete(); + $count = GameScreenshot::whereKey($affectedIds) + ->update([ + 'is_primary' => false, + 'status' => GameScreenshotStatus::Rejected->value, + ]); $game->syncLegacyScreenshotFields(ScreenshotType::Title); $game->syncLegacyScreenshotFields(ScreenshotType::Ingame); - return $deletedCount; + return $count; }); } } diff --git a/app/Platform/Services/GameScreenshotValidationService.php b/app/Platform/Services/GameScreenshotValidationService.php index 710aeb63be..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,12 +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. - // Trashed screenshots are included so that re-uploading a cleared screenshot - // can't bypass the check and create a collision when the original is restored. + // 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() - ->withTrashed() - ->where('status', '!=', GameScreenshotStatus::Rejected) ->whereHas('media', function ($query) use ($hash) { $query->where('custom_properties->sha1', $hash); }) diff --git a/database/migrations/2026_05_03_000000_add_deleted_at_to_game_screenshots_table.php b/database/migrations/2026_05_03_000000_add_deleted_at_to_game_screenshots_table.php deleted file mode 100644 index 86af38ab74..0000000000 --- a/database/migrations/2026_05_03_000000_add_deleted_at_to_game_screenshots_table.php +++ /dev/null @@ -1,23 +0,0 @@ -softDeletes()->after('updated_at'); - }); - } - - public function down(): void - { - Schema::table('game_screenshots', function (Blueprint $table) { - $table->dropSoftDeletes(); - }); - } -}; diff --git a/resources/js/types/generated.d.ts b/resources/js/types/generated.d.ts index 69aae1c5a8..fdcf22fc34 100644 --- a/resources/js/types/generated.d.ts +++ b/resources/js/types/generated.d.ts @@ -1,3 +1,6 @@ +declare namespace App.Api.V2.UserAwards { +export type UserAwardKind = 'achievement-points-yield' | 'achievement-unlocks-yield' | 'beaten-hardcore' | 'beaten-softcore' | 'certified-legend' | 'completed' | 'event' | 'mastered' | 'patreon-supporter' | 'playtest'; +} declare namespace App.Community.Data { export type AchievementChecklistGroup = { header: string; @@ -640,9 +643,9 @@ systems?: Array | null; }; export type EmulatorDownload = { id: number; -platformId: number; label: string | null; url: string; +platformId: number; }; export type EventAchievement = { achievement?: App.Platform.Data.Achievement; @@ -700,10 +703,10 @@ export type GameAchievementSet = { id: number; type: App.Platform.Enums.AchievementSetType; title: string | null; -orderColumn: number; createdAt: string | null; updatedAt: string | null; achievementSet: App.Platform.Data.AchievementSet; +orderColumn: number; }; export type GameClaimant = { user: App.Data.User; diff --git a/tests/Feature/Filament/RelationManagers/GameScreenshotsRelationManagerTest.php b/tests/Feature/Filament/RelationManagers/GameScreenshotsRelationManagerTest.php index 3fa565f3b4..1c68c39c3d 100644 --- a/tests/Feature/Filament/RelationManagers/GameScreenshotsRelationManagerTest.php +++ b/tests/Feature/Filament/RelationManagers/GameScreenshotsRelationManagerTest.php @@ -121,7 +121,7 @@ function createScreenshotMedia(Game $game, array $customProperties = []): Media // ACT $media = $primary->media; - $primary->forceDelete(); + $primary->delete(); $media?->delete(); // ASSERT @@ -144,7 +144,7 @@ function createScreenshotMedia(Game $game, array $customProperties = []): Media // ACT $media = $screenshot->media; - $screenshot->forceDelete(); + $screenshot->delete(); $media?->delete(); // ASSERT @@ -165,7 +165,7 @@ function createScreenshotMedia(Game $game, array $customProperties = []): Media // ACT $media = $screenshot->media; - $screenshot->forceDelete(); + $screenshot->delete(); $media?->delete(); // ASSERT @@ -173,7 +173,7 @@ function createScreenshotMedia(Game $game, array $customProperties = []): Media expect(GameScreenshot::find($screenshot->id))->toBeNull(); }); -it('given screenshots are cleared from the game page, soft deletes every screenshot and preserves media', function () { +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(), @@ -183,7 +183,9 @@ function createScreenshotMedia(Game $game, array $customProperties = []): Media $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, @@ -193,53 +195,48 @@ function createScreenshotMedia(Game $game, array $customProperties = []): Media '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(0); - expect(GameScreenshot::withTrashed()->where('game_id', $game->id)->count())->toEqual(3); + expect(GameScreenshot::where('game_id', $game->id)->count())->toEqual(5); - $this->assertSoftDeleted($title); - $this->assertSoftDeleted($ingame); - $this->assertSoftDeleted($rejected); + 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($rejectedMedia->id))->not->toBeNull(); - - expect(GameScreenshot::withTrashed()->where('game_id', $game->id)->where('is_primary', true)->count())->toEqual(0); + 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); - expect(GameScreenshot::withTrashed()->findOrFail($rejected->id)->status)->toEqual(GameScreenshotStatus::Rejected); -}); - -it('given a cleared screenshot is restored, restores it with its prior status', function () { - // ARRANGE - $game = Game::factory()->create(['system_id' => System::factory()]); - $media = createScreenshotMedia($game); - - $screenshot = GameScreenshot::factory()->for($game)->ingame()->primary()->create([ - 'media_id' => $media->id, - ]); - - (new ClearGameScreenshotsFromGamePageAction())->execute($game); - - // ACT - $trashedScreenshot = GameScreenshot::onlyTrashed()->findOrFail($screenshot->id); - $trashedScreenshot->restore(); - - // ASSERT - $fresh = GameScreenshot::findOrFail($screenshot->id); - expect($fresh->status)->toEqual(GameScreenshotStatus::Approved); - expect($fresh->is_primary)->toBeFalse(); }); it('given a screenshot is uploaded as the first of its type, auto-promotes it to the primary of that type', function () { diff --git a/tests/Feature/Platform/Actions/AddGameScreenshotActionTest.php b/tests/Feature/Platform/Actions/AddGameScreenshotActionTest.php index f42f776391..ad6f05b23f 100644 --- a/tests/Feature/Platform/Actions/AddGameScreenshotActionTest.php +++ b/tests/Feature/Platform/Actions/AddGameScreenshotActionTest.php @@ -89,7 +89,7 @@ $action->execute($game->fresh(), $duplicate, ScreenshotType::Ingame); })->throws(ValidationException::class); -it('rejects re-uploading an image that matches a soft-deleted screenshot', function () { +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(); @@ -98,7 +98,10 @@ $sourceContent = file_get_contents($source->getRealPath()); $original = $action->execute($game, $source, ScreenshotType::Ingame); - $original->delete(); + $original->update([ + 'is_primary' => false, + 'status' => GameScreenshotStatus::Rejected, + ]); $duplicate = UploadedFile::fake()->image('duplicate.png', 256, 224); file_put_contents($duplicate->getRealPath(), $sourceContent); From 2a3c4b5b97e4dbf8f6215794f63853004b091592 Mon Sep 17 00:00:00 2001 From: Wes Copeland Date: Thu, 7 May 2026 17:30:38 -0400 Subject: [PATCH 5/5] fix: one more tweak --- .../RelationManagers/GameScreenshotsRelationManager.php | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/app/Filament/Resources/GameResource/RelationManagers/GameScreenshotsRelationManager.php b/app/Filament/Resources/GameResource/RelationManagers/GameScreenshotsRelationManager.php index 1bdc8cbeda..a30ab240b4 100644 --- a/app/Filament/Resources/GameResource/RelationManagers/GameScreenshotsRelationManager.php +++ b/app/Filament/Resources/GameResource/RelationManagers/GameScreenshotsRelationManager.php @@ -16,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; @@ -368,12 +367,15 @@ 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() ->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) - ->using(function (GameScreenshot $record) use ($game): void { + ->action(function (GameScreenshot $record) use ($game): void { $screenshotUrl = $record->media?->getUrl(); $type = $record->type->label();