Create real Queue counters when specialty modules activate.
Deploy Ladill Care / deploy (push) Successful in 1m39s
Deploy Ladill Care / deploy (push) Successful in 1m39s
Wire module activation to the Queue create/upsert APIs, persist queue and counter UUIDs on Care stubs, and soft-deactivate on module off so embedded service counters work without manual setup. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -456,6 +456,8 @@ class DemoTenantSeeder
|
||||
private function seedSpecialtyModules(Organization $organization, string $ownerRef): void
|
||||
{
|
||||
try {
|
||||
// Activates dentistry (depts + stubs). When queue_integration_enabled and
|
||||
// QUEUE_API_* are configured, SpecialtyModuleService creates real Queue counters.
|
||||
app(SpecialtyModuleService::class)->activate($organization->fresh(), $ownerRef, 'dentistry');
|
||||
} catch (\Throwable) {
|
||||
// Demo seed should not fail if Queue API is unreachable; settings flag remains.
|
||||
|
||||
@@ -108,8 +108,13 @@ class ServiceQueuePresenter
|
||||
}
|
||||
}
|
||||
|
||||
$counters = $this->filterCounters($counters, $branchName, $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)) {
|
||||
@@ -165,11 +170,21 @@ class ServiceQueuePresenter
|
||||
* @param list<array<string, mixed>> $counters
|
||||
* @return list<array<string, mixed>>
|
||||
*/
|
||||
protected function filterCounters(array $counters, ?string $branchName, string $context): array
|
||||
{
|
||||
$keywords = $this->keywordsFor($context);
|
||||
protected function filterCounters(
|
||||
array $counters,
|
||||
?string $branchName,
|
||||
string $context,
|
||||
?string $preferredUuid = null,
|
||||
): array {
|
||||
$all = collect($counters);
|
||||
$filtered = $all;
|
||||
|
||||
$filtered = collect($counters);
|
||||
if ($preferredUuid) {
|
||||
$linked = $all->first(fn ($c) => ($c['uuid'] ?? '') === $preferredUuid);
|
||||
if ($linked) {
|
||||
return [$linked];
|
||||
}
|
||||
}
|
||||
|
||||
if ($branchName) {
|
||||
$branchFiltered = $filtered->filter(function ($counter) use ($branchName) {
|
||||
@@ -182,11 +197,16 @@ class ServiceQueuePresenter
|
||||
}
|
||||
}
|
||||
|
||||
$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'] ?? ''),
|
||||
collect($counter['queues'] ?? [])->pluck('name')->implode(' '),
|
||||
$queueNames,
|
||||
]));
|
||||
|
||||
foreach ($keywords as $keyword) {
|
||||
|
||||
@@ -157,7 +157,23 @@ class SpecialtyModuleService
|
||||
$organization->update(['settings' => $settings]);
|
||||
|
||||
if (data_get($organization->fresh()->settings, 'queue_integration_enabled') && $this->queue->configured()) {
|
||||
$this->queue->syncSpecialtyQueueStubs($ownerRef, $queueStubs);
|
||||
try {
|
||||
$synced = $this->queue->syncSpecialtyQueueStubs($ownerRef, $queueStubs);
|
||||
$settings = $organization->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]);
|
||||
} catch (\Throwable $e) {
|
||||
Log::warning('specialty_module.queue_sync_failed', [
|
||||
'key' => $key,
|
||||
'message' => $e->getMessage(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -185,8 +201,27 @@ 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')
|
||||
&& $this->queue->configured()
|
||||
&& $queues !== []
|
||||
) {
|
||||
try {
|
||||
$queues = $this->queue->deactivateSpecialtyQueueStubs($ownerRef, $queues);
|
||||
} 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;
|
||||
}
|
||||
}
|
||||
|
||||
$provisioning[$key] = array_merge($record, [
|
||||
@@ -246,8 +281,8 @@ class SpecialtyModuleService
|
||||
}
|
||||
|
||||
/**
|
||||
* Care-side queue stubs (branch-aware). Full counter creation lives in Queue admin;
|
||||
* stubs document intended queues and feed embedded panels / future API sync.
|
||||
* 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>>
|
||||
@@ -263,8 +298,16 @@ class SpecialtyModuleService
|
||||
->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,
|
||||
@@ -273,6 +316,12 @@ class SpecialtyModuleService
|
||||
'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}",
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ namespace App\Services\Queue;
|
||||
|
||||
use Illuminate\Http\Client\RequestException;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
class QueueClient
|
||||
{
|
||||
@@ -100,6 +101,79 @@ class QueueClient
|
||||
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 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>
|
||||
*/
|
||||
@@ -124,8 +198,8 @@ class QueueClient
|
||||
}
|
||||
|
||||
/**
|
||||
* Best-effort specialty queue sync. Care integration API is read/console-only today,
|
||||
* so stubs remain Care-side until Queue exposes create-queue for service tokens.
|
||||
* 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>>
|
||||
@@ -137,20 +211,128 @@ class QueueClient
|
||||
}
|
||||
|
||||
try {
|
||||
$existing = collect($this->counters($owner))
|
||||
->flatMap(fn ($c) => $c['queues'] ?? [])
|
||||
->map(fn ($q) => strtolower((string) ($q['name'] ?? '')))
|
||||
->filter()
|
||||
->all();
|
||||
if (! $this->isLinked($owner)) {
|
||||
return $stubs;
|
||||
}
|
||||
} catch (\Throwable) {
|
||||
return $stubs;
|
||||
}
|
||||
|
||||
foreach ($stubs as $i => $stub) {
|
||||
$name = strtolower((string) ($stub['name'] ?? ''));
|
||||
$stubs[$i]['synced'] = $name !== '' && in_array($name, $existing, true);
|
||||
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 branch already exists.
|
||||
if ($e->response?->status() !== 422) {
|
||||
throw $e;
|
||||
}
|
||||
$existing = collect($this->branches($owner))->first(
|
||||
fn ($b) => strcasecmp((string) ($b['name'] ?? ''), $branchName) === 0
|
||||
);
|
||||
if (! $existing) {
|
||||
throw $e;
|
||||
}
|
||||
}
|
||||
|
||||
$queue = $this->ensureQueue($owner, [
|
||||
'name' => $name,
|
||||
'prefix' => $prefix !== '' ? $prefix : 'A',
|
||||
'branch_name' => $branchName,
|
||||
'strategy' => 'fifo',
|
||||
'external_key' => $queueKey,
|
||||
'is_active' => true,
|
||||
]);
|
||||
|
||||
$counter = $this->ensureCounter($owner, [
|
||||
'name' => $name.' counter',
|
||||
'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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -85,6 +85,134 @@ class CareSpecialtyModulesTest extends TestCase
|
||||
$this->assertSame($this->branch->id, $stubs[0]['branch_id']);
|
||||
}
|
||||
|
||||
public function test_activating_with_queue_integration_creates_real_counters(): void
|
||||
{
|
||||
config([
|
||||
'care.queue.api_url' => 'https://queue.test/api/v1',
|
||||
'care.queue.api_key' => 'care-test-key',
|
||||
]);
|
||||
|
||||
$this->organization->update([
|
||||
'settings' => array_merge($this->organization->settings ?? [], [
|
||||
'queue_integration_enabled' => true,
|
||||
]),
|
||||
]);
|
||||
|
||||
$queueUuid = 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa';
|
||||
$counterUuid = 'bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb';
|
||||
|
||||
Http::fake([
|
||||
'https://queue.test/api/v1/integrations/status*' => Http::response([
|
||||
'data' => [
|
||||
'provisioned' => true,
|
||||
'integrations' => ['care' => true],
|
||||
],
|
||||
], 200),
|
||||
'https://queue.test/api/v1/branches*' => Http::sequence()
|
||||
->push(['data' => ['id' => 1, 'name' => 'Main', 'is_active' => true]], 200)
|
||||
->push(['data' => ['id' => 1, 'name' => 'Main', 'is_active' => true]], 200),
|
||||
'https://queue.test/api/v1/queues*' => Http::response([
|
||||
'data' => [
|
||||
'uuid' => $queueUuid,
|
||||
'name' => 'Dentistry',
|
||||
'prefix' => 'DEN',
|
||||
'external_key' => 'care:specialty:dentistry:queue:'.$this->branch->id,
|
||||
],
|
||||
], 201),
|
||||
'https://queue.test/api/v1/counters*' => Http::response([
|
||||
'data' => [
|
||||
'uuid' => $counterUuid,
|
||||
'name' => 'Dentistry counter',
|
||||
'branch' => 'Main',
|
||||
'queues' => [['uuid' => $queueUuid, 'name' => 'Dentistry']],
|
||||
],
|
||||
], 201),
|
||||
]);
|
||||
|
||||
app(SpecialtyModuleService::class)->activate(
|
||||
$this->organization->fresh(),
|
||||
$this->owner->public_id,
|
||||
'dentistry',
|
||||
);
|
||||
|
||||
$this->organization->refresh();
|
||||
$stub = data_get($this->organization->settings, 'specialty_module_provisioning.dentistry.queues.0');
|
||||
$this->assertTrue((bool) ($stub['synced'] ?? false));
|
||||
$this->assertSame($queueUuid, $stub['queue_uuid'] ?? null);
|
||||
$this->assertSame($counterUuid, $stub['counter_uuid'] ?? null);
|
||||
|
||||
Http::assertSent(function ($request) {
|
||||
return $request->method() === 'POST'
|
||||
&& str_contains($request->url(), '/queues')
|
||||
&& ($request['external_key'] ?? null) === 'care:specialty:dentistry:queue:'.$this->branch->id;
|
||||
});
|
||||
}
|
||||
|
||||
public function test_reactivation_is_idempotent_for_queue_external_keys(): void
|
||||
{
|
||||
config([
|
||||
'care.queue.api_url' => 'https://queue.test/api/v1',
|
||||
'care.queue.api_key' => 'care-test-key',
|
||||
]);
|
||||
|
||||
$this->organization->update([
|
||||
'settings' => array_merge($this->organization->settings ?? [], [
|
||||
'queue_integration_enabled' => true,
|
||||
'specialty_module_provisioning' => [
|
||||
'dentistry' => [
|
||||
'active' => false,
|
||||
'queues' => [[
|
||||
'module' => 'dentistry',
|
||||
'branch_id' => $this->branch->id,
|
||||
'branch_name' => 'Main',
|
||||
'name' => 'Dentistry',
|
||||
'prefix' => 'DEN',
|
||||
'active' => false,
|
||||
'synced' => true,
|
||||
'queue_uuid' => 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa',
|
||||
'counter_uuid' => 'bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb',
|
||||
'queue_external_key' => 'care:specialty:dentistry:queue:'.$this->branch->id,
|
||||
'counter_external_key' => 'care:specialty:dentistry:counter:'.$this->branch->id,
|
||||
]],
|
||||
],
|
||||
],
|
||||
]),
|
||||
]);
|
||||
|
||||
Http::fake([
|
||||
'https://queue.test/api/v1/integrations/status*' => Http::response([
|
||||
'data' => ['provisioned' => true, 'integrations' => ['care' => true]],
|
||||
], 200),
|
||||
'https://queue.test/api/v1/branches*' => Http::response([
|
||||
'data' => ['id' => 1, 'name' => 'Main', 'is_active' => true],
|
||||
], 200),
|
||||
'https://queue.test/api/v1/queues*' => Http::response([
|
||||
'data' => [
|
||||
'uuid' => 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa',
|
||||
'name' => 'Dentistry',
|
||||
'external_key' => 'care:specialty:dentistry:queue:'.$this->branch->id,
|
||||
],
|
||||
], 200),
|
||||
'https://queue.test/api/v1/counters*' => Http::response([
|
||||
'data' => [
|
||||
'uuid' => 'bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb',
|
||||
'name' => 'Dentistry counter',
|
||||
],
|
||||
], 200),
|
||||
]);
|
||||
|
||||
app(SpecialtyModuleService::class)->activate(
|
||||
$this->organization->fresh(),
|
||||
$this->owner->public_id,
|
||||
'dentistry',
|
||||
);
|
||||
|
||||
$this->organization->refresh();
|
||||
$stub = data_get($this->organization->settings, 'specialty_module_provisioning.dentistry.queues.0');
|
||||
$this->assertSame('care:specialty:dentistry:queue:'.$this->branch->id, $stub['queue_external_key']);
|
||||
$this->assertSame('aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa', $stub['queue_uuid']);
|
||||
}
|
||||
|
||||
public function test_deactivating_hides_department_without_deleting(): void
|
||||
{
|
||||
Http::fake();
|
||||
|
||||
Reference in New Issue
Block a user