diff --git a/app/Services/Care/SpecialtyModuleService.php b/app/Services/Care/SpecialtyModuleService.php index 9650f51..1fadbc9 100644 --- a/app/Services/Care/SpecialtyModuleService.php +++ b/app/Services/Care/SpecialtyModuleService.php @@ -214,16 +214,11 @@ class SpecialtyModuleService return 'manage'; } - $canRefer = $this->roleListed($member, $definition['refer_roles'] ?? []); - $canView = $this->roleListed($member, $definition['view_roles'] ?? []); - - if ($canRefer && $canView) { + // Use gated helpers so desk specialists do not inherit GP view/refer on other modules. + if ($this->memberCanRefer($organization, $member, $key)) { return 'refer'; } - if ($canRefer) { - return 'refer'; - } - if ($canView) { + if ($this->memberCanView($organization, $member, $key)) { return 'view'; } @@ -256,6 +251,12 @@ class SpecialtyModuleService return false; } + // Desk specialists only manage modules that match their specialty / assignment. + if ($this->isDeskSpecialist($organization, $member) + && ! $this->specialistBelongsToModule($organization, $member, $key)) { + return false; + } + $tier = $this->accessTier($key); $role = (string) $member->role; $manageRoles = $definition['roles'] ?? []; @@ -266,14 +267,14 @@ class SpecialtyModuleService return true; } if ($role === 'doctor' && $this->roleListed($member, $manageRoles)) { - return $this->doctorAssignedToModule($organization, $member, $key) - || $this->doctorSpecialtyMatchesModule($organization, $member, $key); + return $this->specialistBelongsToModule($organization, $member, $key); } return false; } - // general + limited: role allowlist is enough (GPs = doctor without specialty desk). + // general + limited: role allowlist is enough for GPs and non-doctor roles. + // Desk specialists already passed the match gate above. return $this->roleListed($member, $manageRoles); } @@ -290,6 +291,12 @@ class SpecialtyModuleService return false; } + // Desk specialists do not get cross-module view; only their matching desks. + if ($this->isDeskSpecialist($organization, $member) + && ! $this->specialistBelongsToModule($organization, $member, $key)) { + return false; + } + $definition = $this->definition($key); if (! $definition) { return false; @@ -311,6 +318,12 @@ class SpecialtyModuleService return false; } + // Desk specialists do not get cross-module referral nav; GPs keep refer_roles. + if ($this->isDeskSpecialist($organization, $member) + && ! $this->specialistBelongsToModule($organization, $member, $key)) { + return false; + } + $definition = $this->definition($key); if (! $definition) { return false; @@ -319,6 +332,34 @@ class SpecialtyModuleService return $this->roleListed($member, $definition['refer_roles'] ?? []); } + /** + * Doctor with a specialty desk: non-GP specialty matching a catalog module, or + * assigned to a provisioned specialty department. GPs and non-doctors are false. + */ + public function isDeskSpecialist(Organization $organization, Member $member): bool + { + if ((string) $member->role !== 'doctor') { + return false; + } + + foreach (array_keys($this->catalog()) as $key) { + if ($this->specialistBelongsToModule($organization, $member, $key)) { + return true; + } + } + + return false; + } + + /** + * Whether this doctor belongs to the module via department assignment or specialty keywords. + */ + public function specialistBelongsToModule(Organization $organization, Member $member, string $key): bool + { + return $this->doctorAssignedToModule($organization, $member, $key) + || $this->doctorSpecialtyMatchesModule($organization, $member, $key); + } + /** * @param list|mixed $roles */ diff --git a/config/care_specialty_modules.php b/config/care_specialty_modules.php index c984411..3bcc494 100644 --- a/config/care_specialty_modules.php +++ b/config/care_specialty_modules.php @@ -5,10 +5,14 @@ * Pro-gated via plans.*.features specialty_modules. * * Access tiers (RBAC via Care member roles — do not invent new roles): - * - general: broad day-to-day access for listed roles (GPs = doctor) - * - limited: GPs + nurses (and listed roles) without specialist assignment + * - general: broad day-to-day access for listed roles (GPs = doctor without specialty desk) + * - limited: GPs + nurses (and listed roles); desk specialists only when specialty keywords match * - restricted: specialist doctors (department / specialty keywords) + optional support_roles; - * other clinicians use view_roles / refer_roles + * GPs use view_roles / refer_roles; desk specialists only see matching modules + * + * Desk specialists (doctor whose practitioner specialty matches specialist_keywords, or who is + * assigned to the module department) only see modules relevant to their work — non-matching + * modules are access "none" (hidden from sidebar). Nurses / receptionists keep role allowlists. * * default_on_paid_plans: always on for Pro/Enterprise (sidebar + access). * diff --git a/tests/Feature/CareSpecialtyAccessTest.php b/tests/Feature/CareSpecialtyAccessTest.php index ca16497..579edf6 100644 --- a/tests/Feature/CareSpecialtyAccessTest.php +++ b/tests/Feature/CareSpecialtyAccessTest.php @@ -120,6 +120,7 @@ class CareSpecialtyAccessTest extends TestCase 'is_active' => true, ]); + $this->assertFalse($this->modules->isDeskSpecialist($this->organization, $member)); $this->assertTrue($this->modules->memberCanManage($this->organization, $member, 'emergency')); $this->assertTrue($this->modules->memberCanManage($this->organization, $member, 'dentistry')); $this->assertFalse($this->modules->memberCanManage($this->organization, $member, 'cardiology')); @@ -146,6 +147,55 @@ class CareSpecialtyAccessTest extends TestCase $this->assertTrue($this->modules->memberCanManage($this->organization, $member, 'cardiology')); $this->assertSame('manage', $this->modules->memberAccessLevel($this->organization, $member, 'cardiology')); + // Desk specialists do not keep GP-wide general/limited manage. + $this->assertSame('none', $this->modules->memberAccessLevel($this->organization, $member, 'emergency')); + $this->assertSame('none', $this->modules->memberAccessLevel($this->organization, $member, 'dentistry')); + } + + public function test_dentistry_specialist_only_sees_matching_modules(): void + { + [$doctor, $member] = $this->makeStaff('doctor', 'dentist1'); + Practitioner::create([ + 'owner_ref' => $this->owner->public_id, + 'organization_id' => $this->organization->id, + 'branch_id' => $this->branch->id, + 'member_id' => $member->id, + 'user_ref' => $doctor->public_id, + 'name' => 'Dr Dentist', + 'specialty' => 'Dentistry', + 'is_active' => true, + ]); + + $this->assertTrue($this->modules->isDeskSpecialist($this->organization, $member)); + $this->assertTrue($this->modules->memberCanManage($this->organization, $member, 'dentistry')); + $this->assertSame('manage', $this->modules->memberAccessLevel($this->organization, $member, 'dentistry')); + + foreach (['emergency', 'blood_bank', 'cardiology', 'infusion', 'pathology'] as $key) { + $this->assertSame( + 'none', + $this->modules->memberAccessLevel($this->organization, $member, $key), + "Dentistry specialist should not see [{$key}]", + ); + $this->assertFalse($this->modules->memberCanManage($this->organization, $member, $key)); + $this->assertFalse($this->modules->memberCanView($this->organization, $member, $key)); + $this->assertFalse($this->modules->memberCanRefer($this->organization, $member, $key)); + } + + $enabledKeys = collect($this->modules->enabledModulesForMember($this->organization, $member)) + ->pluck('key') + ->all(); + $this->assertContains('dentistry', $enabledKeys); + $this->assertNotContains('emergency', $enabledKeys); + $this->assertNotContains('blood_bank', $enabledKeys); + $this->assertNotContains('cardiology', $enabledKeys); + + $this->actingAs($doctor) + ->get(route('care.dashboard')) + ->assertOk() + ->assertSee('Dentistry') + ->assertDontSee('Emergency') + ->assertDontSee('Cardiology') + ->assertDontSee('Blood Bank'); } public function test_gp_can_refer_into_restricted_specialty_queue(): void diff --git a/tests/Feature/CareSpecialtyModulesTest.php b/tests/Feature/CareSpecialtyModulesTest.php index 020182d..961e8b6 100644 --- a/tests/Feature/CareSpecialtyModulesTest.php +++ b/tests/Feature/CareSpecialtyModulesTest.php @@ -302,7 +302,7 @@ class CareSpecialtyModulesTest extends TestCase 'dentistry', ), ); - // Restricted modules stay off for GPs without specialty assignment. + // Desk specialists only see matching modules — no cross-specialty refer. $this->assertFalse( app(SpecialtyModuleService::class)->memberCanManage( $this->organization->fresh(), @@ -311,13 +311,21 @@ class CareSpecialtyModulesTest extends TestCase ), ); app(SpecialtyModuleService::class)->activate($this->organization->fresh(), $this->owner->public_id, 'cardiology'); - $this->assertTrue( + $this->assertFalse( app(SpecialtyModuleService::class)->memberCanRefer( $this->organization->fresh(), $member, 'cardiology', ), ); + $this->assertSame( + 'none', + app(SpecialtyModuleService::class)->memberAccessLevel( + $this->organization->fresh(), + $member, + 'emergency', + ), + ); } public function test_emergency_and_blood_bank_are_default_on_pro(): void