Commit workspace changes
This commit is contained in:
366
app/Services/GroupJoinRequestService.php
Normal file
366
app/Services/GroupJoinRequestService.php
Normal file
@@ -0,0 +1,366 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use App\Models\Group;
|
||||
use App\Models\GroupJoinRequest;
|
||||
use App\Models\GroupMember;
|
||||
use App\Models\User;
|
||||
use App\Support\AvatarUrl;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
|
||||
class GroupJoinRequestService
|
||||
{
|
||||
public function __construct(
|
||||
private readonly GroupHistoryService $history,
|
||||
private readonly GroupMembershipService $memberships,
|
||||
private readonly NotificationService $notifications,
|
||||
) {
|
||||
}
|
||||
|
||||
public function submit(Group $group, User $actor, array $attributes): GroupJoinRequest
|
||||
{
|
||||
if (! $group->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();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user