diff --git a/src/Models/RoleMember.php b/src/Models/RoleMember.php index a72e8e8..5f83036 100644 --- a/src/Models/RoleMember.php +++ b/src/Models/RoleMember.php @@ -13,6 +13,9 @@ class RoleMember extends MorphPivot protected $fillable = [ 'role_id', 'member', + // The morph columns: without them RoleMember::create() silently dropped the member. + 'member_type', + 'member_id', 'context', 'expires_at', 'created_at', diff --git a/src/Traits/HasRoles.php b/src/Traits/HasRoles.php index 24af7f8..2975358 100644 --- a/src/Traits/HasRoles.php +++ b/src/Traits/HasRoles.php @@ -69,10 +69,12 @@ trait HasRoles * Assigns the role to the memberable * * @param int|string|Role $role + * @param array $context Stored on the pivot row (e.g. who assigned it and why). Read back with roleAssignment(). + * @param \DateTimeInterface|null $expiresAt The assignment stops counting after this moment; null never expires. * * @return $this */ - public function assignRole(string|Role $role, int $max_times = 1) + public function assignRole(string|Role $role, int $max_times = 1, array $context = [], ?\DateTimeInterface $expiresAt = null) { if (self::isRoleIdString($role)) { $role = config('roles.models.role', \Blax\Roles\Models\Role::class)::find($role); @@ -91,7 +93,11 @@ trait HasRoles } if ($role instanceof Role) { - $this->roles()->attach($role); + $pivot = array_filter([ + 'context' => $context ?: null, + 'expires_at' => $expiresAt, + ], fn ($value) => $value !== null); + $this->roles()->attach($role, $pivot); } else { throw new \InvalidArgumentException('Role must be a string, numeric ID, or an instance of Role.'); } @@ -99,6 +105,37 @@ trait HasRoles return $this; } + /** + * The member's active assignment of a role: the pivot row with its context, + * expires_at and created_at (when it was given). Null when the member doesn't + * hold the role or the assignment has expired. With several active rows + * (assignRole with $max_times > 1) it returns the newest. + * + * @param int|string|Role $role A role id, a slug, or a Role + */ + public function roleAssignment(string|Role $role): ?\Blax\Roles\Models\RoleMember + { + $roleModel = config('roles.models.role', \Blax\Roles\Models\Role::class); + if (self::isRoleIdString($role)) { + $role = $roleModel::find($role); + } elseif (is_string($role)) { + $role = $roleModel::where('slug', $role)->first(); + } + + if (! $role) { + return null; + } + + $roleMemberModel = config('roles.models.role_member', \Blax\Roles\Models\RoleMember::class); + + return $roleMemberModel::query() + ->where('role_id', $role->id) + ->where('member_type', $this->getMorphClass()) + ->where('member_id', (string) $this->getKey()) + ->latest() + ->first(); + } + /** * Removes the role from the memberable * diff --git a/tests/Unit/HasRolesTest.php b/tests/Unit/HasRolesTest.php index 337ef4f..07d1e27 100644 --- a/tests/Unit/HasRolesTest.php +++ b/tests/Unit/HasRolesTest.php @@ -4,6 +4,7 @@ namespace Blax\Roles\Tests\Unit; use Blax\Roles\Models\Permission; use Blax\Roles\Models\Role; +use Blax\Roles\Models\RoleMember; use Blax\Roles\RolesServiceProvider; use Illuminate\Foundation\Testing\RefreshDatabase; use Illuminate\Support\Facades\DB; @@ -642,4 +643,85 @@ class HasRolesTest extends TestCase $this->assertNotNull($membership->expires_at); } + + // ─── assignRole context + expiry, roleAssignment ───────────── + + public function test_assign_role_stores_context_and_expiry(): void + { + $user = User::factory()->create(); + $role = Role::create(['name' => 'Muted', 'slug' => 'muted']); + $until = now()->addDays(3)->startOfSecond(); + + $user->assignRole($role, context: ['by' => 'admin:1', 'reason' => 'spam'], expiresAt: $until); + + $assignment = $user->roleAssignment('muted'); + $this->assertSame(['by' => 'admin:1', 'reason' => 'spam'], $assignment->context); + $this->assertTrue($assignment->expires_at->equalTo($until)); + $this->assertTrue($user->hasRole('muted')); + } + + public function test_assign_role_without_context_keeps_the_pivot_fields_empty(): void + { + $user = User::factory()->create(); + $role = Role::create(['name' => 'Plain', 'slug' => 'plain']); + + $user->assignRole($role); + + $row = DB::table(config('roles.table_names.role_member'))->where('role_id', $role->id)->first(); + $this->assertNull($row->context); + $this->assertNull($row->expires_at); + } + + public function test_assign_role_with_context_still_respects_max_times(): void + { + $user = User::factory()->create(); + $role = Role::create(['name' => 'Once', 'slug' => 'once']); + + $user->assignRole($role, context: ['n' => 1]); + $user->assignRole($role, context: ['n' => 2]); + + $this->assertSame(1, DB::table(config('roles.table_names.role_member'))->where('role_id', $role->id)->count()); + $this->assertSame(['n' => 1], $user->roleAssignment($role)->context); + } + + public function test_role_assignment_tells_when_the_role_was_given(): void + { + $this->travelTo(now()->subHour()); + $user = User::factory()->create(); + $role = Role::create(['name' => 'Since', 'slug' => 'since']); + $user->assignRole($role); + $given = now()->startOfSecond(); + $this->travelBack(); + + $this->assertTrue($user->roleAssignment($role->id)->created_at->equalTo($given)); + } + + public function test_role_assignment_is_null_when_not_held_or_expired(): void + { + $user = User::factory()->create(); + $role = Role::create(['name' => 'Gone', 'slug' => 'gone']); + + $this->assertNull($user->roleAssignment('gone')); + $this->assertNull($user->roleAssignment('no-such-role')); + + $user->assignRole($role, expiresAt: now()->subMinute()); + $this->assertNull($user->roleAssignment('gone')); + $this->assertFalse($user->hasRole('gone')); + } + + public function test_role_member_can_be_created_for_any_model(): void + { + $user = User::factory()->create(); + $role = Role::create(['name' => 'Direct', 'slug' => 'direct']); + + RoleMember::create([ + 'role_id' => $role->id, + 'member_type' => $user->getMorphClass(), + 'member_id' => (string) $user->id, + 'context' => ['source' => 'direct'], + ]); + + $this->assertTrue($user->hasRole('direct')); + $this->assertSame(['source' => 'direct'], $user->roleAssignment('direct')->context); + } }