$this->getHeroArtwork(), 'rising' => $this->getRising(), 'trending' => $this->getTrending(), 'fresh' => $this->getFreshUploads(), 'tags' => $this->getPopularTags(), 'creators' => $this->getCreatorSpotlight(), 'news' => $this->getNews(), ]; } /** * 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. trending – same trending feed as guests * 4. by_tags – artworks matching user's top tags (Trending For You) * 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(), 'for_you' => $this->getForYouPreview($user), 'from_following' => $this->getFollowingFeed($user, $prefs), 'rising' => $this->getRising(), 'trending' => $this->getTrending(), 'fresh' => $this->getFreshUploads(), '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: first 12 results from the Phase 1 personalised feed. * * Uses RecommendationService which handles Meilisearch retrieval, PHP reranking, * diversity controls, and its own Redis cache layer. */ public function getForYouPreview(\App\Models\User $user, int $limit = 12): array { try { return $this->reco->forYouPreview($user, $limit); } catch (\Throwable $e) { Log::warning('HomepageService::getForYouPreview failed', ['error' => $e->getMessage()]); return []; } } // ───────────────────────────────────────────────────────────────────────── // Sections // ───────────────────────────────────────────────────────────────────────── /** * Hero artwork: first item from the featured list. */ public function getHeroArtwork(): ?array { return Cache::remember('homepage.hero', self::CACHE_TTL, function (): ?array { $result = $this->artworks->getFeaturedArtworks(null, 1); /** @var \Illuminate\Database\Eloquent\Model|\null $artwork */ if ($result instanceof \Illuminate\Pagination\LengthAwarePaginator) { $artwork = $result->getCollection()->first(); } elseif ($result instanceof \Illuminate\Support\Collection) { $artwork = $result->first(); } elseif (is_array($result)) { $artwork = $result[0] ?? null; } else { $artwork = null; } return $artwork ? $this->serializeArtwork($artwork, 'lg') : null; }); } /** * 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}", 120, function () use ($limit, $cutoff): array { try { $results = Artwork::search('') ->options([ 'filter' => 'is_public = true AND is_approved = true AND created_at >= "' . $cutoff . '"', 'sort' => ['heat_score:desc', 'engagement_velocity:desc', 'created_at:desc'], ]) ->paginate($limit, 'page', 1); $results->getCollection()->load(['user:id,name,username', 'user.profile:user_id,avatar_hash']); if ($results->isEmpty()) { return $this->getRisingFromDb($limit); } return $results->getCollection() ->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 { return Artwork::public() ->published() ->with(['user:id,name,username', 'user.profile:user_id,avatar_hash']) ->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() ->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}", self::CACHE_TTL, function () use ($limit, $cutoff): array { try { $results = Artwork::search('') ->options([ 'filter' => 'is_public = true AND is_approved = true AND created_at >= "' . $cutoff . '"', 'sort' => ['ranking_score:desc', 'engagement_velocity:desc', 'views:desc'], ]) ->paginate($limit, 'page', 1); $results->getCollection()->load(['user:id,name,username', 'user.profile:user_id,avatar_hash']); if ($results->isEmpty()) { return $this->getTrendingFromDb($limit); } return $results->getCollection() ->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 { return Artwork::public() ->published() ->with(['user:id,name,username', 'user.profile:user_id,avatar_hash']) ->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() ->map(fn ($a) => $this->serializeArtwork($a)) ->values() ->all(); } /** * Fresh uploads: latest 12 approved public artworks. */ public function getFreshUploads(int $limit = 10): array { return Cache::remember("homepage.fresh.{$limit}", self::CACHE_TTL, function () use ($limit): array { $artworks = Artwork::public() ->published() ->with(['user:id,name,username', 'user.profile:user_id,avatar_hash']) ->orderByDesc('published_at') ->limit($limit) ->get(); 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(); $rows = DB::table('artworks') ->join('users as u', 'u.id', '=', 'artworks.user_id') ->leftJoin('user_profiles as up', 'up.user_id', '=', 'u.id') ->leftJoin('artwork_awards as aw', 'aw.artwork_id', '=', 'artworks.id') ->leftJoin('artwork_stats as s', 's.artwork_id', '=', 'artworks.id') ->select( 'u.id', 'u.name', 'u.username', 'up.avatar_hash', DB::raw('COUNT(DISTINCT artworks.id) as upload_count'), DB::raw('SUM(CASE WHEN artworks.published_at >= \'' . $since->toDateTimeString() . '\' THEN 1 ELSE 0 END) as weekly_uploads'), DB::raw('COALESCE(SUM(s.views), 0) as total_views'), DB::raw('COUNT(DISTINCT aw.id) as total_awards') ) ->where('artworks.is_public', true) ->where('artworks.is_approved', true) ->whereNull('artworks.deleted_at') ->whereNotNull('artworks.published_at') ->groupBy('u.id', 'u.name', 'u.username', 'up.avatar_hash') ->orderByDesc('weekly_uploads') ->orderByDesc('total_awards') ->orderByDesc('total_views') ->limit($limit) ->get(); $userIds = $rows->pluck('id')->all(); // Pick one random artwork thumbnail per creator for the card background. $thumbsByUser = Artwork::public() ->published() ->whereIn('user_id', $userIds) ->whereNotNull('hash') ->whereNotNull('thumb_ext') ->inRandomOrder() ->get(['id', 'user_id', 'hash', 'thumb_ext']) ->groupBy('user_id'); return $rows->map(function ($u) use ($thumbsByUser) { $artworkForBg = $thumbsByUser->get($u->id)?->first(); $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 { $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.artworks_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}", 60, // short TTL – personal data function () use ($followingIds): array { $artworks = Artwork::public() ->published() ->with(['user:id,name,username', 'user.profile:user_id,avatar_hash']) ->whereIn('user_id', $followingIds) ->orderByDesc('published_at') ->limit(10) ->get(); return $artworks->map(fn ($a) => $this->serializeArtwork($a))->values()->all(); } ); } /** * Artworks matching the user's top tags (max 12). * Powered by Meilisearch. */ public function getByTags(array $tagSlugs): array { if (empty($tagSlugs)) { return []; } try { $results = $this->search->discoverByTags($tagSlugs, 12); return $results->getCollection() ->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); return $results->getCollection() ->map(fn ($a) => $this->serializeArtwork($a)) ->values() ->all(); } catch (\Throwable $e) { Log::warning('HomepageService::getByCategories failed', ['error' => $e->getMessage()]); return []; } } // ───────────────────────────────────────────────────────────────────────── // Helpers // ───────────────────────────────────────────────────────────────────────── private function serializeArtwork(Artwork $artwork, string $preferSize = 'md'): array { $thumbMd = $artwork->thumbUrl('md'); $thumbLg = $artwork->thumbUrl('lg'); $thumb = $preferSize === 'lg' ? ($thumbLg ?? $thumbMd) : ($thumbMd ?? $thumbLg); $authorId = $artwork->user_id; $authorName = $artwork->user?->name ?? 'Artist'; $authorUsername = $artwork->user?->username ?? ''; $avatarHash = $artwork->user?->profile?->avatar_hash ?? null; $authorAvatar = AvatarUrl::forUser((int) $authorId, $avatarHash, 40); return [ 'id' => $artwork->id, 'title' => $artwork->title ?? 'Untitled', 'slug' => $artwork->slug, 'author' => $authorName, 'author_id' => $authorId, 'author_username' => $authorUsername, 'author_avatar' => $authorAvatar, 'thumb' => $thumb, 'thumb_md' => $thumbMd, 'thumb_lg' => $thumbLg, 'url' => '/art/' . $artwork->id . '/' . ($artwork->slug ?? ''), 'width' => $artwork->width, 'height' => $artwork->height, 'published_at' => $artwork->published_at?->toIso8601String(), ]; } }