Files
SkinbaseNova/.deploy/artwork-evolution-release/app/Services/GroupDiscoveryService.php
2026-04-18 17:02:56 +02:00

300 lines
16 KiB
PHP

<?php
declare(strict_types=1);
namespace App\Services;
use App\Models\Artwork;
use App\Models\GroupChallenge;
use App\Models\Group;
use App\Models\GroupActivityItem;
use App\Models\GroupDiscoveryMetric;
use App\Models\GroupEvent;
use App\Models\GroupProject;
use App\Models\GroupRelease;
use App\Models\User;
use Illuminate\Pagination\LengthAwarePaginator;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Support\Collection;
class GroupDiscoveryService
{
public function __construct(
private readonly GroupCardService $cards,
) {
}
public function refresh(Group $group): GroupDiscoveryMetric
{
$publicReleaseCount = (int) $group->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,
};
}
}