Hard-delete Care remote Queue adapter (Phase 5).
Deploy Ladill Care / deploy (push) Failing after 1m13s

Care Queue Engine is the only path; remove QueueClient, driver config, and remote HTTP tests.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
isaacclad
2026-07-18 11:16:41 +00:00
co-authored by Cursor
parent 966f0496b2
commit dd4bc493b4
31 changed files with 441 additions and 2108 deletions
+8 -48
View File
@@ -11,19 +11,15 @@ use App\Models\Patient;
use App\Models\Practitioner;
use App\Models\Prescription;
use App\Services\Care\Workflow\WorkflowQueueGate;
use App\Services\Queue\QueueClient;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Http\Client\RequestException;
use Illuminate\Support\Facades\Log;
/**
* Bridges Care workflow entities to service-queue tickets (native Care Queue
* Engine by default; optional legacy Ladill Queue HTTP when driver=remote).
* Bridges Care workflow entities to Care Queue Engine tickets.
*/
class CareQueueBridge
{
public function __construct(
protected QueueClient $queue,
protected CareQueueEngine $engine,
protected CareQueueProvisioner $provisioner,
protected PlanService $plans,
@@ -31,21 +27,10 @@ class CareQueueBridge
protected WorkflowQueueGate $workflowGate,
) {}
public function usesNative(): bool
{
return config('care.queue.driver', 'native') === 'native';
}
public function isEnabled(Organization $organization): bool
{
$enabled = $this->plans->canUseQueueIntegration($organization)
return $this->plans->canUseQueueIntegration($organization)
&& (bool) data_get($organization->settings, 'queue_integration_enabled');
if (! $enabled) {
return false;
}
return $this->usesNative() || $this->queue->configured();
}
public function contextForAppointment(Organization $organization, Appointment $appointment): string
@@ -380,25 +365,11 @@ class CareQueueBridge
protected function attemptCallNext(Organization $organization, array $resources): ?array
{
try {
if ($this->usesNative()) {
return $this->engine->callNext(
$organization,
(string) $resources['queue_uuid'],
(string) $resources['counter_uuid'],
);
}
return $this->queue->callNext(
(string) $organization->owner_ref,
return $this->engine->callNext(
$organization,
(string) $resources['queue_uuid'],
(string) $resources['counter_uuid'],
);
} catch (RequestException $e) {
Log::warning('care.queue_call_next_failed', [
'message' => $e->getMessage(),
]);
return null;
} catch (\Throwable $e) {
Log::warning('care.queue_call_next_failed', [
'message' => $e->getMessage(),
@@ -583,7 +554,6 @@ class CareQueueBridge
return null;
}
$owner = (string) $organization->owner_ref;
$payload = [
'service_queue_id' => $resources['queue_uuid'],
'customer_name' => $patient?->fullName(),
@@ -597,11 +567,7 @@ class CareQueueBridge
}
try {
if ($this->usesNative()) {
return $this->engine->issueTicket($organization, $payload);
}
return $this->queue->issueTicket($owner, $payload);
return $this->engine->issueTicket($organization, $payload);
} catch (\Throwable $e) {
Log::warning('care.queue_issue_failed', [
'context' => $context,
@@ -650,15 +616,9 @@ class CareQueueBridge
}
try {
if ($this->usesNative()) {
$ticket = $this->engine->ticketAction($organization, $uuid, [
'action' => $action,
]);
} else {
$ticket = $this->queue->ticketAction((string) $organization->owner_ref, $uuid, [
'action' => $action,
]);
}
$ticket = $this->engine->ticketAction($organization, $uuid, [
'action' => $action,
]);
$this->applyTicket($entity, $ticket);
return $ticket;
+3 -122
View File
@@ -8,36 +8,22 @@ use App\Models\CareServiceQueue;
use App\Models\Member;
use App\Models\Organization;
use App\Models\Practitioner;
use App\Services\Queue\QueueClient;
use Illuminate\Support\Facades\Log;
/**
* Idempotently provisions service queues and service points from Care staff /
* branch configuration. Native driver writes Care Queue Engine tables; remote
* driver syncs to Ladill Queue over HTTP (legacy cutover).
* Idempotently provisions Care Queue Engine service queues and service points
* from Care staff / branch configuration.
*/
class CareQueueProvisioner
{
public function __construct(
protected QueueClient $queue,
protected PlanService $plans,
) {}
public function usesNative(): bool
{
return config('care.queue.driver', 'native') === 'native';
}
public function shouldProvision(Organization $organization): bool
{
$enabled = $this->plans->canUseQueueIntegration($organization)
return $this->plans->canUseQueueIntegration($organization)
&& (bool) data_get($organization->settings, 'queue_integration_enabled');
if (! $enabled) {
return false;
}
return $this->usesNative() || $this->queue->configured();
}
/**
@@ -121,95 +107,6 @@ class CareQueueProvisioner
'synced' => false,
];
if ($this->usesNative()) {
return $this->provisionBranchDepartmentNative($organization, $owner, $context, $meta, $branch, $stub);
}
try {
$this->ensureBranchName($owner, $branch->name);
$department = $this->queue->ensureDepartment($owner, [
'name' => $meta['name'],
'type' => $meta['type'] ?? 'general',
'branch_name' => $branch->name,
'external_key' => $stub['department_external_key'],
'is_active' => true,
]);
$stub['department_id'] = $department['id'] ?? $stub['department_id'];
$queue = $this->queue->ensureQueue($owner, [
'name' => $meta['name'],
'prefix' => $meta['prefix'],
'branch_name' => $branch->name,
'strategy' => 'fifo',
'external_key' => $stub['queue_external_key'],
'routing_mode' => $meta['routing_mode'],
'department_id' => $stub['department_id'],
'is_active' => true,
]);
$stub['queue_uuid'] = $queue['uuid'] ?? $stub['queue_uuid'];
$points = $this->buildPointsForContext($organization, $context, $branch, $stub);
$syncedPoints = [];
foreach ($points as $point) {
try {
$counter = $this->queue->ensureCounter($owner, [
'name' => $point['name'],
'destination' => $point['destination'],
'staff_ref' => $point['staff_ref'],
'staff_display_name' => $point['staff_display_name'],
'branch_name' => $branch->name,
'department_id' => $stub['department_id'],
'queue_uuids' => array_values(array_filter([(string) $stub['queue_uuid']])),
'external_key' => $point['external_key'],
'is_active' => true,
]);
$point['counter_uuid'] = $counter['uuid'] ?? null;
$point['synced'] = ! empty($point['counter_uuid']);
} catch (\Throwable $e) {
Log::warning('care.queue_point_provision_failed', [
'context' => $context,
'branch_id' => $branch->id,
'point' => $point['external_key'] ?? null,
'message' => $e->getMessage(),
]);
$point['synced'] = false;
}
$syncedPoints[] = $point;
}
$stub['points'] = $syncedPoints;
// Legacy single counter: first point (or dedicated default) for older callers.
$first = collect($syncedPoints)->first(fn ($p) => ! empty($p['counter_uuid']));
if ($first) {
$stub['counter_uuid'] = $first['counter_uuid'];
$stub['counter_external_key'] = $first['external_key'];
}
$stub['synced'] = ! empty($stub['queue_uuid']) && collect($syncedPoints)->contains(fn ($p) => ! empty($p['synced']));
} catch (\Throwable $e) {
Log::warning('care.queue_department_provision_failed', [
'context' => $context,
'branch_id' => $branch->id,
'message' => $e->getMessage(),
]);
}
return $stub;
}
/**
* @param array<string, mixed> $meta
* @param array<string, mixed> $stub
* @return array<string, mixed>
*/
protected function provisionBranchDepartmentNative(
Organization $organization,
string $owner,
string $context,
array $meta,
Branch $branch,
array $stub,
): array {
try {
$queue = CareServiceQueue::query()->updateOrCreate(
[
@@ -264,7 +161,6 @@ class CareQueueProvisioner
$syncedPoints[] = $point;
}
// Soft-deactivate points no longer in the staff roster.
CareServicePoint::query()
->where('service_queue_id', $queue->id)
->when(
@@ -302,7 +198,6 @@ class CareQueueProvisioner
Branch $branch,
array $stub,
): array {
$owner = (string) $organization->owner_ref;
$priorByKey = collect($stub['points'] ?? [])->keyBy(fn ($p) => (string) ($p['external_key'] ?? ''));
$points = match (true) {
@@ -362,7 +257,6 @@ class CareQueueProvisioner
default => [],
};
// Always keep at least one default point so call-next / least-load have a target.
if ($points === []) {
$key = CareQueueContexts::pointExternalKey($context, $branch->id, 'default', 0);
$prior = $priorByKey->get($key, []);
@@ -487,17 +381,6 @@ class CareQueueProvisioner
return $points;
}
protected function ensureBranchName(string $owner, string $branchName): void
{
try {
$this->queue->ensureBranch($owner, ['name' => $branchName]);
} catch (\Illuminate\Http\Client\RequestException $e) {
if ($e->response?->status() !== 422) {
throw $e;
}
}
}
/**
* @return array{queue_uuid: ?string, counter_uuid: ?string, queue_external_key: string, counter_external_key: string, synced: bool, points: list<array<string, mixed>>, routing_mode: string}|null
*/
@@ -632,8 +515,6 @@ class CareQueueProvisioner
if ($existing && ! empty($existing['queue_uuid']) && (
! empty($existing['points']) || ! empty($existing['counter_uuid'])
) && $modeMatches) {
// Specialty module stubs often only store a legacy counter — still provision
// practitioner points so check-in / sync can assign real desks.
if (! CareQueueContexts::isSpecialty($context) || ! empty($existing['points'])) {
return $existing;
}
+2 -16
View File
@@ -734,16 +734,8 @@ class DemoTenantSeeder
private function seedSpecialtyModules(Organization $organization, string $ownerRef): void
{
// Remote Queue HTTP during per-module activate can hang Enterprise demos.
// Native Care Queue Engine is local — keep integration on so desks provision.
$organization = $organization->fresh();
$settings = $organization->settings ?? [];
$queueEnabled = (bool) data_get($settings, 'queue_integration_enabled');
$native = config('care.queue.driver', 'native') === 'native';
if ($queueEnabled && ! $native) {
data_set($settings, 'queue_integration_enabled', false);
$organization->update(['settings' => $settings]);
}
$queueEnabled = (bool) data_get($organization->settings, 'queue_integration_enabled');
$service = app(SpecialtyModuleService::class);
foreach (array_keys($service->catalog()) as $key) {
@@ -754,13 +746,7 @@ class DemoTenantSeeder
}
}
if ($queueEnabled && ! $native) {
$organization = $organization->fresh();
$settings = $organization->settings ?? [];
data_set($settings, 'queue_integration_enabled', true);
$organization->update(['settings' => $settings]);
// Skip CareQueueProvisioner here — same Queue HTTP storm as ticket sync.
} elseif ($queueEnabled && $native) {
if ($queueEnabled) {
try {
app(CareQueueProvisioner::class)->provisionOrganization($organization->fresh());
} catch (\Throwable) {
+1 -1
View File
@@ -42,7 +42,7 @@ class PlanService
return in_array($this->planKey($organization), ['pro', 'enterprise'], true);
}
/** Lab, pharmacy, billing, and Ladill Queue integration require Pro or Enterprise. */
/** Lab, pharmacy, billing, and in-app service queues require Pro or Enterprise. */
public function canUseProModules(Organization $organization): bool
{
return $this->hasPaidPlan($organization);
-327
View File
@@ -1,327 +0,0 @@
<?php
namespace App\Services\Care;
use App\Models\Branch;
use App\Models\Member;
use App\Models\Organization;
use App\Services\Queue\QueueClient;
use Illuminate\Http\Client\RequestException;
use Illuminate\Http\Request;
/**
* Loads Ladill Queue console state for embedding on role-native pages
* (clinical queue, pharmacy, lab, specialty modules) instead of a standalone Service Queues nav.
*/
class ServiceQueuePresenter
{
/** @var array<string, list<string>> */
protected array $contextKeywords = [
'clinical' => ['reception', 'consult', 'outpatient', 'general', 'doctor', 'nurse', 'triage', 'waiting'],
'pharmacy' => ['pharm', 'dispens', 'drug', 'rx'],
'laboratory' => ['lab', 'sample', 'patholog', 'investig'],
'dentistry' => ['dent', 'dental', 'oral'],
'ophthalmology' => ['eye', 'ophthal', 'optom'],
'physiotherapy' => ['physio', 'rehab', 'therapy'],
'maternity' => ['matern', 'antenatal', 'obstetric'],
'radiology' => ['radio', 'imaging', 'x-ray', 'xray', 'ultrasound', 'ct', 'mri'],
];
public function __construct(
protected QueueClient $queue,
protected CarePermissions $permissions,
protected PlanService $plans,
protected OrganizationResolver $resolver,
) {}
/**
* @return array{
* enabled: bool,
* counters: list<array<string, mixed>>,
* counter_uuid: ?string,
* state: ?array<string, mixed>,
* stubs: list<array<string, mixed>>,
* error: ?string,
* context: string
* }|null
*/
public function panel(
Request $request,
Organization $organization,
?Member $member,
string $context = 'clinical',
?string $preferredCounterUuid = null,
): ?array {
if (! $this->shouldShow($organization, $member)) {
return null;
}
$owner = (string) $organization->owner_ref;
$branchScope = $this->resolver->branchScope($member);
$branchName = null;
if ($branchScope) {
$branchName = Branch::owned($owner)->where('id', $branchScope)->value('name');
}
$stubs = $this->stubsForContext($organization, $context, $branchScope);
if (config('care.queue.driver', 'native') === 'native') {
$counters = $this->nativeCountersFromStubs($stubs, $context);
$stubCounterUuid = collect($stubs)
->map(fn ($s) => (string) ($s['counter_uuid'] ?? ''))
->first(fn ($uuid) => $uuid !== '');
$counterUuid = $preferredCounterUuid
?: ($stubCounterUuid ?: '')
?: (string) ($counters[0]['uuid'] ?? '');
return [
'enabled' => true,
'counters' => $counters,
'counter_uuid' => $counterUuid !== '' ? $counterUuid : null,
'state' => null,
'stubs' => $stubs,
'error' => null,
'context' => $context,
];
}
if (! $this->queue->configured()) {
return [
'enabled' => true,
'counters' => [],
'counter_uuid' => null,
'state' => null,
'stubs' => $stubs,
'error' => 'Ladill Queue API is not configured on this Care instance.',
'context' => $context,
];
}
try {
$counters = $this->queue->counters($owner);
} catch (RequestException $e) {
if ($e->response?->status() === 403) {
try {
$this->queue->syncIntegration($owner, true);
$counters = $this->queue->counters($owner);
} catch (RequestException) {
return [
'enabled' => true,
'counters' => [],
'counter_uuid' => null,
'state' => null,
'stubs' => $stubs,
'error' => 'Queue integration is not linked. Re-save Settings with Queue enabled.',
'context' => $context,
];
}
} else {
return [
'enabled' => true,
'counters' => [],
'counter_uuid' => null,
'state' => null,
'stubs' => $stubs,
'error' => 'Could not reach Ladill Queue.',
'context' => $context,
];
}
}
$stubCounterUuid = collect($stubs)
->map(fn ($s) => (string) ($s['counter_uuid'] ?? ''))
->first(fn ($uuid) => $uuid !== '');
$counters = $this->filterCounters($counters, $branchName, $context, $stubCounterUuid);
$counterUuid = $preferredCounterUuid
?: ($stubCounterUuid ?: '')
?: (string) ($counters[0]['uuid'] ?? '');
if ($counterUuid !== '' && ! collect($counters)->contains(fn ($c) => ($c['uuid'] ?? '') === $counterUuid)) {
$counterUuid = (string) ($counters[0]['uuid'] ?? '');
}
$state = null;
if ($counterUuid !== '') {
try {
$state = $this->queue->console($owner, $counterUuid);
} catch (RequestException) {
return [
'enabled' => true,
'counters' => $counters,
'counter_uuid' => $counterUuid,
'state' => null,
'stubs' => $stubs,
'error' => 'Counter console unavailable.',
'context' => $context,
];
}
}
return [
'enabled' => true,
'counters' => $counters,
'counter_uuid' => $counterUuid !== '' ? $counterUuid : null,
'state' => $state,
'stubs' => $stubs,
'error' => null,
'context' => $context,
];
}
public function shouldShow(Organization $organization, ?Member $member): bool
{
if (! $member) {
return false;
}
if (! $this->plans->canUseQueueIntegration($organization)) {
return false;
}
if (! data_get($organization->settings, 'queue_integration_enabled')) {
return false;
}
return $this->permissions->can($member, 'service_queues.console');
}
/**
* @param list<array<string, mixed>> $counters
* @return list<array<string, mixed>>
*/
protected function filterCounters(
array $counters,
?string $branchName,
string $context,
?string $preferredUuid = null,
): array {
$all = collect($counters);
$filtered = $all;
if ($preferredUuid) {
$linked = $all->first(fn ($c) => ($c['uuid'] ?? '') === $preferredUuid);
if ($linked) {
return [$linked];
}
}
if ($branchName) {
$branchFiltered = $filtered->filter(function ($counter) use ($branchName) {
$counterBranch = (string) ($counter['branch'] ?? '');
return $counterBranch === '' || strcasecmp($counterBranch, $branchName) === 0;
});
if ($branchFiltered->isNotEmpty()) {
$filtered = $branchFiltered;
}
}
$keywords = $this->keywordsFor($context);
if ($keywords !== []) {
$keywordFiltered = $filtered->filter(function ($counter) use ($keywords) {
$queueNames = collect($counter['queues'] ?? [])->map(function ($q) {
return is_array($q) ? (string) ($q['name'] ?? '') : (string) $q;
})->implode(' ');
$haystack = strtolower(implode(' ', [
(string) ($counter['name'] ?? ''),
$queueNames,
]));
foreach ($keywords as $keyword) {
if ($keyword !== '' && str_contains($haystack, $keyword)) {
return true;
}
}
return false;
});
// Fall back to branch-scoped counters when no keyword match (demo / generic counters).
if ($keywordFiltered->isNotEmpty()) {
$filtered = $keywordFiltered;
}
}
return $filtered->values()->all();
}
/**
* @param list<array<string, mixed>> $stubs
* @return list<array<string, mixed>>
*/
protected function nativeCountersFromStubs(array $stubs, string $context): array
{
$counters = [];
foreach ($stubs as $stub) {
$branchName = $stub['branch_name'] ?? null;
$queueName = $stub['name'] ?? $context;
$points = is_array($stub['points'] ?? null) ? $stub['points'] : [];
foreach ($points as $point) {
$uuid = (string) ($point['counter_uuid'] ?? '');
if ($uuid === '') {
continue;
}
$counters[] = [
'uuid' => $uuid,
'name' => (string) ($point['name'] ?? $point['destination'] ?? 'Desk'),
'branch' => $branchName,
'queues' => [['name' => $queueName]],
];
}
if ($points === [] && ! empty($stub['counter_uuid'])) {
$counters[] = [
'uuid' => (string) $stub['counter_uuid'],
'name' => (string) $queueName,
'branch' => $branchName,
'queues' => [['name' => $queueName]],
];
}
}
return $counters;
}
/**
* @return list<string>
*/
protected function keywordsFor(string $context): array
{
if (isset($this->contextKeywords[$context])) {
return $this->contextKeywords[$context];
}
$module = config("care.specialty_modules.{$context}.queue_keywords");
return is_array($module) ? $module : [];
}
/**
* @return list<array<string, mixed>>
*/
protected function stubsForContext(Organization $organization, string $context, ?int $branchId): array
{
if (! array_key_exists($context, config('care.specialty_modules', []))) {
return [];
}
$queues = data_get($organization->settings, "specialty_module_provisioning.{$context}.queues", []);
if (! is_array($queues)) {
return [];
}
return array_values(array_filter($queues, function ($queue) use ($branchId) {
if (! ($queue['active'] ?? false)) {
return false;
}
if ($branchId !== null && (int) ($queue['branch_id'] ?? 0) !== $branchId) {
return false;
}
return true;
}));
}
}
+19 -46
View File
@@ -3,18 +3,18 @@
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 App\Services\Queue\QueueClient;
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-side queue stubs (and best-effort Queue API sync).
* 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).
@@ -23,7 +23,6 @@ class SpecialtyModuleService
{
public function __construct(
protected PlanService $plans,
protected QueueClient $queue,
) {}
/**
@@ -332,38 +331,18 @@ class SpecialtyModuleService
$fresh = $organization->fresh();
if (data_get($fresh?->settings, 'queue_integration_enabled')) {
if (config('care.queue.driver', 'native') === 'native') {
$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(),
]);
}
}
} elseif ($this->queue->configured()) {
$provisioner = app(CareQueueProvisioner::class);
$branches = Branch::owned($ownerRef)
->where('organization_id', $organization->id)
->where('is_active', true)
->pluck('id');
foreach ($branches as $branchId) {
try {
$synced = $this->queue->syncSpecialtyQueueStubs($ownerRef, $queueStubs);
$settings = $fresh->settings ?? [];
$provisioning = is_array($settings['specialty_module_provisioning'] ?? null)
? $settings['specialty_module_provisioning']
: [];
$provisioning[$key] = array_merge($provisioning[$key] ?? [], [
'queues' => $synced,
]);
$settings['specialty_module_provisioning'] = $provisioning;
$organization->update(['settings' => $settings]);
$provisioner->ensure($fresh, $key, (int) $branchId);
} catch (\Throwable $e) {
Log::warning('specialty_module.queue_sync_failed', [
Log::warning('specialty_module.native_queue_provision_failed', [
'key' => $key,
'branch_id' => $branchId,
'message' => $e->getMessage(),
]);
}
@@ -399,27 +378,21 @@ class SpecialtyModuleService
}
$queues = is_array($record['queues'] ?? null) ? $record['queues'] : [];
foreach ($queues as $i => $queue) {
$queues[$i]['active'] = false;
}
if (
data_get($settings, 'queue_integration_enabled')
&& config('care.queue.driver', 'native') !== 'native'
&& $this->queue->configured()
&& $queues !== []
) {
if (data_get($settings, 'queue_integration_enabled') && $queues !== []) {
try {
$queues = $this->queue->deactivateSpecialtyQueueStubs($ownerRef, $queues);
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(),
]);
foreach ($queues as $i => $queue) {
$queues[$i]['active'] = false;
}
}
} else {
foreach ($queues as $i => $queue) {
$queues[$i]['active'] = false;
}
}
-397
View File
@@ -1,397 +0,0 @@
<?php
namespace App\Services\Queue;
use Illuminate\Http\Client\RequestException;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;
class QueueClient
{
private function base(): string
{
return rtrim((string) config('care.queue.api_url'), '/');
}
private function token(): string
{
return (string) (config('care.queue.api_key') ?? '');
}
private function http(string $owner)
{
return Http::withToken($this->token())
->acceptJson()
->timeout(12)
->withQueryParameters(['owner' => $owner]);
}
public function configured(): bool
{
return $this->token() !== '' && $this->base() !== '';
}
/**
* @return array<string, mixed>
*/
public function status(string $owner): array
{
$response = $this->http($owner)->get($this->base().'/integrations/status');
$response->throw();
return (array) ($response->json('data') ?? []);
}
/**
* @return array<string, mixed>
*/
public function enable(string $owner, string $organizationName, ?string $branchName = null): array
{
$this->http($owner)->post($this->base().'/integrations/provision', array_filter([
'organization_name' => $organizationName,
'branch_name' => $branchName,
'industry' => 'healthcare',
]))->throw();
return $this->syncIntegration($owner, true);
}
/**
* @return array<string, mixed>
*/
public function syncIntegration(string $owner, bool $enabled = true): array
{
$response = $this->http($owner)->patch($this->base().'/integrations', [
'care_enabled' => $enabled,
]);
$response->throw();
return (array) ($response->json('data') ?? []);
}
public function disable(string $owner): void
{
// Local Care setting only; Queue must be disabled separately in Queue settings.
}
public function isLinked(string $owner): bool
{
if (! $this->configured()) {
return false;
}
try {
$status = $this->status($owner);
return (bool) ($status['provisioned'] ?? false)
&& (bool) data_get($status, 'integrations.care', false);
} catch (RequestException) {
return false;
}
}
/**
* @return list<array<string, mixed>>
*/
public function counters(string $owner): array
{
$response = $this->http($owner)->get($this->base().'/counters');
$response->throw();
return (array) ($response->json('data') ?? []);
}
/**
* @return list<array<string, mixed>>
*/
public function branches(string $owner): array
{
$response = $this->http($owner)->get($this->base().'/branches');
$response->throw();
return (array) ($response->json('data') ?? []);
}
/**
* @param array<string, mixed> $payload
* @return array<string, mixed>
*/
public function ensureDepartment(string $owner, array $payload): array
{
$response = $this->http($owner)->post($this->base().'/departments', $payload);
$response->throw();
return (array) ($response->json('data') ?? []);
}
/**
* @param array<string, mixed> $payload
* @return array<string, mixed>
*/
public function ensureBranch(string $owner, array $payload): array
{
$response = $this->http($owner)->post($this->base().'/branches', $payload);
$response->throw();
return (array) ($response->json('data') ?? []);
}
/**
* @param array<string, mixed> $payload
* @return array<string, mixed>
*/
public function ensureQueue(string $owner, array $payload): array
{
$response = $this->http($owner)->post($this->base().'/queues', $payload);
$response->throw();
return (array) ($response->json('data') ?? []);
}
/**
* @param array<string, mixed> $payload
* @return array<string, mixed>
*/
public function ensureCounter(string $owner, array $payload): array
{
$response = $this->http($owner)->post($this->base().'/counters', $payload);
$response->throw();
return (array) ($response->json('data') ?? []);
}
/**
* Soft-deactivate a Queue counter without destroying history.
*
* @return array<string, mixed>
*/
public function updateCounter(string $owner, string $counterUuid, array $payload): array
{
$response = $this->http($owner)->patch($this->base().'/counters/'.$counterUuid, $payload);
$response->throw();
return (array) ($response->json('data') ?? []);
}
/**
* Soft-deactivate a Queue service queue without destroying history.
*
* @return array<string, mixed>
*/
public function updateQueue(string $owner, string $queueUuid, array $payload): array
{
$response = $this->http($owner)->patch($this->base().'/queues/'.$queueUuid, $payload);
$response->throw();
return (array) ($response->json('data') ?? []);
}
/**
* @return array<string, mixed>
*/
public function console(string $owner, string $counterUuid): array
{
$response = $this->http($owner)->get($this->base().'/counters/'.$counterUuid.'/console');
$response->throw();
return (array) ($response->json('data') ?? []);
}
/**
* @param array<string, mixed> $payload
* @return array<string, mixed>
*/
public function consoleAction(string $owner, string $counterUuid, array $payload): array
{
$response = $this->http($owner)->post($this->base().'/counters/'.$counterUuid.'/console', $payload);
$response->throw();
return (array) ($response->json('data') ?? []);
}
/**
* @param array<string, mixed> $payload
* @return array<string, mixed>
*/
public function issueTicket(string $owner, array $payload): array
{
$response = $this->http($owner)->post($this->base().'/tickets', $payload);
$response->throw();
return (array) ($response->json('data') ?? []);
}
/**
* @return array<string, mixed>|null
*/
public function callNext(string $owner, string $queueUuid, string $counterUuid): ?array
{
$response = $this->http($owner)->post($this->base().'/queues/'.$queueUuid.'/call-next', [
'counter_id' => $counterUuid,
]);
$response->throw();
$data = $response->json('data');
return is_array($data) ? $data : null;
}
/**
* @param array<string, mixed> $payload
* @return array<string, mixed>
*/
public function ticketAction(string $owner, string $ticketUuid, array $payload): array
{
$response = $this->http($owner)->post($this->base().'/tickets/'.$ticketUuid.'/action', $payload);
$response->throw();
return (array) ($response->json('data') ?? []);
}
/**
* Create or link real Queue queues/counters for Care specialty stubs.
* Idempotent via external_key (and Queue-side name+branch fallback).
*
* @param list<array<string, mixed>> $stubs
* @return list<array<string, mixed>>
*/
public function syncSpecialtyQueueStubs(string $owner, array $stubs): array
{
if (! $this->configured() || $stubs === []) {
return $stubs;
}
try {
if (! $this->isLinked($owner)) {
return $stubs;
}
} catch (\Throwable) {
return $stubs;
}
foreach ($stubs as $i => $stub) {
if (! ($stub['active'] ?? true)) {
continue;
}
try {
$stubs[$i] = $this->ensureSpecialtyStub($owner, $stub);
} catch (\Throwable $e) {
Log::warning('queue.specialty_stub_sync_failed', [
'owner' => $owner,
'stub' => $stub['name'] ?? null,
'branch' => $stub['branch_name'] ?? null,
'message' => $e->getMessage(),
]);
$stubs[$i]['synced'] = false;
}
}
return $stubs;
}
/**
* Soft-deactivate Queue resources linked on Care stubs (leaves history intact).
*
* @param list<array<string, mixed>> $stubs
* @return list<array<string, mixed>>
*/
public function deactivateSpecialtyQueueStubs(string $owner, array $stubs): array
{
if (! $this->configured() || $stubs === []) {
return $stubs;
}
foreach ($stubs as $i => $stub) {
try {
if (! empty($stub['counter_uuid'])) {
$this->updateCounter($owner, (string) $stub['counter_uuid'], ['is_active' => false]);
}
if (! empty($stub['queue_uuid'])) {
$this->updateQueue($owner, (string) $stub['queue_uuid'], ['is_active' => false]);
}
} catch (\Throwable $e) {
Log::warning('queue.specialty_stub_deactivate_failed', [
'owner' => $owner,
'counter_uuid' => $stub['counter_uuid'] ?? null,
'queue_uuid' => $stub['queue_uuid'] ?? null,
'message' => $e->getMessage(),
]);
}
$stubs[$i]['active'] = false;
}
return $stubs;
}
/**
* @param array<string, mixed> $stub
* @return array<string, mixed>
*/
protected function ensureSpecialtyStub(string $owner, array $stub): array
{
$branchName = trim((string) ($stub['branch_name'] ?? ''));
$name = trim((string) ($stub['name'] ?? ''));
$prefix = trim((string) ($stub['prefix'] ?? 'A'));
$module = (string) ($stub['module'] ?? 'specialty');
$careBranchId = (string) ($stub['branch_id'] ?? '0');
if ($branchName === '' || $name === '') {
$stub['synced'] = false;
return $stub;
}
$queueKey = (string) ($stub['queue_external_key'] ?? "care:specialty:{$module}:queue:{$careBranchId}");
$counterKey = (string) ($stub['counter_external_key'] ?? "care:specialty:{$module}:counter:{$careBranchId}");
try {
$this->ensureBranch($owner, ['name' => $branchName]);
} catch (RequestException $e) {
// Free Queue plans may already be at branch cap — continue if a branch can be resolved.
if ($e->response?->status() !== 422) {
throw $e;
}
$branches = collect($this->branches($owner));
$existing = $branches->first(
fn ($b) => strcasecmp((string) ($b['name'] ?? ''), $branchName) === 0
);
if (! $existing && $branches->count() === 1) {
// Single-location Queue orgs often use a different default name (e.g. "Main Branch").
$existing = $branches->first();
$branchName = (string) ($existing['name'] ?? $branchName);
}
if (! $existing) {
throw $e;
}
}
$queue = $this->ensureQueue($owner, [
'name' => $name,
'prefix' => $prefix !== '' ? $prefix : 'A',
'branch_name' => $branchName,
'strategy' => 'fifo',
'external_key' => $queueKey,
'routing_mode' => (string) ($stub['routing_mode'] ?? 'shared_pool'),
'is_active' => true,
]);
$counter = $this->ensureCounter($owner, [
'name' => $name.' counter',
'destination' => $name,
'branch_name' => $branchName,
'queue_uuids' => array_values(array_filter([(string) ($queue['uuid'] ?? '')])),
'external_key' => $counterKey,
'is_active' => true,
]);
$stub['queue_external_key'] = $queueKey;
$stub['counter_external_key'] = $counterKey;
$stub['queue_uuid'] = $queue['uuid'] ?? null;
$stub['counter_uuid'] = $counter['uuid'] ?? null;
$stub['synced'] = ! empty($stub['queue_uuid']) && ! empty($stub['counter_uuid']);
return $stub;
}
}