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:
parent
c51e4310b1
commit
a07bfb3fd2
4 changed files with 433 additions and 0 deletions
44
Events/WorkspaceOwnershipTransferred.php
Normal file
44
Events/WorkspaceOwnershipTransferred.php
Normal 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,
|
||||
) {}
|
||||
}
|
||||
76
Exceptions/WorkspaceOwnershipException.php
Normal file
76
Exceptions/WorkspaceOwnershipException.php
Normal 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
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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.
|
||||
*/
|
||||
|
|
|
|||
250
tests/Feature/WorkspaceOwnershipTransferTest.php
Normal file
250
tests/Feature/WorkspaceOwnershipTransferTest.php
Normal 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);
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue