diff --git a/.env.example b/.env.example index 4b09932..1754d45 100644 --- a/.env.example +++ b/.env.example @@ -44,6 +44,17 @@ IDENTITY_API_KEY_CARE= MEET_API_URL=https://meet.ladill.com/api/service/v1 MEET_API_KEY_CARE= +# Ladill SMS (patient messaging — management API on ladill.com) +SMS_PLATFORM_API_URL=https://ladill.com/api +SMS_API_KEY_CARE= +SMS_DEFAULT_SENDER_ID=Ladill + +# Ladill Bird / SMTP (patient email — management API on ladill.com) +SMTP_PLATFORM_API_URL=https://ladill.com/api/smtp +SMTP_API_KEY_CARE= +CARE_SMTP_FROM=care@ladill.com +CARE_SMTP_FROM_NAME="Ladill Care" + # Afia in-app assistant (uses platform relay when AFIA_API_KEY is empty) AFIA_ENABLED=true AFIA_PRODUCT=care diff --git a/app/Http/Controllers/Care/AppointmentMeetController.php b/app/Http/Controllers/Care/AppointmentMeetController.php index 46366cd..54dab8e 100644 --- a/app/Http/Controllers/Care/AppointmentMeetController.php +++ b/app/Http/Controllers/Care/AppointmentMeetController.php @@ -27,6 +27,7 @@ class AppointmentMeetController extends Controller $appointment->loadMissing('patient'); $patient = $appointment->patient; $owner = $this->ownerRef($request); + $user = $request->user(); $response = MeetClient::for($owner)->createRoom([ 'title' => 'Video visit — '.$patient->fullName(), @@ -34,6 +35,8 @@ class AppointmentMeetController extends Controller 'scheduled_at' => ($appointment->scheduled_at ?? now()->addHour())->toIso8601String(), 'duration_minutes' => 30, 'branch_id' => $appointment->branch_id, + 'host_name' => $user?->name, + 'host_email' => $user?->email, 'source' => [ 'app' => 'care', 'entity_type' => 'appointment', @@ -60,11 +63,14 @@ class AppointmentMeetController extends Controller $owner = $this->ownerRef($request); $appointment->loadMissing('patient'); + $user = $request->user(); if (! $appointment->meet_room_uuid) { $response = MeetClient::for($owner)->createRoom([ 'title' => 'Video visit — '.$appointment->patient->fullName(), 'description' => $appointment->reason, + 'host_name' => $user?->name, + 'host_email' => $user?->email, 'source' => [ 'app' => 'care', 'entity_type' => 'appointment', diff --git a/app/Http/Controllers/Care/PatientMessageController.php b/app/Http/Controllers/Care/PatientMessageController.php new file mode 100644 index 0000000..1ad2072 --- /dev/null +++ b/app/Http/Controllers/Care/PatientMessageController.php @@ -0,0 +1,104 @@ +authorizeAbility($request, 'patients.manage'); + $this->authorizePatient($request, $patient); + + if (! filter_var((string) $patient->email, FILTER_VALIDATE_EMAIL)) { + return back()->with('error', 'This patient has no valid email on file.'); + } + + $data = $request->validate([ + 'subject' => ['required', 'string', 'max:200'], + 'body' => ['required', 'string', 'max:5000'], + ]); + + $owner = $this->ownerRef($request); + $html = nl2br(e($data['body'])); + $sent = $email->send($owner, (string) $patient->email, $data['subject'], $html, $data['body']); + + AuditLogger::record( + $owner, + $sent ? 'patient.email_sent' : 'patient.email_failed', + $patient->organization_id, + $owner, + Patient::class, + $patient->id, + [ + 'to' => $patient->email, + 'subject' => $data['subject'], + 'error' => $sent ? null : $email->lastError(), + ], + ); + + if (! $sent) { + return back()->with('error', $email->lastError() ?: 'Email could not be sent.'); + } + + return back()->with('success', 'Email sent to '.$patient->email); + } + + public function sendSms(Request $request, Patient $patient, PlatformSmsClient $sms): RedirectResponse + { + $this->authorizeAbility($request, 'patients.manage'); + $this->authorizePatient($request, $patient); + + if (trim((string) $patient->phone) === '') { + return back()->with('error', 'This patient has no phone number on file.'); + } + + $data = $request->validate([ + 'message' => ['required', 'string', 'max:480'], + ]); + + $owner = $this->ownerRef($request); + $sent = $sms->send($owner, (string) $patient->phone, $data['message']); + + AuditLogger::record( + $owner, + $sent ? 'patient.sms_sent' : 'patient.sms_failed', + $patient->organization_id, + $owner, + Patient::class, + $patient->id, + [ + 'to' => $patient->phone, + 'error' => $sent ? null : $sms->lastError(), + ], + ); + + if (! $sent) { + return back()->with('error', $sms->lastError() ?: 'SMS could not be sent.'); + } + + return back()->with('success', 'SMS sent to '.$patient->phone); + } + + protected function authorizePatient(Request $request, Patient $patient): void + { + $this->authorizeOwner($request, $patient); + abort_unless($patient->organization_id === $this->organization($request)->id, 404); + + $branchId = app(OrganizationResolver::class)->branchScope($this->member($request)); + if ($branchId !== null && $patient->branch_id !== $branchId) { + abort(404); + } + } +} diff --git a/app/Services/Billing/PlatformEmailClient.php b/app/Services/Billing/PlatformEmailClient.php new file mode 100644 index 0000000..dc0dcd5 --- /dev/null +++ b/app/Services/Billing/PlatformEmailClient.php @@ -0,0 +1,76 @@ +lastError; + } + + private function base(): string + { + return rtrim((string) config('smtp.platform_api_url', 'https://ladill.com/api/smtp'), '/'); + } + + private function token(): string + { + return (string) config('smtp.platform_api_key', ''); + } + + public function isConfigured(): bool + { + return $this->token() !== ''; + } + + public function send(string $ownerPublicId, string $to, string $subject, string $html, ?string $text = null): bool + { + $this->lastError = null; + + if (! $this->isConfigured()) { + $this->lastError = 'Outbound email is not configured on this Care instance. Set SMTP_API_KEY_CARE.'; + + return false; + } + + try { + $res = Http::withToken($this->token())->acceptJson()->timeout(30) + ->post($this->base().'/messages/send', array_filter([ + 'user' => $ownerPublicId, + 'from' => config('smtp.from'), + 'from_name' => config('smtp.from_name'), + 'to' => $to, + 'subject' => $subject, + 'html' => $html, + 'text' => $text, + ], fn ($value) => $value !== null && $value !== '')); + + if ($res->status() === 402) { + $this->lastError = (string) ($res->json('error') ?: 'Insufficient Bird email credits. Top up Bird and try again.'); + Log::warning('Care platform email send: insufficient balance', ['user' => $ownerPublicId]); + + return false; + } + + if ($res->failed()) { + $this->lastError = (string) ($res->json('error') ?: $res->json('message') ?: 'The email could not be sent.'); + Log::warning('Care platform email send failed', ['status' => $res->status(), 'body' => $res->body()]); + + return false; + } + + return (bool) ($res->json('success') ?? true); + } catch (\Throwable $e) { + $this->lastError = 'Could not reach the email service. Please try again.'; + Log::warning('Care platform email send error', ['error' => $e->getMessage()]); + + return false; + } + } +} diff --git a/app/Services/Billing/PlatformSmsClient.php b/app/Services/Billing/PlatformSmsClient.php new file mode 100644 index 0000000..52a6867 --- /dev/null +++ b/app/Services/Billing/PlatformSmsClient.php @@ -0,0 +1,138 @@ +lastError; + } + + private function base(): string + { + return rtrim((string) config('sms.platform_api_url', 'https://ladill.com/api'), '/'); + } + + private function token(): string + { + return (string) config('sms.platform_api_key', ''); + } + + public function isConfigured(): bool + { + return $this->token() !== ''; + } + + /** @return list> */ + public function services(string $ownerPublicId): array + { + if (! $this->isConfigured()) { + return []; + } + + try { + $res = Http::withToken($this->token())->acceptJson()->timeout(15) + ->get($this->base().'/sms/services', ['user' => $ownerPublicId]); + + if ($res->failed()) { + return []; + } + + return (array) ($res->json('data') ?? []); + } catch (\Throwable $e) { + Log::warning('Care platform SMS services lookup failed', ['error' => $e->getMessage()]); + + return []; + } + } + + public function ensureServiceId(string $ownerPublicId): ?int + { + $services = $this->services($ownerPublicId); + if ($services !== []) { + return (int) ($services[0]['id'] ?? 0) ?: null; + } + + if (! $this->isConfigured()) { + return null; + } + + try { + $res = Http::withToken($this->token())->acceptJson()->timeout(15) + ->post($this->base().'/sms/services', [ + 'user' => $ownerPublicId, + 'name' => 'Ladill Care', + 'brand_name' => 'Care', + ]); + + if ($res->failed()) { + $this->lastError = (string) ($res->json('error') ?: 'Could not create an SMS service for this account.'); + + return null; + } + + return (int) ($res->json('data.id') ?? 0) ?: null; + } catch (\Throwable $e) { + Log::warning('Care platform SMS service create failed', ['error' => $e->getMessage()]); + $this->lastError = 'Could not reach the SMS service.'; + + return null; + } + } + + public function send(string $ownerPublicId, string $to, string $message, ?string $senderId = null): bool + { + $this->lastError = null; + + if (! $this->isConfigured()) { + $this->lastError = 'SMS is not configured on this Care instance. Set SMS_API_KEY_CARE.'; + + return false; + } + + $serviceId = $this->ensureServiceId($ownerPublicId); + if (! $serviceId) { + $this->lastError ??= 'No SMS service is available for this account. Open sms.ladill.com once, then retry.'; + + return false; + } + + try { + $res = Http::withToken($this->token())->acceptJson()->timeout(30) + ->post($this->base().'/sms/messages/send', array_filter([ + 'user' => $ownerPublicId, + 'sms_service_id' => $serviceId, + 'to' => $to, + 'text' => $message, + 'sender_id' => $senderId ?? config('sms.default_sender_id'), + ])); + + if ($res->status() === 402) { + $this->lastError = (string) ($res->json('error') ?: 'Insufficient SMS credit. Top up your Ladill SMS wallet and try again.'); + Log::warning('Care platform SMS send: insufficient balance', ['user' => $ownerPublicId]); + + return false; + } + + if ($res->failed()) { + $this->lastError = (string) ($res->json('error') ?: $res->json('message') ?: 'The SMS could not be sent.'); + Log::warning('Care platform SMS send failed', ['status' => $res->status(), 'body' => $res->body()]); + + return false; + } + + return (bool) ($res->json('success') ?? true); + } catch (\Throwable $e) { + $this->lastError = 'Could not reach the SMS service. Please try again.'; + Log::warning('Care platform SMS send error', ['error' => $e->getMessage()]); + + return false; + } + } +} diff --git a/app/Services/Meet/MeetClient.php b/app/Services/Meet/MeetClient.php index d1af9a7..cfc2e11 100644 --- a/app/Services/Meet/MeetClient.php +++ b/app/Services/Meet/MeetClient.php @@ -56,6 +56,12 @@ class MeetClient */ private function post(string $path, array $data): array { + if (trim((string) config('meet.key')) === '') { + throw ValidationException::withMessages([ + 'meet' => ['Video visits are not configured. Set MEET_API_KEY_CARE on Care and Meet to the same value.'], + ]); + } + try { $response = $this->client()->post($path, $data); } catch (ConnectionException) { @@ -70,8 +76,26 @@ class MeetClient ); } + if ($response->status() === 401 || $response->status() === 403) { + throw ValidationException::withMessages([ + 'meet' => ['Video visits are not authorized. Set the same MEET_API_KEY_CARE on Care and Meet, then clear config cache.'], + ]); + } + + if ($response->status() === 404) { + throw ValidationException::withMessages([ + 'meet' => ['No Meet workspace was found for this account. Sign into meet.ladill.com once to create one, then retry.'], + ]); + } + if ($response->failed()) { - abort($response->status() === 404 ? 404 : 502, 'Meet service error.'); + $message = $response->json('error') + ?? $response->json('message') + ?? 'Meet could not schedule this video visit. Please try again.'; + + throw ValidationException::withMessages([ + 'meet' => [is_string($message) ? $message : 'Meet service error.'], + ]); } return (array) $response->json(); diff --git a/config/sms.php b/config/sms.php new file mode 100644 index 0000000..ab1e786 --- /dev/null +++ b/config/sms.php @@ -0,0 +1,7 @@ + env('SMS_PLATFORM_API_URL', 'https://ladill.com/api'), + 'platform_api_key' => env('SMS_API_KEY_CARE'), + 'default_sender_id' => env('SMS_DEFAULT_SENDER_ID', 'Ladill'), +]; diff --git a/config/smtp.php b/config/smtp.php new file mode 100644 index 0000000..9b4fb27 --- /dev/null +++ b/config/smtp.php @@ -0,0 +1,8 @@ + env('SMTP_PLATFORM_API_URL', 'https://ladill.com/api/smtp'), + 'platform_api_key' => env('SMTP_API_KEY_CARE'), + 'from' => env('CARE_SMTP_FROM', 'care@ladill.com'), + 'from_name' => env('CARE_SMTP_FROM_NAME', 'Ladill Care'), +]; diff --git a/resources/views/care/patients/show.blade.php b/resources/views/care/patients/show.blade.php index aa92cd5..10431c5 100644 --- a/resources/views/care/patients/show.blade.php +++ b/resources/views/care/patients/show.blade.php @@ -134,6 +134,36 @@
+ @if ($canManage) +
+

Message patient

+

SMS uses Ladill SMS credits. Email uses Ladill Bird credits.

+
+ + +
+ +
+ @csrf +

To: {{ $patient->phone ?: 'no phone on file' }}

+ +
+ +
+
+ +
+ @csrf +

To: {{ $patient->email ?: 'no email on file' }}

+ + +
+ +
+
+
+ @endif +

Emergency contacts

@forelse ($patient->emergencyContacts as $contact) diff --git a/routes/web.php b/routes/web.php index 68da85c..5f73a90 100644 --- a/routes/web.php +++ b/routes/web.php @@ -17,6 +17,7 @@ use App\Http\Controllers\Care\InvestigationTypeController; use App\Http\Controllers\Care\MemberController; use App\Http\Controllers\Care\OnboardingController; use App\Http\Controllers\Care\PatientController; +use App\Http\Controllers\Care\PatientMessageController; use App\Http\Controllers\Care\PrescriptionController; use App\Http\Controllers\Care\QueueController; use App\Http\Controllers\Care\ProController; @@ -57,6 +58,8 @@ Route::middleware(['auth', 'platform.session'])->group(function () { Route::get('/patients/create', [PatientController::class, 'create'])->name('care.patients.create'); Route::post('/patients', [PatientController::class, 'store'])->name('care.patients.store'); Route::get('/patients/{patient}', [PatientController::class, 'show'])->name('care.patients.show'); + Route::post('/patients/{patient}/email', [PatientMessageController::class, 'sendEmail'])->name('care.patients.email'); + Route::post('/patients/{patient}/sms', [PatientMessageController::class, 'sendSms'])->name('care.patients.sms'); Route::get('/patients/{patient}/edit', [PatientController::class, 'edit'])->name('care.patients.edit'); Route::put('/patients/{patient}', [PatientController::class, 'update'])->name('care.patients.update'); Route::delete('/patients/{patient}', [PatientController::class, 'destroy'])->name('care.patients.destroy'); diff --git a/tests/Feature/CareAppointmentMeetTest.php b/tests/Feature/CareAppointmentMeetTest.php index f10880e..ddcc4a7 100644 --- a/tests/Feature/CareAppointmentMeetTest.php +++ b/tests/Feature/CareAppointmentMeetTest.php @@ -131,6 +131,24 @@ class CareAppointmentMeetTest extends TestCase $this->assertSame('room-uuid-2', $appointment->meet_room_uuid); } + public function test_schedule_meet_shows_friendly_error_when_unauthorized(): void + { + Http::fake([ + 'meet.test/*' => Http::response(['error' => 'Unauthorized.'], 401), + ]); + + $appointment = $this->createAppointment(); + + $this->actingAs($this->user) + ->from(route('care.appointments.show', $appointment)) + ->post(route('care.appointments.schedule-meet', $appointment)) + ->assertRedirect(route('care.appointments.show', $appointment)) + ->assertSessionHasErrors('meet'); + + $appointment->refresh(); + $this->assertNull($appointment->meet_room_uuid); + } + protected function createAppointment(string $status = Appointment::STATUS_SCHEDULED): Appointment { return Appointment::create([ diff --git a/tests/Feature/CarePatientTest.php b/tests/Feature/CarePatientTest.php index e9ca71f..ea70f4e 100644 --- a/tests/Feature/CarePatientTest.php +++ b/tests/Feature/CarePatientTest.php @@ -137,7 +137,85 @@ class CarePatientTest extends TestCase ->get(route('care.patients.show', $patient)) ->assertOk() ->assertSee('Efua Boateng') - ->assertSee('LC-2026-00001'); + ->assertSee('LC-2026-00001') + ->assertSee('Message patient'); + } + + public function test_can_send_patient_sms_via_platform_api(): void + { + config([ + 'sms.platform_api_url' => 'https://ladill.test/api', + 'sms.platform_api_key' => 'test-sms-care-key', + 'sms.default_sender_id' => 'Ladill', + ]); + + \Illuminate\Support\Facades\Http::fake([ + 'ladill.test/api/sms/services*' => \Illuminate\Support\Facades\Http::response([ + 'data' => [['id' => 9, 'name' => 'Ladill Care']], + ]), + 'ladill.test/api/sms/messages/send' => \Illuminate\Support\Facades\Http::response([ + 'success' => true, + 'segments' => 1, + ]), + ]); + + $patient = Patient::create([ + 'uuid' => (string) \Illuminate\Support\Str::uuid(), + 'owner_ref' => $this->user->public_id, + 'organization_id' => $this->organization->id, + 'branch_id' => $this->branch->id, + 'patient_number' => 'LC-2026-00002', + 'first_name' => 'Kofi', + 'last_name' => 'Mensah', + 'phone' => '0244111222', + ]); + + $this->actingAs($this->user) + ->post(route('care.patients.sms', $patient), [ + 'message' => 'Your appointment is tomorrow at 9am.', + ]) + ->assertRedirect() + ->assertSessionHas('success'); + + $this->assertDatabaseHas('care_audit_logs', ['action' => 'patient.sms_sent']); + } + + public function test_can_send_patient_email_via_platform_api(): void + { + config([ + 'smtp.platform_api_url' => 'https://ladill.test/api/smtp', + 'smtp.platform_api_key' => 'test-smtp-care-key', + 'smtp.from' => 'care@ladill.com', + 'smtp.from_name' => 'Ladill Care', + ]); + + \Illuminate\Support\Facades\Http::fake([ + 'ladill.test/api/smtp/messages/send' => \Illuminate\Support\Facades\Http::response([ + 'success' => true, + 'message_id' => 'msg-1', + ]), + ]); + + $patient = Patient::create([ + 'uuid' => (string) \Illuminate\Support\Str::uuid(), + 'owner_ref' => $this->user->public_id, + 'organization_id' => $this->organization->id, + 'branch_id' => $this->branch->id, + 'patient_number' => 'LC-2026-00003', + 'first_name' => 'Ama', + 'last_name' => 'Serwaa', + 'email' => 'ama@example.com', + ]); + + $this->actingAs($this->user) + ->post(route('care.patients.email', $patient), [ + 'subject' => 'Appointment reminder', + 'body' => 'Please arrive 15 minutes early.', + ]) + ->assertRedirect() + ->assertSessionHas('success'); + + $this->assertDatabaseHas('care_audit_logs', ['action' => 'patient.email_sent']); } public function test_api_can_list_patients(): void