releases() ->where('visibility', GroupRelease::VISIBILITY_PUBLIC) ->where('status', GroupRelease::STATUS_RELEASED) ->count(); $recentReleaseCount = (int) $group->releases() ->where('visibility', GroupRelease::VISIBILITY_PUBLIC) ->where('status', GroupRelease::STATUS_RELEASED) ->where('released_at', '>=', now()->subDays(60)) ->count(); $recentPublicActivity = (int) GroupActivityItem::query() ->where('group_id', $group->id) ->where('visibility', GroupActivityItem::VISIBILITY_PUBLIC) ->where('occurred_at', '>=', now()->subDays(30)) ->count(); $publishedArtworks = (int) Artwork::query() ->where('group_id', $group->id) ->where('artwork_status', 'published') ->count(); $activeMembers = (int) $group->members()->where('status', Group::STATUS_ACTIVE)->count() + 1; $freshnessScore = $this->freshnessScore($group); $activityScore = min(100, ($recentPublicActivity * 12) + ($publishedArtworks * 0.5)); $releaseScore = min(100, ($publicReleaseCount * 14) + ($recentReleaseCount * 12)); $collaborationScore = min(100, ($activeMembers * 10) + ($group->contributorStats()->count() * 4)); $trustScore = $group->status === Group::LIFECYCLE_SUSPENDED ? 0 : min(100, 25 + ($group->is_verified ? 20 : 0) + ($publicReleaseCount * 10) + ($publishedArtworks * 0.35) + ($group->followers_count * 0.2)); return GroupDiscoveryMetric::query()->updateOrCreate( ['group_id' => (int) $group->id], [ 'freshness_score' => $freshnessScore, 'activity_score' => round($activityScore, 2), 'release_score' => round($releaseScore, 2), 'collaboration_score' => round($collaborationScore, 2), 'trust_score' => round($trustScore, 2), 'last_calculated_at' => now(), ] ); } public function publicListing(?User $viewer, string $surface = 'featured', int $page = 1, int $perPage = 24): LengthAwarePaginator { $groups = $this->publicGroupBaseQuery()->get(); $sorted = $this->sortGroups($groups, $surface); $page = max(1, $page); $perPage = max(1, min($perPage, 48)); $slice = $sorted->forPage($page, $perPage)->values(); return new LengthAwarePaginator($slice, $sorted->count(), $perPage, $page, [ 'path' => request()->url(), 'query' => request()->query(), ]); } public function spotlightCard(?User $viewer = null, string $surface = 'featured'): ?array { return $this->surfaceCards($viewer, $surface, 1)[0] ?? null; } public function surfaceCards(?User $viewer = null, string $surface = 'featured', int $limit = 6): array { return $this->sortGroups($this->publicGroupBaseQuery()->get(), $surface) ->take(max(1, $limit)) ->map(fn (Group $group): array => $this->cards->mapGroupCard($group, $viewer)) ->values() ->all(); } public function searchCards(string $query, ?User $viewer = null, int $limit = 8): array { $normalized = mb_strtolower(trim($query)); if (mb_strlen($normalized) < 2) { return []; } $groups = $this->publicGroupBaseQuery() ->where(function (Builder $builder) use ($normalized): void { $builder->whereRaw('LOWER(name) LIKE ?', ['%' . $normalized . '%']) ->orWhereRaw('LOWER(slug) LIKE ?', ['%' . $normalized . '%']) ->orWhereRaw('LOWER(headline) LIKE ?', ['%' . $normalized . '%']) ->orWhereRaw('LOWER(bio) LIKE ?', ['%' . $normalized . '%']) ->orWhereHas('recruitmentProfile', function (Builder $recruitmentQuery) use ($normalized): void { $recruitmentQuery->whereRaw('LOWER(headline) LIKE ?', ['%' . $normalized . '%']) ->orWhereRaw('LOWER(description) LIKE ?', ['%' . $normalized . '%']) ->orWhereRaw('LOWER(roles_json) LIKE ?', ['%' . $normalized . '%']) ->orWhereRaw('LOWER(skills_json) LIKE ?', ['%' . $normalized . '%']); }) ->orWhereHas('releases', function (Builder $releaseQuery) use ($normalized): void { $releaseQuery->where('visibility', GroupRelease::VISIBILITY_PUBLIC) ->where('status', GroupRelease::STATUS_RELEASED) ->where(function (Builder $nestedQuery) use ($normalized): void { $nestedQuery->whereRaw('LOWER(title) LIKE ?', ['%' . $normalized . '%']) ->orWhereRaw('LOWER(summary) LIKE ?', ['%' . $normalized . '%']) ->orWhereRaw('LOWER(description) LIKE ?', ['%' . $normalized . '%']) ->orWhereRaw('LOWER(release_notes) LIKE ?', ['%' . $normalized . '%']); }); }) ->orWhereHas('projects', function (Builder $projectQuery) use ($normalized): void { $projectQuery->where('visibility', GroupProject::VISIBILITY_PUBLIC) ->where(function (Builder $nestedQuery) use ($normalized): void { $nestedQuery->whereRaw('LOWER(title) LIKE ?', ['%' . $normalized . '%']) ->orWhereRaw('LOWER(summary) LIKE ?', ['%' . $normalized . '%']) ->orWhereRaw('LOWER(description) LIKE ?', ['%' . $normalized . '%']); }); }) ->orWhereHas('challenges', function (Builder $challengeQuery) use ($normalized): void { $challengeQuery->where('visibility', GroupChallenge::VISIBILITY_PUBLIC) ->whereIn('status', [GroupChallenge::STATUS_PUBLISHED, GroupChallenge::STATUS_ACTIVE]) ->where(function (Builder $nestedQuery) use ($normalized): void { $nestedQuery->whereRaw('LOWER(title) LIKE ?', ['%' . $normalized . '%']) ->orWhereRaw('LOWER(summary) LIKE ?', ['%' . $normalized . '%']) ->orWhereRaw('LOWER(description) LIKE ?', ['%' . $normalized . '%']); }); }) ->orWhereHas('events', function (Builder $eventQuery) use ($normalized): void { $eventQuery->where('visibility', GroupEvent::VISIBILITY_PUBLIC) ->where('status', GroupEvent::STATUS_PUBLISHED) ->where(function (Builder $nestedQuery) use ($normalized): void { $nestedQuery->whereRaw('LOWER(title) LIKE ?', ['%' . $normalized . '%']) ->orWhereRaw('LOWER(summary) LIKE ?', ['%' . $normalized . '%']) ->orWhereRaw('LOWER(description) LIKE ?', ['%' . $normalized . '%']); }); }) ->orWhereHas('badges', function (Builder $badgeQuery) use ($normalized): void { $badgeQuery->whereRaw('LOWER(badge_key) LIKE ?', ['%' . $normalized . '%']) ->orWhereRaw("LOWER(REPLACE(badge_key, '_', ' ')) LIKE ?", ['%' . $normalized . '%']); }) ->orWhereHas('members.user', function (Builder $userQuery) use ($normalized): void { $userQuery->whereRaw('LOWER(name) LIKE ?', ['%' . $normalized . '%']) ->orWhereRaw('LOWER(username) LIKE ?', ['%' . $normalized . '%']); }); }) ->limit(max($limit * 3, 12)) ->get(); return $groups ->sortByDesc(fn (Group $group): float => $this->searchWeight($group, $normalized)) ->take(max(1, $limit)) ->map(fn (Group $group): array => $this->cards->mapGroupCard($group, $viewer)) ->values() ->all(); } public function publicGroupCount(): int { return Group::query()->public()->count(); } public function availableSurfaces(): array { return [ ['value' => 'featured', 'label' => 'Featured'], ['value' => 'recruiting', 'label' => 'Recruiting'], ['value' => 'new_rising', 'label' => 'New & Rising'], ['value' => 'trusted', 'label' => 'Trusted'], ['value' => 'recent_releases', 'label' => 'Recent releases'], ['value' => 'featured_projects', 'label' => 'Featured projects'], ['value' => 'current_challenges', 'label' => 'Current challenges'], ['value' => 'upcoming_events', 'label' => 'Upcoming events'], ]; } private function publicGroupBaseQuery(): Builder { return Group::query() ->with(['owner.profile', 'recruitmentProfile', 'discoveryMetric', 'members', 'badges']) ->withCount([ 'members as active_members_count' => fn (Builder $query) => $query->where('status', Group::STATUS_ACTIVE), 'releases as public_releases_count' => fn (Builder $query) => $query ->where('visibility', GroupRelease::VISIBILITY_PUBLIC) ->where('status', GroupRelease::STATUS_RELEASED), 'releases as recent_public_releases_count' => fn (Builder $query) => $query ->where('visibility', GroupRelease::VISIBILITY_PUBLIC) ->where('status', GroupRelease::STATUS_RELEASED) ->where('released_at', '>=', now()->subDays(60)), 'projects as public_projects_count' => fn (Builder $query) => $query ->where('visibility', GroupProject::VISIBILITY_PUBLIC) ->whereIn('status', [GroupProject::STATUS_ACTIVE, GroupProject::STATUS_REVIEW, GroupProject::STATUS_RELEASED]), 'challenges as active_public_challenges_count' => fn (Builder $query) => $query ->where('visibility', GroupChallenge::VISIBILITY_PUBLIC) ->whereIn('status', [GroupChallenge::STATUS_PUBLISHED, GroupChallenge::STATUS_ACTIVE]), 'events as upcoming_public_events_count' => fn (Builder $query) => $query ->where('visibility', GroupEvent::VISIBILITY_PUBLIC) ->where('status', GroupEvent::STATUS_PUBLISHED) ->where('start_at', '>=', now()), 'activityItems as public_activity_30d_count' => fn (Builder $query) => $query ->where('visibility', GroupActivityItem::VISIBILITY_PUBLIC) ->where('occurred_at', '>=', now()->subDays(30)), 'contributorStats as contributor_stats_count', ]) ->withMax([ 'releases as latest_public_release_at' => fn (Builder $query) => $query ->where('visibility', GroupRelease::VISIBILITY_PUBLIC) ->where('status', GroupRelease::STATUS_RELEASED), ], 'released_at') ->public(); } private function sortGroups(Collection $groups, string $surface): Collection { return (match ($surface) { 'recent_releases' => $groups->sortByDesc(fn (Group $group): string => (string) ($group->latest_public_release_at ?? '')), 'featured_projects' => $groups->sortByDesc(fn (Group $group): float => ((int) ($group->public_projects_count ?? 0) > 0 ? 1000 : 0) + $this->discoveryWeight($group, 'collaboration') + $this->discoveryWeight($group, 'activity')), 'current_challenges' => $groups->sortByDesc(fn (Group $group): float => ((int) ($group->active_public_challenges_count ?? 0) > 0 ? 1000 : 0) + $this->discoveryWeight($group, 'freshness') + $this->discoveryWeight($group, 'activity')), 'upcoming_events' => $groups->sortByDesc(fn (Group $group): float => ((int) ($group->upcoming_public_events_count ?? 0) > 0 ? 1000 : 0) + $this->discoveryWeight($group, 'activity') + $this->discoveryWeight($group, 'trust')), 'recruiting' => $groups->sortByDesc(fn (Group $group): float => (($group->recruitmentProfile?->is_recruiting ?? false) ? 1000 : 0) + $this->discoveryWeight($group, 'activity') + ($group->followers_count * 0.03)), 'new_rising' => $groups->sortByDesc(fn (Group $group): float => ($this->freshnessScore($group) * 1.2) + min(20, max(0, 50 - ((int) $group->followers_count / 2)))), 'trusted' => $groups->sortByDesc(fn (Group $group): float => $this->discoveryWeight($group, 'trust') + $this->discoveryWeight($group, 'release')), default => $groups->sortByDesc(fn (Group $group): float => $this->discoveryWeight($group, 'trust') + $this->discoveryWeight($group, 'activity') + $this->discoveryWeight($group, 'collaboration')), })->values(); } private function searchWeight(Group $group, string $query): float { $name = mb_strtolower((string) $group->name); $slug = mb_strtolower((string) $group->slug); $headline = mb_strtolower((string) ($group->headline ?? '')); $bio = mb_strtolower((string) ($group->bio ?? '')); $exact = $name === $query || $slug === $query ? 1800 : 0; $prefix = str_starts_with($name, $query) || str_starts_with($slug, $query) ? 600 : 0; $contains = str_contains($name, $query) || str_contains($slug, $query) ? 240 : 0; $descriptive = str_contains($headline, $query) || str_contains($bio, $query) ? 90 : 0; return $exact + $prefix + $contains + $descriptive + ($this->discoveryWeight($group, 'trust') * 1.25) + $this->discoveryWeight($group, 'activity') + ($this->discoveryWeight($group, 'release') * 0.8) + ((float) ($group->followers_count ?? 0) * 0.08) + (($group->recruitmentProfile?->is_recruiting ?? false) ? 15 : 0); } private function discoveryWeight(Group $group, string $dimension): float { $metric = $group->relationLoaded('discoveryMetric') ? $group->discoveryMetric : $group->discoveryMetric()->first(); if (! $metric) { $metric = $this->refresh($group); } return match ($dimension) { 'activity' => (float) $metric->activity_score, 'release' => (float) $metric->release_score, 'collaboration' => (float) $metric->collaboration_score, 'freshness' => (float) $metric->freshness_score, default => (float) $metric->trust_score, }; } private function freshnessScore(Group $group): float { if (! $group->last_activity_at) { return 20.0; } $days = $group->last_activity_at->diffInDays(now()); return match (true) { $days <= 7 => 100.0, $days <= 14 => 80.0, $days <= 30 => 60.0, $days <= 60 => 40.0, default => 20.0, }; } }