normalizePeriod($period); $rows = $normalizedPeriod === Leaderboard::PERIOD_ALL_TIME ? $this->allTimeCreatorRows() : $this->windowedCreatorRows($this->periodStart($normalizedPeriod)); return $this->persistRows(Leaderboard::TYPE_CREATOR, $normalizedPeriod, $rows, self::CREATOR_STORE_LIMIT); } public function calculateArtworkLeaderboard(string $period): int { $normalizedPeriod = $this->normalizePeriod($period); $rows = $normalizedPeriod === Leaderboard::PERIOD_ALL_TIME ? $this->allTimeArtworkRows() : $this->windowedArtworkRows($this->periodStart($normalizedPeriod)); return $this->persistRows(Leaderboard::TYPE_ARTWORK, $normalizedPeriod, $rows, self::ENTITY_STORE_LIMIT); } public function calculateStoryLeaderboard(string $period): int { $normalizedPeriod = $this->normalizePeriod($period); $rows = $normalizedPeriod === Leaderboard::PERIOD_ALL_TIME ? $this->allTimeStoryRows() : $this->windowedStoryRows($this->periodStart($normalizedPeriod)); return $this->persistRows(Leaderboard::TYPE_STORY, $normalizedPeriod, $rows, self::ENTITY_STORE_LIMIT); } public function calculateGroupLeaderboard(string $period): int { $normalizedPeriod = $this->normalizePeriod($period); $rows = $normalizedPeriod === Leaderboard::PERIOD_ALL_TIME ? $this->allTimeGroupRows() : $this->windowedGroupRows($this->periodStart($normalizedPeriod)); return $this->persistRows(Leaderboard::TYPE_GROUP, $normalizedPeriod, $rows, self::ENTITY_STORE_LIMIT); } public function refreshAll(): array { $results = []; foreach ([ Leaderboard::TYPE_CREATOR, Leaderboard::TYPE_ARTWORK, Leaderboard::TYPE_GROUP, Leaderboard::TYPE_STORY, ] as $type) { foreach ($this->periods() as $period) { $results[$type][$period] = match ($type) { Leaderboard::TYPE_CREATOR => $this->calculateCreatorLeaderboard($period), Leaderboard::TYPE_ARTWORK => $this->calculateArtworkLeaderboard($period), Leaderboard::TYPE_GROUP => $this->calculateGroupLeaderboard($period), Leaderboard::TYPE_STORY => $this->calculateStoryLeaderboard($period), }; } } return $results; } public function getLeaderboard(string $type, string $period, int $limit = 50): array { $normalizedType = $this->normalizeType($type); $normalizedPeriod = $this->normalizePeriod($period); $limit = max(1, min($limit, 100)); return Cache::remember( $this->cacheKey($normalizedType, $normalizedPeriod, $limit), self::CACHE_TTL_SECONDS, function () use ($normalizedType, $normalizedPeriod, $limit): array { $items = $this->leaderboardRows($normalizedType, $normalizedPeriod, $limit); if ($items->isEmpty()) { $this->generateLeaderboard($normalizedType, $normalizedPeriod); $items = $this->leaderboardRows($normalizedType, $normalizedPeriod, $limit); } if ($items->isEmpty()) { return [ 'type' => $normalizedType, 'period' => $normalizedPeriod, 'items' => [], ]; } $entities = match ($normalizedType) { Leaderboard::TYPE_CREATOR => $this->creatorEntities($items->pluck('entity_id')->all()), Leaderboard::TYPE_ARTWORK => $this->artworkEntities($items->pluck('entity_id')->all()), Leaderboard::TYPE_GROUP => $this->groupEntities($items->pluck('entity_id')->all()), Leaderboard::TYPE_STORY => $this->storyEntities($items->pluck('entity_id')->all()), }; return [ 'type' => $normalizedType, 'period' => $normalizedPeriod, 'items' => $items->values()->map(function (Leaderboard $row, int $index) use ($entities): array { return [ 'rank' => $index + 1, 'score' => round((float) $row->score, 1), 'entity' => $entities[(int) $row->entity_id] ?? null, ]; })->filter(fn (array $item): bool => $item['entity'] !== null)->values()->all(), ]; } ); } public function creatorRankSummary(int $userId, string $period = Leaderboard::PERIOD_WEEKLY): ?array { $normalizedPeriod = $this->normalizePeriod($period); return Cache::remember( sprintf('leaderboard:creator-rank:%d:%s', $userId, $normalizedPeriod), self::CACHE_TTL_SECONDS, function () use ($userId, $normalizedPeriod): ?array { $row = Leaderboard::query() ->where('type', Leaderboard::TYPE_CREATOR) ->where('period', $normalizedPeriod) ->where('entity_id', $userId) ->first(['entity_id', 'score']); if (! $row) { return null; } $higherScores = Leaderboard::query() ->where('type', Leaderboard::TYPE_CREATOR) ->where('period', $normalizedPeriod) ->where(function ($query) use ($row): void { $query->where('score', '>', $row->score) ->orWhere(function ($ties) use ($row): void { $ties->where('score', '=', $row->score) ->where('entity_id', '<', $row->entity_id); }); }) ->count(); return [ 'period' => $normalizedPeriod, 'rank' => $higherScores + 1, 'score' => round((float) $row->score, 1), ]; } ); } public function periods(): array { return [ Leaderboard::PERIOD_DAILY, Leaderboard::PERIOD_WEEKLY, Leaderboard::PERIOD_MONTHLY, Leaderboard::PERIOD_ALL_TIME, ]; } public function normalizePeriod(string $period): string { return match (strtolower(trim($period))) { 'daily' => Leaderboard::PERIOD_DAILY, 'weekly' => Leaderboard::PERIOD_WEEKLY, 'monthly' => Leaderboard::PERIOD_MONTHLY, 'all', 'all_time', 'all-time' => Leaderboard::PERIOD_ALL_TIME, default => Leaderboard::PERIOD_WEEKLY, }; } private function normalizeType(string $type): string { return match (strtolower(trim($type))) { 'creator', 'creators' => Leaderboard::TYPE_CREATOR, 'artwork', 'artworks' => Leaderboard::TYPE_ARTWORK, 'group', 'groups' => Leaderboard::TYPE_GROUP, 'story', 'stories' => Leaderboard::TYPE_STORY, default => Leaderboard::TYPE_CREATOR, }; } private function leaderboardRows(string $type, string $period, int $limit): Collection { return Leaderboard::query() ->where('type', $type) ->where('period', $period) ->orderByDesc('score') ->orderBy('entity_id') ->limit($limit) ->get(['entity_id', 'score']) ->values(); } private function generateLeaderboard(string $type, string $period): void { match ($type) { Leaderboard::TYPE_CREATOR => $this->calculateCreatorLeaderboard($period), Leaderboard::TYPE_ARTWORK => $this->calculateArtworkLeaderboard($period), Leaderboard::TYPE_GROUP => $this->calculateGroupLeaderboard($period), Leaderboard::TYPE_STORY => $this->calculateStoryLeaderboard($period), }; } private function periodStart(string $period): CarbonImmutable { $now = CarbonImmutable::now(); return match ($period) { Leaderboard::PERIOD_DAILY => $now->subDay(), Leaderboard::PERIOD_WEEKLY => $now->subWeek(), Leaderboard::PERIOD_MONTHLY => $now->subMonth(), default => $now->subWeek(), }; } private function persistRows(string $type, string $period, Collection $rows, int $limit): int { $trimmed = $rows ->sortByDesc('score') ->take($limit) ->values(); DB::transaction(function () use ($type, $period, $trimmed): void { Leaderboard::query() ->where('type', $type) ->where('period', $period) ->delete(); if ($trimmed->isNotEmpty()) { $timestamp = now(); Leaderboard::query()->insert( $trimmed->map(fn (array $row): array => [ 'type' => $type, 'period' => $period, 'entity_id' => (int) $row['entity_id'], 'score' => round((float) $row['score'], 2), 'created_at' => $timestamp, 'updated_at' => $timestamp, ])->all() ); } }); $this->flushCache($type, $period); return $trimmed->count(); } private function flushCache(string $type, string $period): void { foreach ([10, 25, 50, 100] as $limit) { Cache::forget($this->cacheKey($type, $period, $limit)); } if ($type === Leaderboard::TYPE_CREATOR) { Cache::forget('leaderboard:top-creators-widget:' . $period); } } private function cacheKey(string $type, string $period, int $limit): string { return sprintf('leaderboard:%s:%s:%d', $type, $period, $limit); } private function allTimeCreatorRows(): Collection { return User::query() ->from('users') ->leftJoin('user_statistics as us', 'us.user_id', '=', 'users.id') ->whereNull('users.deleted_at') ->where('users.is_active', true) ->select([ 'users.id', DB::raw('COALESCE(users.xp, 0) as xp'), DB::raw('COALESCE(us.followers_count, 0) as followers_count'), DB::raw('COALESCE(us.favorites_received_count, 0) as likes_received'), DB::raw('COALESCE(us.artwork_views_received_count, 0) as artwork_views'), ]) ->get() ->map(function ($row): array { $score = ((int) $row->xp * 1) + ((int) $row->followers_count * 10) + ((int) $row->likes_received * 2) + ((int) $row->artwork_views * 0.1); return [ 'entity_id' => (int) $row->id, 'score' => $score, ]; }) ->filter(fn (array $row): bool => $row['score'] > 0) ->values(); } private function windowedCreatorRows(CarbonImmutable $start): Collection { $xp = DB::table('user_xp_logs') ->select('user_id', DB::raw('SUM(xp) as xp')) ->where('created_at', '>=', $start) ->groupBy('user_id'); $followers = DB::table('user_followers') ->select('user_id', DB::raw('COUNT(*) as followers_count')) ->where('created_at', '>=', $start) ->groupBy('user_id'); $likes = DB::table('artwork_likes as likes') ->join('artworks as artworks', 'artworks.id', '=', 'likes.artwork_id') ->select('artworks.user_id', DB::raw('COUNT(*) as likes_received')) ->where('likes.created_at', '>=', $start) ->groupBy('artworks.user_id'); $views = DB::query() ->fromSub($this->artworkSnapshotDeltas($start), 'deltas') ->join('artworks as artworks', 'artworks.id', '=', 'deltas.artwork_id') ->select('artworks.user_id', DB::raw('SUM(deltas.views_delta) as artwork_views')) ->groupBy('artworks.user_id'); return User::query() ->from('users') ->leftJoinSub($xp, 'xp', 'xp.user_id', '=', 'users.id') ->leftJoinSub($followers, 'followers', 'followers.user_id', '=', 'users.id') ->leftJoinSub($likes, 'likes', 'likes.user_id', '=', 'users.id') ->leftJoinSub($views, 'views', 'views.user_id', '=', 'users.id') ->whereNull('users.deleted_at') ->where('users.is_active', true) ->select([ 'users.id', DB::raw('COALESCE(xp.xp, 0) as xp'), DB::raw('COALESCE(followers.followers_count, 0) as followers_count'), DB::raw('COALESCE(likes.likes_received, 0) as likes_received'), DB::raw('COALESCE(views.artwork_views, 0) as artwork_views'), ]) ->get() ->map(function ($row): array { $score = ((int) $row->xp * 1) + ((int) $row->followers_count * 10) + ((int) $row->likes_received * 2) + ((float) $row->artwork_views * 0.1); return [ 'entity_id' => (int) $row->id, 'score' => $score, ]; }) ->filter(fn (array $row): bool => $row['score'] > 0) ->values(); } private function allTimeArtworkRows(): Collection { return Artwork::query() ->from('artworks') ->join('artwork_stats as stats', 'stats.artwork_id', '=', 'artworks.id') ->public() ->select([ 'artworks.id', DB::raw('COALESCE(stats.favorites, 0) as likes_count'), DB::raw('COALESCE(stats.views, 0) as views_count'), DB::raw('COALESCE(stats.downloads, 0) as downloads_count'), DB::raw('COALESCE(stats.comments_count, 0) as comments_count'), ]) ->get() ->map(fn ($row): array => [ 'entity_id' => (int) $row->id, 'score' => ((int) $row->likes_count * 3) + ((int) $row->views_count * 1) + ((int) $row->downloads_count * 5) + ((int) $row->comments_count * 4), ]) ->filter(fn (array $row): bool => $row['score'] > 0) ->values(); } private function windowedArtworkRows(CarbonImmutable $start): Collection { $views = $this->artworkSnapshotDeltas($start); $likes = DB::table('artwork_likes') ->select('artwork_id', DB::raw('COUNT(*) as favourites_delta')) ->where('created_at', '>=', $start) ->groupBy('artwork_id'); $downloads = DB::table('artwork_downloads') ->select('artwork_id', DB::raw('COUNT(*) as downloads_delta')) ->where('created_at', '>=', $start) ->groupBy('artwork_id'); $comments = DB::table('artwork_comments') ->select('artwork_id', DB::raw('COUNT(*) as comments_delta')) ->where('created_at', '>=', $start) ->where('is_approved', true) ->whereNull('deleted_at') ->groupBy('artwork_id'); return Artwork::query() ->from('artworks') ->leftJoinSub($views, 'views', 'views.artwork_id', '=', 'artworks.id') ->leftJoinSub($likes, 'likes', 'likes.artwork_id', '=', 'artworks.id') ->leftJoinSub($downloads, 'downloads', 'downloads.artwork_id', '=', 'artworks.id') ->leftJoinSub($comments, 'comments', 'comments.artwork_id', '=', 'artworks.id') ->where('artworks.is_public', true) ->where('artworks.is_approved', true) ->whereNull('artworks.deleted_at') ->whereNotNull('artworks.published_at') ->select([ 'artworks.id', DB::raw('COALESCE(likes.favourites_delta, 0) as favourites_delta'), DB::raw('COALESCE(views.views_delta, 0) as views_delta'), DB::raw('COALESCE(downloads.downloads_delta, 0) as downloads_delta'), DB::raw('COALESCE(comments.comments_delta, 0) as comments_delta'), ]) ->get() ->map(fn ($row): array => [ 'entity_id' => (int) $row->id, 'score' => ((int) $row->favourites_delta * 3) + ((int) $row->views_delta * 1) + ((int) $row->downloads_delta * 5) + ((int) $row->comments_delta * 4), ]) ->filter(fn (array $row): bool => $row['score'] > 0) ->values(); } private function allTimeStoryRows(): Collection { return Story::query() ->published() ->select(['id', 'views', 'likes_count', 'comments_count', 'reading_time']) ->get() ->map(fn (Story $story): array => [ 'entity_id' => (int) $story->id, 'score' => ((int) $story->views * 1) + ((int) $story->likes_count * 3) + ((int) $story->comments_count * 4) + ((int) $story->reading_time * 0.5), ]) ->filter(fn (array $row): bool => $row['score'] > 0) ->values(); } private function windowedStoryRows(CarbonImmutable $start): Collection { $views = StoryView::query() ->select('story_id', DB::raw('COUNT(*) as views_count')) ->where('created_at', '>=', $start) ->groupBy('story_id'); $likes = StoryLike::query() ->select('story_id', DB::raw('COUNT(*) as likes_count')) ->where('created_at', '>=', $start) ->groupBy('story_id'); return Story::query() ->from('stories') ->leftJoinSub($views, 'views', 'views.story_id', '=', 'stories.id') ->leftJoinSub($likes, 'likes', 'likes.story_id', '=', 'stories.id') ->published() ->select([ 'stories.id', 'stories.comments_count', 'stories.reading_time', DB::raw('COALESCE(views.views_count, 0) as views_count'), DB::raw('COALESCE(likes.likes_count, 0) as likes_count'), ]) ->get() ->map(fn ($row): array => [ 'entity_id' => (int) $row->id, 'score' => ((int) $row->views_count * 1) + ((int) $row->likes_count * 3) + ((int) $row->comments_count * 4) + ((int) $row->reading_time * 0.5), ]) ->filter(fn (array $row): bool => $row['score'] > 0) ->values(); } private function allTimeGroupRows(): Collection { $members = DB::table('group_members') ->select('group_id', DB::raw('COUNT(*) as members_count')) ->where('status', Group::STATUS_ACTIVE) ->groupBy('group_id'); $releases = DB::table('group_releases') ->select('group_id', DB::raw('COUNT(*) as releases_count')) ->where('visibility', 'public') ->where('status', 'released') ->groupBy('group_id'); $projects = DB::table('group_projects') ->select('group_id', DB::raw('COUNT(*) as projects_count')) ->where('visibility', 'public') ->whereIn('status', ['active', 'review', 'released']) ->groupBy('group_id'); $challenges = DB::table('group_challenges') ->select('group_id', DB::raw('COUNT(*) as challenges_count')) ->where('visibility', 'public') ->whereIn('status', ['published', 'active']) ->groupBy('group_id'); $events = DB::table('group_events') ->select('group_id', DB::raw('COUNT(*) as events_count')) ->where('visibility', 'public') ->where('status', 'published') ->groupBy('group_id'); $activity = DB::table('group_activity_items') ->select('group_id', DB::raw('COUNT(*) as activity_count')) ->where('visibility', 'public') ->groupBy('group_id'); return Group::query() ->from('groups') ->leftJoinSub($members, 'members', 'members.group_id', '=', 'groups.id') ->leftJoinSub($releases, 'releases', 'releases.group_id', '=', 'groups.id') ->leftJoinSub($projects, 'projects', 'projects.group_id', '=', 'groups.id') ->leftJoinSub($challenges, 'challenges', 'challenges.group_id', '=', 'groups.id') ->leftJoinSub($events, 'events', 'events.group_id', '=', 'groups.id') ->leftJoinSub($activity, 'activity', 'activity.group_id', '=', 'groups.id') ->public() ->select([ 'groups.id', 'groups.followers_count', 'groups.artworks_count', 'groups.collections_count', 'groups.is_verified', DB::raw('COALESCE(members.members_count, 0) as members_count'), DB::raw('COALESCE(releases.releases_count, 0) as releases_count'), DB::raw('COALESCE(projects.projects_count, 0) as projects_count'), DB::raw('COALESCE(challenges.challenges_count, 0) as challenges_count'), DB::raw('COALESCE(events.events_count, 0) as events_count'), DB::raw('COALESCE(activity.activity_count, 0) as activity_count'), ]) ->get() ->map(function ($row): array { $score = ((int) $row->followers_count * 8) + ((int) $row->artworks_count * 10) + ((int) $row->collections_count * 6) + ((int) $row->members_count * 20) + ((int) $row->releases_count * 30) + ((int) $row->projects_count * 24) + ((int) $row->challenges_count * 18) + ((int) $row->events_count * 14) + ((int) $row->activity_count * 4) + ((bool) $row->is_verified ? 120 : 0); return [ 'entity_id' => (int) $row->id, 'score' => $score, ]; }) ->filter(fn (array $row): bool => $row['score'] > 0) ->values(); } private function windowedGroupRows(CarbonImmutable $start): Collection { $follows = DB::table('group_follows') ->select('group_id', DB::raw('COUNT(*) as follows_count')) ->where('created_at', '>=', $start) ->groupBy('group_id'); $artworks = DB::table('artworks') ->select('group_id', DB::raw('COUNT(*) as artworks_count')) ->whereNotNull('group_id') ->where('is_public', true) ->where('is_approved', true) ->whereNull('deleted_at') ->whereNotNull('published_at') ->where('published_at', '>=', $start) ->groupBy('group_id'); $releases = DB::table('group_releases') ->select('group_id', DB::raw('COUNT(*) as releases_count')) ->where('visibility', 'public') ->where('status', 'released') ->where('released_at', '>=', $start) ->groupBy('group_id'); $projects = DB::table('group_projects') ->select('group_id', DB::raw('COUNT(*) as projects_count')) ->where('visibility', 'public') ->whereIn('status', ['active', 'review', 'released']) ->where('updated_at', '>=', $start) ->groupBy('group_id'); $challenges = DB::table('group_challenges') ->select('group_id', DB::raw('COUNT(*) as challenges_count')) ->where('visibility', 'public') ->whereIn('status', ['published', 'active']) ->where(function ($query) use ($start): void { $query->where('updated_at', '>=', $start) ->orWhere('start_at', '>=', $start) ->orWhere('created_at', '>=', $start); }) ->groupBy('group_id'); $events = DB::table('group_events') ->select('group_id', DB::raw('COUNT(*) as events_count')) ->where('visibility', 'public') ->where('status', 'published') ->where(function ($query) use ($start): void { $query->where('published_at', '>=', $start) ->orWhere('start_at', '>=', $start) ->orWhere('updated_at', '>=', $start); }) ->groupBy('group_id'); $activity = DB::table('group_activity_items') ->select('group_id', DB::raw('COUNT(*) as activity_count')) ->where('visibility', 'public') ->where('occurred_at', '>=', $start) ->groupBy('group_id'); $members = DB::table('group_members') ->select('group_id', DB::raw('COUNT(*) as members_count')) ->where('status', Group::STATUS_ACTIVE) ->groupBy('group_id'); return Group::query() ->from('groups') ->leftJoinSub($follows, 'follows', 'follows.group_id', '=', 'groups.id') ->leftJoinSub($artworks, 'artworks', 'artworks.group_id', '=', 'groups.id') ->leftJoinSub($releases, 'releases', 'releases.group_id', '=', 'groups.id') ->leftJoinSub($projects, 'projects', 'projects.group_id', '=', 'groups.id') ->leftJoinSub($challenges, 'challenges', 'challenges.group_id', '=', 'groups.id') ->leftJoinSub($events, 'events', 'events.group_id', '=', 'groups.id') ->leftJoinSub($activity, 'activity', 'activity.group_id', '=', 'groups.id') ->leftJoinSub($members, 'members', 'members.group_id', '=', 'groups.id') ->public() ->select([ 'groups.id', 'groups.is_verified', DB::raw('COALESCE(follows.follows_count, 0) as follows_count'), DB::raw('COALESCE(artworks.artworks_count, 0) as artworks_count'), DB::raw('COALESCE(releases.releases_count, 0) as releases_count'), DB::raw('COALESCE(projects.projects_count, 0) as projects_count'), DB::raw('COALESCE(challenges.challenges_count, 0) as challenges_count'), DB::raw('COALESCE(events.events_count, 0) as events_count'), DB::raw('COALESCE(activity.activity_count, 0) as activity_count'), DB::raw('COALESCE(members.members_count, 0) as members_count'), ]) ->get() ->map(function ($row): array { $score = ((int) $row->follows_count * 18) + ((int) $row->artworks_count * 16) + ((int) $row->releases_count * 34) + ((int) $row->projects_count * 22) + ((int) $row->challenges_count * 20) + ((int) $row->events_count * 16) + ((int) $row->activity_count * 6) + ((int) $row->members_count * 8) + ((bool) $row->is_verified ? 45 : 0); return [ 'entity_id' => (int) $row->id, 'score' => $score, ]; }) ->filter(fn (array $row): bool => $row['score'] > 0) ->values(); } private function artworkSnapshotDeltas(CarbonImmutable $start): \Illuminate\Database\Query\Builder { return ArtworkMetricSnapshotHourly::query() ->from('artwork_metric_snapshots_hourly as snapshots') ->where('snapshots.bucket_hour', '>=', $start) ->select([ 'snapshots.artwork_id', DB::raw($this->nonNegativeSnapshotDelta('views_count', 'views_delta')), DB::raw($this->nonNegativeSnapshotDelta('downloads_count', 'downloads_delta')), DB::raw($this->nonNegativeSnapshotDelta('favourites_count', 'favourites_delta')), DB::raw($this->nonNegativeSnapshotDelta('comments_count', 'comments_delta')), ]) ->groupBy('snapshots.artwork_id') ->toBase(); } private function nonNegativeSnapshotDelta(string $column, string $alias): string { $delta = sprintf('MAX(snapshots.%1$s) - MIN(snapshots.%1$s)', $column); if (DB::connection()->getDriverName() === 'sqlite') { return sprintf('CASE WHEN %1$s > 0 THEN %1$s ELSE 0 END as %2$s', $delta, $alias); } return sprintf('GREATEST(%s, 0) as %s', $delta, $alias); } private function creatorEntities(array $ids): array { return User::query() ->from('users') ->leftJoin('user_profiles as profiles', 'profiles.user_id', '=', 'users.id') ->whereIn('users.id', $ids) ->select([ 'users.id', 'users.username', 'users.name', 'users.level', 'users.rank', 'profiles.avatar_hash', ]) ->get() ->mapWithKeys(fn ($row): array => [ (int) $row->id => [ 'id' => (int) $row->id, 'type' => Leaderboard::TYPE_CREATOR, 'name' => (string) ($row->username ?: $row->name ?: 'Creator'), 'username' => $row->username, 'url' => $row->username ? '/@' . $row->username : null, 'avatar' => \App\Support\AvatarUrl::forUser((int) $row->id, $row->avatar_hash, 128), 'level' => (int) ($row->level ?? 1), 'rank' => (string) ($row->rank ?? 'Newbie'), ], ]) ->all(); } private function artworkEntities(array $ids): array { return Artwork::query() ->with(['user.profile']) ->whereIn('id', $ids) ->get() ->mapWithKeys(fn (Artwork $artwork): array => [ (int) $artwork->id => [ 'id' => (int) $artwork->id, 'type' => Leaderboard::TYPE_ARTWORK, 'name' => $artwork->title, 'url' => '/art/' . $artwork->id . '/' . $artwork->slug, 'image' => $artwork->thumbUrl('md') ?? $artwork->thumbnail_url, 'creator_name' => (string) ($artwork->user?->username ?: $artwork->user?->name ?: 'Creator'), 'creator_url' => $artwork->user?->username ? '/@' . $artwork->user->username : null, ], ]) ->all(); } private function storyEntities(array $ids): array { return Story::query() ->with('creator.profile') ->whereIn('id', $ids) ->get() ->mapWithKeys(fn (Story $story): array => [ (int) $story->id => [ 'id' => (int) $story->id, 'type' => Leaderboard::TYPE_STORY, 'name' => $story->title, 'url' => '/stories/' . $story->slug, 'image' => $story->cover_url, 'creator_name' => (string) ($story->creator?->username ?: $story->creator?->name ?: 'Creator'), 'creator_url' => $story->creator?->username ? '/@' . $story->creator->username : null, ], ]) ->all(); } private function groupEntities(array $ids): array { return Group::query() ->with(['owner.profile', 'recruitmentProfile', 'badges', 'members']) ->whereIn('id', $ids) ->public() ->get() ->mapWithKeys(function (Group $group): array { return [ (int) $group->id => [ 'id' => (int) $group->id, 'type' => Leaderboard::TYPE_GROUP, 'name' => (string) $group->name, 'headline' => (string) ($group->headline ?? ''), 'url' => $group->publicUrl(), 'avatar' => $group->avatarUrl(), 'image' => $group->bannerUrl() ?: $group->avatarUrl(), 'followers_count' => (int) ($group->followers_count ?? 0), 'artworks_count' => (int) ($group->artworks_count ?? 0), 'collections_count' => (int) ($group->collections_count ?? 0), 'members_count' => (int) $group->members->where('status', Group::STATUS_ACTIVE)->count(), 'is_recruiting' => (bool) ($group->recruitmentProfile?->is_recruiting ?? false), 'trust_signals' => $this->groupReputation->trustSignals($group), 'badges' => $this->groupReputation->groupBadges($group, 3), ], ]; }) ->all(); } }