From 2cff8fdd6cc8bb58ad2755da57f9f7cef1f47929 Mon Sep 17 00:00:00 2001 From: isaacclad Date: Sat, 18 Jul 2026 18:03:47 +0000 Subject: [PATCH] Gate specialty modules by staff role with view and refer levels. Give nurses, lab techs, pharmacists, and GPs broad access to day-to-day modules, keep restricted specialties for specialists (with support nurses), and let GPs view results or refer without full clinical edit. Co-authored-by: Cursor --- .../Care/DentistryWorkspaceController.php | 44 +-- .../Care/EmergencyWorkspaceController.php | 12 +- .../Care/SpecialtyModuleController.php | 84 +++++- app/Services/Care/SpecialtyModuleService.php | 239 +++++++++++++--- config/care_specialty_modules.php | 103 +++++-- resources/views/care/dashboard.blade.php | 9 +- .../specialty/partials/actions-menu.blade.php | 21 +- .../partials/clinical-form.blade.php | 20 ++ .../views/care/specialty/shell.blade.php | 17 +- resources/views/partials/sidebar.blade.php | 22 +- routes/web.php | 1 + tests/Feature/CareSpecialtyAccessTest.php | 258 ++++++++++++++++++ tests/Feature/CareSpecialtyModulesTest.php | 21 +- 13 files changed, 762 insertions(+), 89 deletions(-) create mode 100644 tests/Feature/CareSpecialtyAccessTest.php diff --git a/app/Http/Controllers/Care/DentistryWorkspaceController.php b/app/Http/Controllers/Care/DentistryWorkspaceController.php index 26da720..bba21bb 100644 --- a/app/Http/Controllers/Care/DentistryWorkspaceController.php +++ b/app/Http/Controllers/Care/DentistryWorkspaceController.php @@ -37,6 +37,12 @@ class DentistryWorkspaceController extends Controller abort_unless($modules->memberCanAccess($organization, $this->member($request), 'dentistry'), 403); } + protected function assertDentistryManage(Request $request, SpecialtyModuleService $modules): void + { + $organization = $this->organization($request); + abort_unless($modules->memberCanManage($organization, $this->member($request), 'dentistry'), 403); + } + protected function assertVisit(Request $request, Visit $visit): void { abort_unless($visit->organization_id === $this->organization($request)->id, 404); @@ -50,7 +56,7 @@ class DentistryWorkspaceController extends Controller DentalChartService $charts, ): RedirectResponse { $this->authorizeAbility($request, 'consultations.manage'); - $this->assertDentistryAccess($request, $modules); + $this->assertDentistryManage($request, $modules); $this->assertVisit($request, $visit); $validated = $request->validate([ @@ -93,7 +99,7 @@ class DentistryWorkspaceController extends Controller DentalTreatmentPlanService $plans, ): RedirectResponse { $this->authorizeAbility($request, 'consultations.manage'); - $this->assertDentistryAccess($request, $modules); + $this->assertDentistryManage($request, $modules); $this->assertVisit($request, $visit); $validated = $request->validate([ @@ -128,7 +134,7 @@ class DentistryWorkspaceController extends Controller DentalTreatmentPlanService $plans, ): RedirectResponse { $this->authorizeAbility($request, 'consultations.manage'); - $this->assertDentistryAccess($request, $modules); + $this->assertDentistryManage($request, $modules); $this->assertVisit($request, $visit); $organization = $this->organization($request); @@ -153,7 +159,7 @@ class DentistryWorkspaceController extends Controller DentalProcedureService $procedures, ): RedirectResponse { $this->authorizeAbility($request, 'consultations.manage'); - $this->assertDentistryAccess($request, $modules); + $this->assertDentistryManage($request, $modules); $this->assertVisit($request, $visit); $validated = $request->validate([ @@ -194,7 +200,7 @@ class DentistryWorkspaceController extends Controller DentalVisitNoteService $notes, ): RedirectResponse { $this->authorizeAbility($request, 'consultations.manage'); - $this->assertDentistryAccess($request, $modules); + $this->assertDentistryManage($request, $modules); $this->assertVisit($request, $visit); $validated = $request->validate([ @@ -225,7 +231,7 @@ class DentistryWorkspaceController extends Controller DentalProcedureService $procedures, ): RedirectResponse { $this->authorizeAbility($request, 'consultations.manage'); - $this->assertDentistryAccess($request, $modules); + $this->assertDentistryManage($request, $modules); $this->assertVisit($request, $visit); $validated = $request->validate([ @@ -335,7 +341,7 @@ class DentistryWorkspaceController extends Controller DentalChartService $charts, ): RedirectResponse { $this->authorizeAbility($request, 'consultations.manage'); - $this->assertDentistryAccess($request, $modules); + $this->assertDentistryManage($request, $modules); $this->assertVisit($request, $visit); $validated = $request->validate([ @@ -371,7 +377,7 @@ class DentistryWorkspaceController extends Controller DentalTreatmentPlanService $plans, ): RedirectResponse { $this->authorizeAbility($request, 'consultations.manage'); - $this->assertDentistryAccess($request, $modules); + $this->assertDentistryManage($request, $modules); $this->assertVisit($request, $visit); $validated = $request->validate([ @@ -408,7 +414,7 @@ class DentistryWorkspaceController extends Controller DentalTreatmentPlanService $plans, ): RedirectResponse { $this->authorizeAbility($request, 'consultations.manage'); - $this->assertDentistryAccess($request, $modules); + $this->assertDentistryManage($request, $modules); $this->assertVisit($request, $visit); $organization = $this->organization($request); @@ -435,7 +441,7 @@ class DentistryWorkspaceController extends Controller DentalTreatmentPlanService $plans, ): RedirectResponse { $this->authorizeAbility($request, 'consultations.manage'); - $this->assertDentistryAccess($request, $modules); + $this->assertDentistryManage($request, $modules); $this->assertVisit($request, $visit); $validated = $request->validate([ @@ -473,7 +479,7 @@ class DentistryWorkspaceController extends Controller DentalTreatmentPlanService $plans, ): RedirectResponse { $this->authorizeAbility($request, 'consultations.manage'); - $this->assertDentistryAccess($request, $modules); + $this->assertDentistryManage($request, $modules); $this->assertVisit($request, $visit); $organization = $this->organization($request); @@ -503,7 +509,7 @@ class DentistryWorkspaceController extends Controller DentalImagingService $imaging, ): RedirectResponse { $this->authorizeAbility($request, 'consultations.manage'); - $this->assertDentistryAccess($request, $modules); + $this->assertDentistryManage($request, $modules); $this->assertVisit($request, $visit); $organization = $this->organization($request); @@ -532,7 +538,7 @@ class DentistryWorkspaceController extends Controller SpecialtyVisitStageService $stages, ): RedirectResponse { $this->authorizeAbility($request, 'consultations.manage'); - $this->assertDentistryAccess($request, $modules); + $this->assertDentistryManage($request, $modules); $this->assertVisit($request, $visit); $validated = $request->validate([ @@ -564,7 +570,7 @@ class DentistryWorkspaceController extends Controller DentalPerioService $perio, ): RedirectResponse { $this->authorizeAbility($request, 'consultations.manage'); - $this->assertDentistryAccess($request, $modules); + $this->assertDentistryManage($request, $modules); $this->assertVisit($request, $visit); $validated = $request->validate([ @@ -608,7 +614,7 @@ class DentistryWorkspaceController extends Controller DentalLabCaseService $labCases, ): RedirectResponse { $this->authorizeAbility($request, 'consultations.manage'); - $this->assertDentistryAccess($request, $modules); + $this->assertDentistryManage($request, $modules); $this->assertVisit($request, $visit); $validated = $request->validate([ @@ -651,7 +657,7 @@ class DentistryWorkspaceController extends Controller DentalLabCaseService $labCases, ): RedirectResponse { $this->authorizeAbility($request, 'consultations.manage'); - $this->assertDentistryAccess($request, $modules); + $this->assertDentistryManage($request, $modules); $this->assertVisit($request, $visit); $validated = $request->validate([ @@ -689,7 +695,7 @@ class DentistryWorkspaceController extends Controller DentalRecallService $recalls, ): RedirectResponse { $this->authorizeAbility($request, 'consultations.manage'); - $this->assertDentistryAccess($request, $modules); + $this->assertDentistryManage($request, $modules); $this->assertVisit($request, $visit); $validated = $request->validate([ @@ -727,7 +733,7 @@ class DentistryWorkspaceController extends Controller DentalRecallService $recalls, ): RedirectResponse { $this->authorizeAbility($request, 'consultations.manage'); - $this->assertDentistryAccess($request, $modules); + $this->assertDentistryManage($request, $modules); $this->assertVisit($request, $visit); $organization = $this->organization($request); @@ -762,7 +768,7 @@ class DentistryWorkspaceController extends Controller DentalRecallService $recalls, ): RedirectResponse { $this->authorizeAbility($request, 'consultations.manage'); - $this->assertDentistryAccess($request, $modules); + $this->assertDentistryManage($request, $modules); $this->assertVisit($request, $visit); $organization = $this->organization($request); diff --git a/app/Http/Controllers/Care/EmergencyWorkspaceController.php b/app/Http/Controllers/Care/EmergencyWorkspaceController.php index 08efe19..cac3638 100644 --- a/app/Http/Controllers/Care/EmergencyWorkspaceController.php +++ b/app/Http/Controllers/Care/EmergencyWorkspaceController.php @@ -27,6 +27,12 @@ class EmergencyWorkspaceController extends Controller abort_unless($modules->memberCanAccess($organization, $this->member($request), 'emergency'), 403); } + protected function assertEmergencyManage(Request $request, SpecialtyModuleService $modules): void + { + $organization = $this->organization($request); + abort_unless($modules->memberCanManage($organization, $this->member($request), 'emergency'), 403); + } + protected function assertVisit(Request $request, Visit $visit): void { abort_unless($visit->organization_id === $this->organization($request)->id, 404); @@ -40,7 +46,7 @@ class EmergencyWorkspaceController extends Controller SpecialtyVisitStageService $stages, ): RedirectResponse { $this->authorizeAbility($request, 'consultations.manage'); - $this->assertEmergencyAccess($request, $modules); + $this->assertEmergencyManage($request, $modules); $this->assertVisit($request, $visit); $validated = $request->validate([ @@ -72,7 +78,7 @@ class EmergencyWorkspaceController extends Controller EmergencyVitalsService $vitals, ): RedirectResponse { $this->authorizeAbility($request, 'consultations.manage'); - $this->assertEmergencyAccess($request, $modules); + $this->assertEmergencyManage($request, $modules); $this->assertVisit($request, $visit); $validated = $request->validate([ @@ -112,7 +118,7 @@ class EmergencyWorkspaceController extends Controller EmergencyWorkflowService $workflow, ): RedirectResponse { $this->authorizeAbility($request, 'consultations.manage'); - $this->assertEmergencyAccess($request, $modules); + $this->assertEmergencyManage($request, $modules); $this->assertVisit($request, $visit); $organization = $this->organization($request); diff --git a/app/Http/Controllers/Care/SpecialtyModuleController.php b/app/Http/Controllers/Care/SpecialtyModuleController.php index 28bbeba..bf9ddf7 100644 --- a/app/Http/Controllers/Care/SpecialtyModuleController.php +++ b/app/Http/Controllers/Care/SpecialtyModuleController.php @@ -104,6 +104,7 @@ class SpecialtyModuleController extends Controller $organization = $this->organization($request); $member = $this->member($request); abort_unless($modules->memberCanAccess($organization, $member, $module), 403); + abort_unless($modules->memberCanManage($organization, $member, $module), 403); abort_unless($visit->organization_id === $organization->id, 404); $validated = $request->validate([ @@ -142,6 +143,7 @@ class SpecialtyModuleController extends Controller $organization = $this->organization($request); $member = $this->member($request); abort_unless($modules->memberCanAccess($organization, $member, $module), 403); + abort_unless($modules->memberCanManage($organization, $member, $module), 403); abort_unless($visit->organization_id === $organization->id, 404); $this->authorizeBranch($request, (int) $visit->branch_id); @@ -256,6 +258,7 @@ class SpecialtyModuleController extends Controller $organization = $this->organization($request); $member = $this->member($request); abort_unless($modules->memberCanAccess($organization, $member, $module), 403); + abort_unless($modules->memberCanManage($organization, $member, $module), 403); abort_unless($visit->organization_id === $organization->id, 404); $tab = (string) $request->input('tab', ''); @@ -345,6 +348,7 @@ class SpecialtyModuleController extends Controller $organization = $this->organization($request); $member = $this->member($request); abort_unless($modules->memberCanAccess($organization, $member, $module), 403); + abort_unless($modules->memberCanManage($organization, $member, $module), 403); abort_unless($visit->organization_id === $organization->id, 404); abort_unless($visit->patient_id, 422); @@ -388,6 +392,7 @@ class SpecialtyModuleController extends Controller $member = $this->member($request); $owner = $this->ownerRef($request); abort_unless($modules->memberCanAccess($organization, $member, $module), 403); + abort_unless($modules->memberCanManage($organization, $member, $module), 403); abort_unless($queueBridge->isEnabled($organization), 404); $resolver = app(OrganizationResolver::class); @@ -473,6 +478,74 @@ class SpecialtyModuleController extends Controller return back()->with('success', 'Called '.$ticket['ticket_number'].' — '.$name.'.'); } + /** + * Refer a patient into this specialty queue (walk-in) without full clinical edit access. + */ + public function refer( + Request $request, + string $module, + SpecialtyModuleService $modules, + AppointmentService $appointments, + ): RedirectResponse { + $definition = $modules->definition($module); + abort_unless($definition, 404); + + $organization = $this->organization($request); + $member = $this->member($request); + abort_unless($modules->memberCanRefer($organization, $member, $module), 403); + + $owner = $this->ownerRef($request); + $branchScope = app(OrganizationResolver::class)->branchScope($member); + + $validated = $request->validate([ + 'patient_id' => ['required', 'integer', 'exists:care_patients,id'], + 'branch_id' => ['nullable', 'integer', 'exists:care_branches,id'], + 'reason' => ['nullable', 'string', 'max:500'], + 'notes' => ['nullable', 'string', 'max:2000'], + ]); + + $branchId = (int) ($validated['branch_id'] ?? $branchScope ?? 0); + abort_unless($branchId > 0, 422); + $this->authorizeBranch($request, $branchId); + + $department = Department::owned($owner) + ->where('type', $definition['department_type'] ?? 'general') + ->where('branch_id', $branchId) + ->where('is_active', true) + ->orderBy('id') + ->first(); + + if (! $department) { + return back()->with('error', 'No active '.($definition['label'] ?? $module).' department at this branch.'); + } + + $patient = \App\Models\Patient::owned($owner) + ->where('organization_id', $organization->id) + ->findOrFail((int) $validated['patient_id']); + + $appointment = $appointments->walkIn($organization, $owner, [ + 'patient_id' => $patient->id, + 'branch_id' => $branchId, + 'department_id' => $department->id, + 'reason' => $validated['reason'] ?? ('Referral to '.($definition['label'] ?? $module)), + 'notes' => $validated['notes'] ?? null, + ], $owner); + + \App\Services\Care\AuditLogger::record( + $owner, + 'specialty.referral', + $organization->id, + $owner, + Appointment::class, + $appointment->id, + ['module' => $module, 'department_id' => $department->id], + ); + + return redirect() + ->route('care.queue.index') + ->with('success', 'Referred '.$patient->fullName().' to '.($definition['label'] ?? $module).'.'); + } + protected function renderShell( Request $request, string $module, @@ -489,6 +562,10 @@ class SpecialtyModuleController extends Controller $member = $this->member($request); abort_unless($modules->memberCanAccess($organization, $member, $module), 403); + $canManageSpecialty = $modules->memberCanManage($organization, $member, $module); + $canReferSpecialty = $modules->memberCanRefer($organization, $member, $module); + $specialtyAccessLevel = $modules->memberAccessLevel($organization, $member, $module); + $owner = $this->ownerRef($request); $resolver = app(OrganizationResolver::class); $branchScope = $resolver->branchScope($member); @@ -559,8 +636,8 @@ class SpecialtyModuleController extends Controller $services = $shell->provisionedServices($organization, $module); $permissions = app(\App\Services\Care\CarePermissions::class); - $canConsult = $permissions->can($member, 'consultations.manage'); - $canManageQueue = $permissions->can($member, 'appointments.manage'); + $canConsult = $permissions->can($member, 'consultations.manage') && $canManageSpecialty; + $canManageQueue = $permissions->can($member, 'appointments.manage') && $canManageSpecialty; $branchLabel = $branchId ? (string) (\App\Models\Branch::owned($owner)->whereKey($branchId)->value('name') ?? '') : ''; @@ -803,6 +880,9 @@ class SpecialtyModuleController extends Controller 'lockToPractitioner' => $practitionerScope !== null, 'canConsult' => $canConsult, 'canManageQueue' => $canManageQueue, + 'canManageSpecialty' => $canManageSpecialty, + 'canReferSpecialty' => $canReferSpecialty, + 'specialtyAccessLevel' => $specialtyAccessLevel, 'queueIntegration' => $branchId ? $queueBridge->listMeta($organization, $module, $branchId) : ['enabled' => false], diff --git a/app/Services/Care/SpecialtyModuleService.php b/app/Services/Care/SpecialtyModuleService.php index 0e85e9b..5d4ad7e 100644 --- a/app/Services/Care/SpecialtyModuleService.php +++ b/app/Services/Care/SpecialtyModuleService.php @@ -135,26 +135,131 @@ class SpecialtyModuleService } /** - * Enabled modules the member may open (role allowlist + doctor specialty assignment). + * Enabled modules the member may open (manage, view, or refer). * - * @return list}> + * @return list, access_level: string}> */ public function enabledModulesForMember(Organization $organization, ?Member $member): array { - return array_values(array_filter( - $this->enabledModules($organization), - fn (array $item) => $this->memberCanAccess($organization, $member, $item['key']), - )); + $out = []; + foreach ($this->enabledModules($organization) as $item) { + $level = $this->memberAccessLevel($organization, $member, $item['key']); + if ($level === 'none') { + continue; + } + $out[] = array_merge($item, ['access_level' => $level]); + } + + return $out; } /** - * Whether the member may see/serve this specialty module. - * Doctors must be linked to a practitioner desk in that specialty department. - * Receptionists keep queue/ops visibility; facility admins do not staff specialty floors. + * @return 'general'|'limited'|'restricted' + */ + public function accessTier(string $key): string + { + $tier = (string) ($this->definition($key)['access'] ?? 'general'); + + return in_array($tier, ['general', 'limited', 'restricted'], true) ? $tier : 'general'; + } + + /** + * Highest access for this member on the module. + * + * @return 'none'|'view'|'refer'|'manage' + */ + public function memberAccessLevel(Organization $organization, ?Member $member, string $key): string + { + if (! $this->isEnabled($organization, $key)) { + return 'none'; + } + + $definition = $this->definition($key); + if (! $definition || ! $member) { + return 'none'; + } + + if (app(CarePermissions::class)->isAdmin($member)) { + return 'manage'; + } + + if ($this->memberCanManage($organization, $member, $key)) { + return 'manage'; + } + + $canRefer = $this->roleListed($member, $definition['refer_roles'] ?? []); + $canView = $this->roleListed($member, $definition['view_roles'] ?? []); + + if ($canRefer && $canView) { + return 'refer'; + } + if ($canRefer) { + return 'refer'; + } + if ($canView) { + return 'view'; + } + + return 'none'; + } + + /** + * Whether the member may see this specialty module in nav / open workspace (any level). */ public function memberCanAccess(Organization $organization, ?Member $member, string $key): bool { - if (! $this->isEnabled($organization, $key)) { + return $this->memberAccessLevel($organization, $member, $key) !== 'none'; + } + + /** + * Full clinical / queue edit access for the module. + */ + public function memberCanManage(Organization $organization, ?Member $member, string $key): bool + { + if (! $this->isEnabled($organization, $key) || ! $member) { + return false; + } + + if (app(CarePermissions::class)->isAdmin($member)) { + return true; + } + + $definition = $this->definition($key); + if (! $definition) { + return false; + } + + $tier = $this->accessTier($key); + $role = (string) $member->role; + $manageRoles = $definition['roles'] ?? []; + $supportRoles = $definition['support_roles'] ?? []; + + if ($tier === 'restricted') { + if ($this->roleListed($member, $supportRoles)) { + return true; + } + if ($role === 'doctor' && $this->roleListed($member, $manageRoles)) { + return $this->doctorAssignedToModule($organization, $member, $key) + || $this->doctorSpecialtyMatchesModule($organization, $member, $key); + } + + return false; + } + + // general + limited: role allowlist is enough (GPs = doctor without specialty desk). + return $this->roleListed($member, $manageRoles); + } + + /** + * Read results / history / workspace without clinical edit. + */ + public function memberCanView(Organization $organization, ?Member $member, string $key): bool + { + if ($this->memberCanManage($organization, $member, $key)) { + return true; + } + + if (! $this->isEnabled($organization, $key) || ! $member) { return false; } @@ -163,25 +268,36 @@ class SpecialtyModuleService return false; } - if (app(CarePermissions::class)->isAdmin($member)) { + return $this->roleListed($member, $definition['view_roles'] ?? []); + } + + /** + * Refer a patient into the specialty queue without full clinical edit. + */ + public function memberCanRefer(Organization $organization, ?Member $member, string $key): bool + { + if ($this->memberCanManage($organization, $member, $key)) { return true; } - $roles = $definition['roles'] ?? []; - if (is_array($roles) && $roles !== [] && $member && ! in_array($member->role, $roles, true)) { + if (! $this->isEnabled($organization, $key) || ! $member) { return false; } - if ($member && $member->role === 'doctor') { - // Default Pro surfaces (Emergency, Blood Bank) are available to all doctors. - if ($this->isDefaultOnPaidPlans($key)) { - return true; - } - - return $this->doctorAssignedToModule($organization, $member, $key); + $definition = $this->definition($key); + if (! $definition) { + return false; } - return true; + return $this->roleListed($member, $definition['refer_roles'] ?? []); + } + + /** + * @param list|mixed $roles + */ + protected function roleListed(Member $member, mixed $roles): bool + { + return is_array($roles) && $roles !== [] && in_array($member->role, $roles, true); } public function doctorAssignedToModule(Organization $organization, Member $member, string $key): bool @@ -201,9 +317,77 @@ class SpecialtyModuleService ? array_map('intval', $provisionedIds) : []; + foreach ($this->practitionersForMember($organization, $member) as $practitioner) { + if ($provisionedIds !== [] && in_array((int) $practitioner->department_id, $provisionedIds, true)) { + return true; + } + if ($departmentType !== '' && $practitioner->department?->type === $departmentType) { + return true; + } + } + + return false; + } + + /** + * Match practitioner.specialty text to module specialist_keywords (restricted specialists). + */ + public function doctorSpecialtyMatchesModule(Organization $organization, Member $member, string $key): bool + { + $definition = $this->definition($key); + if (! $definition) { + return false; + } + + $keywords = $definition['specialist_keywords'] ?? []; + if (! is_array($keywords) || $keywords === []) { + $keywords = $definition['queue_keywords'] ?? []; + } + if (! is_array($keywords) || $keywords === []) { + return false; + } + + foreach ($this->practitionersForMember($organization, $member) as $practitioner) { + $hay = strtolower(trim((string) ($practitioner->specialty ?? ''))); + if ($hay === '' || $this->isGeneralPracticeSpecialty($hay)) { + continue; + } + foreach ($keywords as $keyword) { + $needle = strtolower((string) $keyword); + if ($needle !== '' && str_contains($hay, $needle)) { + return true; + } + } + } + + return false; + } + + protected function isGeneralPracticeSpecialty(string $specialtyLower): bool + { + if (in_array($specialtyLower, [ + 'general practice', + 'general', + 'gp', + 'family medicine', + 'internal medicine', + 'nursing', + ], true)) { + return true; + } + + return str_contains($specialtyLower, 'general practice') + || str_contains($specialtyLower, 'family medicine'); + } + + /** + * @return \Illuminate\Support\Collection + */ + protected function practitionersForMember(Organization $organization, Member $member) + { $branchId = app(OrganizationResolver::class)->branchScope($member); - $practitioners = Practitioner::owned((string) $organization->owner_ref) + return Practitioner::owned((string) $organization->owner_ref) ->where('organization_id', $organization->id) ->where('is_active', true) ->where(function ($query) use ($member) { @@ -219,17 +403,6 @@ class SpecialtyModuleService return in_array((int) $branchId, $practitioner->assignedBranchIds(), true); }); - - foreach ($practitioners as $practitioner) { - if ($provisionedIds !== [] && in_array((int) $practitioner->department_id, $provisionedIds, true)) { - return true; - } - if ($departmentType !== '' && $practitioner->department?->type === $departmentType) { - return true; - } - } - - return false; } public function canManage(Organization $organization): bool diff --git a/config/care_specialty_modules.php b/config/care_specialty_modules.php index 2bf01af..cf8afc1 100644 --- a/config/care_specialty_modules.php +++ b/config/care_specialty_modules.php @@ -4,6 +4,12 @@ * Specialty practice modules (Settings → Modules). * Pro-gated via plans.*.features specialty_modules. * + * Access tiers (RBAC via Care member roles — do not invent new roles): + * - general: broad day-to-day access for listed roles (GPs = doctor) + * - limited: GPs + nurses (and listed roles) without specialist assignment + * - restricted: specialist doctors (department / specialty keywords) + optional support_roles; + * other clinicians use view_roles / refer_roles + * * default_on_paid_plans: always on for Pro/Enterprise (sidebar + access). * * @return array> @@ -18,7 +24,8 @@ return [ 'queue_prefix' => 'ER', 'nav_label' => 'Emergency', 'queue_keywords' => ['emerg', 'casualty', 'trauma', 'er', 'a&e'], - 'roles' => ['doctor', 'nurse', 'receptionist'], + 'access' => 'general', + 'roles' => ['doctor', 'nurse', 'lab_technician', 'pharmacist', 'receptionist'], 'default_on_paid_plans' => true, ], 'blood_bank' => [ @@ -30,7 +37,8 @@ return [ 'queue_prefix' => 'BB', 'nav_label' => 'Blood Bank', 'queue_keywords' => ['blood', 'transfusion', 'crossmatch', 'donor'], - 'roles' => ['doctor', 'nurse', 'lab_technician', 'receptionist'], + 'access' => 'general', + 'roles' => ['lab_technician', 'nurse', 'doctor', 'receptionist'], 'default_on_paid_plans' => true, ], 'dentistry' => [ @@ -42,7 +50,9 @@ return [ 'queue_prefix' => 'DEN', 'nav_label' => 'Dentistry', 'queue_keywords' => ['dent', 'dental', 'oral'], + 'access' => 'limited', 'roles' => ['doctor', 'nurse', 'receptionist'], + 'specialist_keywords' => ['dent', 'dental', 'oral', 'odont'], ], 'ophthalmology' => [ 'label' => 'Eye care', @@ -53,7 +63,9 @@ return [ 'queue_prefix' => 'EYE', 'nav_label' => 'Eye care', 'queue_keywords' => ['eye', 'ophthal', 'optom'], + 'access' => 'limited', 'roles' => ['doctor', 'nurse', 'receptionist'], + 'specialist_keywords' => ['eye', 'ophthal', 'optom'], ], 'physiotherapy' => [ 'label' => 'Physiotherapy', @@ -64,6 +76,7 @@ return [ 'queue_prefix' => 'PHY', 'nav_label' => 'Physiotherapy', 'queue_keywords' => ['physio', 'rehab', 'therapy'], + 'access' => 'general', 'roles' => ['doctor', 'nurse', 'receptionist'], ], 'maternity' => [ @@ -75,7 +88,8 @@ return [ 'queue_prefix' => 'MAT', 'nav_label' => 'Maternity', 'queue_keywords' => ['matern', 'antenatal', 'obstetric'], - 'roles' => ['doctor', 'nurse', 'receptionist'], + 'access' => 'general', + 'roles' => ['doctor', 'nurse', 'pharmacist', 'receptionist'], ], 'radiology' => [ 'label' => 'Radiology', @@ -86,7 +100,8 @@ return [ 'queue_prefix' => 'RAD', 'nav_label' => 'Radiology', 'queue_keywords' => ['radio', 'imaging', 'x-ray', 'xray', 'ultrasound', 'ct', 'mri'], - 'roles' => ['doctor', 'lab_technician', 'receptionist'], + 'access' => 'general', + 'roles' => ['doctor', 'nurse', 'lab_technician', 'receptionist'], ], 'cardiology' => [ 'label' => 'Cardiology', @@ -97,7 +112,12 @@ return [ 'queue_prefix' => 'CAR', 'nav_label' => 'Cardiology', 'queue_keywords' => ['cardio', 'heart', 'ecg', 'echo'], - 'roles' => ['doctor', 'nurse', 'receptionist'], + 'access' => 'restricted', + 'roles' => ['doctor'], + 'support_roles' => ['nurse'], + 'view_roles' => ['doctor', 'nurse', 'lab_technician'], + 'refer_roles' => ['doctor', 'nurse'], + 'specialist_keywords' => ['cardio', 'cardiology', 'heart'], ], 'psychiatry' => [ 'label' => 'Psychiatry', @@ -108,7 +128,12 @@ return [ 'queue_prefix' => 'PSY', 'nav_label' => 'Psychiatry', 'queue_keywords' => ['psych', 'mental', 'behaviour'], - 'roles' => ['doctor', 'nurse', 'receptionist'], + 'access' => 'restricted', + 'roles' => ['doctor'], + 'support_roles' => ['nurse'], + 'view_roles' => ['doctor', 'nurse'], + 'refer_roles' => ['doctor', 'nurse'], + 'specialist_keywords' => ['psych', 'psychiatry', 'mental'], ], 'pediatrics' => [ 'label' => 'Pediatrics', @@ -119,6 +144,7 @@ return [ 'queue_prefix' => 'PED', 'nav_label' => 'Pediatrics', 'queue_keywords' => ['pedia', 'paedia', 'child', 'infant'], + 'access' => 'general', 'roles' => ['doctor', 'nurse', 'receptionist'], ], 'orthopedics' => [ @@ -130,7 +156,12 @@ return [ 'queue_prefix' => 'ORT', 'nav_label' => 'Orthopedics', 'queue_keywords' => ['ortho', 'fracture', 'bone', 'joint'], - 'roles' => ['doctor', 'nurse', 'receptionist'], + 'access' => 'restricted', + 'roles' => ['doctor'], + 'support_roles' => [], + 'view_roles' => ['doctor', 'nurse'], + 'refer_roles' => ['doctor', 'nurse'], + 'specialist_keywords' => ['ortho', 'orthopedic', 'orthopaedic', 'fracture'], ], 'ent' => [ 'label' => 'ENT', @@ -141,7 +172,12 @@ return [ 'queue_prefix' => 'ENT', 'nav_label' => 'ENT', 'queue_keywords' => ['ent', 'ear', 'nose', 'throat', 'otolaryng'], - 'roles' => ['doctor', 'nurse', 'receptionist'], + 'access' => 'restricted', + 'roles' => ['doctor'], + 'support_roles' => [], + 'view_roles' => ['doctor', 'nurse'], + 'refer_roles' => ['doctor', 'nurse'], + 'specialist_keywords' => ['ent', 'otolaryng', 'ear', 'nose', 'throat'], ], 'oncology' => [ 'label' => 'Oncology', @@ -152,7 +188,12 @@ return [ 'queue_prefix' => 'ONC', 'nav_label' => 'Oncology', 'queue_keywords' => ['oncol', 'cancer', 'chemo'], - 'roles' => ['doctor', 'nurse', 'receptionist'], + 'access' => 'restricted', + 'roles' => ['doctor'], + 'support_roles' => ['nurse'], + 'view_roles' => ['doctor', 'nurse', 'pharmacist'], + 'refer_roles' => ['doctor', 'nurse'], + 'specialist_keywords' => ['oncol', 'cancer', 'chemo'], ], 'renal' => [ 'label' => 'Renal Care', @@ -163,7 +204,12 @@ return [ 'queue_prefix' => 'REN', 'nav_label' => 'Renal Care', 'queue_keywords' => ['renal', 'dialysis', 'nephro', 'kidney'], - 'roles' => ['doctor', 'nurse', 'receptionist'], + 'access' => 'restricted', + 'roles' => ['doctor'], + 'support_roles' => ['nurse'], + 'view_roles' => ['doctor', 'nurse'], + 'refer_roles' => ['doctor', 'nurse'], + 'specialist_keywords' => ['renal', 'nephro', 'dialysis', 'kidney'], ], 'surgery' => [ 'label' => 'Surgery', @@ -174,7 +220,12 @@ return [ 'queue_prefix' => 'SUR', 'nav_label' => 'Surgery', 'queue_keywords' => ['surg', 'theatre', 'operat', 'pre-op'], - 'roles' => ['doctor', 'nurse', 'receptionist'], + 'access' => 'restricted', + 'roles' => ['doctor'], + 'support_roles' => ['nurse'], + 'view_roles' => ['doctor', 'nurse'], + 'refer_roles' => ['doctor', 'nurse'], + 'specialist_keywords' => ['surg', 'theatre', 'operat'], ], 'vaccination' => [ 'label' => 'Vaccination & Immunization', @@ -185,6 +236,7 @@ return [ 'queue_prefix' => 'VAX', 'nav_label' => 'Vaccination', 'queue_keywords' => ['vaccin', 'immun', 'epi', 'injection'], + 'access' => 'general', 'roles' => ['doctor', 'nurse', 'receptionist'], ], 'pathology' => [ @@ -196,7 +248,9 @@ return [ 'queue_prefix' => 'PATH', 'nav_label' => 'Pathology', 'queue_keywords' => ['pathol', 'histo', 'cytol', 'biopsy'], - 'roles' => ['doctor', 'lab_technician', 'receptionist'], + 'access' => 'general', + 'roles' => ['lab_technician', 'doctor', 'nurse', 'receptionist'], + 'specialist_keywords' => ['pathol', 'histo', 'cytol'], ], 'infusion' => [ 'label' => 'Infusion Centre', @@ -207,7 +261,8 @@ return [ 'queue_prefix' => 'INF', 'nav_label' => 'Infusion', 'queue_keywords' => ['infusion', 'iv', 'drip', 'day care'], - 'roles' => ['doctor', 'nurse', 'receptionist'], + 'access' => 'general', + 'roles' => ['doctor', 'nurse', 'pharmacist', 'receptionist'], ], 'dermatology' => [ 'label' => 'Dermatology', @@ -218,7 +273,12 @@ return [ 'queue_prefix' => 'DER', 'nav_label' => 'Dermatology', 'queue_keywords' => ['derma', 'skin', 'rash'], - 'roles' => ['doctor', 'nurse', 'receptionist'], + 'access' => 'restricted', + 'roles' => ['doctor'], + 'support_roles' => [], + 'view_roles' => ['doctor', 'nurse'], + 'refer_roles' => ['doctor', 'nurse'], + 'specialist_keywords' => ['derma', 'skin'], ], 'podiatry' => [ 'label' => 'Podiatry', @@ -229,7 +289,12 @@ return [ 'queue_prefix' => 'POD', 'nav_label' => 'Podiatry', 'queue_keywords' => ['podiat', 'foot', 'diabetic foot'], - 'roles' => ['doctor', 'nurse', 'receptionist'], + 'access' => 'restricted', + 'roles' => ['doctor'], + 'support_roles' => [], + 'view_roles' => ['doctor', 'nurse'], + 'refer_roles' => ['doctor', 'nurse'], + 'specialist_keywords' => ['podiat', 'foot'], ], 'fertility' => [ 'label' => 'Fertility', @@ -240,7 +305,12 @@ return [ 'queue_prefix' => 'FER', 'nav_label' => 'Fertility', 'queue_keywords' => ['fertil', 'ivf', 'reproduct'], - 'roles' => ['doctor', 'nurse', 'receptionist'], + 'access' => 'restricted', + 'roles' => ['doctor'], + 'support_roles' => [], + 'view_roles' => ['doctor', 'nurse'], + 'refer_roles' => ['doctor', 'nurse'], + 'specialist_keywords' => ['fertil', 'ivf', 'reproduct'], ], 'child_welfare' => [ 'label' => 'Child Welfare Clinic', @@ -251,6 +321,7 @@ return [ 'queue_prefix' => 'CWC', 'nav_label' => 'Child Welfare', 'queue_keywords' => ['cwc', 'child welfare', 'well baby', 'growth'], + 'access' => 'general', 'roles' => ['doctor', 'nurse', 'receptionist'], ], ]; diff --git a/resources/views/care/dashboard.blade.php b/resources/views/care/dashboard.blade.php index 9a781af..ab73823 100644 --- a/resources/views/care/dashboard.blade.php +++ b/resources/views/care/dashboard.blade.php @@ -205,7 +205,14 @@ @endphp -

{{ $item['definition']['nav_label'] ?? $item['definition']['label'] }}

+
+

{{ $item['definition']['nav_label'] ?? $item['definition']['label'] }}

+ @if (! empty($item['access_level']) && $item['access_level'] !== 'manage' && ! $toSettings) + + {{ $item['access_level'] === 'refer' ? 'View + refer' : 'View' }} + + @endif +

{{ $item['definition']['description'] ?? '' }}

@if ($toSettings)

Settings & pricing →

diff --git a/resources/views/care/specialty/partials/actions-menu.blade.php b/resources/views/care/specialty/partials/actions-menu.blade.php index e1c047a..4ca321c 100644 --- a/resources/views/care/specialty/partials/actions-menu.blade.php +++ b/resources/views/care/specialty/partials/actions-menu.blade.php @@ -58,25 +58,25 @@ @endif @if ($workspaceVisit) - @if ($moduleKey === 'dentistry' && $currentStage && isset($stageFlow[$currentStage])) + @if (($canManageSpecialty ?? true) && $moduleKey === 'dentistry' && $currentStage && isset($stageFlow[$currentStage]))
@csrf
- @elseif ($moduleKey === 'dentistry' && ! $currentStage) + @elseif (($canManageSpecialty ?? true) && $moduleKey === 'dentistry' && ! $currentStage)
@csrf
- @elseif ($moduleKey === 'emergency' && $currentStage && isset($stageFlow[$currentStage]) && ($stageFlow[$currentStage]['next'] ?? null) !== $currentStage) + @elseif (($canManageSpecialty ?? true) && $moduleKey === 'emergency' && $currentStage && isset($stageFlow[$currentStage]) && ($stageFlow[$currentStage]['next'] ?? null) !== $currentStage)
@csrf
- @elseif ($moduleKey === 'emergency' && ! $currentStage) + @elseif (($canManageSpecialty ?? true) && $moduleKey === 'emergency' && ! $currentStage)
@csrf @@ -84,7 +84,7 @@
@endif - @if ($canCompleteConsultation) + @if (($canManageSpecialty ?? true) && $canCompleteConsultation)
@endif + @if (($canReferSpecialty ?? false) && ! ($canManageSpecialty ?? true) && $workspaceVisit->patient) +
+ @csrf + + + + +
+ @endif @if ($workspaceVisit->patient) Patient chart diff --git a/resources/views/care/specialty/partials/clinical-form.blade.php b/resources/views/care/specialty/partials/clinical-form.blade.php index d0c0473..a3cd896 100644 --- a/resources/views/care/specialty/partials/clinical-form.blade.php +++ b/resources/views/care/specialty/partials/clinical-form.blade.php @@ -29,6 +29,25 @@ @endif + @if (! ($canManageSpecialty ?? true)) +
+ @forelse ($clinicalFields as $field) + @php + $name = $field['name']; + $value = $values[$name] ?? null; + if (is_bool($value)) { + $value = $value ? 'Yes' : 'No'; + } + @endphp +
($field['type'] ?? '') === 'textarea'])> +
{{ $field['label'] }}
+
{{ $value !== null && $value !== '' ? $value : '—' }}
+
+ @empty +

No clinical record yet.

+ @endforelse +
+ @else
@csrf @@ -103,4 +122,5 @@ @endif
+ @endif diff --git a/resources/views/care/specialty/shell.blade.php b/resources/views/care/specialty/shell.blade.php index 935e6a2..5b4bf3e 100644 --- a/resources/views/care/specialty/shell.blade.php +++ b/resources/views/care/specialty/shell.blade.php @@ -16,11 +16,26 @@ @if ($moduleKey === 'dentistry')
Reports @endif + @if ($moduleKey === 'emergency') + Reports + @endif History - Billing + @if ($canManageSpecialty ?? true) + Billing + @endif + @if (($specialtyAccessLevel ?? 'manage') !== 'manage') +
+ @if (($specialtyAccessLevel ?? '') === 'refer') + You have view + refer access. You can review results and refer patients into this specialty queue; clinical editing is limited to specialists and supporting staff. + @else + You have view-only access. Results and history are available; clinical edits require specialty access. + @endif +
+ @endif +
@include('care.specialty.sections.workspace') diff --git a/resources/views/partials/sidebar.blade.php b/resources/views/partials/sidebar.blade.php index fe88ff6..de20ff9 100644 --- a/resources/views/partials/sidebar.blade.php +++ b/resources/views/partials/sidebar.blade.php @@ -76,12 +76,25 @@ 'icon' => '']; } - // Specialty modules stay enabled for clinical depth, but patient flow lives on Queue only. + // Specialty modules — role-gated clinical surfaces for floor staff. if ($permissions->handlesFloorCare($member) && $organization) { - app(\App\Services\Care\SpecialtyModuleService::class)->ensureDefaultModulesProvisioned( + $specialtyService = app(\App\Services\Care\SpecialtyModuleService::class); + $specialtyService->ensureDefaultModulesProvisioned( $organization, (string) $organization->owner_ref, ); + $specialtyIcon = ''; + foreach ($specialtyService->enabledModulesForMember($organization, $member) as $item) { + $key = $item['key']; + $label = $item['definition']['nav_label'] ?? $item['definition']['label'] ?? $key; + $nav[] = [ + 'name' => $label, + 'route' => route('care.specialty.workspace', $key), + 'active' => request()->routeIs('care.specialty.*') && request()->route('module') === $key, + 'icon' => $specialtyIcon, + 'group' => 'specialty', + ]; + } } } @@ -108,7 +121,12 @@ @endphp