From c5c129b2399be678e9fc726a682cf64f331cd33e Mon Sep 17 00:00:00 2001 From: Gregor Vostrak Date: Mon, 8 Jun 2026 18:53:56 +0200 Subject: [PATCH] add switch current organization endpoint --- .../Controllers/Api/V1/UserController.php | 32 +++++++ .../UserUpdateCurrentOrganizationRequest.php | 32 +++++++ routes/api.php | 1 + .../Unit/Endpoint/Api/V1/UserEndpointTest.php | 85 +++++++++++++++++++ 4 files changed, 150 insertions(+) create mode 100644 app/Http/Requests/V1/User/UserUpdateCurrentOrganizationRequest.php diff --git a/app/Http/Controllers/Api/V1/UserController.php b/app/Http/Controllers/Api/V1/UserController.php index 10e7cd3d..b8d3fc42 100644 --- a/app/Http/Controllers/Api/V1/UserController.php +++ b/app/Http/Controllers/Api/V1/UserController.php @@ -6,11 +6,14 @@ namespace App\Http\Controllers\Api\V1; use App\Exceptions\Api\CanNotDeleteUserWhoIsOwnerOfOrganizationWithMultipleMembers; use App\Exceptions\Api\UserResendEmailVerificationNoPendingEmailApiException; +use App\Http\Requests\V1\User\UserUpdateCurrentOrganizationRequest; use App\Http\Requests\V1\User\UserUpdateRequest; use App\Http\Resources\V1\User\UserResource; use App\Mail\VerifyUpdatedEmailMail; +use App\Models\Organization; use App\Models\User; use App\Service\DeletionService; +use App\Service\UserService; use App\Support\Base64File; use Illuminate\Auth\Access\AuthorizationException; use Illuminate\Http\JsonResponse; @@ -36,6 +39,35 @@ class UserController extends Controller return new UserResource($user); } + /** + * Update the current organization of the current user + * + * Switches the organization that the user is currently working in. The user + * must be a member of the given organization. This endpoint is independent of + * the organization. + * + * @operationId updateMyCurrentOrganization + * + * @throws AuthorizationException + */ + public function updateMyCurrentOrganization(UserUpdateCurrentOrganizationRequest $request, UserService $userService): UserResource + { + $user = $this->user(); + + /** @var Organization|null $organization */ + $organization = $user->organizations() + ->whereKey($request->getOrganizationId()) + ->first(); + + if ($organization === null) { + throw new AuthorizationException; + } + + $userService->switchCurrentOrganization($user, $organization); + + return new UserResource($user->refresh()); + } + /** * Update the current user * diff --git a/app/Http/Requests/V1/User/UserUpdateCurrentOrganizationRequest.php b/app/Http/Requests/V1/User/UserUpdateCurrentOrganizationRequest.php new file mode 100644 index 00000000..9bc6e481 --- /dev/null +++ b/app/Http/Requests/V1/User/UserUpdateCurrentOrganizationRequest.php @@ -0,0 +1,32 @@ +> + */ + public function rules(): array + { + return [ + 'organization_id' => [ + 'required', + 'string', + 'uuid', + ], + ]; + } + + public function getOrganizationId(): string + { + return (string) $this->input('organization_id'); + } +} diff --git a/routes/api.php b/routes/api.php index 2738aa03..6f1cfae2 100644 --- a/routes/api.php +++ b/routes/api.php @@ -62,6 +62,7 @@ Route::prefix('v1')->name('v1.')->group(static function (): void { // User routes Route::name('users.')->group(static function (): void { Route::get('/users/me', [UserController::class, 'me'])->name('me'); + Route::put('/users/me/current-organization', [UserController::class, 'updateMyCurrentOrganization'])->name('update-current-organization'); Route::put('/users/{user}', [UserController::class, 'update'])->name('update'); Route::post('/users/{user}/resend-email-verification', [UserController::class, 'resendEmailVerification'])->name('resend-email-verification'); Route::delete('/users/{user}', [UserController::class, 'destroy'])->name('destroy'); diff --git a/tests/Unit/Endpoint/Api/V1/UserEndpointTest.php b/tests/Unit/Endpoint/Api/V1/UserEndpointTest.php index 2d4f4765..fdb41e4b 100644 --- a/tests/Unit/Endpoint/Api/V1/UserEndpointTest.php +++ b/tests/Unit/Endpoint/Api/V1/UserEndpointTest.php @@ -4,8 +4,11 @@ declare(strict_types=1); namespace Tests\Unit\Endpoint\Api\V1; +use App\Enums\Role; use App\Enums\Weekday; use App\Mail\VerifyUpdatedEmailMail; +use App\Models\Member; +use App\Models\Organization; use App\Models\User; use Illuminate\Support\Facades\Mail; use Illuminate\Support\Facades\Storage; @@ -46,6 +49,88 @@ class UserEndpointTest extends ApiEndpointTestAbstract ]); } + public function test_update_current_organization_fails_when_not_authenticated(): void + { + // Arrange + $organization = Organization::factory()->create(); + + // Act + $response = $this->putJson(route('api.v1.users.update-current-organization'), [ + 'organization_id' => $organization->getKey(), + ]); + + // Assert + $response->assertUnauthorized(); + } + + public function test_update_current_organization_switches_the_current_organization_of_the_user(): void + { + // Arrange + $data = $this->createUserWithPermission([], isOwner: true); + $otherOrganization = Organization::factory()->create(); + Member::factory()->forUser($data->user)->forOrganization($otherOrganization)->create([ + 'role' => Role::Admin->value, + ]); + Passport::actingAs($data->user); + + // Act + $response = $this->putJson(route('api.v1.users.update-current-organization'), [ + 'organization_id' => $otherOrganization->getKey(), + ]); + + // Assert + $response->assertSuccessful(); + $this->assertSame($otherOrganization->getKey(), $data->user->fresh()->current_team_id); + } + + public function test_update_current_organization_fails_if_user_is_not_a_member_of_the_target_organization(): void + { + // Arrange + $data = $this->createUserWithPermission([], isOwner: true); + $currentOrganizationId = $data->user->current_team_id; + $otherOrganization = Organization::factory()->create(); + Passport::actingAs($data->user); + + // Act + $response = $this->putJson(route('api.v1.users.update-current-organization'), [ + 'organization_id' => $otherOrganization->getKey(), + ]); + + // Assert + $response->assertForbidden(); + $this->assertSame($currentOrganizationId, $data->user->fresh()->current_team_id); + } + + public function test_update_current_organization_fails_if_organization_id_is_missing(): void + { + // Arrange + $data = $this->createUserWithPermission([], isOwner: true); + Passport::actingAs($data->user); + + // Act + $response = $this->putJson(route('api.v1.users.update-current-organization'), []); + + // Assert + $response->assertUnprocessable(); + $response->assertJsonValidationErrors('organization_id'); + } + + public function test_update_current_organization_fails_if_organization_id_is_not_a_uuid(): void + { + // Arrange + $data = $this->createUserWithPermission([], isOwner: true); + Passport::actingAs($data->user); + + // Act + $response = $this->putJson(route('api.v1.users.update-current-organization'), [ + 'organization_id' => 'not-a-uuid', + ]); + + // Assert + $response->assertUnprocessable(); + $response->assertJsonValidationErrors('organization_id'); + } + public function test_update_changes_user_name_timezone_and_week_start(): void { // Arrange