> */ protected array $doctorAssignedBranchIdsCache = []; /** @var array */ protected array $membershipCache = []; public function resolveForUser(User $user): ?Organization { $member = $this->membershipFor($user); if ($member) { $organization = Organization::query()->find($member->organization_id); if ($organization) { return $organization; } } return Organization::owned($user->ownerRef())->first(); } public function forUser(User $user): Organization { return $this->resolveForUser($user) ?? throw new \RuntimeException('No organization for user.'); } public function isOnboarded(User $user): bool { $organization = $this->resolveForUser($user); return $organization !== null && (bool) data_get($organization->settings, 'onboarded', false); } /** * Any Care membership for this user (public_id or invite/demo email key). * Remaps email-keyed rows to public_id when found so staff stay linked after SSO. */ public function membershipFor(User $user): ?Member { $cacheKey = $user->ownerRef(); if (array_key_exists($cacheKey, $this->membershipCache)) { return $this->membershipCache[$cacheKey]; } $member = Member::query()->where('user_ref', $user->ownerRef())->first(); if ($member) { return $this->membershipCache[$cacheKey] = $member; } $email = strtolower(trim((string) $user->email)); if ($email === '' || $email === $user->ownerRef()) { return $this->membershipCache[$cacheKey] = null; } $member = Member::query()->where('user_ref', $email)->first(); return $this->membershipCache[$cacheKey] = ($member ? $this->remapMemberUserRef($member, $user) : null); } /** * True when the user may run owner onboarding (create a facility). * Staff already attached to a tenant must not create a second org. */ public function canCompleteOnboarding(User $user): bool { if ($this->membershipFor($user)) { return false; } return ! $this->isOnboarded($user); } public function memberFor(User $user, ?Organization $organization = null): ?Member { $organization ??= $this->resolveForUser($user); if (! $organization) { return null; } $member = Member::query() ->where('organization_id', $organization->id) ->where('user_ref', $user->ownerRef()) ->first(); if ($member) { return $member; } $email = strtolower(trim((string) $user->email)); if ($email === '') { return null; } $member = Member::query() ->where('organization_id', $organization->id) ->where('user_ref', $email) ->first(); return $member ? $this->remapMemberUserRef($member, $user) : null; } /** * Promote invite/demo email user_ref keys to the stable OIDC public_id. */ protected function remapMemberUserRef(Member $member, User $user): Member { $ref = $user->ownerRef(); if ($member->user_ref === $ref) { return $member; } $conflict = Member::query() ->where('organization_id', $member->organization_id) ->where('user_ref', $ref) ->where('id', '!=', $member->id) ->first(); if ($conflict) { $member->delete(); return $conflict; } $member->forceFill(['user_ref' => $ref])->save(); return $member->fresh() ?? $member; } public function ensureOwnerMember(User $user, Organization $organization): Member { return Member::firstOrCreate( [ 'organization_id' => $organization->id, 'user_ref' => $user->ownerRef(), ], [ 'owner_ref' => $user->ownerRef(), 'role' => 'hospital_admin', ], ); } /** * @param array $data */ public function completeOnboarding(User $user, array $data): Organization { $ref = $user->ownerRef(); $registry = app(WorkflowTemplateRegistry::class); // New onboarding leads with Facility Category (modules) + Workflow // Template (patient flow); legacy callers may still pass facility_type. $category = $data['facility_category'] ?? $this->categoryFromLegacyType($data['facility_type'] ?? null); $templateKey = $data['workflow_template'] ?? $registry->defaultTemplateForCategory($category); $modules = $registry->modulesForCategory($category); $settings = [ 'onboarded' => true, 'facility_category' => $category, 'facility_type' => $data['facility_type'] ?? $category, 'modules' => $modules, 'workflow_template' => $templateKey, 'rollout' => [ CareFeatures::WORKFLOW_ENGINE => $templateKey !== 'legacy_clinic', // Financial gates require the paid billing module and are // explicitly enabled by an administrator in Settings. CareFeatures::FINANCIAL_GATES => false, ], ]; // Reuse an existing owner org (e.g. demo shell that lost the race) so // onboarding never creates a second tenant for the same owner_ref. $organization = Organization::withTrashed()->where('owner_ref', $ref)->first(); if ($organization) { if ($organization->trashed()) { $organization->restore(); } $organization->forceFill([ 'name' => $data['organization_name'], 'slug' => Str::slug($data['organization_name']).'-'.substr($ref, 0, 6), 'timezone' => $data['timezone'] ?? config('app.timezone', 'UTC'), 'settings' => array_merge($organization->settings ?? [], $settings), ])->save(); $organization = $organization->fresh(); } else { $organization = Organization::create([ 'owner_ref' => $ref, 'name' => $data['organization_name'], 'slug' => Str::slug($data['organization_name']).'-'.substr($ref, 0, 6), 'timezone' => $data['timezone'] ?? config('app.timezone', 'UTC'), 'settings' => $settings, ]); } $this->ensureOwnerMember($user, $organization); $branch = Branch::query() ->where('organization_id', $organization->id) ->orderBy('id') ->first(); if (! $branch) { $branch = Branch::create([ 'owner_ref' => $ref, 'organization_id' => $organization->id, 'name' => $data['branch_name'], 'address' => $data['branch_address'] ?? null, 'phone' => $data['branch_phone'] ?? null, 'is_active' => true, ]); Department::create([ 'owner_ref' => $ref, 'branch_id' => $branch->id, 'name' => 'General Outpatient', 'type' => 'outpatient', 'is_active' => true, ]); } else { $branch->forceFill([ 'name' => $data['branch_name'], 'address' => $data['branch_address'] ?? $branch->address, 'phone' => $data['branch_phone'] ?? $branch->phone, 'is_active' => true, ])->save(); } if ($registry->hasTemplate($templateKey) && ! FacilityWorkflow::query()->where('organization_id', $organization->id)->exists()) { app(WorkflowTemplateInstaller::class)->install($organization, $branch, $templateKey, [ 'category' => $category, ]); } AuditLogger::record($ref, 'organization.created', $organization->id, $ref, Organization::class, $organization->id); AuditLogger::record($ref, 'branch.created', $organization->id, $ref, Branch::class, $branch->id); return $organization; } protected function categoryFromLegacyType(?string $type): string { return match ($type) { 'hospital' => 'hospital', 'diagnostic' => 'diagnostic', 'specialist' => 'specialist', default => 'clinic', }; } /** * 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 (app(CarePermissions::class)->usesPractitionerBranchScope($member)) { 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). * GPs with no practitioner desk fall back to their member branch so they can still * open the shared consultation Queue board. * * @return list */ public function doctorAssignedBranchIds(Member $member): array { $cacheKey = (string) $member->id; if (isset($this->doctorAssignedBranchIdsCache[$cacheKey])) { return $this->doctorAssignedBranchIdsCache[$cacheKey]; } $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()); } $ids = array_values(array_unique(array_map('intval', $ids))); if ($ids === [] && $member->branch_id) { $ids = [(int) $member->branch_id]; } return $this->doctorAssignedBranchIdsCache[$cacheKey] = $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) { return null; } if (in_array($member->role, ['super_admin', 'hospital_admin', 'accountant'], true)) { return null; } if (app(CarePermissions::class)->usesPractitionerBranchScope($member)) { $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]; } if ($member->branch_id) { return (int) $member->branch_id; } return $member->branch_id; } /** * Practitioner IDs a doctor is locked to. Null = no practitioner lock * (non-doctors, or GPs with no linked desk — they work the shared consultation board). * Non-empty = desk-locked specialist / assigned GP. * * @return list|null */ public function practitionerScope(Organization $organization, ?Member $member): ?array { if ($member === null || $member->role !== 'doctor') { return null; } $ids = 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); }) ->orderBy('id') ->pluck('id') ->map(fn ($id) => (int) $id) ->values() ->all(); return $ids === [] ? null : $ids; } }