id, $resolvedLimit); return Cache::remember($cacheKey, now()->addMinutes(15), function () use ($viewer, $resolvedLimit): array { try { return $this->buildSuggestions($viewer, $resolvedLimit); } catch (\Throwable $e) { Log::warning('UserSuggestionService failed', [ 'viewer_id' => (int) $viewer->id, 'error' => $e->getMessage(), ]); return []; } }); } private function buildSuggestions(User $viewer, int $limit): array { $profile = $this->preferenceBuilder->build($viewer); $followingIds = DB::table('user_followers') ->where('follower_id', $viewer->id) ->pluck('user_id') ->map(fn ($id) => (int) $id) ->values() ->all(); $excludedIds = array_values(array_unique(array_merge($followingIds, [(int) $viewer->id]))); $topTagSlugs = array_slice($profile->topTagSlugs ?? [], 0, 10); $topCategoryIds = $this->topCategoryIdsForViewer((int) $viewer->id); $candidates = []; foreach ($this->mutualFollowCandidates($viewer, $followingIds) as $candidate) { $candidates[$candidate['id']] = $candidate; } foreach ($this->sharedInterestCandidates($viewer, $topTagSlugs, $topCategoryIds) as $candidate) { if (isset($candidates[$candidate['id']])) { $candidates[$candidate['id']]['score'] += $candidate['score']; $candidates[$candidate['id']]['reason'] = $candidates[$candidate['id']]['reason'] . ' ยท ' . $candidate['reason']; continue; } $candidates[$candidate['id']] = $candidate; } foreach ($this->trendingCreatorCandidates($excludedIds) as $candidate) { if (! isset($candidates[$candidate['id']])) { $candidates[$candidate['id']] = $candidate; } } foreach ($this->newActiveCreatorCandidates($excludedIds) as $candidate) { if (! isset($candidates[$candidate['id']])) { $candidates[$candidate['id']] = $candidate; } } $ranked = array_values(array_filter( $candidates, fn (array $candidate): bool => ! in_array((int) $candidate['id'], $excludedIds, true) )); usort($ranked, fn (array $left, array $right): int => $right['score'] <=> $left['score']); return array_map(function (array $candidate) use ($viewer): array { $context = $this->followService->relationshipContext((int) $viewer->id, (int) $candidate['id']); return [ 'id' => (int) $candidate['id'], 'username' => (string) $candidate['username'], 'name' => (string) ($candidate['name'] ?? $candidate['username']), 'profile_url' => '/@' . strtolower((string) $candidate['username']), 'avatar_url' => AvatarUrl::forUser((int) $candidate['id'], $candidate['avatar_hash'] ?? null, 64), 'followers_count' => (int) ($candidate['followers_count'] ?? 0), 'following_count' => (int) ($candidate['following_count'] ?? 0), 'reason' => (string) ($candidate['reason'] ?? 'Recommended creator'), 'context' => $context, ]; }, array_slice($ranked, 0, $limit)); } private function mutualFollowCandidates(User $viewer, array $followingIds): array { if ($followingIds === []) { return []; } return DB::table('user_followers as uf') ->join('users as u', 'u.id', '=', 'uf.user_id') ->leftJoin('user_profiles as up', 'up.user_id', '=', 'u.id') ->leftJoin('user_statistics as us', 'us.user_id', '=', 'u.id') ->whereIn('uf.follower_id', $followingIds) ->where('uf.user_id', '!=', $viewer->id) ->where('u.is_active', true) ->whereNull('u.deleted_at') ->selectRaw('u.id, u.username, u.name, up.avatar_hash, COALESCE(us.followers_count, 0) as followers_count, COALESCE(us.following_count, 0) as following_count, COUNT(*) as overlap_count') ->groupBy('u.id', 'u.username', 'u.name', 'up.avatar_hash', 'us.followers_count', 'us.following_count') ->orderByDesc('overlap_count') ->limit(20) ->get() ->map(fn ($row) => [ 'id' => (int) $row->id, 'username' => $row->username, 'name' => $row->name, 'avatar_hash' => $row->avatar_hash, 'followers_count' => (int) $row->followers_count, 'following_count' => (int) $row->following_count, 'score' => (float) $row->overlap_count * 3.0, 'reason' => 'Popular in your network', ]) ->all(); } private function sharedInterestCandidates(User $viewer, array $topTagSlugs, array $topCategoryIds): array { if ($topTagSlugs === [] && $topCategoryIds === []) { return []; } $query = 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') ->join('artworks as a', 'a.user_id', '=', 'u.id') ->leftJoin('artwork_tag as at', 'at.artwork_id', '=', 'a.id') ->leftJoin('tags as t', 't.id', '=', 'at.tag_id') ->leftJoin('artwork_category as ac', 'ac.artwork_id', '=', 'a.id') ->where('u.id', '!=', $viewer->id) ->where('u.is_active', true) ->whereNull('u.deleted_at') ->where('a.is_public', true) ->where('a.is_approved', true) ->whereNull('a.deleted_at') ->whereNotNull('a.published_at'); $query->where(function ($builder) use ($topTagSlugs, $topCategoryIds): void { if ($topTagSlugs !== []) { $builder->orWhereIn('t.slug', $topTagSlugs); } if ($topCategoryIds !== []) { $builder->orWhereIn('ac.category_id', $topCategoryIds); } }); return $query ->selectRaw('u.id, u.username, u.name, up.avatar_hash, COALESCE(us.followers_count, 0) as followers_count, COALESCE(us.following_count, 0) as following_count, COUNT(DISTINCT t.id) as matched_tags, COUNT(DISTINCT ac.category_id) as matched_categories') ->groupBy('u.id', 'u.username', 'u.name', 'up.avatar_hash', 'us.followers_count', 'us.following_count') ->orderByDesc(DB::raw('COUNT(DISTINCT t.id) + COUNT(DISTINCT ac.category_id)')) ->limit(20) ->get() ->map(fn ($row) => [ 'id' => (int) $row->id, 'username' => $row->username, 'name' => $row->name, 'avatar_hash' => $row->avatar_hash, 'followers_count' => (int) $row->followers_count, 'following_count' => (int) $row->following_count, 'score' => ((float) $row->matched_tags * 2.0) + (float) $row->matched_categories, 'reason' => 'Shared tags and categories', ]) ->all(); } private function trendingCreatorCandidates(array $excludedIds): array { return 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') ->join('artworks as a', 'a.user_id', '=', 'u.id') ->whereNotIn('u.id', $excludedIds) ->where('u.is_active', true) ->whereNull('u.deleted_at') ->where('a.is_public', true) ->where('a.is_approved', true) ->whereNull('a.deleted_at') ->where('a.published_at', '>=', now()->subDays(30)) ->selectRaw('u.id, u.username, u.name, up.avatar_hash, COALESCE(us.followers_count, 0) as followers_count, COALESCE(us.following_count, 0) as following_count, COUNT(a.id) as recent_artworks') ->groupBy('u.id', 'u.username', 'u.name', 'up.avatar_hash', 'us.followers_count', 'us.following_count') ->orderByDesc('followers_count') ->orderByDesc('recent_artworks') ->limit(10) ->get() ->map(fn ($row) => [ 'id' => (int) $row->id, 'username' => $row->username, 'name' => $row->name, 'avatar_hash' => $row->avatar_hash, 'followers_count' => (int) $row->followers_count, 'following_count' => (int) $row->following_count, 'score' => ((float) $row->followers_count * 0.1) + (float) $row->recent_artworks, 'reason' => 'Trending creator', ]) ->all(); } private function newActiveCreatorCandidates(array $excludedIds): array { return 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') ->join('artworks as a', 'a.user_id', '=', 'u.id') ->whereNotIn('u.id', $excludedIds) ->where('u.is_active', true) ->whereNull('u.deleted_at') ->where('u.created_at', '>=', now()->subDays(60)) ->where('a.is_public', true) ->where('a.is_approved', true) ->whereNull('a.deleted_at') ->where('a.published_at', '>=', now()->subDays(14)) ->selectRaw('u.id, u.username, u.name, up.avatar_hash, COALESCE(us.followers_count, 0) as followers_count, COALESCE(us.following_count, 0) as following_count, COUNT(a.id) as recent_artworks') ->groupBy('u.id', 'u.username', 'u.name', 'up.avatar_hash', 'us.followers_count', 'us.following_count') ->orderByDesc('recent_artworks') ->orderByDesc('followers_count') ->limit(10) ->get() ->map(fn ($row) => [ 'id' => (int) $row->id, 'username' => $row->username, 'name' => $row->name, 'avatar_hash' => $row->avatar_hash, 'followers_count' => (int) $row->followers_count, 'following_count' => (int) $row->following_count, 'score' => ((float) $row->recent_artworks * 2.0) + ((float) $row->followers_count * 0.05), 'reason' => 'New active creator', ]) ->all(); } private function topCategoryIdsForViewer(int $viewerId): array { return DB::table('artwork_category as ac') ->join('artworks as a', 'a.id', '=', 'ac.artwork_id') ->leftJoin('artwork_favourites as af', 'af.artwork_id', '=', 'a.id') ->where(function ($query) use ($viewerId): void { $query ->where('a.user_id', $viewerId) ->orWhere('af.user_id', $viewerId); }) ->selectRaw('ac.category_id, COUNT(*) as weight') ->groupBy('ac.category_id') ->orderByDesc('weight') ->limit(6) ->pluck('category_id') ->map(fn ($id) => (int) $id) ->values() ->all(); } }