diff --git a/.env.example b/.env.example index 3c01279..0abfc39 100644 --- a/.env.example +++ b/.env.example @@ -29,6 +29,11 @@ BILLING_API_KEY_CARE= IDENTITY_API_URL=https://ladill.com/api IDENTITY_API_KEY_CARE= +# Afia in-app assistant (uses platform relay when AFIA_API_KEY is empty) +AFIA_ENABLED=true +AFIA_PRODUCT=care +AFIA_PLATFORM_API_KEY= + # Platform events webhook (user/org lifecycle from the monolith) SERVICE_EVENTS_INBOUND_SECRET= diff --git a/app/Http/Controllers/Care/AiController.php b/app/Http/Controllers/Care/AiController.php new file mode 100644 index 0000000..b27cb7e --- /dev/null +++ b/app/Http/Controllers/Care/AiController.php @@ -0,0 +1,70 @@ +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 */ + private function context(Request $request): array + { + $owner = $this->ownerRef($request); + $organization = $this->organization($request); + + return [ + 'signed_in' => 'yes', + 'patients_total' => Patient::owned($owner)->where('organization_id', $organization->id)->count(), + 'appointments_today' => Appointment::owned($owner) + ->where('organization_id', $organization->id) + ->whereDate('scheduled_at', today()) + ->count(), + 'queue_waiting' => Appointment::owned($owner) + ->where('organization_id', $organization->id) + ->where('status', Appointment::STATUS_WAITING) + ->count(), + 'in_consultation' => Appointment::owned($owner) + ->where('organization_id', $organization->id) + ->where('status', Appointment::STATUS_IN_CONSULTATION) + ->count(), + 'consultations_completed_today' => Consultation::owned($owner) + ->whereHas('visit', fn ($q) => $q->where('organization_id', $organization->id)) + ->where('status', Consultation::STATUS_COMPLETED) + ->whereDate('completed_at', today()) + ->count(), + ]; + } +} diff --git a/app/Http/Controllers/NotificationController.php b/app/Http/Controllers/NotificationController.php new file mode 100644 index 0000000..d438a3a --- /dev/null +++ b/app/Http/Controllers/NotificationController.php @@ -0,0 +1,64 @@ +user() + ->notifications() + ->latest() + ->paginate(20); + + return view('notifications.index', compact('notifications')); + } + + public function unread(Request $request): JsonResponse + { + $notifications = $request->user() + ->unreadNotifications() + ->latest() + ->take(10) + ->get() + ->map(fn ($n) => [ + 'id' => $n->id, + 'type' => class_basename($n->type), + 'title' => $n->data['title'] ?? 'Notification', + 'message' => $n->data['message'] ?? '', + 'icon' => $n->data['icon'] ?? 'bell', + 'url' => $n->data['url'] ?? null, + 'created_at' => $n->created_at->diffForHumans(), + ]); + + return response()->json([ + 'notifications' => $notifications, + 'unread_count' => $request->user()->unreadNotifications()->count(), + ]); + } + + public function markAsRead(Request $request, string $id): JsonResponse + { + $notification = $request->user() + ->notifications() + ->where('id', $id) + ->first(); + + if ($notification) { + $notification->markAsRead(); + } + + return response()->json(['success' => true]); + } + + public function markAllAsRead(Request $request): JsonResponse + { + $request->user()->unreadNotifications->markAsRead(); + + return response()->json(['success' => true]); + } +} diff --git a/app/Services/Afia/AfiaService.php b/app/Services/Afia/AfiaService.php new file mode 100644 index 0000000..2361524 --- /dev/null +++ b/app/Services/Afia/AfiaService.php @@ -0,0 +1,175 @@ +hasLocalKey() || $this->hasPlatformRelay(); + } + + /** + * @param array $history + * @param array $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 $history + * @param array $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 $history + * @param array $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', 'care'), + '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 $context */ + private function systemPrompt(array $context): string + { + $ctx = collect($context)->map(fn ($v, $k) => "- {$k}: {$v}")->implode("\n"); + + return << env('AFIA_PRODUCT', 'care'), + '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_CARE')), +]; diff --git a/database/migrations/2026_06_23_120000_create_notifications_table.php b/database/migrations/2026_06_23_120000_create_notifications_table.php new file mode 100644 index 0000000..52e3b00 --- /dev/null +++ b/database/migrations/2026_06_23_120000_create_notifications_table.php @@ -0,0 +1,25 @@ +uuid('id')->primary(); + $table->string('type'); + $table->morphs('notifiable'); + $table->text('data'); + $table->timestamp('read_at')->nullable(); + $table->timestamps(); + }); + } + + public function down(): void + { + Schema::dropIfExists('notifications'); + } +}; diff --git a/resources/views/components/app-layout.blade.php b/resources/views/components/app-layout.blade.php index 1560677..981178b 100644 --- a/resources/views/components/app-layout.blade.php +++ b/resources/views/components/app-layout.blade.php @@ -44,9 +44,9 @@ 'homeActive' => request()->routeIs('care.dashboard'), 'searchUrl' => route('care.patients.index'), 'searchActive' => request()->routeIs('care.patients.*'), - 'notificationsUrl' => route('care.dashboard'), - 'notificationsActive' => false, - 'unreadUrl' => route('care.dashboard'), + 'notificationsUrl' => route('notifications.index'), + 'notificationsActive' => request()->routeIs('notifications.*'), + 'unreadUrl' => route('notifications.unread'), 'profileActive' => false, 'profileName' => $navUser?->name ?? '', 'profileSubtitle' => $navUser?->email ?? '', @@ -55,6 +55,7 @@ 'initials' => $navInitials !== '' ? $navInitials : 'U', ]) @endauth + @include('partials.afia') @include('partials.wallet-topup-modal', ['openOnLoad' => (bool) session('topup_required')]) diff --git a/resources/views/partials/afia.blade.php b/resources/views/partials/afia.blade.php index 265fdb5..04253c6 100644 --- a/resources/views/partials/afia.blade.php +++ b/resources/views/partials/afia.blade.php @@ -1,15 +1,15 @@ @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 patients, appointments, the queue, consultations, lab requests, prescriptions, or billing…"; $afiaSuggestions = [ - 'How do I set up a visitor kiosk?', - 'How do I check a visitor in?', - 'Where do I add reception desks?', - 'How do hosts approve visits?', + 'How do I register a new patient?', + 'How does the patient queue work?', + 'How do I start a consultation?', + 'How do I request lab investigations?', ]; @endphp {{-- Afia — Ladill AI assistant slide-over. Opened via $dispatch('afia-open'). --}}

Afia

-

Frontdesk assistant

+

Care assistant

diff --git a/resources/views/partials/mobile-topbar-title.blade.php b/resources/views/partials/mobile-topbar-title.blade.php index ff10b4a..7a3fe86 100644 --- a/resources/views/partials/mobile-topbar-title.blade.php +++ b/resources/views/partials/mobile-topbar-title.blade.php @@ -1,5 +1,5 @@ @php - $title = $mobileTopbarTitle ?? 'Ladill'; + $title = $heading ?? $mobileTopbarTitle ?? 'Ladill Care'; @endphp

{{ $title }}

diff --git a/resources/views/partials/topbar-desktop-widgets.blade.php b/resources/views/partials/topbar-desktop-widgets.blade.php index 3be9422..5e00826 100644 --- a/resources/views/partials/topbar-desktop-widgets.blade.php +++ b/resources/views/partials/topbar-desktop-widgets.blade.php @@ -13,6 +13,10 @@ $showUserHeader = $showUser ?? true; @endphp +@includeIf('partials.afia-button', ['compact' => true]) + +@includeIf('partials.notification-dropdown') + @include('partials.launcher') @includeIf('partials.topbar-widgets-mid') diff --git a/resources/views/partials/topbar.blade.php b/resources/views/partials/topbar.blade.php index 7df329f..c1cbcb1 100644 --- a/resources/views/partials/topbar.blade.php +++ b/resources/views/partials/topbar.blade.php @@ -9,9 +9,7 @@ - @include('partials.mobile-topbar-title') - -

{{ $heading ?? 'Ladill Care' }}

+ @include('partials.mobile-topbar-title', ['heading' => $heading ?? null]) @if (auth()->check() && app(\App\Services\Care\CarePermissions::class)->can( app(\App\Services\Care\OrganizationResolver::class)->memberFor(auth()->user()), 'patients.view')) @@ -22,7 +20,8 @@ + 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"> + @endif
diff --git a/routes/web.php b/routes/web.php index 04f3928..552b040 100644 --- a/routes/web.php +++ b/routes/web.php @@ -1,6 +1,8 @@ auth()->check() ? redirect()->route('care.dashboard') : view('auth.signed-out'))->name('care.signed-out'); Route::middleware(['auth', 'platform.session'])->group(function () { + Route::get('/notifications', [NotificationController::class, 'index'])->name('notifications.index'); + Route::get('/notifications/unread', [NotificationController::class, 'unread'])->name('notifications.unread'); + Route::post('/notifications/{id}/read', [NotificationController::class, 'markAsRead'])->name('notifications.mark-read'); + Route::post('/notifications/mark-all-read', [NotificationController::class, 'markAllAsRead'])->name('notifications.mark-all-read'); + Route::get('/onboarding', [OnboardingController::class, 'show'])->name('care.onboarding.show'); Route::post('/onboarding', [OnboardingController::class, 'store'])->name('care.onboarding.store'); Route::middleware(['care.setup'])->group(function () { + Route::post('/ai/chat', [AiController::class, 'chat'])->middleware('throttle:30,1')->name('care.ai.chat'); + Route::get('/dashboard', [DashboardController::class, 'index'])->name('care.dashboard'); Route::get('/patients', [PatientController::class, 'index'])->name('care.patients.index');