feat: ship creator journey v2 and profile updates

This commit is contained in:
2026-04-12 21:42:07 +02:00
parent a2457f4e49
commit d5cff21ea2
335 changed files with 20147 additions and 1545 deletions

View File

@@ -7,9 +7,11 @@ namespace App\Services;
use App\Models\Artwork;
use App\Models\Tag;
use App\Services\EarlyGrowth\AdaptiveTimeWindow;
use App\Services\Maturity\ArtworkMaturityService;
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
use Illuminate\Pagination\LengthAwarePaginator as PaginationLengthAwarePaginator;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\DB;
/**
* High-level search API powered by Meilisearch via Laravel Scout.
@@ -21,9 +23,12 @@ final class ArtworkSearchService
private const BASE_FILTER = 'is_public = true AND is_approved = true';
private const CACHE_TTL = 300; // 5 minutes
private const TAG_SORTS = ['popular', 'likes', 'latest', 'downloads'];
private const SEARCH_CANDIDATE_POOL_MULTIPLIER = 4;
private const SEARCH_CANDIDATE_POOL_MAX = 240;
public function __construct(
private readonly AdaptiveTimeWindow $timeWindow,
private readonly ArtworkMaturityService $maturity,
) {}
/**
@@ -76,11 +81,38 @@ final class ArtworkSearchService
$options['sort'] = $sort;
}
$options = $this->viewerAwareOptions($options);
return Artwork::search($q ?: '')
->options($options)
->paginate($perPage);
}
public function searchWithThumbnailPreference(array $options, int $perPage, bool $excludeMissing = false, ?int $page = null): LengthAwarePaginator
{
$page = max(1, $page ?? (int) request()->get('page', 1));
$candidateCount = $this->determineSearchCandidatePoolSize($perPage, $page);
$results = Artwork::search('')
->options($this->viewerAwareOptions($options))
->paginate($candidateCount, 'page', 1);
$ordered = $this->rerankSearchCollectionByThumbnailHealth($results->getCollection(), $excludeMissing);
$offset = max(0, ($page - 1) * $perPage);
$slice = $ordered->slice($offset, $perPage)->values();
return new PaginationLengthAwarePaginator(
$slice->all(),
(int) $results->total(),
$perPage,
$page,
[
'path' => request()->url(),
'query' => request()->query(),
'pageName' => 'page',
]
);
}
/**
* Load artworks for a tag page, sorted by views + likes descending.
*/
@@ -92,12 +124,13 @@ final class ArtworkSearchService
}
$sort = in_array($sort, self::TAG_SORTS, true) ? $sort : 'popular';
$cacheKey = "search.tag.{$slug}.{$sort}.{$perPage}.page." . request()->get('page', 1);
$cacheKey = "search.tag.{$slug}.{$sort}.{$perPage}.{$this->viewerCacheSegment()}.page." . request()->get('page', 1);
return Cache::remember($cacheKey, self::CACHE_TTL, function () use ($tag, $perPage, $sort) {
$query = Artwork::query()
->public()
->published()
->tap(fn ($builder) => $this->maturity->applyViewerFilter($builder, request()->user()))
->whereHas('tags', fn ($tagQuery) => $tagQuery->where('tags.id', $tag->id))
->leftJoin('artwork_stats', 'artwork_stats.artwork_id', '=', 'artworks.id')
->select('artworks.*')
@@ -132,14 +165,14 @@ final class ArtworkSearchService
*/
public function byCategory(string $cat, int $perPage = 24, array $filters = []): LengthAwarePaginator
{
$cacheKey = "search.cat.{$cat}.page." . request()->get('page', 1);
$cacheKey = "search.cat.{$cat}.{$this->viewerCacheSegment()}.page." . request()->get('page', 1);
return Cache::remember($cacheKey, self::CACHE_TTL, function () use ($cat, $perPage) {
return Artwork::search('')
->options([
->options($this->viewerAwareOptions([
'filter' => self::BASE_FILTER . ' AND category = "' . addslashes($cat) . '"',
'sort' => ['created_at:desc'],
])
]))
->paginate($perPage);
});
}
@@ -181,14 +214,14 @@ final class ArtworkSearchService
$sort = array_key_exists($sort, self::CATEGORY_SORT_FIELDS) ? $sort : 'trending';
$page = (int) request()->get('page', 1);
$ttl = self::CATEGORY_SORT_TTL[$sort] ?? self::CACHE_TTL;
$cacheKey = "category.{$categorySlug}.{$sort}.{$page}";
$cacheKey = "category.{$categorySlug}.{$sort}.{$this->viewerCacheSegment()}.{$page}";
return Cache::remember($cacheKey, $ttl, function () use ($categorySlug, $sort, $perPage) {
return Artwork::search('')
->options([
->options($this->viewerAwareOptions([
'filter' => self::BASE_FILTER . ' AND category = "' . addslashes($categorySlug) . '"',
'sort' => self::CATEGORY_SORT_FIELDS[$sort],
])
]))
->paginate($perPage);
});
}
@@ -204,14 +237,14 @@ final class ArtworkSearchService
$sort = array_key_exists($sort, self::CATEGORY_SORT_FIELDS) ? $sort : 'trending';
$page = (int) request()->get('page', 1);
$ttl = self::CATEGORY_SORT_TTL[$sort] ?? self::CACHE_TTL;
$cacheKey = "content_type.{$contentTypeSlug}.{$sort}.{$page}";
$cacheKey = "content_type.{$contentTypeSlug}.{$sort}.{$this->viewerCacheSegment()}.{$page}";
return Cache::remember($cacheKey, $ttl, function () use ($contentTypeSlug, $sort, $perPage) {
return Artwork::search('')
->options([
->options($this->viewerAwareOptions([
'filter' => self::BASE_FILTER . ' AND content_type = "' . addslashes($contentTypeSlug) . '"',
'sort' => self::CATEGORY_SORT_FIELDS[$sort],
])
]))
->paginate($perPage);
});
}
@@ -230,7 +263,7 @@ final class ArtworkSearchService
return $this->popular($limit);
}
$cacheKey = "search.related.{$artwork->id}";
$cacheKey = "search.related.{$artwork->id}.{$this->viewerCacheSegment()}";
return Cache::remember($cacheKey, self::CACHE_TTL, function () use ($artwork, $tags, $limit) {
$tagFilters = implode(' OR ', array_map(
@@ -239,10 +272,10 @@ final class ArtworkSearchService
));
return Artwork::search('')
->options([
->options($this->viewerAwareOptions([
'filter' => self::BASE_FILTER . ' AND id != ' . $artwork->id . ' AND (' . $tagFilters . ')',
'sort' => ['views:desc', 'likes:desc'],
])
]))
->paginate($limit);
});
}
@@ -252,12 +285,12 @@ final class ArtworkSearchService
*/
public function popular(int $perPage = 24): LengthAwarePaginator
{
return Cache::remember('search.popular.page.' . request()->get('page', 1), self::CACHE_TTL, function () use ($perPage) {
return Cache::remember('search.popular.' . $this->viewerCacheSegment() . '.page.' . request()->get('page', 1), self::CACHE_TTL, function () use ($perPage) {
return Artwork::search('')
->options([
->options($this->viewerAwareOptions([
'filter' => self::BASE_FILTER,
'sort' => ['views:desc', 'likes:desc'],
])
]))
->paginate($perPage);
});
}
@@ -267,12 +300,12 @@ final class ArtworkSearchService
*/
public function recent(int $perPage = 24): LengthAwarePaginator
{
return Cache::remember('search.recent.page.' . request()->get('page', 1), self::CACHE_TTL, function () use ($perPage) {
return Cache::remember('search.recent.' . $this->viewerCacheSegment() . '.page.' . request()->get('page', 1), self::CACHE_TTL, function () use ($perPage) {
return Artwork::search('')
->options([
->options($this->viewerAwareOptions([
'filter' => self::BASE_FILTER,
'sort' => ['published_at_ts:desc'],
])
]))
->paginate($perPage);
});
}
@@ -291,15 +324,13 @@ final class ArtworkSearchService
$windowDays = $this->timeWindow->getTrendingWindowDays(30);
$cutoff = now()->subDays($windowDays)->toDateString();
// Include window in cache key so adaptive expansions surface immediately
$cacheKey = "discover.trending.{$windowDays}d.{$page}";
$cacheKey = "discover.trending.{$windowDays}d.{$this->viewerCacheSegment()}.{$page}";
return Cache::remember($cacheKey, self::CACHE_TTL, function () use ($perPage, $cutoff) {
return Artwork::search('')
->options([
'filter' => self::BASE_FILTER . ' AND created_at >= "' . $cutoff . '"',
'sort' => ['ranking_score:desc', 'engagement_velocity:desc', 'views:desc'],
])
->paginate($perPage);
return $this->searchWithThumbnailPreference([
'filter' => self::BASE_FILTER . ' AND created_at >= "' . $cutoff . '"',
'sort' => ['ranking_score:desc', 'engagement_velocity:desc', 'views:desc'],
], $perPage);
});
}
@@ -314,15 +345,13 @@ final class ArtworkSearchService
$page = (int) request()->get('page', 1);
$windowDays = $this->timeWindow->getTrendingWindowDays(30);
$cutoff = now()->subDays($windowDays)->toDateString();
$cacheKey = "discover.rising.{$windowDays}d.{$page}";
$cacheKey = "discover.rising.{$windowDays}d.{$this->viewerCacheSegment()}.{$page}";
return Cache::remember($cacheKey, 120, function () use ($perPage, $cutoff) {
return Artwork::search('')
->options([
'filter' => self::BASE_FILTER . ' AND created_at >= "' . $cutoff . '"',
'sort' => ['heat_score:desc', 'engagement_velocity:desc', 'published_at_ts:desc'],
])
->paginate($perPage);
return $this->searchWithThumbnailPreference([
'filter' => self::BASE_FILTER . ' AND created_at >= "' . $cutoff . '"',
'sort' => ['heat_score:desc', 'engagement_velocity:desc', 'published_at_ts:desc'],
], $perPage);
});
}
@@ -332,13 +361,11 @@ final class ArtworkSearchService
public function discoverFresh(int $perPage = 24): LengthAwarePaginator
{
$page = (int) request()->get('page', 1);
return Cache::remember("discover.fresh.{$page}", self::CACHE_TTL, function () use ($perPage) {
return Artwork::search('')
->options([
'filter' => self::BASE_FILTER,
'sort' => ['published_at_ts:desc'],
])
->paginate($perPage);
return Cache::remember("discover.fresh.{$this->viewerCacheSegment()}.{$page}", self::CACHE_TTL, function () use ($perPage) {
return $this->searchWithThumbnailPreference([
'filter' => self::BASE_FILTER,
'sort' => ['published_at_ts:desc'],
], $perPage);
});
}
@@ -348,13 +375,11 @@ final class ArtworkSearchService
public function discoverTopRated(int $perPage = 24): LengthAwarePaginator
{
$page = (int) request()->get('page', 1);
return Cache::remember("discover.top-rated.{$page}", self::CACHE_TTL, function () use ($perPage) {
return Artwork::search('')
->options([
'filter' => self::BASE_FILTER,
'sort' => ['likes:desc', 'views:desc'],
])
->paginate($perPage);
return Cache::remember("discover.top-rated.{$this->viewerCacheSegment()}.{$page}", self::CACHE_TTL, function () use ($perPage) {
return $this->searchWithThumbnailPreference([
'filter' => self::BASE_FILTER,
'sort' => ['likes:desc', 'views:desc'],
], $perPage);
});
}
@@ -364,13 +389,11 @@ final class ArtworkSearchService
public function discoverMostDownloaded(int $perPage = 24): LengthAwarePaginator
{
$page = (int) request()->get('page', 1);
return Cache::remember("discover.most-downloaded.{$page}", self::CACHE_TTL, function () use ($perPage) {
return Artwork::search('')
->options([
'filter' => self::BASE_FILTER,
'sort' => ['downloads:desc', 'views:desc'],
])
->paginate($perPage);
return Cache::remember("discover.most-downloaded.{$this->viewerCacheSegment()}.{$page}", self::CACHE_TTL, function () use ($perPage) {
return $this->searchWithThumbnailPreference([
'filter' => self::BASE_FILTER,
'sort' => ['downloads:desc', 'views:desc'],
], $perPage);
});
}
@@ -391,18 +414,28 @@ final class ArtworkSearchService
array_slice($tagSlugs, 0, 5)
));
$cacheKey = 'discover.by-tags.' . md5(implode(',', $tagSlugs));
$cacheKey = 'discover.by-tags.' . $this->viewerCacheSegment() . '.' . md5(implode(',', $tagSlugs));
return Cache::remember($cacheKey, self::CACHE_TTL, function () use ($tagFilter, $limit) {
return Artwork::search('')
->options([
'filter' => self::BASE_FILTER . ' AND (' . $tagFilter . ')',
'sort' => ['trending_score_7d:desc', 'likes:desc'],
])
->paginate($limit);
return $this->searchWithThumbnailPreference([
'filter' => self::BASE_FILTER . ' AND (' . $tagFilter . ')',
'sort' => ['trending_score_7d:desc', 'likes:desc'],
], $limit, true, 1);
});
}
private function viewerAwareOptions(array $options): array
{
$options['filter'] = $this->maturity->appendSearchFilter((string) ($options['filter'] ?? self::BASE_FILTER), request()->user());
return $options;
}
private function viewerCacheSegment(): string
{
return 'visibility-' . $this->maturity->viewerPreferences(request()->user())['visibility'];
}
/**
* Fresh artworks in given categories, sorted by publish timestamp desc.
* Used for personalized "Fresh in your favourite categories" section.
@@ -420,15 +453,13 @@ final class ArtworkSearchService
array_slice($categorySlugs, 0, 3)
));
$cacheKey = 'discover.by-cats.' . md5(implode(',', $categorySlugs));
$cacheKey = 'discover.by-cats.' . $this->viewerCacheSegment() . '.' . md5(implode(',', $categorySlugs));
return Cache::remember($cacheKey, self::CACHE_TTL, function () use ($catFilter, $limit) {
return Artwork::search('')
->options([
'filter' => self::BASE_FILTER . ' AND (' . $catFilter . ')',
'sort' => ['published_at_ts:desc'],
])
->paginate($limit);
return $this->searchWithThumbnailPreference([
'filter' => self::BASE_FILTER . ' AND (' . $catFilter . ')',
'sort' => ['published_at_ts:desc'],
], $limit, true, 1);
});
}
@@ -444,6 +475,52 @@ final class ArtworkSearchService
return in_array($field, $allowed, true) ? [$field, $dir] : [null, 'desc'];
}
private function rerankSearchCollectionByThumbnailHealth(Collection $items, bool $excludeMissing): Collection
{
if ($items->isEmpty()) {
return $items;
}
$ids = $items
->pluck('id')
->filter(fn ($id) => is_numeric($id) && (int) $id > 0)
->map(fn ($id) => (int) $id)
->values();
if ($ids->isEmpty()) {
return $items->values();
}
$missingIds = Artwork::query()
->whereIn('id', $ids)
->where('has_missing_thumbnails', true)
->pluck('id')
->map(fn ($id) => (int) $id)
->flip();
if ($missingIds->isEmpty()) {
return $items->values();
}
$healthy = $items->reject(fn ($item) => $missingIds->has((int) ($item->id ?? 0)));
if ($excludeMissing) {
return $healthy->values();
}
return $healthy
->concat($items->filter(fn ($item) => $missingIds->has((int) ($item->id ?? 0))))
->values();
}
private function determineSearchCandidatePoolSize(int $perPage, int $page): int
{
return min(
self::SEARCH_CANDIDATE_POOL_MAX,
max($perPage, $perPage * max(self::SEARCH_CANDIDATE_POOL_MULTIPLIER, $page + 2))
);
}
private function emptyPaginator(int $perPage): LengthAwarePaginator
{
return new \Illuminate\Pagination\LengthAwarePaginator([], 0, $perPage);