Implement Care RBAC role→permission→app matrix.
Deploy Ladill Care / deploy (push) Successful in 57s

Replace broad doctor/nurse specialty access with granular roles and primary
apps, permission inheritance for lab/BB managers, and cannot-rules for
discharge, lab approve, and cashier vs billing officer.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
isaacclad
2026-07-20 00:24:09 +00:00
co-authored by Cursor
parent 55a1288d30
commit ac870bcf33
21 changed files with 913 additions and 239 deletions
@@ -143,7 +143,7 @@ class InvestigationController extends Controller
public function approve(Request $request, InvestigationRequest $investigation): JsonResponse public function approve(Request $request, InvestigationRequest $investigation): JsonResponse
{ {
$this->authorizeAbility($request, 'lab.manage'); $this->authorizeAbility($request, 'pathology.result.approve');
$this->authorizeInvestigation($request, $investigation); $this->authorizeInvestigation($request, $investigation);
$updated = $this->investigations->approve($investigation, $this->ownerRef($request), $this->ownerRef($request)); $updated = $this->investigations->approve($investigation, $this->ownerRef($request), $this->ownerRef($request));
+14 -3
View File
@@ -87,9 +87,14 @@ class BillController extends Controller
public function callNext(Request $request): RedirectResponse public function callNext(Request $request): RedirectResponse
{ {
$this->authorizeAbility($request, 'bills.manage'); $permissions = app(CarePermissions::class);
$organization = $this->organization($request);
$member = $this->member($request); $member = $this->member($request);
abort_unless(
$permissions->can($member, 'payments.manage')
|| $permissions->can($member, 'bills.manage'),
403,
);
$organization = $this->organization($request);
$branchId = $this->branchContext->resolve($request, $organization, $member); $branchId = $this->branchContext->resolve($request, $organization, $member);
abort_unless($branchId > 0, 422); abort_unless($branchId > 0, 422);
abort_unless($this->queueBridge->isEnabled($organization), 404); abort_unless($this->queueBridge->isEnabled($organization), 404);
@@ -108,7 +113,13 @@ class BillController extends Controller
public function serve(Request $request, Bill $bill): RedirectResponse public function serve(Request $request, Bill $bill): RedirectResponse
{ {
$this->authorizeAbility($request, 'bills.manage'); $permissions = app(CarePermissions::class);
$member = $this->member($request);
abort_unless(
$permissions->can($member, 'payments.manage')
|| $permissions->can($member, 'bills.manage'),
403,
);
$this->authorizeBill($request, $bill); $this->authorizeBill($request, $bill);
$organization = $this->organization($request); $organization = $this->organization($request);
abort_unless($this->queueBridge->isEnabled($organization), 404); abort_unless($this->queueBridge->isEnabled($organization), 404);
@@ -128,7 +128,7 @@ class EmergencyWorkspaceController extends Controller
SpecialtyVisitStageService $stages, SpecialtyVisitStageService $stages,
EmergencyWorkflowService $workflow, EmergencyWorkflowService $workflow,
): RedirectResponse { ): RedirectResponse {
$this->authorizeAbility($request, 'consultations.manage'); $this->authorizeAbility($request, 'emergency.discharge');
$this->assertEmergencyManage($request, $modules); $this->assertEmergencyManage($request, $modules);
$this->assertVisit($request, $visit); $this->assertVisit($request, $visit);
@@ -228,7 +228,7 @@ class InvestigationController extends Controller
public function approve(Request $request, InvestigationRequest $investigation): RedirectResponse public function approve(Request $request, InvestigationRequest $investigation): RedirectResponse
{ {
$this->authorizeAbility($request, 'lab.manage'); $this->authorizeAbility($request, 'pathology.result.approve');
$this->authorizeInvestigation($request, $investigation); $this->authorizeInvestigation($request, $investigation);
$this->investigations->approve($investigation, $this->ownerRef($request), $this->ownerRef($request)); $this->investigations->approve($investigation, $this->ownerRef($request), $this->ownerRef($request));
+13 -3
View File
@@ -8,6 +8,7 @@ use App\Models\Branch;
use App\Models\Member; use App\Models\Member;
use App\Models\Practitioner; use App\Models\Practitioner;
use App\Services\Care\AuditLogger; use App\Services\Care\AuditLogger;
use App\Services\Care\CarePermissions;
use App\Services\Identity\IdentityTeamClient; use App\Services\Identity\IdentityTeamClient;
use Illuminate\Http\RedirectResponse; use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request; use Illuminate\Http\Request;
@@ -31,7 +32,7 @@ class MemberController extends Controller
->get(); ->get();
$adminRoles = ['super_admin', 'hospital_admin']; $adminRoles = ['super_admin', 'hospital_admin'];
$clinicalRoles = ['doctor', 'nurse', 'lab_technician', 'lab_manager', 'pharmacist', 'blood_bank_manager']; $clinicalRoles = app(CarePermissions::class)->clinicalPractitionerRoles();
$heroStats = [ $heroStats = [
'total' => $members->count(), 'total' => $members->count(),
@@ -71,6 +72,7 @@ class MemberController extends Controller
'organization' => $organization, 'organization' => $organization,
'branches' => $branches, 'branches' => $branches,
'roles' => config('care.roles'), 'roles' => config('care.roles'),
'practitionerRoles' => app(CarePermissions::class)->clinicalPractitionerRoles(),
'mailboxOptions' => $mailboxOptions, 'mailboxOptions' => $mailboxOptions,
'specialties' => Practitioner::specialtyOptions(), 'specialties' => Practitioner::specialtyOptions(),
]); ]);
@@ -82,11 +84,16 @@ class MemberController extends Controller
$organization = $this->organization($request); $organization = $this->organization($request);
$owner = $this->ownerRef($request); $owner = $this->ownerRef($request);
$permissions = app(CarePermissions::class);
$needsPractitionerDesk = $permissions->usesPractitionerBranchScope(
new Member(['role' => $request->input('role')])
);
$validated = $request->validate([ $validated = $request->validate([
'email' => ['required', 'email', 'max:255'], 'email' => ['required', 'email', 'max:255'],
'role' => ['required', 'string', 'in:'.implode(',', array_keys(config('care.roles')))], 'role' => ['required', 'string', 'in:'.implode(',', array_keys(config('care.roles')))],
'branch_id' => [ 'branch_id' => [
Rule::requiredIf(fn () => $request->input('role') === 'doctor'), Rule::requiredIf(fn () => $needsPractitionerDesk),
'nullable', 'nullable',
'integer', 'integer',
'exists:care_branches,id', 'exists:care_branches,id',
@@ -138,7 +145,10 @@ class MemberController extends Controller
AuditLogger::record($owner, 'member.invited', $organization->id, $owner, Member::class, $member->id); AuditLogger::record($owner, 'member.invited', $organization->id, $owner, Member::class, $member->id);
$createPractitioner = $request->boolean('create_practitioner', $validated['role'] === 'doctor'); $createPractitioner = $request->boolean(
'create_practitioner',
app(CarePermissions::class)->usesPractitionerBranchScope(new Member(['role' => $validated['role']])),
);
if ($createPractitioner) { if ($createPractitioner) {
$name = trim((string) ($validated['practitioner_name'] ?? '')) ?: Str::headline(Str::before($email, '@')); $name = trim((string) ($validated['practitioner_name'] ?? '')) ?: Str::headline(Str::before($email, '@'));
$practitioner = Practitioner::query()->firstOrCreate( $practitioner = Practitioner::query()->firstOrCreate(
@@ -210,7 +210,7 @@ class PractitionerController extends Controller
{ {
return Member::owned($owner) return Member::owned($owner)
->where('organization_id', $organizationId) ->where('organization_id', $organizationId)
->whereIn('role', ['doctor', 'nurse', 'lab_technician', 'lab_manager', 'pharmacist', 'blood_bank_manager', 'hospital_admin', 'super_admin']) ->whereIn('role', app(\App\Services\Care\CarePermissions::class)->clinicalPractitionerRoles())
->orderBy('user_ref') ->orderBy('user_ref')
->get(); ->get();
} }
+10 -2
View File
@@ -311,8 +311,16 @@ class AssessmentService
public function defaultCaptureRoles(string $category): array public function defaultCaptureRoles(string $category): array
{ {
return match ($category) { return match ($category) {
AssessmentTemplate::CATEGORY_DISEASE => ['doctor'], AssessmentTemplate::CATEGORY_DISEASE => [
default => ['doctor', 'nurse'], 'doctor', 'general_physician', 'emergency_physician', 'surgeon',
'pediatrician', 'oncologist', 'psychiatrist', 'dentist', 'pathologist',
],
default => [
'doctor', 'general_physician', 'emergency_physician', 'surgeon',
'pediatrician', 'oncologist', 'psychiatrist', 'dentist', 'pathologist',
'nurse', 'ed_nurse', 'theatre_nurse', 'dialysis_nurse', 'midwife',
'physiotherapist', 'ambulance_staff', 'radiographer',
],
}; };
} }
+3 -10
View File
@@ -29,20 +29,13 @@ class BranchContext
return false; return false;
} }
if ($member->role === 'doctor') { if ($member->role === 'doctor' || app(CarePermissions::class)->usesPractitionerBranchScope($member)) {
return count($this->organizations->doctorAssignedBranchIds($member)) > 1; return count($this->organizations->doctorAssignedBranchIds($member)) > 1;
} }
// Other site clinicians stay on their assigned branch — never a picker. // Other site clinicians stay on their assigned branch — never a picker.
if (in_array($member->role, [ if (in_array($member->role, app(CarePermissions::class)->floorCareRoles(), true)
'nurse', && ! app(CarePermissions::class)->usesPractitionerBranchScope($member)) {
'pharmacist',
'lab_technician',
'lab_manager',
'blood_bank_manager',
'cashier',
'receptionist',
], true)) {
return false; return false;
} }
+398 -49
View File
@@ -4,86 +4,332 @@ namespace App\Services\Care;
use App\Models\Member; use App\Models\Member;
/**
* Care RBAC: role abilities + primary specialty 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).
*
* Inheritance: lab_manager lab_technician; blood_bank_manager blood_bank_officer.
*/
class CarePermissions class CarePermissions
{ {
/** /**
* Role abilities. * Child role abilities are merged into the parent (parent wins on duplicates).
* *
* Lab catalog pricing uses `lab.catalog.manage` (admins via `*`, plus `lab_manager`). * @var array<string, list<string>>
* Lab technicians keep `lab.manage` for queue / sample / process / results */
* not catalog CRUD or price edits. protected array $roleInherits = [
* 'lab_manager' => ['lab_technician'],
* Blood Bank operational inventory uses `blood_bank.manage` (managers + admins). 'blood_bank_manager' => ['blood_bank_officer'],
* Clinical visit stage/issue for managers also requires specialty module manage. ];
/**
* 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.
* *
* @var array<string, list<string>|null>
*/
protected array $rolePrimaryApps = [
'super_admin' => null, // all via admin
'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
'surgeon' => ['surgery', 'radiology', 'pathology', 'blood_bank'],
'theatre_nurse' => ['surgery', 'blood_bank'],
'radiologist' => ['radiology'],
'radiographer' => ['radiology'],
'lab_technician' => ['pathology'],
'lab_manager' => ['pathology'],
'pathologist' => ['pathology'],
'blood_bank_officer' => ['blood_bank'],
'blood_bank_manager' => ['blood_bank'],
'pharmacist' => [], // Pharmacy is core, not a specialty module
'dentist' => ['dentistry'],
'psychiatrist' => ['psychiatry'],
'physiotherapist' => ['physiotherapy'],
'midwife' => ['maternity'],
'pediatrician' => ['pediatrics'],
'oncologist' => ['oncology', 'infusion'],
'dialysis_nurse' => ['renal'],
'ambulance_staff' => ['ambulance', 'emergency'],
'receptionist' => [], // registration / queues — refer via queue.manage
'billing_officer' => [],
'cashier' => [],
'accountant' => [],
'department_manager' => null, // department analytics — all enabled modules view
'nurse' => [], // legacy limited — no specialty manage
];
/**
* @var array<string, list<string>> * @var array<string, list<string>>
*/ */
protected array $roleAbilities = [ protected array $roleAbilities = [
'super_admin' => ['*'], 'super_admin' => ['*'],
'hospital_admin' => ['*'], 'hospital_admin' => ['*'],
'receptionist' => [
'dashboard.view', 'patients.view', 'patients.manage', 'emergency_physician' => [
'appointments.view', 'appointments.manage', 'queue.manage', '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',
'emergency.queue.view', 'emergency.discharge',
'radiology.request', 'pathology.request', 'bloodbank.request',
'cardiology.view',
'queue.patient_board',
'service_queues.console', 'service_queues.console',
], ],
'ed_nurse' => [
'dashboard.view', 'patients.view', 'appointments.view',
'consultations.view',
'vitals.manage',
'assessments.view', 'assessments.capture',
'emergency.queue.view',
'radiology.request', 'pathology.request', 'bloodbank.request',
// cannot: emergency.discharge, consultations.manage
'service_queues.console',
],
'general_physician' => [
'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',
'emergency.queue.view', 'emergency.discharge',
'cardiology.view', 'pediatrics.view', 'ent.view', 'dermatology.view',
'queue.patient_board',
'service_queues.console',
],
// Legacy alias — same abilities as general_physician (desk specialists
// still narrowed by SpecialtyModuleService keyword matching).
'doctor' => [ 'doctor' => [
'dashboard.view', 'patients.view', 'appointments.view', 'dashboard.view', 'patients.view', 'appointments.view',
'consultations.view', 'consultations.manage', 'consultations.view', 'consultations.manage',
'investigations.request', 'prescriptions.manage', 'investigations.request', 'prescriptions.manage',
'lab.view', 'lab.results.view', 'lab.view', 'lab.results.view',
'vitals.manage',
'assessments.view', 'assessments.capture', 'assessments.manage', 'assessments.view', 'assessments.capture', 'assessments.manage',
'pathways.manage', 'pathways.manage',
'emergency.queue.view', 'emergency.discharge',
'cardiology.view', 'pediatrics.view', 'ent.view', 'dermatology.view',
'queue.patient_board',
'service_queues.console', 'service_queues.console',
], ],
'nurse' => [ 'surgeon' => [
'dashboard.view', 'patients.view', 'appointments.view', 'dashboard.view', 'patients.view', 'appointments.view',
'consultations.view', 'vitals.manage', 'consultations.view', 'consultations.manage',
'investigations.request', 'prescriptions.manage',
'lab.view', 'lab.results.view',
'vitals.manage',
'assessments.view', 'assessments.capture', 'assessments.manage',
'pathways.manage',
'radiology.request', 'pathology.request', 'bloodbank.request',
'service_queues.console', 'service_queues.console',
],
'theatre_nurse' => [
'dashboard.view', 'patients.view', 'appointments.view',
'consultations.view',
'vitals.manage',
'assessments.view', 'assessments.capture', 'assessments.view', 'assessments.capture',
'bloodbank.request',
'service_queues.console',
],
'radiologist' => [
'dashboard.view', 'patients.view', 'appointments.view',
'consultations.view', 'consultations.manage',
'investigations.request',
'lab.view', 'lab.results.view',
'radiology.view', 'radiology.report',
'assessments.view', 'assessments.capture',
'service_queues.console',
],
'radiographer' => [
'dashboard.view', 'patients.view', 'appointments.view',
'consultations.view',
'radiology.view', 'radiology.capture',
'service_queues.console',
], ],
'lab_technician' => [ 'lab_technician' => [
'dashboard.view', 'patients.view', 'lab.view', 'lab.manage', 'dashboard.view', 'patients.view',
'lab.view', 'lab.manage',
'pathology.view', 'pathology.result.enter',
// cannot: pathology.result.approve, lab.catalog.manage
'service_queues.console', 'service_queues.console',
], ],
'lab_manager' => [ 'lab_manager' => [
'dashboard.view', 'patients.view', 'lab.view', 'lab.manage',
'lab.catalog.manage', 'lab.catalog.manage',
'pathology.result.approve',
'analytics.lab.view',
'lab.admin',
],
'pathologist' => [
'dashboard.view', 'patients.view', 'appointments.view',
'consultations.view', 'consultations.manage',
'lab.view', 'lab.manage', 'lab.results.view',
'pathology.view', 'pathology.result.enter', 'pathology.result.approve',
'assessments.view', 'assessments.capture',
'service_queues.console', 'service_queues.console',
], ],
'pharmacist' => [ 'blood_bank_officer' => [
'dashboard.view', 'patients.view', 'prescriptions.view', 'prescriptions.dispense', 'dashboard.view', 'patients.view',
'pharmacy.view', 'pharmacy.manage', 'blood_bank.view', 'blood_bank.issue',
'service_queues.console', 'service_queues.console',
], ],
'blood_bank_manager' => [ 'blood_bank_manager' => [
'blood_bank.manage',
'blood_bank.inventory',
'blood_bank.reports',
],
'pharmacist' => [
'dashboard.view', 'patients.view', 'dashboard.view', 'patients.view',
'blood_bank.view', 'blood_bank.manage', 'prescriptions.view', 'prescriptions.dispense',
'pharmacy.view', 'pharmacy.manage',
'service_queues.console', 'service_queues.console',
], ],
// Cashiers run the Billing desk (bills / payments). They do not get 'dentist' => [
// Ladill POS app access — see filterAllowedAppSlugs() / DemoWorld. 'dashboard.view', 'patients.view', 'appointments.view',
'consultations.view', 'consultations.manage',
'investigations.request', 'prescriptions.manage',
'vitals.manage',
'assessments.view', 'assessments.capture', 'assessments.manage',
'pathways.manage',
'dentistry.view', 'dentistry.manage',
'service_queues.console',
],
'psychiatrist' => [
'dashboard.view', 'patients.view', 'appointments.view',
'consultations.view', 'consultations.manage',
'prescriptions.manage',
'assessments.view', 'assessments.capture', 'assessments.manage',
'pathways.manage',
'psychiatry.view', 'psychiatry.manage',
'service_queues.console',
],
'physiotherapist' => [
'dashboard.view', 'patients.view', 'appointments.view',
'consultations.view', 'consultations.manage',
'vitals.manage',
'assessments.view', 'assessments.capture',
'physiotherapy.view', 'physiotherapy.manage',
'service_queues.console',
],
'midwife' => [
'dashboard.view', 'patients.view', 'appointments.view',
'consultations.view', 'consultations.manage',
'vitals.manage',
'assessments.view', 'assessments.capture', 'assessments.manage',
'maternity.view', 'maternity.manage',
'service_queues.console',
],
'pediatrician' => [
'dashboard.view', 'patients.view', 'appointments.view',
'consultations.view', 'consultations.manage',
'investigations.request', 'prescriptions.manage',
'vitals.manage',
'assessments.view', 'assessments.capture', 'assessments.manage',
'pathways.manage',
'pediatrics.view', 'pediatrics.manage',
'queue.patient_board',
'service_queues.console',
],
'oncologist' => [
'dashboard.view', 'patients.view', 'appointments.view',
'consultations.view', 'consultations.manage',
'investigations.request', 'prescriptions.manage',
'vitals.manage',
'assessments.view', 'assessments.capture', 'assessments.manage',
'pathways.manage',
'oncology.view', 'oncology.manage',
'infusion.view', 'infusion.manage',
'service_queues.console',
],
'dialysis_nurse' => [
'dashboard.view', 'patients.view', 'appointments.view',
'consultations.view',
'vitals.manage',
'assessments.view', 'assessments.capture',
'renal.view', 'renal.manage',
'service_queues.console',
],
'ambulance_staff' => [
'dashboard.view', 'patients.view', 'appointments.view',
'consultations.view',
'vitals.manage',
'assessments.view', 'assessments.capture',
'ambulance.view', 'ambulance.manage',
'emergency.queue.view',
'service_queues.console',
],
'receptionist' => [
'dashboard.view', 'patients.view', 'patients.manage',
'appointments.view', 'appointments.manage', 'queue.manage',
'service_queues.console',
],
'billing_officer' => [
'dashboard.view', 'patients.view',
'bills.view', 'bills.manage',
'insurance.view', 'patient_accounts.view',
// cannot: payments.manage (receive payments)
'service_queues.console',
],
// Cashiers collect payments — cannot edit invoices (no bills.manage).
'cashier' => [ 'cashier' => [
'dashboard.view', 'patients.view', 'bills.view', 'bills.manage', 'payments.manage', 'dashboard.view', 'patients.view',
'bills.view',
'payments.manage', 'payments.refund', 'receipts.view',
'service_queues.console', 'service_queues.console',
], ],
'accountant' => [ 'accountant' => [
'dashboard.view', 'bills.view', 'reports.finance.view', 'reports.finance.export', 'dashboard.view', 'bills.view',
'reports.finance.view', 'reports.finance.export',
'accounting.view', 'accounting.gl', 'accounting.reconcile',
'audit.view', 'audit.export', 'audit.view', 'audit.export',
], ],
'department_manager' => [
'dashboard.view', 'patients.view', 'appointments.view',
'consultations.view',
'analytics.department.view',
'reports.finance.view',
'service_queues.console',
],
// Legacy nurse — floor vitals / assessments only; no specialty modules.
'nurse' => [
'dashboard.view', 'patients.view', 'appointments.view',
'consultations.view', 'vitals.manage',
'assessments.view', 'assessments.capture',
'service_queues.console',
],
]; ];
/** @var list<string> */ /** Clinical roles that staff the floor (queues, specialty desks, counters). */
protected array $adminAbilities = [ protected array $floorCareRoles = [
'admin.branches.view', 'admin.branches.manage', 'emergency_physician', 'ed_nurse', 'general_physician', 'doctor',
'admin.departments.view', 'admin.departments.manage', 'surgeon', 'theatre_nurse', 'radiologist', 'radiographer',
'admin.practitioners.view', 'admin.practitioners.manage', 'lab_technician', 'lab_manager', 'pathologist',
'admin.members.view', 'admin.members.manage', 'blood_bank_officer', 'blood_bank_manager',
'settings.view', 'settings.manage', 'pharmacist', 'dentist', 'psychiatrist', 'physiotherapist',
'lab.catalog.manage', 'midwife', 'pediatrician', 'oncologist', 'dialysis_nurse',
'blood_bank.view', 'blood_bank.manage', 'ambulance_staff', 'receptionist', 'cashier', 'billing_officer',
'devices.view', 'devices.manage', 'nurse',
'displays.view', 'displays.manage', ];
'audit.view', 'audit.export',
/** Roles that get practitioner desks / multi-branch clinical assignment. */
protected array $clinicalPractitionerRoles = [
'emergency_physician', 'general_physician', 'doctor', 'surgeon',
'radiologist', 'pathologist', 'dentist', 'psychiatrist',
'physiotherapist', 'midwife', 'pediatrician', 'oncologist',
'ed_nurse', 'theatre_nurse', 'dialysis_nurse', 'ambulance_staff',
'radiographer', 'lab_technician', 'lab_manager',
'blood_bank_officer', 'blood_bank_manager', 'pharmacist', 'nurse',
]; ];
public function can(?Member $member, string $ability): bool public function can(?Member $member, string $ability): bool
@@ -92,7 +338,7 @@ class CarePermissions
return false; return false;
} }
$abilities = $this->roleAbilities[$member->role] ?? []; $abilities = $this->resolvedAbilities((string) $member->role);
if (in_array('*', $abilities, true)) { if (in_array('*', $abilities, true)) {
return true; return true;
@@ -101,11 +347,88 @@ class CarePermissions
return in_array($ability, $abilities, true); return in_array($ability, $abilities, true);
} }
/**
* @return list<string>
*/
public function resolvedAbilities(string $role): array
{
$seen = [];
$merged = [];
$stack = [$role];
while ($stack !== []) {
$current = array_pop($stack);
if (isset($seen[$current])) {
continue;
}
$seen[$current] = true;
foreach ($this->roleInherits[$current] ?? [] as $parent) {
$stack[] = $parent;
}
foreach ($this->roleAbilities[$current] ?? [] as $ability) {
$merged[$ability] = true;
}
}
return array_keys($merged);
}
public function isAdmin(?Member $member): bool public function isAdmin(?Member $member): bool
{ {
return $member !== null && in_array($member->role, ['super_admin', 'hospital_admin'], true); return $member !== null && in_array($member->role, ['super_admin', 'hospital_admin'], true);
} }
/**
* Primary specialty module keys for this member's role.
*
* @return list<string>|null null = unrestricted specialty (admins / dept manager use other rules)
*/
public function primaryAppsFor(?Member $member): ?array
{
if ($member === null) {
return [];
}
if ($this->isAdmin($member)) {
return null;
}
$role = (string) $member->role;
if (! array_key_exists($role, $this->rolePrimaryApps)) {
return [];
}
return $this->rolePrimaryApps[$role];
}
/**
* Whether the role matrix lists this specialty module as a primary app.
*/
public function roleHasPrimaryApp(?Member $member, string $moduleKey): bool
{
$apps = $this->primaryAppsFor($member);
if ($apps === null) {
return true;
}
return in_array($moduleKey, $apps, true);
}
/**
* Single-desk specialist roles (hide Specialty nav group).
*/
public function isSingleAppSpecialist(?Member $member): bool
{
$apps = $this->primaryAppsFor($member);
if ($apps === null) {
return false;
}
return count($apps) === 1;
}
/** /**
* Floor care: queues, specialty clinics, lab/pharmacy counters. * Floor care: queues, specialty clinics, lab/pharmacy counters.
* Facility admins manage settings/staff they do not staff waiting lines. * Facility admins manage settings/staff they do not staff waiting lines.
@@ -116,27 +439,53 @@ class CarePermissions
return false; return false;
} }
return in_array($member->role, [ return in_array($member->role, $this->floorCareRoles, true);
'doctor',
'nurse',
'lab_technician',
'lab_manager',
'pharmacist',
'blood_bank_manager',
'receptionist',
'cashier',
], true);
} }
/** /**
* Dedicated patient-flow board (GP Queue home / call-next board). * Dedicated patient-flow board (GP Queue home / call-next board).
* Doctors only nurses, reception, pharmacy, and other floor roles use * Permission-gated not a role string check. Facility admins do not staff this board.
* specialty module lists and service queues instead.
* Facility admins manage settings/staff and do not staff this board.
*/ */
public function canAccessPatientQueue(?Member $member): bool public function canAccessPatientQueue(?Member $member): bool
{ {
return $member !== null && $member->role === 'doctor'; if ($member === null || $this->isAdmin($member)) {
return false;
}
return $this->can($member, 'queue.patient_board');
}
/**
* @return list<string>
*/
public function clinicalPractitionerRoles(): array
{
return $this->clinicalPractitionerRoles;
}
/**
* @return list<string>
*/
public function floorCareRoles(): array
{
return $this->floorCareRoles;
}
/**
* Roles that may switch / multi-site like legacy doctors (practitioner branches).
*/
public function usesPractitionerBranchScope(?Member $member): bool
{
if ($member === null) {
return false;
}
return $this->can($member, 'queue.patient_board')
|| in_array($member->role, [
'emergency_physician', 'general_physician', 'doctor', 'surgeon',
'radiologist', 'pathologist', 'dentist', 'psychiatrist',
'pediatrician', 'oncologist', 'physiotherapist', 'midwife',
], true);
} }
/** /**
+1 -1
View File
@@ -820,7 +820,7 @@ class CareQueueBridge
fn ($p) => ($p['kind'] ?? '') === 'practitioner' && (int) ($p['ref_id'] ?? 0) === $practitionerId fn ($p) => ($p['kind'] ?? '') === 'practitioner' && (int) ($p['ref_id'] ?? 0) === $practitionerId
); );
} }
if ($member && $member->role === 'doctor') { if ($member && app(CarePermissions::class)->usesPractitionerBranchScope($member)) {
$prac = Practitioner::owned($organization->owner_ref) $prac = Practitioner::owned($organization->owner_ref)
->where('organization_id', $organization->id) ->where('organization_id', $organization->id)
->where('branch_id', $branchId) ->where('branch_id', $branchId)
+4 -2
View File
@@ -920,8 +920,10 @@ class DemoTenantSeeder
// One doctor = one branch. Only link the specialty desk on their home site. // One doctor = one branch. Only link the specialty desk on their home site.
$assignMember = $member && $homeBranchId > 0 && (int) $branch->id === $homeBranchId; $assignMember = $member && $homeBranchId > 0 && (int) $branch->id === $homeBranchId;
$stableRef = sprintf('demo-specialty-%s-%s', $key, $branch->id); $stableRef = sprintf('demo-specialty-%s-%s', $key, $branch->id);
$doctorName = (string) (\App\Support\DemoWorld::SPECIALTY_DOCTORS[$key] $specialtyMeta = \App\Support\DemoWorld::SPECIALTY_DOCTORS[$key] ?? null;
?? ('Dr. '.($definition['label'] ?? ucfirst($key)))); $doctorName = is_array($specialtyMeta)
? (string) ($specialtyMeta['name'] ?? ('Dr. '.($definition['label'] ?? ucfirst($key))))
: (string) ($specialtyMeta ?? ('Dr. '.($definition['label'] ?? ucfirst($key))));
if ($assignMember && is_array($staff) && filled($staff['name'] ?? null)) { if ($assignMember && is_array($staff) && filled($staff['name'] ?? null)) {
$practitionerName = (string) $staff['name']; $practitionerName = (string) $staff['name'];
} elseif ($assignMember) { } elseif ($assignMember) {
+2 -2
View File
@@ -282,7 +282,7 @@ class OrganizationResolver
return null; return null;
} }
if ($member->role === 'doctor') { if (app(CarePermissions::class)->usesPractitionerBranchScope($member)) {
return $this->doctorAssignedBranchIds($member); return $this->doctorAssignedBranchIds($member);
} }
@@ -356,7 +356,7 @@ class OrganizationResolver
return null; return null;
} }
if ($member->role === 'doctor') { if (app(CarePermissions::class)->usesPractitionerBranchScope($member)) {
$ids = $this->doctorAssignedBranchIds($member); $ids = $this->doctorAssignedBranchIds($member);
if ($ids === []) { if ($ids === []) {
return 0; return 0;
+83 -9
View File
@@ -200,10 +200,10 @@ class SpecialtyModuleService
/** /**
* Whether the sidebar should show the Specialty nav group. * Whether the sidebar should show the Specialty nav group.
* *
* Desk specialists only receive their own matching module(s), so a * Single-app specialists (dentist, radiologist, ) and legacy desk
* Specialty Dentistry (etc.) group is redundant they use dashboard * specialists only receive their own module(s), so a Specialty group is
* cards / direct routes instead. GPs, nurses, and other multi-module * redundant they use dashboard cards / direct routes instead.
* roles keep the group for referral and cross-module access. * Multi-app clinical roles keep the group.
*/ */
public function shouldShowSpecialtyNav(Organization $organization, ?Member $member): bool public function shouldShowSpecialtyNav(Organization $organization, ?Member $member): bool
{ {
@@ -216,6 +216,11 @@ class SpecialtyModuleService
return false; return false;
} }
$permissions = app(CarePermissions::class);
if ($permissions->isSingleAppSpecialist($member)) {
return false;
}
if ($this->isDeskSpecialist($organization, $member)) { if ($this->isDeskSpecialist($organization, $member)) {
return false; return false;
} }
@@ -283,6 +288,9 @@ class SpecialtyModuleService
/** /**
* Full clinical / queue edit access for the module. * Full clinical / queue edit access for the module.
*
* Role primary apps (CarePermissions) are the source of truth for the
* RBAC matrix. Legacy `doctor` desk specialists still use keyword matching.
*/ */
public function memberCanManage(Organization $organization, ?Member $member, string $key): bool public function memberCanManage(Organization $organization, ?Member $member, string $key): bool
{ {
@@ -290,7 +298,8 @@ class SpecialtyModuleService
return false; return false;
} }
if (app(CarePermissions::class)->isAdmin($member)) { $permissions = app(CarePermissions::class);
if ($permissions->isAdmin($member)) {
return true; return true;
} }
@@ -299,6 +308,24 @@ class SpecialtyModuleService
return false; return false;
} }
// Department managers get analytics view, not clinical manage.
if ((string) $member->role === 'department_manager') {
return false;
}
$primaryApps = $permissions->primaryAppsFor($member);
// Matrix-driven roles (including GP / EP / specialists with app lists).
if (is_array($primaryApps)) {
// Legacy doctor + specialty desk: only matching modules.
if ((string) $member->role === 'doctor'
&& $this->isDeskSpecialist($organization, $member)) {
return $this->specialistBelongsToModule($organization, $member, $key);
}
return in_array($key, $primaryApps, true);
}
// Desk specialists only manage modules that match their specialty / assignment. // Desk specialists only manage modules that match their specialty / assignment.
if ($this->isDeskSpecialist($organization, $member) if ($this->isDeskSpecialist($organization, $member)
&& ! $this->specialistBelongsToModule($organization, $member, $key)) { && ! $this->specialistBelongsToModule($organization, $member, $key)) {
@@ -314,7 +341,7 @@ class SpecialtyModuleService
if ($this->roleListed($member, $supportRoles)) { if ($this->roleListed($member, $supportRoles)) {
return true; return true;
} }
if ($role === 'doctor' && $this->roleListed($member, $manageRoles)) { if (in_array($role, ['doctor', 'general_physician'], true) && $this->roleListed($member, $manageRoles)) {
return $this->specialistBelongsToModule($organization, $member, $key); return $this->specialistBelongsToModule($organization, $member, $key);
} }
@@ -339,6 +366,33 @@ class SpecialtyModuleService
return false; return false;
} }
$permissions = app(CarePermissions::class);
if ((string) $member->role === 'department_manager') {
return true;
}
$primaryApps = $permissions->primaryAppsFor($member);
if (is_array($primaryApps)) {
// Matrix roles: view only what they can manage (no cross-module GP view
// unless listed — general_physician gets cardiology manage via apps).
if ((string) $member->role === 'doctor'
&& $this->isDeskSpecialist($organization, $member)) {
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;
}
}
return in_array($key, $primaryApps, true);
}
// Desk specialists do not get cross-module view; only their matching desks. // Desk specialists do not get cross-module view; only their matching desks.
if ($this->isDeskSpecialist($organization, $member) if ($this->isDeskSpecialist($organization, $member)
&& ! $this->specialistBelongsToModule($organization, $member, $key)) { && ! $this->specialistBelongsToModule($organization, $member, $key)) {
@@ -366,6 +420,25 @@ class SpecialtyModuleService
return false; return false;
} }
$permissions = app(CarePermissions::class);
$primaryApps = $permissions->primaryAppsFor($member);
if (is_array($primaryApps)) {
if ((string) $member->role === 'doctor'
&& $this->isDeskSpecialist($organization, $member)) {
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;
}
}
return in_array($key, $primaryApps, true);
}
// Desk specialists do not get cross-module referral nav; GPs keep refer_roles. // Desk specialists do not get cross-module referral nav; GPs keep refer_roles.
if ($this->isDeskSpecialist($organization, $member) if ($this->isDeskSpecialist($organization, $member)
&& ! $this->specialistBelongsToModule($organization, $member, $key)) { && ! $this->specialistBelongsToModule($organization, $member, $key)) {
@@ -381,8 +454,9 @@ class SpecialtyModuleService
} }
/** /**
* Doctor with a specialty desk: non-GP specialty matching a catalog module, or * Legacy doctor with a specialty desk: non-GP specialty matching a catalog
* assigned to a provisioned specialty department. GPs and non-doctors are false. * module, or assigned to a provisioned specialty department.
* Dedicated specialist roles use primary apps instead.
*/ */
public function isDeskSpecialist(Organization $organization, Member $member): bool public function isDeskSpecialist(Organization $organization, Member $member): bool
{ {
@@ -391,7 +465,7 @@ class SpecialtyModuleService
return $this->deskSpecialistCache[$cacheKey]; return $this->deskSpecialistCache[$cacheKey];
} }
if ((string) $member->role !== 'doctor') { if (! in_array((string) $member->role, ['doctor'], true)) {
return $this->deskSpecialistCache[$cacheKey] = false; return $this->deskSpecialistCache[$cacheKey] = false;
} }
+87 -31
View File
@@ -187,34 +187,52 @@ final class DemoWorld
* }>> * }>>
*/ */
/** /**
* Care specialty module doctors (Pro / Enterprise). Emails: demo-{tier}-{key}@ladill.com * Care specialty module clinicians (Pro / Enterprise).
* Emails: demo-{tier}-{key}@ladill.com
* Roles follow the Care RBAC matrix when a dedicated slug exists;
* remaining modules stay on legacy `doctor` + desk specialty keywords.
* *
* @var array<string, string> module key => display name * @var array<string, array{name: string, role: string}>
*/ */
public const SPECIALTY_DOCTORS = [ public const SPECIALTY_DOCTORS = [
'emergency' => 'Dr. Kojo Emergency', 'emergency' => ['name' => 'Dr. Kojo Emergency', 'role' => 'emergency_physician'],
'blood_bank' => 'Dr. Ama Blood Bank', 'blood_bank' => ['name' => 'Ama Blood Bank Officer', 'role' => 'blood_bank_officer'],
'dentistry' => 'Dr. Ama Dental', 'dentistry' => ['name' => 'Dr. Ama Dental', 'role' => 'dentist'],
'ophthalmology' => 'Dr. Kofi Eye', 'ophthalmology' => ['name' => 'Dr. Kofi Eye', 'role' => 'doctor'],
'physiotherapy' => 'Dr. Efua Physio', 'physiotherapy' => ['name' => 'Dr. Efua Physio', 'role' => 'physiotherapist'],
'maternity' => 'Dr. Abena Maternity', 'maternity' => ['name' => 'Abena Midwife', 'role' => 'midwife'],
'radiology' => 'Dr. Yaw Radiology', 'radiology' => ['name' => 'Dr. Yaw Radiology', 'role' => 'radiologist'],
'cardiology' => 'Dr. Kwesi Cardiology', 'cardiology' => ['name' => 'Dr. Kwesi Cardiology', 'role' => 'doctor'],
'psychiatry' => 'Dr. Adwoa Psychiatry', 'psychiatry' => ['name' => 'Dr. Adwoa Psychiatry', 'role' => 'psychiatrist'],
'pediatrics' => 'Dr. Fiifi Pediatrics', 'pediatrics' => ['name' => 'Dr. Fiifi Pediatrics', 'role' => 'pediatrician'],
'orthopedics' => 'Dr. Kojo Orthopedics', 'orthopedics' => ['name' => 'Dr. Kojo Orthopedics', 'role' => 'doctor'],
'ent' => 'Dr. Esi ENT', 'ent' => ['name' => 'Dr. Esi ENT', 'role' => 'doctor'],
'oncology' => 'Dr. Mansa Oncology', 'oncology' => ['name' => 'Dr. Mansa Oncology', 'role' => 'oncologist'],
'renal' => 'Dr. Nana Renal', 'renal' => ['name' => 'Dr. Nana Renal', 'role' => 'doctor'],
'surgery' => 'Dr. Kwadwo Surgery', 'surgery' => ['name' => 'Dr. Kwadwo Surgery', 'role' => 'surgeon'],
'vaccination' => 'Dr. Afia Vaccination', 'vaccination' => ['name' => 'Dr. Afia Vaccination', 'role' => 'doctor'],
'pathology' => 'Dr. Yaw Pathology', 'pathology' => ['name' => 'Dr. Yaw Pathology', 'role' => 'pathologist'],
'infusion' => 'Dr. Akosua Infusion', 'infusion' => ['name' => 'Dr. Akosua Infusion', 'role' => 'doctor'],
'dermatology' => 'Dr. Ama Dermatology', 'dermatology' => ['name' => 'Dr. Ama Dermatology', 'role' => 'doctor'],
'podiatry' => 'Dr. Kofi Podiatry', 'podiatry' => ['name' => 'Dr. Kofi Podiatry', 'role' => 'doctor'],
'fertility' => 'Dr. Abena Fertility', 'fertility' => ['name' => 'Dr. Abena Fertility', 'role' => 'doctor'],
'child_welfare' => 'Dr. Efua Child Welfare', 'child_welfare' => ['name' => 'Dr. Efua Child Welfare', 'role' => 'doctor'],
'ambulance' => 'Dr. Kojo Ambulance', 'ambulance' => ['name' => 'Kojo Ambulance', 'role' => 'ambulance_staff'],
];
/**
* Extra Care matrix roles (not covered by SPECIALTY_DOCTORS keys).
*
* @var list<array{key: string, name: string, role: string}>
*/
public const CARE_MATRIX_STAFF = [
['key' => 'ed-nurse', 'name' => 'Ama ED Nurse', 'role' => 'ed_nurse'],
['key' => 'theatre-nurse', 'name' => 'Efua Theatre Nurse', 'role' => 'theatre_nurse'],
['key' => 'radiographer', 'name' => 'Kofi Radiographer', 'role' => 'radiographer'],
['key' => 'dialysis-nurse', 'name' => 'Abena Dialysis Nurse', 'role' => 'dialysis_nurse'],
['key' => 'billing-officer', 'name' => 'Yaw Billing Officer', 'role' => 'billing_officer'],
['key' => 'department-manager', 'name' => 'Adwoa Department Manager', 'role' => 'department_manager'],
['key' => 'hospital-admin', 'name' => 'Kojo Hospital Admin', 'role' => 'hospital_admin'],
]; ];
public const STAFF = [ public const STAFF = [
@@ -243,7 +261,14 @@ final class DemoWorld
'email' => 'demo-pro-doctor@ladill.com', 'email' => 'demo-pro-doctor@ladill.com',
'name' => 'Dr. Kwame Mensah (Pro)', 'name' => 'Dr. Kwame Mensah (Pro)',
'apps' => ['care'], 'apps' => ['care'],
'roles' => ['care' => 'doctor'], 'roles' => ['care' => 'general_physician'],
],
[
'key' => 'general-physician',
'email' => 'demo-pro-general-physician@ladill.com',
'name' => 'Dr. Ama General Physician (Pro)',
'apps' => ['care'],
'roles' => ['care' => 'general_physician'],
], ],
[ [
'key' => 'nurse', 'key' => 'nurse',
@@ -345,7 +370,14 @@ final class DemoWorld
'email' => 'demo-enterprise-doctor@ladill.com', 'email' => 'demo-enterprise-doctor@ladill.com',
'name' => 'Dr. Kwame Mensah', 'name' => 'Dr. Kwame Mensah',
'apps' => ['care'], 'apps' => ['care'],
'roles' => ['care' => 'doctor'], 'roles' => ['care' => 'general_physician'],
],
[
'key' => 'general-physician',
'email' => 'demo-enterprise-general-physician@ladill.com',
'name' => 'Dr. Ama General Physician',
'apps' => ['care'],
'roles' => ['care' => 'general_physician'],
], ],
[ [
'key' => 'nurse', 'key' => 'nurse',
@@ -572,7 +604,7 @@ final class DemoWorld
return $roster; return $roster;
} }
foreach (self::SPECIALTY_DOCTORS as $key => $name) { foreach (self::SPECIALTY_DOCTORS as $key => $meta) {
$email = sprintf('demo-%s-%s@ladill.com', $tier, $key); $email = sprintf('demo-%s-%s@ladill.com', $tier, $key);
$already = false; $already = false;
foreach ($roster as $row) { foreach ($roster as $row) {
@@ -584,13 +616,37 @@ final class DemoWorld
if ($already) { if ($already) {
continue; continue;
} }
$name = is_array($meta) ? (string) ($meta['name'] ?? $key) : (string) $meta;
$role = is_array($meta) ? (string) ($meta['role'] ?? 'doctor') : 'doctor';
$suffix = $tier === 'pro' ? ' (Pro)' : '';
$roster[] = [
'key' => $key,
'email' => $email,
'name' => $name.$suffix,
'apps' => ['care'],
'roles' => ['care' => $role],
];
}
foreach (self::CARE_MATRIX_STAFF as $extra) {
$email = sprintf('demo-%s-%s@ladill.com', $tier, $extra['key']);
$already = false;
foreach ($roster as $row) {
if (strtolower((string) ($row['email'] ?? '')) === $email) {
$already = true;
break;
}
}
if ($already) {
continue;
}
$suffix = $tier === 'pro' ? ' (Pro)' : ''; $suffix = $tier === 'pro' ? ' (Pro)' : '';
$roster[] = [ $roster[] = [
'key' => $key, 'key' => $extra['key'],
'email' => $email, 'email' => $email,
'name' => $name.$suffix, 'name' => $extra['name'].$suffix,
'apps' => ['care'], 'apps' => ['care'],
'roles' => ['care' => 'doctor'], 'roles' => ['care' => $extra['role']],
]; ];
} }
+23 -4
View File
@@ -5,13 +5,32 @@ return [
'roles' => [ 'roles' => [
'super_admin' => 'Super Administrator', 'super_admin' => 'Super Administrator',
'hospital_admin' => 'Hospital Administrator', 'hospital_admin' => 'Hospital Administrator',
'receptionist' => 'Receptionist', 'department_manager' => 'Department Manager',
'doctor' => 'Doctor', 'emergency_physician' => 'Emergency Physician',
'nurse' => 'Nurse', 'ed_nurse' => 'ED Nurse',
'general_physician' => 'General Physician',
'doctor' => 'Doctor (legacy)',
'surgeon' => 'Surgeon',
'theatre_nurse' => 'Theatre Nurse',
'radiologist' => 'Radiologist',
'radiographer' => 'Radiographer',
'lab_technician' => 'Laboratory Technician', 'lab_technician' => 'Laboratory Technician',
'lab_manager' => 'Laboratory Manager', 'lab_manager' => 'Laboratory Manager',
'pharmacist' => 'Pharmacist', 'pathologist' => 'Pathologist',
'blood_bank_officer' => 'Blood Bank Officer',
'blood_bank_manager' => 'Blood Bank Manager', 'blood_bank_manager' => 'Blood Bank Manager',
'pharmacist' => 'Pharmacist',
'dentist' => 'Dentist',
'psychiatrist' => 'Psychiatrist',
'physiotherapist' => 'Physiotherapist',
'midwife' => 'Midwife',
'pediatrician' => 'Pediatrician',
'oncologist' => 'Oncologist',
'dialysis_nurse' => 'Dialysis Nurse',
'ambulance_staff' => 'Ambulance Staff',
'nurse' => 'Nurse (legacy)',
'receptionist' => 'Receptionist',
'billing_officer' => 'Billing Officer',
'cashier' => 'Cashier', 'cashier' => 'Cashier',
'accountant' => 'Accountant', 'accountant' => 'Accountant',
], ],
+24 -34
View File
@@ -4,20 +4,10 @@
* Specialty practice modules (Settings Modules). * Specialty practice modules (Settings Modules).
* Pro-gated via plans.*.features specialty_modules. * Pro-gated via plans.*.features specialty_modules.
* *
* Access tiers (RBAC via Care member roles): * Primary specialty visibility is driven by CarePermissions::primaryAppsFor()
* - general: broad day-to-day access for listed roles (GPs = doctor without specialty desk) * (role module keys). Config roles / support_roles / view_roles / refer_roles
* - limited: GPs + nurses (and listed roles); desk specialists only when specialty keywords match * remain for legacy doctor desk-specialist keyword matching and GP refer/view
* - restricted: specialist doctors (department / specialty keywords) + optional support_roles; * outside the primary-app matrix.
* GPs use view_roles / refer_roles; desk specialists only see matching modules
*
* Prefer existing clinical role slugs on allowlists. Dedicated managers (e.g. blood_bank_manager,
* lab_manager) are intentional product roles add them to the relevant module roles lists.
*
* 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).
* *
* @return array<string, array<string, mixed>> * @return array<string, array<string, mixed>>
*/ */
@@ -133,8 +123,8 @@ return [
'access' => 'restricted', 'access' => 'restricted',
'roles' => ['doctor'], 'roles' => ['doctor'],
'support_roles' => ['nurse'], 'support_roles' => ['nurse'],
'view_roles' => ['doctor', 'nurse', 'lab_technician'], 'view_roles' => ['doctor', 'general_physician', 'nurse', 'lab_technician'],
'refer_roles' => ['doctor', 'nurse'], 'refer_roles' => ['doctor', 'general_physician', 'nurse'],
'specialist_keywords' => ['cardio', 'cardiology', 'heart', 'ecg'], 'specialist_keywords' => ['cardio', 'cardiology', 'heart', 'ecg'],
], ],
'psychiatry' => [ 'psychiatry' => [
@@ -150,8 +140,8 @@ return [
'access' => 'restricted', 'access' => 'restricted',
'roles' => ['doctor'], 'roles' => ['doctor'],
'support_roles' => ['nurse'], 'support_roles' => ['nurse'],
'view_roles' => ['doctor', 'nurse'], 'view_roles' => ['doctor', 'general_physician', 'nurse'],
'refer_roles' => ['doctor', 'nurse'], 'refer_roles' => ['doctor', 'general_physician', 'nurse'],
'specialist_keywords' => ['psych', 'psychiatry', 'mental', 'behaviour'], 'specialist_keywords' => ['psych', 'psychiatry', 'mental', 'behaviour'],
], ],
'pediatrics' => [ 'pediatrics' => [
@@ -182,8 +172,8 @@ return [
'access' => 'restricted', 'access' => 'restricted',
'roles' => ['doctor'], 'roles' => ['doctor'],
'support_roles' => ['nurse'], 'support_roles' => ['nurse'],
'view_roles' => ['doctor', 'nurse'], 'view_roles' => ['doctor', 'general_physician', 'nurse'],
'refer_roles' => ['doctor', 'nurse'], 'refer_roles' => ['doctor', 'general_physician', 'nurse'],
'specialist_keywords' => ['ortho', 'orthopedic', 'orthopaedic', 'fracture'], 'specialist_keywords' => ['ortho', 'orthopedic', 'orthopaedic', 'fracture'],
], ],
'ent' => [ 'ent' => [
@@ -199,8 +189,8 @@ return [
'access' => 'restricted', 'access' => 'restricted',
'roles' => ['doctor'], 'roles' => ['doctor'],
'support_roles' => ['nurse'], 'support_roles' => ['nurse'],
'view_roles' => ['doctor', 'nurse'], 'view_roles' => ['doctor', 'general_physician', 'nurse'],
'refer_roles' => ['doctor', 'nurse'], 'refer_roles' => ['doctor', 'general_physician', 'nurse'],
'specialist_keywords' => ['ent', 'otolaryng', 'ear', 'nose', 'throat'], 'specialist_keywords' => ['ent', 'otolaryng', 'ear', 'nose', 'throat'],
], ],
'oncology' => [ 'oncology' => [
@@ -216,8 +206,8 @@ return [
'access' => 'restricted', 'access' => 'restricted',
'roles' => ['doctor'], 'roles' => ['doctor'],
'support_roles' => ['nurse'], 'support_roles' => ['nurse'],
'view_roles' => ['doctor', 'nurse', 'pharmacist'], 'view_roles' => ['doctor', 'general_physician', 'nurse', 'pharmacist'],
'refer_roles' => ['doctor', 'nurse'], 'refer_roles' => ['doctor', 'general_physician', 'nurse'],
'specialist_keywords' => ['oncol', 'cancer', 'chemo'], 'specialist_keywords' => ['oncol', 'cancer', 'chemo'],
], ],
'renal' => [ 'renal' => [
@@ -233,8 +223,8 @@ return [
'access' => 'restricted', 'access' => 'restricted',
'roles' => ['doctor'], 'roles' => ['doctor'],
'support_roles' => ['nurse'], 'support_roles' => ['nurse'],
'view_roles' => ['doctor', 'nurse'], 'view_roles' => ['doctor', 'general_physician', 'nurse'],
'refer_roles' => ['doctor', 'nurse'], 'refer_roles' => ['doctor', 'general_physician', 'nurse'],
'specialist_keywords' => ['renal', 'nephro', 'dialysis', 'kidney'], 'specialist_keywords' => ['renal', 'nephro', 'dialysis', 'kidney'],
], ],
'surgery' => [ 'surgery' => [
@@ -250,8 +240,8 @@ return [
'access' => 'restricted', 'access' => 'restricted',
'roles' => ['doctor'], 'roles' => ['doctor'],
'support_roles' => ['nurse'], 'support_roles' => ['nurse'],
'view_roles' => ['doctor', 'nurse'], 'view_roles' => ['doctor', 'general_physician', 'nurse'],
'refer_roles' => ['doctor', 'nurse'], 'refer_roles' => ['doctor', 'general_physician', 'nurse'],
'specialist_keywords' => ['surg', 'theatre', 'operat'], 'specialist_keywords' => ['surg', 'theatre', 'operat'],
], ],
'vaccination' => [ 'vaccination' => [
@@ -309,8 +299,8 @@ return [
'access' => 'restricted', 'access' => 'restricted',
'roles' => ['doctor'], 'roles' => ['doctor'],
'support_roles' => [], 'support_roles' => [],
'view_roles' => ['doctor', 'nurse'], 'view_roles' => ['doctor', 'general_physician', 'nurse'],
'refer_roles' => ['doctor', 'nurse'], 'refer_roles' => ['doctor', 'general_physician', 'nurse'],
'specialist_keywords' => ['derma', 'skin'], 'specialist_keywords' => ['derma', 'skin'],
], ],
'podiatry' => [ 'podiatry' => [
@@ -326,8 +316,8 @@ return [
'access' => 'restricted', 'access' => 'restricted',
'roles' => ['doctor'], 'roles' => ['doctor'],
'support_roles' => [], 'support_roles' => [],
'view_roles' => ['doctor', 'nurse'], 'view_roles' => ['doctor', 'general_physician', 'nurse'],
'refer_roles' => ['doctor', 'nurse'], 'refer_roles' => ['doctor', 'general_physician', 'nurse'],
'specialist_keywords' => ['podiat', 'foot'], 'specialist_keywords' => ['podiat', 'foot'],
], ],
'fertility' => [ 'fertility' => [
@@ -343,8 +333,8 @@ return [
'access' => 'restricted', 'access' => 'restricted',
'roles' => ['doctor'], 'roles' => ['doctor'],
'support_roles' => [], 'support_roles' => [],
'view_roles' => ['doctor', 'nurse'], 'view_roles' => ['doctor', 'general_physician', 'nurse'],
'refer_roles' => ['doctor', 'nurse'], 'refer_roles' => ['doctor', 'general_physician', 'nurse'],
'specialist_keywords' => ['fertil', 'ivf', 'reproduct'], 'specialist_keywords' => ['fertil', 'ivf', 'reproduct'],
], ],
'child_welfare' => [ 'child_welfare' => [
@@ -9,7 +9,12 @@
</div> </div>
<x-settings.card title="Invitation"> <x-settings.card title="Invitation">
<form method="POST" action="{{ route('care.members.store') }}" class="space-y-4" x-data="{ role: '{{ old('role', 'doctor') }}' }"> <form method="POST" action="{{ route('care.members.store') }}" class="space-y-4"
x-data="{
role: '{{ old('role', 'general_physician') }}',
practitionerRoles: @js($practitionerRoles ?? ['doctor', 'general_physician']),
get needsDesk() { return this.practitionerRoles.includes(this.role); }
}">
@csrf @csrf
@if (! empty($mailboxOptions)) @if (! empty($mailboxOptions))
@@ -41,28 +46,28 @@
<label class="block text-sm font-medium text-slate-700">Role</label> <label class="block text-sm font-medium text-slate-700">Role</label>
<select name="role" x-model="role" class="mt-1 w-full rounded-lg border-slate-300 text-sm"> <select name="role" x-model="role" class="mt-1 w-full rounded-lg border-slate-300 text-sm">
@foreach ($roles as $value => $label) @foreach ($roles as $value => $label)
<option value="{{ $value }}" @selected(old('role', 'doctor') === $value)>{{ $label }}</option> <option value="{{ $value }}" @selected(old('role', 'general_physician') === $value)>{{ $label }}</option>
@endforeach @endforeach
</select> </select>
</div> </div>
<div> <div>
<label class="block text-sm font-medium text-slate-700"> <label class="block text-sm font-medium text-slate-700">
<span x-text="role === 'doctor' ? 'Home branch' : 'Branch (optional)'"></span> <span x-text="needsDesk ? 'Home branch' : 'Branch (optional)'"></span>
</label> </label>
<select name="branch_id" class="mt-1 w-full rounded-lg border-slate-300 text-sm" <select name="branch_id" class="mt-1 w-full rounded-lg border-slate-300 text-sm"
:required="role === 'doctor'"> :required="needsDesk">
<option value="" x-text="role === 'doctor' ? 'Select a branch…' : 'No branch lock'"></option> <option value="" x-text="needsDesk ? 'Select a branch…' : 'No branch lock'"></option>
@foreach ($branches as $branch) @foreach ($branches as $branch)
<option value="{{ $branch->id }}" @selected(old('branch_id') == $branch->id)>{{ $branch->name }}</option> <option value="{{ $branch->id }}" @selected(old('branch_id') == $branch->id)>{{ $branch->name }}</option>
@endforeach @endforeach
</select> </select>
<p class="mt-1 text-xs text-slate-500" x-show="role === 'doctor'" x-cloak> <p class="mt-1 text-xs text-slate-500" x-show="needsDesk" x-cloak>
Required for doctors. Add more sites later under Practitioners (telemedicine). Required for clinical desks. Add more sites later under Practitioners (telemedicine).
</p> </p>
</div> </div>
<div class="rounded-xl border border-slate-100 bg-slate-50 p-4" x-show="role === 'doctor'" x-cloak> <div class="rounded-xl border border-slate-100 bg-slate-50 p-4" x-show="needsDesk" x-cloak>
<label class="flex items-start gap-2 text-sm"> <label class="flex items-start gap-2 text-sm">
<input type="checkbox" name="create_practitioner" value="1" class="mt-0.5" @checked(old('create_practitioner', true))> <input type="checkbox" name="create_practitioner" value="1" class="mt-0.5" @checked(old('create_practitioner', true))>
<span> <span>
+53 -13
View File
@@ -20,6 +20,8 @@ class CareBillTest extends TestCase
protected User $user; protected User $user;
protected User $cashierUser;
protected Organization $organization; protected Organization $organization;
protected Visit $visit; protected Visit $visit;
@@ -31,8 +33,14 @@ class CareBillTest extends TestCase
$this->user = User::create([ $this->user = User::create([
'public_id' => 'test-user-001', 'public_id' => 'test-user-001',
'name' => 'Test User', 'name' => 'Billing Officer',
'email' => 'test@example.com', 'email' => 'billing@example.com',
]);
$this->cashierUser = User::create([
'public_id' => 'test-cashier-001',
'name' => 'Cashier',
'email' => 'cashier@example.com',
]); ]);
$this->organization = Organization::create([ $this->organization = Organization::create([
@@ -47,6 +55,13 @@ class CareBillTest extends TestCase
'owner_ref' => $this->user->public_id, 'owner_ref' => $this->user->public_id,
'organization_id' => $this->organization->id, 'organization_id' => $this->organization->id,
'user_ref' => $this->user->public_id, 'user_ref' => $this->user->public_id,
'role' => 'billing_officer',
]);
Member::create([
'owner_ref' => $this->user->public_id,
'organization_id' => $this->organization->id,
'user_ref' => $this->cashierUser->public_id,
'role' => 'cashier', 'role' => 'cashier',
]); ]);
@@ -88,7 +103,7 @@ class CareBillTest extends TestCase
]); ]);
} }
public function test_cashier_can_generate_bill_from_visit(): void public function test_billing_officer_can_generate_bill_from_visit(): void
{ {
$this->actingAs($this->user) $this->actingAs($this->user)
->post(route('care.bills.generate', $this->visit)) ->post(route('care.bills.generate', $this->visit))
@@ -101,7 +116,24 @@ class CareBillTest extends TestCase
$this->assertDatabaseHas('care_audit_logs', ['action' => 'bill.created']); $this->assertDatabaseHas('care_audit_logs', ['action' => 'bill.created']);
} }
public function test_cashier_can_add_line_item_and_record_payments(): void public function test_cashier_cannot_edit_invoice_lines(): void
{
$this->actingAs($this->user)
->post(route('care.bills.generate', $this->visit));
$bill = Bill::firstOrFail();
$this->actingAs($this->cashierUser)
->post(route('care.bills.line-items.store', $bill), [
'type' => 'misc',
'description' => 'Dressing supplies',
'quantity' => 2,
'unit_price_minor' => 1000,
])
->assertForbidden();
}
public function test_billing_officer_can_add_line_item_and_cashier_records_payments(): void
{ {
$this->actingAs($this->user) $this->actingAs($this->user)
->post(route('care.bills.generate', $this->visit)); ->post(route('care.bills.generate', $this->visit));
@@ -123,7 +155,15 @@ class CareBillTest extends TestCase
$this->assertSame($expectedTotal, $bill->total_minor); $this->assertSame($expectedTotal, $bill->total_minor);
// Billing officer cannot receive payments.
$this->actingAs($this->user) $this->actingAs($this->user)
->post(route('care.bills.payments.store', $bill), [
'amount' => 30.00,
'method' => 'cash',
])
->assertForbidden();
$this->actingAs($this->cashierUser)
->post(route('care.bills.payments.store', $bill), [ ->post(route('care.bills.payments.store', $bill), [
'amount' => 30.00, 'amount' => 30.00,
'method' => 'cash', 'method' => 'cash',
@@ -134,7 +174,7 @@ class CareBillTest extends TestCase
$this->assertSame(Bill::STATUS_PARTIAL, $bill->status); $this->assertSame(Bill::STATUS_PARTIAL, $bill->status);
$this->assertSame(3000, $bill->amount_paid_minor); $this->assertSame(3000, $bill->amount_paid_minor);
$this->actingAs($this->user) $this->actingAs($this->cashierUser)
->post(route('care.bills.payments.store', $bill), [ ->post(route('care.bills.payments.store', $bill), [
'amount' => $bill->balance_minor / 100, 'amount' => $bill->balance_minor / 100,
'method' => 'momo', 'method' => 'momo',
@@ -153,7 +193,7 @@ class CareBillTest extends TestCase
$this->actingAs($this->user) $this->actingAs($this->user)
->post(route('care.bills.generate', $this->visit)); ->post(route('care.bills.generate', $this->visit));
$this->actingAs($this->user) $this->actingAs($this->cashierUser)
->get(route('care.bills.index')) ->get(route('care.bills.index'))
->assertOk() ->assertOk()
->assertSee('INV-'); ->assertSee('INV-');
@@ -181,14 +221,14 @@ class CareBillTest extends TestCase
'balance_minor' => 0, 'balance_minor' => 0,
]); ]);
$this->actingAs($this->user) $this->actingAs($this->cashierUser)
->get(route('care.bills.index')) ->get(route('care.bills.index'))
->assertOk() ->assertOk()
->assertSee($open->invoice_number) ->assertSee($open->invoice_number)
->assertSee('Pay') ->assertSee('Pay')
->assertDontSee($paid->invoice_number, false); ->assertDontSee($paid->invoice_number, false);
$this->actingAs($this->user) $this->actingAs($this->cashierUser)
->get(route('care.bills.index', ['status' => ''])) ->get(route('care.bills.index', ['status' => '']))
->assertOk() ->assertOk()
->assertSee($paid->invoice_number); ->assertSee($paid->invoice_number);
@@ -201,7 +241,7 @@ class CareBillTest extends TestCase
$bill = Bill::firstOrFail(); $bill = Bill::firstOrFail();
$this->actingAs($this->user) $this->actingAs($this->cashierUser)
->get(route('care.bills.index')) ->get(route('care.bills.index'))
->assertOk() ->assertOk()
->assertSee($bill->invoice_number) ->assertSee($bill->invoice_number)
@@ -211,7 +251,7 @@ class CareBillTest extends TestCase
$balanceMajor = number_format($bill->balance_minor / 100, 2, '.', ''); $balanceMajor = number_format($bill->balance_minor / 100, 2, '.', '');
$this->actingAs($this->user) $this->actingAs($this->cashierUser)
->get(route('care.bills.show', $bill)) ->get(route('care.bills.show', $bill))
->assertOk() ->assertOk()
->assertSee('Record payment') ->assertSee('Record payment')
@@ -228,9 +268,9 @@ class CareBillTest extends TestCase
$settings['queue_integration_enabled'] = true; $settings['queue_integration_enabled'] = true;
$this->organization->update(['settings' => $settings]); $this->organization->update(['settings' => $settings]);
$this->actingAs($this->user) $this->actingAs($this->cashierUser)
->post(route('care.bills.call-next'), ['branch_id' => Branch::firstOrFail()->id]) ->post(route('care.bills.call-next'))
->assertRedirect() ->assertRedirect()
->assertSessionHas('info', 'No patients waiting at the billing booth. Open an unpaid invoice above to record a walk-up payment.'); ->assertSessionHas('info');
} }
} }
+7
View File
@@ -179,6 +179,13 @@ class CareLabTest extends TestCase
$this->assertSame(InvestigationRequest::STATUS_AWAITING_REVIEW, $request->status); $this->assertSame(InvestigationRequest::STATUS_AWAITING_REVIEW, $request->status);
$this->assertTrue($request->result->is_abnormal); $this->assertTrue($request->result->is_abnormal);
// Lab techs enter results; approval requires pathology.result.approve (manager+).
$this->actingAs($this->user)
->post(route('care.lab.requests.approve', $request))
->assertForbidden();
Member::where('user_ref', $this->user->public_id)->update(['role' => 'lab_manager']);
$this->actingAs($this->user) $this->actingAs($this->user)
->post(route('care.lab.requests.approve', $request)) ->post(route('care.lab.requests.approve', $request))
->assertRedirect(); ->assertRedirect();
+3 -3
View File
@@ -167,7 +167,7 @@ class CarePatientQueueAccessTest extends TestCase
public function test_nurse_specialty_show_renders_list_not_auto_open_visit(): void public function test_nurse_specialty_show_renders_list_not_auto_open_visit(): void
{ {
[$nurseUser] = $this->makeStaff('nurse', 'nurse-spec'); [$nurseUser] = $this->makeStaff('ed_nurse', 'nurse-spec');
$modules = app(SpecialtyModuleService::class); $modules = app(SpecialtyModuleService::class);
$modules->ensureDefaultModulesProvisioned($this->organization, $this->owner->public_id); $modules->ensureDefaultModulesProvisioned($this->organization, $this->owner->public_id);
@@ -242,7 +242,7 @@ class CarePatientQueueAccessTest extends TestCase
public function test_nurse_can_open_emergency_visit_from_specialty_list(): void public function test_nurse_can_open_emergency_visit_from_specialty_list(): void
{ {
[$nurseUser] = $this->makeStaff('nurse', 'nurse-er-open'); [$nurseUser] = $this->makeStaff('ed_nurse', 'nurse-er-open');
$modules = app(SpecialtyModuleService::class); $modules = app(SpecialtyModuleService::class);
$modules->ensureDefaultModulesProvisioned($this->organization, $this->owner->public_id); $modules->ensureDefaultModulesProvisioned($this->organization, $this->owner->public_id);
@@ -315,7 +315,7 @@ class CarePatientQueueAccessTest extends TestCase
public function test_nurse_specialty_show_redirects_to_workspace_not_queue(): void public function test_nurse_specialty_show_redirects_to_workspace_not_queue(): void
{ {
[$nurseUser] = $this->makeStaff('nurse', 'nurse-spec-er'); [$nurseUser] = $this->makeStaff('ed_nurse', 'nurse-spec-er');
app(SpecialtyModuleService::class) app(SpecialtyModuleService::class)
->ensureDefaultModulesProvisioned($this->organization, $this->owner->public_id); ->ensureDefaultModulesProvisioned($this->organization, $this->owner->public_id);
+171 -61
View File
@@ -69,7 +69,7 @@ class CareSpecialtyAccessTest extends TestCase
$this->modules = app(SpecialtyModuleService::class); $this->modules = app(SpecialtyModuleService::class);
$this->modules->ensureDefaultModulesProvisioned($this->organization, $this->owner->public_id); $this->modules->ensureDefaultModulesProvisioned($this->organization, $this->owner->public_id);
foreach (['cardiology', 'infusion', 'pathology', 'dentistry'] as $key) { foreach (['cardiology', 'infusion', 'pathology', 'dentistry', 'psychiatry', 'surgery', 'radiology'] as $key) {
$this->modules->activate($this->organization->fresh(), $this->owner->public_id, $key); $this->modules->activate($this->organization->fresh(), $this->owner->public_id, $key);
} }
$this->organization->refresh(); $this->organization->refresh();
@@ -96,20 +96,26 @@ class CareSpecialtyAccessTest extends TestCase
public function test_nurse_and_pharmacist_manage_general_modules(): void public function test_nurse_and_pharmacist_manage_general_modules(): void
{ {
[, $nurse] = $this->makeStaff('nurse', 'nurse1'); [, $nurse] = $this->makeStaff('nurse', 'nurse1');
[, $edNurse] = $this->makeStaff('ed_nurse', 'edn1');
[, $pharmacist] = $this->makeStaff('pharmacist', 'rx1'); [, $pharmacist] = $this->makeStaff('pharmacist', 'rx1');
[, $lab] = $this->makeStaff('lab_technician', 'lab1'); [, $lab] = $this->makeStaff('lab_technician', 'lab1');
$this->assertTrue($this->modules->memberCanManage($this->organization, $nurse, 'emergency')); // Legacy nurse is limited — no specialty modules.
$this->assertTrue($this->modules->memberCanManage($this->organization, $pharmacist, 'emergency')); $this->assertFalse($this->modules->memberCanManage($this->organization, $nurse, 'emergency'));
$this->assertTrue($this->modules->memberCanManage($this->organization, $pharmacist, 'infusion')); // 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.
$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->assertTrue($this->modules->memberCanManage($this->organization, $lab, 'pathology'));
$this->assertTrue($this->modules->memberCanManage($this->organization, $lab, 'blood_bank')); $this->assertFalse($this->modules->memberCanManage($this->organization, $lab, 'blood_bank'));
$this->assertFalse($this->modules->memberCanManage($this->organization, $pharmacist, 'cardiology')); $this->assertFalse($this->modules->memberCanManage($this->organization, $pharmacist, 'cardiology'));
} }
public function test_gp_doctor_manages_general_but_only_views_restricted(): void public function test_gp_doctor_manages_general_but_only_views_restricted(): void
{ {
[$doctor, $member] = $this->makeStaff('doctor', 'gp1'); [$doctor, $member] = $this->makeStaff('general_physician', 'gp1');
Practitioner::create([ Practitioner::create([
'owner_ref' => $this->owner->public_id, 'owner_ref' => $this->owner->public_id,
'organization_id' => $this->organization->id, 'organization_id' => $this->organization->id,
@@ -123,11 +129,15 @@ class CareSpecialtyAccessTest extends TestCase
$this->assertFalse($this->modules->isDeskSpecialist($this->organization, $member)); $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, 'emergency'));
$this->assertTrue($this->modules->memberCanManage($this->organization, $member, 'dentistry')); // Dentistry is not a GP primary app.
$this->assertFalse($this->modules->memberCanManage($this->organization, $member, 'cardiology')); $this->assertFalse($this->modules->memberCanManage($this->organization, $member, 'dentistry'));
$this->assertTrue($this->modules->memberCanView($this->organization, $member, 'cardiology')); // Cardiology is a GP primary app (manage).
$this->assertTrue($this->modules->memberCanRefer($this->organization, $member, 'cardiology')); $this->assertTrue($this->modules->memberCanManage($this->organization, $member, 'cardiology'));
$this->assertSame('refer', $this->modules->memberAccessLevel($this->organization, $member, 'cardiology')); // Psychiatry is outside GP apps — refer/view via legacy config for GP.
$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_cardiologist_manages_restricted_cardiology(): void public function test_cardiologist_manages_restricted_cardiology(): void
@@ -226,7 +236,8 @@ class CareSpecialtyAccessTest extends TestCase
->assertOk() ->assertOk()
->assertSee('>Specialty</p>', false) ->assertSee('>Specialty</p>', false)
->assertSee('Emergency') ->assertSee('Emergency')
->assertSee('Dentistry'); ->assertSee('Cardiology')
->assertDontSee('Dentistry');
} }
public function test_dentistry_specialty_does_not_match_ent_keyword(): void public function test_dentistry_specialty_does_not_match_ent_keyword(): void
@@ -290,7 +301,7 @@ class CareSpecialtyAccessTest extends TestCase
public function test_gp_cannot_save_clinical_on_restricted_module(): void public function test_gp_cannot_save_clinical_on_restricted_module(): void
{ {
[$doctor, $member] = $this->makeStaff('doctor', 'gp3'); [$doctor, $member] = $this->makeStaff('general_physician', 'gp3');
Practitioner::create([ Practitioner::create([
'owner_ref' => $this->owner->public_id, 'owner_ref' => $this->owner->public_id,
'organization_id' => $this->organization->id, 'organization_id' => $this->organization->id,
@@ -302,14 +313,14 @@ class CareSpecialtyAccessTest extends TestCase
'is_active' => true, 'is_active' => true,
]); ]);
$dept = Department::query()->where('type', 'cardiology')->firstOrFail(); $dept = Department::query()->where('type', 'psychiatry')->firstOrFail();
$patient = Patient::create([ $patient = Patient::create([
'owner_ref' => $this->owner->public_id, 'owner_ref' => $this->owner->public_id,
'organization_id' => $this->organization->id, 'organization_id' => $this->organization->id,
'branch_id' => $this->branch->id, 'branch_id' => $this->branch->id,
'patient_number' => 'P-CARD-1', 'patient_number' => 'P-PSY-1',
'first_name' => 'Kofi', 'first_name' => 'Kofi',
'last_name' => 'Heart', 'last_name' => 'Mind',
'gender' => 'male', 'gender' => 'male',
'date_of_birth' => '1980-01-01', 'date_of_birth' => '1980-01-01',
]); ]);
@@ -336,7 +347,7 @@ class CareSpecialtyAccessTest extends TestCase
]); ]);
$this->actingAs($doctor) $this->actingAs($doctor)
->get(route('care.specialty.workspace', ['module' => 'cardiology', 'visit' => $visit, 'tab' => 'exam'])) ->get(route('care.specialty.workspace', ['module' => 'psychiatry', 'visit' => $visit, 'tab' => 'exam']))
->assertOk() ->assertOk()
->assertSee('view + refer') ->assertSee('view + refer')
->assertDontSee('Save record') ->assertDontSee('Save record')
@@ -345,12 +356,12 @@ class CareSpecialtyAccessTest extends TestCase
->assertDontSee('Upload document') ->assertDontSee('Upload document')
->assertSee('Refer to specialty queue') ->assertSee('Refer to specialty queue')
->assertDontSee( ->assertDontSee(
'action="'.route('care.specialty.consultation.start', ['module' => 'cardiology', 'visit' => $visit], false).'"', 'action="'.route('care.specialty.consultation.start', ['module' => 'psychiatry', 'visit' => $visit], false).'"',
false, false,
); );
$this->actingAs($doctor) $this->actingAs($doctor)
->post(route('care.specialty.clinical.save', ['module' => 'cardiology', 'visit' => $visit]), [ ->post(route('care.specialty.clinical.save', ['module' => 'psychiatry', 'visit' => $visit]), [
'tab' => 'exam', 'tab' => 'exam',
'payload' => [ 'payload' => [
'chief_complaint' => 'Should not save', 'chief_complaint' => 'Should not save',
@@ -362,23 +373,28 @@ class CareSpecialtyAccessTest extends TestCase
public function test_sidebar_and_dashboard_exclude_modules_with_no_access(): void public function test_sidebar_and_dashboard_exclude_modules_with_no_access(): void
{ {
[$pharmacistUser, $pharmacistMember] = $this->makeStaff('pharmacist', 'rx-nav'); [$pharmacistUser, $pharmacistMember] = $this->makeStaff('pharmacist', 'rx-nav');
[, $epMember] = $this->makeStaff('emergency_physician', 'ep-nav');
$enabledForMember = collect($this->modules->enabledModulesForMember($this->organization, $pharmacistMember)) $enabledForPharmacist = collect($this->modules->enabledModulesForMember($this->organization, $pharmacistMember))
->pluck('key')
->all();
$enabledForEp = collect($this->modules->enabledModulesForMember($this->organization, $epMember))
->pluck('key') ->pluck('key')
->all(); ->all();
$this->assertContains('emergency', $enabledForMember); // Pharmacist: pharmacy core only — no specialty modules.
$this->assertContains('infusion', $enabledForMember); $this->assertSame([], $enabledForPharmacist);
$this->assertNotContains('cardiology', $enabledForMember); $this->assertFalse($this->modules->shouldShowSpecialtyNav($this->organization, $pharmacistMember));
$this->assertNotContains('dentistry', $enabledForMember);
$this->assertTrue($this->modules->shouldShowSpecialtyNav($this->organization, $pharmacistMember)); $this->assertContains('emergency', $enabledForEp);
$this->assertContains('radiology', $enabledForEp);
$this->assertNotContains('dentistry', $enabledForEp);
$this->assertTrue($this->modules->shouldShowSpecialtyNav($this->organization, $epMember));
$this->actingAs($pharmacistUser) $this->actingAs($pharmacistUser)
->get(route('care.dashboard')) ->get(route('care.dashboard'))
->assertOk() ->assertOk()
->assertSee('>Specialty</p>', false) ->assertDontSee('>Specialty</p>', false);
->assertSee('Emergency')
->assertDontSee('Cardiology');
} }
public function test_manage_role_still_sees_mutate_actions_on_restricted_module(): void public function test_manage_role_still_sees_mutate_actions_on_restricted_module(): void
@@ -444,13 +460,65 @@ class CareSpecialtyAccessTest extends TestCase
$this->assertTrue($this->modules->memberCanManage($this->organization, $admin, 'emergency')); $this->assertTrue($this->modules->memberCanManage($this->organization, $admin, 'emergency'));
} }
/**
* @return array{0: \App\Models\User, 1: Member, 2: \App\Models\Visit}
*/
protected function specialtyVisitForRole(string $role, string $module, string $suffix): array
{
if ($module === 'dentistry') {
return $this->dentistryVisitForRole($role, $suffix);
}
if ($module === 'emergency') {
return $this->emergencyVisitForRole($role, $suffix);
}
[$user, $member] = $this->makeStaff($role, $suffix);
$definition = $this->modules->definition($module);
$deptType = (string) ($definition['department_type'] ?? $module);
$dept = Department::query()->where('type', $deptType)->firstOrFail();
$patient = Patient::create([
'owner_ref' => $this->owner->public_id,
'organization_id' => $this->organization->id,
'branch_id' => $this->branch->id,
'patient_number' => 'P-'.strtoupper(substr($module, 0, 3)).'-'.$suffix,
'first_name' => 'Floor',
'last_name' => ucfirst($module),
'gender' => 'female',
'date_of_birth' => '1990-01-01',
]);
$visit = \App\Models\Visit::create([
'owner_ref' => $this->owner->public_id,
'organization_id' => $this->organization->id,
'branch_id' => $this->branch->id,
'patient_id' => $patient->id,
'status' => \App\Models\Visit::STATUS_IN_PROGRESS,
'checked_in_at' => now(),
'specialty_stage' => 'waiting',
]);
Appointment::create([
'owner_ref' => $this->owner->public_id,
'organization_id' => $this->organization->id,
'branch_id' => $this->branch->id,
'patient_id' => $patient->id,
'department_id' => $dept->id,
'visit_id' => $visit->id,
'type' => Appointment::TYPE_WALK_IN,
'status' => Appointment::STATUS_WAITING,
'scheduled_at' => now(),
'checked_in_at' => now(),
'waiting_at' => now(),
]);
return [$user, $member, $visit];
}
/** /**
* @return array{0: \App\Models\User, 1: Member, 2: \App\Models\Visit} * @return array{0: \App\Models\User, 1: Member, 2: \App\Models\Visit}
*/ */
protected function dentistryVisitForRole(string $role, string $suffix): array protected function dentistryVisitForRole(string $role, string $suffix): array
{ {
[$user, $member] = $this->makeStaff($role, $suffix); [$user, $member] = $this->makeStaff($role, $suffix);
if ($role === 'doctor') { if (in_array($role, ['doctor', 'dentist'], true)) {
Practitioner::create([ Practitioner::create([
'owner_ref' => $this->owner->public_id, 'owner_ref' => $this->owner->public_id,
'organization_id' => $this->organization->id, 'organization_id' => $this->organization->id,
@@ -553,10 +621,10 @@ class CareSpecialtyAccessTest extends TestCase
public static function floorRolesWithoutConsultManage(): array public static function floorRolesWithoutConsultManage(): array
{ {
return [ return [
['nurse', 'dentistry', 'floor-nurse-den'], ['ed_nurse', 'emergency', 'floor-edn-er'],
['receptionist', 'dentistry', 'floor-recv-den'], ['theatre_nurse', 'surgery', 'floor-tn-sur'],
['lab_technician', 'emergency', 'floor-lab-er'], ['lab_technician', 'pathology', 'floor-lab-path'],
['pharmacist', 'emergency', 'floor-rx-er'], ['radiographer', 'radiology', 'floor-rad-tech'],
]; ];
} }
@@ -566,9 +634,7 @@ class CareSpecialtyAccessTest extends TestCase
string $module, string $module,
string $suffix, string $suffix,
): void { ): void {
[$user, $member, $visit] = $module === 'dentistry' [$user, $member, $visit] = $this->specialtyVisitForRole($role, $module, $suffix);
? $this->dentistryVisitForRole($role, $suffix)
: $this->emergencyVisitForRole($role, $suffix);
$permissions = app(\App\Services\Care\CarePermissions::class); $permissions = app(\App\Services\Care\CarePermissions::class);
$this->assertTrue($this->modules->memberCanManage($this->organization, $member, $module)); $this->assertTrue($this->modules->memberCanManage($this->organization, $member, $module));
@@ -605,7 +671,7 @@ class CareSpecialtyAccessTest extends TestCase
$html, $html,
); );
$this->assertStringContainsString('Waiting', $html); $this->assertStringContainsString('Waiting', $html);
} else { } elseif ($module === 'emergency') {
$this->assertStringNotContainsString( $this->assertStringNotContainsString(
route('care.specialty.emergency.stage', $visit, absolute: false), route('care.specialty.emergency.stage', $visit, absolute: false),
$html, $html,
@@ -654,7 +720,7 @@ class CareSpecialtyAccessTest extends TestCase
public function test_lab_technician_and_pharmacist_emergency_mutate_posts_are_forbidden(): void public function test_lab_technician_and_pharmacist_emergency_mutate_posts_are_forbidden(): void
{ {
foreach (['lab_technician' => 'lab-er-post', 'pharmacist' => 'rx-er-post'] as $role => $suffix) { foreach (['lab_technician' => 'lab-er-post', 'pharmacist' => 'rx-er-post', 'nurse' => 'nurse-er-post'] as $role => $suffix) {
[$user, , $visit] = $this->emergencyVisitForRole($role, $suffix); [$user, , $visit] = $this->emergencyVisitForRole($role, $suffix);
$this->actingAs($user) $this->actingAs($user)
@@ -664,20 +730,64 @@ class CareSpecialtyAccessTest extends TestCase
$this->actingAs($user) $this->actingAs($user)
->post(route('care.specialty.consultation.start', ['module' => 'emergency', 'visit' => $visit])) ->post(route('care.specialty.consultation.start', ['module' => 'emergency', 'visit' => $visit]))
->assertForbidden(); ->assertForbidden();
$this->actingAs($user)
->post(route('care.specialty.emergency.vitals', $visit), [
'bp_systolic' => 120,
'bp_diastolic' => 80,
'pulse' => 72,
])
->assertForbidden();
} }
} }
public function test_ed_nurse_cannot_discharge_emergency_visit(): void
{
[$user, $member, $visit] = $this->emergencyVisitForRole('ed_nurse', 'edn-discharge');
$permissions = app(\App\Services\Care\CarePermissions::class);
$this->assertTrue($this->modules->memberCanManage($this->organization, $member, 'emergency'));
$this->assertFalse($permissions->can($member, 'emergency.discharge'));
$this->actingAs($user)
->post(route('care.specialty.emergency.disposition', $visit), [
'payload' => [
'disposition' => 'Discharged home',
'destination' => 'Home',
'medications' => 'None',
],
])
->assertForbidden();
}
public function test_rbac_matrix_smoke_key_roles(): void
{
$permissions = app(\App\Services\Care\CarePermissions::class);
[, $ep] = $this->makeStaff('emergency_physician', 'matrix-ep');
[, $dentist] = $this->makeStaff('dentist', 'matrix-den');
[, $cashier] = $this->makeStaff('cashier', 'matrix-cash');
[, $lab] = $this->makeStaff('lab_technician', 'matrix-lab');
[, $labMgr] = $this->makeStaff('lab_manager', 'matrix-labm');
[, $billing] = $this->makeStaff('billing_officer', 'matrix-bill');
$this->assertTrue($this->modules->memberCanManage($this->organization, $ep, 'emergency'));
$this->assertTrue($this->modules->memberCanManage($this->organization, $ep, 'radiology'));
$this->assertFalse($this->modules->memberCanManage($this->organization, $ep, 'dentistry'));
$this->assertTrue($this->modules->memberCanManage($this->organization, $dentist, 'dentistry'));
$this->assertFalse($this->modules->memberCanManage($this->organization, $dentist, 'emergency'));
$this->assertFalse($this->modules->shouldShowSpecialtyNav($this->organization, $dentist));
$this->assertTrue($permissions->can($cashier, 'payments.manage'));
$this->assertFalse($permissions->can($cashier, 'bills.manage'));
$this->assertFalse($permissions->can($cashier, 'consultations.manage'));
$this->assertSame([], $this->modules->enabledModulesForMember($this->organization, $cashier));
$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($permissions->can($labMgr, 'pathology.result.approve'));
$this->assertTrue($permissions->can($labMgr, 'lab.catalog.manage'));
$this->assertTrue($permissions->can($billing, 'bills.manage'));
$this->assertFalse($permissions->can($billing, 'payments.manage'));
}
public function test_doctor_on_dentistry_sees_and_can_advance_stage(): void public function test_doctor_on_dentistry_sees_and_can_advance_stage(): void
{ {
[$doctorUser, $doctorMember, $visit] = $this->dentistryVisitForRole('doctor', 'den-doc'); [$doctorUser, $doctorMember, $visit] = $this->dentistryVisitForRole('dentist', 'den-doc');
$this->assertTrue($this->modules->memberCanManage($this->organization, $doctorMember, 'dentistry')); $this->assertTrue($this->modules->memberCanManage($this->organization, $doctorMember, 'dentistry'));
$this->assertTrue(app(\App\Services\Care\CarePermissions::class)->can($doctorMember, 'consultations.manage')); $this->assertTrue(app(\App\Services\Care\CarePermissions::class)->can($doctorMember, 'consultations.manage'));
@@ -698,7 +808,7 @@ class CareSpecialtyAccessTest extends TestCase
public function test_gp_with_consult_ability_but_no_module_manage_hides_mutate_ctas(): void public function test_gp_with_consult_ability_but_no_module_manage_hides_mutate_ctas(): void
{ {
[$doctor, $member] = $this->makeStaff('doctor', 'gp-no-manage'); [$doctor, $member] = $this->makeStaff('general_physician', 'gp-no-manage');
Practitioner::create([ Practitioner::create([
'owner_ref' => $this->owner->public_id, 'owner_ref' => $this->owner->public_id,
'organization_id' => $this->organization->id, 'organization_id' => $this->organization->id,
@@ -712,10 +822,10 @@ class CareSpecialtyAccessTest extends TestCase
$permissions = app(\App\Services\Care\CarePermissions::class); $permissions = app(\App\Services\Care\CarePermissions::class);
$this->assertTrue($permissions->can($member, 'consultations.manage')); $this->assertTrue($permissions->can($member, 'consultations.manage'));
$this->assertFalse($this->modules->memberCanManage($this->organization, $member, 'cardiology')); $this->assertFalse($this->modules->memberCanManage($this->organization, $member, 'psychiatry'));
$this->assertSame('refer', $this->modules->memberAccessLevel($this->organization, $member, 'cardiology')); $this->assertSame('refer', $this->modules->memberAccessLevel($this->organization, $member, 'psychiatry'));
$dept = Department::query()->where('type', 'cardiology')->firstOrFail(); $dept = Department::query()->where('type', 'psychiatry')->firstOrFail();
$patient = Patient::create([ $patient = Patient::create([
'owner_ref' => $this->owner->public_id, 'owner_ref' => $this->owner->public_id,
'organization_id' => $this->organization->id, 'organization_id' => $this->organization->id,
@@ -749,7 +859,7 @@ class CareSpecialtyAccessTest extends TestCase
]); ]);
$html = $this->actingAs($doctor) $html = $this->actingAs($doctor)
->get(route('care.specialty.workspace', ['module' => 'cardiology', 'visit' => $visit, 'tab' => 'overview'])) ->get(route('care.specialty.workspace', ['module' => 'psychiatry', 'visit' => $visit, 'tab' => 'overview']))
->assertOk() ->assertOk()
->assertSee('view + refer') ->assertSee('view + refer')
->assertDontSee('consultation access') ->assertDontSee('consultation access')
@@ -758,7 +868,7 @@ class CareSpecialtyAccessTest extends TestCase
->getContent(); ->getContent();
$this->assertStringNotContainsString( $this->assertStringNotContainsString(
route('care.specialty.consultation.start', ['module' => 'cardiology', 'visit' => $visit], absolute: false), route('care.specialty.consultation.start', ['module' => 'psychiatry', 'visit' => $visit], absolute: false),
$html, $html,
); );
$this->assertStringNotContainsString('>Start</button>', $html); $this->assertStringNotContainsString('>Start</button>', $html);
@@ -809,9 +919,10 @@ class CareSpecialtyAccessTest extends TestCase
public function test_nurse_can_save_emergency_vitals_with_vitals_manage(): void public function test_nurse_can_save_emergency_vitals_with_vitals_manage(): void
{ {
[$nurseUser, $nurseMember] = $this->makeStaff('nurse', 'er-vitals'); [$nurseUser, $nurseMember] = $this->makeStaff('ed_nurse', 'er-vitals');
$this->assertTrue(app(\App\Services\Care\CarePermissions::class)->can($nurseMember, 'vitals.manage')); $this->assertTrue(app(\App\Services\Care\CarePermissions::class)->can($nurseMember, 'vitals.manage'));
$this->assertFalse(app(\App\Services\Care\CarePermissions::class)->can($nurseMember, 'consultations.manage')); $this->assertFalse(app(\App\Services\Care\CarePermissions::class)->can($nurseMember, 'consultations.manage'));
$this->assertTrue($this->modules->memberCanManage($this->organization, $nurseMember, 'emergency'));
$dept = Department::query()->where('type', 'emergency')->firstOrFail(); $dept = Department::query()->where('type', 'emergency')->firstOrFail();
$patient = Patient::create([ $patient = Patient::create([
@@ -868,7 +979,7 @@ class CareSpecialtyAccessTest extends TestCase
public function test_doctor_with_prescriptions_manage_sees_prescribe_on_specialty_workspace(): void public function test_doctor_with_prescriptions_manage_sees_prescribe_on_specialty_workspace(): void
{ {
[$doctorUser, $doctorMember, $visit] = $this->dentistryVisitForRole('doctor', 'den-rx'); [$doctorUser, $doctorMember, $visit] = $this->dentistryVisitForRole('dentist', 'den-rx');
$appointment = $visit->appointment()->firstOrFail(); $appointment = $visit->appointment()->firstOrFail();
$appointment->update([ $appointment->update([
'status' => Appointment::STATUS_IN_CONSULTATION, 'status' => Appointment::STATUS_IN_CONSULTATION,
@@ -909,7 +1020,7 @@ class CareSpecialtyAccessTest extends TestCase
public function test_nurse_without_prescriptions_manage_does_not_see_prescribe_cta(): void public function test_nurse_without_prescriptions_manage_does_not_see_prescribe_cta(): void
{ {
[$nurseUser, $nurseMember, $visit] = $this->dentistryVisitForRole('nurse', 'den-no-rx'); [$nurseUser, $nurseMember, $visit] = $this->emergencyVisitForRole('ed_nurse', 'er-no-rx');
$appointment = $visit->appointment()->firstOrFail(); $appointment = $visit->appointment()->firstOrFail();
$appointment->update([ $appointment->update([
'status' => Appointment::STATUS_IN_CONSULTATION, 'status' => Appointment::STATUS_IN_CONSULTATION,
@@ -926,24 +1037,23 @@ class CareSpecialtyAccessTest extends TestCase
]); ]);
$permissions = app(\App\Services\Care\CarePermissions::class); $permissions = app(\App\Services\Care\CarePermissions::class);
$this->assertTrue($this->modules->memberCanManage($this->organization, $nurseMember, 'dentistry')); $this->assertTrue($this->modules->memberCanManage($this->organization, $nurseMember, 'emergency'));
$this->assertFalse($permissions->can($nurseMember, 'prescriptions.manage')); $this->assertFalse($permissions->can($nurseMember, 'prescriptions.manage'));
$this->actingAs($nurseUser) $this->actingAs($nurseUser)
->get(route('care.specialty.workspace', [ ->get(route('care.specialty.workspace', [
'module' => 'dentistry', 'module' => 'emergency',
'visit' => $visit, 'visit' => $visit,
'tab' => 'overview', 'tab' => 'overview',
])) ]))
->assertOk() ->assertOk()
->assertDontSee('Prescribe') ->assertDontSee('Prescribe')
->assertDontSee('New prescription') ->assertDontSee('New prescription');
->assertDontSee(route('care.prescriptions.store', Consultation::query()->where('visit_id', $visit->id)->first(), absolute: false));
} }
public function test_specialty_prescribe_store_returns_to_workspace(): void public function test_specialty_prescribe_store_returns_to_workspace(): void
{ {
[$doctorUser, , $visit] = $this->dentistryVisitForRole('doctor', 'den-rx-store'); [$doctorUser, , $visit] = $this->dentistryVisitForRole('dentist', 'den-rx-store');
$appointment = $visit->appointment()->firstOrFail(); $appointment = $visit->appointment()->firstOrFail();
$appointment->update([ $appointment->update([
'status' => Appointment::STATUS_IN_CONSULTATION, 'status' => Appointment::STATUS_IN_CONSULTATION,