424 lines
16 KiB
PHP
424 lines
16 KiB
PHP
<?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;
|
|
}
|
|
}
|