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