Files
ladill-care/app/Services/Care/OrganizationResolver.php
T
isaaccladandCursor ac870bcf33
Deploy Ladill Care / deploy (push) Successful in 57s
Implement Care RBAC role→permission→app matrix.
Replace broad doctor/nurse specialty access with granular roles and primary
apps, permission inheritance for lab/BB managers, and cannot-rules for
discharge, lab approve, and cashier vs billing officer.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-20 00:24:09 +00:00

412 lines
13 KiB
PHP

<?php
namespace App\Services\Care;
use App\Models\Branch;
use App\Models\Department;
use App\Models\FacilityWorkflow;
use App\Models\Member;
use App\Models\Organization;
use App\Models\Practitioner;
use App\Models\User;
use App\Services\Care\Workflow\WorkflowTemplateInstaller;
use App\Services\Care\Workflow\WorkflowTemplateRegistry;
use Illuminate\Support\Str;
class OrganizationResolver
{
/** @var array<string, list<int>> */
protected array $doctorAssignedBranchIdsCache = [];
/** @var array<string, ?Member> */
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<string, mixed> $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<int>|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<int>
*/
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<int>|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;
}
}