title)); return Collection::query() ->where('id', '!=', $collection->id) ->whereNotExists(function ($query) use ($collection): void { $query->select(DB::raw('1')) ->from('collection_merge_actions as cma') ->where('cma.action_type', 'rejected') ->where(function ($pair) use ($collection): void { $pair->where(function ($forward) use ($collection): void { $forward->where('cma.source_collection_id', $collection->id) ->whereColumn('cma.target_collection_id', 'collections.id'); })->orWhere(function ($reverse) use ($collection): void { $reverse->where('cma.target_collection_id', $collection->id) ->whereColumn('cma.source_collection_id', 'collections.id'); }); }); }) ->where(function ($query) use ($collection, $normalizedTitle): void { $query->where('user_id', $collection->user_id) ->orWhere(function ($inner) use ($collection, $normalizedTitle): void { $inner->whereRaw('LOWER(title) = ?', [$normalizedTitle]) ->when(filled($collection->campaign_key), fn ($builder) => $builder->orWhere('campaign_key', $collection->campaign_key)) ->when(filled($collection->series_key), fn ($builder) => $builder->orWhere('series_key', $collection->series_key)); }); }) ->with(['user:id,username,name', 'coverArtwork:id,user_id,title,slug,hash,thumb_ext,published_at,is_public,is_approved,deleted_at']) ->orderByDesc('updated_at') ->limit(max(1, min($limit, 10))) ->get(); } public function reviewCandidates(Collection $collection, bool $ownerView = true, int $limit = 5): array { $candidates = $this->duplicateCandidates($collection, $limit); $candidateIds = $candidates->pluck('id')->map(static fn ($id): int => (int) $id)->values()->all(); $cards = collect($this->collections->mapCollectionCardPayloads($candidates, $ownerView))->keyBy('id'); $latestActions = $this->latestActionsForPair($collection, $candidateIds); return $candidates->map(function (Collection $candidate) use ($collection, $cards, $latestActions): array { return [ 'collection' => $cards->get((int) $candidate->id), 'comparison' => $this->comparisonForCollections($collection, $candidate), 'decision' => $latestActions[$this->pairKey((int) $collection->id, (int) $candidate->id)] ?? null, 'is_current_canonical_target' => (int) ($collection->canonical_collection_id ?? 0) === (int) $candidate->id, ]; })->values()->all(); } public function queueOverview(bool $ownerView = true, int $pendingLimit = 8, int $recentLimit = 8): array { $latestActions = CollectionMergeAction::query() ->with([ 'sourceCollection.user:id,username,name', 'sourceCollection.coverArtwork:id,user_id,title,slug,hash,thumb_ext,published_at,is_public,is_approved,deleted_at', 'targetCollection.user:id,username,name', 'targetCollection.coverArtwork:id,user_id,title,slug,hash,thumb_ext,published_at,is_public,is_approved,deleted_at', 'actor:id,username,name', ]) ->orderByDesc('id') ->get() ->filter(fn (CollectionMergeAction $action): bool => $action->sourceCollection !== null && $action->targetCollection !== null) ->groupBy(fn (CollectionMergeAction $action): string => $this->pairKey((int) $action->source_collection_id, (int) $action->target_collection_id)) ->map(fn (SupportCollection $actions): CollectionMergeAction => $actions->first()) ->values(); $pending = $latestActions ->filter(fn (CollectionMergeAction $action): bool => $action->action_type === 'suggested') ->sortByDesc(fn (CollectionMergeAction $action) => optional($action->updated_at)?->timestamp ?? 0) ->take($pendingLimit) ->values(); $recent = $latestActions ->filter(fn (CollectionMergeAction $action): bool => $action->action_type !== 'suggested') ->sortByDesc(fn (CollectionMergeAction $action) => optional($action->updated_at)?->timestamp ?? 0) ->take($recentLimit) ->values(); return [ 'summary' => [ 'pending' => $latestActions->where('action_type', 'suggested')->count(), 'approved' => $latestActions->where('action_type', 'approved')->count(), 'rejected' => $latestActions->where('action_type', 'rejected')->count(), 'completed' => $latestActions->where('action_type', 'completed')->count(), ], 'pending' => $pending->map(fn (CollectionMergeAction $action): array => $this->mapQueueAction($action, $ownerView))->values()->all(), 'recent' => $recent->map(fn (CollectionMergeAction $action): array => $this->mapQueueAction($action, $ownerView))->values()->all(), ]; } public function syncSuggestedCandidates(Collection $collection, ?User $actor = null, int $limit = 5): array { $candidates = $this->duplicateCandidates($collection, $limit); $candidateIds = $candidates->pluck('id')->map(static fn ($id): int => (int) $id)->values()->all(); $staleSuggestions = CollectionMergeAction::query() ->where('source_collection_id', $collection->id) ->where('action_type', 'suggested'); if ($candidateIds === []) { $staleSuggestions->delete(); } else { $staleSuggestions->whereNotIn('target_collection_id', $candidateIds)->delete(); } foreach ($candidates as $candidate) { CollectionMergeAction::query()->updateOrCreate( [ 'source_collection_id' => $collection->id, 'target_collection_id' => $candidate->id, 'action_type' => 'suggested', ], [ 'actor_user_id' => $actor?->id, 'summary' => 'Potential duplicate candidate detected.', ] ); } $this->syncDuplicateClusterKeys($collection, $candidateIds); app(CollectionHistoryService::class)->record( $collection->fresh(), $actor, 'duplicate_candidates_synced', sprintf('Collection duplicate candidates scanned. %d potential matches found.', count($candidateIds)), null, ['candidate_collection_ids' => $candidateIds] ); return [ 'count' => count($candidateIds), 'items' => $candidates->map(fn (Collection $candidate): array => [ 'id' => (int) $candidate->id, 'title' => (string) $candidate->title, 'slug' => (string) $candidate->slug, ])->values()->all(), ]; } public function rejectCandidate(Collection $source, Collection $target, ?User $actor = null): Collection { if ((int) $source->id === (int) $target->id) { throw ValidationException::withMessages([ 'target_collection_id' => 'A collection cannot reject itself as a duplicate.', ]); } CollectionMergeAction::query() ->where(function ($query) use ($source, $target): void { $query->where(function ($forward) use ($source, $target): void { $forward->where('source_collection_id', $source->id) ->where('target_collection_id', $target->id); })->orWhere(function ($reverse) use ($source, $target): void { $reverse->where('source_collection_id', $target->id) ->where('target_collection_id', $source->id); }); }) ->whereIn('action_type', ['suggested']) ->delete(); CollectionMergeAction::query()->updateOrCreate( [ 'source_collection_id' => $source->id, 'target_collection_id' => $target->id, 'action_type' => 'rejected', ], [ 'actor_user_id' => $actor?->id, 'summary' => 'Marked as not a duplicate.', ] ); app(CollectionHistoryService::class)->record( $source->fresh(), $actor, 'duplicate_rejected', 'Duplicate candidate dismissed.', null, ['target_collection_id' => (int) $target->id] ); $this->syncDuplicateClusterKeys($source->fresh(), $this->duplicateCandidates($source->fresh())->pluck('id')->map(static fn ($id): int => (int) $id)->all()); $this->syncDuplicateClusterKeys($target->fresh(), $this->duplicateCandidates($target->fresh())->pluck('id')->map(static fn ($id): int => (int) $id)->all()); return app(CollectionHealthService::class)->refresh($source->fresh(), $actor, 'duplicate-rejected'); } public function mergeInto(Collection $source, Collection $target, ?User $actor = null): array { if ((int) $source->id === (int) $target->id) { throw ValidationException::withMessages([ 'target_collection_id' => 'A collection cannot merge into itself.', ]); } if ($target->isSmart()) { throw ValidationException::withMessages([ 'target_collection_id' => 'Target collection must be manual so merged artworks can be referenced safely.', ]); } return DB::transaction(function () use ($source, $target, $actor): array { $artworkIds = $source->artworks()->pluck('artworks.id')->map(static fn ($id) => (int) $id)->all(); $this->collections->attachArtworkIds($target, $artworkIds); $source = $this->canonical->designate($source->fresh(), $target->fresh(), $actor); $source->forceFill([ 'workflow_state' => Collection::WORKFLOW_ARCHIVED, 'lifecycle_state' => Collection::LIFECYCLE_ARCHIVED, 'archived_at' => now(), 'placement_eligibility' => false, ])->save(); CollectionMergeAction::query()->create([ 'source_collection_id' => $source->id, 'target_collection_id' => $target->id, 'action_type' => 'completed', 'actor_user_id' => $actor?->id, 'summary' => 'Collection merge completed.', ]); app(CollectionHistoryService::class)->record($target->fresh(), $actor, 'merged_into_target', 'Collection absorbed merge references.', null, [ 'source_collection_id' => (int) $source->id, 'artwork_ids' => $artworkIds, ]); app(CollectionHistoryService::class)->record($source->fresh(), $actor, 'merged_into_canonical', 'Collection archived after merge.', null, [ 'target_collection_id' => (int) $target->id, ]); return [ 'source' => $source->fresh(), 'target' => $target->fresh(), 'attached_artwork_ids' => $artworkIds, ]; }); } /** * @param array $candidateIds * @return array> */ private function latestActionsForPair(Collection $source, array $candidateIds): array { if ($candidateIds === []) { return []; } return CollectionMergeAction::query() ->where(function ($query) use ($source, $candidateIds): void { $query->where('source_collection_id', $source->id) ->whereIn('target_collection_id', $candidateIds) ->orWhere(function ($reverse) use ($source, $candidateIds): void { $reverse->where('target_collection_id', $source->id) ->whereIn('source_collection_id', $candidateIds); }); }) ->orderByDesc('id') ->get() ->groupBy(function (CollectionMergeAction $action): string { return $this->pairKey((int) $action->source_collection_id, (int) $action->target_collection_id); }) ->map(fn ($actions): array => [ 'action_type' => (string) $actions->first()->action_type, 'summary' => $actions->first()->summary, 'updated_at' => optional($actions->first()->updated_at)?->toISOString(), ]) ->all(); } private function pairKey(int $leftId, int $rightId): string { $pair = [$leftId, $rightId]; sort($pair); return implode(':', $pair); } private function comparisonForCollections(Collection $source, Collection $target): array { $sourceArtworkIds = $source->artworks()->pluck('artworks.id')->map(static fn ($id): int => (int) $id)->values()->all(); $targetArtworkIds = $target->artworks()->pluck('artworks.id')->map(static fn ($id): int => (int) $id)->values()->all(); $sharedArtworkIds = array_values(array_intersect($sourceArtworkIds, $targetArtworkIds)); $reasons = array_values(array_filter([ (int) $target->user_id === (int) $source->user_id ? 'same_owner' : null, mb_strtolower(trim((string) $target->title)) === mb_strtolower(trim((string) $source->title)) ? 'same_title' : null, filled($source->campaign_key) && $target->campaign_key === $source->campaign_key ? 'same_campaign' : null, filled($source->series_key) && $target->series_key === $source->series_key ? 'same_series' : null, $sharedArtworkIds !== [] ? 'shared_artworks' : null, ])); return [ 'same_owner' => (int) $target->user_id === (int) $source->user_id, 'same_title' => mb_strtolower(trim((string) $target->title)) === mb_strtolower(trim((string) $source->title)), 'same_campaign' => filled($source->campaign_key) && $target->campaign_key === $source->campaign_key, 'same_series' => filled($source->series_key) && $target->series_key === $source->series_key, 'shared_artworks_count' => count($sharedArtworkIds), 'source_artworks_count' => count($sourceArtworkIds), 'target_artworks_count' => count($targetArtworkIds), 'match_reasons' => $reasons, ]; } /** * @param array $candidateIds */ private function syncDuplicateClusterKeys(Collection $collection, array $candidateIds): void { $clusterIds = collect([$collection->id]) ->merge($candidateIds) ->map(static fn ($id): int => (int) $id) ->unique() ->values(); if ($clusterIds->count() <= 1) { Collection::query() ->where('id', $collection->id) ->whereNull('canonical_collection_id') ->update(['duplicate_cluster_key' => null]); return; } $clusterKey = sprintf('dup:%d:%d', $clusterIds->min(), $clusterIds->count()); Collection::query() ->whereIn('id', $clusterIds->all()) ->whereNull('canonical_collection_id') ->update(['duplicate_cluster_key' => $clusterKey]); } private function mapQueueAction(CollectionMergeAction $action, bool $ownerView): array { $source = $action->sourceCollection; $target = $action->targetCollection; return [ 'id' => (int) $action->id, 'action_type' => (string) $action->action_type, 'summary' => $action->summary, 'updated_at' => optional($action->updated_at)?->toISOString(), 'source' => $source ? $this->collections->mapCollectionCardPayloads([$source], $ownerView)[0] : null, 'target' => $target ? $this->collections->mapCollectionCardPayloads([$target], $ownerView)[0] : null, 'comparison' => ($source && $target) ? $this->comparisonForCollections($source, $target) : null, 'actor' => $action->actor ? [ 'id' => (int) $action->actor->id, 'username' => (string) $action->actor->username, 'name' => $action->actor->name, ] : null, ]; } }