366 lines
14 KiB
PHP
366 lines
14 KiB
PHP
<?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();
|
|
}
|
|
} |