> */ public function catalog(): array { return config('care.specialty_modules', []); } public function definition(string $key): ?array { $catalog = $this->catalog(); return $catalog[$key] ?? null; } 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 (role allowlist + doctor specialty assignment). * * @return list}> */ public function enabledModulesForMember(Organization $organization, ?Member $member): array { return array_values(array_filter( $this->enabledModules($organization), fn (array $item) => $this->memberCanAccess($organization, $member, $item['key']), )); } /** * Whether the member may see/serve this specialty module. * Doctors must be linked to a practitioner desk in that specialty department. * Admins/receptionists keep org-wide visibility for operations. */ public function memberCanAccess(Organization $organization, ?Member $member, string $key): bool { if (! $this->isEnabled($organization, $key)) { return false; } $definition = $this->definition($key); if (! $definition) { return false; } $roles = $definition['roles'] ?? []; if (is_array($roles) && $roles !== [] && $member && ! in_array($member->role, $roles, true)) { return false; } if ($member && $member->role === 'doctor') { // Default Pro surfaces (Emergency, Blood Bank) are available to all doctors. if ($this->isDefaultOnPaidPlans($key)) { return true; } return $this->doctorAssignedToModule($organization, $member, $key); } return 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) : []; $branchId = app(OrganizationResolver::class)->branchScope($member); $practitioners = 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); }); foreach ($practitioners as $practitioner) { if ($provisionedIds !== [] && in_array((int) $practitioner->department_id, $provisionedIds, true)) { return true; } if ($departmentType !== '' && $practitioner->department?->type === $departmentType) { return true; } } return false; } 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))) : []; } }