diff --git a/app/Http/Controllers/Care/DashboardController.php b/app/Http/Controllers/Care/DashboardController.php index 076ff69..aa6f894 100644 --- a/app/Http/Controllers/Care/DashboardController.php +++ b/app/Http/Controllers/Care/DashboardController.php @@ -95,7 +95,7 @@ class DashboardController extends Controller 'patientQueue' => $queue, 'inConsultation' => $inConsultation, 'practitionerId' => $practitionerId, - 'specialtyModules' => $this->specialtyModules->enabledModules($organization), + 'specialtyModules' => $this->specialtyModules->enabledModulesForMember($organization, $member), ]); } diff --git a/app/Http/Controllers/Care/SpecialtyModuleController.php b/app/Http/Controllers/Care/SpecialtyModuleController.php index 5023d47..35ce821 100644 --- a/app/Http/Controllers/Care/SpecialtyModuleController.php +++ b/app/Http/Controllers/Care/SpecialtyModuleController.php @@ -27,16 +27,13 @@ class SpecialtyModuleController extends Controller abort_unless($definition, 404); $organization = $this->organization($request); - abort_unless($modules->isEnabled($organization, $module), 404); - - $roles = $definition['roles'] ?? []; $member = $this->member($request); - if (is_array($roles) && $roles !== [] && $member && ! in_array($member->role, $roles, true)) { - abort(403); - } + abort_unless($modules->memberCanAccess($organization, $member, $module), 403); $owner = $this->ownerRef($request); - $branchScope = app(OrganizationResolver::class)->branchScope($member); + $resolver = app(OrganizationResolver::class); + $branchScope = $resolver->branchScope($member); + $practitionerScope = $resolver->practitionerScope($organization, $member); $departments = Department::owned($owner) ->where('type', $definition['department_type'] ?? 'general') @@ -54,6 +51,10 @@ class SpecialtyModuleController extends Controller ->when($departmentIds !== [], fn ($q) => $q->whereIn('department_id', $departmentIds)) ->when($departmentIds === [], fn ($q) => $q->whereRaw('1 = 0')) ->when($branchScope, fn ($q) => $q->where('branch_id', $branchScope)) + ->when( + $practitionerScope !== null, + fn ($q) => $q->whereIn('practitioner_id', $practitionerScope ?: [0]), + ) ->with(['patient', 'practitioner', 'department']) ->orderBy('queue_position') ->orderBy('checked_in_at') @@ -61,6 +62,14 @@ class SpecialtyModuleController extends Controller ->get(); $branchId = (int) ($branchScope ?: $departments->first()?->branch_id); + if ($practitionerScope !== null && $practitionerScope !== []) { + $pracBranch = (int) \App\Models\Practitioner::owned($owner) + ->whereKey($practitionerScope[0]) + ->value('branch_id'); + if ($pracBranch > 0) { + $branchId = $pracBranch; + } + } return view('care.specialty.show', [ 'organization' => $organization, @@ -86,15 +95,30 @@ class SpecialtyModuleController extends Controller abort_unless($definition, 404); $organization = $this->organization($request); - abort_unless($modules->isEnabled($organization, $module), 404); + $member = $this->member($request); + abort_unless($modules->memberCanAccess($organization, $member, $module), 403); abort_unless($queueBridge->isEnabled($organization), 404); - $member = $this->member($request); - $branchScope = app(OrganizationResolver::class)->branchScope($member); + $resolver = app(OrganizationResolver::class); + $branchScope = $resolver->branchScope($member); + $practitionerScope = $resolver->practitionerScope($organization, $member); $branchId = (int) ($request->input('branch_id') ?: $branchScope); + $practitionerId = null; + + if ($practitionerScope !== null) { + abort_unless($practitionerScope !== [], 422); + $practitionerId = $practitionerScope[0]; + $pracBranch = (int) \App\Models\Practitioner::owned($this->ownerRef($request)) + ->whereKey($practitionerId) + ->value('branch_id'); + if ($pracBranch > 0) { + $branchId = $pracBranch; + } + } + abort_unless($branchId > 0, 422); - $result = $queueBridge->callNext($organization, $module, $branchId); + $result = $queueBridge->callNext($organization, $module, $branchId, $member, $practitionerId); $ticket = $result['ticket'] ?? null; if (! $ticket) { return back()->with('info', 'No patients waiting in this specialty queue.'); diff --git a/app/Services/Care/DemoTenantSeeder.php b/app/Services/Care/DemoTenantSeeder.php index 4a12e97..142b06e 100644 --- a/app/Services/Care/DemoTenantSeeder.php +++ b/app/Services/Care/DemoTenantSeeder.php @@ -132,6 +132,7 @@ class DemoTenantSeeder $practitioners, $patients, $ownerRef, + $plan, ); } $this->assignAllDemoWaiters($organization, $ownerRef, $branches, $practitioners); @@ -286,6 +287,9 @@ class DemoTenantSeeder ->owned($ownerRef) ->where('organization_id', $organization->id) ->whereIn('status', [Appointment::STATUS_WAITING, Appointment::STATUS_CHECKED_IN]) + ->where(function ($q) { + $q->whereNull('practitioner_id')->orWhere('practitioner_id', 0); + }) ->get(); foreach ($waiters as $index => $appointment) { @@ -772,6 +776,7 @@ class DemoTenantSeeder array $practitioners, array $patients, string $ownerRef, + string $plan = 'pro', ): int { if ($branches === [] || $patients === [] || $practitioners === []) { return 0; @@ -789,6 +794,9 @@ class DemoTenantSeeder $catalog = config('care.specialty_modules', []); /** @var array $nextPositionByBranch */ $nextPositionByBranch = []; + /** @var array $moduleMembers */ + $moduleMembers = []; + foreach ($catalog as $key => $definition) { $type = (string) ($definition['department_type'] ?? ''); if ($type === '') { @@ -796,6 +804,11 @@ class DemoTenantSeeder } $moduleReasons = $reasons[$key] ?? ['Specialty visit']; + $staffEmail = sprintf('demo-%s-%s@ladill.com', $plan, $key); + $moduleMembers[$key] = Member::query() + ->where('organization_id', $organization->id) + ->where('user_ref', $staffEmail) + ->first(); foreach ($branches as $branchIndex => $branch) { $department = Department::owned($ownerRef) @@ -807,17 +820,22 @@ class DemoTenantSeeder continue; } + $member = $moduleMembers[$key]; + $staff = $member ? \App\Support\DemoWorld::staffByEmail($staffEmail) : null; + $stableRef = sprintf('demo-specialty-%s-%s', $key, $branch->id); $practitioner = Practitioner::withTrashed()->updateOrCreate( [ 'organization_id' => $organization->id, - 'user_ref' => sprintf('demo-specialty-%s-%s', $key, $branch->id), + 'user_ref' => $stableRef, ], [ 'owner_ref' => $ownerRef, 'branch_id' => $branch->id, 'department_id' => $department->id, - 'member_id' => null, - 'name' => ($definition['label'] ?? ucfirst($key)).' Clinic', + 'member_id' => $member?->id, + 'name' => is_array($staff) + ? (string) $staff['name'] + : (($definition['label'] ?? ucfirst($key)).' Clinic'), 'specialty' => (string) ($definition['label'] ?? $key), 'room' => ($definition['label'] ?? 'Specialty').' Bay', 'is_active' => true, diff --git a/app/Services/Care/SpecialtyModuleService.php b/app/Services/Care/SpecialtyModuleService.php index 0869150..b37e174 100644 --- a/app/Services/Care/SpecialtyModuleService.php +++ b/app/Services/Care/SpecialtyModuleService.php @@ -4,7 +4,9 @@ namespace App\Services\Care; use App\Models\Branch; use App\Models\Department; +use App\Models\Member; use App\Models\Organization; +use App\Models\Practitioner; use App\Services\Queue\QueueClient; use Illuminate\Support\Facades\Log; @@ -79,6 +81,86 @@ class SpecialtyModuleService return $out; } + /** + * Enabled modules the member may open (role allowlist + doctor specialty assignment). + * + * @return list}> + */ + 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']), + )); + } + + /** + * Whether the member may see/serve this specialty module. + * Doctors must be linked to a practitioner desk in that specialty department. + * Admins/receptionists keep org-wide visibility for operations. + */ + public function memberCanAccess(Organization $organization, ?Member $member, string $key): bool + { + if (! $this->isEnabled($organization, $key)) { + return false; + } + + $definition = $this->definition($key); + if (! $definition) { + return false; + } + + $roles = $definition['roles'] ?? []; + if (is_array($roles) && $roles !== [] && $member && ! in_array($member->role, $roles, true)) { + return false; + } + + if ($member && $member->role === 'doctor') { + return $this->doctorAssignedToModule($organization, $member, $key); + } + + return true; + } + + public function doctorAssignedToModule(Organization $organization, Member $member, string $key): bool + { + $definition = $this->definition($key); + if (! $definition) { + return false; + } + + $departmentType = (string) ($definition['department_type'] ?? ''); + $provisionedIds = data_get( + $organization->settings, + "specialty_module_provisioning.{$key}.department_ids", + [], + ); + $provisionedIds = is_array($provisionedIds) + ? array_map('intval', $provisionedIds) + : []; + + $practitioners = Practitioner::owned((string) $organization->owner_ref) + ->where('organization_id', $organization->id) + ->where('is_active', true) + ->where(function ($query) use ($member) { + $query->where('member_id', $member->id) + ->orWhere('user_ref', $member->user_ref); + }) + ->with('department') + ->get(); + + 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 { return $this->plans->hasFeature($organization, 'specialty_modules'); diff --git a/app/Support/DemoWorld.php b/app/Support/DemoWorld.php index c2bc1b6..56e4793 100644 --- a/app/Support/DemoWorld.php +++ b/app/Support/DemoWorld.php @@ -272,6 +272,41 @@ final class DemoWorld 'meet' => 'member', ], ], + [ + 'key' => 'dentistry', + 'email' => 'demo-pro-dentistry@ladill.com', + 'name' => 'Dr. Ama Dental (Pro)', + 'apps' => ['care'], + 'roles' => ['care' => 'doctor'], + ], + [ + 'key' => 'ophthalmology', + 'email' => 'demo-pro-ophthalmology@ladill.com', + 'name' => 'Dr. Kofi Eye (Pro)', + 'apps' => ['care'], + 'roles' => ['care' => 'doctor'], + ], + [ + 'key' => 'physiotherapy', + 'email' => 'demo-pro-physiotherapy@ladill.com', + 'name' => 'Dr. Efua Physio (Pro)', + 'apps' => ['care'], + 'roles' => ['care' => 'doctor'], + ], + [ + 'key' => 'maternity', + 'email' => 'demo-pro-maternity@ladill.com', + 'name' => 'Dr. Abena Maternity (Pro)', + 'apps' => ['care'], + 'roles' => ['care' => 'doctor'], + ], + [ + 'key' => 'radiology', + 'email' => 'demo-pro-radiology@ladill.com', + 'name' => 'Dr. Yaw Radiology (Pro)', + 'apps' => ['care'], + 'roles' => ['care' => 'doctor'], + ], ], 'enterprise' => [ [ @@ -346,6 +381,41 @@ final class DemoWorld 'events' => 'member', ], ], + [ + 'key' => 'dentistry', + 'email' => 'demo-enterprise-dentistry@ladill.com', + 'name' => 'Dr. Ama Dental', + 'apps' => ['care'], + 'roles' => ['care' => 'doctor'], + ], + [ + 'key' => 'ophthalmology', + 'email' => 'demo-enterprise-ophthalmology@ladill.com', + 'name' => 'Dr. Kofi Eye', + 'apps' => ['care'], + 'roles' => ['care' => 'doctor'], + ], + [ + 'key' => 'physiotherapy', + 'email' => 'demo-enterprise-physiotherapy@ladill.com', + 'name' => 'Dr. Efua Physio', + 'apps' => ['care'], + 'roles' => ['care' => 'doctor'], + ], + [ + 'key' => 'maternity', + 'email' => 'demo-enterprise-maternity@ladill.com', + 'name' => 'Dr. Abena Maternity', + 'apps' => ['care'], + 'roles' => ['care' => 'doctor'], + ], + [ + 'key' => 'radiology', + 'email' => 'demo-enterprise-radiology@ladill.com', + 'name' => 'Dr. Yaw Radiology', + 'apps' => ['care'], + 'roles' => ['care' => 'doctor'], + ], ], ]; diff --git a/resources/views/partials/sidebar.blade.php b/resources/views/partials/sidebar.blade.php index 389341f..fcf1a24 100644 --- a/resources/views/partials/sidebar.blade.php +++ b/resources/views/partials/sidebar.blade.php @@ -76,12 +76,9 @@ // Specialty modules (Settings → Modules) — no separate Service Queues nav; // Ladill Queue embeds on clinical / pharmacy / lab / specialty pages. - $specialtyModules = app(\App\Services\Care\SpecialtyModuleService::class)->enabledModules($organization); + $specialtyModules = app(\App\Services\Care\SpecialtyModuleService::class) + ->enabledModulesForMember($organization, $member); foreach ($specialtyModules as $item) { - $roles = $item['definition']['roles'] ?? []; - if (is_array($roles) && $roles !== [] && $member && ! in_array($member->role, $roles, true)) { - continue; - } $nav[] = [ 'name' => $item['definition']['nav_label'] ?? $item['definition']['label'], 'route' => route('care.specialty.show', $item['key']), diff --git a/tests/Feature/CareSpecialtyModulesTest.php b/tests/Feature/CareSpecialtyModulesTest.php index f237c38..f6c0425 100644 --- a/tests/Feature/CareSpecialtyModulesTest.php +++ b/tests/Feature/CareSpecialtyModulesTest.php @@ -268,4 +268,78 @@ class CareSpecialtyModulesTest extends TestCase ->assertSee('Dentistry') ->assertSee('Departments'); } + + public function test_unassigned_doctor_cannot_open_specialty_module(): void + { + Http::fake(); + app(SpecialtyModuleService::class)->activate($this->organization, $this->owner->public_id, 'dentistry'); + + $doctor = User::create([ + 'public_id' => 'unassigned-doc', + 'name' => 'General Doctor', + 'email' => 'general-doc@example.com', + ]); + Member::create([ + 'owner_ref' => $this->owner->public_id, + 'organization_id' => $this->organization->id, + 'user_ref' => $doctor->public_id, + 'role' => 'doctor', + 'branch_id' => $this->branch->id, + ]); + + $this->actingAs($doctor) + ->get(route('care.specialty.show', 'dentistry')) + ->assertForbidden(); + } + + public function test_assigned_specialty_doctor_can_open_module(): void + { + Http::fake(); + app(SpecialtyModuleService::class)->activate($this->organization, $this->owner->public_id, 'dentistry'); + $department = Department::query()->where('type', 'dental')->firstOrFail(); + + $doctor = User::create([ + 'public_id' => 'dental-doc', + 'name' => 'Dental Doctor', + 'email' => 'dental-doc@example.com', + ]); + $member = Member::create([ + 'owner_ref' => $this->owner->public_id, + 'organization_id' => $this->organization->id, + 'user_ref' => $doctor->public_id, + 'role' => 'doctor', + 'branch_id' => $this->branch->id, + ]); + \App\Models\Practitioner::create([ + 'owner_ref' => $this->owner->public_id, + 'organization_id' => $this->organization->id, + 'branch_id' => $this->branch->id, + 'department_id' => $department->id, + 'member_id' => $member->id, + 'user_ref' => $doctor->public_id, + 'name' => 'Dental Doctor', + 'specialty' => 'Dentistry', + 'is_active' => true, + ]); + + $this->actingAs($doctor) + ->get(route('care.specialty.show', 'dentistry')) + ->assertOk() + ->assertSee('Dentistry'); + + $this->assertTrue( + app(SpecialtyModuleService::class)->memberCanAccess( + $this->organization->fresh(), + $member, + 'dentistry', + ), + ); + $this->assertFalse( + app(SpecialtyModuleService::class)->memberCanAccess( + $this->organization->fresh(), + $member, + 'maternity', + ), + ); + } }