diff --git a/.gitignore b/.gitignore index b39bae1..dde8410 100644 --- a/.gitignore +++ b/.gitignore @@ -4,4 +4,4 @@ vendor composer.lock .idea/ workbench -.phpunit.result.cache \ No newline at end of file +.phpunit.* \ No newline at end of file diff --git a/src/Traits/HasRoles.php b/src/Traits/HasRoles.php index f8962f3..b56bc58 100644 --- a/src/Traits/HasRoles.php +++ b/src/Traits/HasRoles.php @@ -21,7 +21,7 @@ trait HasRoles config('roles.models.role', \Blax\Roles\Models\Role::class), 'member', $pivotTable - )->withPivot('expires_at', 'created_at', 'updated_at') + )->withPivot('expires_at', 'context', 'created_at', 'updated_at') ->withTimestamps() ->where(function ($q) use ($pivotTable) { $q->where($pivotTable . '.expires_at', '>', now()) @@ -201,6 +201,71 @@ trait HasRoles return $this; } + /** + * Extend or create a role membership, scoped by an origin identifier stored in `context`. + * + * This allows multiple independent role_member records for the same role+user (e.g., + * one from a subscription and one from a day-pass purchase). Each origin tracks its + * own expiry independently. + * + * - If an active (non-expired) record with the same origin exists → extend it + * - If only expired records exist for this origin, or no record exists → create new + * + * @param int|string|Role $role The role to assign/extend + * @param int $hours Duration in hours + * @param string $originName Human-readable label (e.g., product name) + * @param string $originValue Lookup key (e.g., "ProductPrice:uuid") + * @param bool $forceExpiry If true, set expiration even on records with null expires_at + * @return $this + */ + public function extendOrAddRoleByOrigin(int|string|Role $role, int $hours, string $originName, string $originValue, bool $forceExpiry = false) + { + $hours = (int) $hours; + if ($hours <= 0) { + return $this; + } + + // Resolve role + if (is_string($role) && !is_numeric($role)) { + $role = config('roles.models.role', \Blax\Roles\Models\Role::class)::firstOrCreate([ + 'name' => $role, + ], [ + 'slug' => str()->slug($role) + ]); + } elseif (is_numeric($role)) { + $role = config('roles.models.role', \Blax\Roles\Models\Role::class)::find($role); + } elseif (!$role instanceof Role) { + throw new \InvalidArgumentException('Role must be a string, numeric ID, or an instance of Role.'); + } + + if (!$role) { + return $this; + } + + $roleMemberModel = config('roles.models.role_member', \Blax\Roles\Models\RoleMember::class); + + // Look for an active (non-expired) record from the same origin + $existing = $roleMemberModel::where('role_id', $role->id) + ->where('member_id', $this->getKey()) + ->where('member_type', $this->getMorphClass()) + ->whereJsonContains('context->origin_value', $originValue) + ->first(); + + if ($existing) { + $existing->extendByHours($hours, $forceExpiry); + } else { + $this->roles()->attach($role->id, [ + 'expires_at' => now()->addHours($hours), + 'context' => json_encode([ + 'origin_name' => $originName, + 'origin_value' => $originValue, + ]), + ]); + } + + return $this; + } + /** * Checks if the memberable has any of the given roles * diff --git a/tests/Unit/HasRolesTest.php b/tests/Unit/HasRolesTest.php index fc0140b..6cbb3e4 100644 --- a/tests/Unit/HasRolesTest.php +++ b/tests/Unit/HasRolesTest.php @@ -490,4 +490,148 @@ class HasRolesTest extends TestCase $this->assertEquals('admin', $role1->slug); $this->assertEquals('admin-1', $role2->slug); } + + // ─── extendOrAddRoleByOrigin ──────────────────────────────── + + public function test_extend_by_origin_creates_new_record_with_context(): void + { + $user = User::factory()->create(); + $role = Role::create(['name' => 'Premium', 'slug' => 'premium']); + + $user->extendOrAddRoleByOrigin($role, 24, 'Monthly Sub', 'ProductPrice:abc-123'); + + $membership = DB::table(config('roles.table_names.role_member')) + ->where('role_id', $role->id) + ->where('member_id', $user->id) + ->first(); + + $this->assertNotNull($membership); + $context = json_decode($membership->context, true); + $this->assertEquals('Monthly Sub', $context['origin_name']); + $this->assertEquals('ProductPrice:abc-123', $context['origin_value']); + } + + public function test_extend_by_origin_extends_active_record_from_same_origin(): void + { + $user = User::factory()->create(); + $role = Role::create(['name' => 'Premium', 'slug' => 'premium']); + + // First purchase — creates record + $user->extendOrAddRoleByOrigin($role, 24, 'Monthly Sub', 'ProductPrice:abc-123'); + + // Second purchase from same price — should extend, not create new + $user->extendOrAddRoleByOrigin($role, 24, 'Monthly Sub', 'ProductPrice:abc-123'); + + $count = DB::table(config('roles.table_names.role_member')) + ->where('role_id', $role->id) + ->where('member_id', $user->id) + ->count(); + + $this->assertEquals(1, $count); + + // Should be ~48 hours from now + $membership = DB::table(config('roles.table_names.role_member')) + ->where('role_id', $role->id) + ->where('member_id', $user->id) + ->first(); + + $expiresAt = \Carbon\Carbon::parse($membership->expires_at); + $this->assertTrue($expiresAt->gt(now()->addHours(40))); + } + + public function test_extend_by_origin_creates_separate_records_for_different_origins(): void + { + $user = User::factory()->create(); + $role = Role::create(['name' => 'Premium', 'slug' => 'premium']); + + // Subscription creates one record + $user->extendOrAddRoleByOrigin($role, 720, 'Monthly Sub', 'ProductPrice:sub-monthly'); + + // Day pass creates a separate record + $user->extendOrAddRoleByOrigin($role, 24, 'Day Pass', 'ProductPrice:day-pass'); + + $count = DB::table(config('roles.table_names.role_member')) + ->where('role_id', $role->id) + ->where('member_id', $user->id) + ->count(); + + // Both should coexist + $this->assertEquals(2, $count); + + // User should have the role + $this->assertTrue($user->hasRole($role)); + } + + public function test_extend_by_origin_creates_new_record_when_previous_expired(): void + { + $user = User::factory()->create(); + $role = Role::create(['name' => 'Premium', 'slug' => 'premium']); + + // Insert an expired record from a subscription + DB::table(config('roles.table_names.role_member'))->insert([ + 'role_id' => $role->id, + 'member_id' => $user->id, + 'member_type' => $user->getMorphClass(), + 'expires_at' => now()->subDay(), + 'context' => json_encode(['origin_name' => 'Monthly Sub', 'origin_value' => 'ProductPrice:sub-monthly']), + 'created_at' => now(), + 'updated_at' => now(), + ]); + + // Same price triggers again — expired record should NOT be extended, new one created + $user->extendOrAddRoleByOrigin($role, 720, 'Monthly Sub', 'ProductPrice:sub-monthly'); + + $count = DB::table(config('roles.table_names.role_member')) + ->where('role_id', $role->id) + ->where('member_id', $user->id) + ->count(); + + // Old expired + new active = 2 records + $this->assertEquals(2, $count); + + // User should have the role (active one) + $this->assertTrue($user->hasRole($role)); + } + + public function test_extend_by_origin_with_zero_hours_does_nothing(): void + { + $user = User::factory()->create(); + $role = Role::create(['name' => 'Zero', 'slug' => 'zero']); + + $user->extendOrAddRoleByOrigin($role, 0, 'Test', 'ProductPrice:test'); + + $count = DB::table(config('roles.table_names.role_member')) + ->where('role_id', $role->id) + ->where('member_id', $user->id) + ->count(); + + $this->assertEquals(0, $count); + } + + public function test_extend_by_origin_force_expiry_sets_expiration_on_null(): void + { + $user = User::factory()->create(); + $role = Role::create(['name' => 'Perm', 'slug' => 'perm']); + + // Insert a permanent (null expires_at) record with matching origin + DB::table(config('roles.table_names.role_member'))->insert([ + 'role_id' => $role->id, + 'member_id' => $user->id, + 'member_type' => $user->getMorphClass(), + 'expires_at' => null, + 'context' => json_encode(['origin_name' => 'Sub', 'origin_value' => 'ProductPrice:sub']), + 'created_at' => now(), + 'updated_at' => now(), + ]); + + // With forceExpiry=true, should set expiration even on null expires_at + $user->extendOrAddRoleByOrigin($role, 24, 'Sub', 'ProductPrice:sub', true); + + $membership = DB::table(config('roles.table_names.role_member')) + ->where('role_id', $role->id) + ->where('member_id', $user->id) + ->first(); + + $this->assertNotNull($membership->expires_at); + } }