optimizations
This commit is contained in:
423
app/Services/CollectionSurfaceService.php
Normal file
423
app/Services/CollectionSurfaceService.php
Normal file
@@ -0,0 +1,423 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use App\Models\Collection;
|
||||
use App\Models\CollectionSurfaceDefinition;
|
||||
use App\Models\CollectionSurfacePlacement;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Support\Collection as SupportCollection;
|
||||
|
||||
class CollectionSurfaceService
|
||||
{
|
||||
public function definitions(): SupportCollection
|
||||
{
|
||||
return CollectionSurfaceDefinition::query()->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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user