canRequestJoin($actor)) { throw ValidationException::withMessages([ 'group' => 'This group is not accepting join requests.', ]); } if ($group->hasActiveMember($actor)) { throw ValidationException::withMessages([ 'group' => 'You are already a member of this group.', ]); } $pendingRequest = GroupJoinRequest::query() ->where('group_id', $group->id) ->where('user_id', $actor->id) ->whereIn('status', [ GroupJoinRequest::STATUS_PENDING, ]) ->exists(); if ($pendingRequest) { throw ValidationException::withMessages([ 'group' => 'You already have a pending request for this group.', ]); } $request = GroupJoinRequest::query()->create([ 'group_id' => $group->id, 'user_id' => $actor->id, 'message' => $attributes['message'] ?? null, 'portfolio_url' => $attributes['portfolio_url'] ?? null, 'desired_role' => isset($attributes['desired_role']) ? Group::normalizeMemberRole((string) $attributes['desired_role']) : null, 'skills_json' => $attributes['skills_json'] ?? null, 'status' => GroupJoinRequest::STATUS_PENDING, 'expires_at' => now()->addDays(max(3, (int) config('groups.join_requests.expires_after_days', 21))), ]); $this->history->record( $group, $actor, 'join_request_submitted', sprintf('%s requested to join the group.', $actor->name ?: $actor->username ?: 'A user'), 'group_join_request', (int) $request->id, null, [ 'desired_role' => $request->desired_role, 'portfolio_url' => $request->portfolio_url, ], ); foreach ($this->reviewRecipients($group, $actor->id) as $recipient) { $this->notifications->notifyGroupJoinRequestReceived($recipient, $actor, $group, $request); } if ($group->membership_policy === Group::MEMBERSHIP_OPEN) { GroupMember::query()->updateOrCreate( [ 'group_id' => $group->id, 'user_id' => $actor->id, ], [ 'invited_by_user_id' => $group->owner_user_id, 'role' => Group::normalizeMemberRole((string) ($request->desired_role ?: Group::ROLE_MEMBER)), 'status' => Group::STATUS_ACTIVE, 'note' => 'Auto-approved by open membership policy.', 'invited_at' => now(), 'accepted_at' => now(), 'revoked_at' => null, ], ); $request->forceFill([ 'status' => GroupJoinRequest::STATUS_APPROVED, 'review_notes' => 'Auto-approved by open membership policy.', 'reviewed_at' => now(), ])->save(); $this->history->record( $group, $actor, 'join_request_auto_approved', 'Auto-approved join request because the group uses open membership.', 'group_join_request', (int) $request->id, ['status' => GroupJoinRequest::STATUS_PENDING], ['status' => GroupJoinRequest::STATUS_APPROVED], ); return $request->fresh(['group', 'user.profile']); } return $request->fresh(['group', 'user.profile']); } public function approve(GroupJoinRequest $request, User $actor, ?string $role = null, ?string $notes = null): GroupJoinRequest { $group = $request->group()->with('members')->firstOrFail(); if (! $group->canReviewJoinRequests($actor)) { throw ValidationException::withMessages([ 'request' => 'You are not allowed to review join requests for this group.', ]); } if ($request->status !== GroupJoinRequest::STATUS_PENDING) { throw ValidationException::withMessages([ 'request' => 'Only pending join requests can be approved.', ]); } $resolvedRole = Group::normalizeMemberRole((string) ($role ?: $request->desired_role ?: Group::ROLE_MEMBER)); if (! in_array($resolvedRole, [Group::ROLE_ADMIN, Group::ROLE_EDITOR, Group::ROLE_MEMBER], true)) { $resolvedRole = Group::ROLE_MEMBER; } DB::transaction(function () use ($group, $request, $actor, $resolvedRole, $notes): void { GroupMember::query()->updateOrCreate( [ 'group_id' => $group->id, 'user_id' => $request->user_id, ], [ 'invited_by_user_id' => $actor->id, 'role' => $resolvedRole, 'status' => Group::STATUS_ACTIVE, 'note' => $notes, 'invited_at' => now(), 'accepted_at' => now(), 'revoked_at' => null, ], ); $request->forceFill([ 'status' => GroupJoinRequest::STATUS_APPROVED, 'reviewed_by_user_id' => $actor->id, 'review_notes' => $notes, 'reviewed_at' => now(), ])->save(); }); $request->refresh(); $this->history->record( $group, $actor, 'join_request_approved', sprintf('Approved %s to join the group.', $request->user?->name ?: $request->user?->username ?: 'a user'), 'group_join_request', (int) $request->id, ['status' => GroupJoinRequest::STATUS_PENDING], ['status' => GroupJoinRequest::STATUS_APPROVED, 'role' => $resolvedRole], ); app(GroupActivityService::class)->record( $group, $actor, 'member_joined', 'group_join_request', (int) $request->id, sprintf('%s joined %s', $request->user?->name ?: $request->user?->username ?: 'A member', $group->name), 'Membership approved through group join requests.', 'public', ); $this->notifications->notifyGroupJoinRequestApproved($request->user, $actor, $group, $resolvedRole, $request); return $request->fresh(['group', 'user.profile', 'reviewedBy.profile']); } public function reject(GroupJoinRequest $request, User $actor, ?string $notes = null): GroupJoinRequest { $group = $request->group()->with('members')->firstOrFail(); if (! $group->canReviewJoinRequests($actor)) { throw ValidationException::withMessages([ 'request' => 'You are not allowed to review join requests for this group.', ]); } if ($request->status !== GroupJoinRequest::STATUS_PENDING) { throw ValidationException::withMessages([ 'request' => 'Only pending join requests can be rejected.', ]); } $request->forceFill([ 'status' => GroupJoinRequest::STATUS_REJECTED, 'reviewed_by_user_id' => $actor->id, 'review_notes' => $notes, 'reviewed_at' => now(), ])->save(); $this->history->record( $group, $actor, 'join_request_rejected', sprintf('Rejected join request from %s.', $request->user?->name ?: $request->user?->username ?: 'a user'), 'group_join_request', (int) $request->id, ['status' => GroupJoinRequest::STATUS_PENDING], ['status' => GroupJoinRequest::STATUS_REJECTED], ); $this->notifications->notifyGroupJoinRequestRejected($request->user, $actor, $group, $request); return $request->fresh(['group', 'user.profile', 'reviewedBy.profile']); } public function withdraw(GroupJoinRequest $request, User $actor): GroupJoinRequest { if ((int) $request->user_id !== (int) $actor->id || $request->status !== GroupJoinRequest::STATUS_PENDING) { throw ValidationException::withMessages([ 'request' => 'This join request cannot be withdrawn.', ]); } $request->forceFill([ 'status' => GroupJoinRequest::STATUS_WITHDRAWN, 'reviewed_at' => now(), ])->save(); $this->history->record( $request->group, $actor, 'join_request_withdrawn', 'Join request withdrawn.', 'group_join_request', (int) $request->id, ['status' => GroupJoinRequest::STATUS_PENDING], ['status' => GroupJoinRequest::STATUS_WITHDRAWN], ); return $request->fresh(['group', 'user.profile']); } public function pendingCount(Group $group): int { return (int) GroupJoinRequest::query() ->where('group_id', $group->id) ->where('status', GroupJoinRequest::STATUS_PENDING) ->count(); } public function currentRequestFor(Group $group, ?User $viewer): ?array { if (! $viewer) { return null; } $request = GroupJoinRequest::query() ->where('group_id', $group->id) ->where('user_id', $viewer->id) ->latest('created_at') ->first(); return $request ? $this->mapRequest($request) : null; } public function mapRequests(Group $group, ?User $viewer = null, array $filters = []): array { $bucket = (string) ($filters['bucket'] ?? 'pending'); $page = max(1, (int) ($filters['page'] ?? 1)); $perPage = min(max((int) ($filters['per_page'] ?? 20), 10), 50); $query = GroupJoinRequest::query() ->with(['user.profile', 'reviewedBy.profile']) ->where('group_id', $group->id); if ($bucket !== 'all') { $query->where('status', $bucket); } $paginator = $query->latest('created_at')->paginate($perPage, ['*'], 'page', $page); return [ 'items' => collect($paginator->items())->map(fn (GroupJoinRequest $request): array => $this->mapRequest($request, $group, $viewer))->values()->all(), 'meta' => [ 'current_page' => $paginator->currentPage(), 'last_page' => $paginator->lastPage(), 'per_page' => $paginator->perPage(), 'total' => $paginator->total(), ], 'filters' => [ 'bucket' => $bucket, ], 'bucket_options' => [ ['value' => 'pending', 'label' => 'Pending'], ['value' => 'approved', 'label' => 'Approved'], ['value' => 'rejected', 'label' => 'Rejected'], ['value' => 'withdrawn', 'label' => 'Withdrawn'], ['value' => 'all', 'label' => 'All'], ], ]; } public function mapRequest(GroupJoinRequest $request, ?Group $group = null, ?User $viewer = null): array { $resolvedGroup = $group ?: $request->group; return [ 'id' => (int) $request->id, 'status' => (string) $request->status, 'message' => $request->message, 'portfolio_url' => $request->portfolio_url, 'desired_role' => $request->desired_role, 'desired_role_label' => Group::displayRole($request->desired_role), 'skills' => array_values(array_filter($request->skills_json ?? [])), 'review_notes' => $request->review_notes, 'created_at' => $request->created_at?->toISOString(), 'reviewed_at' => $request->reviewed_at?->toISOString(), 'expires_at' => $request->expires_at?->toISOString(), 'user' => $request->user ? [ 'id' => (int) $request->user->id, 'name' => $request->user->name, 'username' => $request->user->username, 'avatar_url' => AvatarUrl::forUser((int) $request->user->id, $request->user->profile?->avatar_hash, 72), 'profile_url' => route('profile.show', ['username' => strtolower((string) $request->user->username)]), ] : null, 'reviewed_by' => $request->reviewedBy ? [ 'id' => (int) $request->reviewedBy->id, 'name' => $request->reviewedBy->name, 'username' => $request->reviewedBy->username, ] : null, 'can_approve' => $viewer !== null && $resolvedGroup !== null && $resolvedGroup->canReviewJoinRequests($viewer) && $request->status === GroupJoinRequest::STATUS_PENDING, 'can_reject' => $viewer !== null && $resolvedGroup !== null && $resolvedGroup->canReviewJoinRequests($viewer) && $request->status === GroupJoinRequest::STATUS_PENDING, ]; } private function reviewRecipients(Group $group, int $excludeUserId): array { return User::query() ->whereIn('id', $this->memberships->activeContributorIds($group)) ->get() ->filter(fn (User $member): bool => (int) $member->id !== $excludeUserId && $group->canReviewJoinRequests($member)) ->values() ->all(); } }