Files
SkinbaseNova/app/Services/HomepageService.php

1235 lines
52 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?php
declare(strict_types=1);
namespace App\Services;
use App\Models\Artwork;
use App\Models\Leaderboard;
use App\Models\Tag;
use App\Services\ArtworkSearchService;
use App\Services\EarlyGrowth\EarlyGrowth;
use App\Services\EarlyGrowth\GridFiller;
use App\Services\Recommendations\RecommendationFeedResolver;
use App\Services\UserPreferenceService;
use App\Support\AvatarUrl;
use App\Models\Collection as CollectionModel;
use Illuminate\Contracts\Cache\Repository as CacheRepository;
use Illuminate\Support\Collection;
use Illuminate\Support\Str;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
use Illuminate\Database\QueryException;
use cPad\Plugins\News\Models\NewsArticle;
use App\Services\Maturity\ArtworkMaturityService;
/**
* HomepageService
*
* Aggregates all data sections needed for the Nova homepage.
* All results are cached for CACHE_TTL seconds.
* Controllers stay thin — only call the aggregator.
*/
final class HomepageService
{
private const CACHE_TTL = 300; // 5 minutes
private const DEFAULT_ARTWORK_RAIL_LIMIT = 10;
private const ARTWORK_SERIALIZATION_RELATIONS = [
'user:id,name,username',
'user.profile:user_id,avatar_hash',
'group:id,name,slug,headline,avatar_path,followers_count',
'categories:id,name,slug,content_type_id,sort_order',
'categories.contentType:id,name,slug',
];
public function __construct(
private readonly ArtworkService $artworks,
private readonly ArtworkSearchService $search,
private readonly ArtworkMaturityService $maturity,
private readonly UserPreferenceService $prefs,
private readonly RecommendationFeedResolver $feedResolver,
private readonly GridFiller $gridFiller,
private readonly CollectionDiscoveryService $collectionDiscovery,
private readonly CollectionService $collectionService,
private readonly CollectionSurfaceService $collectionSurfaces,
private readonly GroupDiscoveryService $groupDiscovery,
private readonly LeaderboardService $leaderboards,
) {}
// ─────────────────────────────────────────────────────────────────────────
// Public aggregator
// ─────────────────────────────────────────────────────────────────────────
/**
* Return all homepage section data as a single array ready to JSON-encode.
*/
public function all(): array
{
return $this->guestPayloadCache()->remember(
$this->guestPayloadCacheKey(),
$this->guestPayloadCacheTtl(),
fn (): array => $this->buildGuestPayload(),
);
}
public function warmGuestPayloadCache(): array
{
$payload = $this->buildGuestPayload();
$this->guestPayloadCache()->put(
$this->guestPayloadCacheKey(),
$payload,
$this->guestPayloadCacheTtl(),
);
return $payload;
}
public function clearGuestPayloadCache(): void
{
$this->guestPayloadCache()->forget($this->guestPayloadCacheKey());
}
public function clearFeaturedAndMedalCaches(): void
{
$this->clearGuestPayloadCache();
foreach (['visibility-hide', 'visibility-blur', 'visibility-show'] as $segment) {
Cache::forget("homepage.hero.{$segment}");
Cache::forget("homepage.community-favorites.8.{$segment}");
Cache::forget("homepage.hall-of-fame.8.{$segment}");
}
}
public function guestPayloadCacheStoreName(): string
{
$configuredStore = (string) config('homepage.cache_store', 'homepage');
if (is_array(config('cache.stores.' . $configuredStore))) {
return $configuredStore;
}
return (string) config('cache.default', 'database');
}
private function buildGuestPayload(): array
{
return [
'hero' => $this->getHeroArtwork(),
'community_favorites' => $this->getCommunityFavorites(),
'hall_of_fame' => $this->getHallOfFame(),
'rising' => $this->getRising(),
'trending' => $this->getTrending(),
'fresh' => $this->getFreshUploads(),
'collections_featured' => $this->getFeaturedCollections(),
'collections_trending' => $this->getTrendingCollections(),
'collections_editorial' => $this->getEditorialCollections(),
'collections_community' => $this->getCommunityCollections(),
'groups' => $this->getHomepageGroups(),
'tags' => $this->getPopularTags(),
'creators' => $this->getCreatorSpotlight(),
'news' => $this->getNews(),
];
}
private function guestPayloadCache(): CacheRepository
{
return Cache::store($this->guestPayloadCacheStoreName());
}
private function guestPayloadCacheKey(): string
{
return (string) config('homepage.guest_payload_key', 'homepage.payload.guest');
}
private function guestPayloadCacheTtl(): int
{
return max(60, (int) config('homepage.guest_payload_ttl_seconds', 1800));
}
/**
* Personalized homepage data for an authenticated user.
*
* Sections:
* 1. user_data welcome row counts (messages, notifications, new followers)
* 2. from_following artworks from creators you follow
* 3. for_you personalized recommendation preview
* 4. trending same trending feed as guests
* 5. by_categories fresh uploads in user's favourite categories
* 6. suggested_creators creators the user might want to follow
* 7. tags / creators / news shared with guest homepage
*/
public function allForUser(\App\Models\User $user): array
{
$prefs = $this->prefs->build($user);
return [
'is_logged_in' => true,
'user_data' => $this->getUserData($user),
'hero' => $this->getHeroArtwork(),
'community_favorites' => $this->getCommunityFavorites(),
'hall_of_fame' => $this->getHallOfFame(),
'for_you' => $this->getForYouPreview($user),
'from_following' => $this->getFollowingFeed($user, $prefs),
'rising' => $this->getRising(),
'trending' => $this->getTrending(),
'fresh' => $this->getFreshUploads(),
'collections_featured' => $this->getFeaturedCollections(),
'collections_recent' => $this->getRecentCollections(),
'collections_trending' => $this->getTrendingCollections(),
'collections_editorial' => $this->getEditorialCollections(),
'collections_community' => $this->getCommunityCollections(),
'groups' => $this->getHomepageGroups($user),
'by_tags' => $this->getByTags($prefs['top_tags'] ?? []),
'by_categories' => $this->getByCategories($prefs['top_categories'] ?? []),
'suggested_creators' => $this->getSuggestedCreators($user, $prefs),
'tags' => $this->getPopularTags(),
'creators' => $this->getCreatorSpotlight(),
'news' => $this->getNews(),
'preferences' => [
'top_tags' => $prefs['top_tags'] ?? [],
'top_categories' => $prefs['top_categories'] ?? [],
],
];
}
/**
* "For You" homepage preview backed by the personalized feed engine.
*/
public function getForYouPreview(\App\Models\User $user, int $limit = 12): array
{
try {
$feed = $this->feedResolver->getFeed((int) $user->id, max($limit * 3, $limit));
$algoVersion = (string) ($feed['meta']['algo_version'] ?? '');
$discoveryEndpoint = route('api.discovery.events.store');
$hideArtworkEndpoint = route('api.discovery.feedback.hide-artwork');
$dislikeTagEndpoint = route('api.discovery.feedback.dislike-tag');
return $this->filterMissingThumbnailPayloadItems(collect($feed['data'] ?? []))
->take($limit)
->map(function (array $item) use ($algoVersion, $discoveryEndpoint, $hideArtworkEndpoint, $dislikeTagEndpoint): array {
$reason = (string) ($item['reason'] ?? 'Picked for you');
return [
'id' => (int) ($item['id'] ?? 0),
'title' => (string) ($item['title'] ?? 'Untitled'),
'name' => (string) ($item['title'] ?? 'Untitled'),
'slug' => (string) ($item['slug'] ?? ''),
'author' => (string) ($item['author'] ?? 'Artist'),
'author_id' => isset($item['author_id']) ? (int) $item['author_id'] : null,
'author_username' => (string) ($item['username'] ?? ''),
'author_avatar' => $item['avatar_url'] ?? null,
'avatar_url' => $item['avatar_url'] ?? null,
'published_as_type' => (string) ($item['published_as_type'] ?? ''),
'publisher' => is_array($item['publisher'] ?? null) ? $item['publisher'] : null,
'thumb' => $item['thumbnail_url'] ?? null,
'thumb_url' => $item['thumbnail_url'] ?? null,
'thumb_srcset' => $item['thumbnail_srcset'] ?? null,
'category_name' => (string) ($item['category_name'] ?? ''),
'category_slug' => (string) ($item['category_slug'] ?? ''),
'content_type_name' => (string) ($item['content_type_name'] ?? ''),
'content_type_slug' => (string) ($item['content_type_slug'] ?? ''),
'url' => (string) ($item['url'] ?? ('/art/' . ((int) ($item['id'] ?? 0)) . '/' . ($item['slug'] ?? ''))),
'width' => isset($item['width']) ? (int) $item['width'] : null,
'height' => isset($item['height']) ? (int) $item['height'] : null,
'published_at' => $item['published_at'] ?? null,
'primary_tag' => $item['primary_tag'] ?? null,
'tags' => is_array($item['tags'] ?? null) ? $item['tags'] : [],
'recommendation_source' => (string) ($item['source'] ?? 'mixed'),
'recommendation_reason' => $reason,
'recommendation_score' => isset($item['score']) ? round((float) $item['score'], 4) : null,
'recommendation_algo_version' => (string) ($item['algo_version'] ?? $algoVersion),
'recommendation_surface' => 'homepage-for-you',
'discovery_endpoint' => $discoveryEndpoint,
'hide_artwork_endpoint' => $hideArtworkEndpoint,
'dislike_tag_endpoint' => $dislikeTagEndpoint,
'metric_badge' => [
'label' => $reason,
'className' => 'bg-sky-500/14 text-sky-100 ring-sky-300/30 max-w-[15rem] truncate',
],
];
})->values()->all();
} catch (\Throwable $e) {
Log::warning('HomepageService::getForYouPreview failed', ['error' => $e->getMessage()]);
return [];
}
}
public function getTrendingCollections(int $limit = 6): array
{
$surfaceCollections = $this->collectionSurfaces->resolveSurfaceItems('homepage.trending_collections', $limit);
if ($surfaceCollections->isNotEmpty()) {
return $this->collectionService->mapCollectionCardPayloads($surfaceCollections, false);
}
return $this->collectionService->mapCollectionCardPayloads(
$this->collectionDiscovery->publicTrendingCollections($limit),
false
);
}
public function getEditorialCollections(int $limit = 6): array
{
$surfaceCollections = $this->collectionSurfaces->resolveSurfaceItems('homepage.editorial_collections', $limit);
if ($surfaceCollections->isNotEmpty()) {
return $this->collectionService->mapCollectionCardPayloads($surfaceCollections, false);
}
return $this->collectionService->mapCollectionCardPayloads(
$this->collectionDiscovery->publicCollectionsByType(CollectionModel::TYPE_EDITORIAL, $limit),
false
);
}
public function getCommunityCollections(int $limit = 6): array
{
$surfaceCollections = $this->collectionSurfaces->resolveSurfaceItems('homepage.community_collections', $limit);
if ($surfaceCollections->isNotEmpty()) {
return $this->collectionService->mapCollectionCardPayloads($surfaceCollections, false);
}
return $this->collectionService->mapCollectionCardPayloads(
$this->collectionDiscovery->publicCollectionsByType(CollectionModel::TYPE_COMMUNITY, $limit),
false
);
}
public function getFeaturedCollections(int $limit = 6): array
{
$surfaceCollections = $this->collectionSurfaces->resolveSurfaceItems('homepage.featured_collections', $limit);
if ($surfaceCollections->isNotEmpty()) {
return $this->collectionService->mapCollectionCardPayloads($surfaceCollections, false);
}
return $this->collectionService->mapCollectionCardPayloads(
$this->collectionDiscovery->publicFeaturedCollections($limit),
false,
);
}
public function getRecentCollections(int $limit = 6): array
{
return $this->collectionService->mapCollectionCardPayloads(
$this->collectionDiscovery->publicRecentCollections($limit),
false,
);
}
public function getHomepageGroups(?\App\Models\User $viewer = null): array
{
if (! $viewer) {
return Cache::remember('homepage.groups', self::CACHE_TTL, fn (): array => $this->buildHomepageGroups());
}
return $this->buildHomepageGroups($viewer);
}
private function buildHomepageGroups(?\App\Models\User $viewer = null): array
{
$featured = $this->groupDiscovery->surfaceCards($viewer, 'featured', 4);
$spotlight = $featured[0] ?? null;
return [
'spotlight' => $spotlight,
'featured' => $featured,
'recruiting' => $this->groupDiscovery->surfaceCards($viewer, 'recruiting', 4),
'rising' => $this->groupDiscovery->surfaceCards($viewer, 'new_rising', 4),
'leaderboard' => $this->leaderboards->getLeaderboard(Leaderboard::TYPE_GROUP, Leaderboard::PERIOD_MONTHLY, 5),
'count' => $this->groupDiscovery->publicGroupCount(),
];
}
// ─────────────────────────────────────────────────────────────────────────
// Sections
// ─────────────────────────────────────────────────────────────────────────
/**
* Hero artwork: first item from the featured list.
*/
public function getHeroArtwork(): ?array
{
return Cache::remember('homepage.hero.' . $this->viewerCacheSegment(), self::CACHE_TTL, function (): ?array {
$artwork = $this->artworks->getFeaturedArtworkWinner();
if (! $artwork instanceof Artwork) {
$artwork = Artwork::query()
->public()
->published()
->tap(fn ($builder) => $this->maturity->applyViewerFilter($builder, request()->user()))
->withoutMissingThumbnails()
->with(self::ARTWORK_SERIALIZATION_RELATIONS)
->latest('published_at')
->first();
}
if ($artwork instanceof Artwork) {
$artwork->loadMissing(self::ARTWORK_SERIALIZATION_RELATIONS);
}
return $artwork ? $this->serializeArtwork($artwork, 'lg') : null;
});
}
public function getCommunityFavorites(int $limit = self::DEFAULT_ARTWORK_RAIL_LIMIT): array
{
return Cache::remember("homepage.community-favorites.{$limit}.{$this->viewerCacheSegment()}", self::CACHE_TTL, function () use ($limit): array {
try {
$artworks = Artwork::query()
->public()
->published()
->tap(fn ($builder) => $this->maturity->applyViewerFilter($builder, request()->user()))
->withoutMissingThumbnails()
->with(array_merge(self::ARTWORK_SERIALIZATION_RELATIONS, [
'awardStat:artwork_id,gold_count,silver_count,bronze_count,score_total,score_7d,score_30d,last_medaled_at,updated_at',
]))
->leftJoin('artwork_medal_stats as aas', 'aas.artwork_id', '=', 'artworks.id')
->select('artworks.*')
->whereRaw('COALESCE(aas.score_30d, 0) > 0')
->orderByRaw('COALESCE(aas.score_30d, 0) DESC')
->orderByDesc('artworks.published_at')
->limit($limit)
->get();
return $this->fillArtworkRailFromArchive($artworks, $limit)
->map(fn (Artwork $artwork): array => $this->serializeArtworkWithMedalBadge($artwork, 'community_favorites'))
->values()
->all();
} catch (\Throwable $e) {
Log::warning('HomepageService::getCommunityFavorites failed', ['error' => $e->getMessage()]);
return [];
}
});
}
public function getHallOfFame(int $limit = self::DEFAULT_ARTWORK_RAIL_LIMIT): array
{
return Cache::remember("homepage.hall-of-fame.{$limit}.{$this->viewerCacheSegment()}", self::CACHE_TTL, function () use ($limit): array {
try {
$artworks = Artwork::query()
->public()
->published()
->tap(fn ($builder) => $this->maturity->applyViewerFilter($builder, request()->user()))
->withoutMissingThumbnails()
->with(array_merge(self::ARTWORK_SERIALIZATION_RELATIONS, [
'awardStat:artwork_id,gold_count,silver_count,bronze_count,score_total,score_7d,score_30d,last_medaled_at,updated_at',
]))
->leftJoin('artwork_medal_stats as aas', 'aas.artwork_id', '=', 'artworks.id')
->select('artworks.*')
->whereRaw('COALESCE(aas.score_total, 0) > 0')
->orderByRaw('COALESCE(aas.score_total, 0) DESC')
->orderByRaw('COALESCE(aas.last_medaled_at, artworks.published_at) DESC')
->limit($limit)
->get();
return $this->fillArtworkRailFromArchive($artworks, $limit)
->map(fn (Artwork $artwork): array => $this->serializeArtworkWithMedalBadge($artwork, 'hall_of_fame'))
->values()
->all();
} catch (\Throwable $e) {
Log::warning('HomepageService::getHallOfFame failed', ['error' => $e->getMessage()]);
return [];
}
});
}
/**
* Rising Now: up to 10 artworks sorted by heat_score (updated every 15 min).
*
* Surfaces artworks with the fastest recent engagement growth.
* Falls back to DB ORDER BY heat_score if Meilisearch is unavailable.
*/
public function getRising(int $limit = 10): array
{
$cutoff = now()->subDays(30)->toDateString();
return Cache::remember("homepage.rising.{$limit}.{$this->viewerCacheSegment()}", 120, function () use ($limit, $cutoff): array {
try {
$results = $this->search->searchWithThumbnailPreference([
'filter' => 'is_public = true AND is_approved = true AND created_at >= "' . $cutoff . '"',
'sort' => ['heat_score:desc', 'engagement_velocity:desc', 'created_at:desc'],
], $limit, true, 1);
$items = $this->prepareArtworksForSerialization($this->searchResultCollection($results));
if ($items->isEmpty()) {
return $this->getRisingFromDb($limit);
}
if ($this->collectionHasNoRisingMomentum($this->searchResultCollection($results))) {
return $this->getRisingLowSignalFromDb($limit);
}
return $this->fillArtworkRailFromArchive($items, $limit)
->map(fn ($a) => $this->serializeArtwork($a))
->values()
->all();
} catch (\Throwable $e) {
Log::warning('HomepageService::getRising Meilisearch unavailable, DB fallback', [
'error' => $e->getMessage(),
]);
return $this->getRisingFromDb($limit);
}
});
}
/**
* DB-only fallback for rising (Meilisearch unavailable).
*/
private function getRisingFromDb(int $limit): array
{
$artworks = Artwork::public()
->published()
->tap(fn ($builder) => $this->maturity->applyViewerFilter($builder, request()->user()))
->withoutMissingThumbnails()
->with(self::ARTWORK_SERIALIZATION_RELATIONS)
->leftJoin('artwork_stats', 'artwork_stats.artwork_id', '=', 'artworks.id')
->select('artworks.*')
->where('artworks.published_at', '>=', now()->subDays(30))
->orderByDesc('artwork_stats.heat_score')
->orderByDesc('artwork_stats.engagement_velocity')
->limit($limit)
->get();
return $this->fillArtworkRailFromArchive($artworks, $limit)
->map(fn ($a) => $this->serializeArtwork($a))
->values()
->all();
}
private function getRisingLowSignalFromDb(int $limit): array
{
$artworks = Artwork::public()
->published()
->tap(fn ($builder) => $this->maturity->applyViewerFilter($builder, request()->user()))
->withoutMissingThumbnails()
->with(self::ARTWORK_SERIALIZATION_RELATIONS)
->leftJoinSub($this->risingRecentActivitySubquery(), 'recent_rising_activity', function ($join): void {
$join->on('recent_rising_activity.artwork_id', '=', 'artworks.id');
})
->select('artworks.*')
->where('artworks.published_at', '>=', now()->subDays(30))
->orderByRaw('COALESCE(recent_rising_activity.recent_signal_24h, 0) DESC')
->orderByDesc('artworks.published_at')
->orderByDesc('artworks.id')
->limit($limit)
->get();
return $this->fillArtworkRailFromArchive($artworks, $limit)
->map(fn ($a) => $this->serializeArtwork($a))
->values()
->all();
}
/**
* Trending: up to 12 artworks sorted by Ranking V2 `ranking_score`.
*
* Uses Meilisearch sorted by the V2 score (updated every 30 min).
* Falls back to DB ORDER BY ranking_score if Meilisearch is unavailable.
* Spec §6: ranking_score, last 30 days, highlight high-velocity artworks.
*/
public function getTrending(int $limit = 10): array
{
$cutoff = now()->subDays(30)->toDateString();
return Cache::remember("homepage.trending.{$limit}.{$this->viewerCacheSegment()}", self::CACHE_TTL, function () use ($limit, $cutoff): array {
try {
$results = $this->search->searchWithThumbnailPreference([
'filter' => 'is_public = true AND is_approved = true AND created_at >= "' . $cutoff . '"',
'sort' => ['ranking_score:desc', 'engagement_velocity:desc', 'views:desc'],
], $limit, true, 1);
$items = $this->prepareArtworksForSerialization($this->searchResultCollection($results));
if ($items->isEmpty()) {
return $this->getTrendingFromDb($limit);
}
return $this->fillArtworkRailFromArchive($items, $limit)
->map(fn ($a) => $this->serializeArtwork($a))
->values()
->all();
} catch (\Throwable $e) {
Log::warning('HomepageService::getTrending Meilisearch unavailable, DB fallback', [
'error' => $e->getMessage(),
]);
return $this->getTrendingFromDb($limit);
}
});
}
/**
* DB-only fallback for trending (Meilisearch unavailable).
* Joins artwork_stats to sort by V2 ranking_score.
*/
private function getTrendingFromDb(int $limit): array
{
$artworks = Artwork::public()
->published()
->tap(fn ($builder) => $this->maturity->applyViewerFilter($builder, request()->user()))
->withoutMissingThumbnails()
->with(self::ARTWORK_SERIALIZATION_RELATIONS)
->leftJoin('artwork_stats', 'artwork_stats.artwork_id', '=', 'artworks.id')
->select('artworks.*')
->where('artworks.published_at', '>=', now()->subDays(30))
->orderByDesc('artwork_stats.ranking_score')
->orderByDesc('artwork_stats.engagement_velocity')
->limit($limit)
->get();
return $this->fillArtworkRailFromArchive($artworks, $limit)
->map(fn ($a) => $this->serializeArtwork($a))
->values()
->all();
}
/**
* Fresh uploads: latest 10 approved public artworks.
* EGS: GridFiller ensures the section is never empty even on low-traffic days.
*/
public function getFreshUploads(int $limit = 10): array
{
// Include EGS mode in cache key so toggling EGS updates the section within TTL
$egsKey = EarlyGrowth::gridFillerEnabled() ? 'egs-' . EarlyGrowth::mode() : 'std';
$cacheKey = "homepage.fresh.{$limit}.{$egsKey}.{$this->viewerCacheSegment()}";
return Cache::remember($cacheKey, self::CACHE_TTL, function () use ($limit): array {
$artworks = Artwork::public()
->published()
->tap(fn ($builder) => $this->maturity->applyViewerFilter($builder, request()->user()))
->withoutMissingThumbnails()
->with(self::ARTWORK_SERIALIZATION_RELATIONS)
->orderByDesc('published_at')
->limit($limit)
->get();
// EGS: fill up to $limit when fresh uploads are sparse
$artworks = $this->gridFiller->fillCollection($artworks, $limit);
return $artworks->map(fn ($a) => $this->serializeArtwork($a))->values()->all();
});
}
/**
* Top 12 popular tags by usage_count.
*/
public function getPopularTags(int $limit = 12): array
{
return Cache::remember("homepage.tags.{$limit}", self::CACHE_TTL, function () use ($limit): array {
return Tag::query()
->where('is_active', true)
->orderByDesc('usage_count')
->limit($limit)
->get(['id', 'name', 'slug', 'usage_count'])
->map(fn ($t) => [
'id' => $t->id,
'name' => $t->name,
'slug' => $t->slug,
'count' => (int) $t->usage_count,
])
->values()
->all();
});
}
/**
* Creator spotlight: top 6 creators by weekly uploads, awards, and engagement.
* "Weekly uploads" drives ranking per spec; ties broken by total awards then views.
*/
public function getCreatorSpotlight(int $limit = 6): array
{
return Cache::remember("homepage.creators.{$limit}", self::CACHE_TTL, function () use ($limit): array {
try {
$since = now()->subWeek();
$weeklyUploads = Artwork::query()
->selectRaw('user_id, COUNT(*) as weekly_uploads')
->where('is_public', true)
->where('is_approved', true)
->whereNull('deleted_at')
->whereNotNull('published_at')
->where('published_at', '<=', now())
->where('published_at', '>=', $since)
->groupBy('user_id');
$rows = DB::table('users as u')
->leftJoin('user_profiles as up', 'up.user_id', '=', 'u.id')
->leftJoin('user_statistics as us', 'us.user_id', '=', 'u.id')
->leftJoinSub($weeklyUploads, 'weekly_uploads', function ($join): void {
$join->on('weekly_uploads.user_id', '=', 'u.id');
})
->select(
'u.id',
'u.name',
'u.username',
'up.avatar_hash',
DB::raw('COALESCE(us.uploads_count, 0) as upload_count'),
DB::raw('COALESCE(weekly_uploads.weekly_uploads, 0) as weekly_uploads'),
DB::raw('COALESCE(us.artwork_views_received_count, 0) as total_views'),
DB::raw('COALESCE(us.awards_received_count, 0) as total_awards')
)
->whereNull('u.deleted_at')
->where('u.is_active', true)
->where(function ($query): void {
$query->where('us.uploads_count', '>', 0)
->orWhere('weekly_uploads.weekly_uploads', '>', 0);
})
->orderByDesc('weekly_uploads')
->orderByDesc('total_awards')
->orderByDesc('total_views')
->limit($limit)
->get();
$userIds = $rows->pluck('id')->all();
$latestArtworkIds = Artwork::public()
->published()
->withoutMissingThumbnails()
->whereIn('user_id', $userIds)
->whereNotNull('hash')
->whereNotNull('thumb_ext')
->selectRaw('MAX(id) as id')
->groupBy('user_id')
->pluck('id')
->all();
$thumbsByUser = Artwork::query()
->whereIn('id', $latestArtworkIds)
->get(['id', 'user_id', 'hash', 'thumb_ext'])
->keyBy('user_id');
return $rows->map(function ($u) use ($thumbsByUser) {
$artworkForBg = $thumbsByUser->get($u->id);
$bgThumb = $artworkForBg ? $artworkForBg->thumbUrl('md') : null;
return [
'id' => $u->id,
'name' => $u->name,
'uploads' => (int) $u->upload_count,
'weekly_uploads' => (int) $u->weekly_uploads,
'views' => (int) $u->total_views,
'awards' => (int) $u->total_awards,
'url' => $u->username ? '/@' . $u->username : '/profile/' . $u->id,
'avatar' => AvatarUrl::forUser((int) $u->id, $u->avatar_hash ?: null, 128),
'bg_thumb' => $bgThumb,
];
})->values()->all();
} catch (QueryException $e) {
Log::warning('HomepageService::getCreatorSpotlight DB error', [
'exception' => $e->getMessage(),
]);
return [];
}
});
}
/**
* Latest 5 news posts from the forum news category.
*/
public function getNews(int $limit = 5): array
{
return Cache::remember("homepage.news.{$limit}", self::CACHE_TTL, function () use ($limit): array {
try {
$articles = NewsArticle::query()
->with('category')
->published()
->editorialOrder()
->limit($limit)
->get();
if ($articles->isNotEmpty()) {
return $articles->map(fn (NewsArticle $article) => [
'id' => $article->id,
'title' => $article->title,
'date' => $article->published_at,
'url' => route('news.show', ['slug' => $article->slug]),
'eyebrow' => $article->category?->name ?: $article->type_label,
'excerpt' => Str::limit(strip_tags((string) ($article->excerpt ?: $article->rendered_content)), 120),
])->values()->all();
}
$items = DB::table('forum_threads as t')
->leftJoin('forum_categories as c', 'c.id', '=', 't.category_id')
->select('t.id', 't.title', 't.created_at', 't.slug as thread_slug')
->where(function ($q) {
$q->where('t.category_id', 2876)
->orWhereIn('c.slug', ['news', 'forum-news']);
})
->whereNull('t.deleted_at')
->orderByDesc('t.created_at')
->limit($limit)
->get();
return $items->map(fn ($row) => [
'id' => $row->id,
'title' => $row->title,
'date' => $row->created_at,
'url' => '/forum/thread/' . $row->id . '-' . ($row->thread_slug ?? 'post'),
])->values()->all();
} catch (QueryException $e) {
Log::warning('HomepageService::getNews DB error', [
'exception' => $e->getMessage(),
]);
return [];
}
});
}
// ─────────────────────────────────────────────────────────────────────────
// Personalized sections (auth only)
// ─────────────────────────────────────────────────────────────────────────
/**
* Welcome-row counts: unread messages, unread notifications, new followers.
* Returns quickly from DB using simple COUNTs; never throws.
*/
public function getUserData(\App\Models\User $user): array
{
try {
$unreadMessages = DB::table('conversations as c')
->join('conversation_participants as cp', 'cp.conversation_id', '=', 'c.id')
->join('messages as m', 'm.conversation_id', '=', 'c.id')
->where('cp.user_id', $user->id)
->where('m.user_id', '!=', $user->id)
->whereColumn('m.created_at', '>', 'cp.last_read_at')
->distinct('c.id')
->count('c.id');
} catch (\Throwable) {
$unreadMessages = 0;
}
try {
$unreadNotifications = DB::table('notifications')
->where('user_id', $user->id)
->whereNull('read_at')
->count();
} catch (\Throwable) {
$unreadNotifications = 0;
}
return [
'id' => $user->id,
'name' => $user->name,
'username' => $user->username,
'avatar' => AvatarUrl::forUser((int) $user->id, $user->profile?->avatar_hash ?? null, 64),
'messages_unread' => (int) $unreadMessages,
'notifications_unread' => (int) $unreadNotifications,
'followers_count' => (int) ($user->statistics?->followers_count ?? 0),
];
}
/**
* Suggested creators: active public uploaders NOT already followed by the user,
* ranked by follower count. Optionally filtered to the user's top categories.
*/
public function getSuggestedCreators(\App\Models\User $user, array $prefs, int $limit = 8): array
{
return Cache::remember(
"homepage.suggested.{$user->id}",
300,
function () use ($user, $prefs, $limit): array {
try {
$followingIds = $prefs['followed_creators'] ?? [];
$query = DB::table('users as u')
->join('user_profiles as up', 'up.user_id', '=', 'u.id')
->leftJoin('user_statistics as us', 'us.user_id', '=', 'u.id')
->select(
'u.id',
'u.name',
'u.username',
'up.avatar_hash',
DB::raw('COALESCE(us.followers_count, 0) as followers_count'),
DB::raw('COALESCE(us.uploads_count, 0) as artworks_count'),
)
->where('u.id', '!=', $user->id)
->whereNotIn('u.id', array_merge($followingIds, [$user->id]))
->where('u.is_active', true)
->orderByDesc('followers_count')
->orderByDesc('artworks_count')
->limit($limit);
$rows = $query->get();
return $rows->map(fn ($u) => [
'id' => $u->id,
'name' => $u->name,
'username' => $u->username,
'url' => $u->username ? '/@' . $u->username : '/profile/' . $u->id,
'avatar' => AvatarUrl::forUser((int) $u->id, $u->avatar_hash ?: null, 64),
'followers_count' => (int) $u->followers_count,
'artworks_count' => (int) $u->artworks_count,
])->values()->all();
} catch (\Throwable $e) {
Log::warning('HomepageService::getSuggestedCreators failed', ['error' => $e->getMessage()]);
return [];
}
}
);
}
/**
* Latest artworks from creators the user follows (max 12).
*/
public function getFollowingFeed(\App\Models\User $user, array $prefs): array
{
$followingIds = $prefs['followed_creators'] ?? [];
if (empty($followingIds)) {
return [];
}
return Cache::remember(
"homepage.following.{$user->id}.{$this->viewerCacheSegment()}",
60, // short TTL personal data
function () use ($followingIds): array {
$artworks = Artwork::public()
->published()
->tap(fn ($builder) => $this->maturity->applyViewerFilter($builder, request()->user()))
->withoutMissingThumbnails()
->with(self::ARTWORK_SERIALIZATION_RELATIONS)
->whereIn('user_id', $followingIds)
->orderByDesc('published_at')
->limit(10)
->get();
return $artworks->map(fn ($a) => $this->serializeArtwork($a))->values()->all();
}
);
}
/**
* Fresh artworks matching the user's favourite tags (max 12).
* Powered by Meilisearch.
*/
public function getByTags(array $tagSlugs): array
{
if (empty($tagSlugs)) {
return [];
}
try {
$results = $this->search->discoverByTags($tagSlugs, 12);
$items = $this->fillArtworkRailFromArchive(
$this->searchResultCollection($results),
12,
static fn ($query) => $query->whereHas('tags', function ($tagQuery) use ($tagSlugs): void {
$tagQuery->whereIn('slug', array_slice($tagSlugs, 0, 5));
}),
);
return $items
->map(fn ($a) => $this->serializeArtwork($a))
->values()
->all();
} catch (\Throwable $e) {
Log::warning('HomepageService::getByTags failed', ['error' => $e->getMessage()]);
return [];
}
}
/**
* Fresh artworks in the user's favourite categories (max 12).
* Powered by Meilisearch.
*/
public function getByCategories(array $categorySlugs): array
{
if (empty($categorySlugs)) {
return [];
}
try {
$results = $this->search->discoverByCategories($categorySlugs, 12);
$items = $this->fillArtworkRailFromArchive(
$this->searchResultCollection($results),
12,
static fn ($query) => $query->whereHas('categories', function ($categoryQuery) use ($categorySlugs): void {
$categoryQuery->whereIn('slug', array_slice($categorySlugs, 0, 3));
}),
);
return $items
->map(fn ($a) => $this->serializeArtwork($a))
->values()
->all();
} catch (\Throwable $e) {
Log::warning('HomepageService::getByCategories failed', ['error' => $e->getMessage()]);
return [];
}
}
// ─────────────────────────────────────────────────────────────────────────
// Helpers
// ─────────────────────────────────────────────────────────────────────────
/**
* @return Collection<int, Artwork>
*/
private function searchResultCollection(mixed $results): Collection
{
if ($results instanceof Collection) {
return $results;
}
if (is_object($results) && method_exists($results, 'getCollection')) {
$collection = $results->getCollection();
if ($collection instanceof Collection) {
return $collection;
}
}
return collect();
}
/**
* Ensure serialized artwork payloads do not trigger lazy-loading per item.
*
* @param Collection<int, Artwork> $artworks
* @return Collection<int, Artwork>
*/
private function prepareArtworksForSerialization(Collection $artworks): Collection
{
if ($artworks->isEmpty()) {
return $artworks;
}
$artworks->loadMissing(self::ARTWORK_SERIALIZATION_RELATIONS);
return $artworks
->reject(fn ($artwork) => (bool) ($artwork->has_missing_thumbnails ?? false))
->values();
}
/**
* Backfill sparse homepage rails with recent archive artworks while preserving lead ordering.
*
* @param Collection<int, Artwork> $artworks
* @return Collection<int, Artwork>
*/
private function fillArtworkRailFromArchive(Collection $artworks, int $limit, ?callable $fallbackConstraint = null): Collection
{
$artworks = $this->prepareArtworksForSerialization($artworks)->take($limit)->values();
if ($artworks->count() >= $limit) {
return $artworks;
}
$needed = $limit - $artworks->count();
$excludeIds = $artworks
->pluck('id')
->filter(fn ($id) => is_numeric($id) && (int) $id > 0)
->map(fn ($id) => (int) $id)
->values()
->all();
$fallback = Artwork::query()
->public()
->published()
->tap(fn ($builder) => $this->maturity->applyViewerFilter($builder, request()->user()))
->withoutMissingThumbnails()
->with(self::ARTWORK_SERIALIZATION_RELATIONS)
->when($fallbackConstraint !== null, fn ($query) => $fallbackConstraint($query))
->when(! empty($excludeIds), fn ($query) => $query->whereNotIn('artworks.id', $excludeIds))
->orderByDesc('artworks.published_at')
->orderByDesc('artworks.id')
->limit($needed)
->get();
return $artworks
->concat($fallback)
->unique('id')
->take($limit)
->values();
}
/**
* @param Collection<int, array<string, mixed>> $items
* @return Collection<int, array<string, mixed>>
*/
private function filterMissingThumbnailPayloadItems(Collection $items): 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;
}
$missingIds = Artwork::query()
->whereIn('id', $ids)
->where('has_missing_thumbnails', true)
->pluck('id')
->map(fn ($id) => (int) $id)
->flip();
if ($missingIds->isEmpty()) {
return $items;
}
return $items
->reject(fn (array $item) => $missingIds->has((int) ($item['id'] ?? 0)))
->values();
}
private function collectionHasNoRisingMomentum(Collection $items): bool
{
if ($items->isEmpty()) {
return true;
}
return $items->every(function ($item): bool {
$heat = (float) ($item->heat_score ?? $item->stats?->heat_score ?? 0);
$velocity = (float) ($item->engagement_velocity ?? $item->stats?->engagement_velocity ?? 0);
return $heat <= 0.0 && $velocity <= 0.0;
});
}
private function risingRecentActivitySubquery()
{
$since = now()->startOfHour()->subHours(24);
return DB::table('artwork_metric_snapshots_hourly as rising_snapshots')
->selectRaw('rising_snapshots.artwork_id')
->selectRaw('(
COALESCE(MAX(rising_snapshots.views_count) - MIN(rising_snapshots.views_count), 0)
+ (COALESCE(MAX(rising_snapshots.downloads_count) - MIN(rising_snapshots.downloads_count), 0) * 3)
+ (COALESCE(MAX(rising_snapshots.favourites_count) - MIN(rising_snapshots.favourites_count), 0) * 4)
+ (COALESCE(MAX(rising_snapshots.comments_count) - MIN(rising_snapshots.comments_count), 0) * 5)
+ (COALESCE(MAX(rising_snapshots.shares_count) - MIN(rising_snapshots.shares_count), 0) * 6)
) as recent_signal_24h')
->where('rising_snapshots.bucket_hour', '>=', $since)
->groupBy('rising_snapshots.artwork_id');
}
private function serializeArtwork(Artwork $artwork, string $preferSize = 'md'): array
{
$awardStat = $artwork->relationLoaded('awardStat') ? $artwork->awardStat : null;
$thumbSm = $artwork->thumbUrl('sm');
$thumbMd = $artwork->thumbUrl('md');
$thumbLg = $artwork->thumbUrl('lg');
$thumbXl = $artwork->thumbUrl('xl');
$thumb = $preferSize === 'lg' ? ($thumbLg ?? $thumbMd) : ($thumbMd ?? $thumbLg);
$primaryCategory = $artwork->categories->sortBy('sort_order')->first();
$thumbSrcset = collect([
$thumbSm ? $thumbSm . ' 320w' : null,
$thumbMd ? $thumbMd . ' 640w' : null,
$thumbLg ? $thumbLg . ' 1280w' : null,
$thumbXl ? $thumbXl . ' 1920w' : null,
])->filter()->implode(', ');
$publisher = $this->mapArtworkPublisherPayload($artwork);
$isGroupPublisher = ($publisher['type'] ?? null) === 'group';
$authorId = $artwork->user_id;
$authorName = $isGroupPublisher ? ((string) ($publisher['name'] ?? 'Skinbase Group')) : ($artwork->user?->name ?? 'Artist');
$authorUsername = $isGroupPublisher ? '' : ($artwork->user?->username ?? '');
$avatarHash = $artwork->user?->profile?->avatar_hash ?? null;
$authorAvatar = $isGroupPublisher
? ($publisher['avatar_url'] ?? null)
: AvatarUrl::forUser((int) $authorId, $avatarHash, 64);
return $this->maturity->decoratePayload([
'id' => $artwork->id,
'title' => $artwork->title ?? 'Untitled',
'slug' => $artwork->slug,
'author' => $authorName,
'author_id' => $authorId,
'author_username' => $authorUsername,
'author_avatar' => $authorAvatar,
'published_as_type' => $artwork->publishedAsType(),
'publisher' => $publisher,
'thumb' => $thumb,
'thumb_sm' => $thumbSm,
'thumb_md' => $thumbMd,
'thumb_lg' => $thumbLg,
'thumb_xl' => $thumbXl,
'thumb_srcset' => $thumbSrcset !== '' ? $thumbSrcset : null,
'category_name' => $primaryCategory->name ?? '',
'category_slug' => $primaryCategory->slug ?? '',
'content_type_name' => $primaryCategory?->contentType?->name ?? '',
'content_type_slug' => $primaryCategory?->contentType?->slug ?? '',
'url' => '/art/' . $artwork->id . '/' . ($artwork->slug ?? ''),
'width' => $artwork->width,
'height' => $artwork->height,
'published_at' => $artwork->published_at?->toIso8601String(),
'medals' => [
'gold' => (int) ($awardStat?->gold_count ?? 0),
'silver' => (int) ($awardStat?->silver_count ?? 0),
'bronze' => (int) ($awardStat?->bronze_count ?? 0),
'score' => (int) ($awardStat?->score_total ?? 0),
'score_7d' => (int) ($awardStat?->score_7d ?? 0),
'score_30d' => (int) ($awardStat?->score_30d ?? 0),
],
], $artwork, request()->user());
}
/**
* @return array<string, mixed>|null
*/
private function mapArtworkPublisherPayload(Artwork $artwork): ?array
{
if ($artwork->publishedAsType() !== Artwork::PUBLISHED_AS_GROUP) {
return null;
}
$group = $artwork->relationLoaded('group') ? $artwork->group : $artwork->group()->first();
if (! $group) {
return null;
}
return [
'id' => (int) $group->id,
'type' => 'group',
'name' => (string) $group->name,
'slug' => (string) $group->slug,
'headline' => (string) ($group->headline ?? ''),
'avatar_url' => $group->avatarUrl(),
'profile_url' => $group->publicUrl(),
'followers_count' => (int) ($group->followers_count ?? 0),
];
}
private function serializeArtworkWithMedalBadge(Artwork $artwork, string $surface): array
{
$awardStat = $artwork->relationLoaded('awardStat') ? $artwork->awardStat : null;
$payload = $this->serializeArtwork($artwork);
$score = $surface === 'community_favorites'
? (int) ($awardStat?->score_30d ?? 0)
: (int) ($awardStat?->score_total ?? 0);
$payload['metric_badge'] = [
'label' => $surface === 'community_favorites'
? '30d medals: ' . $score
: 'All-time medals: ' . $score,
'className' => $surface === 'community_favorites'
? 'bg-amber-500/14 text-amber-100 ring-amber-300/30'
: 'bg-cyan-500/14 text-cyan-100 ring-cyan-300/30',
];
return $payload;
}
private function viewerCacheSegment(): string
{
return 'visibility-' . $this->maturity->viewerPreferences(request()->user())['visibility'];
}
}