Deploy Ladill Care / deploy (push) Successful in 36s
Resolve staff memberships by invite/demo email as well as public_id so nurses stay on their employer org; block staff from owner onboarding; keep Documents viewable with module access while upload stays manage-gated. Co-authored-by: Cursor <cursoragent@cursor.com>
385 lines
12 KiB
PHP
385 lines
12 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
|
|
{
|
|
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
|
|
{
|
|
$member = Member::query()->where('user_ref', $user->ownerRef())->first();
|
|
if ($member) {
|
|
return $member;
|
|
}
|
|
|
|
$email = strtolower(trim((string) $user->email));
|
|
if ($email === '' || $email === $user->ownerRef()) {
|
|
return null;
|
|
}
|
|
|
|
$member = Member::query()->where('user_ref', $email)->first();
|
|
|
|
return $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 ($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<int>
|
|
*/
|
|
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) {
|
|
return null;
|
|
}
|
|
|
|
if (in_array($member->role, ['super_admin', 'hospital_admin', 'accountant'], true)) {
|
|
return null;
|
|
}
|
|
|
|
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];
|
|
}
|
|
|
|
if ($member->branch_id) {
|
|
return (int) $member->branch_id;
|
|
}
|
|
|
|
return $member->branch_id;
|
|
}
|
|
|
|
/**
|
|
* Practitioner IDs a doctor is locked to. Null = no practitioner lock.
|
|
* Empty array = doctor with no linked desk.
|
|
*
|
|
* @return list<int>|null
|
|
*/
|
|
public function practitionerScope(Organization $organization, ?Member $member): ?array
|
|
{
|
|
if ($member === null || $member->role !== 'doctor') {
|
|
return null;
|
|
}
|
|
|
|
return 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();
|
|
}
|
|
}
|