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
{
$this->authorizeAbility($request, 'lab.manage');
$this->authorizeAbility($request, 'pathology.result.approve');
$this->authorizeInvestigation($request, $investigation);
$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
{
$this->authorizeAbility($request, 'bills.manage');
$organization = $this->organization($request);
$permissions = app(CarePermissions::class);
$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);
abort_unless($branchId > 0, 422);
abort_unless($this->queueBridge->isEnabled($organization), 404);
@@ -108,7 +113,13 @@ class BillController extends Controller
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);
$organization = $this->organization($request);
abort_unless($this->queueBridge->isEnabled($organization), 404);
@@ -128,7 +128,7 @@ class EmergencyWorkspaceController extends Controller
SpecialtyVisitStageService $stages,
EmergencyWorkflowService $workflow,
): RedirectResponse {
$this->authorizeAbility($request, 'consultations.manage');
$this->authorizeAbility($request, 'emergency.discharge');
$this->assertEmergencyManage($request, $modules);
$this->assertVisit($request, $visit);
@@ -228,7 +228,7 @@ class InvestigationController extends Controller
public function approve(Request $request, InvestigationRequest $investigation): RedirectResponse
{
$this->authorizeAbility($request, 'lab.manage');
$this->authorizeAbility($request, 'pathology.result.approve');
$this->authorizeInvestigation($request, $investigation);
$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\Practitioner;
use App\Services\Care\AuditLogger;
use App\Services\Care\CarePermissions;
use App\Services\Identity\IdentityTeamClient;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
@@ -31,7 +32,7 @@ class MemberController extends Controller
->get();
$adminRoles = ['super_admin', 'hospital_admin'];
$clinicalRoles = ['doctor', 'nurse', 'lab_technician', 'lab_manager', 'pharmacist', 'blood_bank_manager'];
$clinicalRoles = app(CarePermissions::class)->clinicalPractitionerRoles();
$heroStats = [
'total' => $members->count(),
@@ -71,6 +72,7 @@ class MemberController extends Controller
'organization' => $organization,
'branches' => $branches,
'roles' => config('care.roles'),
'practitionerRoles' => app(CarePermissions::class)->clinicalPractitionerRoles(),
'mailboxOptions' => $mailboxOptions,
'specialties' => Practitioner::specialtyOptions(),
]);
@@ -82,11 +84,16 @@ class MemberController extends Controller
$organization = $this->organization($request);
$owner = $this->ownerRef($request);
$permissions = app(CarePermissions::class);
$needsPractitionerDesk = $permissions->usesPractitionerBranchScope(
new Member(['role' => $request->input('role')])
);
$validated = $request->validate([
'email' => ['required', 'email', 'max:255'],
'role' => ['required', 'string', 'in:'.implode(',', array_keys(config('care.roles')))],
'branch_id' => [
Rule::requiredIf(fn () => $request->input('role') === 'doctor'),
Rule::requiredIf(fn () => $needsPractitionerDesk),
'nullable',
'integer',
'exists:care_branches,id',
@@ -138,7 +145,10 @@ class MemberController extends Controller
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) {
$name = trim((string) ($validated['practitioner_name'] ?? '')) ?: Str::headline(Str::before($email, '@'));
$practitioner = Practitioner::query()->firstOrCreate(
@@ -210,7 +210,7 @@ class PractitionerController extends Controller
{
return Member::owned($owner)
->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')
->get();
}
+10 -2
View File
@@ -311,8 +311,16 @@ class AssessmentService
public function defaultCaptureRoles(string $category): array
{
return match ($category) {
AssessmentTemplate::CATEGORY_DISEASE => ['doctor'],
default => ['doctor', 'nurse'],
AssessmentTemplate::CATEGORY_DISEASE => [
'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;
}
if ($member->role === 'doctor') {
if ($member->role === 'doctor' || app(CarePermissions::class)->usesPractitionerBranchScope($member)) {
return count($this->organizations->doctorAssignedBranchIds($member)) > 1;
}
// Other site clinicians stay on their assigned branch — never a picker.
if (in_array($member->role, [
'nurse',
'pharmacist',
'lab_technician',
'lab_manager',
'blood_bank_manager',
'cashier',
'receptionist',
], true)) {
if (in_array($member->role, app(CarePermissions::class)->floorCareRoles(), true)
&& ! app(CarePermissions::class)->usesPractitionerBranchScope($member)) {
return false;
}
+398 -49
View File
@@ -4,86 +4,332 @@ namespace App\Services\Care;
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
{
/**
* 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`).
* Lab technicians keep `lab.manage` for queue / sample / process / results
* not catalog CRUD or price edits.
*
* Blood Bank operational inventory uses `blood_bank.manage` (managers + admins).
* Clinical visit stage/issue for managers also requires specialty module manage.
* @var array<string, list<string>>
*/
protected array $roleInherits = [
'lab_manager' => ['lab_technician'],
'blood_bank_manager' => ['blood_bank_officer'],
];
/**
* 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>>
*/
protected array $roleAbilities = [
'super_admin' => ['*'],
'hospital_admin' => ['*'],
'receptionist' => [
'dashboard.view', 'patients.view', 'patients.manage',
'appointments.view', 'appointments.manage', 'queue.manage',
'emergency_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',
'radiology.request', 'pathology.request', 'bloodbank.request',
'cardiology.view',
'queue.patient_board',
'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' => [
'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',
],
'nurse' => [
'surgeon' => [
'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',
],
'theatre_nurse' => [
'dashboard.view', 'patients.view', 'appointments.view',
'consultations.view',
'vitals.manage',
'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' => [
'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',
],
'lab_manager' => [
'dashboard.view', 'patients.view', 'lab.view', 'lab.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',
],
'pharmacist' => [
'dashboard.view', 'patients.view', 'prescriptions.view', 'prescriptions.dispense',
'pharmacy.view', 'pharmacy.manage',
'blood_bank_officer' => [
'dashboard.view', 'patients.view',
'blood_bank.view', 'blood_bank.issue',
'service_queues.console',
],
'blood_bank_manager' => [
'blood_bank.manage',
'blood_bank.inventory',
'blood_bank.reports',
],
'pharmacist' => [
'dashboard.view', 'patients.view',
'blood_bank.view', 'blood_bank.manage',
'prescriptions.view', 'prescriptions.dispense',
'pharmacy.view', 'pharmacy.manage',
'service_queues.console',
],
// Cashiers run the Billing desk (bills / payments). They do not get
// Ladill POS app access — see filterAllowedAppSlugs() / DemoWorld.
'dentist' => [
'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' => [
'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',
],
'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',
],
'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> */
protected array $adminAbilities = [
'admin.branches.view', 'admin.branches.manage',
'admin.departments.view', 'admin.departments.manage',
'admin.practitioners.view', 'admin.practitioners.manage',
'admin.members.view', 'admin.members.manage',
'settings.view', 'settings.manage',
'lab.catalog.manage',
'blood_bank.view', 'blood_bank.manage',
'devices.view', 'devices.manage',
'displays.view', 'displays.manage',
'audit.view', 'audit.export',
/** Clinical roles that staff the floor (queues, specialty desks, counters). */
protected array $floorCareRoles = [
'emergency_physician', 'ed_nurse', 'general_physician', 'doctor',
'surgeon', 'theatre_nurse', 'radiologist', 'radiographer',
'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',
'nurse',
];
/** 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
@@ -92,7 +338,7 @@ class CarePermissions
return false;
}
$abilities = $this->roleAbilities[$member->role] ?? [];
$abilities = $this->resolvedAbilities((string) $member->role);
if (in_array('*', $abilities, true)) {
return true;
@@ -101,11 +347,88 @@ class CarePermissions
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
{
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.
* Facility admins manage settings/staff they do not staff waiting lines.
@@ -116,27 +439,53 @@ class CarePermissions
return false;
}
return in_array($member->role, [
'doctor',
'nurse',
'lab_technician',
'lab_manager',
'pharmacist',
'blood_bank_manager',
'receptionist',
'cashier',
], true);
return in_array($member->role, $this->floorCareRoles, true);
}
/**
* Dedicated patient-flow board (GP Queue home / call-next board).
* Doctors only nurses, reception, pharmacy, and other floor roles use
* specialty module lists and service queues instead.
* Facility admins manage settings/staff and do not staff this board.
* Permission-gated not a role string check. Facility admins do not staff this board.
*/
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
);
}
if ($member && $member->role === 'doctor') {
if ($member && app(CarePermissions::class)->usesPractitionerBranchScope($member)) {
$prac = Practitioner::owned($organization->owner_ref)
->where('organization_id', $organization->id)
->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.
$assignMember = $member && $homeBranchId > 0 && (int) $branch->id === $homeBranchId;
$stableRef = sprintf('demo-specialty-%s-%s', $key, $branch->id);
$doctorName = (string) (\App\Support\DemoWorld::SPECIALTY_DOCTORS[$key]
?? ('Dr. '.($definition['label'] ?? ucfirst($key))));
$specialtyMeta = \App\Support\DemoWorld::SPECIALTY_DOCTORS[$key] ?? null;
$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)) {
$practitionerName = (string) $staff['name'];
} elseif ($assignMember) {
+2 -2
View File
@@ -282,7 +282,7 @@ class OrganizationResolver
return null;
}
if ($member->role === 'doctor') {
if (app(CarePermissions::class)->usesPractitionerBranchScope($member)) {
return $this->doctorAssignedBranchIds($member);
}
@@ -356,7 +356,7 @@ class OrganizationResolver
return null;
}
if ($member->role === 'doctor') {
if (app(CarePermissions::class)->usesPractitionerBranchScope($member)) {
$ids = $this->doctorAssignedBranchIds($member);
if ($ids === []) {
return 0;
+83 -9
View File
@@ -200,10 +200,10 @@ class SpecialtyModuleService
/**
* Whether the sidebar should show the Specialty nav group.
*
* Desk specialists only receive their own matching module(s), so a
* Specialty Dentistry (etc.) group is redundant they use dashboard
* cards / direct routes instead. GPs, nurses, and other multi-module
* roles keep the group for referral and cross-module access.
* Single-app specialists (dentist, radiologist, ) and legacy desk
* specialists only receive their own module(s), so a Specialty group is
* redundant they use dashboard cards / direct routes instead.
* Multi-app clinical roles keep the group.
*/
public function shouldShowSpecialtyNav(Organization $organization, ?Member $member): bool
{
@@ -216,6 +216,11 @@ class SpecialtyModuleService
return false;
}
$permissions = app(CarePermissions::class);
if ($permissions->isSingleAppSpecialist($member)) {
return false;
}
if ($this->isDeskSpecialist($organization, $member)) {
return false;
}
@@ -283,6 +288,9 @@ class SpecialtyModuleService
/**
* 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
{
@@ -290,7 +298,8 @@ class SpecialtyModuleService
return false;
}
if (app(CarePermissions::class)->isAdmin($member)) {
$permissions = app(CarePermissions::class);
if ($permissions->isAdmin($member)) {
return true;
}
@@ -299,6 +308,24 @@ class SpecialtyModuleService
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.
if ($this->isDeskSpecialist($organization, $member)
&& ! $this->specialistBelongsToModule($organization, $member, $key)) {
@@ -314,7 +341,7 @@ class SpecialtyModuleService
if ($this->roleListed($member, $supportRoles)) {
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);
}
@@ -339,6 +366,33 @@ class SpecialtyModuleService
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.
if ($this->isDeskSpecialist($organization, $member)
&& ! $this->specialistBelongsToModule($organization, $member, $key)) {
@@ -366,6 +420,25 @@ class SpecialtyModuleService
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.
if ($this->isDeskSpecialist($organization, $member)
&& ! $this->specialistBelongsToModule($organization, $member, $key)) {
@@ -381,8 +454,9 @@ class SpecialtyModuleService
}
/**
* Doctor with a specialty desk: non-GP specialty matching a catalog module, or
* assigned to a provisioned specialty department. GPs and non-doctors are false.
* Legacy doctor with a specialty desk: non-GP specialty matching a catalog
* module, or assigned to a provisioned specialty department.
* Dedicated specialist roles use primary apps instead.
*/
public function isDeskSpecialist(Organization $organization, Member $member): bool
{
@@ -391,7 +465,7 @@ class SpecialtyModuleService
return $this->deskSpecialistCache[$cacheKey];
}
if ((string) $member->role !== 'doctor') {
if (! in_array((string) $member->role, ['doctor'], true)) {
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 = [
'emergency' => 'Dr. Kojo Emergency',
'blood_bank' => 'Dr. Ama Blood Bank',
'dentistry' => 'Dr. Ama Dental',
'ophthalmology' => 'Dr. Kofi Eye',
'physiotherapy' => 'Dr. Efua Physio',
'maternity' => 'Dr. Abena Maternity',
'radiology' => 'Dr. Yaw Radiology',
'cardiology' => 'Dr. Kwesi Cardiology',
'psychiatry' => 'Dr. Adwoa Psychiatry',
'pediatrics' => 'Dr. Fiifi Pediatrics',
'orthopedics' => 'Dr. Kojo Orthopedics',
'ent' => 'Dr. Esi ENT',
'oncology' => 'Dr. Mansa Oncology',
'renal' => 'Dr. Nana Renal',
'surgery' => 'Dr. Kwadwo Surgery',
'vaccination' => 'Dr. Afia Vaccination',
'pathology' => 'Dr. Yaw Pathology',
'infusion' => 'Dr. Akosua Infusion',
'dermatology' => 'Dr. Ama Dermatology',
'podiatry' => 'Dr. Kofi Podiatry',
'fertility' => 'Dr. Abena Fertility',
'child_welfare' => 'Dr. Efua Child Welfare',
'ambulance' => 'Dr. Kojo Ambulance',
'emergency' => ['name' => 'Dr. Kojo Emergency', 'role' => 'emergency_physician'],
'blood_bank' => ['name' => 'Ama Blood Bank Officer', 'role' => 'blood_bank_officer'],
'dentistry' => ['name' => 'Dr. Ama Dental', 'role' => 'dentist'],
'ophthalmology' => ['name' => 'Dr. Kofi Eye', 'role' => 'doctor'],
'physiotherapy' => ['name' => 'Dr. Efua Physio', 'role' => 'physiotherapist'],
'maternity' => ['name' => 'Abena Midwife', 'role' => 'midwife'],
'radiology' => ['name' => 'Dr. Yaw Radiology', 'role' => 'radiologist'],
'cardiology' => ['name' => 'Dr. Kwesi Cardiology', 'role' => 'doctor'],
'psychiatry' => ['name' => 'Dr. Adwoa Psychiatry', 'role' => 'psychiatrist'],
'pediatrics' => ['name' => 'Dr. Fiifi Pediatrics', 'role' => 'pediatrician'],
'orthopedics' => ['name' => 'Dr. Kojo Orthopedics', 'role' => 'doctor'],
'ent' => ['name' => 'Dr. Esi ENT', 'role' => 'doctor'],
'oncology' => ['name' => 'Dr. Mansa Oncology', 'role' => 'oncologist'],
'renal' => ['name' => 'Dr. Nana Renal', 'role' => 'doctor'],
'surgery' => ['name' => 'Dr. Kwadwo Surgery', 'role' => 'surgeon'],
'vaccination' => ['name' => 'Dr. Afia Vaccination', 'role' => 'doctor'],
'pathology' => ['name' => 'Dr. Yaw Pathology', 'role' => 'pathologist'],
'infusion' => ['name' => 'Dr. Akosua Infusion', 'role' => 'doctor'],
'dermatology' => ['name' => 'Dr. Ama Dermatology', 'role' => 'doctor'],
'podiatry' => ['name' => 'Dr. Kofi Podiatry', 'role' => 'doctor'],
'fertility' => ['name' => 'Dr. Abena Fertility', 'role' => 'doctor'],
'child_welfare' => ['name' => 'Dr. Efua Child Welfare', 'role' => 'doctor'],
'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 = [
@@ -243,7 +261,14 @@ final class DemoWorld
'email' => 'demo-pro-doctor@ladill.com',
'name' => 'Dr. Kwame Mensah (Pro)',
'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',
@@ -345,7 +370,14 @@ final class DemoWorld
'email' => 'demo-enterprise-doctor@ladill.com',
'name' => 'Dr. Kwame Mensah',
'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',
@@ -572,7 +604,7 @@ final class DemoWorld
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);
$already = false;
foreach ($roster as $row) {
@@ -584,13 +616,37 @@ final class DemoWorld
if ($already) {
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)' : '';
$roster[] = [
'key' => $key,
'key' => $extra['key'],
'email' => $email,
'name' => $name.$suffix,
'name' => $extra['name'].$suffix,
'apps' => ['care'],
'roles' => ['care' => 'doctor'],
'roles' => ['care' => $extra['role']],
];
}