Lock doctors to a single branch — never a branch picker.
Deploy Ladill Care / deploy (push) Successful in 1m3s

Doctors were treated as multi-branch when Identity cleared branch_id; now they stay on their assigned/desk site, specialty desks are home-branch only, and the queue shows site name as text only.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
isaacclad
2026-07-18 08:45:57 +00:00
co-authored by Cursor
parent bb4dbee81a
commit d643687335
10 changed files with 120 additions and 23 deletions
@@ -73,7 +73,11 @@ class QueueController extends Controller
}
if ($lockToPractitioner) {
$queue = $this->appointments->queueForPractitioners($owner, $practitionerScope ?? []);
$queue = $this->appointments->queueForPractitioners(
$owner,
$practitionerScope ?? [],
$branchId > 0 ? $branchId : null,
);
$inConsultation = Appointment::owned($owner)
->where('organization_id', $organization->id)
->where('status', Appointment::STATUS_IN_CONSULTATION)
+18 -1
View File
@@ -25,6 +25,22 @@ class BranchContext
public function canSwitch(?Member $member): bool
{
if ($member === null) {
return false;
}
// Site clinicians are assigned to one branch — never a branch picker.
if (in_array($member->role, [
'doctor',
'nurse',
'pharmacist',
'lab_technician',
'cashier',
'receptionist',
], true)) {
return false;
}
return $this->organizations->branchScope($member) === null;
}
@@ -40,7 +56,8 @@ class BranchContext
return Branch::owned((string) $organization->owner_ref)
->where('organization_id', $organization->id)
->where('is_active', true)
->when($scope, fn ($q) => $q->where('id', $scope))
// Scope 0 = no site assigned — must not fall through to every branch.
->when($scope !== null, fn ($q) => $q->where('id', $scope))
->orderBy('name')
->get();
}
+5 -2
View File
@@ -822,6 +822,9 @@ class DemoTenantSeeder
$member = $moduleMembers[$key];
$staff = $member ? \App\Support\DemoWorld::staffByEmail($staffEmail) : null;
$homeBranchId = (int) ($member?->branch_id ?? 0);
// One doctor = one branch. Only link the specialty desk on their home site.
$assignMember = $member && $homeBranchId > 0 && (int) $branch->id === $homeBranchId;
$stableRef = sprintf('demo-specialty-%s-%s', $key, $branch->id);
$practitioner = Practitioner::withTrashed()->updateOrCreate(
[
@@ -832,8 +835,8 @@ class DemoTenantSeeder
'owner_ref' => $ownerRef,
'branch_id' => $branch->id,
'department_id' => $department->id,
'member_id' => $member?->id,
'name' => is_array($staff)
'member_id' => $assignMember ? $member->id : null,
'name' => $assignMember && is_array($staff)
? (string) $staff['name']
: (($definition['label'] ?? ucfirst($key)).' Clinic'),
'specialty' => (string) ($definition['label'] ?? $key),
+24 -3
View File
@@ -180,7 +180,7 @@ class OrganizationResolver
};
}
/** Branch ID the member may access; null = all branches. */
/** Branch ID the member may access; null = all branches (admins only). */
public function branchScope(?Member $member): ?int
{
if ($member === null) {
@@ -191,12 +191,30 @@ class OrganizationResolver
return null;
}
if ($member->branch_id) {
return (int) $member->branch_id;
}
// Doctors are always one site. Prefer linked desk when Identity omitted branch_id.
if ($member->role === 'doctor') {
$deskBranch = Practitioner::query()
->where('is_active', true)
->where(function ($query) use ($member) {
$query->where('member_id', $member->id)
->orWhere('user_ref', $member->user_ref);
})
->orderBy('id')
->value('branch_id');
return $deskBranch ? (int) $deskBranch : 0;
}
return $member->branch_id;
}
/**
* Practitioner IDs a doctor is locked to. Null = no practitioner lock (non-doctors).
* Empty array = doctor with no linked practitioner desk (see no clinical queue).
* Practitioner IDs a doctor is locked to (single branch). Null = no practitioner lock.
* Empty array = doctor with no linked desk on their branch.
*
* @return list<int>|null
*/
@@ -206,9 +224,12 @@ class OrganizationResolver
return null;
}
$branchId = $this->branchScope($member);
return Practitioner::owned((string) $organization->owner_ref)
->where('organization_id', $organization->id)
->where('is_active', true)
->when($branchId, fn ($q) => $q->where('branch_id', $branchId))
->where(function ($query) use ($member) {
$query->where('member_id', $member->id)
->orWhere('user_ref', $member->user_ref);
@@ -139,9 +139,12 @@ class SpecialtyModuleService
? array_map('intval', $provisionedIds)
: [];
$branchId = app(OrganizationResolver::class)->branchScope($member);
$practitioners = Practitioner::owned((string) $organization->owner_ref)
->where('organization_id', $organization->id)
->where('is_active', true)
->when($branchId, fn ($q) => $q->where('branch_id', $branchId))
->where(function ($query) use ($member) {
$query->where('member_id', $member->id)
->orWhere('user_ref', $member->user_ref);
+6 -1
View File
@@ -53,7 +53,12 @@ class TeamMemberProvisioner
[
'owner_ref' => $ownerRef !== '' ? $ownerRef : $user->ownerRef(),
'role' => (string) ($appMeta['role'] ?? 'member'),
'branch_id' => $appMeta['branch_id'] ?? null,
'branch_id' => array_key_exists('branch_id', $appMeta)
? $appMeta['branch_id']
: Member::query()
->where('organization_id', $organizationId)
->where('user_ref', $user->ownerRef())
->value('branch_id'),
],
);
}
+10 -5
View File
@@ -13,11 +13,16 @@
</div>
<form method="GET" class="mt-4 flex flex-wrap gap-3 rounded-2xl border border-slate-200 bg-white p-4">
<select name="branch_id" class="rounded-lg border-slate-300 text-sm" onchange="this.form.submit()">
@foreach ($branches as $branch)
<option value="{{ $branch->id }}" @selected($branchId == $branch->id)>{{ $branch->name }}</option>
@endforeach
</select>
@if (($canSwitchBranch ?? false) && isset($branches) && $branches->count() > 1)
<select name="branch_id" class="rounded-lg border-slate-300 text-sm" onchange="this.form.submit()">
@foreach ($branches as $branch)
<option value="{{ $branch->id }}" @selected($branchId == $branch->id)>{{ $branch->name }}</option>
@endforeach
</select>
@elseif (isset($branches) && $branches->isNotEmpty())
<input type="hidden" name="branch_id" value="{{ $branchId }}">
<span class="flex items-center text-sm font-medium text-slate-700">{{ $branches->first()->name }}</span>
@endif
<select name="status" class="rounded-lg border-slate-300 text-sm">
<option value="">All active</option>
@foreach ($statuses as $value => $label)
+21 -9
View File
@@ -17,18 +17,20 @@
</x-care.page-hero>
<form method="GET" class="flex flex-wrap gap-3 rounded-2xl border border-slate-200 bg-white p-4">
@if ($canSwitchBranch ?? false)
@if ($lockToPractitioner ?? false)
<p class="text-sm text-slate-500">
@if (isset($branches) && $branches->isNotEmpty())
<span class="font-medium text-slate-700">{{ $branches->first()->name }}</span>
<span class="text-slate-300"> · </span>
@endif
Showing patients assigned to you
</p>
@elseif ($canSwitchBranch ?? false)
<select name="branch_id" class="rounded-lg border-slate-300 text-sm" onchange="this.form.submit()">
@foreach ($branches as $branch)
<option value="{{ $branch->id }}" @selected($branchId == $branch->id)>{{ $branch->name }}</option>
@endforeach
</select>
@elseif (($lockToPractitioner ?? false) === false && isset($branches) && $branches->isNotEmpty())
<input type="hidden" name="branch_id" value="{{ $branchId }}">
<span class="flex items-center text-sm text-slate-500">{{ $branches->first()->name }}</span>
@endif
@unless ($lockToPractitioner ?? false)
<select name="practitioner_id" class="rounded-lg border-slate-300 text-sm">
<option value="">All practitioners</option>
@foreach ($practitioners as $practitioner)
@@ -37,8 +39,18 @@
</select>
<button type="submit" class="btn-primary">Filter</button>
@else
<span class="flex items-center text-sm text-slate-500">Showing patients assigned to you</span>
@endunless
@if (isset($branches) && $branches->isNotEmpty())
<input type="hidden" name="branch_id" value="{{ $branchId }}">
<span class="flex items-center text-sm font-medium text-slate-700">{{ $branches->first()->name }}</span>
@endif
<select name="practitioner_id" class="rounded-lg border-slate-300 text-sm">
<option value="">All practitioners</option>
@foreach ($practitioners as $practitioner)
<option value="{{ $practitioner->id }}" @selected($practitionerId == $practitioner->id)>{{ $practitioner->name }}</option>
@endforeach
</select>
<button type="submit" class="btn-primary">Filter</button>
@endif
</form>
@include('care.partials.queue-ops', [
+9 -1
View File
@@ -142,7 +142,15 @@ class CareAppointmentTest extends TestCase
$this->assertNotNull($appointment->visit_id);
$this->assertDatabaseHas('care_visits', ['patient_id' => $this->patient->id]);
Member::where('user_ref', $this->user->public_id)->update(['role' => 'doctor']);
$member = Member::where('user_ref', $this->user->public_id)->firstOrFail();
$member->update([
'role' => 'doctor',
'branch_id' => $this->branch->id,
]);
$this->practitioner->update([
'member_id' => $member->id,
'user_ref' => $this->user->public_id,
]);
$this->actingAs($this->user)
->post(route('care.queue.start', $appointment))
@@ -142,13 +142,32 @@ class CareStaffTenantScopeTest extends TestCase
->get(route('care.queue.index'))
->assertOk()
->assertSee('Showing patients assigned to you')
->assertSee('East Legon Care')
->assertDontSee('All practitioners')
->assertDontSee('name="branch_id"', false)
->assertDontSee('<select', false)
->assertSee('Ama Mensah')
->assertSee('Toothache')
->assertDontSee('Kofi Boateng')
->assertDontSee('Fever');
// Even if Identity clears member.branch_id, doctors stay on their desk's branch — no picker.
$doctorMember->update(['branch_id' => null]);
Branch::create([
'owner_ref' => $owner->public_id,
'organization_id' => $organization->id,
'name' => 'West Hills Care',
'is_active' => true,
]);
$this->actingAs($doctor)
->get(route('care.queue.index'))
->assertOk()
->assertSee('East Legon Care')
->assertDontSee('West Hills Care')
->assertDontSee('name="branch_id"', false)
->assertDontSee('<select', false);
$this->actingAs($doctor)
->get(route('care.patients.index'))
->assertOk()