Commit workspace changes

This commit is contained in:
2026-04-05 19:42:33 +02:00
parent 148a3bbe43
commit 08ad757bcb
312 changed files with 35149 additions and 399 deletions

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