Files
ladill-care/app/Services/Care/SpecialtyModuleService.php
T
isaaccladandCursor 938f351ede
Deploy Ladill Care / deploy (push) Successful in 41s
Expand specialty RBAC with dedicated specialist roles and support access.
Add cardiologist/ENT/derm/ophthalmology/ortho/podiatry/fertility roles and
manage/refer/view matrices so GPs, nurses, lab, pharmacy, and reception get
realistic module access beyond single-app specialists.

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

922 lines
32 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?php
namespace App\Services\Care;
use App\Models\Branch;
use App\Models\CareServiceQueue;
use App\Models\Department;
use App\Models\Member;
use App\Models\Organization;
use App\Models\Practitioner;
use Illuminate\Support\Facades\Log;
/**
* Activate/deactivate specialty practice modules (dentistry, eye care, …).
*
* Persistence: organization.settings.specialty_modules[key] = bool
* Provisioning: departments per branch + Care Queue Engine stubs/points.
* Deactivate hides UI and marks queues inactive; does not destroy clinical history.
*
* Plan gate: Pro/Enterprise via PlanService feature specialty_modules (same tier as queue_integration).
*/
class SpecialtyModuleService
{
/** @var array<string, \Illuminate\Support\Collection<int, Practitioner>> */
protected array $practitionersCache = [];
/** @var array<string, bool> */
protected array $deskSpecialistCache = [];
/** @var array<string, list<array{key: string, definition: array<string, mixed>, access_level: string}>> */
protected array $enabledModulesForMemberCache = [];
/** @var array<string, string> */
protected array $memberAccessLevelCache = [];
public function __construct(
protected PlanService $plans,
) {}
/**
* @return array<string, array<string, mixed>>
*/
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<string>
*/
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<string>
*/
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<array{key: string, definition: array<string, mixed>}>
*/
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<array{key: string, definition: array<string, mixed>, 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
{
$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
{
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
{
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
{
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<string>|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<int, Practitioner>
*/
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<string, bool> $desired module key => enabled
* @return array{activated: list<string>, deactivated: list<string>, errors: list<string>}
*/
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<string, mixed> $definition
* @return list<int>
*/
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<string, mixed> $definition
* @return list<array<string, mixed>>
*/
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<array<string, mixed>>
*/
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))) : [];
}
}