762 lines
29 KiB
PHP
762 lines
29 KiB
PHP
<?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();
|
|
}
|
|
} |