> */ public function catalog(): array { return config('care.specialty_modules', []); } public function definition(string $key): ?array { $catalog = $this->catalog(); return $catalog[$key] ?? null; } /** * Heroicon-style icon identifier from the specialty catalog (e.g. bolt, heart). */ public function iconName(string $key): string { $name = (string) ($this->definition($key)['icon'] ?? 'squares-2x2'); return $name !== '' ? $name : 'squares-2x2'; } /** * Inline SVG path markup for sidebar / card icons (24×24 outline). */ public function iconSvgPath(string $key): string { $icons = config('care.specialty_icons', []); $name = $this->iconName($key); if (is_array($icons) && isset($icons[$name]) && is_string($icons[$name]) && $icons[$name] !== '') { return $icons[$name]; } return is_array($icons) && isset($icons['squares-2x2']) && is_string($icons['squares-2x2']) ? $icons['squares-2x2'] : ''; } public function isDefaultOnPaidPlans(string $key): bool { return (bool) ($this->definition($key)['default_on_paid_plans'] ?? false); } public function isEnabled(Organization $organization, string $key): bool { if (! $this->definition($key)) { return false; } if ($this->isDefaultOnPaidPlans($key) && $this->plans->hasPaidPlan($organization)) { return true; } return (bool) data_get($organization->settings, "specialty_modules.{$key}", false); } /** * @return list */ public function defaultKeysForPaidPlans(): array { $keys = []; foreach (array_keys($this->catalog()) as $key) { if ($this->isDefaultOnPaidPlans($key)) { $keys[] = $key; } } return $keys; } /** * Provision Emergency / Blood Bank (and any other default_on_paid_plans modules). */ public function ensureDefaultModulesProvisioned(Organization $organization, string $ownerRef): void { if (! $this->plans->hasPaidPlan($organization)) { return; } foreach ($this->defaultKeysForPaidPlans() as $key) { $provisioned = data_get( $organization->settings, "specialty_module_provisioning.{$key}.active", ); if ($provisioned) { continue; } try { $this->activate($organization->fresh(), $ownerRef, $key); $organization->refresh(); } catch (\Throwable $e) { Log::warning('specialty_module.default_provision_failed', [ 'key' => $key, 'message' => $e->getMessage(), ]); } } } /** * @return list */ public function enabledKeys(Organization $organization): array { $enabled = []; foreach (array_keys($this->catalog()) as $key) { if ($this->isEnabled($organization, $key)) { $enabled[] = $key; } } return $enabled; } /** * @return list}> */ public function enabledModules(Organization $organization): array { $out = []; foreach ($this->enabledKeys($organization) as $key) { $definition = $this->definition($key); if ($definition) { $out[] = ['key' => $key, 'definition' => $definition]; } } return $out; } /** * Enabled modules the member may open (manage, view, or refer). * * @return list, access_level: string}> */ public function enabledModulesForMember(Organization $organization, ?Member $member): array { $out = []; foreach ($this->enabledModules($organization) as $item) { $level = $this->memberAccessLevel($organization, $member, $item['key']); if ($level === 'none') { continue; } $out[] = array_merge($item, ['access_level' => $level]); } return $out; } /** * @return 'general'|'limited'|'restricted' */ public function accessTier(string $key): string { $tier = (string) ($this->definition($key)['access'] ?? 'general'); return in_array($tier, ['general', 'limited', 'restricted'], true) ? $tier : 'general'; } /** * Highest access for this member on the module. * * @return 'none'|'view'|'refer'|'manage' */ public function memberAccessLevel(Organization $organization, ?Member $member, string $key): string { if (! $this->isEnabled($organization, $key)) { return 'none'; } $definition = $this->definition($key); if (! $definition || ! $member) { return 'none'; } if (app(CarePermissions::class)->isAdmin($member)) { return 'manage'; } if ($this->memberCanManage($organization, $member, $key)) { return 'manage'; } $canRefer = $this->roleListed($member, $definition['refer_roles'] ?? []); $canView = $this->roleListed($member, $definition['view_roles'] ?? []); if ($canRefer && $canView) { return 'refer'; } if ($canRefer) { return 'refer'; } if ($canView) { return 'view'; } return 'none'; } /** * Whether the member may see this specialty module in nav / open workspace (any level). */ public function memberCanAccess(Organization $organization, ?Member $member, string $key): bool { return $this->memberAccessLevel($organization, $member, $key) !== 'none'; } /** * Full clinical / queue edit access for the module. */ public function memberCanManage(Organization $organization, ?Member $member, string $key): bool { if (! $this->isEnabled($organization, $key) || ! $member) { return false; } if (app(CarePermissions::class)->isAdmin($member)) { return true; } $definition = $this->definition($key); if (! $definition) { return false; } $tier = $this->accessTier($key); $role = (string) $member->role; $manageRoles = $definition['roles'] ?? []; $supportRoles = $definition['support_roles'] ?? []; if ($tier === 'restricted') { if ($this->roleListed($member, $supportRoles)) { return true; } if ($role === 'doctor' && $this->roleListed($member, $manageRoles)) { return $this->doctorAssignedToModule($organization, $member, $key) || $this->doctorSpecialtyMatchesModule($organization, $member, $key); } return false; } // general + limited: role allowlist is enough (GPs = doctor without specialty desk). return $this->roleListed($member, $manageRoles); } /** * Read results / history / workspace without clinical edit. */ public function memberCanView(Organization $organization, ?Member $member, string $key): bool { if ($this->memberCanManage($organization, $member, $key)) { return true; } if (! $this->isEnabled($organization, $key) || ! $member) { return false; } $definition = $this->definition($key); if (! $definition) { return false; } return $this->roleListed($member, $definition['view_roles'] ?? []); } /** * Refer a patient into the specialty queue without full clinical edit. */ public function memberCanRefer(Organization $organization, ?Member $member, string $key): bool { if ($this->memberCanManage($organization, $member, $key)) { return true; } if (! $this->isEnabled($organization, $key) || ! $member) { return false; } $definition = $this->definition($key); if (! $definition) { return false; } return $this->roleListed($member, $definition['refer_roles'] ?? []); } /** * @param list|mixed $roles */ protected function roleListed(Member $member, mixed $roles): bool { return is_array($roles) && $roles !== [] && in_array($member->role, $roles, true); } public function doctorAssignedToModule(Organization $organization, Member $member, string $key): bool { $definition = $this->definition($key); if (! $definition) { return false; } $departmentType = (string) ($definition['department_type'] ?? ''); $provisionedIds = data_get( $organization->settings, "specialty_module_provisioning.{$key}.department_ids", [], ); $provisionedIds = is_array($provisionedIds) ? array_map('intval', $provisionedIds) : []; foreach ($this->practitionersForMember($organization, $member) as $practitioner) { if ($provisionedIds !== [] && in_array((int) $practitioner->department_id, $provisionedIds, true)) { return true; } if ($departmentType !== '' && $practitioner->department?->type === $departmentType) { return true; } } return false; } /** * Match practitioner.specialty text to module specialist_keywords (restricted specialists). */ public function doctorSpecialtyMatchesModule(Organization $organization, Member $member, string $key): bool { $definition = $this->definition($key); if (! $definition) { return false; } $keywords = $definition['specialist_keywords'] ?? []; if (! is_array($keywords) || $keywords === []) { $keywords = $definition['queue_keywords'] ?? []; } if (! is_array($keywords) || $keywords === []) { return false; } foreach ($this->practitionersForMember($organization, $member) as $practitioner) { $hay = strtolower(trim((string) ($practitioner->specialty ?? ''))); if ($hay === '' || $this->isGeneralPracticeSpecialty($hay)) { continue; } foreach ($keywords as $keyword) { $needle = strtolower((string) $keyword); if ($needle !== '' && str_contains($hay, $needle)) { return true; } } } return false; } protected function isGeneralPracticeSpecialty(string $specialtyLower): bool { if (in_array($specialtyLower, [ 'general practice', 'general', 'gp', 'family medicine', 'internal medicine', 'nursing', ], true)) { return true; } return str_contains($specialtyLower, 'general practice') || str_contains($specialtyLower, 'family medicine'); } /** * @return \Illuminate\Support\Collection */ protected function practitionersForMember(Organization $organization, Member $member) { $branchId = app(OrganizationResolver::class)->branchScope($member); 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); }) ->with(['department', 'branches']) ->get() ->filter(function (Practitioner $practitioner) use ($branchId) { if (! $branchId) { return true; } return in_array((int) $branchId, $practitioner->assignedBranchIds(), true); }); } public function canManage(Organization $organization): bool { return $this->plans->hasFeature($organization, 'specialty_modules'); } /** * @param array $desired module key => enabled * @return array{activated: list, deactivated: list, errors: list} */ public function sync(Organization $organization, string $ownerRef, array $desired): array { $activated = []; $deactivated = []; $errors = []; foreach ($this->catalog() as $key => $definition) { $want = (bool) ($desired[$key] ?? false); if ($this->isDefaultOnPaidPlans($key) && $this->plans->hasPaidPlan($organization)) { $want = true; } $have = $this->isEnabled($organization, $key); if ($want === $have) { // Still ensure defaults are provisioned even when already "enabled". if ($want && $this->isDefaultOnPaidPlans($key) && ! data_get($organization->settings, "specialty_module_provisioning.{$key}.active")) { try { $this->activate($organization, $ownerRef, $key); $organization->refresh(); } catch (\Throwable $e) { $errors[] = ($definition['label'] ?? $key).': '.$e->getMessage(); } } continue; } try { if ($want) { $this->activate($organization, $ownerRef, $key); $activated[] = $key; } else { $this->deactivate($organization, $ownerRef, $key); $deactivated[] = $key; } $organization->refresh(); } catch (\Throwable $e) { Log::warning('specialty_module.sync_failed', [ 'key' => $key, 'want' => $want, 'message' => $e->getMessage(), ]); $errors[] = ($definition['label'] ?? $key).': '.$e->getMessage(); } } return compact('activated', 'deactivated', 'errors'); } public function activate(Organization $organization, string $ownerRef, string $key): void { $definition = $this->definition($key); if (! $definition) { throw new \InvalidArgumentException("Unknown specialty module [{$key}]."); } if (! $this->canManage($organization)) { throw new \RuntimeException('Specialty modules require Care Pro or Enterprise.'); } $settings = $organization->settings ?? []; $modules = is_array($settings['specialty_modules'] ?? null) ? $settings['specialty_modules'] : []; $modules[$key] = true; $settings['specialty_modules'] = $modules; $departments = $this->provisionDepartments($organization, $ownerRef, $definition); $queueStubs = $this->provisionQueueStubs($organization, $ownerRef, $key, $definition); $provisioning = is_array($settings['specialty_module_provisioning'] ?? null) ? $settings['specialty_module_provisioning'] : []; $provisioning[$key] = [ 'active' => true, 'department_ids' => $departments, 'queues' => $queueStubs, 'activated_at' => now()->toIso8601String(), ]; $settings['specialty_module_provisioning'] = $provisioning; $organization->update(['settings' => $settings]); // Seed billable specialty services into provisioning (Billing Engine catalog). try { app(SpecialtyShellService::class)->seedServices($organization->fresh(), $key); } catch (\Throwable $e) { Log::warning('specialty_module.service_catalog_seed_failed', [ 'key' => $key, 'message' => $e->getMessage(), ]); } $fresh = $organization->fresh(); if (data_get($fresh?->settings, 'queue_integration_enabled')) { $provisioner = app(CareQueueProvisioner::class); $branches = Branch::owned($ownerRef) ->where('organization_id', $organization->id) ->where('is_active', true) ->pluck('id'); foreach ($branches as $branchId) { try { $provisioner->ensure($fresh, $key, (int) $branchId); } catch (\Throwable $e) { Log::warning('specialty_module.native_queue_provision_failed', [ 'key' => $key, 'branch_id' => $branchId, 'message' => $e->getMessage(), ]); } } } } public function deactivate(Organization $organization, string $ownerRef, string $key): void { if (! $this->definition($key)) { throw new \InvalidArgumentException("Unknown specialty module [{$key}]."); } if ($this->isDefaultOnPaidPlans($key) && $this->plans->hasPaidPlan($organization)) { throw new \RuntimeException(($this->definition($key)['label'] ?? $key).' stays enabled on Pro and Enterprise.'); } $settings = $organization->settings ?? []; $modules = is_array($settings['specialty_modules'] ?? null) ? $settings['specialty_modules'] : []; $modules[$key] = false; $settings['specialty_modules'] = $modules; $provisioning = is_array($settings['specialty_module_provisioning'] ?? null) ? $settings['specialty_module_provisioning'] : []; $record = is_array($provisioning[$key] ?? null) ? $provisioning[$key] : []; $departmentIds = $record['department_ids'] ?? []; if (is_array($departmentIds) && $departmentIds !== []) { Department::owned($ownerRef) ->whereIn('id', $departmentIds) ->update(['is_active' => false]); } $queues = is_array($record['queues'] ?? null) ? $record['queues'] : []; foreach ($queues as $i => $queue) { $queues[$i]['active'] = false; } if (data_get($settings, 'queue_integration_enabled') && $queues !== []) { try { CareServiceQueue::query() ->where('organization_id', $organization->id) ->where('context', $key) ->update(['is_active' => false]); } catch (\Throwable $e) { Log::warning('specialty_module.queue_deactivate_failed', [ 'key' => $key, 'message' => $e->getMessage(), ]); } } $provisioning[$key] = array_merge($record, [ 'active' => false, 'queues' => $queues, 'deactivated_at' => now()->toIso8601String(), ]); $settings['specialty_module_provisioning'] = $provisioning; $organization->update(['settings' => $settings]); } /** * @param array $definition * @return list */ protected function provisionDepartments(Organization $organization, string $ownerRef, array $definition): array { $type = (string) ($definition['department_type'] ?? 'general'); $name = (string) ($definition['department_name'] ?? $definition['label'] ?? 'Specialty'); $branches = Branch::owned($ownerRef) ->where('organization_id', $organization->id) ->where('is_active', true) ->get(); $ids = []; foreach ($branches as $branch) { $department = Department::withTrashed() ->owned($ownerRef) ->where('branch_id', $branch->id) ->where('type', $type) ->first(); if ($department) { if ($department->trashed()) { $department->restore(); } $department->update([ 'name' => $name, 'is_active' => true, ]); } else { $department = Department::create([ 'owner_ref' => $ownerRef, 'branch_id' => $branch->id, 'name' => $name, 'type' => $type, 'is_active' => true, ]); } $ids[] = (int) $department->id; } return $ids; } /** * Care-side queue stubs (branch-aware). When Queue integration is enabled, * activate() creates/links real Queue queues + counters and stores UUIDs here. * * @param array $definition * @return list> */ protected function provisionQueueStubs( Organization $organization, string $ownerRef, string $key, array $definition, ): array { $branches = Branch::owned($ownerRef) ->where('organization_id', $organization->id) ->where('is_active', true) ->get(); $existingByBranch = collect( data_get($organization->settings, "specialty_module_provisioning.{$key}.queues", []) )->keyBy(fn ($q) => (string) ($q['branch_id'] ?? '')); $stubs = []; foreach ($branches as $branch) { $prior = is_array($existingByBranch->get((string) $branch->id)) ? $existingByBranch->get((string) $branch->id) : []; $stubs[] = [ 'module' => $key, 'branch_id' => $branch->id, 'branch_name' => $branch->name, 'name' => (string) ($definition['queue_name'] ?? $definition['label']), 'prefix' => (string) ($definition['queue_prefix'] ?? strtoupper(substr($key, 0, 3))), 'active' => true, 'synced' => false, 'queue_uuid' => $prior['queue_uuid'] ?? null, 'counter_uuid' => $prior['counter_uuid'] ?? null, 'queue_external_key' => $prior['queue_external_key'] ?? "care:specialty:{$key}:queue:{$branch->id}", 'counter_external_key' => $prior['counter_external_key'] ?? "care:specialty:{$key}:counter:{$branch->id}", ]; } return $stubs; } /** * @return list> */ public function queueStubsFor(Organization $organization, string $key): array { $queues = data_get($organization->settings, "specialty_module_provisioning.{$key}.queues", []); return is_array($queues) ? array_values(array_filter($queues, fn ($q) => (bool) ($q['active'] ?? false))) : []; } }