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) <noreply@anthropic.com>
This commit is contained in:
Claude 2026-03-24 13:56:18 +00:00
parent c51e4310b1
commit a07bfb3fd2
No known key found for this signature in database
GPG key ID: AF404715446AEB41
4 changed files with 433 additions and 0 deletions

View file

@ -0,0 +1,44 @@
<?php
declare(strict_types=1);
namespace Core\Tenant\Events;
use Core\Tenant\Models\User;
use Core\Tenant\Models\Workspace;
use Illuminate\Foundation\Events\Dispatchable;
/**
* Event dispatched when workspace ownership is transferred to another member.
*
* ## Event Payload
*
* - `workspace`: The affected Workspace model
* - `previousOwner`: The User who was the previous owner (now demoted to admin)
* - `newOwner`: The User who is the new owner
*
* ## Usage
*
* ```php
* Event::listen(WorkspaceOwnershipTransferred::class, function ($event) {
* // Notify relevant parties, log audit trail, etc.
* });
* ```
*/
class WorkspaceOwnershipTransferred
{
use Dispatchable;
/**
* Create a new event instance.
*
* @param Workspace $workspace The workspace whose ownership was transferred
* @param User $previousOwner The user who was the previous owner
* @param User $newOwner The user who is now the owner
*/
public function __construct(
public readonly Workspace $workspace,
public readonly User $previousOwner,
public readonly User $newOwner,
) {}
}

View file

@ -0,0 +1,76 @@
<?php
declare(strict_types=1);
namespace Core\Tenant\Exceptions;
use Core\Tenant\Models\User;
use Core\Tenant\Models\Workspace;
use Exception;
/**
* Exception thrown when a workspace ownership transfer fails.
*/
class WorkspaceOwnershipException extends Exception
{
public function __construct(
string $message,
public readonly ?Workspace $workspace = null,
public readonly ?User $user = null,
int $code = 403,
?\Throwable $previous = null
) {
parent::__construct($message, $code, $previous);
}
/**
* The new owner is not a member of the workspace.
*/
public static function notAMember(Workspace $workspace, User $user): self
{
return new self(
message: "User {$user->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
);
}
}

View file

@ -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.
*/

View file

@ -0,0 +1,250 @@
<?php
declare(strict_types=1);
namespace Core\Tenant\Tests\Feature;
use Core\Tenant\Events\WorkspaceOwnershipTransferred;
use Core\Tenant\Exceptions\WorkspaceOwnershipException;
use Core\Tenant\Models\User;
use Core\Tenant\Models\Workspace;
use Core\Tenant\Models\WorkspaceTeam;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Event;
use Tests\TestCase;
class WorkspaceOwnershipTransferTest extends TestCase
{
use RefreshDatabase;
public function test_owner_can_transfer_ownership_to_member(): 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);
// 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);
}
}