From eaf6da5cdb7f0d1f57eb24f3a734dc9fddc91441 Mon Sep 17 00:00:00 2001 From: isaacclad Date: Mon, 29 Jun 2026 12:53:34 +0000 Subject: [PATCH] Align Care header with other Ladill apps. Add Afia and notifications to the top bar, remove the duplicate desktop page title, wire notification and AI routes, and fix mobile nav notification links. Co-authored-by: Cursor --- .env.example | 5 + app/Http/Controllers/Care/AiController.php | 70 +++++++ .../Controllers/NotificationController.php | 64 +++++++ app/Services/Afia/AfiaService.php | 175 ++++++++++++++++++ config/afia.php | 11 ++ ...6_23_120000_create_notifications_table.php | 25 +++ .../views/components/app-layout.blade.php | 7 +- resources/views/partials/afia.blade.php | 14 +- .../partials/mobile-topbar-title.blade.php | 2 +- .../partials/topbar-desktop-widgets.blade.php | 4 + resources/views/partials/topbar.blade.php | 7 +- routes/web.php | 9 + 12 files changed, 378 insertions(+), 15 deletions(-) create mode 100644 app/Http/Controllers/Care/AiController.php create mode 100644 app/Http/Controllers/NotificationController.php create mode 100644 app/Services/Afia/AfiaService.php create mode 100644 config/afia.php create mode 100644 database/migrations/2026_06_23_120000_create_notifications_table.php 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');