From a07bfb3fd2fe081798d358404c8b3c4a28cdf99e Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 24 Mar 2026 13:56:18 +0000 Subject: [PATCH] feat: add workspace ownership transfer Add transferOwnership() method to the Workspace model that allows the current owner to transfer ownership to another existing workspace member. The method: - Verifies the new owner is an existing member - Demotes the current owner to admin role - Promotes the new owner to owner role - Updates team assignments when teams are in use - Wraps the role changes in a DB transaction - Dispatches WorkspaceOwnershipTransferred event - Throws WorkspaceOwnershipException for auth/validation failures New files: - Events/WorkspaceOwnershipTransferred.php - Exceptions/WorkspaceOwnershipException.php - tests/Feature/WorkspaceOwnershipTransferTest.php Fixes #35 Co-Authored-By: Claude Opus 4.6 (1M context) --- Events/WorkspaceOwnershipTransferred.php | 44 +++ Exceptions/WorkspaceOwnershipException.php | 76 ++++++ Models/Workspace.php | 63 +++++ .../WorkspaceOwnershipTransferTest.php | 250 ++++++++++++++++++ 4 files changed, 433 insertions(+) create mode 100644 Events/WorkspaceOwnershipTransferred.php create mode 100644 Exceptions/WorkspaceOwnershipException.php create mode 100644 tests/Feature/WorkspaceOwnershipTransferTest.php diff --git a/Events/WorkspaceOwnershipTransferred.php b/Events/WorkspaceOwnershipTransferred.php new file mode 100644 index 0000000..46362a9 --- /dev/null +++ b/Events/WorkspaceOwnershipTransferred.php @@ -0,0 +1,44 @@ +id} is not a member of workspace {$workspace->id}. Only existing members can become owners.", + workspace: $workspace, + user: $user, + code: 422 + ); + } + + /** + * The user is already the owner. + */ + public static function alreadyOwner(Workspace $workspace, User $user): self + { + return new self( + message: "User {$user->id} is already the owner of workspace {$workspace->id}.", + workspace: $workspace, + user: $user, + code: 422 + ); + } + + /** + * The workspace has no current owner. + */ + public static function noCurrentOwner(Workspace $workspace): self + { + return new self( + message: "Workspace {$workspace->id} has no current owner.", + workspace: $workspace, + code: 500 + ); + } + + /** + * The requesting user is not authorised to transfer ownership. + */ + public static function unauthorised(Workspace $workspace, ?User $user = null): self + { + return new self( + message: 'Only the workspace owner can transfer ownership.', + workspace: $workspace, + user: $user, + code: 403 + ); + } +} diff --git a/Models/Workspace.php b/Models/Workspace.php index 490bc64..bcba34c 100644 --- a/Models/Workspace.php +++ b/Models/Workspace.php @@ -36,6 +36,8 @@ use Core\Mod\Trees\Models\TreePlanting; use Core\Mod\Trust\Models\Campaign; use Core\Mod\Trust\Models\Notification; use Core\Tenant\Database\Factories\WorkspaceFactory; +use Core\Tenant\Events\WorkspaceOwnershipTransferred; +use Core\Tenant\Exceptions\WorkspaceOwnershipException; use Core\Tenant\Notifications\WorkspaceInvitationNotification; use Core\Tenant\Services\EntitlementResult; use Core\Tenant\Services\EntitlementService; @@ -45,6 +47,7 @@ use Illuminate\Database\Eloquent\Relations\BelongsToMany; use Illuminate\Database\Eloquent\Relations\HasMany; use Illuminate\Database\Eloquent\Relations\MorphMany; use Illuminate\Support\Collection; +use Illuminate\Support\Facades\DB; use Mod\Commerce\Models\Invoice; use Mod\Commerce\Models\Order; use Mod\Commerce\Models\PaymentMethod; @@ -152,6 +155,66 @@ class Workspace extends Model ->first(); } + /** + * Transfer workspace ownership to another member. + * + * The new owner must already be a member of the workspace. The current owner + * is demoted to admin, and the new owner is promoted to owner. If teams are + * in use, team assignments are updated accordingly. + * + * @param User|null $actingUser The user performing the transfer (for authorisation). If null, skips auth check. + * + * @throws WorkspaceOwnershipException If the transfer is not permitted + */ + public function transferOwnership(User $newOwner, ?User $actingUser = null): self + { + $currentOwner = $this->owner(); + + if (! $currentOwner) { + throw WorkspaceOwnershipException::noCurrentOwner($this); + } + + if ($actingUser !== null && $actingUser->id !== $currentOwner->id) { + throw WorkspaceOwnershipException::unauthorised($this, $actingUser); + } + + if ($newOwner->id === $currentOwner->id) { + throw WorkspaceOwnershipException::alreadyOwner($this, $newOwner); + } + + $newOwnerMembership = $this->members() + ->where('user_id', $newOwner->id) + ->first(); + + if (! $newOwnerMembership) { + throw WorkspaceOwnershipException::notAMember($this, $newOwner); + } + + $currentOwnerMembership = $this->members() + ->where('user_id', $currentOwner->id) + ->first(); + + DB::transaction(function () use ($currentOwnerMembership, $newOwnerMembership) { + $currentOwnerMembership->update(['role' => WorkspaceMember::ROLE_ADMIN]); + + $adminTeam = $this->teams()->where('slug', WorkspaceTeam::TEAM_ADMIN)->first(); + if ($adminTeam && $currentOwnerMembership->team_id) { + $currentOwnerMembership->update(['team_id' => $adminTeam->id]); + } + + $newOwnerMembership->update(['role' => WorkspaceMember::ROLE_OWNER]); + + $ownerTeam = $this->teams()->where('slug', WorkspaceTeam::TEAM_OWNER)->first(); + if ($ownerTeam) { + $newOwnerMembership->update(['team_id' => $ownerTeam->id]); + } + }); + + WorkspaceOwnershipTransferred::dispatch($this, $currentOwner, $newOwner); + + return $this; + } + /** * Get the default team for new members. */ diff --git a/tests/Feature/WorkspaceOwnershipTransferTest.php b/tests/Feature/WorkspaceOwnershipTransferTest.php new file mode 100644 index 0000000..e8373c8 --- /dev/null +++ b/tests/Feature/WorkspaceOwnershipTransferTest.php @@ -0,0 +1,250 @@ +create(); + $member = User::factory()->create(); + $workspace = Workspace::factory()->create(); + + $workspace->users()->attach($owner->id, ['role' => 'owner']); + $workspace->users()->attach($member->id, ['role' => 'member']); + + $workspace->transferOwnership($member, $owner); + + // Previous owner should now be admin + $this->assertEquals( + 'admin', + $workspace->members()->where('user_id', $owner->id)->first()->role + ); + + // New owner should now be owner + $this->assertEquals( + 'owner', + $workspace->members()->where('user_id', $member->id)->first()->role + ); + + Event::assertDispatched(WorkspaceOwnershipTransferred::class, function ($event) use ($workspace, $owner, $member) { + return $event->workspace->id === $workspace->id + && $event->previousOwner->id === $owner->id + && $event->newOwner->id === $member->id; + }); + } + + public function test_owner_can_transfer_ownership_to_admin(): void + { + Event::fake(); + + $owner = User::factory()->create(); + $admin = User::factory()->create(); + $workspace = Workspace::factory()->create(); + + $workspace->users()->attach($owner->id, ['role' => 'owner']); + $workspace->users()->attach($admin->id, ['role' => 'admin']); + + $workspace->transferOwnership($admin, $owner); + + $this->assertEquals( + 'admin', + $workspace->members()->where('user_id', $owner->id)->first()->role + ); + $this->assertEquals( + 'owner', + $workspace->members()->where('user_id', $admin->id)->first()->role + ); + } + + public function test_transfer_without_acting_user_skips_auth_check(): void + { + Event::fake(); + + $owner = User::factory()->create(); + $member = User::factory()->create(); + $workspace = Workspace::factory()->create(); + + $workspace->users()->attach($owner->id, ['role' => 'owner']); + $workspace->users()->attach($member->id, ['role' => 'member']); + + // No acting user passed - should succeed (for programmatic use) + $workspace->transferOwnership($member); + + $this->assertEquals( + 'owner', + $workspace->members()->where('user_id', $member->id)->first()->role + ); + } + + public function test_non_owner_cannot_transfer_ownership(): void + { + $owner = User::factory()->create(); + $admin = User::factory()->create(); + $member = User::factory()->create(); + $workspace = Workspace::factory()->create(); + + $workspace->users()->attach($owner->id, ['role' => 'owner']); + $workspace->users()->attach($admin->id, ['role' => 'admin']); + $workspace->users()->attach($member->id, ['role' => 'member']); + + $this->expectException(WorkspaceOwnershipException::class); + $this->expectExceptionCode(403); + + $workspace->transferOwnership($member, $admin); + } + + public function test_cannot_transfer_to_non_member(): void + { + $owner = User::factory()->create(); + $nonMember = User::factory()->create(); + $workspace = Workspace::factory()->create(); + + $workspace->users()->attach($owner->id, ['role' => 'owner']); + + $this->expectException(WorkspaceOwnershipException::class); + $this->expectExceptionCode(422); + + $workspace->transferOwnership($nonMember, $owner); + } + + public function test_cannot_transfer_to_self(): void + { + $owner = User::factory()->create(); + $workspace = Workspace::factory()->create(); + + $workspace->users()->attach($owner->id, ['role' => 'owner']); + + $this->expectException(WorkspaceOwnershipException::class); + $this->expectExceptionCode(422); + + $workspace->transferOwnership($owner, $owner); + } + + public function test_transfer_updates_team_assignments(): void + { + Event::fake(); + + $owner = User::factory()->create(); + $member = User::factory()->create(); + $workspace = Workspace::factory()->create(); + + // Create system teams + $ownerTeam = WorkspaceTeam::create([ + 'workspace_id' => $workspace->id, + 'name' => 'Owner', + 'slug' => WorkspaceTeam::TEAM_OWNER, + 'is_system' => true, + 'colour' => 'violet', + 'sort_order' => 1, + ]); + + $adminTeam = WorkspaceTeam::create([ + 'workspace_id' => $workspace->id, + 'name' => 'Admin', + 'slug' => WorkspaceTeam::TEAM_ADMIN, + 'is_system' => true, + 'colour' => 'blue', + 'sort_order' => 2, + ]); + + $memberTeam = WorkspaceTeam::create([ + 'workspace_id' => $workspace->id, + 'name' => 'Member', + 'slug' => WorkspaceTeam::TEAM_MEMBER, + 'is_system' => true, + 'is_default' => true, + 'colour' => 'emerald', + 'sort_order' => 3, + ]); + + // Attach users with teams + $workspace->users()->attach($owner->id, [ + 'role' => 'owner', + 'team_id' => $ownerTeam->id, + ]); + $workspace->users()->attach($member->id, [ + 'role' => 'member', + 'team_id' => $memberTeam->id, + ]); + + $workspace->transferOwnership($member, $owner); + + // Previous owner should be in admin team + $ownerMembership = $workspace->members()->where('user_id', $owner->id)->first(); + $this->assertEquals('admin', $ownerMembership->role); + $this->assertEquals($adminTeam->id, $ownerMembership->team_id); + + // New owner should be in owner team + $memberMembership = $workspace->members()->where('user_id', $member->id)->first(); + $this->assertEquals('owner', $memberMembership->role); + $this->assertEquals($ownerTeam->id, $memberMembership->team_id); + } + + public function test_transfer_dispatches_event(): void + { + Event::fake(); + + $owner = User::factory()->create(); + $member = User::factory()->create(); + $workspace = Workspace::factory()->create(); + + $workspace->users()->attach($owner->id, ['role' => 'owner']); + $workspace->users()->attach($member->id, ['role' => 'member']); + + $workspace->transferOwnership($member, $owner); + + Event::assertDispatched(WorkspaceOwnershipTransferred::class, 1); + } + + public function test_failed_transfer_does_not_dispatch_event(): void + { + Event::fake(); + + $owner = User::factory()->create(); + $nonMember = User::factory()->create(); + $workspace = Workspace::factory()->create(); + + $workspace->users()->attach($owner->id, ['role' => 'owner']); + + try { + $workspace->transferOwnership($nonMember, $owner); + } catch (WorkspaceOwnershipException) { + // Expected + } + + Event::assertNotDispatched(WorkspaceOwnershipTransferred::class); + } + + public function test_transfer_returns_workspace_instance(): void + { + Event::fake(); + + $owner = User::factory()->create(); + $member = User::factory()->create(); + $workspace = Workspace::factory()->create(); + + $workspace->users()->attach($owner->id, ['role' => 'owner']); + $workspace->users()->attach($member->id, ['role' => 'member']); + + $result = $workspace->transferOwnership($member, $owner); + + $this->assertInstanceOf(Workspace::class, $result); + $this->assertEquals($workspace->id, $result->id); + } +} -- 2.45.3