diff --git a/app/Http/Controllers/Care/Concerns/ScopesToAccount.php b/app/Http/Controllers/Care/Concerns/ScopesToAccount.php index 15a2d49..65efb15 100644 --- a/app/Http/Controllers/Care/Concerns/ScopesToAccount.php +++ b/app/Http/Controllers/Care/Concerns/ScopesToAccount.php @@ -58,12 +58,20 @@ trait ScopesToAccount protected function scopeToBranch(Request $request, Builder $query, string $column = 'branch_id'): Builder { - $branchId = app(OrganizationResolver::class)->branchScope($this->member($request)); + $allowed = app(OrganizationResolver::class)->allowedBranchIds($this->member($request)); - if ($branchId !== null) { - $query->where($column, $branchId); + if ($allowed !== null) { + $query->whereIn($column, $allowed !== [] ? $allowed : [0]); } return $query; } + + protected function authorizeBranch(Request $request, ?int $branchId): void + { + abort_unless( + app(OrganizationResolver::class)->mayAccessBranch($this->member($request), $branchId), + 404, + ); + } } diff --git a/app/Http/Controllers/Care/MemberController.php b/app/Http/Controllers/Care/MemberController.php index 7503fd9..556d896 100644 --- a/app/Http/Controllers/Care/MemberController.php +++ b/app/Http/Controllers/Care/MemberController.php @@ -12,6 +12,7 @@ use App\Services\Identity\IdentityTeamClient; use Illuminate\Http\RedirectResponse; use Illuminate\Http\Request; use Illuminate\Support\Str; +use Illuminate\Validation\Rule; use Illuminate\View\View; class MemberController extends Controller @@ -83,12 +84,22 @@ class MemberController extends Controller $validated = $request->validate([ 'email' => ['required', 'email', 'max:255'], 'role' => ['required', 'string', 'in:'.implode(',', array_keys(config('care.roles')))], - 'branch_id' => ['nullable', 'integer', 'exists:care_branches,id'], + 'branch_id' => [ + Rule::requiredIf(fn () => $request->input('role') === 'doctor'), + 'nullable', + 'integer', + 'exists:care_branches,id', + ], 'create_practitioner' => ['sometimes', 'boolean'], 'practitioner_name' => ['nullable', 'string', 'max:255'], 'specialty' => ['nullable', 'string', 'max:255'], ]); + if (! empty($validated['branch_id'])) { + $branch = Branch::owned($owner)->findOrFail($validated['branch_id']); + abort_unless($branch->organization_id === $organization->id, 404); + } + $email = strtolower(trim($validated['email'])); if ($email === strtolower((string) $request->user()->email)) { @@ -125,7 +136,7 @@ class MemberController extends Controller $createPractitioner = $request->boolean('create_practitioner', $validated['role'] === 'doctor'); if ($createPractitioner) { $name = trim((string) ($validated['practitioner_name'] ?? '')) ?: Str::headline(Str::before($email, '@')); - Practitioner::query()->firstOrCreate( + $practitioner = Practitioner::query()->firstOrCreate( [ 'organization_id' => $organization->id, 'member_id' => $member->id, @@ -139,6 +150,10 @@ class MemberController extends Controller 'is_active' => true, ], ); + + if (! empty($validated['branch_id'])) { + $practitioner->syncAssignedBranches([(int) $validated['branch_id']]); + } } return redirect()->route('care.members.index')->with('success', 'Invitation sent to '.$email.'.'); diff --git a/app/Http/Controllers/Care/PractitionerController.php b/app/Http/Controllers/Care/PractitionerController.php index cbd62e4..5be665d 100644 --- a/app/Http/Controllers/Care/PractitionerController.php +++ b/app/Http/Controllers/Care/PractitionerController.php @@ -11,6 +11,7 @@ use App\Models\Practitioner; use App\Services\Care\AuditLogger; use Illuminate\Http\RedirectResponse; use Illuminate\Http\Request; +use Illuminate\Validation\Rule; use Illuminate\View\View; class PractitionerController extends Controller @@ -25,7 +26,7 @@ class PractitionerController extends Controller $practitioners = Practitioner::owned($owner) ->where('organization_id', $organization->id) - ->with(['branch', 'department', 'member']) + ->with(['branch', 'branches', 'department', 'member']) ->orderBy('name') ->get(); @@ -66,10 +67,12 @@ class PractitionerController extends Controller ? Member::owned($owner)->where('organization_id', $organization->id)->findOrFail($validated['member_id']) : null; + $branchIds = array_map('intval', $validated['branch_ids']); + $practitioner = Practitioner::create([ 'owner_ref' => $owner, 'organization_id' => $organization->id, - 'branch_id' => $validated['branch_id'] ?? null, + 'branch_id' => $branchIds[0], 'department_id' => $validated['department_id'] ?? null, 'member_id' => $member?->id, 'user_ref' => $member?->user_ref, @@ -77,6 +80,7 @@ class PractitionerController extends Controller 'specialty' => $validated['specialty'] ?? null, 'is_active' => true, ]); + $practitioner->syncAssignedBranches($branchIds); AuditLogger::record($owner, 'practitioner.created', $organization->id, $owner, Practitioner::class, $practitioner->id); @@ -90,6 +94,8 @@ class PractitionerController extends Controller $organization = $this->organization($request); $owner = $this->ownerRef($request); + $practitioner->load('branches'); + return view('care.admin.practitioners.edit', [ 'practitioner' => $practitioner, 'branches' => $this->activeBranches($owner, $organization->id), @@ -111,8 +117,10 @@ class PractitionerController extends Controller ? Member::owned($owner)->where('organization_id', $organization->id)->findOrFail($validated['member_id']) : null; + $branchIds = array_map('intval', $validated['branch_ids']); + $practitioner->update([ - 'branch_id' => $validated['branch_id'] ?? null, + 'branch_id' => $branchIds[0], 'department_id' => $validated['department_id'] ?? null, 'member_id' => $member?->id, 'user_ref' => $member?->user_ref, @@ -120,6 +128,7 @@ class PractitionerController extends Controller 'specialty' => $validated['specialty'] ?? null, 'is_active' => $request->boolean('is_active', true), ]); + $practitioner->syncAssignedBranches($branchIds); AuditLogger::record($owner, 'practitioner.updated', $organization->id, $owner, Practitioner::class, $practitioner->id); @@ -141,23 +150,22 @@ class PractitionerController extends Controller } /** - * @return array{name: string, specialty?: string|null, branch_id?: int|null, department_id?: int|null, member_id?: int|null} + * @return array{name: string, specialty?: string|null, branch_ids: list, department_id?: int|null, member_id?: int|null} */ protected function validated(Request $request, string $owner, int $organizationId): array { $validated = $request->validate([ 'name' => ['required', 'string', 'max:255'], 'specialty' => ['nullable', 'string', 'max:255'], - 'branch_id' => ['nullable', 'integer', 'exists:care_branches,id'], + 'branch_ids' => ['required', 'array', 'min:1'], + 'branch_ids.*' => [ + 'integer', + Rule::exists('care_branches', 'id')->where(fn ($q) => $q->where('organization_id', $organizationId)->where('owner_ref', $owner)), + ], 'department_id' => ['nullable', 'integer', 'exists:care_departments,id'], 'member_id' => ['nullable', 'integer', 'exists:care_members,id'], ]); - if (! empty($validated['branch_id'])) { - $branch = Branch::owned($owner)->findOrFail($validated['branch_id']); - abort_unless($branch->organization_id === $organizationId, 404); - } - if (! empty($validated['department_id'])) { $department = Department::owned($owner)->with('branch')->findOrFail($validated['department_id']); abort_unless($department->branch?->organization_id === $organizationId, 404); diff --git a/app/Http/Controllers/Care/QueueController.php b/app/Http/Controllers/Care/QueueController.php index 367ab1a..cfaa498 100644 --- a/app/Http/Controllers/Care/QueueController.php +++ b/app/Http/Controllers/Care/QueueController.php @@ -240,10 +240,6 @@ class QueueController extends Controller { $this->authorizeOwner($request, $appointment); abort_unless($appointment->organization_id === $this->organization($request)->id, 404); - - $branchId = app(OrganizationResolver::class)->branchScope($this->member($request)); - if ($branchId !== null && $appointment->branch_id !== $branchId) { - abort(404); - } + $this->authorizeBranch($request, (int) $appointment->branch_id); } } diff --git a/app/Models/Practitioner.php b/app/Models/Practitioner.php index aeccc2b..77ba725 100644 --- a/app/Models/Practitioner.php +++ b/app/Models/Practitioner.php @@ -5,6 +5,7 @@ namespace App\Models; use App\Models\Concerns\BelongsToOwner; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\BelongsTo; +use Illuminate\Database\Eloquent\Relations\BelongsToMany; use Illuminate\Database\Eloquent\Relations\HasMany; use Illuminate\Database\Eloquent\SoftDeletes; @@ -34,6 +35,15 @@ class Practitioner extends Model return $this->belongsTo(Branch::class, 'branch_id'); } + /** + * Explicitly assigned branches (telemedicine may include several). Empty = unassigned. + */ + public function branches(): BelongsToMany + { + return $this->belongsToMany(Branch::class, 'care_practitioner_branch', 'practitioner_id', 'branch_id') + ->withTimestamps(); + } + public function department(): BelongsTo { return $this->belongsTo(Department::class, 'department_id'); @@ -53,4 +63,32 @@ class Practitioner extends Model { return $this->hasMany(Appointment::class, 'practitioner_id'); } + + /** + * @return list + */ + public function assignedBranchIds(): array + { + $ids = $this->relationLoaded('branches') + ? $this->branches->pluck('id')->all() + : $this->branches()->pluck('care_branches.id')->all(); + + if ($ids === [] && $this->branch_id) { + return [(int) $this->branch_id]; + } + + return array_values(array_unique(array_map('intval', $ids))); + } + + /** + * @param list $branchIds + */ + public function syncAssignedBranches(array $branchIds): void + { + $ids = array_values(array_unique(array_filter(array_map('intval', $branchIds)))); + $this->branches()->sync($ids); + $this->forceFill([ + 'branch_id' => $ids[0] ?? null, + ])->save(); + } } diff --git a/app/Services/Care/BranchContext.php b/app/Services/Care/BranchContext.php index e526ee9..5ff65a8 100644 --- a/app/Services/Care/BranchContext.php +++ b/app/Services/Care/BranchContext.php @@ -11,9 +11,9 @@ use Illuminate\Support\Collection; /** * Resolves which Care branch the current user is working in. * - * Staff with a member.branch_id stay locked to that site. Hospital admins (and - * similar all-branch roles) pick a branch via the queue filters; that choice is - * remembered in session so Patient / Pharmacy / Lab / Billing queues stay aligned. + * Doctors may be assigned to multiple sites (telemedicine) but only those + * explicitly linked on their practitioner desks — never org-wide by default. + * Hospital admins pick among all branches; that choice is remembered in session. */ class BranchContext { @@ -29,9 +29,12 @@ class BranchContext return false; } - // Site clinicians are assigned to one branch — never a branch picker. + if ($member->role === 'doctor') { + return count($this->organizations->doctorAssignedBranchIds($member)) > 1; + } + + // Other site clinicians stay on their assigned branch — never a picker. if (in_array($member->role, [ - 'doctor', 'nurse', 'pharmacist', 'lab_technician', @@ -41,30 +44,29 @@ class BranchContext return false; } - return $this->organizations->branchScope($member) === null; + return $this->organizations->allowedBranchIds($member) === null; } /** - * Active branches the member may see (one site for scoped staff, all for admins). + * Active branches the member may see. * * @return Collection */ public function branches(Request $request, Organization $organization, ?Member $member): Collection { - $scope = $this->organizations->branchScope($member); + $ids = $this->organizations->allowedBranchIds($member); return Branch::owned((string) $organization->owner_ref) ->where('organization_id', $organization->id) ->where('is_active', true) - // Scope 0 = no site assigned — must not fall through to every branch. - ->when($scope !== null, fn ($q) => $q->where('id', $scope)) + ->when($ids !== null, fn ($q) => $q->whereIn('id', $ids !== [] ? $ids : [0])) ->orderBy('name') ->get(); } /** - * Preferred working branch id (0 when the org has no active branches). - * Persists admin selections from ?branch_id= into the session. + * Preferred working branch id (0 when none allowed). + * Persists switcher selections from ?branch_id= into the session. */ public function resolve(Request $request, Organization $organization, ?Member $member): int { @@ -74,28 +76,27 @@ class BranchContext return 0; } - $scope = $this->organizations->branchScope($member); - if ($scope !== null) { - return (int) $scope; - } + if ($this->canSwitch($member)) { + if ($request->filled('branch_id')) { + $requested = (int) $request->input('branch_id'); + if (in_array($requested, $allowed, true)) { + $request->session()->put(self::SESSION_KEY, $requested); - if ($request->filled('branch_id')) { - $requested = (int) $request->input('branch_id'); - if (in_array($requested, $allowed, true)) { - $request->session()->put(self::SESSION_KEY, $requested); - - return $requested; + return $requested; + } } + + $preferred = (int) $request->session()->get(self::SESSION_KEY, 0); + if ($preferred > 0 && in_array($preferred, $allowed, true)) { + return $preferred; + } + + $fallback = (int) $branches->first()->id; + $request->session()->put(self::SESSION_KEY, $fallback); + + return $fallback; } - $preferred = (int) $request->session()->get(self::SESSION_KEY, 0); - if ($preferred > 0 && in_array($preferred, $allowed, true)) { - return $preferred; - } - - $fallback = (int) $branches->first()->id; - $request->session()->put(self::SESSION_KEY, $fallback); - - return $fallback; + return (int) $allowed[0]; } } diff --git a/app/Services/Care/DemoTenantSeeder.php b/app/Services/Care/DemoTenantSeeder.php index 84ad671..9cb99b5 100644 --- a/app/Services/Care/DemoTenantSeeder.php +++ b/app/Services/Care/DemoTenantSeeder.php @@ -259,6 +259,10 @@ class DemoTenantSeeder 'member_id' => $member?->id, 'name' => $doctorName !== '' ? $doctorName : $practitioner->name, ])->save(); + + if ($homeBranchId > 0) { + $practitioner->syncAssignedBranches([$homeBranchId]); + } } /** @@ -845,6 +849,7 @@ class DemoTenantSeeder 'deleted_at' => null, ], ); + $practitioner->syncAssignedBranches([(int) $branch->id]); foreach ([Appointment::STATUS_WAITING, Appointment::STATUS_CHECKED_IN, Appointment::STATUS_COMPLETED] as $slot => $status) { $patient = $patients[($branchIndex + $slot + $count) % count($patients)]; @@ -950,6 +955,7 @@ class DemoTenantSeeder 'deleted_at' => null, ], ); + $practitioners[array_key_last($practitioners)]->syncAssignedBranches([(int) $branch->id]); } return $practitioners; diff --git a/app/Services/Care/OrganizationResolver.php b/app/Services/Care/OrganizationResolver.php index 49ebd4b..4cf8bc3 100644 --- a/app/Services/Care/OrganizationResolver.php +++ b/app/Services/Care/OrganizationResolver.php @@ -180,7 +180,73 @@ class OrganizationResolver }; } - /** Branch ID the member may access; null = all branches (admins only). */ + /** + * Branch IDs the member may access. + * null = all branches (admins). Empty array = none assigned. + * + * @return list|null + */ + public function allowedBranchIds(?Member $member): ?array + { + if ($member === null) { + return null; + } + + if (in_array($member->role, ['super_admin', 'hospital_admin', 'accountant'], true)) { + return null; + } + + if ($member->role === 'doctor') { + return $this->doctorAssignedBranchIds($member); + } + + if ($member->branch_id) { + return [(int) $member->branch_id]; + } + + return null; + } + + /** + * Explicit branch IDs for a doctor's linked practitioner desks (pivot + legacy branch_id). + * + * @return list + */ + public function doctorAssignedBranchIds(Member $member): array + { + $practitioners = Practitioner::query() + ->where('is_active', true) + ->where(function ($query) use ($member) { + $query->where('member_id', $member->id) + ->orWhere('user_ref', $member->user_ref); + }) + ->with('branches') + ->orderBy('id') + ->get(); + + $ids = []; + foreach ($practitioners as $practitioner) { + $ids = array_merge($ids, $practitioner->assignedBranchIds()); + } + + return array_values(array_unique(array_map('intval', $ids))); + } + + public function mayAccessBranch(?Member $member, ?int $branchId): bool + { + if ($branchId === null || $branchId <= 0) { + return false; + } + + $allowed = $this->allowedBranchIds($member); + if ($allowed === null) { + return true; + } + + return in_array((int) $branchId, $allowed, true); + } + + /** Current working branch ID; null = all branches (admins only). */ public function branchScope(?Member $member): ?int { if ($member === null) { @@ -191,30 +257,33 @@ class OrganizationResolver return null; } - if ($member->branch_id) { - return (int) $member->branch_id; + if ($member->role === 'doctor') { + $ids = $this->doctorAssignedBranchIds($member); + if ($ids === []) { + return 0; + } + if (count($ids) === 1) { + return $ids[0]; + } + + $preferred = (int) session(BranchContext::SESSION_KEY, 0); + if (in_array($preferred, $ids, true)) { + return $preferred; + } + + return $ids[0]; } - // 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; + if ($member->branch_id) { + return (int) $member->branch_id; } return $member->branch_id; } /** - * Practitioner IDs a doctor is locked to (single branch). Null = no practitioner lock. - * Empty array = doctor with no linked desk on their branch. + * Practitioner IDs a doctor is locked to. Null = no practitioner lock. + * Empty array = doctor with no linked desk. * * @return list|null */ @@ -224,12 +293,9 @@ 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); diff --git a/app/Services/Care/SpecialtyModuleService.php b/app/Services/Care/SpecialtyModuleService.php index 2f7e193..60375b1 100644 --- a/app/Services/Care/SpecialtyModuleService.php +++ b/app/Services/Care/SpecialtyModuleService.php @@ -144,13 +144,19 @@ class SpecialtyModuleService $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); }) - ->with('department') - ->get(); + ->with(['department', 'branches']) + ->get() + ->filter(function (Practitioner $practitioner) use ($branchId) { + if (! $branchId) { + return true; + } + + return in_array((int) $branchId, $practitioner->assignedBranchIds(), true); + }); foreach ($practitioners as $practitioner) { if ($provisionedIds !== [] && in_array((int) $practitioner->department_id, $provisionedIds, true)) { diff --git a/database/migrations/2026_07_18_090000_create_care_practitioner_branch_table.php b/database/migrations/2026_07_18_090000_create_care_practitioner_branch_table.php new file mode 100644 index 0000000..afb99c7 --- /dev/null +++ b/database/migrations/2026_07_18_090000_create_care_practitioner_branch_table.php @@ -0,0 +1,37 @@ +id(); + $table->foreignId('practitioner_id')->constrained('care_practitioners')->cascadeOnDelete(); + $table->foreignId('branch_id')->constrained('care_branches')->cascadeOnDelete(); + $table->timestamps(); + + $table->unique(['practitioner_id', 'branch_id']); + }); + + // Backfill explicit assignments from the legacy single branch_id (never invent "all branches"). + $now = now(); + foreach (DB::table('care_practitioners')->whereNotNull('branch_id')->get(['id', 'branch_id']) as $row) { + DB::table('care_practitioner_branch')->insertOrIgnore([ + 'practitioner_id' => $row->id, + 'branch_id' => $row->branch_id, + 'created_at' => $now, + 'updated_at' => $now, + ]); + } + } + + public function down(): void + { + Schema::dropIfExists('care_practitioner_branch'); + } +}; diff --git a/resources/views/care/admin/members/create.blade.php b/resources/views/care/admin/members/create.blade.php index 4c7872e..e6a2fbe 100644 --- a/resources/views/care/admin/members/create.blade.php +++ b/resources/views/care/admin/members/create.blade.php @@ -47,13 +47,19 @@
- - + @foreach ($branches as $branch) @endforeach +

+ Required for doctors. Add more sites later under Practitioners (telemedicine). +

diff --git a/resources/views/care/admin/practitioners/create.blade.php b/resources/views/care/admin/practitioners/create.blade.php index 11f42c6..636fe9e 100644 --- a/resources/views/care/admin/practitioners/create.blade.php +++ b/resources/views/care/admin/practitioners/create.blade.php @@ -14,15 +14,24 @@
-
- - -
+
+ Branches +

Select every site this clinician may serve (including telemedicine). Nothing is selected by default.

+
+ @forelse ($branches as $branch) + + @empty +

Create a branch first under Settings → Branches.

+ @endforelse +
+ @error('branch_ids')

{{ $message }}

@enderror + @error('branch_ids.*')

{{ $message }}

@enderror +
-
- - -
+
+ Branches +

Select every site this clinician may serve (including telemedicine). Nothing is selected by default.

+ @php + $selectedBranchIds = collect(old('branch_ids', $practitioner->assignedBranchIds())); + @endphp +
+ @forelse ($branches as $branch) + + @empty +

Create a branch first under Settings → Branches.

+ @endforelse +
+ @error('branch_ids')

{{ $message }}

@enderror + @error('branch_ids.*')

{{ $message }}

@enderror +
+ @foreach ($branches as $branch) + + @endforeach + + @elseif (isset($branches) && $branches->isNotEmpty()) + {{ $branches->first()->name }} + @endif + Showing patients assigned to you @elseif ($canSwitchBranch ?? false)