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 */ 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 $artworks * @return Collection */ 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 $artworks * @return Collection */ 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> $items * @return Collection> */ 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|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']; } }