diff --git a/composer.json b/composer.json index 20d6c3a..01c93fa 100644 --- a/composer.json +++ b/composer.json @@ -3,7 +3,7 @@ "description": "Workspace management package for Laravel applications", "type": "library", "license": "MIT", - "version": "1.1.1", + "version": "1.2.0", "authors": [ { "name": "WhileSmart", diff --git a/src/Http/Controllers/WorkspaceController.php b/src/Http/Controllers/WorkspaceController.php index 0fa93c4..e3831a7 100644 --- a/src/Http/Controllers/WorkspaceController.php +++ b/src/Http/Controllers/WorkspaceController.php @@ -169,7 +169,7 @@ public function members(Workspace $workspace): JsonResponse $members = $workspace->members() ->with(['roleAssignments' => function ($query) use ($workspace) { - $query->where('context_type', config('workspaces.workspace_model', Workspace::class)) + $query->where('context_type', $workspace->getMorphClass()) ->where('context_id', $workspace->id) ->with('role'); }]) @@ -446,7 +446,7 @@ protected function userCanAccess(Workspace $workspace): bool } foreach (Role::cases() as $role) { - if ($role->canAccess() && $user->hasRole($role->value, config('workspaces.workspace_model', Workspace::class), $workspace->id)) { + if ($role->canAccess() && $user->hasRole($role->value, $workspace->getMorphClass(), $workspace->id)) { return true; } } @@ -463,7 +463,7 @@ protected function userCanManage(Workspace $workspace): bool } foreach (Role::cases() as $role) { - if ($role->canManage() && $user->hasRole($role->value, config('workspaces.workspace_model', Workspace::class), $workspace->id)) { + if ($role->canManage() && $user->hasRole($role->value, $workspace->getMorphClass(), $workspace->id)) { return true; } } @@ -479,7 +479,7 @@ protected function userIsOwner(Workspace $workspace): bool return false; } - return $user->hasRole(Role::OWNER->value, config('workspaces.workspace_model', Workspace::class), $workspace->id); + return $user->hasRole(Role::OWNER->value, $workspace->getMorphClass(), $workspace->id); } protected function getUserRole(Workspace $workspace): ?string @@ -491,7 +491,7 @@ protected function getUserRole(Workspace $workspace): ?string } foreach (Role::byPrecedence() as $role) { - if ($user->hasRole($role->value, config('workspaces.workspace_model', Workspace::class), $workspace->id)) { + if ($user->hasRole($role->value, $workspace->getMorphClass(), $workspace->id)) { return $role->value; } } diff --git a/src/Models/Workspace.php b/src/Models/Workspace.php index 80e866e..b9139ed 100644 --- a/src/Models/Workspace.php +++ b/src/Models/Workspace.php @@ -70,8 +70,8 @@ public function members() 'id', 'id', 'assignable_id' - )->where('role_assignments.context_type', static::class) - ->where('role_assignments.assignable_type', config('workspaces.user_model', 'App\\Models\\User')); + )->where('role_assignments.context_type', $this->getMorphClass()) + ->where('role_assignments.assignable_type', $this->userMorphClass()); } public function invitations(): HasMany @@ -93,7 +93,7 @@ public function pendingInvitations(): HasMany public function roleAssignments() { return $this->hasMany('Whilesmart\\Roles\\Models\\RoleAssignment', 'context_id') - ->where('context_type', static::class); + ->where('context_type', $this->getMorphClass()); } public function getOwnersAttribute() @@ -103,7 +103,7 @@ public function getOwnersAttribute() $query->whereHas('role', function ($q) { $q->where('slug', 'workspace-owner'); }) - ->where('context_type', static::class) + ->where('context_type', $this->getMorphClass()) ->where('context_id', $this->id); }) ->get(); @@ -116,7 +116,7 @@ public function getAdminsAttribute() $query->whereHas('role', function ($q) { $q->whereIn('slug', ['workspace-owner', 'workspace-admin']); }) - ->where('context_type', static::class) + ->where('context_type', $this->getMorphClass()) ->where('context_id', $this->id); }) ->get(); @@ -148,4 +148,17 @@ public function setSetting(string $key, mixed $value): self return $this; } + + /** + * The name a user is stored under in a polymorphic column. + * + * A role assignment is written through the user's own morph relation, so + * this is what was stored, which is not always the configured class. + */ + private function userMorphClass(): string + { + $model = config('workspaces.user_model', 'App\\Models\\User'); + + return (new $model)->getMorphClass(); + } } diff --git a/src/Traits/HasWorkspaces.php b/src/Traits/HasWorkspaces.php index c98bb7a..bec67fe 100644 --- a/src/Traits/HasWorkspaces.php +++ b/src/Traits/HasWorkspaces.php @@ -17,6 +17,20 @@ public static function workspaceModel(): string return config('workspaces.workspace_model', Workspace::class); } + /** + * The name a workspace is stored under in a polymorphic column. + * + * Not the class name. A model may answer with something else, which is how + * a morph map works and how a subclass keeps the name its table already + * holds, and a comparison against the class name misses both. + */ + public static function workspaceMorphClass(): string + { + $model = static::workspaceModel(); + + return (new $model)->getMorphClass(); + } + public function ownedWorkspaces(): MorphMany { return $this->morphMany(static::workspaceModel(), 'owner'); @@ -31,8 +45,8 @@ public function workspaces() 'id', 'id', 'context_id' - )->where('role_assignments.assignable_type', static::class) - ->where('role_assignments.context_type', static::workspaceModel()); + )->where('role_assignments.assignable_type', $this->getMorphClass()) + ->where('role_assignments.context_type', static::workspaceMorphClass()); } public function pendingWorkspaceInvitations() @@ -90,7 +104,7 @@ public function joinWorkspace(Workspace $workspace, ?string $role = null): void $role = $role ?? Role::default()->value; if (method_exists($this, 'assignRole')) { - $this->assignRole($role, static::workspaceModel(), $workspace->id); + $this->assignRole($role, static::workspaceMorphClass(), $workspace->id); } } @@ -98,7 +112,7 @@ public function leaveWorkspace(Workspace $workspace): void { if (method_exists($this, 'removeRole')) { foreach (Role::values() as $role) { - $this->removeRole($role, static::workspaceModel(), $workspace->id); + $this->removeRole($role, static::workspaceMorphClass(), $workspace->id); } } } diff --git a/tests/Feature/SubclassedWorkspaceTest.php b/tests/Feature/SubclassedWorkspaceTest.php index 29c4761..258d2b8 100644 --- a/tests/Feature/SubclassedWorkspaceTest.php +++ b/tests/Feature/SubclassedWorkspaceTest.php @@ -2,6 +2,7 @@ namespace Tests\Feature; +use Illuminate\Database\Eloquent\Relations\Relation; use Illuminate\Support\Facades\Hash; use PHPUnit\Framework\Attributes\Test; use Tests\TestCase; @@ -15,8 +16,30 @@ class SubWorkspace extends Workspace protected $table = 'workspaces'; } +class RenamedWorkspace extends Workspace +{ + protected $table = 'workspaces'; + + public function getMorphClass(): string + { + return Workspace::class; + } +} + class SubclassedWorkspaceTest extends TestCase { + /** + * A morph map is global and outlives the test that set one, so anything + * after it would resolve against a map it never asked for. + */ + protected function tearDown(): void + { + Relation::morphMap([], false); + Relation::requireMorphMap(false); + + parent::tearDown(); + } + #[Test] public function members_resolve_when_the_workspace_model_is_subclassed(): void { @@ -47,4 +70,59 @@ public function members_resolve_when_the_workspace_model_is_subclassed(): void // base Workspace class while the assignment stores the subclass. $this->assertCount(1, $workspace->members()->get()); } + + #[Test] + public function members_resolve_when_a_subclass_keeps_the_name_already_stored(): void + { + config(['workspaces.workspace_model' => RenamedWorkspace::class]); + + $user = User::create([ + 'name' => 'Renamed Owner', + 'email' => 'renamed-'.uniqid().'@example.com', + 'password' => Hash::make('password'), + ]); + + $workspace = RenamedWorkspace::create([ + 'name' => 'Renamed Workspace', + 'type' => 'team', + 'owner_type' => User::class, + 'owner_id' => $user->id, + ]); + + RoleAssignment::create([ + 'assignable_type' => User::class, + 'assignable_id' => $user->id, + 'role_id' => Role::where('slug', 'owner')->first()->id, + 'context_type' => Workspace::class, + 'context_id' => $workspace->id, + ]); + + $this->assertCount(1, $workspace->members()->get()); + $this->assertContains($workspace->id, $user->workspaces()->pluck('workspaces.id')->all()); + } + + #[Test] + public function members_resolve_when_the_user_model_is_behind_a_morph_map(): void + { + Relation::morphMap(['user' => User::class]); + + $user = User::create([ + 'name' => 'Mapped Owner', + 'email' => 'mapped-'.uniqid().'@example.com', + 'password' => Hash::make('password'), + ]); + + $workspace = Workspace::create([ + 'name' => 'Mapped Workspace', + 'type' => 'team', + 'owner_type' => 'user', + 'owner_id' => $user->id, + ]); + + // Written the way a role assignment is written, through the user's own + // morph relation, which stores the alias rather than the class. + $user->assignRole('owner', $workspace->getMorphClass(), $workspace->id); + + $this->assertCount(1, $workspace->members()->get()); + } }