Files
SkinbaseNova/app/Services/GroupReputationService.php

610 lines
26 KiB
PHP

<?php
declare(strict_types=1);
namespace App\Services;
use App\Models\Artwork;
use App\Models\Group;
use App\Models\GroupBadge;
use App\Models\GroupContributorStat;
use App\Models\GroupMember;
use App\Models\GroupMemberBadge;
use App\Models\GroupProject;
use App\Models\GroupRelease;
use App\Models\GroupReleaseContributor;
use App\Models\User;
use App\Support\AvatarUrl;
use Carbon\CarbonImmutable;
use Illuminate\Support\Collection;
class GroupReputationService
{
public function __construct(
private readonly NotificationService $notifications,
) {
}
public function refreshGroup(Group $group): void
{
$userIds = $this->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 = isset($group->public_releases_count)
? (int) $group->public_releases_count
: (int) $group->releases()->where('status', GroupRelease::STATUS_RELEASED)->count();
$recentReleaseCount = isset($group->recent_public_releases_count)
? (int) $group->recent_public_releases_count
: (int) $group->releases()
->where('status', GroupRelease::STATUS_RELEASED)
->where('released_at', '>=', now()->subDays(45))
->count();
$activeMembers = (isset($group->active_members_count)
? (int) $group->active_members_count
: ($group->relationLoaded('members')
? (int) $group->members->where('status', Group::STATUS_ACTIVE)->count()
: (int) $group->members()->where('status', Group::STATUS_ACTIVE)->count())) + 1;
$approvedArtworks = isset($group->approved_group_artworks_count)
? (int) $group->approved_group_artworks_count
: (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
{
$badges = $group->relationLoaded('badges')
? $group->badges->sortByDesc(fn (GroupBadge $badge) => $badge->awarded_at?->getTimestamp() ?? 0)
->take(max(1, min(24, $limit)))
: $group->badges()
->latest('awarded_at')
->limit(max(1, min(24, $limit)))
->get();
return $badges
->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();
$userIds = $stats->pluck('user_id')->map(static fn ($id): int => (int) $id)->unique()->values();
$projectLeadIds = GroupProject::query()
->where('group_id', $group->id)
->whereIn('lead_user_id', $userIds)
->pluck('lead_user_id')
->map(static fn ($id): int => (int) $id)
->flip();
$assetCounts = $group->assets()
->selectRaw('uploaded_by_user_id, COUNT(*) as aggregate')
->whereIn('uploaded_by_user_id', $userIds)
->groupBy('uploaded_by_user_id')
->pluck('aggregate', 'uploaded_by_user_id');
$foundingMemberIds = GroupMember::query()
->where('group_id', $group->id)
->whereIn('user_id', $userIds)
->when($group->created_at, fn ($query) => $query->where('accepted_at', '<=', $group->created_at->copy()->addDays(30)))
->pluck('user_id')
->map(static fn ($id): int => (int) $id)
->flip();
foreach ($stats as $stat) {
$userId = (int) $stat->user_id;
$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, $userId, 'project_lead', $projectLeadIds->has($userId));
$this->awardMemberBadge($group, $userId, 'reliable_reviewer', (int) $stat->review_actions_count >= 5);
$this->awardMemberBadge($group, $userId, 'long_term_collaborator', ((int) $stat->project_count + (int) $stat->release_count) >= 5);
$this->awardMemberBadge($group, $userId, 'founding_member', (int) $group->owner_user_id === $userId || $foundingMemberIds->has($userId));
$this->awardMemberBadge($group, $userId, 'asset_builder', (int) ($assetCounts[$userId] ?? 0) >= 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;
}
}