Fix onboarding 500 by replacing leftover Frontdesk route references.
Deploy Ladill Queue / deploy (push) Successful in 38s
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:
@@ -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(),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
return [
|
||||||
|
'product' => env('AFIA_PRODUCT', 'queue'),
|
||||||
|
'enabled' => (bool) env('AFIA_ENABLED', true),
|
||||||
|
'provider' => env('AFIA_PROVIDER', 'openai'),
|
||||||
|
'model' => env('AFIA_MODEL', 'gpt-4o-mini'),
|
||||||
|
'api_key' => env('AFIA_API_KEY', env('OPENAI_API_KEY')),
|
||||||
|
'platform_api_url' => env('AFIA_PLATFORM_API_URL', env('IDENTITY_API_URL', 'https://ladill.com/api')),
|
||||||
|
'platform_api_key' => env('AFIA_PLATFORM_API_KEY', env('IDENTITY_API_KEY_QUEUE')),
|
||||||
|
];
|
||||||
@@ -40,10 +40,10 @@
|
|||||||
: ($navUser?->avatar_url ?? null);
|
: ($navUser?->avatar_url ?? null);
|
||||||
@endphp
|
@endphp
|
||||||
@include('partials.mobile-bottom-nav', [
|
@include('partials.mobile-bottom-nav', [
|
||||||
'homeUrl' => route('frontdesk.dashboard'),
|
'homeUrl' => route('qms.dashboard'),
|
||||||
'homeActive' => request()->routeIs('frontdesk.dashboard'),
|
'homeActive' => request()->routeIs('qms.dashboard'),
|
||||||
'searchUrl' => route('frontdesk.visitors.index'),
|
'searchUrl' => route('qms.tickets.index'),
|
||||||
'searchActive' => request()->routeIs('frontdesk.visitors.*'),
|
'searchActive' => request()->routeIs('qms.tickets.*'),
|
||||||
'notificationsUrl' => route('notifications.index'),
|
'notificationsUrl' => route('notifications.index'),
|
||||||
'notificationsActive' => request()->routeIs('notifications.*'),
|
'notificationsActive' => request()->routeIs('notifications.*'),
|
||||||
'unreadUrl' => route('notifications.unread'),
|
'unreadUrl' => route('notifications.unread'),
|
||||||
|
|||||||
@@ -1,15 +1,15 @@
|
|||||||
@php
|
@php
|
||||||
$afiaGreeting = "Hi, I'm Afia 👋 Ask me about visitor check-in, kiosks, hosts, badges, devices, or setting up your reception desk…";
|
$afiaGreeting = "Hi, I'm Afia 👋 Ask me about queues, tickets, counters, displays, appointments, or setting up your branches…";
|
||||||
$afiaSuggestions = [
|
$afiaSuggestions = [
|
||||||
'How do I set up a visitor kiosk?',
|
'How do I create a service queue?',
|
||||||
'How do I check a visitor in?',
|
'How do I issue tickets from the kiosk?',
|
||||||
'Where do I add reception desks?',
|
'Where do I set up counters and consoles?',
|
||||||
'How do hosts approve visits?',
|
'How do digital displays work?',
|
||||||
];
|
];
|
||||||
@endphp
|
@endphp
|
||||||
{{-- Afia — Ladill AI assistant slide-over. Opened via $dispatch('afia-open'). --}}
|
{{-- Afia — Ladill AI assistant slide-over. Opened via $dispatch('afia-open'). --}}
|
||||||
<div x-data="afia({
|
<div x-data="afia({
|
||||||
chatUrl: '{{ route('frontdesk.ai.chat') }}',
|
chatUrl: '{{ route('qms.ai.chat') }}',
|
||||||
csrf: '{{ csrf_token() }}',
|
csrf: '{{ csrf_token() }}',
|
||||||
greeting: @js($afiaGreeting),
|
greeting: @js($afiaGreeting),
|
||||||
suggestions: @js($afiaSuggestions),
|
suggestions: @js($afiaSuggestions),
|
||||||
@@ -48,7 +48,7 @@
|
|||||||
</span>
|
</span>
|
||||||
<div class="leading-tight">
|
<div class="leading-tight">
|
||||||
<p class="text-sm font-semibold text-slate-900">Afia</p>
|
<p class="text-sm font-semibold text-slate-900">Afia</p>
|
||||||
<p class="text-[11px] text-slate-400">Frontdesk assistant</p>
|
<p class="text-[11px] text-slate-400">Queue assistant</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex items-center gap-1">
|
<div class="flex items-center gap-1">
|
||||||
|
|||||||
@@ -12,14 +12,14 @@
|
|||||||
{{-- Mobile: app name --}}
|
{{-- Mobile: app name --}}
|
||||||
@include('partials.mobile-topbar-title')
|
@include('partials.mobile-topbar-title')
|
||||||
|
|
||||||
{{-- Desktop: search (visitors) --}}
|
{{-- Desktop: search (tickets) --}}
|
||||||
<form method="GET" action="{{ route('frontdesk.visitors.index') }}"
|
<form method="GET" action="{{ route('qms.tickets.index') }}"
|
||||||
class="relative hidden w-full max-w-md lg:block"
|
class="relative hidden w-full max-w-md lg:block"
|
||||||
x-data
|
x-data
|
||||||
@keydown.window="if ($event.key === '/' && !['INPUT','TEXTAREA','SELECT'].includes($event.target.tagName)) { $event.preventDefault(); $refs.search.focus(); }">
|
@keydown.window="if ($event.key === '/' && !['INPUT','TEXTAREA','SELECT'].includes($event.target.tagName)) { $event.preventDefault(); $refs.search.focus(); }">
|
||||||
<svg class="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-slate-400" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="m21 21-5.197-5.197m0 0A7.5 7.5 0 1 0 5.196 5.196a7.5 7.5 0 0 0 10.607 10.607Z"/></svg>
|
<svg class="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-slate-400" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="m21 21-5.197-5.197m0 0A7.5 7.5 0 1 0 5.196 5.196a7.5 7.5 0 0 0 10.607 10.607Z"/></svg>
|
||||||
<input x-ref="search" type="search" name="q" value="{{ request('q') }}"
|
<input x-ref="search" type="search" name="q" value="{{ request('q') }}"
|
||||||
placeholder="Search visitors…"
|
placeholder="Search tickets…"
|
||||||
class="w-full rounded-xl border border-slate-200 bg-slate-50 py-2 pl-9 pr-10 text-sm text-slate-700 placeholder-slate-400 transition focus:border-indigo-300 focus:bg-white focus:ring-2 focus:ring-indigo-100 focus:outline-none">
|
class="w-full rounded-xl border border-slate-200 bg-slate-50 py-2 pl-9 pr-10 text-sm text-slate-700 placeholder-slate-400 transition focus:border-indigo-300 focus:bg-white focus:ring-2 focus:ring-indigo-100 focus:outline-none">
|
||||||
<kbd class="pointer-events-none absolute right-3 top-1/2 hidden -translate-y-1/2 rounded-md border border-slate-200 bg-white px-1.5 py-0.5 text-[10px] font-medium text-slate-400 sm:inline-block">/</kbd>
|
<kbd class="pointer-events-none absolute right-3 top-1/2 hidden -translate-y-1/2 rounded-md border border-slate-200 bg-white px-1.5 py-0.5 text-[10px] font-medium text-slate-400 sm:inline-block">/</kbd>
|
||||||
</form>
|
</form>
|
||||||
|
|||||||
@@ -6,7 +6,7 @@
|
|||||||
<p class="mt-1 text-sm text-slate-500">Add funds to your Ladill wallet to continue.</p>
|
<p class="mt-1 text-sm text-slate-500">Add funds to your Ladill wallet to continue.</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<form action="{{ route('frontdesk.wallet') }}" method="GET" class="p-5 sm:p-6">
|
<form action="{{ route('qms.wallet') }}" method="GET" class="p-5 sm:p-6">
|
||||||
@csrf
|
@csrf
|
||||||
<input type="hidden" name="return_url" value="{{ $returnUrl ?? url()->current() }}">
|
<input type="hidden" name="return_url" value="{{ $returnUrl ?? url()->current() }}">
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
use App\Http\Controllers\Auth\SsoLoginController;
|
use App\Http\Controllers\Auth\SsoLoginController;
|
||||||
use App\Http\Controllers\NotificationController;
|
use App\Http\Controllers\NotificationController;
|
||||||
|
use App\Http\Controllers\Qms\AiController;
|
||||||
use App\Http\Controllers\Qms\AnalyticsController;
|
use App\Http\Controllers\Qms\AnalyticsController;
|
||||||
use App\Http\Controllers\Qms\AppointmentController;
|
use App\Http\Controllers\Qms\AppointmentController;
|
||||||
use App\Http\Controllers\Qms\AuditLogController;
|
use App\Http\Controllers\Qms\AuditLogController;
|
||||||
@@ -72,6 +73,8 @@ Route::middleware(['auth', 'platform.session'])->group(function () {
|
|||||||
|
|
||||||
Route::get('/onboarding', [OnboardingController::class, 'show'])->name('qms.onboarding.show');
|
Route::get('/onboarding', [OnboardingController::class, 'show'])->name('qms.onboarding.show');
|
||||||
Route::post('/onboarding', [OnboardingController::class, 'store'])->name('qms.onboarding.store');
|
Route::post('/onboarding', [OnboardingController::class, 'store'])->name('qms.onboarding.store');
|
||||||
|
Route::post('/ai/chat', [AiController::class, 'chat'])->middleware('throttle:30,1')->name('qms.ai.chat');
|
||||||
|
Route::get('/wallet', fn () => redirect()->away(ladill_account_url('/wallet')))->name('qms.wallet');
|
||||||
|
|
||||||
Route::middleware(['qms.setup'])->group(function () {
|
Route::middleware(['qms.setup'])->group(function () {
|
||||||
Route::get('/dashboard', [DashboardController::class, 'index'])->name('qms.dashboard');
|
Route::get('/dashboard', [DashboardController::class, 'index'])->name('qms.dashboard');
|
||||||
|
|||||||
Reference in New Issue
Block a user