Skip to content

Commit ab263e7

Browse files
committed
Fixed bugs in member endpoints; Added merge-into member endpoint
1 parent f93c537 commit ab263e7

13 files changed

+487
-3
lines changed
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace App\Console\Commands\Correction;
6+
7+
use App\Enums\Role;
8+
use App\Models\Member;
9+
use App\Models\User;
10+
use Illuminate\Console\Command;
11+
use Illuminate\Database\Eloquent\Builder;
12+
13+
class CorrectionPlaceholderMembersCommand extends Command
14+
{
15+
/**
16+
* The name and signature of the console command.
17+
*
18+
* @var string
19+
*/
20+
protected $signature = 'correction:placeholder-members '.
21+
' { --dry-run : Do not actually save anything to the database, just output what would happen }';
22+
23+
/**
24+
* The console command description.
25+
*
26+
* @var string
27+
*/
28+
protected $description = 'Sets all members who belong to a placeholder user to role placeholder';
29+
30+
/**
31+
* Execute the console command.
32+
*/
33+
public function handle(): int
34+
{
35+
$this->comment('Sets all members who belong to a placeholder user to role placeholder...');
36+
$dryRun = (bool) $this->option('dry-run');
37+
if ($dryRun) {
38+
$this->comment('Running in dry-run mode. Nothing will be saved to the database.');
39+
}
40+
41+
$members = Member::query()
42+
->where('role', '!=', Role::Placeholder->value)
43+
->whereHas('user', function (Builder $builder): void {
44+
/** @var Builder<User> $builder */
45+
$builder->where('is_placeholder', '=', true);
46+
})
47+
->get();
48+
foreach ($members as $member) {
49+
/** @var Member $member */
50+
$member->role = Role::Placeholder->value;
51+
if (! $dryRun) {
52+
$member->save();
53+
}
54+
$this->line('Set role of member (id='.$member->getKey().') to placeholder');
55+
}
56+
57+
return self::SUCCESS;
58+
}
59+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace App\Exceptions\Api;
6+
7+
class ChangingRoleOfPlaceholderIsNotAllowed extends ApiException
8+
{
9+
public const string KEY = 'changing_role_of_placeholder_is_not_allowed';
10+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace App\Exceptions\Api;
6+
7+
class OnlyPlaceholdersCanBeMergedIntoAnotherMember extends ApiException
8+
{
9+
public const string KEY = 'only_placeholders_can_be_merged_into_another_member';
10+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace App\Exceptions\Api;
6+
7+
class ThisPlaceholderCanNotBeInvitedUseTheMergeToolInsteadException extends ApiException
8+
{
9+
public const string KEY = 'this_placeholder_can_not_be_invited_use_the_merge_tool_instead_api_exception';
10+
}

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

+45-2
Original file line numberDiff line numberDiff line change
@@ -7,12 +7,17 @@
77
use App\Enums\Role;
88
use App\Events\MemberMadeToPlaceholder;
99
use App\Exceptions\Api\CanNotRemoveOwnerFromOrganization;
10+
use App\Exceptions\Api\ChangingRoleOfPlaceholderIsNotAllowed;
1011
use App\Exceptions\Api\ChangingRoleToPlaceholderIsNotAllowed;
1112
use App\Exceptions\Api\EntityStillInUseApiException;
1213
use App\Exceptions\Api\OnlyOwnerCanChangeOwnership;
14+
use App\Exceptions\Api\OnlyPlaceholdersCanBeMergedIntoAnotherMember;
1315
use App\Exceptions\Api\OrganizationNeedsAtLeastOneOwner;
16+
use App\Exceptions\Api\ThisPlaceholderCanNotBeInvitedUseTheMergeToolInsteadException;
17+
use App\Exceptions\Api\UserIsAlreadyMemberOfOrganizationApiException;
1418
use App\Exceptions\Api\UserNotPlaceholderApiException;
1519
use App\Http\Requests\V1\Member\MemberIndexRequest;
20+
use App\Http\Requests\V1\Member\MemberMergeIntoRequest;
1621
use App\Http\Requests\V1\Member\MemberUpdateRequest;
1722
use App\Http\Resources\V1\Member\MemberCollection;
1823
use App\Http\Resources\V1\Member\MemberResource;
@@ -21,9 +26,12 @@
2126
use App\Service\BillableRateService;
2227
use App\Service\InvitationService;
2328
use App\Service\MemberService;
29+
use App\Service\UserService;
2430
use Illuminate\Auth\Access\AuthorizationException;
2531
use Illuminate\Http\JsonResponse;
2632
use Illuminate\Http\Resources\Json\JsonResource;
33+
use Illuminate\Support\Facades\DB;
34+
use Illuminate\Support\Str;
2735

2836
class MemberController extends Controller
2937
{
@@ -63,6 +71,7 @@ public function index(Organization $organization, MemberIndexRequest $request):
6371
* @throws OrganizationNeedsAtLeastOneOwner
6472
* @throws OnlyOwnerCanChangeOwnership
6573
* @throws ChangingRoleToPlaceholderIsNotAllowed
74+
* @throws ChangingRoleOfPlaceholderIsNotAllowed
6675
*
6776
* @operationId updateMember
6877
*/
@@ -105,7 +114,7 @@ public function destroy(Organization $organization, Member $member, MemberServic
105114
/**
106115
* Make a member a placeholder member
107116
*
108-
* @throws AuthorizationException|CanNotRemoveOwnerFromOrganization
117+
* @throws AuthorizationException|CanNotRemoveOwnerFromOrganization|ChangingRoleOfPlaceholderIsNotAllowed
109118
*/
110119
public function makePlaceholder(Organization $organization, Member $member, MemberService $memberService): JsonResponse
111120
{
@@ -114,6 +123,9 @@ public function makePlaceholder(Organization $organization, Member $member, Memb
114123
if ($member->role === Role::Owner->value) {
115124
throw new CanNotRemoveOwnerFromOrganization;
116125
}
126+
if ($member->role === Role::Placeholder->value) {
127+
throw new ChangingRoleOfPlaceholderIsNotAllowed;
128+
}
117129

118130
$memberService->makeMemberToPlaceholder($member);
119131

@@ -122,10 +134,37 @@ public function makePlaceholder(Organization $organization, Member $member, Memb
122134
return response()->json(null, 204);
123135
}
124136

137+
/**
138+
* @throws AuthorizationException
139+
* @throws OnlyPlaceholdersCanBeMergedIntoAnotherMember
140+
* @throws \Throwable
141+
*/
142+
public function mergeInto(Organization $organization, Member $member, MemberMergeIntoRequest $request): JsonResponse
143+
{
144+
$this->checkPermission($organization, 'members:merge-into', $member);
145+
146+
$user = $member->user;
147+
if ($member->role !== Role::Placeholder->value || ! $user->is_placeholder) {
148+
throw new OnlyPlaceholdersCanBeMergedIntoAnotherMember;
149+
}
150+
$memberTo = Member::findOrFail($request->getMemberId());
151+
152+
DB::transaction(function () use ($organization, $member, $user, $memberTo): void {
153+
app(UserService::class)->assignOrganizationEntitiesToDifferentMember($organization, $user, $memberTo->user, $memberTo);
154+
$member->delete();
155+
$user->delete();
156+
});
157+
158+
return response()->json(null, 204);
159+
}
160+
125161
/**
126162
* Invite a placeholder member to become a real member of the organization
127163
*
128-
* @throws AuthorizationException|UserNotPlaceholderApiException
164+
* @throws AuthorizationException
165+
* @throws UserNotPlaceholderApiException
166+
* @throws UserIsAlreadyMemberOfOrganizationApiException
167+
* @throws ThisPlaceholderCanNotBeInvitedUseTheMergeToolInsteadException
129168
*
130169
* @operationId invitePlaceholder
131170
*/
@@ -138,6 +177,10 @@ public function invitePlaceholder(Organization $organization, Member $member, In
138177
throw new UserNotPlaceholderApiException;
139178
}
140179

180+
if (Str::endsWith($user->email, '@solidtime-import.test')) {
181+
throw new ThisPlaceholderCanNotBeInvitedUseTheMergeToolInsteadException;
182+
}
183+
141184
$invitationService->inviteUser($organization, $user->email, Role::Employee);
142185

143186
return response()->json(null, 204);
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace App\Http\Requests\V1\Member;
6+
7+
use App\Models\Member;
8+
use App\Models\Organization;
9+
use Illuminate\Contracts\Validation\ValidationRule;
10+
use Illuminate\Database\Eloquent\Builder;
11+
use Illuminate\Foundation\Http\FormRequest;
12+
use Korridor\LaravelModelValidationRules\Rules\ExistsEloquent;
13+
14+
/**
15+
* @property Organization $organization
16+
*/
17+
class MemberMergeIntoRequest extends FormRequest
18+
{
19+
/**
20+
* Get the validation rules that apply to the request.
21+
*
22+
* @return array<string, array<string|ValidationRule|\Illuminate\Contracts\Validation\Rule>>
23+
*/
24+
public function rules(): array
25+
{
26+
return [
27+
// ID of the member to which the data should be transferred (destination)
28+
'member_id' => [
29+
'string',
30+
ExistsEloquent::make(Member::class, null, function (Builder $builder): Builder {
31+
/** @var Builder<Member> $builder */
32+
return $builder->whereBelongsTo($this->organization, 'organization');
33+
})->uuid(),
34+
],
35+
];
36+
}
37+
38+
public function getMemberId(): string
39+
{
40+
return (string) $this->input('member_id');
41+
}
42+
}

app/Models/Member.php

+11
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
use App\Models\Concerns\CustomAuditable;
88
use App\Models\Concerns\HasUuids;
99
use Database\Factories\MemberFactory;
10+
use Illuminate\Database\Eloquent\Collection;
1011
use Illuminate\Database\Eloquent\Factories\HasFactory;
1112
use Illuminate\Database\Eloquent\Relations\BelongsTo;
1213
use Illuminate\Database\Eloquent\Relations\HasMany;
@@ -24,6 +25,8 @@
2425
* @property Carbon|null $updated_at
2526
* @property-read Organization $organization
2627
* @property-read User $user
28+
* @property-read Collection<ProjectMember> $projectMembers
29+
* @property-read Collection<TimeEntry> $timeEntries
2730
*
2831
* @method static MemberFactory factory()
2932
*/
@@ -59,6 +62,14 @@ public function organization(): BelongsTo
5962
return $this->belongsTo(Organization::class, 'organization_id');
6063
}
6164

65+
/**
66+
* @return HasMany<TimeEntry>
67+
*/
68+
public function timeEntries(): HasMany
69+
{
70+
return $this->hasMany(TimeEntry::class, 'member_id');
71+
}
72+
6273
/**
6374
* @return HasMany<ProjectMember>
6475
*/

app/Service/MemberService.php

+5
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
use App\Enums\Role;
88
use App\Events\MemberRemoved;
99
use App\Exceptions\Api\CanNotRemoveOwnerFromOrganization;
10+
use App\Exceptions\Api\ChangingRoleOfPlaceholderIsNotAllowed;
1011
use App\Exceptions\Api\ChangingRoleToPlaceholderIsNotAllowed;
1112
use App\Exceptions\Api\EntityStillInUseApiException;
1213
use App\Exceptions\Api\OnlyOwnerCanChangeOwnership;
@@ -75,13 +76,17 @@ public function removeMember(Member $member, Organization $organization): void
7576
* @throws ChangingRoleToPlaceholderIsNotAllowed
7677
* @throws OnlyOwnerCanChangeOwnership
7778
* @throws OrganizationNeedsAtLeastOneOwner
79+
* @throws ChangingRoleOfPlaceholderIsNotAllowed
7880
*/
7981
public function changeRole(Member $member, Organization $organization, Role $newRole, bool $allowOwnerChange): void
8082
{
8183
$oldRole = Role::from($member->role);
8284
if ($oldRole === Role::Owner) {
8385
throw new OrganizationNeedsAtLeastOneOwner;
8486
}
87+
if ($oldRole === Role::Placeholder) {
88+
throw new ChangingRoleOfPlaceholderIsNotAllowed;
89+
}
8590
if ($newRole === Role::Placeholder) {
8691
throw new ChangingRoleToPlaceholderIsNotAllowed;
8792
}

lang/en/exceptions.php

+6
Original file line numberDiff line numberDiff line change
@@ -4,15 +4,18 @@
44

55
use App\Exceptions\Api\CanNotDeleteUserWhoIsOwnerOfOrganizationWithMultipleMembers;
66
use App\Exceptions\Api\CanNotRemoveOwnerFromOrganization;
7+
use App\Exceptions\Api\ChangingRoleOfPlaceholderIsNotAllowed;
78
use App\Exceptions\Api\ChangingRoleToPlaceholderIsNotAllowed;
89
use App\Exceptions\Api\EntityStillInUseApiException;
910
use App\Exceptions\Api\FeatureIsNotAvailableInFreePlanApiException;
1011
use App\Exceptions\Api\InactiveUserCanNotBeUsedApiException;
1112
use App\Exceptions\Api\OnlyOwnerCanChangeOwnership;
13+
use App\Exceptions\Api\OnlyPlaceholdersCanBeMergedIntoAnotherMember;
1214
use App\Exceptions\Api\OrganizationHasNoSubscriptionButMultipleMembersException;
1315
use App\Exceptions\Api\OrganizationNeedsAtLeastOneOwner;
1416
use App\Exceptions\Api\PdfRendererIsNotConfiguredException;
1517
use App\Exceptions\Api\PersonalAccessClientIsNotConfiguredException;
18+
use App\Exceptions\Api\ThisPlaceholderCanNotBeInvitedUseTheMergeToolInsteadException;
1619
use App\Exceptions\Api\TimeEntryCanNotBeRestartedApiException;
1720
use App\Exceptions\Api\TimeEntryStillRunningApiException;
1821
use App\Exceptions\Api\UserIsAlreadyMemberOfOrganizationApiException;
@@ -39,6 +42,9 @@
3942
PdfRendererIsNotConfiguredException::KEY => 'PDF renderer is not configured',
4043
FeatureIsNotAvailableInFreePlanApiException::KEY => 'Feature is not available in free plan',
4144
PersonalAccessClientIsNotConfiguredException::KEY => 'Personal access client is not configured',
45+
ChangingRoleOfPlaceholderIsNotAllowed::KEY => 'Changing role of placeholder is not allowed',
46+
OnlyPlaceholdersCanBeMergedIntoAnotherMember::KEY => 'Only placeholders can be merged into another member',
47+
ThisPlaceholderCanNotBeInvitedUseTheMergeToolInsteadException::KEY => 'This placeholder can not be invited use the merge tool instead',
4248
],
4349
'unknown_error_in_admin_panel' => 'An unknown error occurred. Please check the logs.',
4450
];

lang/en/importer.php

+1-1
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@
1313
'<br> 4. Now click Export -> Save as CSV. The Export dropdown is in the header of the export table left of the printer symbol. '.
1414
'<br><br>Before you import make sure that the Timezone settings in Clockify are the same as in solidtime.',
1515
],
16-
'generic_project' => [
16+
'generic_projects' => [
1717
'name' => 'Generic Projects',
1818
'description' => 'If you want to import many projects yourself this importer the right choice. Please see our docs for <a href="https://docs.solidtime.io/user-guide/import">more information about the CSV structure</a>',
1919
],

routes/api.php

+1
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,7 @@
5151
Route::delete('/members/{member}', [MemberController::class, 'destroy'])->name('destroy');
5252
Route::post('/members/{member}/invite-placeholder', [MemberController::class, 'invitePlaceholder'])->name('invite-placeholder');
5353
Route::post('/members/{member}/make-placeholder', [MemberController::class, 'makePlaceholder'])->name('make-placeholder');
54+
Route::post('member/{member}/merge-into', [MemberController::class, 'mergeInto'])->name('merge-into');
5455
});
5556

5657
// User routes

0 commit comments

Comments
 (0)