Compare commits
1 commit
dev
...
feat/works
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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\Campaign;
|
||||||
use Core\Mod\Trust\Models\Notification;
|
use Core\Mod\Trust\Models\Notification;
|
||||||
use Core\Tenant\Database\Factories\WorkspaceFactory;
|
use Core\Tenant\Database\Factories\WorkspaceFactory;
|
||||||
|
use Core\Tenant\Events\WorkspaceOwnershipTransferred;
|
||||||
|
use Core\Tenant\Exceptions\WorkspaceOwnershipException;
|
||||||
use Core\Tenant\Notifications\WorkspaceInvitationNotification;
|
use Core\Tenant\Notifications\WorkspaceInvitationNotification;
|
||||||
use Core\Tenant\Services\EntitlementResult;
|
use Core\Tenant\Services\EntitlementResult;
|
||||||
use Core\Tenant\Services\EntitlementService;
|
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\HasMany;
|
||||||
use Illuminate\Database\Eloquent\Relations\MorphMany;
|
use Illuminate\Database\Eloquent\Relations\MorphMany;
|
||||||
use Illuminate\Support\Collection;
|
use Illuminate\Support\Collection;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
use Mod\Commerce\Models\Invoice;
|
use Mod\Commerce\Models\Invoice;
|
||||||
use Mod\Commerce\Models\Order;
|
use Mod\Commerce\Models\Order;
|
||||||
use Mod\Commerce\Models\PaymentMethod;
|
use Mod\Commerce\Models\PaymentMethod;
|
||||||
|
|
@ -152,6 +155,66 @@ class Workspace extends Model
|
||||||
->first();
|
->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.
|
* 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