Commit workspace changes
This commit is contained in:
762
app/Services/GroupMembershipService.php
Normal file
762
app/Services/GroupMembershipService.php
Normal file
@@ -0,0 +1,762 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use App\Models\Group;
|
||||
use App\Models\GroupInvitation;
|
||||
use App\Models\GroupMember;
|
||||
use App\Models\User;
|
||||
use App\Support\AvatarUrl;
|
||||
use Illuminate\Support\Carbon;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Str;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
|
||||
class GroupMembershipService
|
||||
{
|
||||
public function __construct(
|
||||
private readonly NotificationService $notifications,
|
||||
) {
|
||||
}
|
||||
|
||||
public function ensureOwnerMembership(Group $group): void
|
||||
{
|
||||
GroupMember::query()
|
||||
->where('group_id', $group->id)
|
||||
->where('role', Group::ROLE_OWNER)
|
||||
->where('user_id', '!=', $group->owner_user_id)
|
||||
->update([
|
||||
'role' => Group::ROLE_ADMIN,
|
||||
'updated_at' => now(),
|
||||
]);
|
||||
|
||||
GroupMember::query()->updateOrCreate(
|
||||
[
|
||||
'group_id' => $group->id,
|
||||
'user_id' => $group->owner_user_id,
|
||||
],
|
||||
[
|
||||
'invited_by_user_id' => $group->owner_user_id,
|
||||
'role' => Group::ROLE_OWNER,
|
||||
'status' => Group::STATUS_ACTIVE,
|
||||
'invited_at' => now(),
|
||||
'expires_at' => null,
|
||||
'accepted_at' => now(),
|
||||
'revoked_at' => null,
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
public function inviteMember(Group $group, User $actor, User $invitee, string $role, ?string $note = null, ?int $expiresInDays = null): GroupInvitation
|
||||
{
|
||||
$this->guardManageMembers($group, $actor);
|
||||
$this->expirePendingInvites();
|
||||
$role = Group::normalizeMemberRole($role);
|
||||
|
||||
if (! in_array($role, [Group::ROLE_ADMIN, Group::ROLE_EDITOR, Group::ROLE_MEMBER], true)) {
|
||||
throw ValidationException::withMessages([
|
||||
'role' => 'Choose a valid group role.',
|
||||
]);
|
||||
}
|
||||
|
||||
if ($group->isOwnedBy($invitee)) {
|
||||
throw ValidationException::withMessages([
|
||||
'username' => 'The group owner is already a member.',
|
||||
]);
|
||||
}
|
||||
|
||||
$existingMembership = GroupMember::query()
|
||||
->where('group_id', $group->id)
|
||||
->where('user_id', $invitee->id)
|
||||
->where('status', Group::STATUS_ACTIVE)
|
||||
->exists();
|
||||
|
||||
if ($existingMembership) {
|
||||
throw ValidationException::withMessages([
|
||||
'username' => 'This user is already an active member of the group.',
|
||||
]);
|
||||
}
|
||||
|
||||
$invitation = DB::transaction(function () use ($group, $actor, $invitee, $role, $note, $expiresInDays): GroupInvitation {
|
||||
$now = now();
|
||||
|
||||
GroupInvitation::query()
|
||||
->where('group_id', $group->id)
|
||||
->where('invited_user_id', $invitee->id)
|
||||
->where('status', GroupInvitation::STATUS_PENDING)
|
||||
->update([
|
||||
'status' => GroupInvitation::STATUS_REVOKED,
|
||||
'responded_at' => $now,
|
||||
'revoked_at' => $now,
|
||||
'updated_at' => $now,
|
||||
]);
|
||||
|
||||
$invitation = GroupInvitation::query()->create([
|
||||
'group_id' => $group->id,
|
||||
'invited_user_id' => $invitee->id,
|
||||
'invited_by_user_id' => $actor->id,
|
||||
'role' => $role,
|
||||
'status' => GroupInvitation::STATUS_PENDING,
|
||||
'token' => Str::random(64),
|
||||
'note' => $note,
|
||||
'invited_at' => $now,
|
||||
'expires_at' => $now->copy()->addDays(max(1, (int) ($expiresInDays ?? config('groups.invites.expires_after_days', 7)))),
|
||||
'responded_at' => null,
|
||||
'accepted_at' => null,
|
||||
'revoked_at' => null,
|
||||
]);
|
||||
|
||||
return $invitation->fresh(['group.owner.profile', 'invitedUser.profile', 'invitedBy.profile']);
|
||||
});
|
||||
|
||||
$this->notifications->notifyGroupInvite($invitee, $actor, $group, $role, $invitation);
|
||||
|
||||
return $invitation;
|
||||
}
|
||||
|
||||
public function acceptInvitation(GroupInvitation $invitation, User $user): GroupMember
|
||||
{
|
||||
$this->expireInvitationIfNeeded($invitation);
|
||||
|
||||
if ((int) $invitation->invited_user_id !== (int) $user->id || $invitation->status !== GroupInvitation::STATUS_PENDING) {
|
||||
throw ValidationException::withMessages([
|
||||
'invitation' => 'This invitation cannot be accepted.',
|
||||
]);
|
||||
}
|
||||
|
||||
$member = DB::transaction(function () use ($invitation): GroupMember {
|
||||
$acceptedAt = now();
|
||||
|
||||
$member = GroupMember::query()->updateOrCreate(
|
||||
[
|
||||
'group_id' => $invitation->group_id,
|
||||
'user_id' => $invitation->invited_user_id,
|
||||
],
|
||||
[
|
||||
'invited_by_user_id' => $invitation->invited_by_user_id,
|
||||
'role' => $invitation->role,
|
||||
'status' => Group::STATUS_ACTIVE,
|
||||
'note' => $invitation->note,
|
||||
'invited_at' => $invitation->invited_at ?? $acceptedAt,
|
||||
'expires_at' => null,
|
||||
'accepted_at' => $acceptedAt,
|
||||
'revoked_at' => null,
|
||||
]
|
||||
);
|
||||
|
||||
$invitation->forceFill([
|
||||
'status' => GroupInvitation::STATUS_ACCEPTED,
|
||||
'responded_at' => $acceptedAt,
|
||||
'accepted_at' => $acceptedAt,
|
||||
'revoked_at' => null,
|
||||
])->save();
|
||||
|
||||
$this->syncLegacyMemberFromInvitation($invitation, Group::STATUS_ACTIVE, $acceptedAt);
|
||||
|
||||
GroupInvitation::query()
|
||||
->where('group_id', $invitation->group_id)
|
||||
->where('invited_user_id', $invitation->invited_user_id)
|
||||
->where('status', GroupInvitation::STATUS_PENDING)
|
||||
->where('id', '!=', $invitation->id)
|
||||
->update([
|
||||
'status' => GroupInvitation::STATUS_REVOKED,
|
||||
'responded_at' => $acceptedAt,
|
||||
'revoked_at' => $acceptedAt,
|
||||
'updated_at' => $acceptedAt,
|
||||
]);
|
||||
|
||||
return $member->fresh(['group.owner.profile', 'user.profile', 'invitedBy.profile']);
|
||||
});
|
||||
|
||||
$recipient = $member->invitedBy ?: $member->group?->owner;
|
||||
if ($recipient) {
|
||||
$this->notifications->notifyGroupInviteAccepted($recipient, $user, $member->group);
|
||||
}
|
||||
|
||||
if ($member->group) {
|
||||
app(GroupActivityService::class)->record(
|
||||
$member->group,
|
||||
$user,
|
||||
'member_joined',
|
||||
'group_member',
|
||||
(int) $member->id,
|
||||
sprintf('%s joined %s', $user->name ?: $user->username ?: 'A member', $member->group->name),
|
||||
'Accepted a group invitation.',
|
||||
'public',
|
||||
);
|
||||
}
|
||||
|
||||
return $member;
|
||||
}
|
||||
|
||||
public function declineInvitation(GroupInvitation $invitation, User $user): GroupInvitation
|
||||
{
|
||||
$this->expireInvitationIfNeeded($invitation);
|
||||
|
||||
if ((int) $invitation->invited_user_id !== (int) $user->id || $invitation->status !== GroupInvitation::STATUS_PENDING) {
|
||||
throw ValidationException::withMessages([
|
||||
'invitation' => 'This invitation cannot be declined.',
|
||||
]);
|
||||
}
|
||||
|
||||
$declinedAt = now();
|
||||
|
||||
$invitation->forceFill([
|
||||
'status' => GroupInvitation::STATUS_DECLINED,
|
||||
'responded_at' => $declinedAt,
|
||||
'accepted_at' => null,
|
||||
'revoked_at' => null,
|
||||
])->save();
|
||||
|
||||
$this->syncLegacyMemberFromInvitation($invitation, Group::STATUS_REVOKED, $declinedAt);
|
||||
|
||||
return $invitation->fresh(['group.owner.profile', 'invitedUser.profile', 'invitedBy.profile']);
|
||||
}
|
||||
|
||||
public function acceptLegacyInvite(GroupMember $member, User $user): GroupMember
|
||||
{
|
||||
$this->expireMemberIfNeeded($member);
|
||||
|
||||
if ((int) $member->user_id !== (int) $user->id || $member->status !== Group::STATUS_PENDING) {
|
||||
throw ValidationException::withMessages([
|
||||
'member' => 'This invitation cannot be accepted.',
|
||||
]);
|
||||
}
|
||||
|
||||
$acceptedAt = now();
|
||||
|
||||
$member->forceFill([
|
||||
'status' => Group::STATUS_ACTIVE,
|
||||
'expires_at' => null,
|
||||
'accepted_at' => $acceptedAt,
|
||||
'revoked_at' => null,
|
||||
])->save();
|
||||
|
||||
GroupInvitation::query()
|
||||
->where('source_group_member_id', $member->id)
|
||||
->where('status', GroupInvitation::STATUS_PENDING)
|
||||
->update([
|
||||
'status' => GroupInvitation::STATUS_ACCEPTED,
|
||||
'responded_at' => $acceptedAt,
|
||||
'accepted_at' => $acceptedAt,
|
||||
'revoked_at' => null,
|
||||
'updated_at' => $acceptedAt,
|
||||
]);
|
||||
|
||||
$recipient = $member->invitedBy ?: $member->group?->owner;
|
||||
if ($recipient) {
|
||||
$this->notifications->notifyGroupInviteAccepted($recipient, $user, $member->group);
|
||||
}
|
||||
|
||||
if ($member->group) {
|
||||
app(GroupActivityService::class)->record(
|
||||
$member->group,
|
||||
$user,
|
||||
'member_joined',
|
||||
'group_member',
|
||||
(int) $member->id,
|
||||
sprintf('%s joined %s', $user->name ?: $user->username ?: 'A member', $member->group->name),
|
||||
'Accepted a legacy group invitation.',
|
||||
'public',
|
||||
);
|
||||
}
|
||||
|
||||
return $member->fresh(['group.owner.profile', 'user.profile', 'invitedBy.profile']);
|
||||
}
|
||||
|
||||
public function declineLegacyInvite(GroupMember $member, User $user): GroupMember
|
||||
{
|
||||
$this->expireMemberIfNeeded($member);
|
||||
|
||||
if ((int) $member->user_id !== (int) $user->id || $member->status !== Group::STATUS_PENDING) {
|
||||
throw ValidationException::withMessages([
|
||||
'member' => 'This invitation cannot be declined.',
|
||||
]);
|
||||
}
|
||||
|
||||
$declinedAt = now();
|
||||
|
||||
$member->forceFill([
|
||||
'status' => Group::STATUS_REVOKED,
|
||||
'accepted_at' => null,
|
||||
'revoked_at' => $declinedAt,
|
||||
])->save();
|
||||
|
||||
GroupInvitation::query()
|
||||
->where('source_group_member_id', $member->id)
|
||||
->where('status', GroupInvitation::STATUS_PENDING)
|
||||
->update([
|
||||
'status' => GroupInvitation::STATUS_DECLINED,
|
||||
'responded_at' => $declinedAt,
|
||||
'accepted_at' => null,
|
||||
'updated_at' => $declinedAt,
|
||||
]);
|
||||
|
||||
return $member->fresh(['group.owner.profile', 'user.profile', 'invitedBy.profile']);
|
||||
}
|
||||
|
||||
public function updateMemberRole(GroupMember $member, User $actor, string $role): GroupMember
|
||||
{
|
||||
$this->guardManageMembers($member->group, $actor);
|
||||
$role = Group::normalizeMemberRole($role);
|
||||
|
||||
if ($member->role === Group::ROLE_OWNER) {
|
||||
throw ValidationException::withMessages([
|
||||
'member' => 'The group owner role cannot be changed.',
|
||||
]);
|
||||
}
|
||||
|
||||
if (! in_array($role, [Group::ROLE_ADMIN, Group::ROLE_EDITOR, Group::ROLE_MEMBER], true)) {
|
||||
throw ValidationException::withMessages([
|
||||
'role' => 'Choose a valid group role.',
|
||||
]);
|
||||
}
|
||||
|
||||
$member->forceFill(['role' => $role])->save();
|
||||
$this->notifications->notifyGroupRoleChanged($member->user, $actor, $member->group, Group::displayRole($role) ?? $role);
|
||||
|
||||
return $member->fresh(['group.owner.profile', 'user.profile', 'invitedBy.profile']);
|
||||
}
|
||||
|
||||
public function updatePermissionOverrides(GroupMember $member, User $actor, array $overrides): GroupMember
|
||||
{
|
||||
if (! $member->group->canManageMemberPermissions($actor) && ! $actor->isAdmin()) {
|
||||
throw ValidationException::withMessages([
|
||||
'member' => 'You are not allowed to manage group member permissions.',
|
||||
]);
|
||||
}
|
||||
|
||||
if ($member->role === Group::ROLE_OWNER) {
|
||||
throw ValidationException::withMessages([
|
||||
'member' => 'The group owner already has full permissions.',
|
||||
]);
|
||||
}
|
||||
|
||||
$normalized = collect($overrides)
|
||||
->map(function ($override): ?array {
|
||||
if (is_string($override)) {
|
||||
$key = trim($override);
|
||||
|
||||
return $key !== '' && in_array($key, Group::allowedPermissionOverrides(), true)
|
||||
? ['key' => $key, 'is_allowed' => true]
|
||||
: null;
|
||||
}
|
||||
|
||||
if (! is_array($override)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$key = trim((string) ($override['key'] ?? ''));
|
||||
if ($key === '' || ! in_array($key, Group::allowedPermissionOverrides(), true)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return [
|
||||
'key' => $key,
|
||||
'is_allowed' => (bool) ($override['is_allowed'] ?? false),
|
||||
];
|
||||
})
|
||||
->filter()
|
||||
->unique(fn (array $override): string => $override['key'])
|
||||
->values()
|
||||
->all();
|
||||
|
||||
$member->forceFill([
|
||||
'permission_overrides_json' => $normalized,
|
||||
])->save();
|
||||
|
||||
return $member->fresh(['group.owner.profile', 'user.profile', 'invitedBy.profile']);
|
||||
}
|
||||
|
||||
public function revokeMember(GroupMember $member, User $actor): void
|
||||
{
|
||||
$this->guardManageMembers($member->group, $actor);
|
||||
|
||||
$wasActiveMember = $member->status === Group::STATUS_ACTIVE;
|
||||
|
||||
if ($member->role === Group::ROLE_OWNER) {
|
||||
throw ValidationException::withMessages([
|
||||
'member' => 'The group owner cannot be removed.',
|
||||
]);
|
||||
}
|
||||
|
||||
$member->forceFill([
|
||||
'status' => Group::STATUS_REVOKED,
|
||||
'expires_at' => null,
|
||||
'revoked_at' => now(),
|
||||
])->save();
|
||||
|
||||
if ($wasActiveMember) {
|
||||
$this->notifications->notifyGroupMemberRemoved($member->user, $actor, $member->group);
|
||||
}
|
||||
}
|
||||
|
||||
public function revokeInvitation(GroupInvitation $invitation, User $actor): GroupInvitation
|
||||
{
|
||||
$this->guardManageMembers($invitation->group, $actor);
|
||||
|
||||
if ($invitation->status !== GroupInvitation::STATUS_PENDING) {
|
||||
throw ValidationException::withMessages([
|
||||
'invitation' => 'Only pending invitations can be revoked.',
|
||||
]);
|
||||
}
|
||||
|
||||
$revokedAt = now();
|
||||
|
||||
$invitation->forceFill([
|
||||
'status' => GroupInvitation::STATUS_REVOKED,
|
||||
'responded_at' => $revokedAt,
|
||||
'revoked_at' => $revokedAt,
|
||||
])->save();
|
||||
|
||||
$this->syncLegacyMemberFromInvitation($invitation, Group::STATUS_REVOKED, $revokedAt);
|
||||
|
||||
return $invitation->fresh(['group.owner.profile', 'invitedUser.profile', 'invitedBy.profile']);
|
||||
}
|
||||
|
||||
public function transferOwnership(Group $group, GroupMember $member, User $actor): Group
|
||||
{
|
||||
if (! $group->isOwnedBy($actor) && ! $actor->isAdmin()) {
|
||||
throw ValidationException::withMessages([
|
||||
'member' => 'Only the group owner can transfer ownership.',
|
||||
]);
|
||||
}
|
||||
|
||||
if ((int) $member->group_id !== (int) $group->id) {
|
||||
throw ValidationException::withMessages([
|
||||
'member' => 'This member does not belong to the selected group.',
|
||||
]);
|
||||
}
|
||||
|
||||
if ($member->status !== Group::STATUS_ACTIVE) {
|
||||
throw ValidationException::withMessages([
|
||||
'member' => 'Only active members can become the new owner.',
|
||||
]);
|
||||
}
|
||||
|
||||
return DB::transaction(function () use ($group, $member): Group {
|
||||
GroupMember::query()
|
||||
->where('group_id', $group->id)
|
||||
->where('user_id', $group->owner_user_id)
|
||||
->update([
|
||||
'role' => Group::ROLE_ADMIN,
|
||||
'updated_at' => now(),
|
||||
]);
|
||||
|
||||
$member->forceFill([
|
||||
'role' => Group::ROLE_OWNER,
|
||||
'status' => Group::STATUS_ACTIVE,
|
||||
'expires_at' => null,
|
||||
'accepted_at' => $member->accepted_at ?? now(),
|
||||
])->save();
|
||||
|
||||
$group->forceFill([
|
||||
'owner_user_id' => $member->user_id,
|
||||
'last_activity_at' => now(),
|
||||
])->save();
|
||||
|
||||
$this->ensureOwnerMembership($group->fresh());
|
||||
|
||||
return $group->fresh(['owner.profile']);
|
||||
});
|
||||
}
|
||||
|
||||
public function expirePendingInvites(): int
|
||||
{
|
||||
$expired = GroupInvitation::query()
|
||||
->with('sourceGroupMember')
|
||||
->where('status', GroupInvitation::STATUS_PENDING)
|
||||
->whereNotNull('expires_at')
|
||||
->where('expires_at', '<=', now())
|
||||
->get();
|
||||
|
||||
foreach ($expired as $invitation) {
|
||||
$expiredAt = now();
|
||||
$invitation->forceFill([
|
||||
'status' => GroupInvitation::STATUS_EXPIRED,
|
||||
'responded_at' => $expiredAt,
|
||||
'revoked_at' => $expiredAt,
|
||||
])->save();
|
||||
|
||||
$this->syncLegacyMemberFromInvitation($invitation, Group::STATUS_REVOKED, $expiredAt);
|
||||
}
|
||||
|
||||
return $expired->count();
|
||||
}
|
||||
|
||||
public function activeContributorIds(Group $group): array
|
||||
{
|
||||
$activeIds = $group->members()
|
||||
->where('status', Group::STATUS_ACTIVE)
|
||||
->whereIn('role', [Group::ROLE_OWNER, Group::ROLE_ADMIN, Group::ROLE_EDITOR, Group::ROLE_MEMBER])
|
||||
->pluck('user_id')
|
||||
->map(static fn ($id): int => (int) $id)
|
||||
->all();
|
||||
|
||||
if (! in_array((int) $group->owner_user_id, $activeIds, true)) {
|
||||
$activeIds[] = (int) $group->owner_user_id;
|
||||
}
|
||||
|
||||
return array_values(array_unique($activeIds));
|
||||
}
|
||||
|
||||
public function mapMembers(Group $group, ?User $viewer = null): array
|
||||
{
|
||||
$this->expirePendingInvites();
|
||||
|
||||
$members = $group->members()
|
||||
->with(['user.profile', 'invitedBy.profile'])
|
||||
->where('status', Group::STATUS_ACTIVE)
|
||||
->orderByRaw("CASE role WHEN 'owner' THEN 0 WHEN 'admin' THEN 1 WHEN 'editor' THEN 2 ELSE 3 END")
|
||||
->orderBy('created_at')
|
||||
->get();
|
||||
|
||||
return $members->map(fn (GroupMember $member): array => $this->mapMemberRow($member, $group, $viewer))->all();
|
||||
}
|
||||
|
||||
public function mapInvitations(Group $group, ?User $viewer = null): array
|
||||
{
|
||||
$this->expirePendingInvites();
|
||||
|
||||
return $group->invitations()
|
||||
->with(['invitedUser.profile', 'invitedBy.profile'])
|
||||
->whereIn('status', [
|
||||
GroupInvitation::STATUS_PENDING,
|
||||
GroupInvitation::STATUS_REVOKED,
|
||||
GroupInvitation::STATUS_DECLINED,
|
||||
GroupInvitation::STATUS_EXPIRED,
|
||||
])
|
||||
->orderByDesc('invited_at')
|
||||
->orderByDesc('updated_at')
|
||||
->get()
|
||||
->map(fn (GroupInvitation $invitation): array => $this->mapInvitationRow($invitation, $group, $viewer))
|
||||
->values()
|
||||
->all();
|
||||
}
|
||||
|
||||
public function pendingInviteCount(Group $group): int
|
||||
{
|
||||
$this->expirePendingInvites();
|
||||
|
||||
return (int) $group->invitations()
|
||||
->where('status', GroupInvitation::STATUS_PENDING)
|
||||
->count();
|
||||
}
|
||||
|
||||
public function pendingInvitationsForUser(User $user): array
|
||||
{
|
||||
$this->expirePendingInvites();
|
||||
|
||||
return $user->groupInvitations()
|
||||
->with(['group.owner.profile', 'group.members', 'invitedBy.profile'])
|
||||
->where('status', GroupInvitation::STATUS_PENDING)
|
||||
->orderByDesc('invited_at')
|
||||
->get()
|
||||
->map(fn (GroupInvitation $invitation): array => [
|
||||
'id' => (int) $invitation->id,
|
||||
'group' => $invitation->group ? [
|
||||
'id' => (int) $invitation->group->id,
|
||||
'name' => (string) $invitation->group->name,
|
||||
'slug' => (string) $invitation->group->slug,
|
||||
'avatar_url' => $invitation->group->avatarUrl(),
|
||||
'counts' => [
|
||||
'artworks' => (int) $invitation->group->artworks_count,
|
||||
'collections' => (int) $invitation->group->collections_count,
|
||||
'followers' => (int) $invitation->group->followers_count,
|
||||
],
|
||||
] : null,
|
||||
'role' => Group::displayRole((string) $invitation->role) ?? (string) $invitation->role,
|
||||
'invited_at' => $invitation->invited_at?->toISOString(),
|
||||
'expires_at' => $invitation->expires_at?->toISOString(),
|
||||
'accept_url' => route('studio.groups.invitations.accept', ['invitation' => $invitation]),
|
||||
'decline_url' => route('studio.groups.invitations.decline', ['invitation' => $invitation]),
|
||||
'invited_by' => $invitation->invitedBy ? [
|
||||
'name' => $invitation->invitedBy->name,
|
||||
'username' => $invitation->invitedBy->username,
|
||||
] : null,
|
||||
])
|
||||
->values()
|
||||
->all();
|
||||
}
|
||||
|
||||
public function contributorOptions(Group $group): array
|
||||
{
|
||||
return User::query()
|
||||
->with('profile:user_id,avatar_hash')
|
||||
->whereIn('id', $this->activeContributorIds($group))
|
||||
->orderBy('username')
|
||||
->get()
|
||||
->map(fn (User $user): array => [
|
||||
'id' => (int) $user->id,
|
||||
'name' => $user->name,
|
||||
'username' => $user->username,
|
||||
'avatar_url' => AvatarUrl::forUser((int) $user->id, $user->profile?->avatar_hash, 64),
|
||||
])
|
||||
->values()
|
||||
->all();
|
||||
}
|
||||
|
||||
private function guardManageMembers(Group $group, User $actor): void
|
||||
{
|
||||
if (! $group->canManageMembers($actor) && ! $actor->isAdmin()) {
|
||||
throw ValidationException::withMessages([
|
||||
'group' => 'You are not allowed to manage this group.',
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
private function expireMemberIfNeeded(GroupMember $member): void
|
||||
{
|
||||
if ($member->status !== Group::STATUS_PENDING || ! $member->expires_at || $member->expires_at->isFuture()) {
|
||||
return;
|
||||
}
|
||||
|
||||
$member->forceFill([
|
||||
'status' => Group::STATUS_REVOKED,
|
||||
'revoked_at' => Carbon::now(),
|
||||
])->save();
|
||||
|
||||
GroupInvitation::query()
|
||||
->where('source_group_member_id', $member->id)
|
||||
->where('status', GroupInvitation::STATUS_PENDING)
|
||||
->update([
|
||||
'status' => GroupInvitation::STATUS_EXPIRED,
|
||||
'responded_at' => now(),
|
||||
'revoked_at' => now(),
|
||||
'updated_at' => now(),
|
||||
]);
|
||||
}
|
||||
|
||||
private function expireInvitationIfNeeded(GroupInvitation $invitation): void
|
||||
{
|
||||
if ($invitation->status !== GroupInvitation::STATUS_PENDING || ! $invitation->expires_at || $invitation->expires_at->isFuture()) {
|
||||
return;
|
||||
}
|
||||
|
||||
$expiredAt = Carbon::now();
|
||||
|
||||
$invitation->forceFill([
|
||||
'status' => GroupInvitation::STATUS_EXPIRED,
|
||||
'responded_at' => $expiredAt,
|
||||
'revoked_at' => $expiredAt,
|
||||
])->save();
|
||||
|
||||
$this->syncLegacyMemberFromInvitation($invitation, Group::STATUS_REVOKED, $expiredAt);
|
||||
}
|
||||
|
||||
private function mapMemberRow(GroupMember $member, Group $group, ?User $viewer = null): array
|
||||
{
|
||||
$user = $member->user;
|
||||
|
||||
return [
|
||||
'id' => (int) $member->id,
|
||||
'user_id' => (int) $member->user_id,
|
||||
'role' => (string) $member->role,
|
||||
'role_label' => Group::displayRole((string) $member->role),
|
||||
'status' => (string) $member->status,
|
||||
'permission_overrides' => collect($member->permission_overrides_json ?? [])
|
||||
->map(function ($override): ?array {
|
||||
if (is_array($override)) {
|
||||
$key = trim((string) ($override['key'] ?? ''));
|
||||
|
||||
return $key !== '' ? ['key' => $key, 'is_allowed' => (bool) ($override['is_allowed'] ?? false)] : null;
|
||||
}
|
||||
|
||||
$key = trim((string) $override);
|
||||
|
||||
return $key !== '' ? ['key' => $key, 'is_allowed' => true] : null;
|
||||
})
|
||||
->filter()
|
||||
->values()
|
||||
->all(),
|
||||
'note' => $member->note,
|
||||
'invited_at' => $member->invited_at?->toISOString(),
|
||||
'expires_at' => $member->expires_at?->toISOString(),
|
||||
'accepted_at' => $member->accepted_at?->toISOString(),
|
||||
'is_expired' => $member->status === Group::STATUS_REVOKED && $member->expires_at !== null && $member->expires_at->lte(now()) && $member->accepted_at === null,
|
||||
'can_accept' => $viewer !== null && (int) $member->user_id === (int) $viewer->id && $member->status === Group::STATUS_PENDING,
|
||||
'can_decline' => $viewer !== null && (int) $member->user_id === (int) $viewer->id && $member->status === Group::STATUS_PENDING,
|
||||
'can_revoke' => $viewer !== null && $group->canManageMembers($viewer) && $member->role !== Group::ROLE_OWNER,
|
||||
'can_transfer' => $viewer !== null
|
||||
&& $group->isOwnedBy($viewer)
|
||||
&& $member->status === Group::STATUS_ACTIVE
|
||||
&& $member->role !== Group::ROLE_OWNER,
|
||||
'can_manage_permissions' => $viewer !== null
|
||||
&& $group->canManageMemberPermissions($viewer)
|
||||
&& $member->status === Group::STATUS_ACTIVE
|
||||
&& $member->role !== Group::ROLE_OWNER,
|
||||
'user' => [
|
||||
'id' => (int) $user->id,
|
||||
'name' => $user->name,
|
||||
'username' => $user->username,
|
||||
'avatar_url' => AvatarUrl::forUser((int) $user->id, $user->profile?->avatar_hash, 72),
|
||||
'profile_url' => route('profile.show', ['username' => strtolower((string) $user->username)]),
|
||||
],
|
||||
'invited_by' => $member->invitedBy ? [
|
||||
'id' => (int) $member->invitedBy->id,
|
||||
'username' => $member->invitedBy->username,
|
||||
'name' => $member->invitedBy->name,
|
||||
] : null,
|
||||
];
|
||||
}
|
||||
|
||||
private function mapInvitationRow(GroupInvitation $invitation, Group $group, ?User $viewer = null): array
|
||||
{
|
||||
$user = $invitation->invitedUser;
|
||||
$displayStatus = $invitation->status === GroupInvitation::STATUS_PENDING ? GroupInvitation::STATUS_PENDING : Group::STATUS_REVOKED;
|
||||
|
||||
return [
|
||||
'id' => (int) $invitation->id,
|
||||
'user_id' => (int) $invitation->invited_user_id,
|
||||
'role' => (string) $invitation->role,
|
||||
'role_label' => Group::displayRole((string) $invitation->role),
|
||||
'status' => $displayStatus,
|
||||
'status_raw' => (string) $invitation->status,
|
||||
'note' => $invitation->note,
|
||||
'invited_at' => $invitation->invited_at?->toISOString(),
|
||||
'expires_at' => $invitation->expires_at?->toISOString(),
|
||||
'accepted_at' => $invitation->accepted_at?->toISOString(),
|
||||
'is_expired' => $invitation->status === GroupInvitation::STATUS_EXPIRED,
|
||||
'can_accept' => $viewer !== null && (int) $invitation->invited_user_id === (int) $viewer->id && $invitation->status === GroupInvitation::STATUS_PENDING,
|
||||
'can_decline' => $viewer !== null && (int) $invitation->invited_user_id === (int) $viewer->id && $invitation->status === GroupInvitation::STATUS_PENDING,
|
||||
'can_revoke' => $viewer !== null && $group->canManageMembers($viewer) && $invitation->status === GroupInvitation::STATUS_PENDING,
|
||||
'can_transfer' => false,
|
||||
'accept_url' => $invitation->status === GroupInvitation::STATUS_PENDING ? route('studio.groups.invitations.accept', ['invitation' => $invitation]) : null,
|
||||
'decline_url' => $invitation->status === GroupInvitation::STATUS_PENDING ? route('studio.groups.invitations.decline', ['invitation' => $invitation]) : null,
|
||||
'revoke_url' => $viewer !== null && $group->canManageMembers($viewer) ? route('studio.groups.invitations.destroy', ['group' => $group, 'invitation' => $invitation]) : null,
|
||||
'user' => $user ? [
|
||||
'id' => (int) $user->id,
|
||||
'name' => $user->name,
|
||||
'username' => $user->username,
|
||||
'avatar_url' => AvatarUrl::forUser((int) $user->id, $user->profile?->avatar_hash, 72),
|
||||
'profile_url' => route('profile.show', ['username' => strtolower((string) $user->username)]),
|
||||
] : null,
|
||||
'invited_by' => $invitation->invitedBy ? [
|
||||
'id' => (int) $invitation->invitedBy->id,
|
||||
'username' => $invitation->invitedBy->username,
|
||||
'name' => $invitation->invitedBy->name,
|
||||
] : null,
|
||||
];
|
||||
}
|
||||
|
||||
private function syncLegacyMemberFromInvitation(GroupInvitation $invitation, string $memberStatus, Carbon $timestamp): void
|
||||
{
|
||||
if (! $invitation->source_group_member_id) {
|
||||
return;
|
||||
}
|
||||
|
||||
$member = $invitation->sourceGroupMember()->first();
|
||||
if (! $member) {
|
||||
return;
|
||||
}
|
||||
|
||||
$member->forceFill([
|
||||
'status' => $memberStatus,
|
||||
'expires_at' => $memberStatus === Group::STATUS_ACTIVE ? null : $member->expires_at,
|
||||
'accepted_at' => $memberStatus === Group::STATUS_ACTIVE ? $timestamp : null,
|
||||
'revoked_at' => $memberStatus === Group::STATUS_ACTIVE ? null : $timestamp,
|
||||
])->save();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user