Limit specialty module visibility to matching desk specialists.
Deploy Ladill Care / deploy (push) Successful in 35s
Deploy Ladill Care / deploy (push) Successful in 35s
GPs keep broad general/limited manage and restricted view/refer; dentists and other desk specialists only see modules that match their specialty. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -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<string>|mixed $roles
|
||||
*/
|
||||
|
||||
@@ -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).
|
||||
*
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user