Files
SkinbaseNova/app/Services/CollectionCollaborationService.php
2026-03-28 19:15:39 +01:00

384 lines
14 KiB
PHP

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