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