Faster voice announcements, Care/Frontdesk API, and console UI polish.
Deploy Ladill Queue / deploy (push) Successful in 28s

Poll displays every 1.2s with client-side TTS ack; expose service API for sibling apps; redesign tickets, counters, and staff console.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
isaacclad
2026-06-29 21:51:13 +00:00
co-authored by Cursor
parent 051372672b
commit bc6bf0a07c
22 changed files with 813 additions and 140 deletions
@@ -11,13 +11,32 @@ use Illuminate\Http\Request;
trait ScopesApiToAccount
{
protected function isServiceRequest(Request $request): bool
{
return $request->attributes->get('service_caller') !== null;
}
protected function ownerRef(Request $request): string
{
if ($this->isServiceRequest($request)) {
$owner = trim((string) ($request->input('owner') ?? $request->query('owner', '')));
abort_if($owner === '', 422, 'The owner parameter is required.');
return $owner;
}
return (string) $request->user()->public_id;
}
protected function organization(Request $request): Organization
{
if ($this->isServiceRequest($request)) {
$organization = Organization::owned($this->ownerRef($request))->first();
abort_unless($organization, 404, 'Queue organization not found.');
return $organization;
}
$organization = app(OrganizationResolver::class)->resolveForUser($request->user());
abort_unless($organization, 404);
@@ -26,11 +45,19 @@ trait ScopesApiToAccount
protected function member(Request $request): ?Member
{
if ($this->isServiceRequest($request)) {
return null;
}
return app(OrganizationResolver::class)->memberFor($request->user(), $this->organization($request));
}
protected function authorizeAbility(Request $request, string $ability): void
{
if ($this->isServiceRequest($request)) {
return;
}
abort_unless(
app(QmsPermissions::class)->can($this->member($request), $ability),
403,
@@ -0,0 +1,132 @@
<?php
namespace App\Http\Controllers\Api;
use App\Http\Controllers\Api\Concerns\ScopesApiToAccount;
use App\Http\Controllers\Controller;
use App\Models\Counter;
use App\Models\ServiceQueue;
use App\Models\Ticket;
use App\Services\Qms\QueueEngine;
use App\Services\Qms\TicketPresenter;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
class ConsoleApiController extends Controller
{
use ScopesApiToAccount;
public function __construct(
protected QueueEngine $engine,
) {}
public function show(Request $request, Counter $counter): JsonResponse
{
$this->authorizeAbility($request, 'console.use');
$this->authorizeOwner($request, $counter);
$owner = $this->ownerRef($request);
$counter->load(['serviceQueues', 'branch']);
$currentTicket = Ticket::owned($owner)
->where('counter_id', $counter->id)
->whereIn('status', ['called', 'serving', 'on_hold'])
->with('serviceQueue')
->latest('called_at')
->first();
$waitingByQueue = collect($counter->serviceQueues)->mapWithKeys(function (ServiceQueue $queue) {
return [$queue->uuid => array_map(
fn ($t) => TicketPresenter::toArray($t),
$this->engine->waitingTickets($queue, 10),
)];
});
$allQueues = ServiceQueue::owned($owner)
->where('organization_id', $counter->organization_id)
->where('branch_id', $counter->branch_id)
->where('is_active', true)
->orderBy('name')
->get(['uuid', 'name', 'prefix']);
return response()->json([
'data' => [
'counter' => [
'uuid' => $counter->uuid,
'name' => $counter->name,
'status' => $counter->status,
'branch' => $counter->branch?->name,
],
'current_ticket' => $currentTicket ? TicketPresenter::toArray($currentTicket) : null,
'queues' => $counter->serviceQueues->map(fn (ServiceQueue $q) => [
'uuid' => $q->uuid,
'name' => $q->name,
'prefix' => $q->prefix,
]),
'waiting_by_queue' => $waitingByQueue,
'transfer_queues' => $allQueues,
],
]);
}
public function action(Request $request, Counter $counter): JsonResponse
{
$this->authorizeAbility($request, 'console.use');
$this->authorizeOwner($request, $counter);
$actor = $this->ownerRef($request);
$validated = $request->validate([
'action' => ['required', 'string'],
'queue_uuid' => ['nullable', 'uuid'],
'ticket_uuid' => ['nullable', 'uuid'],
'to_queue_uuid' => ['nullable', 'uuid'],
'reason' => ['nullable', 'string', 'max:500'],
]);
$result = match ($validated['action']) {
'call_next' => $this->handleCallNext($counter, $validated['queue_uuid'] ?? '', $actor),
'available' => tap($counter)->update(['status' => 'available']),
'offline' => tap($counter)->update(['status' => 'offline']),
default => $this->handleTicketAction($validated, $actor),
};
if ($result instanceof Ticket) {
return response()->json(['data' => TicketPresenter::toArray($result->load(['serviceQueue', 'counter']))]);
}
return $this->show($request, $counter->fresh());
}
protected function handleCallNext(Counter $counter, string $queueUuid, string $actor): ?Ticket
{
abort_if($queueUuid === '', 422, 'queue_uuid is required.');
$queue = ServiceQueue::where('uuid', $queueUuid)->firstOrFail();
return $this->engine->callNext($queue, $counter, $actor);
}
protected function handleTicketAction(array $validated, string $actor): Ticket|Counter|null
{
$ticketUuid = $validated['ticket_uuid'] ?? '';
abort_if($ticketUuid === '', 422, 'ticket_uuid is required for this action.');
$ticket = Ticket::where('uuid', $ticketUuid)->firstOrFail();
return match ($validated['action']) {
'recall' => $this->engine->recall($ticket, $actor),
'start' => $this->engine->startServing($ticket, $actor),
'hold' => $this->engine->hold($ticket, $actor),
'skip' => $this->engine->skip($ticket, $actor),
'complete' => $this->engine->completeTicket($ticket, $actor),
'no_show' => $this->engine->markNoShow($ticket, $actor),
'transfer' => $this->engine->transfer(
$ticket,
ServiceQueue::where('uuid', $validated['to_queue_uuid'] ?? '')->firstOrFail(),
null,
$validated['reason'] ?? null,
$actor,
),
default => abort(422, 'Unknown action.'),
};
}
}
@@ -0,0 +1,156 @@
<?php
namespace App\Http\Controllers\Api;
use App\Http\Controllers\Api\Concerns\ScopesApiToAccount;
use App\Http\Controllers\Controller;
use App\Models\Branch;
use App\Models\Department;
use App\Models\Organization;
use App\Services\Qms\AuditLogger;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Str;
class IntegrationController extends Controller
{
use ScopesApiToAccount;
public function status(Request $request): JsonResponse
{
$owner = $this->ownerRef($request);
$organization = Organization::owned($owner)->first();
if (! $organization) {
return response()->json([
'data' => [
'provisioned' => false,
'onboarded' => false,
'integrations' => ['care' => false, 'frontdesk' => false],
],
]);
}
return response()->json([
'data' => [
'provisioned' => true,
'onboarded' => (bool) data_get($organization->settings, 'onboarded', false),
'organization_name' => $organization->name,
'integrations' => [
'care' => (bool) data_get($organization->settings, 'integrations.care_enabled', false),
'frontdesk' => (bool) data_get($organization->settings, 'integrations.frontdesk_enabled', false),
],
],
]);
}
public function provision(Request $request): JsonResponse
{
$owner = $this->ownerRef($request);
$caller = (string) $request->attributes->get('service_caller', '');
$validated = $request->validate([
'organization_name' => ['nullable', 'string', 'max:255'],
'branch_name' => ['nullable', 'string', 'max:255'],
'timezone' => ['nullable', 'timezone'],
'industry' => ['nullable', 'string'],
]);
$organization = Organization::owned($owner)->first();
$created = false;
if (! $organization) {
$created = true;
$name = $validated['organization_name'] ?? 'Service queues';
$organization = Organization::create([
'owner_ref' => $owner,
'name' => $name,
'slug' => Str::slug($name).'-'.substr($owner, 0, 6),
'timezone' => $validated['timezone'] ?? config('app.timezone', 'UTC'),
'settings' => [
'onboarded' => true,
'industry' => $validated['industry'] ?? ($caller === 'care' ? 'healthcare' : 'visitor'),
'appointment_mode' => 'hybrid',
'integrations' => [
'care_enabled' => false,
'frontdesk_enabled' => false,
],
],
]);
$branch = Branch::create([
'owner_ref' => $owner,
'organization_id' => $organization->id,
'name' => $validated['branch_name'] ?? 'Main branch',
'is_active' => true,
]);
Department::create([
'owner_ref' => $owner,
'branch_id' => $branch->id,
'name' => 'Reception',
'type' => 'reception',
'is_active' => true,
]);
AuditLogger::record($owner, 'organization.created', $organization->id, $owner, Organization::class, $organization->id);
}
return response()->json([
'data' => [
'provisioned' => true,
'organization_name' => $organization->fresh()->name,
'integrations' => [
'care' => (bool) data_get($organization->fresh()->settings, 'integrations.care_enabled', false),
'frontdesk' => (bool) data_get($organization->fresh()->settings, 'integrations.frontdesk_enabled', false),
],
],
], $created ? 201 : 200);
}
public function update(Request $request): JsonResponse
{
$validated = $request->validate([
'care_enabled' => ['nullable', 'boolean'],
'frontdesk_enabled' => ['nullable', 'boolean'],
]);
$organization = $this->organization($request);
$caller = (string) $request->attributes->get('service_caller', '');
$settings = $organization->settings ?? [];
$integrations = $settings['integrations'] ?? [];
if (array_key_exists('care_enabled', $validated) && in_array($caller, ['care', 'crm'], true)) {
$integrations['care_enabled'] = (bool) $validated['care_enabled'];
}
if (array_key_exists('frontdesk_enabled', $validated) && in_array($caller, ['frontdesk', 'crm'], true)) {
$integrations['frontdesk_enabled'] = (bool) $validated['frontdesk_enabled'];
}
$settings['integrations'] = $integrations;
$organization->update(['settings' => $settings]);
return response()->json([
'data' => [
'integrations' => [
'care' => (bool) ($integrations['care_enabled'] ?? false),
'frontdesk' => (bool) ($integrations['frontdesk_enabled'] ?? false),
],
],
]);
}
protected function syncIntegrationFlag(Organization $organization, string $caller, bool $enabled): void
{
if (! in_array($caller, ['care', 'frontdesk'], true)) {
return;
}
$settings = $organization->settings ?? [];
$integrations = $settings['integrations'] ?? [];
$key = $caller.'_enabled';
$integrations[$key] = $enabled;
$settings['integrations'] = $integrations;
$organization->update(['settings' => $settings]);
}
}
+20 -1
View File
@@ -36,9 +36,28 @@ class CounterController extends Controller
{
$this->authorizeAbility($request, 'counters.view');
$this->authorizeOwner($request, $counter);
$owner = $this->ownerRef($request);
$counter->load(['branch', 'serviceQueues']);
return view('qms.counters.show', compact('counter'));
$currentTicket = \App\Models\Ticket::owned($owner)
->where('counter_id', $counter->id)
->whereIn('status', ['called', 'serving', 'on_hold'])
->with('serviceQueue')
->latest('called_at')
->first();
$waitingCounts = $counter->serviceQueues->mapWithKeys(function (ServiceQueue $queue) use ($owner) {
$count = \App\Models\Ticket::owned($owner)
->where('service_queue_id', $queue->id)
->where('status', 'waiting')
->count();
return [$queue->id => $count];
});
$canManage = app(\App\Services\Qms\QmsPermissions::class)->can($this->member($request), 'counters.manage');
return view('qms.counters.show', compact('counter', 'currentTicket', 'waitingCounts', 'canManage'));
}
public function create(Request $request): View
@@ -3,6 +3,7 @@
namespace App\Http\Controllers\Qms;
use App\Http\Controllers\Controller;
use App\Models\VoiceAnnouncement;
use App\Services\Qms\DisplayService;
use App\Services\Qms\VoiceAnnouncementService;
use Illuminate\Http\JsonResponse;
@@ -23,6 +24,8 @@ class DisplayPublicController extends Controller
return view('qms.display.public', [
'screen' => $screen,
'dataUrl' => route('qms.display.data', $token),
'playedUrl' => route('qms.display.announcement.played', ['token' => $token, 'announcement' => '__ID__']),
'pollMs' => config('qms.display_poll_ms', 1200),
]);
}
@@ -31,17 +34,16 @@ class DisplayPublicController extends Controller
$screen = $this->displays->findByToken($token) ?? abort(404);
$this->displays->touch($screen);
$payload = $this->displays->payload($screen);
return response()->json($this->displays->payload($screen));
}
foreach ($payload['announcements'] as $announcement) {
if (isset($announcement['id'])) {
$model = \App\Models\VoiceAnnouncement::find($announcement['id']);
if ($model) {
$this->voice->markPlayed($model);
}
}
}
public function played(string $token, VoiceAnnouncement $announcement): JsonResponse
{
$screen = $this->displays->findByToken($token) ?? abort(404);
abort_unless($announcement->branch_id === $screen->branch_id, 404);
return response()->json($payload);
$this->voice->markPlayed($announcement);
return response()->json(['ok' => true]);
}
}
@@ -46,12 +46,22 @@ class SettingsController extends Controller
'appointment_mode' => ['required', 'string', 'in:'.implode(',', array_keys(config('qms.appointment_modes')))],
'industry' => ['nullable', 'string'],
'notifications_enabled' => ['boolean'],
'care_integration_enabled' => ['nullable', 'boolean'],
'frontdesk_integration_enabled' => ['nullable', 'boolean'],
]);
$settings = $organization->settings ?? [];
$settings['appointment_mode'] = $validated['appointment_mode'];
$settings['industry'] = $validated['industry'] ?? $settings['industry'] ?? null;
$settings['notifications_enabled'] = $request->boolean('notifications_enabled', true);
$integrations = $settings['integrations'] ?? [];
if ($request->has('care_integration_enabled')) {
$integrations['care_enabled'] = $request->boolean('care_integration_enabled');
}
if ($request->has('frontdesk_integration_enabled')) {
$integrations['frontdesk_enabled'] = $request->boolean('frontdesk_integration_enabled');
}
$settings['integrations'] = $integrations;
$organization->update([
'name' => $validated['name'],
@@ -29,9 +29,11 @@ class TicketController extends Controller
$tickets = Ticket::owned($owner)
->where('organization_id', $organization->id)
->with(['serviceQueue', 'counter'])
->when($request->query('queue'), fn ($q, $uuid) => $q->whereHas('serviceQueue', fn ($sq) => $sq->where('uuid', $uuid)))
->when($request->query('status'), fn ($q, $s) => $q->where('status', $s))
->latest('issued_at')
->paginate(30);
->paginate(30)
->withQueryString();
$queues = ServiceQueue::owned($owner)->where('organization_id', $organization->id)->orderBy('name')->get();