Implement Care RBAC role→permission→app matrix.
Deploy Ladill Care / deploy (push) Successful in 57s
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:
@@ -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));
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
@@ -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']],
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
+23
-4
@@ -5,13 +5,32 @@ return [
|
||||
'roles' => [
|
||||
'super_admin' => 'Super Administrator',
|
||||
'hospital_admin' => 'Hospital Administrator',
|
||||
'receptionist' => 'Receptionist',
|
||||
'doctor' => 'Doctor',
|
||||
'nurse' => 'Nurse',
|
||||
'department_manager' => 'Department Manager',
|
||||
'emergency_physician' => 'Emergency Physician',
|
||||
'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_manager' => 'Laboratory Manager',
|
||||
'pharmacist' => 'Pharmacist',
|
||||
'pathologist' => 'Pathologist',
|
||||
'blood_bank_officer' => 'Blood Bank Officer',
|
||||
'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',
|
||||
'accountant' => 'Accountant',
|
||||
],
|
||||
|
||||
@@ -4,20 +4,10 @@
|
||||
* Specialty practice modules (Settings → Modules).
|
||||
* Pro-gated via plans.*.features specialty_modules.
|
||||
*
|
||||
* Access tiers (RBAC via Care member roles):
|
||||
* - general: broad day-to-day access for listed roles (GPs = doctor without specialty desk)
|
||||
* - limited: GPs + nurses (and listed roles); desk specialists only when specialty keywords match
|
||||
* - restricted: specialist doctors (department / specialty keywords) + optional support_roles;
|
||||
* 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).
|
||||
* Primary specialty visibility is driven by CarePermissions::primaryAppsFor()
|
||||
* (role → module keys). Config roles / support_roles / view_roles / refer_roles
|
||||
* remain for legacy doctor desk-specialist keyword matching and GP refer/view
|
||||
* outside the primary-app matrix.
|
||||
*
|
||||
* @return array<string, array<string, mixed>>
|
||||
*/
|
||||
@@ -133,8 +123,8 @@ return [
|
||||
'access' => 'restricted',
|
||||
'roles' => ['doctor'],
|
||||
'support_roles' => ['nurse'],
|
||||
'view_roles' => ['doctor', 'nurse', 'lab_technician'],
|
||||
'refer_roles' => ['doctor', 'nurse'],
|
||||
'view_roles' => ['doctor', 'general_physician', 'nurse', 'lab_technician'],
|
||||
'refer_roles' => ['doctor', 'general_physician', 'nurse'],
|
||||
'specialist_keywords' => ['cardio', 'cardiology', 'heart', 'ecg'],
|
||||
],
|
||||
'psychiatry' => [
|
||||
@@ -150,8 +140,8 @@ return [
|
||||
'access' => 'restricted',
|
||||
'roles' => ['doctor'],
|
||||
'support_roles' => ['nurse'],
|
||||
'view_roles' => ['doctor', 'nurse'],
|
||||
'refer_roles' => ['doctor', 'nurse'],
|
||||
'view_roles' => ['doctor', 'general_physician', 'nurse'],
|
||||
'refer_roles' => ['doctor', 'general_physician', 'nurse'],
|
||||
'specialist_keywords' => ['psych', 'psychiatry', 'mental', 'behaviour'],
|
||||
],
|
||||
'pediatrics' => [
|
||||
@@ -182,8 +172,8 @@ return [
|
||||
'access' => 'restricted',
|
||||
'roles' => ['doctor'],
|
||||
'support_roles' => ['nurse'],
|
||||
'view_roles' => ['doctor', 'nurse'],
|
||||
'refer_roles' => ['doctor', 'nurse'],
|
||||
'view_roles' => ['doctor', 'general_physician', 'nurse'],
|
||||
'refer_roles' => ['doctor', 'general_physician', 'nurse'],
|
||||
'specialist_keywords' => ['ortho', 'orthopedic', 'orthopaedic', 'fracture'],
|
||||
],
|
||||
'ent' => [
|
||||
@@ -199,8 +189,8 @@ return [
|
||||
'access' => 'restricted',
|
||||
'roles' => ['doctor'],
|
||||
'support_roles' => ['nurse'],
|
||||
'view_roles' => ['doctor', 'nurse'],
|
||||
'refer_roles' => ['doctor', 'nurse'],
|
||||
'view_roles' => ['doctor', 'general_physician', 'nurse'],
|
||||
'refer_roles' => ['doctor', 'general_physician', 'nurse'],
|
||||
'specialist_keywords' => ['ent', 'otolaryng', 'ear', 'nose', 'throat'],
|
||||
],
|
||||
'oncology' => [
|
||||
@@ -216,8 +206,8 @@ return [
|
||||
'access' => 'restricted',
|
||||
'roles' => ['doctor'],
|
||||
'support_roles' => ['nurse'],
|
||||
'view_roles' => ['doctor', 'nurse', 'pharmacist'],
|
||||
'refer_roles' => ['doctor', 'nurse'],
|
||||
'view_roles' => ['doctor', 'general_physician', 'nurse', 'pharmacist'],
|
||||
'refer_roles' => ['doctor', 'general_physician', 'nurse'],
|
||||
'specialist_keywords' => ['oncol', 'cancer', 'chemo'],
|
||||
],
|
||||
'renal' => [
|
||||
@@ -233,8 +223,8 @@ return [
|
||||
'access' => 'restricted',
|
||||
'roles' => ['doctor'],
|
||||
'support_roles' => ['nurse'],
|
||||
'view_roles' => ['doctor', 'nurse'],
|
||||
'refer_roles' => ['doctor', 'nurse'],
|
||||
'view_roles' => ['doctor', 'general_physician', 'nurse'],
|
||||
'refer_roles' => ['doctor', 'general_physician', 'nurse'],
|
||||
'specialist_keywords' => ['renal', 'nephro', 'dialysis', 'kidney'],
|
||||
],
|
||||
'surgery' => [
|
||||
@@ -250,8 +240,8 @@ return [
|
||||
'access' => 'restricted',
|
||||
'roles' => ['doctor'],
|
||||
'support_roles' => ['nurse'],
|
||||
'view_roles' => ['doctor', 'nurse'],
|
||||
'refer_roles' => ['doctor', 'nurse'],
|
||||
'view_roles' => ['doctor', 'general_physician', 'nurse'],
|
||||
'refer_roles' => ['doctor', 'general_physician', 'nurse'],
|
||||
'specialist_keywords' => ['surg', 'theatre', 'operat'],
|
||||
],
|
||||
'vaccination' => [
|
||||
@@ -309,8 +299,8 @@ return [
|
||||
'access' => 'restricted',
|
||||
'roles' => ['doctor'],
|
||||
'support_roles' => [],
|
||||
'view_roles' => ['doctor', 'nurse'],
|
||||
'refer_roles' => ['doctor', 'nurse'],
|
||||
'view_roles' => ['doctor', 'general_physician', 'nurse'],
|
||||
'refer_roles' => ['doctor', 'general_physician', 'nurse'],
|
||||
'specialist_keywords' => ['derma', 'skin'],
|
||||
],
|
||||
'podiatry' => [
|
||||
@@ -326,8 +316,8 @@ return [
|
||||
'access' => 'restricted',
|
||||
'roles' => ['doctor'],
|
||||
'support_roles' => [],
|
||||
'view_roles' => ['doctor', 'nurse'],
|
||||
'refer_roles' => ['doctor', 'nurse'],
|
||||
'view_roles' => ['doctor', 'general_physician', 'nurse'],
|
||||
'refer_roles' => ['doctor', 'general_physician', 'nurse'],
|
||||
'specialist_keywords' => ['podiat', 'foot'],
|
||||
],
|
||||
'fertility' => [
|
||||
@@ -343,8 +333,8 @@ return [
|
||||
'access' => 'restricted',
|
||||
'roles' => ['doctor'],
|
||||
'support_roles' => [],
|
||||
'view_roles' => ['doctor', 'nurse'],
|
||||
'refer_roles' => ['doctor', 'nurse'],
|
||||
'view_roles' => ['doctor', 'general_physician', 'nurse'],
|
||||
'refer_roles' => ['doctor', 'general_physician', 'nurse'],
|
||||
'specialist_keywords' => ['fertil', 'ivf', 'reproduct'],
|
||||
],
|
||||
'child_welfare' => [
|
||||
|
||||
@@ -9,7 +9,12 @@
|
||||
</div>
|
||||
|
||||
<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
|
||||
|
||||
@if (! empty($mailboxOptions))
|
||||
@@ -41,28 +46,28 @@
|
||||
<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">
|
||||
@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
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<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>
|
||||
<select name="branch_id" class="mt-1 w-full rounded-lg border-slate-300 text-sm"
|
||||
:required="role === 'doctor'">
|
||||
<option value="" x-text="role === 'doctor' ? 'Select a branch…' : 'No branch lock'"></option>
|
||||
:required="needsDesk">
|
||||
<option value="" x-text="needsDesk ? 'Select a branch…' : 'No branch lock'"></option>
|
||||
@foreach ($branches as $branch)
|
||||
<option value="{{ $branch->id }}" @selected(old('branch_id') == $branch->id)>{{ $branch->name }}</option>
|
||||
@endforeach
|
||||
</select>
|
||||
<p class="mt-1 text-xs text-slate-500" x-show="role === 'doctor'" x-cloak>
|
||||
Required for doctors. Add more sites later under Practitioners (telemedicine).
|
||||
<p class="mt-1 text-xs text-slate-500" x-show="needsDesk" x-cloak>
|
||||
Required for clinical desks. Add more sites later under Practitioners (telemedicine).
|
||||
</p>
|
||||
</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">
|
||||
<input type="checkbox" name="create_practitioner" value="1" class="mt-0.5" @checked(old('create_practitioner', true))>
|
||||
<span>
|
||||
|
||||
@@ -20,6 +20,8 @@ class CareBillTest extends TestCase
|
||||
|
||||
protected User $user;
|
||||
|
||||
protected User $cashierUser;
|
||||
|
||||
protected Organization $organization;
|
||||
|
||||
protected Visit $visit;
|
||||
@@ -31,8 +33,14 @@ class CareBillTest extends TestCase
|
||||
|
||||
$this->user = User::create([
|
||||
'public_id' => 'test-user-001',
|
||||
'name' => 'Test User',
|
||||
'email' => 'test@example.com',
|
||||
'name' => 'Billing Officer',
|
||||
'email' => 'billing@example.com',
|
||||
]);
|
||||
|
||||
$this->cashierUser = User::create([
|
||||
'public_id' => 'test-cashier-001',
|
||||
'name' => 'Cashier',
|
||||
'email' => 'cashier@example.com',
|
||||
]);
|
||||
|
||||
$this->organization = Organization::create([
|
||||
@@ -47,6 +55,13 @@ class CareBillTest extends TestCase
|
||||
'owner_ref' => $this->user->public_id,
|
||||
'organization_id' => $this->organization->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',
|
||||
]);
|
||||
|
||||
@@ -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)
|
||||
->post(route('care.bills.generate', $this->visit))
|
||||
@@ -101,7 +116,24 @@ class CareBillTest extends TestCase
|
||||
$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)
|
||||
->post(route('care.bills.generate', $this->visit));
|
||||
@@ -123,7 +155,15 @@ class CareBillTest extends TestCase
|
||||
|
||||
$this->assertSame($expectedTotal, $bill->total_minor);
|
||||
|
||||
// Billing officer cannot receive payments.
|
||||
$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), [
|
||||
'amount' => 30.00,
|
||||
'method' => 'cash',
|
||||
@@ -134,7 +174,7 @@ class CareBillTest extends TestCase
|
||||
$this->assertSame(Bill::STATUS_PARTIAL, $bill->status);
|
||||
$this->assertSame(3000, $bill->amount_paid_minor);
|
||||
|
||||
$this->actingAs($this->user)
|
||||
$this->actingAs($this->cashierUser)
|
||||
->post(route('care.bills.payments.store', $bill), [
|
||||
'amount' => $bill->balance_minor / 100,
|
||||
'method' => 'momo',
|
||||
@@ -153,7 +193,7 @@ class CareBillTest extends TestCase
|
||||
$this->actingAs($this->user)
|
||||
->post(route('care.bills.generate', $this->visit));
|
||||
|
||||
$this->actingAs($this->user)
|
||||
$this->actingAs($this->cashierUser)
|
||||
->get(route('care.bills.index'))
|
||||
->assertOk()
|
||||
->assertSee('INV-');
|
||||
@@ -181,14 +221,14 @@ class CareBillTest extends TestCase
|
||||
'balance_minor' => 0,
|
||||
]);
|
||||
|
||||
$this->actingAs($this->user)
|
||||
$this->actingAs($this->cashierUser)
|
||||
->get(route('care.bills.index'))
|
||||
->assertOk()
|
||||
->assertSee($open->invoice_number)
|
||||
->assertSee('Pay')
|
||||
->assertDontSee($paid->invoice_number, false);
|
||||
|
||||
$this->actingAs($this->user)
|
||||
$this->actingAs($this->cashierUser)
|
||||
->get(route('care.bills.index', ['status' => '']))
|
||||
->assertOk()
|
||||
->assertSee($paid->invoice_number);
|
||||
@@ -201,7 +241,7 @@ class CareBillTest extends TestCase
|
||||
|
||||
$bill = Bill::firstOrFail();
|
||||
|
||||
$this->actingAs($this->user)
|
||||
$this->actingAs($this->cashierUser)
|
||||
->get(route('care.bills.index'))
|
||||
->assertOk()
|
||||
->assertSee($bill->invoice_number)
|
||||
@@ -211,7 +251,7 @@ class CareBillTest extends TestCase
|
||||
|
||||
$balanceMajor = number_format($bill->balance_minor / 100, 2, '.', '');
|
||||
|
||||
$this->actingAs($this->user)
|
||||
$this->actingAs($this->cashierUser)
|
||||
->get(route('care.bills.show', $bill))
|
||||
->assertOk()
|
||||
->assertSee('Record payment')
|
||||
@@ -228,9 +268,9 @@ class CareBillTest extends TestCase
|
||||
$settings['queue_integration_enabled'] = true;
|
||||
$this->organization->update(['settings' => $settings]);
|
||||
|
||||
$this->actingAs($this->user)
|
||||
->post(route('care.bills.call-next'), ['branch_id' => Branch::firstOrFail()->id])
|
||||
$this->actingAs($this->cashierUser)
|
||||
->post(route('care.bills.call-next'))
|
||||
->assertRedirect()
|
||||
->assertSessionHas('info', 'No patients waiting at the billing booth. Open an unpaid invoice above to record a walk-up payment.');
|
||||
->assertSessionHas('info');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -179,6 +179,13 @@ class CareLabTest extends TestCase
|
||||
$this->assertSame(InvestigationRequest::STATUS_AWAITING_REVIEW, $request->status);
|
||||
$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)
|
||||
->post(route('care.lab.requests.approve', $request))
|
||||
->assertRedirect();
|
||||
|
||||
@@ -167,7 +167,7 @@ class CarePatientQueueAccessTest extends TestCase
|
||||
|
||||
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->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
|
||||
{
|
||||
[$nurseUser] = $this->makeStaff('nurse', 'nurse-er-open');
|
||||
[$nurseUser] = $this->makeStaff('ed_nurse', 'nurse-er-open');
|
||||
|
||||
$modules = app(SpecialtyModuleService::class);
|
||||
$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
|
||||
{
|
||||
[$nurseUser] = $this->makeStaff('nurse', 'nurse-spec-er');
|
||||
[$nurseUser] = $this->makeStaff('ed_nurse', 'nurse-spec-er');
|
||||
|
||||
app(SpecialtyModuleService::class)
|
||||
->ensureDefaultModulesProvisioned($this->organization, $this->owner->public_id);
|
||||
|
||||
@@ -69,7 +69,7 @@ class CareSpecialtyAccessTest extends TestCase
|
||||
|
||||
$this->modules = app(SpecialtyModuleService::class);
|
||||
$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->organization->refresh();
|
||||
@@ -96,20 +96,26 @@ class CareSpecialtyAccessTest extends TestCase
|
||||
public function test_nurse_and_pharmacist_manage_general_modules(): void
|
||||
{
|
||||
[, $nurse] = $this->makeStaff('nurse', 'nurse1');
|
||||
[, $edNurse] = $this->makeStaff('ed_nurse', 'edn1');
|
||||
[, $pharmacist] = $this->makeStaff('pharmacist', 'rx1');
|
||||
[, $lab] = $this->makeStaff('lab_technician', 'lab1');
|
||||
|
||||
$this->assertTrue($this->modules->memberCanManage($this->organization, $nurse, 'emergency'));
|
||||
$this->assertTrue($this->modules->memberCanManage($this->organization, $pharmacist, 'emergency'));
|
||||
$this->assertTrue($this->modules->memberCanManage($this->organization, $pharmacist, 'infusion'));
|
||||
// Legacy nurse is limited — no specialty modules.
|
||||
$this->assertFalse($this->modules->memberCanManage($this->organization, $nurse, 'emergency'));
|
||||
// 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, 'blood_bank'));
|
||||
$this->assertFalse($this->modules->memberCanManage($this->organization, $lab, 'blood_bank'));
|
||||
$this->assertFalse($this->modules->memberCanManage($this->organization, $pharmacist, 'cardiology'));
|
||||
}
|
||||
|
||||
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([
|
||||
'owner_ref' => $this->owner->public_id,
|
||||
'organization_id' => $this->organization->id,
|
||||
@@ -123,11 +129,15 @@ class CareSpecialtyAccessTest extends TestCase
|
||||
|
||||
$this->assertFalse($this->modules->isDeskSpecialist($this->organization, $member));
|
||||
$this->assertTrue($this->modules->memberCanManage($this->organization, $member, 'emergency'));
|
||||
$this->assertTrue($this->modules->memberCanManage($this->organization, $member, 'dentistry'));
|
||||
$this->assertFalse($this->modules->memberCanManage($this->organization, $member, 'cardiology'));
|
||||
$this->assertTrue($this->modules->memberCanView($this->organization, $member, 'cardiology'));
|
||||
$this->assertTrue($this->modules->memberCanRefer($this->organization, $member, 'cardiology'));
|
||||
$this->assertSame('refer', $this->modules->memberAccessLevel($this->organization, $member, 'cardiology'));
|
||||
// Dentistry is not a GP primary app.
|
||||
$this->assertFalse($this->modules->memberCanManage($this->organization, $member, 'dentistry'));
|
||||
// Cardiology is a GP primary app (manage).
|
||||
$this->assertTrue($this->modules->memberCanManage($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
|
||||
@@ -226,7 +236,8 @@ class CareSpecialtyAccessTest extends TestCase
|
||||
->assertOk()
|
||||
->assertSee('>Specialty</p>', false)
|
||||
->assertSee('Emergency')
|
||||
->assertSee('Dentistry');
|
||||
->assertSee('Cardiology')
|
||||
->assertDontSee('Dentistry');
|
||||
}
|
||||
|
||||
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
|
||||
{
|
||||
[$doctor, $member] = $this->makeStaff('doctor', 'gp3');
|
||||
[$doctor, $member] = $this->makeStaff('general_physician', 'gp3');
|
||||
Practitioner::create([
|
||||
'owner_ref' => $this->owner->public_id,
|
||||
'organization_id' => $this->organization->id,
|
||||
@@ -302,14 +313,14 @@ class CareSpecialtyAccessTest extends TestCase
|
||||
'is_active' => true,
|
||||
]);
|
||||
|
||||
$dept = Department::query()->where('type', 'cardiology')->firstOrFail();
|
||||
$dept = Department::query()->where('type', 'psychiatry')->firstOrFail();
|
||||
$patient = Patient::create([
|
||||
'owner_ref' => $this->owner->public_id,
|
||||
'organization_id' => $this->organization->id,
|
||||
'branch_id' => $this->branch->id,
|
||||
'patient_number' => 'P-CARD-1',
|
||||
'patient_number' => 'P-PSY-1',
|
||||
'first_name' => 'Kofi',
|
||||
'last_name' => 'Heart',
|
||||
'last_name' => 'Mind',
|
||||
'gender' => 'male',
|
||||
'date_of_birth' => '1980-01-01',
|
||||
]);
|
||||
@@ -336,7 +347,7 @@ class CareSpecialtyAccessTest extends TestCase
|
||||
]);
|
||||
|
||||
$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()
|
||||
->assertSee('view + refer')
|
||||
->assertDontSee('Save record')
|
||||
@@ -345,12 +356,12 @@ class CareSpecialtyAccessTest extends TestCase
|
||||
->assertDontSee('Upload document')
|
||||
->assertSee('Refer to specialty queue')
|
||||
->assertDontSee(
|
||||
'action="'.route('care.specialty.consultation.start', ['module' => 'cardiology', 'visit' => $visit], false).'"',
|
||||
'action="'.route('care.specialty.consultation.start', ['module' => 'psychiatry', 'visit' => $visit], false).'"',
|
||||
false,
|
||||
);
|
||||
|
||||
$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',
|
||||
'payload' => [
|
||||
'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
|
||||
{
|
||||
[$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')
|
||||
->all();
|
||||
|
||||
$this->assertContains('emergency', $enabledForMember);
|
||||
$this->assertContains('infusion', $enabledForMember);
|
||||
$this->assertNotContains('cardiology', $enabledForMember);
|
||||
$this->assertNotContains('dentistry', $enabledForMember);
|
||||
$this->assertTrue($this->modules->shouldShowSpecialtyNav($this->organization, $pharmacistMember));
|
||||
// Pharmacist: pharmacy core only — no specialty modules.
|
||||
$this->assertSame([], $enabledForPharmacist);
|
||||
$this->assertFalse($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)
|
||||
->get(route('care.dashboard'))
|
||||
->assertOk()
|
||||
->assertSee('>Specialty</p>', false)
|
||||
->assertSee('Emergency')
|
||||
->assertDontSee('Cardiology');
|
||||
->assertDontSee('>Specialty</p>', false);
|
||||
}
|
||||
|
||||
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'));
|
||||
}
|
||||
|
||||
/**
|
||||
* @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}
|
||||
*/
|
||||
protected function dentistryVisitForRole(string $role, string $suffix): array
|
||||
{
|
||||
[$user, $member] = $this->makeStaff($role, $suffix);
|
||||
if ($role === 'doctor') {
|
||||
if (in_array($role, ['doctor', 'dentist'], true)) {
|
||||
Practitioner::create([
|
||||
'owner_ref' => $this->owner->public_id,
|
||||
'organization_id' => $this->organization->id,
|
||||
@@ -553,10 +621,10 @@ class CareSpecialtyAccessTest extends TestCase
|
||||
public static function floorRolesWithoutConsultManage(): array
|
||||
{
|
||||
return [
|
||||
['nurse', 'dentistry', 'floor-nurse-den'],
|
||||
['receptionist', 'dentistry', 'floor-recv-den'],
|
||||
['lab_technician', 'emergency', 'floor-lab-er'],
|
||||
['pharmacist', 'emergency', 'floor-rx-er'],
|
||||
['ed_nurse', 'emergency', 'floor-edn-er'],
|
||||
['theatre_nurse', 'surgery', 'floor-tn-sur'],
|
||||
['lab_technician', 'pathology', 'floor-lab-path'],
|
||||
['radiographer', 'radiology', 'floor-rad-tech'],
|
||||
];
|
||||
}
|
||||
|
||||
@@ -566,9 +634,7 @@ class CareSpecialtyAccessTest extends TestCase
|
||||
string $module,
|
||||
string $suffix,
|
||||
): void {
|
||||
[$user, $member, $visit] = $module === 'dentistry'
|
||||
? $this->dentistryVisitForRole($role, $suffix)
|
||||
: $this->emergencyVisitForRole($role, $suffix);
|
||||
[$user, $member, $visit] = $this->specialtyVisitForRole($role, $module, $suffix);
|
||||
|
||||
$permissions = app(\App\Services\Care\CarePermissions::class);
|
||||
$this->assertTrue($this->modules->memberCanManage($this->organization, $member, $module));
|
||||
@@ -605,7 +671,7 @@ class CareSpecialtyAccessTest extends TestCase
|
||||
$html,
|
||||
);
|
||||
$this->assertStringContainsString('Waiting', $html);
|
||||
} else {
|
||||
} elseif ($module === 'emergency') {
|
||||
$this->assertStringNotContainsString(
|
||||
route('care.specialty.emergency.stage', $visit, absolute: false),
|
||||
$html,
|
||||
@@ -654,7 +720,7 @@ class CareSpecialtyAccessTest extends TestCase
|
||||
|
||||
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);
|
||||
|
||||
$this->actingAs($user)
|
||||
@@ -664,20 +730,64 @@ class CareSpecialtyAccessTest extends TestCase
|
||||
$this->actingAs($user)
|
||||
->post(route('care.specialty.consultation.start', ['module' => 'emergency', 'visit' => $visit]))
|
||||
->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
|
||||
{
|
||||
[$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(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
|
||||
{
|
||||
[$doctor, $member] = $this->makeStaff('doctor', 'gp-no-manage');
|
||||
[$doctor, $member] = $this->makeStaff('general_physician', 'gp-no-manage');
|
||||
Practitioner::create([
|
||||
'owner_ref' => $this->owner->public_id,
|
||||
'organization_id' => $this->organization->id,
|
||||
@@ -712,10 +822,10 @@ class CareSpecialtyAccessTest extends TestCase
|
||||
|
||||
$permissions = app(\App\Services\Care\CarePermissions::class);
|
||||
$this->assertTrue($permissions->can($member, 'consultations.manage'));
|
||||
$this->assertFalse($this->modules->memberCanManage($this->organization, $member, 'cardiology'));
|
||||
$this->assertSame('refer', $this->modules->memberAccessLevel($this->organization, $member, 'cardiology'));
|
||||
$this->assertFalse($this->modules->memberCanManage($this->organization, $member, 'psychiatry'));
|
||||
$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([
|
||||
'owner_ref' => $this->owner->public_id,
|
||||
'organization_id' => $this->organization->id,
|
||||
@@ -749,7 +859,7 @@ class CareSpecialtyAccessTest extends TestCase
|
||||
]);
|
||||
|
||||
$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()
|
||||
->assertSee('view + refer')
|
||||
->assertDontSee('consultation access')
|
||||
@@ -758,7 +868,7 @@ class CareSpecialtyAccessTest extends TestCase
|
||||
->getContent();
|
||||
|
||||
$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,
|
||||
);
|
||||
$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
|
||||
{
|
||||
[$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->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();
|
||||
$patient = Patient::create([
|
||||
@@ -868,7 +979,7 @@ class CareSpecialtyAccessTest extends TestCase
|
||||
|
||||
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->update([
|
||||
'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
|
||||
{
|
||||
[$nurseUser, $nurseMember, $visit] = $this->dentistryVisitForRole('nurse', 'den-no-rx');
|
||||
[$nurseUser, $nurseMember, $visit] = $this->emergencyVisitForRole('ed_nurse', 'er-no-rx');
|
||||
$appointment = $visit->appointment()->firstOrFail();
|
||||
$appointment->update([
|
||||
'status' => Appointment::STATUS_IN_CONSULTATION,
|
||||
@@ -926,24 +1037,23 @@ class CareSpecialtyAccessTest extends TestCase
|
||||
]);
|
||||
|
||||
$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->actingAs($nurseUser)
|
||||
->get(route('care.specialty.workspace', [
|
||||
'module' => 'dentistry',
|
||||
'module' => 'emergency',
|
||||
'visit' => $visit,
|
||||
'tab' => 'overview',
|
||||
]))
|
||||
->assertOk()
|
||||
->assertDontSee('Prescribe')
|
||||
->assertDontSee('New prescription')
|
||||
->assertDontSee(route('care.prescriptions.store', Consultation::query()->where('visit_id', $visit->id)->first(), absolute: false));
|
||||
->assertDontSee('New prescription');
|
||||
}
|
||||
|
||||
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->update([
|
||||
'status' => Appointment::STATUS_IN_CONSULTATION,
|
||||
|
||||
Reference in New Issue
Block a user