contributorUserIds($group); GroupContributorStat::query() ->where('group_id', $group->id) ->whereNotIn('user_id', $userIds) ->delete(); foreach ($userIds as $userId) { GroupContributorStat::query()->updateOrCreate( [ 'group_id' => (int) $group->id, 'user_id' => $userId, ], $this->statPayload($group, $userId) ); } $this->awardGroupBadges($group); $this->awardMemberBadges($group); } public function topContributors(Group $group, int $limit = 6): array { return GroupContributorStat::query() ->with(['user.profile']) ->where('group_id', $group->id) ->orderByDesc('release_count') ->orderByDesc('credited_artworks_count') ->orderByDesc('review_actions_count') ->limit(max(1, min(24, $limit))) ->get() ->map(fn (GroupContributorStat $stat): array => $this->mapContributorStat($group, $stat)) ->values() ->all(); } public function summary(Group $group): array { $stats = GroupContributorStat::query()->where('group_id', $group->id); return [ 'top_contributors' => $this->topContributors($group, 8), 'counts' => [ 'contributors' => (clone $stats)->count(), 'release_contributors' => (clone $stats)->where('release_count', '>', 0)->count(), 'reliable_reviewers' => (clone $stats)->where('review_actions_count', '>=', 5)->count(), 'trusted_contributors' => (clone $stats)->where('approved_submissions_count', '>=', 3)->count(), 'group_badges' => (int) $group->badges()->count(), 'member_badges' => (int) $group->memberBadges()->count(), ], 'recent_badges' => $this->groupBadges($group, 8), 'member_badge_unlocks' => $this->recentMemberBadges($group, 8), ]; } public function trustSignals(Group $group): array { $releaseCount = (int) $group->releases()->where('status', GroupRelease::STATUS_RELEASED)->count(); $recentReleaseCount = (int) $group->releases() ->where('status', GroupRelease::STATUS_RELEASED) ->where('released_at', '>=', now()->subDays(45)) ->count(); $activeMembers = (int) $group->members()->where('status', Group::STATUS_ACTIVE)->count() + 1; $approvedArtworks = (int) Artwork::query() ->where('group_id', $group->id) ->where('group_review_status', 'approved') ->count(); $signals = []; if ($group->is_verified) { $signals[] = [ 'key' => 'verified', 'label' => 'Verified', 'tone' => 'sky', 'reason' => 'This group has a verified or official identity on Nova.', ]; } if ($group->last_activity_at && $group->last_activity_at->greaterThanOrEqualTo(now()->subDays(14))) { $signals[] = [ 'key' => 'active', 'label' => 'Active', 'tone' => 'emerald', 'reason' => 'The group has posted or published work recently.', ]; } if ($recentReleaseCount > 0) { $signals[] = [ 'key' => 'release_active', 'label' => 'Release Active', 'tone' => 'amber', 'reason' => 'The group has published a release in the last 45 days.', ]; } if ($releaseCount >= 2 && $approvedArtworks >= 6) { $signals[] = [ 'key' => 'trusted', 'label' => 'Trusted', 'tone' => 'sky', 'reason' => 'Trust is earned through repeated releases and approved contributions.', ]; } if ($activeMembers >= 4) { $signals[] = [ 'key' => 'collaborative', 'label' => 'Collaborative', 'tone' => 'violet', 'reason' => 'Several active members are contributing to this group.', ]; } if (($group->recruitmentProfile?->is_recruiting ?? false) === true) { $signals[] = [ 'key' => 'recruiting', 'label' => 'Recruiting', 'tone' => 'emerald', 'reason' => 'The group is currently open to new collaborators.', ]; } if ($signals === []) { $signals[] = [ 'key' => 'new_rising', 'label' => 'New & Rising', 'tone' => 'amber', 'reason' => 'This group is still early, but active enough to remain discoverable.', ]; } return $signals; } public function groupBadges(Group $group, int $limit = 6): array { return $group->badges() ->latest('awarded_at') ->limit(max(1, min(24, $limit))) ->get() ->map(fn (GroupBadge $badge): array => [ 'key' => (string) $badge->badge_key, 'label' => $this->badgeLabel('group', (string) $badge->badge_key), 'awarded_at' => $badge->awarded_at?->toISOString(), 'reason' => $badge->meta_json['reason'] ?? $this->badgeReason('group', (string) $badge->badge_key), ]) ->values() ->all(); } public function memberBadges(Group $group, User|int $user, int $limit = 4): array { $userId = $user instanceof User ? (int) $user->id : (int) $user; return GroupMemberBadge::query() ->where('group_id', $group->id) ->where('user_id', $userId) ->latest('awarded_at') ->limit(max(1, min(12, $limit))) ->get() ->map(fn (GroupMemberBadge $badge): array => [ 'key' => (string) $badge->badge_key, 'label' => $this->badgeLabel('member', (string) $badge->badge_key), 'awarded_at' => $badge->awarded_at?->toISOString(), 'reason' => $badge->meta_json['reason'] ?? $this->badgeReason('member', (string) $badge->badge_key), ]) ->values() ->all(); } private function recentMemberBadges(Group $group, int $limit): array { return GroupMemberBadge::query() ->with('user.profile') ->where('group_id', $group->id) ->latest('awarded_at') ->limit(max(1, min(24, $limit))) ->get() ->map(fn (GroupMemberBadge $badge): array => [ 'user' => [ 'id' => (int) $badge->user_id, 'name' => $badge->user?->name, 'username' => $badge->user?->username, 'avatar_url' => $badge->user ? AvatarUrl::forUser((int) $badge->user->id, $badge->user->profile?->avatar_hash, 72) : null, ], 'badge' => [ 'key' => (string) $badge->badge_key, 'label' => $this->badgeLabel('member', (string) $badge->badge_key), 'awarded_at' => $badge->awarded_at?->toISOString(), 'reason' => $badge->meta_json['reason'] ?? $this->badgeReason('member', (string) $badge->badge_key), ], ]) ->values() ->all(); } private function contributorUserIds(Group $group): array { return collect([(int) $group->owner_user_id]) ->merge($group->members()->where('status', Group::STATUS_ACTIVE)->pluck('user_id')) ->merge($group->releases()->pluck('lead_user_id')) ->merge($group->releases()->pluck('created_by_user_id')) ->merge(GroupReleaseContributor::query() ->whereIn('group_release_id', $group->releases()->pluck('id')) ->pluck('user_id')) ->merge($group->projects()->pluck('lead_user_id')) ->merge($group->projects()->pluck('created_by_user_id')) ->merge($group->projects()->with('memberLinks')->get()->flatMap(fn (GroupProject $project) => $project->memberLinks->pluck('user_id'))) ->merge(Artwork::query()->where('group_id', $group->id)->pluck('primary_author_user_id')) ->merge(Artwork::query()->where('group_id', $group->id)->pluck('uploaded_by_user_id')) ->merge(Artwork::query()->where('group_id', $group->id)->pluck('group_reviewed_by_user_id')) ->filter(fn ($id): bool => (int) $id > 0) ->map(fn ($id): int => (int) $id) ->unique() ->values() ->all(); } private function statPayload(Group $group, int $userId): array { $creditedArtworksCount = Artwork::query() ->where('group_id', $group->id) ->where(function ($query) use ($userId): void { $query->where('primary_author_user_id', $userId) ->orWhere('uploaded_by_user_id', $userId) ->orWhereHas('contributors', fn ($contributorQuery) => $contributorQuery->where('user_id', $userId)); }) ->count(); $releaseCount = GroupRelease::query() ->where('group_id', $group->id) ->where(function ($query) use ($userId): void { $query->where('lead_user_id', $userId) ->orWhere('created_by_user_id', $userId) ->orWhereHas('contributorLinks', fn ($contributorQuery) => $contributorQuery->where('user_id', $userId)); }) ->count(); $projectCount = GroupProject::query() ->where('group_id', $group->id) ->where(function ($query) use ($userId): void { $query->where('lead_user_id', $userId) ->orWhere('created_by_user_id', $userId) ->orWhereHas('memberLinks', fn ($memberQuery) => $memberQuery->where('user_id', $userId)); }) ->count(); $reviewActionsCount = Artwork::query() ->where('group_id', $group->id) ->where('group_reviewed_by_user_id', $userId) ->count(); $approvedSubmissionsCount = Artwork::query() ->where('group_id', $group->id) ->where('uploaded_by_user_id', $userId) ->where('group_review_status', 'approved') ->count(); return [ 'credited_artworks_count' => $creditedArtworksCount, 'release_count' => $releaseCount, 'project_count' => $projectCount, 'review_actions_count' => $reviewActionsCount, 'approved_submissions_count' => $approvedSubmissionsCount, 'reputation_meta_json' => $this->reputationMeta($creditedArtworksCount, $releaseCount, $projectCount, $reviewActionsCount, $approvedSubmissionsCount), ]; } private function reputationMeta(int $creditedArtworks, int $releaseCount, int $projectCount, int $reviewActions, int $approvedSubmissions): array { $creativeLevel = $this->levelLabel($creditedArtworks, [1 => 'Emerging', 5 => 'Established', 12 => 'Trusted']); $collaborationLevel = $this->levelLabel($projectCount + $releaseCount, [1 => 'Active', 4 => 'Reliable', 8 => 'Core']); $publishingLevel = $this->levelLabel($releaseCount + $approvedSubmissions, [1 => 'Contributing', 4 => 'Reliable', 8 => 'Trusted']); $leadershipLevel = $this->levelLabel($reviewActions, [1 => 'Reviewing', 5 => 'Reliable Reviewer', 12 => 'Leadership']); return [ 'trusted_indicator' => $approvedSubmissions >= 3 || $releaseCount >= 2 || $reviewActions >= 5, 'summary' => trim(implode(' • ', array_filter([$creativeLevel, $collaborationLevel, $publishingLevel, $reviewActions > 0 ? $leadershipLevel : null]))), 'dimensions' => [ 'creative_contribution' => [ 'label' => $creativeLevel, 'value' => $creditedArtworks, 'reason' => 'Based on credited artworks and visible contributions in this group.', ], 'collaboration_reliability' => [ 'label' => $collaborationLevel, 'value' => $projectCount + $releaseCount, 'reason' => 'Based on projects, releases, and consistent participation.', ], 'publishing_trust' => [ 'label' => $publishingLevel, 'value' => $releaseCount + $approvedSubmissions, 'reason' => 'Based on published releases and approved submissions.', ], 'review_leadership_trust' => [ 'label' => $reviewActions > 0 ? $leadershipLevel : 'Not enough review activity yet', 'value' => $reviewActions, 'reason' => 'Based on review actions and approval responsibility inside the group.', ], ], ]; } private function mapContributorStat(Group $group, GroupContributorStat $stat): array { $meta = $stat->reputation_meta_json ?? []; return [ 'user' => [ 'id' => (int) $stat->user_id, 'name' => $stat->user?->name, 'username' => $stat->user?->username, 'avatar_url' => $stat->user ? AvatarUrl::forUser((int) $stat->user->id, $stat->user->profile?->avatar_hash, 72) : null, 'profile_url' => $stat->user?->username ? route('profile.show', ['username' => strtolower((string) $stat->user->username)]) : null, ], 'joined_at' => $this->memberJoinedAt($group, $stat->user_id), 'counts' => [ 'credited_artworks' => (int) $stat->credited_artworks_count, 'releases' => (int) $stat->release_count, 'projects' => (int) $stat->project_count, 'review_actions' => (int) $stat->review_actions_count, 'approved_submissions' => (int) $stat->approved_submissions_count, ], 'summary' => $meta['summary'] ?? null, 'trusted_indicator' => (bool) ($meta['trusted_indicator'] ?? false), 'dimensions' => $meta['dimensions'] ?? [], 'badges' => $this->memberBadges($group, (int) $stat->user_id), 'last_active_contribution_at' => $this->lastActiveContributionAt($group, (int) $stat->user_id), ]; } private function awardGroupBadges(Group $group): void { $publicReleaseCount = (int) $group->releases()->where('status', GroupRelease::STATUS_RELEASED)->count(); $publishedArtworksCount = (int) Artwork::query()->where('group_id', $group->id)->where('artwork_status', 'published')->count(); $activeMembers = (int) $group->members()->where('status', Group::STATUS_ACTIVE)->count() + 1; $eventsCount = (int) $group->events()->where('status', 'published')->count(); $challengeCount = (int) $group->challenges()->whereIn('status', ['published', 'active'])->count(); $this->awardGroupBadge($group, 'first_release', $publicReleaseCount >= 1); $this->awardGroupBadge($group, 'ten_releases', $publicReleaseCount >= 10); $this->awardGroupBadge($group, 'hundred_published_artworks', $publishedArtworksCount >= 100); $this->awardGroupBadge($group, 'community_favorite', (int) $group->followers_count >= 25); $this->awardGroupBadge($group, 'consistent_activity', $group->last_activity_at?->greaterThanOrEqualTo(now()->subDays(30)) === true); $this->awardGroupBadge($group, 'event_host', $eventsCount >= 3); $this->awardGroupBadge($group, 'challenge_organizer', $challengeCount >= 2); $this->awardGroupBadge($group, 'collaborative_group', $activeMembers >= 4 && $publicReleaseCount >= 1); $this->awardGroupBadge($group, 'trusted_group', $publicReleaseCount >= 2 && $publishedArtworksCount >= 12); } private function awardMemberBadges(Group $group): void { $stats = GroupContributorStat::query()->where('group_id', $group->id)->get(); foreach ($stats as $stat) { $this->awardMemberBadge($group, (int) $stat->user_id, 'first_group_contribution', (int) $stat->credited_artworks_count >= 1); $this->awardMemberBadge($group, (int) $stat->user_id, 'ten_group_contributions', (int) $stat->credited_artworks_count >= 10); $this->awardMemberBadge($group, (int) $stat->user_id, 'release_contributor', (int) $stat->release_count >= 1); $this->awardMemberBadge($group, (int) $stat->user_id, 'project_lead', GroupProject::query()->where('group_id', $group->id)->where('lead_user_id', $stat->user_id)->exists()); $this->awardMemberBadge($group, (int) $stat->user_id, 'reliable_reviewer', (int) $stat->review_actions_count >= 5); $this->awardMemberBadge($group, (int) $stat->user_id, 'long_term_collaborator', ((int) $stat->project_count + (int) $stat->release_count) >= 5); $this->awardMemberBadge($group, (int) $stat->user_id, 'founding_member', $this->isFoundingMember($group, (int) $stat->user_id)); $this->awardMemberBadge($group, (int) $stat->user_id, 'asset_builder', $group->assets()->where('uploaded_by_user_id', $stat->user_id)->count() >= 3); } } private function awardGroupBadge(Group $group, string $badgeKey, bool $shouldAward): void { if (! $shouldAward) { return; } $badge = GroupBadge::query()->firstOrCreate( [ 'group_id' => (int) $group->id, 'badge_key' => $badgeKey, ], [ 'awarded_at' => now(), 'meta_json' => ['reason' => $this->badgeReason('group', $badgeKey)], ] ); if ($badge->wasRecentlyCreated) { $badgeLabel = $this->badgeLabel('group', $badgeKey); $url = route('studio.groups.reputation', ['group' => $group]); foreach ($this->badgeManagerRecipients($group) as $recipient) { $this->notifications->notifyGroupBadgeEarned($recipient, $group, $badgeLabel, $url); } } } private function awardMemberBadge(Group $group, int $userId, string $badgeKey, bool $shouldAward): void { if (! $shouldAward) { return; } $badge = GroupMemberBadge::query()->firstOrCreate( [ 'group_id' => (int) $group->id, 'user_id' => $userId, 'badge_key' => $badgeKey, ], [ 'awarded_at' => now(), 'meta_json' => ['reason' => $this->badgeReason('member', $badgeKey)], ] ); if ($badge->wasRecentlyCreated) { $recipient = User::query()->find($userId); if ($recipient) { $this->notifications->notifyGroupMemberBadgeEarned( $recipient, $group, $this->badgeLabel('member', $badgeKey), route('groups.show', ['group' => $group]) ); } } } private function badgeManagerRecipients(Group $group): Collection { $owner = $group->relationLoaded('owner') ? $group->owner : $group->owner()->first(); $admins = $group->members() ->with('user.profile') ->where('status', Group::STATUS_ACTIVE) ->where('role', Group::ROLE_ADMIN) ->get() ->pluck('user'); return collect([$owner]) ->merge($admins) ->filter(fn ($user): bool => $user instanceof User) ->unique(fn (User $user): int => (int) $user->id) ->values(); } private function badgeLabel(string $scope, string $badgeKey): string { return (string) config(sprintf('groups.badges.%s.%s', $scope, $badgeKey), str_replace('_', ' ', $badgeKey)); } private function badgeReason(string $scope, string $badgeKey): string { return match ($scope . ':' . $badgeKey) { 'group:first_release' => 'Earned by publishing a first release.', 'group:ten_releases' => 'Earned by publishing ten releases.', 'group:hundred_published_artworks' => 'Earned by publishing one hundred group artworks.', 'group:community_favorite' => 'Earned by sustained follower interest.', 'group:consistent_activity' => 'Earned by staying active over recent weeks.', 'group:event_host' => 'Earned by hosting multiple published events.', 'group:challenge_organizer' => 'Earned by running multiple challenges.', 'group:collaborative_group' => 'Earned by keeping several contributors active and releasing together.', 'group:trusted_group' => 'Earned through repeated public releases and approved work.', 'member:first_group_contribution' => 'Earned by making a first credited contribution to the group.', 'member:ten_group_contributions' => 'Earned by making ten credited group contributions.', 'member:release_contributor' => 'Earned by contributing to a group release.', 'member:project_lead' => 'Earned by leading a group project.', 'member:reliable_reviewer' => 'Earned through repeated group review actions.', 'member:long_term_collaborator' => 'Earned through consistent long-term collaboration.', 'member:founding_member' => 'Earned by helping the group from its early formation stage.', 'member:asset_builder' => 'Earned by supplying multiple shared group assets.', default => 'Earned through visible group activity.', }; } private function memberJoinedAt(Group $group, int $userId): ?string { if ((int) $group->owner_user_id === $userId) { return $group->created_at?->toISOString(); } $member = GroupMember::query() ->where('group_id', $group->id) ->where('user_id', $userId) ->first(); return $member?->accepted_at?->toISOString() ?? $member?->created_at?->toISOString(); } private function lastActiveContributionAt(Group $group, int $userId): ?string { $timestamps = collect([ Artwork::query()->where('group_id', $group->id)->where('uploaded_by_user_id', $userId)->max('updated_at'), GroupProject::query()->where('group_id', $group->id)->where('updated_at', '!=', null)->where(function ($query) use ($userId): void { $query->where('lead_user_id', $userId) ->orWhere('created_by_user_id', $userId) ->orWhereHas('memberLinks', fn ($memberQuery) => $memberQuery->where('user_id', $userId)); })->max('updated_at'), GroupRelease::query()->where('group_id', $group->id)->where(function ($query) use ($userId): void { $query->where('lead_user_id', $userId) ->orWhere('created_by_user_id', $userId) ->orWhereHas('contributorLinks', fn ($contributorQuery) => $contributorQuery->where('user_id', $userId)); })->max('updated_at'), ])->filter(); $latest = $timestamps->sortDesc()->first(); return $latest ? CarbonImmutable::parse((string) $latest)->toISOString() : null; } private function isFoundingMember(Group $group, int $userId): bool { if ((int) $group->owner_user_id === $userId) { return true; } $member = GroupMember::query() ->where('group_id', $group->id) ->where('user_id', $userId) ->first(); if (! $member?->accepted_at || ! $group->created_at) { return false; } return $member->accepted_at->lessThanOrEqualTo($group->created_at->copy()->addDays(30)); } private function levelLabel(int $value, array $thresholds): string { $label = 'New'; foreach ($thresholds as $threshold => $candidate) { if ($value >= $threshold) { $label = $candidate; } } return $label; } }