Expand specialty RBAC with dedicated specialist roles and support access.
Deploy Ladill Care / deploy (push) Successful in 41s

Add cardiologist/ENT/derm/ophthalmology/ortho/podiatry/fertility roles and
manage/refer/view matrices so GPs, nurses, lab, pharmacy, and reception get
realistic module access beyond single-app specialists.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
isaacclad
2026-07-20 09:21:26 +00:00
co-authored by Cursor
parent ac870bcf33
commit 938f351ede
9 changed files with 451 additions and 116 deletions
+182 -15
View File
@@ -5,11 +5,13 @@ namespace App\Services\Care;
use App\Models\Member;
/**
* Care RBAC: role abilities + primary specialty apps.
* Care RBAC: role abilities + specialty manage / refer / view apps.
*
* Authorization must use can() / primaryAppsFor() never role string checks
* like isDoctor. Legacy `doctor` aliases to general_physician; `nurse` is a
* limited floor role (ED / theatre / dialysis nurses are separate).
* Authorization must use can() / primaryAppsFor() / referAppsFor() /
* viewAppsFor() never role string checks like isDoctor. Legacy `doctor`
* aliases to general_physician for abilities; desk specialists are narrowed
* by SpecialtyModuleService keyword matching. `nurse` is a limited floor role
* (ED / theatre / dialysis nurses are separate).
*
* Inheritance: lab_manager lab_technician; blood_bank_manager blood_bank_officer.
*/
@@ -28,7 +30,7 @@ class CarePermissions
/**
* Specialty module keys each role may open (manage). Null = no specialty
* matrix entry (fall back to module config allowlists + desk keywords).
* Empty list = no specialty modules.
* Empty list = no specialty manage.
*
* @var array<string, list<string>|null>
*/
@@ -37,14 +39,20 @@ class CarePermissions
'hospital_admin' => null,
'emergency_physician' => ['emergency', 'radiology', 'pathology', 'blood_bank', 'cardiology'],
'ed_nurse' => ['emergency', 'blood_bank', 'radiology', 'pathology'],
'general_physician' => ['emergency', 'cardiology', 'pediatrics', 'ent', 'dermatology'],
'doctor' => ['emergency', 'cardiology', 'pediatrics', 'ent', 'dermatology'], // legacy → GP
'general_physician' => [
'emergency', 'cardiology', 'pediatrics', 'ent', 'dermatology',
'ophthalmology', 'vaccination', 'child_welfare',
],
'doctor' => [
'emergency', 'cardiology', 'pediatrics', 'ent', 'dermatology',
'ophthalmology', 'vaccination', 'child_welfare',
], // legacy → GP (desk specialists narrowed elsewhere)
'surgeon' => ['surgery', 'radiology', 'pathology', 'blood_bank'],
'theatre_nurse' => ['surgery', 'blood_bank'],
'radiologist' => ['radiology'],
'radiographer' => ['radiology'],
'lab_technician' => ['pathology'],
'lab_manager' => ['pathology'],
'lab_technician' => ['pathology', 'blood_bank'],
'lab_manager' => ['pathology', 'blood_bank'],
'pathologist' => ['pathology'],
'blood_bank_officer' => ['blood_bank'],
'blood_bank_manager' => ['blood_bank'],
@@ -55,14 +63,59 @@ class CarePermissions
'midwife' => ['maternity'],
'pediatrician' => ['pediatrics'],
'oncologist' => ['oncology', 'infusion'],
'cardiologist' => ['cardiology'],
'ent_specialist' => ['ent'],
'dermatologist' => ['dermatology'],
'ophthalmologist' => ['ophthalmology'],
'orthopedist' => ['orthopedics'],
'podiatrist' => ['podiatry'],
'fertility_specialist' => ['fertility'],
'dialysis_nurse' => ['renal'],
'ambulance_staff' => ['ambulance', 'emergency'],
'receptionist' => [], // registration / queues — refer via queue.manage
'receptionist' => [], // refer only — see roleReferApps
'billing_officer' => [],
'cashier' => [],
'accountant' => [],
'department_manager' => null, // department analytics — all enabled modules view
'nurse' => [], // legacy limited — no specialty manage
'nurse' => ['vaccination', 'child_welfare', 'infusion', 'maternity'],
];
/**
* Specialty modules a role may refer into (beyond manage). Empty / missing = none
* beyond primary apps. Manage always implies refer.
*
* @var array<string, list<string>>
*/
protected array $roleReferApps = [
'general_physician' => [
'dentistry', 'physiotherapy', 'maternity', 'psychiatry', 'orthopedics',
'oncology', 'renal', 'surgery', 'radiology', 'pathology', 'blood_bank',
'infusion', 'podiatry', 'fertility', 'ambulance',
],
'doctor' => [
'dentistry', 'physiotherapy', 'maternity', 'psychiatry', 'orthopedics',
'oncology', 'renal', 'surgery', 'radiology', 'pathology', 'blood_bank',
'infusion', 'podiatry', 'fertility', 'ambulance',
],
'nurse' => ['emergency', 'pediatrics', 'blood_bank'],
'pharmacist' => ['oncology', 'infusion', 'maternity', 'emergency'],
'receptionist' => [
'emergency', 'pediatrics', 'vaccination', 'child_welfare', 'dentistry',
'maternity', 'ambulance', 'ophthalmology', 'physiotherapy',
],
];
/**
* Specialty modules a role may view without refer/manage. Empty / missing = none
* beyond primary + refer. Manage and refer always imply view.
*
* @var array<string, list<string>>
*/
protected array $roleViewApps = [
'lab_technician' => ['radiology'],
'lab_manager' => ['radiology'],
'pathologist' => ['blood_bank'],
'pharmacist' => ['oncology', 'infusion', 'maternity', 'emergency'],
];
/**
@@ -106,6 +159,7 @@ class CarePermissions
'pathways.manage',
'emergency.queue.view', 'emergency.discharge',
'cardiology.view', 'pediatrics.view', 'ent.view', 'dermatology.view',
'ophthalmology.view',
'queue.patient_board',
'service_queues.console',
],
@@ -121,6 +175,7 @@ class CarePermissions
'pathways.manage',
'emergency.queue.view', 'emergency.discharge',
'cardiology.view', 'pediatrics.view', 'ent.view', 'dermatology.view',
'ophthalmology.view',
'queue.patient_board',
'service_queues.console',
],
@@ -162,6 +217,7 @@ class CarePermissions
'dashboard.view', 'patients.view',
'lab.view', 'lab.manage',
'pathology.view', 'pathology.result.enter',
'blood_bank.view', 'blood_bank.issue',
// cannot: pathology.result.approve, lab.catalog.manage
'service_queues.console',
],
@@ -170,6 +226,7 @@ class CarePermissions
'pathology.result.approve',
'analytics.lab.view',
'lab.admin',
'blood_bank.manage',
],
'pathologist' => [
'dashboard.view', 'patients.view', 'appointments.view',
@@ -252,6 +309,75 @@ class CarePermissions
'infusion.view', 'infusion.manage',
'service_queues.console',
],
'cardiologist' => [
'dashboard.view', 'patients.view', 'appointments.view',
'consultations.view', 'consultations.manage',
'investigations.request', 'prescriptions.manage',
'lab.view', 'lab.results.view',
'vitals.manage',
'assessments.view', 'assessments.capture', 'assessments.manage',
'pathways.manage',
'cardiology.view', 'cardiology.manage',
'service_queues.console',
],
'ent_specialist' => [
'dashboard.view', 'patients.view', 'appointments.view',
'consultations.view', 'consultations.manage',
'investigations.request', 'prescriptions.manage',
'vitals.manage',
'assessments.view', 'assessments.capture', 'assessments.manage',
'pathways.manage',
'ent.view', 'ent.manage',
'service_queues.console',
],
'dermatologist' => [
'dashboard.view', 'patients.view', 'appointments.view',
'consultations.view', 'consultations.manage',
'investigations.request', 'prescriptions.manage',
'vitals.manage',
'assessments.view', 'assessments.capture', 'assessments.manage',
'pathways.manage',
'dermatology.view', 'dermatology.manage',
'service_queues.console',
],
'ophthalmologist' => [
'dashboard.view', 'patients.view', 'appointments.view',
'consultations.view', 'consultations.manage',
'investigations.request', 'prescriptions.manage',
'vitals.manage',
'assessments.view', 'assessments.capture', 'assessments.manage',
'pathways.manage',
'ophthalmology.view', 'ophthalmology.manage',
'service_queues.console',
],
'orthopedist' => [
'dashboard.view', 'patients.view', 'appointments.view',
'consultations.view', 'consultations.manage',
'investigations.request', 'prescriptions.manage',
'vitals.manage',
'assessments.view', 'assessments.capture', 'assessments.manage',
'pathways.manage',
'orthopedics.view', 'orthopedics.manage',
'service_queues.console',
],
'podiatrist' => [
'dashboard.view', 'patients.view', 'appointments.view',
'consultations.view', 'consultations.manage',
'vitals.manage',
'assessments.view', 'assessments.capture',
'podiatry.view', 'podiatry.manage',
'service_queues.console',
],
'fertility_specialist' => [
'dashboard.view', 'patients.view', 'appointments.view',
'consultations.view', 'consultations.manage',
'investigations.request', 'prescriptions.manage',
'vitals.manage',
'assessments.view', 'assessments.capture', 'assessments.manage',
'pathways.manage',
'fertility.view', 'fertility.manage',
'service_queues.console',
],
'dialysis_nurse' => [
'dashboard.view', 'patients.view', 'appointments.view',
'consultations.view',
@@ -301,7 +427,7 @@ class CarePermissions
'reports.finance.view',
'service_queues.console',
],
// Legacy nurse — floor vitals / assessments only; no specialty modules.
// Legacy floor nurse — vaccination / CWC / infusion / maternity manage.
'nurse' => [
'dashboard.view', 'patients.view', 'appointments.view',
'consultations.view', 'vitals.manage',
@@ -317,8 +443,10 @@ class CarePermissions
'lab_technician', 'lab_manager', 'pathologist',
'blood_bank_officer', 'blood_bank_manager',
'pharmacist', 'dentist', 'psychiatrist', 'physiotherapist',
'midwife', 'pediatrician', 'oncologist', 'dialysis_nurse',
'ambulance_staff', 'receptionist', 'cashier', 'billing_officer',
'midwife', 'pediatrician', 'oncologist',
'cardiologist', 'ent_specialist', 'dermatologist', 'ophthalmologist',
'orthopedist', 'podiatrist', 'fertility_specialist',
'dialysis_nurse', 'ambulance_staff', 'receptionist', 'cashier', 'billing_officer',
'nurse',
];
@@ -327,6 +455,8 @@ class CarePermissions
'emergency_physician', 'general_physician', 'doctor', 'surgeon',
'radiologist', 'pathologist', 'dentist', 'psychiatrist',
'physiotherapist', 'midwife', 'pediatrician', 'oncologist',
'cardiologist', 'ent_specialist', 'dermatologist', 'ophthalmologist',
'orthopedist', 'podiatrist', 'fertility_specialist',
'ed_nurse', 'theatre_nurse', 'dialysis_nurse', 'ambulance_staff',
'radiographer', 'lab_technician', 'lab_manager',
'blood_bank_officer', 'blood_bank_manager', 'pharmacist', 'nurse',
@@ -381,7 +511,7 @@ class CarePermissions
}
/**
* Primary specialty module keys for this member's role.
* Primary specialty module keys for this member's role (manage).
*
* @return list<string>|null null = unrestricted specialty (admins / dept manager use other rules)
*/
@@ -403,6 +533,41 @@ class CarePermissions
return $this->rolePrimaryApps[$role];
}
/**
* Specialty modules this role may refer into (beyond manage).
*
* @return list<string>
*/
public function referAppsFor(?Member $member): array
{
if ($member === null || $this->isAdmin($member)) {
return [];
}
$role = (string) $member->role;
if ($role === 'department_manager') {
return [];
}
return array_values(array_unique($this->roleReferApps[$role] ?? []));
}
/**
* Specialty modules this role may view without refer/manage.
*
* @return list<string>
*/
public function viewAppsFor(?Member $member): array
{
if ($member === null || $this->isAdmin($member)) {
return [];
}
$role = (string) $member->role;
return array_values(array_unique($this->roleViewApps[$role] ?? []));
}
/**
* Whether the role matrix lists this specialty module as a primary app.
*/
@@ -485,6 +650,8 @@ class CarePermissions
'emergency_physician', 'general_physician', 'doctor', 'surgeon',
'radiologist', 'pathologist', 'dentist', 'psychiatrist',
'pediatrician', 'oncologist', 'physiotherapist', 'midwife',
'cardiologist', 'ent_specialist', 'dermatologist', 'ophthalmologist',
'orthopedist', 'podiatrist', 'fertility_specialist',
], true);
}
+18 -15
View File
@@ -358,7 +358,8 @@ class SpecialtyModuleService
*/
public function memberCanView(Organization $organization, ?Member $member, string $key): bool
{
if ($this->memberCanManage($organization, $member, $key)) {
if ($this->memberCanManage($organization, $member, $key)
|| $this->memberCanRefer($organization, $member, $key)) {
return true;
}
@@ -380,14 +381,14 @@ class SpecialtyModuleService
return $this->specialistBelongsToModule($organization, $member, $key);
}
// Legacy doctor (non-desk) / GP: keep refer/view on restricted modules
// outside primary manage via config view_roles when role is doctor/GP.
if (in_array((string) $member->role, ['doctor', 'general_physician'], true)
&& ! in_array($key, $primaryApps, true)) {
$definition = $this->definition($key);
if ($definition && $this->roleListed($member, $definition['view_roles'] ?? [])) {
return true;
}
if (in_array($key, $permissions->viewAppsFor($member), true)) {
return true;
}
// Secondary: config view_roles (docs + legacy allowlists).
$definition = $this->definition($key);
if ($definition && $this->roleListed($member, $definition['view_roles'] ?? [])) {
return true;
}
return in_array($key, $primaryApps, true);
@@ -428,12 +429,14 @@ class SpecialtyModuleService
return $this->specialistBelongsToModule($organization, $member, $key);
}
if (in_array((string) $member->role, ['doctor', 'general_physician'], true)
&& ! in_array($key, $primaryApps, true)) {
$definition = $this->definition($key);
if ($definition && $this->roleListed($member, $definition['refer_roles'] ?? [])) {
return true;
}
if (in_array($key, $permissions->referAppsFor($member), true)) {
return true;
}
// Secondary: config refer_roles (docs + legacy allowlists).
$definition = $this->definition($key);
if ($definition && $this->roleListed($member, $definition['refer_roles'] ?? [])) {
return true;
}
return in_array($key, $primaryApps, true);