From 7805baa17dace80ffa518adf9c52db1a92470e57 Mon Sep 17 00:00:00 2001 From: Gregor Klevze Date: Sun, 30 Aug 2026 07:58:38 +0200 Subject: [PATCH] Collapse profile group contribution queries --- .../Controllers/User/ProfileController.php | 116 ++++++++---- .../ProfileGroupContributionHistoryTest.php | 170 ++++++++++++++++++ 2 files changed, 256 insertions(+), 30 deletions(-) create mode 100644 tests/Feature/Profile/ProfileGroupContributionHistoryTest.php diff --git a/app/Http/Controllers/User/ProfileController.php b/app/Http/Controllers/User/ProfileController.php index 81657c33..aa1ca96e 100644 --- a/app/Http/Controllers/User/ProfileController.php +++ b/app/Http/Controllers/User/ProfileController.php @@ -1499,7 +1499,7 @@ class ProfileController extends Controller private function buildGroupContributionHistory(User $user): array { - return GroupContributorStat::query() + $stats = GroupContributorStat::query() ->with(['group.owner.profile']) ->where('user_id', $user->id) ->whereHas('group', fn ($query) => $query @@ -1508,29 +1508,60 @@ class ProfileController extends Controller ->orderByDesc('release_count') ->orderByDesc('credited_artworks_count') ->limit(8) + ->get(); + + $groupIds = $stats->pluck('group_id')->map(static fn ($id): int => (int) $id)->values()->all(); + if ($groupIds === []) { + return []; + } + + $membershipsByGroup = GroupMember::query() + ->whereIn('group_id', $groupIds) + ->where('user_id', $user->id) + ->where('status', Group::STATUS_ACTIVE) ->get() - ->map(function (GroupContributorStat $stat) use ($user): ?array { + ->keyBy('group_id'); + + $releaseRolesByGroup = GroupReleaseContributor::query() + ->join('group_releases as bulk_releases', 'bulk_releases.id', '=', 'group_release_contributors.group_release_id') + ->where('group_release_contributors.user_id', $user->id) + ->whereIn('bulk_releases.group_id', $groupIds) + ->whereNull('bulk_releases.deleted_at') + ->orderBy('group_release_contributors.id') + ->get([ + 'bulk_releases.group_id', + 'group_release_contributors.role_label', + ]) + ->groupBy('group_id'); + + $projectRolesByGroup = GroupProjectMember::query() + ->join('group_projects as bulk_projects', 'bulk_projects.id', '=', 'group_project_members.group_project_id') + ->where('group_project_members.user_id', $user->id) + ->whereIn('bulk_projects.group_id', $groupIds) + ->whereNull('bulk_projects.deleted_at') + ->orderBy('group_project_members.id') + ->get([ + 'bulk_projects.group_id', + 'group_project_members.role_label', + ]) + ->groupBy('group_id'); + + $recentReleasesByGroup = $this->loadRecentGroupReleaseTitles($user, $groupIds); + + return $stats + ->map(function (GroupContributorStat $stat) use ($user, $membershipsByGroup, $releaseRolesByGroup, $projectRolesByGroup, $recentReleasesByGroup): ?array { $group = $stat->group; if (! $group) { return null; } - $member = GroupMember::query() - ->where('group_id', $group->id) - ->where('user_id', $user->id) - ->where('status', Group::STATUS_ACTIVE) - ->first(); + $groupId = (int) $group->id; + $member = $membershipsByGroup->get($groupId); $roleLabels = collect() - ->merge(GroupReleaseContributor::query() - ->where('user_id', $user->id) - ->whereHas('release', fn ($query) => $query->where('group_id', $group->id)) - ->pluck('role_label')) - ->merge(GroupProjectMember::query() - ->where('user_id', $user->id) - ->whereHas('project', fn ($query) => $query->where('group_id', $group->id)) - ->pluck('role_label')) + ->merge($releaseRolesByGroup->get($groupId, collect())->pluck('role_label')) + ->merge($projectRolesByGroup->get($groupId, collect())->pluck('role_label')) ->filter() ->map(fn ($label): string => trim((string) $label)) ->filter() @@ -1538,21 +1569,7 @@ class ProfileController extends Controller ->values() ->all(); - $recentReleaseTitles = GroupRelease::query() - ->where('group_id', $group->id) - ->where('visibility', GroupRelease::VISIBILITY_PUBLIC) - ->where(function ($query) use ($user): void { - $query->where('lead_user_id', $user->id) - ->orWhere('created_by_user_id', $user->id) - ->orWhereHas('contributorLinks', fn ($contributorQuery) => $contributorQuery->where('user_id', $user->id)); - }) - ->orderByDesc('released_at') - ->latest('updated_at') - ->limit(3) - ->pluck('title') - ->map(fn ($title): string => (string) $title) - ->values() - ->all(); + $recentReleaseTitles = $recentReleasesByGroup->get($groupId, collect())->all(); $joinedAt = $group->isOwnedBy($user) ? $group->created_at?->toISOString() @@ -1589,6 +1606,45 @@ class ProfileController extends Controller ->all(); } + /** + * Load the same top-three public releases per group as the previous + * per-group LIMIT query, while keeping the existing two-column ordering. + * + * @param array $groupIds + * @return \Illuminate\Support\Collection> + */ + private function loadRecentGroupReleaseTitles(User $user, array $groupIds): \Illuminate\Support\Collection + { + $eligibleReleases = GroupRelease::query() + ->whereIn('group_id', $groupIds) + ->where('visibility', GroupRelease::VISIBILITY_PUBLIC) + ->where(function ($query) use ($user): void { + $query->where('lead_user_id', $user->id) + ->orWhere('created_by_user_id', $user->id) + ->orWhereHas('contributorLinks', fn ($contributorQuery) => $contributorQuery->where('user_id', $user->id)); + }) + ->select(['id', 'group_id', 'title', 'released_at', 'updated_at']); + + $ranked = DB::query() + ->fromSub($eligibleReleases, 'eligible_group_releases') + ->select([ + 'group_id', + 'title', + 'released_at', + 'updated_at', + ]) + ->selectRaw('ROW_NUMBER() OVER (PARTITION BY group_id ORDER BY released_at DESC, updated_at DESC) as release_rank'); + + return DB::query() + ->fromSub($ranked, 'ranked_group_releases') + ->where('release_rank', '<=', 3) + ->orderBy('group_id') + ->orderBy('release_rank') + ->get(['group_id', 'title']) + ->groupBy('group_id') + ->map(fn ($rows) => $rows->pluck('title')->map(fn ($title): string => (string) $title)->values()); + } + private function resolveFavouriteTable(): ?string { foreach (['artwork_favourites', 'user_favorites', 'artworks_favourites', 'favourites'] as $table) { diff --git a/tests/Feature/Profile/ProfileGroupContributionHistoryTest.php b/tests/Feature/Profile/ProfileGroupContributionHistoryTest.php new file mode 100644 index 00000000..5e861a28 --- /dev/null +++ b/tests/Feature/Profile/ProfileGroupContributionHistoryTest.php @@ -0,0 +1,170 @@ +setAccessible(true); + + return $method->invoke(app(ProfileController::class), $user); +} + +function makeGroupRelease(Group $group, User $creator, string $title, CarbonImmutable $releasedAt, string $visibility = GroupRelease::VISIBILITY_PUBLIC): GroupRelease +{ + return GroupRelease::create([ + 'group_id' => $group->id, + 'title' => $title, + 'slug' => strtolower(str_replace(' ', '-', $title)) . '-' . uniqid(), + 'status' => GroupRelease::STATUS_RELEASED, + 'current_stage' => GroupRelease::STAGE_RELEASED, + 'visibility' => $visibility, + 'released_at' => $releasedAt, + 'published_at' => $releasedAt, + 'created_by_user_id' => $creator->id, + ]); +} + +it('preserves contribution output semantics while loading group data in bulk', function (): void { + $user = User::factory()->create(); + $owner = User::factory()->create(); + $group = Group::factory()->create([ + 'owner_user_id' => $owner->id, + 'name' => 'Visible Contribution Group', + 'slug' => 'visible-contribution-group-' . uniqid(), + ]); + + GroupContributorStat::create([ + 'group_id' => $group->id, + 'user_id' => $user->id, + 'credited_artworks_count' => 7, + 'release_count' => 5, + 'project_count' => 3, + 'review_actions_count' => 2, + ]); + GroupMember::create([ + 'group_id' => $group->id, + 'user_id' => $user->id, + 'role' => Group::ROLE_EDITOR, + 'status' => Group::STATUS_ACTIVE, + ]); + + $project = GroupProject::create([ + 'group_id' => $group->id, + 'title' => 'Visible Project', + 'slug' => 'visible-project-' . uniqid(), + 'status' => GroupProject::STATUS_ACTIVE, + 'visibility' => GroupProject::VISIBILITY_PUBLIC, + 'created_by_user_id' => $owner->id, + ]); + GroupProjectMember::create([ + 'group_project_id' => $project->id, + 'user_id' => $user->id, + 'role_label' => 'Project Lead', + 'is_lead' => true, + ]); + + $newest = makeGroupRelease($group, $user, 'Newest Release', CarbonImmutable::parse('2026-08-20 12:00:00')); + $middle = makeGroupRelease($group, $owner, 'Middle Release', CarbonImmutable::parse('2026-08-19 12:00:00')); + $oldest = makeGroupRelease($group, $owner, 'Oldest Release', CarbonImmutable::parse('2026-08-18 12:00:00')); + makeGroupRelease($group, $owner, 'Hidden Release', CarbonImmutable::parse('2026-08-21 12:00:00'), GroupRelease::VISIBILITY_PRIVATE); + GroupReleaseContributor::create([ + 'group_release_id' => $middle->id, + 'user_id' => $user->id, + 'role_label' => 'Release Contributor', + 'sort_order' => 0, + ]); + GroupReleaseContributor::create([ + 'group_release_id' => $oldest->id, + 'user_id' => $user->id, + 'role_label' => 'Project Lead', + 'sort_order' => 1, + ]); + + $result = invokeGroupContributionHistory($user); + + expect($result)->toHaveCount(1); + expect($result[0]['group'])->toMatchArray([ + 'id' => $group->id, + 'name' => 'Visible Contribution Group', + 'slug' => $group->slug, + ]); + expect($result[0]['joined_at'])->toBeString(); + expect($result[0]['counts'])->toBe([ + 'credited_artworks' => 7, + 'releases' => 5, + 'projects' => 3, + 'review_actions' => 2, + ]); + expect($result[0]['role_labels'])->toBe(['Release Contributor', 'Project Lead']); + expect($result[0]['recent_release_titles'])->toBe(['Newest Release', 'Middle Release', 'Oldest Release']); + expect($result[0]['role'])->toBe(Group::ROLE_EDITOR); + expect($result[0]['trusted_indicator'])->toBeFalse(); +}); + +it('keeps public visibility filtering and owner role semantics', function (): void { + $user = User::factory()->create(); + $ownerGroup = Group::factory()->create([ + 'owner_user_id' => $user->id, + 'slug' => 'owner-contribution-group-' . uniqid(), + ]); + $privateGroup = Group::factory()->private()->create([ + 'slug' => 'private-contribution-group-' . uniqid(), + ]); + + GroupContributorStat::create(['group_id' => $ownerGroup->id, 'user_id' => $user->id]); + GroupContributorStat::create(['group_id' => $privateGroup->id, 'user_id' => $user->id, 'release_count' => 99]); + + $result = invokeGroupContributionHistory($user); + + expect($result)->toHaveCount(1) + ->and($result[0]['group']['id'])->toBe($ownerGroup->id) + ->and($result[0]['role'])->toBe(Group::ROLE_OWNER) + ->and($result[0]['joined_at'])->toBeString(); +}); + +it('keeps contribution-history query count bounded as group count grows', function (): void { + $counts = []; + + foreach ([0, 1, 2, 4, 8] as $groupCount) { + $user = User::factory()->create(); + + for ($index = 0; $index < $groupCount; $index++) { + $group = Group::factory()->create([ + 'slug' => 'query-count-group-' . $groupCount . '-' . $index . '-' . uniqid(), + ]); + GroupContributorStat::create([ + 'group_id' => $group->id, + 'user_id' => $user->id, + 'credited_artworks_count' => $groupCount - $index, + 'release_count' => $groupCount - $index, + ]); + } + + DB::flushQueryLog(); + DB::enableQueryLog(); + invokeGroupContributionHistory($user); + $counts[$groupCount] = count(DB::getQueryLog()); + DB::disableQueryLog(); + } + + expect($counts)->toBe([ + 0 => 1, + 1 => 8, + 2 => 8, + 4 => 8, + 8 => 8, + ]); +});