diff --git a/app/Exceptions/SimilarGamesCapExceededException.php b/app/Exceptions/SimilarGamesCapExceededException.php new file mode 100644 index 0000000000..9ee38dfcfd --- /dev/null +++ b/app/Exceptions/SimilarGamesCapExceededException.php @@ -0,0 +1,17 @@ +badge((string) $record->similarGamesList->count()); + $count = $record->similarGamesList->count(); + $item->badge("{$count} / " . LinkSimilarGamesAction::MAX_SIMILAR_GAMES); } return [$item]; @@ -127,6 +129,10 @@ public function table(Table $table): Table ->headerActions([ Actions\Action::make('add') ->label('Add similar games') + ->disabled(fn (): bool => $this->isSimilarGamesCapReached()) + ->tooltip(fn (): ?string => $this->isSimilarGamesCapReached() + ? 'This game is at the ' . LinkSimilarGamesAction::MAX_SIMILAR_GAMES . '-similar-game cap. Remove a game before adding more.' + : null) ->schema([ Forms\Components\TextInput::make('game_ids_csv') ->label('Game IDs (CSV)') @@ -181,18 +187,34 @@ public function table(Table $table): Table /** @var Game $game */ $game = $this->getOwnerRecord(); - $gameIds = []; + $selectIds = !empty($data['game_ids']) ? array_map('intval', $data['game_ids']) : []; + $csvIds = !empty($data['game_ids_csv']) + ? (new ParseIdsFromCsvAction())->execute($data['game_ids_csv']) + : []; + $gameIds = array_values(array_unique(array_merge($selectIds, $csvIds))); - // Handle select field input. - if (!empty($data['game_ids'])) { - $gameIds = $data['game_ids']; - (new LinkSimilarGamesAction())->execute($game, $gameIds); + if (empty($gameIds)) { + return; } - // Handle CSV input. - if (!empty($data['game_ids_csv'])) { - $gameIds = (new ParseIdsFromCsvAction())->execute($data['game_ids_csv']); + try { (new LinkSimilarGamesAction())->execute($game, $gameIds); + } catch (SimilarGamesCapExceededException $e) { + if ($e->offendingGameId === $game->id) { + $body = "Adding these games would push this game's similar list over the {$e->cap}-similar-game cap. Remove an entry or add fewer games."; + } else { + $offending = Game::find($e->offendingGameId); + $title = $offending?->title ?? "Game #{$e->offendingGameId}"; + $body = "{$title} is already at the {$e->cap}-similar-game cap and cannot be linked."; + } + + Notification::make() + ->danger() + ->title('Cap exceeded') + ->body($body) + ->send(); + + return; } Notification::make() @@ -214,6 +236,7 @@ public function table(Table $table): Table $game = $this->getOwnerRecord(); (new UnlinkSimilarGamesAction())->execute($game, [$similarGame->id]); + $this->isSimilarGamesCapReached = null; Notification::make() ->success() @@ -237,6 +260,7 @@ public function table(Table $table): Table $game, $similarGames->pluck('id')->toArray() ); + $this->isSimilarGamesCapReached = null; $this->deselectAllTableRecords(); @@ -249,6 +273,20 @@ public function table(Table $table): Table ]); } + private ?bool $isSimilarGamesCapReached = null; + + private function isSimilarGamesCapReached(): bool + { + if ($this->isSimilarGamesCapReached === null) { + /** @var Game $game */ + $game = $this->getOwnerRecord(); + $similarGamesCount = $game->similarGamesList()->count(); + $this->isSimilarGamesCapReached = $similarGamesCount >= LinkSimilarGamesAction::MAX_SIMILAR_GAMES; + } + + return $this->isSimilarGamesCapReached; + } + /** * @param Builder $query * @return Builder diff --git a/app/Platform/Actions/LinkSimilarGamesAction.php b/app/Platform/Actions/LinkSimilarGamesAction.php index f279c6c6b9..a98ed05f8f 100644 --- a/app/Platform/Actions/LinkSimilarGamesAction.php +++ b/app/Platform/Actions/LinkSimilarGamesAction.php @@ -4,46 +4,76 @@ namespace App\Platform\Actions; +use App\Exceptions\SimilarGamesCapExceededException; use App\Models\Game; use App\Models\GameSet; -use App\Models\GameSetGame; use App\Platform\Enums\GameSetType; +use Illuminate\Support\Facades\DB; class LinkSimilarGamesAction { + public const int MAX_SIMILAR_GAMES = 6; + public function execute(Game $parentGame, array $gameIdsToLink): void { - $parentSimilarGamesSet = GameSet::firstOrCreate([ + // normalize to a list of ints + $gameIdsToLink = array_values(array_unique(array_map('intval', $gameIdsToLink))); + + $parentSimilarGamesSet = GameSet::firstOrNew([ 'type' => GameSetType::SimilarGames, 'game_id' => $parentGame->id, ]); - $existingSimilarGames = GameSetGame::query() - ->where('game_set_id', $parentSimilarGamesSet->id) - ->whereIn('game_id', $gameIdsToLink) - ->pluck('game_id') - ->toArray(); + $existingParentLinkedIds = $parentSimilarGamesSet->exists + ? $parentSimilarGamesSet->games()->pluck('games.id')->all() + : []; + + $newSetGameIds = array_values(array_diff($gameIdsToLink, $existingParentLinkedIds)); - $newSetGameIds = array_diff($gameIdsToLink, $existingSimilarGames); - if (!empty($newSetGameIds)) { - $parentSimilarGamesSet->games()->attach($newSetGameIds); + $existingParentCount = count($existingParentLinkedIds); + if ($existingParentCount + count($newSetGameIds) > self::MAX_SIMILAR_GAMES) { + throw new SimilarGamesCapExceededException($parentGame->id, self::MAX_SIMILAR_GAMES); } - // Link each game's similar games set to include the parent game. + /** @var GameSet[] $targetSets */ + $targetSets = []; foreach ($gameIdsToLink as $gameId) { - $similarGamesSet = GameSet::firstOrCreate([ + $candidateSet = GameSet::firstOrNew([ 'type' => GameSetType::SimilarGames, 'game_id' => $gameId, ]); - $isAlreadyAttached = GameSetGame::query() - ->where('game_set_id', $similarGamesSet->id) - ->where('game_id', $parentGame->id) - ->exists(); + if ($candidateSet->exists && $candidateSet->games()->where('games.id', $parentGame->id)->exists()) { + continue; + } - if (!$isAlreadyAttached) { - $similarGamesSet->games()->attach($parentGame->id); + $candidateCount = $candidateSet->exists ? $candidateSet->games()->count() : 0; + if ($candidateCount + 1 > self::MAX_SIMILAR_GAMES) { + throw new SimilarGamesCapExceededException($gameId, self::MAX_SIMILAR_GAMES); } + + $targetSets[] = $candidateSet; } + + if (empty($newSetGameIds) && empty($targetSets)) { + return; + } + + // writes only happen if validation passed + DB::transaction(function () use ($parentGame, $parentSimilarGamesSet, $newSetGameIds, $targetSets): void { + if (!empty($newSetGameIds)) { + if (!$parentSimilarGamesSet->exists) { + $parentSimilarGamesSet->save(); + } + $parentSimilarGamesSet->games()->attach($newSetGameIds); + } + + foreach ($targetSets as $candidateSet) { + if (!$candidateSet->exists) { + $candidateSet->save(); + } + $candidateSet->games()->attach($parentGame->id); + } + }); } }