> */ protected array $practitionersCache = []; /** @var array */ protected array $deskSpecialistCache = []; /** @var array, access_level: string}>> */ protected array $enabledModulesForMemberCache = []; /** @var array */ protected array $memberAccessLevelCache = []; public function __construct( protected PlanService $plans, ) {} /** * @return array> */ public function catalog(): array { return config('care.specialty_modules', []); } /** * Legacy maternity module key → womens_health. */ public function normalizeKey(string $key): string { return $key === 'maternity' ? 'womens_health' : $key; } public function definition(string $key): ?array { $key = $this->normalizeKey($key); $catalog = $this->catalog(); return $catalog[$key] ?? null; } /** * Migrate org settings from maternity → womens_health (idempotent). */ public function migrateMaternityToWomensHealth(Organization $organization): Organization { $settings = is_array($organization->settings) ? $organization->settings : []; $modules = is_array($settings['specialty_modules'] ?? null) ? $settings['specialty_modules'] : []; $provisioning = is_array($settings['specialty_module_provisioning'] ?? null) ? $settings['specialty_module_provisioning'] : []; $changed = false; if (! empty($modules['maternity']) && empty($modules['womens_health'])) { $modules['womens_health'] = true; $modules['maternity'] = false; $changed = true; } if (isset($provisioning['maternity']) && ! isset($provisioning['womens_health'])) { $provisioning['womens_health'] = $provisioning['maternity']; unset($provisioning['maternity']); $changed = true; } elseif (isset($provisioning['maternity']) && isset($provisioning['womens_health'])) { unset($provisioning['maternity']); $changed = true; } if (! $changed) { return $organization; } $settings['specialty_modules'] = $modules; $settings['specialty_module_provisioning'] = $provisioning; $organization->update(['settings' => $settings]); // Point legacy maternity departments at the new type when still labeled maternity. Department::query() ->where('organization_id', $organization->id) ->where('type', 'maternity') ->update(['type' => 'womens_health']); return $organization->fresh(); } /** * @return array */ public function serviceLines(string $moduleKey): array { $definition = $this->definition($moduleKey); $lines = $definition['service_lines'] ?? []; if (! is_array($lines)) { return []; } $out = []; foreach ($lines as $key => $line) { if (! is_string($key) || ! is_array($line)) { continue; } $out[$key] = [ 'label' => (string) ($line['label'] ?? $key), 'default_on' => (bool) ($line['default_on'] ?? true), ]; } return $out; } /** * Enabled service lines for an org module (defaults when unset). * * @return array */ public function enabledServiceLines(Organization $organization, string $moduleKey): array { $moduleKey = $this->normalizeKey($moduleKey); $catalog = $this->serviceLines($moduleKey); if ($catalog === []) { return []; } $stored = data_get( $organization->settings, "specialty_module_provisioning.{$moduleKey}.service_lines", [], ); $stored = is_array($stored) ? $stored : []; $out = []; foreach ($catalog as $key => $line) { $out[$key] = array_key_exists($key, $stored) ? (bool) $stored[$key] : $line['default_on']; } return $out; } /** * @param array $desired */ public function syncServiceLines(Organization $organization, string $moduleKey, array $desired): void { $moduleKey = $this->normalizeKey($moduleKey); $catalog = $this->serviceLines($moduleKey); abort_unless($catalog !== [], 404); $settings = is_array($organization->settings) ? $organization->settings : []; $provisioning = is_array($settings['specialty_module_provisioning'] ?? null) ? $settings['specialty_module_provisioning'] : []; $record = is_array($provisioning[$moduleKey] ?? null) ? $provisioning[$moduleKey] : []; $lines = []; foreach (array_keys($catalog) as $key) { $lines[$key] = (bool) ($desired[$key] ?? false); } // Keep at least one line on so the module remains usable. if (! in_array(true, $lines, true)) { $first = array_key_first($catalog); if ($first !== null) { $lines[$first] = true; } } $record['service_lines'] = $lines; $provisioning[$moduleKey] = $record; $settings['specialty_module_provisioning'] = $provisioning; $organization->update(['settings' => $settings]); } public function isServiceLineEnabled(Organization $organization, string $moduleKey, string $lineKey): bool { $enabled = $this->enabledServiceLines($organization, $moduleKey); return (bool) ($enabled[$lineKey] ?? false); } /** * 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 { $key = $this->normalizeKey($key); if (! $this->definition($key)) { return false; } // One-time maternity → womens_health settings migration. if ($key === 'womens_health' && data_get($organization->settings, 'specialty_modules.maternity')) { $organization = $this->migrateMaternityToWomensHealth($organization); } 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 { $cacheKey = $organization->id.'|'.($member?->id ?? 'guest'); if (isset($this->enabledModulesForMemberCache[$cacheKey])) { return $this->enabledModulesForMemberCache[$cacheKey]; } $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 $this->enabledModulesForMemberCache[$cacheKey] = $out; } /** * Whether the sidebar should show the Specialty nav group. * * Single-app specialists (dentist, radiologist, …) and legacy desk * specialists only receive their own module(s), so a Specialty group is * redundant — they use dashboard cards / direct routes instead. * Multi-app clinical roles keep the group. */ public function shouldShowSpecialtyNav(Organization $organization, ?Member $member): bool { if (! $member) { return false; } $modules = $this->enabledModulesForMember($organization, $member); if ($modules === []) { return false; } $permissions = app(CarePermissions::class); if ($permissions->isSingleAppSpecialist($member)) { return false; } if ($this->isDeskSpecialist($organization, $member)) { return false; } return true; } /** * @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 { $key = $this->normalizeKey($key); $cacheKey = $organization->id.'|'.($member?->id ?? 'guest').'|'.$key; if (isset($this->memberAccessLevelCache[$cacheKey])) { return $this->memberAccessLevelCache[$cacheKey]; } if (! $this->isEnabled($organization, $key)) { return $this->memberAccessLevelCache[$cacheKey] = 'none'; } $definition = $this->definition($key); if (! $definition || ! $member) { return $this->memberAccessLevelCache[$cacheKey] = 'none'; } if (app(CarePermissions::class)->isAdmin($member)) { return $this->memberAccessLevelCache[$cacheKey] = 'manage'; } if ($this->memberCanManage($organization, $member, $key)) { return $this->memberAccessLevelCache[$cacheKey] = 'manage'; } // Use gated helpers so desk specialists do not inherit GP view/refer on other modules. if ($this->memberCanRefer($organization, $member, $key)) { return $this->memberAccessLevelCache[$cacheKey] = 'refer'; } if ($this->memberCanView($organization, $member, $key)) { return $this->memberAccessLevelCache[$cacheKey] = 'view'; } return $this->memberAccessLevelCache[$cacheKey] = '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. * * Role primary apps (CarePermissions) are the source of truth for the * RBAC matrix. Legacy `doctor` desk specialists still use keyword matching. */ public function memberCanManage(Organization $organization, ?Member $member, string $key): bool { $key = $this->normalizeKey($key); if (! $this->isEnabled($organization, $key) || ! $member) { return false; } $permissions = app(CarePermissions::class); if ($permissions->isAdmin($member)) { return true; } $definition = $this->definition($key); if (! $definition) { return false; } // Department managers get analytics view, not clinical manage. if ((string) $member->role === 'department_manager') { return false; } $primaryApps = $permissions->primaryAppsFor($member); // Matrix-driven roles (including GP / EP / specialists with app lists). if (is_array($primaryApps)) { // Legacy doctor + specialty desk: only matching modules. if ((string) $member->role === 'doctor' && $this->isDeskSpecialist($organization, $member)) { return $this->specialistBelongsToModule($organization, $member, $key); } return in_array($key, $primaryApps, true); } // Desk specialists only manage modules that match their specialty / assignment. if ($this->isDeskSpecialist($organization, $member) && ! $this->specialistBelongsToModule($organization, $member, $key)) { 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 (in_array($role, ['doctor', 'general_physician'], true) && $this->roleListed($member, $manageRoles)) { return $this->specialistBelongsToModule($organization, $member, $key); } return false; } // general + limited: role allowlist is enough for GPs and non-doctor roles. // Desk specialists already passed the match gate above. return $this->roleListed($member, $manageRoles); } /** * Read results / history / workspace without clinical edit. */ public function memberCanView(Organization $organization, ?Member $member, string $key): bool { $key = $this->normalizeKey($key); if ($this->memberCanManage($organization, $member, $key) || $this->memberCanRefer($organization, $member, $key)) { return true; } if (! $this->isEnabled($organization, $key) || ! $member) { return false; } $permissions = app(CarePermissions::class); if ((string) $member->role === 'department_manager') { return true; } $primaryApps = $permissions->primaryAppsFor($member); if (is_array($primaryApps)) { // Matrix roles: view only what they can manage (no cross-module GP view // unless listed — general_physician gets cardiology manage via apps). if ((string) $member->role === 'doctor' && $this->isDeskSpecialist($organization, $member)) { return $this->specialistBelongsToModule($organization, $member, $key); } if (in_array($key, $permissions->viewAppsFor($member), true)) { return true; } // Secondary: config view_roles (docs + legacy allowlists). $definition = $this->definition($key); if ($definition && $this->roleListed($member, $definition['view_roles'] ?? [])) { return true; } return in_array($key, $primaryApps, true); } // Desk specialists do not get cross-module view; only their matching desks. if ($this->isDeskSpecialist($organization, $member) && ! $this->specialistBelongsToModule($organization, $member, $key)) { 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 { $key = $this->normalizeKey($key); if ($this->memberCanManage($organization, $member, $key)) { return true; } if (! $this->isEnabled($organization, $key) || ! $member) { return false; } $permissions = app(CarePermissions::class); $primaryApps = $permissions->primaryAppsFor($member); if (is_array($primaryApps)) { if ((string) $member->role === 'doctor' && $this->isDeskSpecialist($organization, $member)) { return $this->specialistBelongsToModule($organization, $member, $key); } if (in_array($key, $permissions->referAppsFor($member), true)) { return true; } // Secondary: config refer_roles (docs + legacy allowlists). $definition = $this->definition($key); if ($definition && $this->roleListed($member, $definition['refer_roles'] ?? [])) { return true; } return in_array($key, $primaryApps, true); } // Desk specialists do not get cross-module referral nav; GPs keep refer_roles. if ($this->isDeskSpecialist($organization, $member) && ! $this->specialistBelongsToModule($organization, $member, $key)) { return false; } $definition = $this->definition($key); if (! $definition) { return false; } return $this->roleListed($member, $definition['refer_roles'] ?? []); } /** * Legacy doctor with a specialty desk: non-GP specialty matching a catalog * module, or assigned to a provisioned specialty department. * Dedicated specialist roles use primary apps instead. */ public function isDeskSpecialist(Organization $organization, Member $member): bool { $cacheKey = $organization->id.'|'.$member->id; if (isset($this->deskSpecialistCache[$cacheKey])) { return $this->deskSpecialistCache[$cacheKey]; } if (! in_array((string) $member->role, ['doctor'], true)) { return $this->deskSpecialistCache[$cacheKey] = false; } foreach (array_keys($this->catalog()) as $key) { if ($this->specialistBelongsToModule($organization, $member, $key)) { return $this->deskSpecialistCache[$cacheKey] = true; } } return $this->deskSpecialistCache[$cacheKey] = false; } /** * Whether this doctor belongs to the module via department assignment or specialty keywords. */ public function specialistBelongsToModule(Organization $organization, Member $member, string $key): bool { return $this->doctorAssignedToModule($organization, $member, $key) || $this->doctorSpecialtyMatchesModule($organization, $member, $key); } /** * @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) { if ($this->specialtyKeywordMatches($hay, strtolower((string) $keyword))) { return true; } } } return false; } /** * Match specialty text to a keyword without short-token false positives * (e.g. "ent" must not match "dentistry"; "ear" must not match "year"). */ protected function specialtyKeywordMatches(string $haystackLower, string $needleLower): bool { $needleLower = trim($needleLower); if ($needleLower === '' || $haystackLower === '') { return false; } if ($haystackLower === $needleLower) { return true; } $quoted = preg_quote($needleLower, '/'); // Whole-word match covers short tokens (ent, ear, eye) and multi-word labels. if (preg_match('/\b'.$quoted.'\b/u', $haystackLower) === 1) { return true; } // Longer stems may prefix a token (dent→dentistry, cardio→cardiology). if (strlen($needleLower) >= 4 && preg_match('/\b'.$quoted.'/u', $haystackLower) === 1) { 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) { $cacheKey = $organization->id.'|'.$member->id; if (isset($this->practitionersCache[$cacheKey])) { return $this->practitionersCache[$cacheKey]; } $branchId = app(OrganizationResolver::class)->branchScope($member); return $this->practitionersCache[$cacheKey] = 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))) : []; } }