Fix onboarding 500 by replacing leftover Frontdesk route references.
Deploy Ladill Queue / deploy (push) Successful in 38s

Point layout partials at qms routes and add wallet/afia endpoints so shared shell renders on queue.ladill.com.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
isaacclad
2026-06-29 20:47:42 +00:00
co-authored by Cursor
parent cca98eefd2
commit 6bb181bbc1
8 changed files with 269 additions and 15 deletions
+63
View File
@@ -0,0 +1,63 @@
<?php
namespace App\Http\Controllers\Qms;
use App\Http\Controllers\Controller;
use App\Http\Controllers\Qms\Concerns\ScopesToAccount;
use App\Models\ServiceQueue;
use App\Models\Ticket;
use App\Services\Afia\AfiaService;
use App\Services\Qms\OrganizationResolver;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
class AiController extends Controller
{
use ScopesToAccount;
public function chat(Request $request, AfiaService $afia): JsonResponse
{
$validated = $request->validate([
'message' => ['required', 'string', 'max:2000'],
'history' => ['nullable', 'array', 'max:20'],
'history.*.role' => ['nullable', 'string'],
'history.*.text' => ['nullable', 'string'],
]);
if (! $afia->enabled()) {
return response()->json(['message' => 'Afia is not available right now.'], 503);
}
try {
$reply = $afia->chat(trim($validated['message']), $validated['history'] ?? [], $this->context($request));
} catch (\Throwable $e) {
report($e);
return response()->json(['message' => 'Afia could not respond right now. Please try again.'], 502);
}
return response()->json(['reply' => $reply]);
}
/** @return array<string, mixed> */
private function context(Request $request): array
{
$organization = app(OrganizationResolver::class)->resolveForUser($request->user());
if ($organization === null) {
return ['signed_in' => 'yes', 'setup' => 'onboarding_pending'];
}
$owner = $this->ownerRef($request);
$ticketQuery = Ticket::owned($owner)->where('organization_id', $organization->id);
return [
'signed_in' => 'yes',
'organization' => $organization->name,
'active_queues' => ServiceQueue::owned($owner)->where('organization_id', $organization->id)->where('is_active', true)->count(),
'tickets_waiting' => (clone $ticketQuery)->where('status', 'waiting')->count(),
'tickets_serving' => (clone $ticketQuery)->whereIn('status', ['called', 'serving'])->count(),
'tickets_completed_today' => (clone $ticketQuery)->where('status', 'completed')->whereDate('completed_at', today())->count(),
];
}
}
+177
View File
@@ -0,0 +1,177 @@
<?php
namespace App\Services\Afia;
use Illuminate\Support\Facades\Http;
use RuntimeException;
/**
* Afia Ladill in-app AI assistant scoped to Queue management.
*/
class AfiaService
{
public function enabled(): bool
{
if (! (bool) config('afia.enabled', true)) {
return false;
}
return $this->hasLocalKey() || $this->hasPlatformRelay();
}
/**
* @param array<int, array{role: string, text: string}> $history
* @param array<string, mixed> $context
*/
public function chat(string $message, array $history, array $context): string
{
if (! $this->enabled()) {
throw new RuntimeException('Afia is not configured.');
}
if ($this->hasLocalKey()) {
return $this->chatLocally($message, $history, $context);
}
return $this->chatViaPlatform($message, $history, $context);
}
/**
* @param array<int, array{role: string, text: string}> $history
* @param array<string, mixed> $context
*/
private function chatLocally(string $message, array $history, array $context): string
{
$provider = (string) config('afia.provider', 'openai');
$model = (string) config('afia.model', 'gpt-4o-mini');
$apiKey = (string) config('afia.api_key');
$messages = [['role' => 'system', 'content' => $this->systemPrompt($context)]];
foreach (array_slice($history, -8) as $turn) {
$role = ($turn['role'] ?? 'user') === 'assistant' ? 'assistant' : 'user';
$text = trim((string) ($turn['text'] ?? ''));
if ($text !== '') {
$messages[] = ['role' => $role, 'content' => $text];
}
}
$messages[] = ['role' => 'user', 'content' => $message];
return $provider === 'anthropic'
? $this->viaAnthropic($model, $apiKey, $messages)
: $this->viaOpenAi($model, $apiKey, $messages);
}
/**
* @param array<int, array{role: string, text: string}> $history
* @param array<string, mixed> $context
*/
private function chatViaPlatform(string $message, array $history, array $context): string
{
$base = rtrim((string) config('afia.platform_api_url', ''), '/');
$token = (string) config('afia.platform_api_key', '');
$res = Http::withToken($token)->acceptJson()->timeout(50)->post($base.'/afia/chat', [
'product' => (string) config('afia.product', 'queue'),
'message' => $message,
'history' => $history,
'system_prompt' => $this->systemPrompt($context),
]);
if ($res->status() === 503) {
throw new RuntimeException('Afia is not configured on the platform.');
}
if ($res->failed()) {
throw new RuntimeException('Platform Afia relay failed: '.$res->status());
}
$reply = trim((string) $res->json('reply', ''));
if ($reply === '') {
throw new RuntimeException('Platform Afia relay returned an empty response.');
}
return $reply;
}
private function hasLocalKey(): bool
{
return (string) config('afia.api_key', '') !== '';
}
private function hasPlatformRelay(): bool
{
return rtrim((string) config('afia.platform_api_url', ''), '/') !== ''
&& (string) config('afia.platform_api_key', '') !== '';
}
private function viaOpenAi(string $model, string $apiKey, array $messages): string
{
$res = Http::withToken($apiKey)->acceptJson()->timeout(45)
->post('https://api.openai.com/v1/chat/completions', [
'model' => $model,
'temperature' => 0.3,
'max_tokens' => 600,
'messages' => $messages,
]);
if ($res->failed()) {
throw new RuntimeException('OpenAI request failed: '.$res->status());
}
return trim((string) $res->json('choices.0.message.content', ''));
}
private function viaAnthropic(string $model, string $apiKey, array $messages): string
{
$system = $messages[0]['content'] ?? '';
$turns = array_values(array_filter($messages, fn ($m) => $m['role'] !== 'system'));
$res = Http::withHeaders([
'x-api-key' => $apiKey,
'anthropic-version' => '2023-06-01',
])->acceptJson()->timeout(45)->post('https://api.anthropic.com/v1/messages', [
'model' => $model,
'max_tokens' => 600,
'system' => $system,
'messages' => array_map(fn ($m) => ['role' => $m['role'], 'content' => $m['content']], $turns),
]);
if ($res->failed()) {
throw new RuntimeException('Anthropic request failed: '.$res->status());
}
return trim((string) $res->json('content.0.text', ''));
}
/** @param array<string, mixed> $context */
private function systemPrompt(array $context): string
{
$ctx = collect($context)->map(fn ($v, $k) => "- {$k}: {$v}")->implode("\n");
return <<<PROMPT
You are Afia, the assistant inside Ladill Queue (queue.ladill.com).
Help staff and admins manage customer flow queues, tickets, counters, displays, appointments, and workflows. Be concise, friendly, and actionable: short numbered steps and name the screen or section to use.
What Ladill Queue does and where things live:
- Dashboard: live queue stats, waiting tickets, and service activity.
- Live board: real-time view of queues and tickets across branches.
- Queues: configure service queues, priorities, and operating hours.
- Tickets: issue, search, print, and track customer tickets.
- Counters & console: staff call next, transfer, complete, or recall tickets.
- Displays: digital screens and voice announcements for waiting areas.
- Devices: register kiosk tablets for self-service ticket issuance.
- Appointments: scheduled visits with check-in and reminders.
- Workflows & rules: multi-step service paths and routing/capacity rules.
- Analytics & reports: wait times, throughput, and service sessions.
- Admin: branches, departments, team members, settings, and audit log.
Rules:
- Only answer questions about Ladill Queue tickets, queues, counters, displays, appointments, and setup.
- If asked about CRM, payments, visitor management, or email, briefly say those live in other Ladill apps and suggest the app launcher.
- Never invent counts or ticket numbers use the user context below or tell them where to check.
Current user context:
{$ctx}
PROMPT;
}
}