where('collection_id', $collection->id) ->where('role', Collection::MEMBER_ROLE_OWNER) ->where('user_id', '!=', $collection->user_id) ->update([ 'role' => Collection::MEMBER_ROLE_EDITOR, 'updated_at' => now(), ]); CollectionMember::query()->updateOrCreate( [ 'collection_id' => $collection->id, 'user_id' => $collection->user_id, ], [ 'invited_by_user_id' => $collection->user_id, 'role' => Collection::MEMBER_ROLE_OWNER, 'status' => Collection::MEMBER_STATUS_ACTIVE, 'invited_at' => now(), 'expires_at' => null, 'accepted_at' => now(), 'revoked_at' => null, ] ); $this->syncCollaboratorsCount($collection); } public function ensureManagerMembership(Collection $collection, User $manager): void { if ((int) $collection->user_id === (int) $manager->id) { return; } CollectionMember::query()->updateOrCreate( [ 'collection_id' => $collection->id, 'user_id' => $manager->id, ], [ 'invited_by_user_id' => $collection->user_id, 'role' => Collection::MEMBER_ROLE_EDITOR, 'status' => Collection::MEMBER_STATUS_ACTIVE, 'invited_at' => now(), 'expires_at' => null, 'accepted_at' => now(), 'revoked_at' => null, ] ); $this->syncCollaboratorsCount($collection); } public function inviteMember(Collection $collection, User $actor, User $invitee, string $role, ?string $note = null, ?int $expiresInDays = null, ?string $expiresAt = null): CollectionMember { $this->guardManageMembers($collection, $actor); $this->expirePendingInvites(); if (! in_array($role, [Collection::MEMBER_ROLE_EDITOR, Collection::MEMBER_ROLE_CONTRIBUTOR, Collection::MEMBER_ROLE_VIEWER], true)) { throw ValidationException::withMessages([ 'role' => 'Choose a valid collaborator role.', ]); } if ($collection->isOwnedBy($invitee)) { throw ValidationException::withMessages([ 'username' => 'The collection owner is already a collaborator.', ]); } $member = DB::transaction(function () use ($collection, $actor, $invitee, $role, $note, $expiresInDays, $expiresAt): CollectionMember { $inviteExpiresAt = $this->resolveInviteExpiry($expiresInDays, $expiresAt); $member = CollectionMember::query()->updateOrCreate( [ 'collection_id' => $collection->id, 'user_id' => $invitee->id, ], [ 'invited_by_user_id' => $actor->id, 'role' => $role, 'status' => Collection::MEMBER_STATUS_PENDING, 'note' => $note, 'invited_at' => now(), 'expires_at' => $inviteExpiresAt, 'accepted_at' => null, 'revoked_at' => null, ] ); $this->syncCollaboratorsCount($collection); return $member->fresh(['user.profile', 'invitedBy.profile']); }); $this->notifications->notifyCollectionInvite($invitee, $actor, $collection, $role); return $member; } public function acceptInvite(CollectionMember $member, User $user): CollectionMember { $this->expireMemberIfNeeded($member); if ((int) $member->user_id !== (int) $user->id || $member->status !== Collection::MEMBER_STATUS_PENDING) { throw ValidationException::withMessages([ 'member' => 'This invitation cannot be accepted.', ]); } $member->forceFill([ 'status' => Collection::MEMBER_STATUS_ACTIVE, 'expires_at' => null, 'accepted_at' => now(), 'revoked_at' => null, ])->save(); $this->syncCollaboratorsCount($member->collection); return $member->fresh(['user.profile', 'invitedBy.profile']); } public function declineInvite(CollectionMember $member, User $user): CollectionMember { $this->expireMemberIfNeeded($member); if ((int) $member->user_id !== (int) $user->id || $member->status !== Collection::MEMBER_STATUS_PENDING) { throw ValidationException::withMessages([ 'member' => 'This invitation cannot be declined.', ]); } $member->forceFill([ 'status' => Collection::MEMBER_STATUS_REVOKED, 'accepted_at' => null, 'revoked_at' => now(), ])->save(); $this->syncCollaboratorsCount($member->collection); return $member->fresh(['user.profile', 'invitedBy.profile']); } public function updateMemberRole(CollectionMember $member, User $actor, string $role): CollectionMember { $this->guardManageMembers($member->collection, $actor); if ($member->role === Collection::MEMBER_ROLE_OWNER) { throw ValidationException::withMessages([ 'member' => 'The collection owner role cannot be changed.', ]); } $member->forceFill(['role' => $role])->save(); return $member->fresh(['user.profile', 'invitedBy.profile']); } public function revokeMember(CollectionMember $member, User $actor): void { $this->guardManageMembers($member->collection, $actor); if ($member->role === Collection::MEMBER_ROLE_OWNER) { throw ValidationException::withMessages([ 'member' => 'The collection owner cannot be removed.', ]); } $member->forceFill([ 'status' => Collection::MEMBER_STATUS_REVOKED, 'expires_at' => null, 'revoked_at' => now(), ])->save(); $this->syncCollaboratorsCount($member->collection); } public function transferOwnership(Collection $collection, CollectionMember $member, User $actor): Collection { if (! $collection->isOwnedBy($actor) && ! $actor->isAdmin()) { throw ValidationException::withMessages([ 'member' => 'Only the collection owner can transfer ownership.', ]); } if ((int) $member->collection_id !== (int) $collection->id) { throw ValidationException::withMessages([ 'member' => 'This collaborator does not belong to the selected collection.', ]); } if ($member->status !== Collection::MEMBER_STATUS_ACTIVE) { throw ValidationException::withMessages([ 'member' => 'Only active collaborators can become the new owner.', ]); } if ($collection->type === Collection::TYPE_EDITORIAL && $collection->editorial_owner_mode !== Collection::EDITORIAL_OWNER_CREATOR) { throw ValidationException::withMessages([ 'member' => 'System-owned and staff-account editorials cannot be transferred through collaborator controls.', ]); } return DB::transaction(function () use ($collection, $member, $actor): Collection { $previousOwnerId = (int) $collection->user_id; CollectionMember::query() ->where('collection_id', $collection->id) ->where('user_id', $previousOwnerId) ->update([ 'role' => Collection::MEMBER_ROLE_EDITOR, 'updated_at' => now(), ]); $member->forceFill([ 'role' => Collection::MEMBER_ROLE_OWNER, 'status' => Collection::MEMBER_STATUS_ACTIVE, 'expires_at' => null, 'accepted_at' => $member->accepted_at ?? now(), ])->save(); $collection->forceFill([ 'user_id' => $member->user_id, 'managed_by_user_id' => (int) $actor->id === (int) $member->user_id ? null : $actor->id, 'editorial_owner_mode' => $collection->type === Collection::TYPE_EDITORIAL ? Collection::EDITORIAL_OWNER_CREATOR : $collection->editorial_owner_mode, 'editorial_owner_user_id' => $collection->type === Collection::TYPE_EDITORIAL ? null : $collection->editorial_owner_user_id, 'editorial_owner_label' => $collection->type === Collection::TYPE_EDITORIAL ? null : $collection->editorial_owner_label, ])->save(); $this->ensureOwnerMembership($collection->fresh()); $this->syncCollaboratorsCount($collection->fresh()); return $collection->fresh(['user.profile']); }); } public function expirePendingInvites(): int { return CollectionMember::query() ->where('status', Collection::MEMBER_STATUS_PENDING) ->whereNotNull('expires_at') ->where('expires_at', '<=', now()) ->update([ 'status' => Collection::MEMBER_STATUS_REVOKED, 'revoked_at' => now(), 'updated_at' => now(), ]); } public function activeContributorIds(Collection $collection): array { $activeIds = $collection->members() ->where('status', Collection::MEMBER_STATUS_ACTIVE) ->whereIn('role', [ Collection::MEMBER_ROLE_OWNER, Collection::MEMBER_ROLE_EDITOR, Collection::MEMBER_ROLE_CONTRIBUTOR, ]) ->pluck('user_id') ->map(static fn ($id) => (int) $id) ->all(); if (! in_array((int) $collection->user_id, $activeIds, true)) { $activeIds[] = (int) $collection->user_id; } return array_values(array_unique($activeIds)); } public function mapMembers(Collection $collection, ?User $viewer = null): array { $this->expirePendingInvites(); $members = $collection->members() ->with(['user.profile', 'invitedBy.profile']) ->orderByRaw("CASE role WHEN 'owner' THEN 0 WHEN 'editor' THEN 1 WHEN 'contributor' THEN 2 ELSE 3 END") ->orderBy('created_at') ->get(); return $members->map(function (CollectionMember $member) use ($collection, $viewer): array { $user = $member->user; return [ 'id' => (int) $member->id, 'user_id' => (int) $member->user_id, 'role' => (string) $member->role, 'status' => (string) $member->status, '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 === Collection::MEMBER_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 === Collection::MEMBER_STATUS_PENDING, 'can_decline' => $viewer !== null && (int) $member->user_id === (int) $viewer->id && $member->status === Collection::MEMBER_STATUS_PENDING, 'can_revoke' => $viewer !== null && $collection->canManageMembers($viewer) && $member->role !== Collection::MEMBER_ROLE_OWNER, 'can_transfer' => $viewer !== null && $collection->isOwnedBy($viewer) && $member->status === Collection::MEMBER_STATUS_ACTIVE && $member->role !== Collection::MEMBER_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, ]; })->all(); } public function syncCollaboratorsCount(Collection $collection): void { $count = (int) $collection->members() ->where('status', Collection::MEMBER_STATUS_ACTIVE) ->count(); $collection->forceFill([ 'collaborators_count' => $count, ])->save(); } private function guardManageMembers(Collection $collection, User $actor): void { if (! $collection->canManageMembers($actor)) { throw ValidationException::withMessages([ 'collection' => 'You are not allowed to manage collaborators for this collection.', ]); } } private function expireMemberIfNeeded(CollectionMember $member): void { if ($member->status !== Collection::MEMBER_STATUS_PENDING || ! $member->expires_at || $member->expires_at->isFuture()) { return; } $member->forceFill([ 'status' => Collection::MEMBER_STATUS_REVOKED, 'revoked_at' => now(), ])->save(); } private function resolveInviteExpiry(?int $expiresInDays, ?string $expiresAt): Carbon { if ($expiresAt !== null && $expiresAt !== '') { return Carbon::parse($expiresAt); } if ($expiresInDays !== null) { return now()->addDays(max(1, $expiresInDays)); } return now()->addDays(max(1, (int) config('collections.invites.expires_after_days', 7))); } }