optimizations
This commit is contained in:
383
app/Services/CollectionCollaborationService.php
Normal file
383
app/Services/CollectionCollaborationService.php
Normal file
@@ -0,0 +1,383 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use App\Models\Collection;
|
||||
use App\Models\CollectionMember;
|
||||
use App\Models\User;
|
||||
use App\Support\AvatarUrl;
|
||||
use Illuminate\Support\Carbon;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
|
||||
class CollectionCollaborationService
|
||||
{
|
||||
public function __construct(
|
||||
private readonly NotificationService $notifications,
|
||||
) {
|
||||
}
|
||||
|
||||
public function ensureOwnerMembership(Collection $collection): void
|
||||
{
|
||||
CollectionMember::query()
|
||||
->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)));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user