mirror of
https://github.com/solidtime-io/solidtime.git
synced 2026-10-14 16:53:17 +01:00
The running time entries of the merged member could give the destination member a second running time entry. The placeholder is deleted after the merge, so its running time entries can not stay with it.
291 lines
11 KiB
PHP
291 lines
11 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace App\Service;
|
|
|
|
use App\Enums\Role;
|
|
use App\Events\MemberAdded;
|
|
use App\Events\MemberAdding;
|
|
use App\Events\MemberRemoved;
|
|
use App\Exceptions\Api\CanNotRemoveOwnerFromOrganization;
|
|
use App\Exceptions\Api\ChangingRoleOfPlaceholderIsNotAllowed;
|
|
use App\Exceptions\Api\ChangingRoleToPlaceholderIsNotAllowed;
|
|
use App\Exceptions\Api\EntityStillInUseApiException;
|
|
use App\Exceptions\Api\OnlyOwnerCanChangeOwnership;
|
|
use App\Exceptions\Api\OrganizationNeedsAtLeastOneOwner;
|
|
use App\Models\Goal;
|
|
use App\Models\Member;
|
|
use App\Models\Organization;
|
|
use App\Models\Project;
|
|
use App\Models\ProjectMember;
|
|
use App\Models\TimeEntry;
|
|
use App\Models\User;
|
|
use Illuminate\Database\Eloquent\Builder;
|
|
use Illuminate\Support\Facades\DB;
|
|
use InvalidArgumentException;
|
|
|
|
class MemberService
|
|
{
|
|
private UserService $userService;
|
|
|
|
public function __construct(UserService $userService)
|
|
{
|
|
$this->userService = $userService;
|
|
}
|
|
|
|
public function addMember(User $user, Organization $organization, Role $role, bool $asSuperAdmin = false): Member
|
|
{
|
|
if (! $asSuperAdmin) {
|
|
MemberAdding::dispatch($user, $organization, $role);
|
|
}
|
|
|
|
$member = new Member;
|
|
DB::transaction(function () use ($organization, $user, $role, &$member): void {
|
|
$member->user()->associate($user);
|
|
$member->organization()->associate($organization);
|
|
$member->role = $role->value;
|
|
$member->save();
|
|
|
|
$user->currentOrganization()->associate($organization);
|
|
$user->save();
|
|
});
|
|
$this->mergePlaceholderMembersIntoExistingMember($member, $organization, $user);
|
|
|
|
if (! $asSuperAdmin) {
|
|
MemberAdded::dispatch($member, $organization, $user);
|
|
}
|
|
|
|
return $member;
|
|
}
|
|
|
|
private function mergePlaceholderMembersIntoExistingMember(Member $member, Organization $organization, User $user): void
|
|
{
|
|
$placeholders = Member::query()
|
|
->whereHas('user', function (Builder $query) use ($user): void {
|
|
/** @var Builder<User> $query */
|
|
$query->where('is_placeholder', '=', true)
|
|
->where('email', '=', $user->email);
|
|
})
|
|
->whereBelongsTo($organization, 'organization')
|
|
->with(['user'])
|
|
->get();
|
|
|
|
foreach ($placeholders as $placeholder) {
|
|
/** @var Member $placeholder */
|
|
$placeholderUser = $placeholder->user;
|
|
$this->assignOrganizationEntitiesToDifferentMember($organization, $placeholder, $member);
|
|
$placeholder->delete();
|
|
$placeholderUser->delete();
|
|
}
|
|
}
|
|
|
|
/**
|
|
* @throws CanNotRemoveOwnerFromOrganization
|
|
* @throws EntityStillInUseApiException
|
|
*/
|
|
public function removeMember(Member $member, Organization $organization, bool $withRelations = false): void
|
|
{
|
|
if ($member->role === Role::Owner->value) {
|
|
throw new CanNotRemoveOwnerFromOrganization;
|
|
}
|
|
|
|
$user = $member->user;
|
|
$isPlaceholder = $user->is_placeholder;
|
|
|
|
if (! $isPlaceholder && $user->current_team_id === $member->organization_id) {
|
|
$user->currentOrganization()->disassociate();
|
|
$user->save();
|
|
}
|
|
|
|
if ($withRelations) {
|
|
TimeEntry::query()->where('user_id', $member->user_id)->whereBelongsTo($organization, 'organization')->delete();
|
|
ProjectMember::query()->whereBelongsToOrganization($organization)->where('user_id', $member->user_id)->delete();
|
|
} else {
|
|
if (TimeEntry::query()->where('user_id', $member->user_id)->whereBelongsTo($organization, 'organization')->exists()) {
|
|
throw new EntityStillInUseApiException('member', 'time_entry');
|
|
}
|
|
if (ProjectMember::query()->whereBelongsToOrganization($organization)->where('user_id', $member->user_id)->exists()) {
|
|
throw new EntityStillInUseApiException('member', 'project_member');
|
|
}
|
|
}
|
|
|
|
// A goal is deleted together with the member it is for, it is not part of the "still in use" check
|
|
Goal::query()
|
|
->whereBelongsTo($organization, 'organization')
|
|
->where('member_id', '=', $member->getKey())
|
|
->delete();
|
|
|
|
$member->delete();
|
|
|
|
if ($isPlaceholder) {
|
|
$user->delete();
|
|
} else {
|
|
$this->userService->makeSureUserHasAtLeastOneOrganization($user);
|
|
$this->userService->makeSureUserHasCurrentOrganization($user);
|
|
}
|
|
|
|
MemberRemoved::dispatch($member, $organization);
|
|
}
|
|
|
|
/**
|
|
* @throws ChangingRoleToPlaceholderIsNotAllowed
|
|
* @throws OnlyOwnerCanChangeOwnership
|
|
* @throws OrganizationNeedsAtLeastOneOwner
|
|
* @throws ChangingRoleOfPlaceholderIsNotAllowed
|
|
*/
|
|
public function changeRole(Member $member, Organization $organization, Role $newRole, bool $allowOwnerChange): void
|
|
{
|
|
$oldRole = Role::from($member->role);
|
|
if ($oldRole === Role::Owner) {
|
|
throw new OrganizationNeedsAtLeastOneOwner;
|
|
}
|
|
if ($oldRole === Role::Placeholder) {
|
|
throw new ChangingRoleOfPlaceholderIsNotAllowed;
|
|
}
|
|
if ($newRole === Role::Placeholder) {
|
|
throw new ChangingRoleToPlaceholderIsNotAllowed;
|
|
}
|
|
if ($newRole === Role::Owner) {
|
|
if ($allowOwnerChange) {
|
|
$this->changeOwnership($organization, $member);
|
|
} else {
|
|
throw new OnlyOwnerCanChangeOwnership;
|
|
}
|
|
} else {
|
|
$member->role = $newRole->value;
|
|
}
|
|
}
|
|
|
|
public function assignOrganizationEntitiesToDifferentMember(Organization $organization, Member $fromMember, Member $toMember): void
|
|
{
|
|
// Running time entries are deleted instead of moved, since $toMember could already have a running time entry
|
|
TimeEntry::query()
|
|
->whereBelongsTo($organization, 'organization')
|
|
->whereBelongsTo($fromMember, 'member')
|
|
->whereNull('end')
|
|
->delete();
|
|
|
|
// Time entries
|
|
TimeEntry::query()
|
|
->whereBelongsTo($organization, 'organization')
|
|
->whereBelongsTo($fromMember, 'member')
|
|
->update([
|
|
'user_id' => $toMember->user_id,
|
|
'member_id' => $toMember->getKey(),
|
|
]);
|
|
|
|
// Project members
|
|
ProjectMember::query()
|
|
->whereBelongsToOrganization($organization)
|
|
->whereBelongsTo($fromMember, 'member')
|
|
->whereDoesntHave('project', function (Builder $builder) use ($toMember): void {
|
|
/** @var Builder<Project> $builder */
|
|
$builder->whereHas('members', function (Builder $builder) use ($toMember): void {
|
|
/** @var Builder<ProjectMember> $builder */
|
|
$builder->where('member_id', $toMember->getKey());
|
|
});
|
|
})
|
|
->update([
|
|
'user_id' => $toMember->user_id,
|
|
'member_id' => $toMember->getKey(),
|
|
]);
|
|
|
|
ProjectMember::query()
|
|
->whereBelongsToOrganization($organization)
|
|
->whereBelongsTo($fromMember, 'member')
|
|
->delete();
|
|
|
|
// Goals: the goal belongs to the person it is for, like time entries it moves along
|
|
Goal::query()
|
|
->whereBelongsTo($organization, 'organization')
|
|
->where('member_id', '=', $fromMember->getKey())
|
|
->update([
|
|
'member_id' => $toMember->getKey(),
|
|
]);
|
|
|
|
// Goal member filters: the time entries of $fromMember now belong to $toMember, so the filters follow them
|
|
$goalsWithMemberFilter = Goal::query()
|
|
->whereBelongsTo($organization, 'organization')
|
|
->whereJsonContains('filters->memberIds', $fromMember->getKey())
|
|
->get();
|
|
foreach ($goalsWithMemberFilter as $goal) {
|
|
$filters = $goal->filters;
|
|
$memberIds = $filters->memberIds?->map(
|
|
fn (string $memberId): string => $memberId === $fromMember->getKey() ? $toMember->getKey() : $memberId
|
|
);
|
|
// $toMember may already be in the filter
|
|
$filters->setMemberIds($memberIds?->unique()->values()->all());
|
|
$goal->filters = $filters;
|
|
$goal->save();
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Change the ownership of an organization to a new user.
|
|
* The previous owner will be demoted to an admin.
|
|
*/
|
|
public function changeOwnership(Organization $organization, Member $newOwner): void
|
|
{
|
|
$organization->update([
|
|
'user_id' => $newOwner->user_id,
|
|
]);
|
|
if ($newOwner->organization_id !== $organization->getKey()) {
|
|
throw new InvalidArgumentException('Member is not part of the organization');
|
|
}
|
|
$newOwner->role = Role::Owner->value;
|
|
$newOwner->save();
|
|
$oldOwners = Member::query()
|
|
->whereBelongsTo($organization, 'organization')
|
|
->where('role', '=', Role::Owner->value)
|
|
->where('id', '!=', $newOwner->getKey())
|
|
->get();
|
|
foreach ($oldOwners as $oldOwner) {
|
|
$oldOwner->role = Role::Admin->value;
|
|
$oldOwner->save();
|
|
}
|
|
}
|
|
|
|
public function makeMemberToPlaceholder(Member $member, bool $makeSureUserHasAtLeastOneOrganization = true): void
|
|
{
|
|
$user = $member->user;
|
|
if ($user->current_team_id === $member->organization_id) {
|
|
$user->currentOrganization()->disassociate();
|
|
$user->save();
|
|
}
|
|
|
|
$placeholderUser = $user->replicate();
|
|
$placeholderUser->is_placeholder = true;
|
|
// Reset authentication relevant properties on the placeholder user
|
|
$placeholderUser->password = null;
|
|
$placeholderUser->remember_token = null;
|
|
$placeholderUser->two_factor_secret = null;
|
|
$placeholderUser->two_factor_recovery_codes = null;
|
|
$placeholderUser->two_factor_confirmed_at = null;
|
|
$placeholderUser->email_verified_at = null;
|
|
$placeholderUser->pending_email = null;
|
|
$placeholderUser->current_team_id = null;
|
|
$placeholderUser->profile_photo_path = null;
|
|
$placeholderUser->save();
|
|
|
|
$member->user()->associate($placeholderUser);
|
|
$member->role = Role::Placeholder->value;
|
|
$member->save();
|
|
|
|
$this->userService->assignOrganizationEntitiesToDifferentUser($member->organization, $user, $placeholderUser);
|
|
if ($makeSureUserHasAtLeastOneOrganization) {
|
|
$this->userService->makeSureUserHasAtLeastOneOrganization($user);
|
|
$this->userService->makeSureUserHasCurrentOrganization($user);
|
|
}
|
|
}
|
|
|
|
public function isEmailAlreadyMember(Organization $organization, string $email): bool
|
|
{
|
|
return Member::query()
|
|
->whereBelongsTo($organization, 'organization')
|
|
->whereRelation('user', 'email', '=', $email)
|
|
->where('role', '!=', Role::Placeholder->value)
|
|
->exists();
|
|
}
|
|
}
|