Skip to content

Commit 73ce5f7

Browse files
committed
Fixed problem with merge into when project members already exist in destination member
1 parent 02a7168 commit 73ce5f7

File tree

7 files changed

+190
-43
lines changed

7 files changed

+190
-43
lines changed

app/Http/Controllers/Api/V1/MemberController.php

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,6 @@
2626
use App\Service\BillableRateService;
2727
use App\Service\InvitationService;
2828
use App\Service\MemberService;
29-
use App\Service\UserService;
3029
use Illuminate\Auth\Access\AuthorizationException;
3130
use Illuminate\Http\JsonResponse;
3231
use Illuminate\Http\Resources\Json\JsonResource;
@@ -141,7 +140,7 @@ public function makePlaceholder(Organization $organization, Member $member, Memb
141140
*
142141
* @operationId mergeMember
143142
*/
144-
public function mergeInto(Organization $organization, Member $member, MemberMergeIntoRequest $request): JsonResponse
143+
public function mergeInto(Organization $organization, Member $member, MemberMergeIntoRequest $request, MemberService $memberService): JsonResponse
145144
{
146145
$this->checkPermission($organization, 'members:merge-into', $member);
147146

@@ -151,8 +150,8 @@ public function mergeInto(Organization $organization, Member $member, MemberMerg
151150
}
152151
$memberTo = Member::findOrFail($request->getMemberId());
153152

154-
DB::transaction(function () use ($organization, $member, $user, $memberTo): void {
155-
app(UserService::class)->assignOrganizationEntitiesToDifferentMember($organization, $user, $memberTo->user, $memberTo);
153+
DB::transaction(function () use ($organization, $member, $user, $memberTo, $memberService): void {
154+
$memberService->assignOrganizationEntitiesToDifferentMember($organization, $member, $memberTo);
156155
$member->delete();
157156
$user->delete();
158157
});

app/Listeners/RemovePlaceholder.php

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66

77
use App\Models\Member;
88
use App\Models\User;
9-
use App\Service\UserService;
9+
use App\Service\MemberService;
1010
use Illuminate\Database\Eloquent\Builder;
1111
use Laravel\Jetstream\Events\TeamMemberAdded;
1212

@@ -17,8 +17,11 @@ class RemovePlaceholder
1717
*/
1818
public function handle(TeamMemberAdded $event): void
1919
{
20-
/** @var UserService $userService */
21-
$userService = app(UserService::class);
20+
$memberService = app(MemberService::class);
21+
$member = Member::query()
22+
->whereBelongsTo($event->team, 'organization')
23+
->whereBelongsTo($event->user, 'user')
24+
->firstOrFail();
2225
$placeholders = Member::query()
2326
->whereHas('user', function (Builder $query) use ($event): void {
2427
/** @var Builder<User> $query */
@@ -32,7 +35,7 @@ public function handle(TeamMemberAdded $event): void
3235
foreach ($placeholders as $placeholder) {
3336
/** @var Member $placeholder */
3437
$placeholderUser = $placeholder->user;
35-
$userService->assignOrganizationEntitiesToDifferentUser($event->team, $placeholderUser, $event->user);
38+
$memberService->assignOrganizationEntitiesToDifferentMember($event->team, $placeholder, $member);
3639
$placeholder->delete();
3740
$placeholderUser->delete();
3841
}

app/Service/MemberService.php

Lines changed: 36 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,9 +14,11 @@
1414
use App\Exceptions\Api\OrganizationNeedsAtLeastOneOwner;
1515
use App\Models\Member;
1616
use App\Models\Organization;
17+
use App\Models\Project;
1718
use App\Models\ProjectMember;
1819
use App\Models\TimeEntry;
1920
use App\Models\User;
21+
use Illuminate\Database\Eloquent\Builder;
2022
use Illuminate\Support\Facades\DB;
2123
use InvalidArgumentException;
2224
use Laravel\Jetstream\Events\AddingTeamMember;
@@ -101,6 +103,39 @@ public function changeRole(Member $member, Organization $organization, Role $new
101103
}
102104
}
103105

106+
public function assignOrganizationEntitiesToDifferentMember(Organization $organization, Member $fromMember, Member $toMember): void
107+
{
108+
// Time entries
109+
TimeEntry::query()
110+
->whereBelongsTo($organization, 'organization')
111+
->whereBelongsTo($fromMember, 'member')
112+
->update([
113+
'user_id' => $toMember->user_id,
114+
'member_id' => $toMember->getKey(),
115+
]);
116+
117+
// Project members
118+
ProjectMember::query()
119+
->whereBelongsToOrganization($organization)
120+
->whereBelongsTo($fromMember, 'member')
121+
->whereDoesntHave('project', function (Builder $builder) use ($toMember): void {
122+
/** @var Builder<Project> $builder */
123+
$builder->whereHas('members', function (Builder $builder) use ($toMember): void {
124+
/** @var Builder<ProjectMember> $builder */
125+
$builder->where('member_id', $toMember->getKey());
126+
});
127+
})
128+
->update([
129+
'user_id' => $toMember->user_id,
130+
'member_id' => $toMember->getKey(),
131+
]);
132+
133+
ProjectMember::query()
134+
->whereBelongsToOrganization($organization)
135+
->whereBelongsTo($fromMember, 'member')
136+
->delete();
137+
}
138+
104139
/**
105140
* Change the ownership of an organization to a new user.
106141
* The previous owner will be demoted to an admin.
@@ -137,7 +172,7 @@ public function makeMemberToPlaceholder(Member $member, bool $makeSureUserHasAtL
137172
$member->role = Role::Placeholder->value;
138173
$member->save();
139174

140-
$this->userService->assignOrganizationEntitiesToDifferentMember($member->organization, $user, $placeholderUser, $member);
175+
$this->userService->assignOrganizationEntitiesToDifferentUser($member->organization, $user, $placeholderUser);
141176
if ($makeSureUserHasAtLeastOneOrganization) {
142177
$this->userService->makeSureUserHasAtLeastOneOrganization($user);
143178
}

app/Service/UserService.php

Lines changed: 2 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -49,32 +49,17 @@ public function createUser(string $name, string $email, string $password, string
4949
}
5050

5151
/**
52-
* Assign all organization entities (time entries, project members) from one user to another.
53-
* This is useful when a placeholder user is replaced with a real user.
52+
* This does NOT change the member id.
53+
* This should only be used in if you want to change a member to a placeholder!
5454
*/
5555
public function assignOrganizationEntitiesToDifferentUser(Organization $organization, User $fromUser, User $toUser): void
56-
{
57-
/** @var Member|null $toMember */
58-
$toMember = Member::query()
59-
->whereBelongsTo($organization, 'organization')
60-
->whereBelongsTo($toUser, 'user')
61-
->first();
62-
if ($toMember === null) {
63-
throw new \InvalidArgumentException('User is not a member of the organization');
64-
}
65-
66-
$this->assignOrganizationEntitiesToDifferentMember($organization, $fromUser, $toUser, $toMember);
67-
}
68-
69-
public function assignOrganizationEntitiesToDifferentMember(Organization $organization, User $fromUser, User $toUser, Member $toMember): void
7056
{
7157
// Time entries
7258
TimeEntry::query()
7359
->whereBelongsTo($organization, 'organization')
7460
->whereBelongsTo($fromUser, 'user')
7561
->update([
7662
'user_id' => $toUser->getKey(),
77-
'member_id' => $toMember->getKey(),
7863
]);
7964

8065
// Project members
@@ -83,7 +68,6 @@ public function assignOrganizationEntitiesToDifferentMember(Organization $organi
8368
->whereBelongsTo($fromUser, 'user')
8469
->update([
8570
'user_id' => $toUser->getKey(),
86-
'member_id' => $toMember->getKey(),
8771
]);
8872
}
8973

tests/Unit/Endpoint/Api/V1/MemberEndpointTest.php

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -350,6 +350,58 @@ public function test_merge_into_assigns_resources_of_source_member_to_destinatio
350350
$memberDestination->refresh();
351351
$this->assertCount(3, $memberDestination->timeEntries);
352352
$this->assertCount(1, $memberDestination->projectMembers);
353+
$this->assertDatabaseHas(ProjectMember::class, [
354+
'project_id' => $project->getKey(),
355+
'member_id' => $memberDestination->getKey(),
356+
'user_id' => $userDestination->getKey(),
357+
]);
358+
}
359+
360+
public function test_merge_into_assigns_resources_of_source_member_to_destination_member_and_deletes_member_with_existing_destination_resources(): void
361+
{
362+
// Arrange
363+
$data = $this->createUserWithPermission([
364+
'members:merge-into',
365+
]);
366+
$userSource = User::factory()->placeholder()->create();
367+
$memberSource = Member::factory()->forUser($userSource)->forOrganization($data->organization)->role(Role::Placeholder)->create();
368+
TimeEntry::factory()->forMember($memberSource)->createMany(3);
369+
$project = Project::factory()->forOrganization($data->organization)->create();
370+
ProjectMember::factory()->forMember($memberSource)->forProject($project)->create([
371+
'billable_rate' => 32100,
372+
]);
373+
374+
$userDestination = User::factory()->create();
375+
$memberDestination = Member::factory()->forUser($userDestination)->forOrganization($data->organization)->role(Role::Admin)->create();
376+
ProjectMember::factory()->forMember($memberDestination)->forProject($project)->create([
377+
'billable_rate' => 12300,
378+
]);
379+
TimeEntry::factory()->forMember($memberDestination)->createMany(3);
380+
Passport::actingAs($data->user);
381+
382+
// Act
383+
$response = $this->withoutExceptionHandling()->postJson(route('api.v1.members.merge-into', [$data->organization->getKey(), $memberSource->getKey()]), [
384+
'member_id' => $memberDestination->getKey(),
385+
]);
386+
387+
// Assert
388+
$response->assertStatus(204);
389+
$this->assertSame('', $response->getContent());
390+
$this->assertDatabaseMissing(Member::class, [
391+
'id' => $memberSource->getKey(),
392+
]);
393+
$this->assertDatabaseMissing(User::class, [
394+
'id' => $userSource->getKey(),
395+
]);
396+
$memberDestination->refresh();
397+
$this->assertCount(6, $memberDestination->timeEntries);
398+
$this->assertCount(1, $memberDestination->projectMembers);
399+
$this->assertDatabaseHas(ProjectMember::class, [
400+
'project_id' => $project->getKey(),
401+
'billable_rate' => 12300,
402+
'member_id' => $memberDestination->getKey(),
403+
'user_id' => $userDestination->getKey(),
404+
]);
353405
}
354406

355407
public function test_update_member_fails_if_user_tries_to_change_role_of_the_current_owner(): void

tests/Unit/Service/MemberServiceTest.php

Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -113,4 +113,94 @@ public function test_make_member_to_placeholder_creates_new_user_based_on_member
113113
$this->assertSame($otherMember->getKey(), $otherTimeEntry->member_id);
114114
$this->assertSame(1, $otherUser->organizations()->count());
115115
}
116+
117+
public function test_assign_organization_entities_to_different_member_without_any_entries(): void
118+
{
119+
// Arrange
120+
$organization = Organization::factory()->create();
121+
$project = Project::factory()->forOrganization($organization)->create();
122+
$otherUser = User::factory()->create();
123+
$fromUser = User::factory()->create();
124+
$toUser = User::factory()->create();
125+
$otherUserMember = Member::factory()->forOrganization($organization)->forUser($otherUser)->create();
126+
$fromUserMember = Member::factory()->forOrganization($organization)->forUser($fromUser)->create();
127+
$toUserMember = Member::factory()->forOrganization($organization)->forUser($toUser)->create();
128+
TimeEntry::factory()->forOrganization($organization)->forMember($otherUserMember)->createMany(3);
129+
TimeEntry::factory()->forOrganization($organization)->forMember($fromUserMember)->createMany(3);
130+
ProjectMember::factory()->forProject($project)->forMember($otherUserMember)->create();
131+
ProjectMember::factory()->forProject($project)->forMember($fromUserMember)->create();
132+
133+
// Act
134+
$this->memberService->assignOrganizationEntitiesToDifferentMember($organization, $fromUserMember, $toUserMember);
135+
136+
// Assert
137+
$this->assertSame(3, TimeEntry::query()->whereBelongsTo($toUser, 'user')->count());
138+
$this->assertSame(3, TimeEntry::query()->whereBelongsTo($otherUser, 'user')->count());
139+
$this->assertSame(0, TimeEntry::query()->whereBelongsTo($fromUser, 'user')->count());
140+
$this->assertSame(1, ProjectMember::query()->whereBelongsTo($toUser, 'user')->count());
141+
$this->assertSame(1, ProjectMember::query()->whereBelongsTo($otherUser, 'user')->count());
142+
$this->assertSame(0, ProjectMember::query()->whereBelongsTo($fromUser, 'user')->count());
143+
144+
$this->assertSame(3, TimeEntry::query()->whereBelongsTo($toUserMember, 'member')->count());
145+
$this->assertSame(3, TimeEntry::query()->whereBelongsTo($otherUserMember, 'member')->count());
146+
$this->assertSame(0, TimeEntry::query()->whereBelongsTo($fromUserMember, 'member')->count());
147+
$this->assertSame(1, ProjectMember::query()->whereBelongsTo($toUserMember, 'member')->count());
148+
$this->assertSame(1, ProjectMember::query()->whereBelongsTo($otherUserMember, 'member')->count());
149+
$this->assertSame(0, ProjectMember::query()->whereBelongsTo($fromUserMember, 'member')->count());
150+
}
151+
152+
public function test_assign_organization_entities_to_different_member_with_entries(): void
153+
{
154+
// Arrange
155+
$organization = Organization::factory()->create();
156+
$project = Project::factory()->forOrganization($organization)->create();
157+
$otherUser = User::factory()->create();
158+
$fromUser = User::factory()->create();
159+
$toUser = User::factory()->create();
160+
$otherUserMember = Member::factory()->forOrganization($organization)->forUser($otherUser)->create();
161+
$fromUserMember = Member::factory()->forOrganization($organization)->forUser($fromUser)->create();
162+
$toUserMember = Member::factory()->forOrganization($organization)->forUser($toUser)->create();
163+
TimeEntry::factory()->forOrganization($organization)->forMember($otherUserMember)->createMany(3);
164+
TimeEntry::factory()->forOrganization($organization)->forMember($fromUserMember)->createMany(3);
165+
TimeEntry::factory()->forOrganization($organization)->forMember($toUserMember)->createMany(3);
166+
ProjectMember::factory()->forProject($project)->forMember($otherUserMember)->create([
167+
'billable_rate' => 1,
168+
]);
169+
ProjectMember::factory()->forProject($project)->forMember($fromUserMember)->create([
170+
'billable_rate' => 2,
171+
]);
172+
ProjectMember::factory()->forProject($project)->forMember($toUserMember)->create([
173+
'billable_rate' => 3,
174+
]);
175+
176+
// Act
177+
$this->memberService->assignOrganizationEntitiesToDifferentMember($organization, $fromUserMember, $toUserMember);
178+
179+
// Assert
180+
$this->assertSame(6, TimeEntry::query()->whereBelongsTo($toUser, 'user')->count());
181+
$this->assertSame(3, TimeEntry::query()->whereBelongsTo($otherUser, 'user')->count());
182+
$this->assertSame(0, TimeEntry::query()->whereBelongsTo($fromUser, 'user')->count());
183+
$this->assertSame(1, ProjectMember::query()->whereBelongsTo($toUser, 'user')->count());
184+
$this->assertSame(1, ProjectMember::query()->whereBelongsTo($otherUser, 'user')->count());
185+
$this->assertSame(0, ProjectMember::query()->whereBelongsTo($fromUser, 'user')->count());
186+
187+
$this->assertSame(6, TimeEntry::query()->whereBelongsTo($toUserMember, 'member')->count());
188+
$this->assertSame(3, TimeEntry::query()->whereBelongsTo($otherUserMember, 'member')->count());
189+
$this->assertSame(0, TimeEntry::query()->whereBelongsTo($fromUserMember, 'member')->count());
190+
$this->assertSame(1, ProjectMember::query()->whereBelongsTo($toUserMember, 'member')->count());
191+
$this->assertSame(1, ProjectMember::query()->whereBelongsTo($otherUserMember, 'member')->count());
192+
$this->assertSame(0, ProjectMember::query()->whereBelongsTo($fromUserMember, 'member')->count());
193+
194+
$this->assertDatabaseCount(ProjectMember::class, 2);
195+
$this->assertDatabaseHas(ProjectMember::class, [
196+
'project_id' => $project->id,
197+
'member_id' => $toUserMember->id,
198+
'billable_rate' => 3,
199+
]);
200+
$this->assertDatabaseHas(ProjectMember::class, [
201+
'project_id' => $project->id,
202+
'member_id' => $otherUserMember->id,
203+
'billable_rate' => 1,
204+
]);
205+
}
116206
}

tests/Unit/Service/UserServiceTest.php

Lines changed: 0 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -59,22 +59,6 @@ public function test_assign_organization_entities_to_different_user(): void
5959
$this->assertSame(0, ProjectMember::query()->whereBelongsTo($fromUser, 'user')->count());
6060
}
6161

62-
public function test_assign_organization_entities_to_different_user_fails_if_new_user_is_not_member_of_organization(): void
63-
{
64-
// Arrange
65-
$organization = Organization::factory()->create();
66-
$fromUser = User::factory()->create();
67-
$toUser = User::factory()->create();
68-
$fromUserMember = Member::factory()->forOrganization($organization)->forUser($fromUser)->create();
69-
70-
// Act
71-
try {
72-
$this->userService->assignOrganizationEntitiesToDifferentUser($organization, $fromUser, $toUser);
73-
} catch (\InvalidArgumentException $e) {
74-
$this->assertSame('User is not a member of the organization', $e->getMessage());
75-
}
76-
}
77-
7862
public function test_change_ownership_changes_ownership_of_organization_to_new_user(): void
7963
{
8064
// Arrange

0 commit comments

Comments
 (0)