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);
}
+12 -9
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,15 +381,15 @@ 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)) {
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,13 +429,15 @@ class SpecialtyModuleService
return $this->specialistBelongsToModule($organization, $member, $key);
}
if (in_array((string) $member->role, ['doctor', 'general_physician'], true)
&& ! in_array($key, $primaryApps, 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);
}
+7
View File
@@ -26,6 +26,13 @@ return [
'midwife' => 'Midwife',
'pediatrician' => 'Pediatrician',
'oncologist' => 'Oncologist',
'cardiologist' => 'Cardiologist',
'ent_specialist' => 'ENT Specialist',
'dermatologist' => 'Dermatologist',
'ophthalmologist' => 'Ophthalmologist',
'orthopedist' => 'Orthopedist',
'podiatrist' => 'Podiatrist',
'fertility_specialist' => 'Fertility Specialist',
'dialysis_nurse' => 'Dialysis Nurse',
'ambulance_staff' => 'Ambulance Staff',
'nurse' => 'Nurse (legacy)',
+160 -54
View File
@@ -4,10 +4,10 @@
* Specialty practice modules (Settings Modules).
* Pro-gated via plans.*.features specialty_modules.
*
* Primary specialty visibility is driven by CarePermissions::primaryAppsFor()
* (role module keys). Config roles / support_roles / view_roles / refer_roles
* remain for legacy doctor desk-specialist keyword matching and GP refer/view
* outside the primary-app matrix.
* Primary specialty visibility is driven by CarePermissions::primaryAppsFor() /
* referAppsFor() / viewAppsFor() (role module keys). Config roles /
* support_roles / view_roles / refer_roles remain for legacy doctor desk-specialist
* keyword matching and as a secondary allowlist aligned with the matrix.
*
* @return array<string, array<string, mixed>>
*/
@@ -23,7 +23,17 @@ return [
'icon' => 'bolt',
'queue_keywords' => ['emerg', 'casualty', 'trauma', 'er', 'a&e'],
'access' => 'general',
'roles' => ['doctor', 'nurse', 'lab_technician', 'pharmacist', 'receptionist'],
'roles' => [
'emergency_physician', 'ed_nurse', 'general_physician', 'doctor',
'ambulance_staff',
],
'view_roles' => [
'emergency_physician', 'ed_nurse', 'general_physician', 'doctor',
'ambulance_staff', 'nurse', 'pharmacist', 'receptionist',
],
'refer_roles' => [
'general_physician', 'doctor', 'nurse', 'pharmacist', 'receptionist',
],
'default_on_paid_plans' => true,
],
'blood_bank' => [
@@ -37,7 +47,18 @@ return [
'icon' => 'droplet',
'queue_keywords' => ['blood', 'transfusion', 'crossmatch', 'donor'],
'access' => 'general',
'roles' => ['blood_bank_manager', 'lab_technician', 'nurse', 'doctor', 'receptionist'],
'roles' => [
'blood_bank_officer', 'blood_bank_manager', 'lab_technician', 'lab_manager',
'emergency_physician', 'ed_nurse', 'surgeon', 'theatre_nurse',
],
'view_roles' => [
'blood_bank_officer', 'blood_bank_manager', 'lab_technician', 'lab_manager',
'pathologist', 'emergency_physician', 'ed_nurse', 'surgeon', 'theatre_nurse',
'general_physician', 'doctor', 'nurse',
],
'refer_roles' => [
'general_physician', 'doctor', 'nurse',
],
'default_on_paid_plans' => true,
],
'dentistry' => [
@@ -51,7 +72,9 @@ return [
'icon' => 'face-smile',
'queue_keywords' => ['dent', 'dental', 'oral'],
'access' => 'limited',
'roles' => ['doctor', 'nurse', 'receptionist'],
'roles' => ['dentist'],
'view_roles' => ['dentist', 'general_physician', 'doctor', 'receptionist'],
'refer_roles' => ['general_physician', 'doctor', 'receptionist'],
'specialist_keywords' => ['dent', 'dental', 'oral', 'odont'],
],
'ophthalmology' => [
@@ -65,7 +88,11 @@ return [
'icon' => 'eye',
'queue_keywords' => ['eye', 'ophthal', 'optom'],
'access' => 'limited',
'roles' => ['doctor', 'nurse', 'receptionist'],
'roles' => ['ophthalmologist', 'general_physician', 'doctor'],
'view_roles' => [
'ophthalmologist', 'general_physician', 'doctor', 'receptionist',
],
'refer_roles' => ['general_physician', 'doctor', 'receptionist'],
'specialist_keywords' => ['eye', 'ophthal', 'optom'],
],
'physiotherapy' => [
@@ -79,7 +106,11 @@ return [
'icon' => 'arrow-path',
'queue_keywords' => ['physio', 'rehab', 'therapy'],
'access' => 'limited',
'roles' => ['doctor', 'nurse', 'receptionist'],
'roles' => ['physiotherapist'],
'view_roles' => [
'physiotherapist', 'general_physician', 'doctor', 'receptionist',
],
'refer_roles' => ['general_physician', 'doctor', 'receptionist'],
'specialist_keywords' => ['physio', 'rehab', 'therapy'],
],
'maternity' => [
@@ -90,10 +121,16 @@ return [
'queue_name' => 'Maternity',
'queue_prefix' => 'MAT',
'nav_label' => 'Maternity',
'icon' => 'heart',
'icon' => 'home',
'queue_keywords' => ['matern', 'antenatal', 'obstetric'],
'access' => 'limited',
'roles' => ['doctor', 'nurse', 'pharmacist', 'receptionist'],
'roles' => ['midwife', 'nurse'],
'view_roles' => [
'midwife', 'nurse', 'general_physician', 'doctor', 'pharmacist', 'receptionist',
],
'refer_roles' => [
'general_physician', 'doctor', 'pharmacist', 'receptionist',
],
'specialist_keywords' => ['matern', 'antenatal', 'obstetric', 'midwif'],
],
'radiology' => [
@@ -107,7 +144,16 @@ return [
'icon' => 'camera',
'queue_keywords' => ['radio', 'imaging', 'x-ray', 'xray', 'ultrasound', 'ct', 'mri'],
'access' => 'general',
'roles' => ['doctor', 'nurse', 'lab_technician', 'receptionist'],
'roles' => [
'radiologist', 'radiographer', 'emergency_physician', 'ed_nurse', 'surgeon',
],
'view_roles' => [
'radiologist', 'radiographer', 'emergency_physician', 'ed_nurse', 'surgeon',
'lab_technician', 'lab_manager', 'general_physician', 'doctor',
],
'refer_roles' => [
'general_physician', 'doctor',
],
'specialist_keywords' => ['radio', 'radiolog', 'imaging'],
],
'cardiology' => [
@@ -121,10 +167,13 @@ return [
'icon' => 'heart',
'queue_keywords' => ['cardio', 'heart', 'ecg', 'echo'],
'access' => 'restricted',
'roles' => ['doctor'],
'support_roles' => ['nurse'],
'view_roles' => ['doctor', 'general_physician', 'nurse', 'lab_technician'],
'refer_roles' => ['doctor', 'general_physician', 'nurse'],
'roles' => ['cardiologist', 'emergency_physician', 'general_physician', 'doctor'],
'support_roles' => ['ed_nurse'],
'view_roles' => [
'cardiologist', 'emergency_physician', 'general_physician', 'doctor',
'ed_nurse',
],
'refer_roles' => ['general_physician', 'doctor'],
'specialist_keywords' => ['cardio', 'cardiology', 'heart', 'ecg'],
],
'psychiatry' => [
@@ -138,10 +187,12 @@ return [
'icon' => 'chat-bubble-left-right',
'queue_keywords' => ['psych', 'mental', 'behaviour'],
'access' => 'restricted',
'roles' => ['doctor'],
'support_roles' => ['nurse'],
'view_roles' => ['doctor', 'general_physician', 'nurse'],
'refer_roles' => ['doctor', 'general_physician', 'nurse'],
'roles' => ['psychiatrist'],
'support_roles' => [],
'view_roles' => [
'psychiatrist', 'general_physician', 'doctor',
],
'refer_roles' => ['general_physician', 'doctor'],
'specialist_keywords' => ['psych', 'psychiatry', 'mental', 'behaviour'],
],
'pediatrics' => [
@@ -155,7 +206,13 @@ return [
'icon' => 'user-group',
'queue_keywords' => ['pedia', 'paedia', 'child', 'infant'],
'access' => 'general',
'roles' => ['doctor', 'nurse', 'receptionist'],
'roles' => ['pediatrician', 'general_physician', 'doctor'],
'view_roles' => [
'pediatrician', 'general_physician', 'doctor', 'nurse', 'receptionist',
],
'refer_roles' => [
'general_physician', 'doctor', 'nurse', 'receptionist',
],
// Avoid bare "child" — collides with Child Welfare Clinic specialty labels.
'specialist_keywords' => ['pedia', 'paedia', 'pediatric', 'paediatric', 'infant'],
],
@@ -170,10 +227,12 @@ return [
'icon' => 'cube',
'queue_keywords' => ['ortho', 'fracture', 'bone', 'joint'],
'access' => 'restricted',
'roles' => ['doctor'],
'support_roles' => ['nurse'],
'view_roles' => ['doctor', 'general_physician', 'nurse'],
'refer_roles' => ['doctor', 'general_physician', 'nurse'],
'roles' => ['orthopedist'],
'support_roles' => [],
'view_roles' => [
'orthopedist', 'general_physician', 'doctor',
],
'refer_roles' => ['general_physician', 'doctor'],
'specialist_keywords' => ['ortho', 'orthopedic', 'orthopaedic', 'fracture'],
],
'ent' => [
@@ -187,10 +246,12 @@ return [
'icon' => 'speaker-wave',
'queue_keywords' => ['ent', 'ear', 'nose', 'throat', 'otolaryng'],
'access' => 'restricted',
'roles' => ['doctor'],
'support_roles' => ['nurse'],
'view_roles' => ['doctor', 'general_physician', 'nurse'],
'refer_roles' => ['doctor', 'general_physician', 'nurse'],
'roles' => ['ent_specialist', 'general_physician', 'doctor'],
'support_roles' => [],
'view_roles' => [
'ent_specialist', 'general_physician', 'doctor',
],
'refer_roles' => ['general_physician', 'doctor'],
'specialist_keywords' => ['ent', 'otolaryng', 'ear', 'nose', 'throat'],
],
'oncology' => [
@@ -204,10 +265,14 @@ return [
'icon' => 'shield-exclamation',
'queue_keywords' => ['oncol', 'cancer', 'chemo'],
'access' => 'restricted',
'roles' => ['doctor'],
'roles' => ['oncologist'],
'support_roles' => ['nurse'],
'view_roles' => ['doctor', 'general_physician', 'nurse', 'pharmacist'],
'refer_roles' => ['doctor', 'general_physician', 'nurse'],
'view_roles' => [
'oncologist', 'general_physician', 'doctor', 'pharmacist',
],
'refer_roles' => [
'general_physician', 'doctor', 'pharmacist',
],
'specialist_keywords' => ['oncol', 'cancer', 'chemo'],
],
'renal' => [
@@ -221,10 +286,12 @@ return [
'icon' => 'circle-stack',
'queue_keywords' => ['renal', 'dialysis', 'nephro', 'kidney'],
'access' => 'restricted',
'roles' => ['doctor'],
'support_roles' => ['nurse'],
'view_roles' => ['doctor', 'general_physician', 'nurse'],
'refer_roles' => ['doctor', 'general_physician', 'nurse'],
'roles' => ['dialysis_nurse'],
'support_roles' => [],
'view_roles' => [
'dialysis_nurse', 'general_physician', 'doctor',
],
'refer_roles' => ['general_physician', 'doctor'],
'specialist_keywords' => ['renal', 'nephro', 'dialysis', 'kidney'],
],
'surgery' => [
@@ -238,10 +305,12 @@ return [
'icon' => 'wrench-screwdriver',
'queue_keywords' => ['surg', 'theatre', 'operat', 'pre-op'],
'access' => 'restricted',
'roles' => ['doctor'],
'support_roles' => ['nurse'],
'view_roles' => ['doctor', 'general_physician', 'nurse'],
'refer_roles' => ['doctor', 'general_physician', 'nurse'],
'roles' => ['surgeon', 'theatre_nurse'],
'support_roles' => [],
'view_roles' => [
'surgeon', 'theatre_nurse', 'general_physician', 'doctor',
],
'refer_roles' => ['general_physician', 'doctor'],
'specialist_keywords' => ['surg', 'theatre', 'operat'],
],
'vaccination' => [
@@ -255,7 +324,11 @@ return [
'icon' => 'shield-check',
'queue_keywords' => ['vaccin', 'immun', 'epi', 'injection'],
'access' => 'general',
'roles' => ['doctor', 'nurse', 'receptionist'],
'roles' => ['general_physician', 'doctor', 'nurse'],
'view_roles' => [
'general_physician', 'doctor', 'nurse', 'receptionist',
],
'refer_roles' => ['general_physician', 'doctor', 'receptionist'],
'specialist_keywords' => ['vaccin', 'immun', 'epi'],
],
'pathology' => [
@@ -269,7 +342,18 @@ return [
'icon' => 'document-magnifying-glass',
'queue_keywords' => ['pathol', 'histo', 'cytol', 'biopsy'],
'access' => 'general',
'roles' => ['lab_technician', 'doctor', 'nurse', 'receptionist'],
'roles' => [
'lab_technician', 'lab_manager', 'pathologist',
'emergency_physician', 'ed_nurse', 'surgeon',
],
'view_roles' => [
'lab_technician', 'lab_manager', 'pathologist',
'emergency_physician', 'ed_nurse', 'surgeon',
'general_physician', 'doctor',
],
'refer_roles' => [
'general_physician', 'doctor',
],
'specialist_keywords' => ['pathol', 'histo', 'cytol'],
],
'infusion' => [
@@ -283,7 +367,13 @@ return [
'icon' => 'beaker',
'queue_keywords' => ['infusion', 'iv', 'drip', 'day care'],
'access' => 'general',
'roles' => ['doctor', 'nurse', 'pharmacist', 'receptionist'],
'roles' => ['oncologist', 'nurse'],
'view_roles' => [
'oncologist', 'nurse', 'general_physician', 'doctor', 'pharmacist',
],
'refer_roles' => [
'general_physician', 'doctor', 'pharmacist',
],
'specialist_keywords' => ['infusion', 'iv therapy', 'day care'],
],
'dermatology' => [
@@ -297,10 +387,12 @@ return [
'icon' => 'swatch',
'queue_keywords' => ['derma', 'skin', 'rash'],
'access' => 'restricted',
'roles' => ['doctor'],
'roles' => ['dermatologist', 'general_physician', 'doctor'],
'support_roles' => [],
'view_roles' => ['doctor', 'general_physician', 'nurse'],
'refer_roles' => ['doctor', 'general_physician', 'nurse'],
'view_roles' => [
'dermatologist', 'general_physician', 'doctor',
],
'refer_roles' => ['general_physician', 'doctor'],
'specialist_keywords' => ['derma', 'skin'],
],
'podiatry' => [
@@ -314,10 +406,12 @@ return [
'icon' => 'finger-print',
'queue_keywords' => ['podiat', 'foot', 'diabetic foot'],
'access' => 'restricted',
'roles' => ['doctor'],
'roles' => ['podiatrist'],
'support_roles' => [],
'view_roles' => ['doctor', 'general_physician', 'nurse'],
'refer_roles' => ['doctor', 'general_physician', 'nurse'],
'view_roles' => [
'podiatrist', 'general_physician', 'doctor',
],
'refer_roles' => ['general_physician', 'doctor'],
'specialist_keywords' => ['podiat', 'foot'],
],
'fertility' => [
@@ -331,10 +425,12 @@ return [
'icon' => 'sparkles',
'queue_keywords' => ['fertil', 'ivf', 'reproduct'],
'access' => 'restricted',
'roles' => ['doctor'],
'roles' => ['fertility_specialist'],
'support_roles' => [],
'view_roles' => ['doctor', 'general_physician', 'nurse'],
'refer_roles' => ['doctor', 'general_physician', 'nurse'],
'view_roles' => [
'fertility_specialist', 'general_physician', 'doctor',
],
'refer_roles' => ['general_physician', 'doctor'],
'specialist_keywords' => ['fertil', 'ivf', 'reproduct'],
],
'child_welfare' => [
@@ -348,7 +444,11 @@ return [
'icon' => 'gift',
'queue_keywords' => ['cwc', 'child welfare', 'well baby', 'well-child', 'welfare clinic'],
'access' => 'general',
'roles' => ['doctor', 'nurse', 'receptionist'],
'roles' => ['general_physician', 'doctor', 'nurse'],
'view_roles' => [
'general_physician', 'doctor', 'nurse', 'receptionist',
],
'refer_roles' => ['general_physician', 'doctor', 'receptionist'],
// Prefer multi-word / CWC stems — do not use bare "child" (steals pediatrics).
'specialist_keywords' => ['child welfare', 'cwc', 'welfare clinic', 'well baby', 'well-child'],
],
@@ -364,7 +464,13 @@ return [
// Avoid ED stems (emergency, casualty, trauma, er, a&e) — those belong to emergency.
'queue_keywords' => ['ambulance', 'ems', 'paramedic', 'prehospital', 'ambulance service'],
'access' => 'general',
'roles' => ['doctor', 'nurse', 'receptionist'],
'roles' => ['ambulance_staff'],
'view_roles' => [
'ambulance_staff', 'general_physician', 'doctor', 'receptionist',
],
'refer_roles' => [
'general_physician', 'doctor', 'receptionist',
],
'specialist_keywords' => ['ambulance', 'ems', 'paramedic', 'prehospital'],
],
];
+10 -9
View File
@@ -123,7 +123,7 @@ class CareNurseOrgAccessTest extends TestCase
'owner_ref' => $owner->public_id,
'organization_id' => $employer->id,
'user_ref' => strtolower($nurse->email),
'role' => 'nurse',
'role' => 'ed_nurse',
]);
$branch = Branch::create([
@@ -195,7 +195,7 @@ class CareNurseOrgAccessTest extends TestCase
$nurse = User::create([
'public_id' => 'er-docs-nurse',
'name' => 'Nurse',
'name' => 'ED Nurse',
'email' => 'er-docs-nurse@example.com',
]);
@@ -222,7 +222,7 @@ class CareNurseOrgAccessTest extends TestCase
'owner_ref' => $owner->public_id,
'organization_id' => $organization->id,
'user_ref' => $nurse->public_id,
'role' => 'nurse',
'role' => 'ed_nurse',
]);
$branch = Branch::create([
@@ -299,8 +299,8 @@ class CareNurseOrgAccessTest extends TestCase
$organization = Organization::create([
'owner_ref' => $owner->public_id,
'name' => 'Cardio Clinic',
'slug' => 'cardio-docs-clinic',
'name' => 'Rad Clinic',
'slug' => 'rad-docs-clinic',
'settings' => [
'onboarded' => true,
'plan' => 'pro',
@@ -330,9 +330,9 @@ class CareNurseOrgAccessTest extends TestCase
]);
$modules = app(SpecialtyModuleService::class);
$modules->activate($organization, $owner->public_id, 'cardiology');
$modules->activate($organization, $owner->public_id, 'radiology');
$dept = Department::query()->where('type', 'cardiology')->where('branch_id', $branch->id)->firstOrFail();
$dept = Department::query()->where('type', 'radiology')->where('branch_id', $branch->id)->firstOrFail();
$patient = Patient::create([
'owner_ref' => $owner->public_id,
@@ -369,11 +369,12 @@ class CareNurseOrgAccessTest extends TestCase
]);
$labMember = Member::query()->where('user_ref', $lab->public_id)->firstOrFail();
$this->assertSame('view', $modules->memberAccessLevel($organization, $labMember, 'cardiology'));
// Lab manages pathology/blood bank; radiology is view-only.
$this->assertSame('view', $modules->memberAccessLevel($organization, $labMember, 'radiology'));
$this->actingAs($lab)
->get(route('care.specialty.workspace', [
'module' => 'cardiology',
'module' => 'radiology',
'visit' => $visit,
'tab' => 'documents',
]))
+58 -12
View File
@@ -95,22 +95,40 @@ class CareSpecialtyAccessTest extends TestCase
public function test_nurse_and_pharmacist_manage_general_modules(): void
{
foreach (['vaccination', 'child_welfare', 'maternity'] as $key) {
$this->modules->activate($this->organization->fresh(), $this->owner->public_id, $key);
}
$this->organization->refresh();
[, $nurse] = $this->makeStaff('nurse', 'nurse1');
[, $edNurse] = $this->makeStaff('ed_nurse', 'edn1');
[, $pharmacist] = $this->makeStaff('pharmacist', 'rx1');
[, $lab] = $this->makeStaff('lab_technician', 'lab1');
[, $receptionist] = $this->makeStaff('receptionist', 'rec1');
// Legacy nurse is limited — no specialty modules.
// Legacy floor nurse: vaccination / CWC / infusion / maternity manage; emergency refer only.
$this->assertFalse($this->modules->memberCanManage($this->organization, $nurse, 'emergency'));
$this->assertTrue($this->modules->memberCanRefer($this->organization, $nurse, 'emergency'));
$this->assertTrue($this->modules->memberCanManage($this->organization, $nurse, 'vaccination'));
$this->assertTrue($this->modules->memberCanManage($this->organization, $nurse, 'infusion'));
$this->assertTrue($this->modules->memberCanManage($this->organization, $nurse, 'maternity'));
// ED nurse matrix apps: Emergency, Blood Bank, Radiology, Pathology.
$this->assertTrue($this->modules->memberCanManage($this->organization, $edNurse, 'emergency'));
$this->assertTrue($this->modules->memberCanManage($this->organization, $edNurse, 'pathology'));
// Pharmacist is pharmacy-only (core app) — no specialty modules.
// Pharmacist: no specialty manage; refer/view pharmacy-adjacent modules.
$this->assertFalse($this->modules->memberCanManage($this->organization, $pharmacist, 'emergency'));
$this->assertFalse($this->modules->memberCanManage($this->organization, $pharmacist, 'infusion'));
$this->assertTrue($this->modules->memberCanManage($this->organization, $lab, 'pathology'));
$this->assertFalse($this->modules->memberCanManage($this->organization, $lab, 'blood_bank'));
$this->assertTrue($this->modules->memberCanRefer($this->organization, $pharmacist, 'infusion'));
$this->assertTrue($this->modules->memberCanView($this->organization, $pharmacist, 'emergency'));
$this->assertFalse($this->modules->memberCanManage($this->organization, $pharmacist, 'cardiology'));
// Lab: pathology + blood bank manage; radiology view.
$this->assertTrue($this->modules->memberCanManage($this->organization, $lab, 'pathology'));
$this->assertTrue($this->modules->memberCanManage($this->organization, $lab, 'blood_bank'));
$this->assertSame('view', $this->modules->memberAccessLevel($this->organization, $lab, 'radiology'));
// Receptionist: refer only (no specialty manage).
$this->assertFalse($this->modules->memberCanManage($this->organization, $receptionist, 'emergency'));
$this->assertTrue($this->modules->memberCanRefer($this->organization, $receptionist, 'emergency'));
$this->assertTrue($this->modules->memberCanRefer($this->organization, $receptionist, 'dentistry'));
}
public function test_gp_doctor_manages_general_but_only_views_restricted(): void
@@ -129,17 +147,33 @@ class CareSpecialtyAccessTest extends TestCase
$this->assertFalse($this->modules->isDeskSpecialist($this->organization, $member));
$this->assertTrue($this->modules->memberCanManage($this->organization, $member, 'emergency'));
// Dentistry is not a GP primary app.
// Dentistry is not a GP primary app — refer only.
$this->assertFalse($this->modules->memberCanManage($this->organization, $member, 'dentistry'));
// Cardiology is a GP primary app (manage).
$this->assertTrue($this->modules->memberCanRefer($this->organization, $member, 'dentistry'));
// Cardiology / ophthalmology are GP primary apps (manage).
$this->assertTrue($this->modules->memberCanManage($this->organization, $member, 'cardiology'));
// Psychiatry is outside GP apps — refer/view via legacy config for GP.
$this->modules->activate($this->organization->fresh(), $this->owner->public_id, 'ophthalmology');
$this->organization->refresh();
$this->assertTrue($this->modules->memberCanManage($this->organization, $member, 'ophthalmology'));
// Psychiatry is outside GP manage — refer/view via roleReferApps.
$this->assertFalse($this->modules->memberCanManage($this->organization, $member, 'psychiatry'));
$this->assertTrue($this->modules->memberCanView($this->organization, $member, 'psychiatry'));
$this->assertTrue($this->modules->memberCanRefer($this->organization, $member, 'psychiatry'));
$this->assertSame('refer', $this->modules->memberAccessLevel($this->organization, $member, 'psychiatry'));
}
public function test_dedicated_cardiologist_role_manages_cardiology_only(): void
{
[, $member] = $this->makeStaff('cardiologist', 'cardio-role');
$this->assertTrue($this->modules->memberCanManage($this->organization, $member, 'cardiology'));
$this->assertSame('manage', $this->modules->memberAccessLevel($this->organization, $member, 'cardiology'));
$this->assertSame('none', $this->modules->memberAccessLevel($this->organization, $member, 'emergency'));
$this->assertSame('none', $this->modules->memberAccessLevel($this->organization, $member, 'dentistry'));
$this->assertTrue(app(\App\Services\Care\CarePermissions::class)->isSingleAppSpecialist($member));
$this->assertFalse($this->modules->shouldShowSpecialtyNav($this->organization, $member));
}
public function test_cardiologist_manages_restricted_cardiology(): void
{
[$doctor, $member] = $this->makeStaff('doctor', 'cardio1');
@@ -237,7 +271,7 @@ class CareSpecialtyAccessTest extends TestCase
->assertSee('>Specialty</p>', false)
->assertSee('Emergency')
->assertSee('Cardiology')
->assertDontSee('Dentistry');
->assertSee('Dentistry'); // GP refer access
}
public function test_dentistry_specialty_does_not_match_ent_keyword(): void
@@ -382,9 +416,11 @@ class CareSpecialtyAccessTest extends TestCase
->pluck('key')
->all();
// Pharmacist: pharmacy core only — no specialty modules.
$this->assertSame([], $enabledForPharmacist);
$this->assertFalse($this->modules->shouldShowSpecialtyNav($this->organization, $pharmacistMember));
// Pharmacist: pharmacy-adjacent specialty refer/view (multi-app → Specialty nav).
$this->assertContains('emergency', $enabledForPharmacist);
$this->assertContains('infusion', $enabledForPharmacist);
$this->assertNotContains('cardiology', $enabledForPharmacist);
$this->assertTrue($this->modules->shouldShowSpecialtyNav($this->organization, $pharmacistMember));
$this->assertContains('emergency', $enabledForEp);
$this->assertContains('radiology', $enabledForEp);
@@ -394,7 +430,7 @@ class CareSpecialtyAccessTest extends TestCase
$this->actingAs($pharmacistUser)
->get(route('care.dashboard'))
->assertOk()
->assertDontSee('>Specialty</p>', false);
->assertSee('>Specialty</p>', false);
}
public function test_manage_role_still_sees_mutate_actions_on_restricted_module(): void
@@ -778,8 +814,18 @@ class CareSpecialtyAccessTest extends TestCase
$this->assertTrue($permissions->can($lab, 'lab.manage'));
$this->assertTrue($permissions->can($lab, 'pathology.result.enter'));
$this->assertFalse($permissions->can($lab, 'pathology.result.approve'));
$this->assertTrue($this->modules->memberCanManage($this->organization, $lab, 'blood_bank'));
$this->assertTrue($permissions->can($labMgr, 'pathology.result.approve'));
$this->assertTrue($permissions->can($labMgr, 'lab.catalog.manage'));
$this->assertTrue($this->modules->memberCanManage($this->organization, $labMgr, 'blood_bank'));
[, $cardiologist] = $this->makeStaff('cardiologist', 'matrix-cardio');
[, $ophthalmologist] = $this->makeStaff('ophthalmologist', 'matrix-oph');
$this->modules->activate($this->organization->fresh(), $this->owner->public_id, 'ophthalmology');
$this->organization->refresh();
$this->assertTrue($this->modules->memberCanManage($this->organization, $cardiologist, 'cardiology'));
$this->assertFalse($this->modules->memberCanManage($this->organization, $cardiologist, 'emergency'));
$this->assertTrue($this->modules->memberCanManage($this->organization, $ophthalmologist, 'ophthalmology'));
$this->assertTrue($permissions->can($billing, 'bills.manage'));
$this->assertFalse($permissions->can($billing, 'payments.manage'));
+1 -1
View File
@@ -54,7 +54,7 @@ class CareSpecialtyClinicalTest extends TestCase
'owner_ref' => $this->owner->public_id,
'organization_id' => $this->organization->id,
'user_ref' => $this->owner->public_id,
'role' => 'nurse',
'role' => 'hospital_admin',
]);
$this->branch = Branch::create([
+8 -7
View File
@@ -216,16 +216,16 @@ class CareSpecialtyModulesTest extends TestCase
Http::fake();
app(SpecialtyModuleService::class)->activate($this->organization, $this->owner->public_id, 'dentistry');
$nurse = User::create([
'public_id' => 'modules-nurse',
'name' => 'Floor Nurse',
'email' => 'modules-nurse@example.com',
$receptionist = User::create([
'public_id' => 'modules-reception',
'name' => 'Front Desk',
'email' => 'modules-reception@example.com',
]);
Member::create([
'owner_ref' => $this->owner->public_id,
'organization_id' => $this->organization->id,
'user_ref' => $nurse->public_id,
'role' => 'nurse',
'user_ref' => $receptionist->public_id,
'role' => 'receptionist',
'branch_id' => $this->branch->id,
]);
@@ -234,7 +234,8 @@ class CareSpecialtyModulesTest extends TestCase
->assertOk()
->assertSee('Patient flow');
$this->actingAs($nurse)
// Receptionist has refer access to dentistry (registration into specialty queues).
$this->actingAs($receptionist)
->get(route('care.specialty.show', 'dentistry'))
->assertOk()
->assertSee('Patient flow');
+7 -3
View File
@@ -55,7 +55,7 @@ class CareSpecialtyShellTest extends TestCase
'owner_ref' => $this->owner->public_id,
'organization_id' => $this->organization->id,
'user_ref' => $this->owner->public_id,
'role' => 'nurse',
'role' => 'dentist',
]);
$this->branch = Branch::create([
@@ -99,6 +99,8 @@ class CareSpecialtyShellTest extends TestCase
public function test_specialty_shell_overview_and_sections_render(): void
{
Member::query()->where('user_ref', $this->owner->public_id)->update(['role' => 'emergency_physician']);
app(SpecialtyModuleService::class)->activate(
$this->organization,
$this->owner->public_id,
@@ -153,6 +155,8 @@ class CareSpecialtyShellTest extends TestCase
public function test_workspace_can_add_catalog_service_to_visit_bill(): void
{
Member::query()->where('user_ref', $this->owner->public_id)->update(['role' => 'emergency_physician']);
app(SpecialtyModuleService::class)->activate(
$this->organization,
$this->owner->public_id,
@@ -307,7 +311,7 @@ class CareSpecialtyShellTest extends TestCase
public function test_workspace_includes_timeline_tab_and_complete_action(): void
{
Member::query()->where('user_ref', $this->owner->public_id)->update(['role' => 'doctor']);
Member::query()->where('user_ref', $this->owner->public_id)->update(['role' => 'dentist']);
app(SpecialtyModuleService::class)->activate(
$this->organization,
@@ -393,7 +397,7 @@ class CareSpecialtyShellTest extends TestCase
public function test_called_workspace_shows_call_again_until_session_starts(): void
{
Member::query()->where('user_ref', $this->owner->public_id)->update(['role' => 'doctor']);
Member::query()->where('user_ref', $this->owner->public_id)->update(['role' => 'dentist']);
app(SpecialtyModuleService::class)->activate(
$this->organization,