Commit workspace changes

This commit is contained in:
2026-04-05 19:42:33 +02:00
parent 148a3bbe43
commit 08ad757bcb
312 changed files with 35149 additions and 399 deletions

View 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();
}
}