orderBy('surface_key')->get(); } public function placements(?string $surfaceKey = null): SupportCollection { $query = CollectionSurfacePlacement::query() ->with([ 'collection.user:id,username,name', 'collection.coverArtwork:id,user_id,title,slug,hash,thumb_ext,published_at,is_public,is_approved,deleted_at', ]) ->orderBy('surface_key') ->orderByDesc('priority') ->orderBy('id'); if ($surfaceKey !== null && $surfaceKey !== '') { $query->where('surface_key', $surfaceKey); } return $query->get(); } public function placementConflicts(?string $surfaceKey = null): SupportCollection { return $this->placements($surfaceKey) ->where('is_active', true) ->groupBy('surface_key') ->flatMap(function (SupportCollection $placements, string $key): array { $conflicts = []; $values = $placements->values(); $count = $values->count(); for ($leftIndex = 0; $leftIndex < $count; $leftIndex++) { $left = $values[$leftIndex]; for ($rightIndex = $leftIndex + 1; $rightIndex < $count; $rightIndex++) { $right = $values[$rightIndex]; if (! $this->placementsOverlap($left, $right)) { continue; } $conflicts[] = [ 'surface_key' => $key, 'placement_ids' => [(int) $left->id, (int) $right->id], 'collection_ids' => [(int) $left->collection_id, (int) $right->collection_id], 'collection_titles' => [ $left->collection?->title ?? 'Unknown collection', $right->collection?->title ?? 'Unknown collection', ], 'summary' => sprintf( '%s overlaps with %s on %s.', $left->collection?->title ?? 'Unknown collection', $right->collection?->title ?? 'Unknown collection', $key, ), 'window' => [ 'starts_at' => $this->earliestStart($left, $right)?->toISOString(), 'ends_at' => $this->latestEnd($left, $right)?->toISOString(), ], ]; } } return $conflicts; }) ->values(); } public function upsertDefinition(array $attributes): CollectionSurfaceDefinition { return CollectionSurfaceDefinition::query()->updateOrCreate( ['surface_key' => (string) $attributes['surface_key']], [ 'title' => (string) $attributes['title'], 'description' => $attributes['description'] ?? null, 'mode' => (string) ($attributes['mode'] ?? 'manual'), 'rules_json' => $attributes['rules_json'] ?? null, 'ranking_mode' => (string) ($attributes['ranking_mode'] ?? 'ranking_score'), 'max_items' => (int) ($attributes['max_items'] ?? 12), 'is_active' => (bool) ($attributes['is_active'] ?? true), 'starts_at' => $attributes['starts_at'] ?? null, 'ends_at' => $attributes['ends_at'] ?? null, 'fallback_surface_key' => $attributes['fallback_surface_key'] ?? null, ] ); } public function populateSurface(string $surfaceKey, int $fallbackLimit = 12): SupportCollection { return $this->resolveSurfaceItems($surfaceKey, $fallbackLimit); } public function resolveSurfaceItems(string $surfaceKey, int $fallbackLimit = 12): SupportCollection { return $this->resolveSurfaceItemsInternal($surfaceKey, $fallbackLimit, []); } public function syncPlacements(): int { return CollectionSurfacePlacement::query() ->where('is_active', true) ->where(function ($query): void { $query->whereNotNull('ends_at')->where('ends_at', '<=', now()); }) ->update([ 'is_active' => false, 'updated_at' => now(), ]); } public function upsertPlacement(array $attributes): CollectionSurfacePlacement { $placementId = isset($attributes['id']) ? (int) $attributes['id'] : null; $payload = [ 'surface_key' => (string) $attributes['surface_key'], 'collection_id' => (int) $attributes['collection_id'], 'placement_type' => (string) ($attributes['placement_type'] ?? 'manual'), 'priority' => (int) ($attributes['priority'] ?? 0), 'starts_at' => $attributes['starts_at'] ?? null, 'ends_at' => $attributes['ends_at'] ?? null, 'is_active' => (bool) ($attributes['is_active'] ?? true), 'campaign_key' => $attributes['campaign_key'] ?? null, 'notes' => $attributes['notes'] ?? null, 'created_by_user_id' => isset($attributes['created_by_user_id']) ? (int) $attributes['created_by_user_id'] : null, ]; if ($placementId) { $placement = CollectionSurfacePlacement::query()->findOrFail($placementId); $placement->fill($payload)->save(); return $placement->refresh(); } return CollectionSurfacePlacement::query()->create($payload); } public function deletePlacement(CollectionSurfacePlacement $placement): void { $placement->delete(); } private function resolveSurfaceItemsInternal(string $surfaceKey, int $fallbackLimit, array $visited): SupportCollection { if (in_array($surfaceKey, $visited, true)) { return collect(); } $visited[] = $surfaceKey; $definition = CollectionSurfaceDefinition::query()->where('surface_key', $surfaceKey)->first(); $limit = max(1, min((int) ($definition?->max_items ?? $fallbackLimit), 24)); $mode = (string) ($definition?->mode ?? 'manual'); if ($definition && ! $this->definitionIsActive($definition)) { return $this->resolveFallbackSurface($definition, $fallbackLimit, $visited); } $manual = CollectionSurfacePlacement::query() ->with([ 'collection.user:id,username,name', 'collection.coverArtwork:id,user_id,title,slug,hash,thumb_ext,published_at,is_public,is_approved,deleted_at', ]) ->where('surface_key', $surfaceKey) ->where('is_active', true) ->where(function ($query): void { $query->whereNull('starts_at')->orWhere('starts_at', '<=', now()); }) ->where(function ($query): void { $query->whereNull('ends_at')->orWhere('ends_at', '>', now()); }) ->orderByDesc('priority') ->orderBy('id') ->limit($limit) ->get() ->pluck('collection') ->filter(fn (?Collection $collection) => $collection && $collection->isFeatureablePublicly()) ->values(); if ($mode === 'manual') { return $manual->isEmpty() ? $this->resolveFallbackSurface($definition, $fallbackLimit, $visited) : $manual; } $query = Collection::query()->publicEligible(); $rules = is_array($definition?->rules_json) ? $definition->rules_json : []; $this->applyAutomaticRules($query, $rules); $rankingMode = (string) ($definition?->ranking_mode ?? 'ranking_score'); if ($rankingMode === 'recent_activity') { $query->orderByDesc('last_activity_at'); } elseif ($rankingMode === 'quality_score') { $query->orderByDesc('quality_score'); } else { $query->orderByDesc('ranking_score'); } $auto = $query ->when($mode === 'hybrid', fn ($builder) => $builder->whereNotIn('id', $manual->pluck('id')->all())) ->with([ 'user:id,username,name', 'coverArtwork:id,user_id,title,slug,hash,thumb_ext,published_at,is_public,is_approved,deleted_at', ]) ->limit($mode === 'hybrid' ? max(0, $limit - $manual->count()) : $limit) ->get(); if ($mode === 'automatic') { return $auto->isEmpty() ? $this->resolveFallbackSurface($definition, $fallbackLimit, $visited) : $auto->values(); } if ($manual->count() >= $limit) { return $manual; } $resolved = $manual->concat($auto)->values(); return $resolved->isEmpty() ? $this->resolveFallbackSurface($definition, $fallbackLimit, $visited) : $resolved; } private function applyAutomaticRules(Builder $query, array $rules): void { $this->applyExactOrListRule($query, 'type', $rules['type'] ?? null); $this->applyExactOrListRule($query, 'campaign_key', $rules['campaign_key'] ?? null); $this->applyExactOrListRule($query, 'event_key', $rules['event_key'] ?? null); $this->applyExactOrListRule($query, 'season_key', $rules['season_key'] ?? null); $this->applyExactOrListRule($query, 'presentation_style', $rules['presentation_style'] ?? null); $this->applyExactOrListRule($query, 'theme_token', $rules['theme_token'] ?? null); $this->applyExactOrListRule($query, 'collaboration_mode', $rules['collaboration_mode'] ?? null); $this->applyExactOrListRule($query, 'promotion_tier', $rules['promotion_tier'] ?? null); if ($this->ruleEnabled($rules['featured_only'] ?? false)) { $query->where('is_featured', true); } if ($this->ruleEnabled($rules['commercial_eligible_only'] ?? false)) { $query->where('commercial_eligibility', true); } if ($this->ruleEnabled($rules['analytics_enabled_only'] ?? false)) { $query->where('analytics_enabled', true); } if (($minQualityScore = $this->numericRule($rules['min_quality_score'] ?? null)) !== null) { $query->where('quality_score', '>=', $minQualityScore); } if (($minRankingScore = $this->numericRule($rules['min_ranking_score'] ?? null)) !== null) { $query->where('ranking_score', '>=', $minRankingScore); } $includeIds = $this->integerRuleList($rules['include_collection_ids'] ?? null); if ($includeIds !== []) { $query->whereIn('id', $includeIds); } $excludeIds = $this->integerRuleList($rules['exclude_collection_ids'] ?? null); if ($excludeIds !== []) { $query->whereNotIn('id', $excludeIds); } $ownerUsernames = $this->stringRuleList($rules['owner_usernames'] ?? ($rules['owner_username'] ?? null)); if ($ownerUsernames !== []) { $normalized = array_map(static fn (string $value): string => mb_strtolower($value), $ownerUsernames); $query->whereHas('user', function (Builder $builder) use ($normalized): void { $builder->whereIn('username', $normalized); }); } } private function applyExactOrListRule(Builder $query, string $column, mixed $value): void { $values = $this->stringRuleList($value); if ($values === []) { return; } if (count($values) === 1) { $query->where($column, $values[0]); return; } $query->whereIn($column, $values); } private function stringRuleList(mixed $value): array { $values = is_array($value) ? $value : [$value]; return array_values(array_unique(array_filter(array_map(static function ($item): ?string { if (! is_string($item) && ! is_numeric($item)) { return null; } $normalized = trim((string) $item); return $normalized !== '' ? $normalized : null; }, $values)))); } private function integerRuleList(mixed $value): array { $values = is_array($value) ? $value : [$value]; return array_values(array_unique(array_filter(array_map(static function ($item): ?int { if (! is_numeric($item)) { return null; } $normalized = (int) $item; return $normalized > 0 ? $normalized : null; }, $values)))); } private function numericRule(mixed $value): ?float { if (! is_numeric($value)) { return null; } return (float) $value; } private function ruleEnabled(mixed $value): bool { if (is_bool($value)) { return $value; } if (is_numeric($value)) { return (int) $value === 1; } if (is_string($value)) { return in_array(mb_strtolower(trim($value)), ['1', 'true', 'yes', 'on'], true); } return false; } private function definitionIsActive(CollectionSurfaceDefinition $definition): bool { if (! $definition->is_active) { return false; } if ($definition->starts_at && $definition->starts_at->isFuture()) { return false; } if ($definition->ends_at && $definition->ends_at->lessThanOrEqualTo(now())) { return false; } return true; } private function resolveFallbackSurface(?CollectionSurfaceDefinition $definition, int $fallbackLimit, array $visited): SupportCollection { $fallbackKey = $definition?->fallback_surface_key; if (! is_string($fallbackKey) || trim($fallbackKey) === '') { return collect(); } return $this->resolveSurfaceItemsInternal(trim($fallbackKey), $fallbackLimit, $visited); } private function placementsOverlap(CollectionSurfacePlacement $left, CollectionSurfacePlacement $right): bool { $leftStart = $left->starts_at?->getTimestamp() ?? PHP_INT_MIN; $leftEnd = $left->ends_at?->getTimestamp() ?? PHP_INT_MAX; $rightStart = $right->starts_at?->getTimestamp() ?? PHP_INT_MIN; $rightEnd = $right->ends_at?->getTimestamp() ?? PHP_INT_MAX; return $leftStart < $rightEnd && $rightStart < $leftEnd; } private function earliestStart(CollectionSurfacePlacement $left, CollectionSurfacePlacement $right) { if ($left->starts_at === null) { return $right->starts_at; } if ($right->starts_at === null) { return $left->starts_at; } return $left->starts_at->lessThanOrEqualTo($right->starts_at) ? $left->starts_at : $right->starts_at; } private function latestEnd(CollectionSurfacePlacement $left, CollectionSurfacePlacement $right) { if ($left->ends_at === null || $right->ends_at === null) { return null; } return $left->ends_at->greaterThanOrEqualTo($right->ends_at) ? $left->ends_at : $right->ends_at; } }