Save workspace changes
This commit is contained in:
@@ -0,0 +1,300 @@
|
||||
<?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,
|
||||
};
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user