diff --git a/app/Http/Controllers/Care/AppointmentMeetController.php b/app/Http/Controllers/Care/AppointmentMeetController.php index 54dab8e..09b7f32 100644 --- a/app/Http/Controllers/Care/AppointmentMeetController.php +++ b/app/Http/Controllers/Care/AppointmentMeetController.php @@ -5,6 +5,7 @@ namespace App\Http\Controllers\Care; use App\Http\Controllers\Controller; use App\Http\Controllers\Care\Concerns\ScopesToAccount; use App\Models\Appointment; +use App\Services\Care\AppointmentPatientNotifier; use App\Services\Care\AuditLogger; use App\Services\Meet\MeetClient; use Illuminate\Http\RedirectResponse; @@ -14,7 +15,7 @@ class AppointmentMeetController extends Controller { use ScopesToAccount; - public function schedule(Request $request, Appointment $appointment): RedirectResponse + public function schedule(Request $request, Appointment $appointment, AppointmentPatientNotifier $notifier): RedirectResponse { $this->authorizeAbility($request, 'appointments.manage'); $this->authorizeOwner($request, $appointment); @@ -27,8 +28,14 @@ class AppointmentMeetController extends Controller $appointment->loadMissing('patient'); $patient = $appointment->patient; $owner = $this->ownerRef($request); + $organization = $this->organization($request); $user = $request->user(); + $inviteEmails = []; + if ($notifier->shouldInvitePatientEmailViaMeet($organization)) { + $inviteEmails = array_values(array_filter([(string) $patient->email])); + } + $response = MeetClient::for($owner)->createRoom([ 'title' => 'Video visit — '.$patient->fullName(), 'description' => $appointment->reason, @@ -42,7 +49,7 @@ class AppointmentMeetController extends Controller 'entity_type' => 'appointment', 'entity_id' => $appointment->uuid, ], - 'invite_emails' => array_values(array_filter([$patient->email])), + 'invite_emails' => $inviteEmails, ]); $appointment->update([ @@ -52,20 +59,29 @@ class AppointmentMeetController extends Controller AuditLogger::record($owner, 'appointment.meet_scheduled', $appointment->organization_id, $owner, Appointment::class, $appointment->id); + $notifier->notifyVideoScheduled($organization, $appointment->fresh(['patient', 'branch'])); + return redirect()->route('care.appointments.show', $appointment) - ->with('success', 'Video visit scheduled. The patient will receive a join link.'); + ->with('success', 'Video visit scheduled.'.($this->notifyHint($organization, $notifier))); } - public function start(Request $request, Appointment $appointment): RedirectResponse + public function start(Request $request, Appointment $appointment, AppointmentPatientNotifier $notifier): RedirectResponse { $this->authorizeAbility($request, 'consultations.manage'); $this->authorizeOwner($request, $appointment); $owner = $this->ownerRef($request); + $organization = $this->organization($request); $appointment->loadMissing('patient'); $user = $request->user(); + $createdRoom = false; if (! $appointment->meet_room_uuid) { + $inviteEmails = []; + if ($notifier->shouldInvitePatientEmailViaMeet($organization)) { + $inviteEmails = array_values(array_filter([(string) $appointment->patient->email])); + } + $response = MeetClient::for($owner)->createRoom([ 'title' => 'Video visit — '.$appointment->patient->fullName(), 'description' => $appointment->reason, @@ -76,17 +92,34 @@ class AppointmentMeetController extends Controller 'entity_type' => 'appointment', 'entity_id' => $appointment->uuid, ], - 'invite_emails' => array_values(array_filter([$appointment->patient->email])), + 'invite_emails' => $inviteEmails, ]); $appointment->update([ 'meet_room_uuid' => data_get($response, 'room.uuid'), 'meet_join_url' => data_get($response, 'join_url'), ]); + $createdRoom = true; + } + + if ($createdRoom) { + $notifier->notifyVideoScheduled($organization, $appointment->fresh(['patient', 'branch'])); } $start = MeetClient::for($owner)->startRoom((string) $appointment->meet_room_uuid, $owner); return redirect()->away((string) (data_get($start, 'join_url') ?: $appointment->meet_join_url)); } + + private function notifyHint($organization, AppointmentPatientNotifier $notifier): string + { + $sms = $notifier->organizationWants($organization, AppointmentPatientNotifier::SETTING_VIDEO_SMS); + $email = $notifier->organizationWants($organization, AppointmentPatientNotifier::SETTING_VIDEO_EMAIL); + + if ($sms || $email) { + return ' Patient notification settings will be applied.'; + } + + return ' Enable video notifications in Settings to alert the patient.'; + } } diff --git a/app/Http/Controllers/Care/SettingsController.php b/app/Http/Controllers/Care/SettingsController.php index 2cb48b0..fb2abe2 100644 --- a/app/Http/Controllers/Care/SettingsController.php +++ b/app/Http/Controllers/Care/SettingsController.php @@ -5,8 +5,10 @@ namespace App\Http\Controllers\Care; use App\Http\Controllers\Controller; use App\Http\Controllers\Care\Concerns\ScopesToAccount; use App\Models\Branch; +use App\Services\Care\AppointmentPatientNotifier; use App\Services\Care\AuditLogger; use App\Services\Care\CarePermissions; +use App\Services\Messaging\MessagingCredentialsService; use App\Services\Queue\QueueClient; use App\Support\OrganizationBranding; use Illuminate\Http\RedirectResponse; @@ -17,11 +19,12 @@ class SettingsController extends Controller { use ScopesToAccount; - public function edit(Request $request): View + public function edit(Request $request, MessagingCredentialsService $credentials): View { $this->authorizeAbility($request, 'settings.view'); $organization = $this->organization($request); $canManage = app(CarePermissions::class)->can($this->member($request), 'settings.manage'); + $credential = $credentials->forOrganization($organization); $branchCount = Branch::owned($this->ownerRef($request)) ->where('organization_id', $organization->id) @@ -31,6 +34,8 @@ class SettingsController extends Controller 'organization' => $organization, 'canManage' => $canManage, 'branchCount' => $branchCount, + 'messagingSmsReady' => $credential->hasValidSms(), + 'messagingEmailReady' => $credential->hasValidBird(), 'facilityTypes' => [ 'clinic' => 'Clinic', 'hospital' => 'Hospital', @@ -53,6 +58,10 @@ class SettingsController extends Controller 'logo' => ['nullable', 'image', 'mimes:jpeg,png,jpg,webp,svg', 'max:2048'], 'remove_logo' => ['nullable', 'boolean'], 'queue_integration_enabled' => ['nullable', 'boolean'], + 'notify_appointment_booked_sms' => ['nullable', 'boolean'], + 'notify_appointment_booked_email' => ['nullable', 'boolean'], + 'notify_video_scheduled_sms' => ['nullable', 'boolean'], + 'notify_video_scheduled_email' => ['nullable', 'boolean'], ]); $settings = $organization->settings ?? []; @@ -60,9 +69,13 @@ class SettingsController extends Controller $settings['facility_type'] = $validated['facility_type']; $wantsQueue = $request->boolean('queue_integration_enabled'); - $hadQueue = (bool) data_get($settings, 'queue_integration_enabled', false); $settings['queue_integration_enabled'] = $wantsQueue; + $settings[AppointmentPatientNotifier::SETTING_BOOKED_SMS] = $request->boolean('notify_appointment_booked_sms'); + $settings[AppointmentPatientNotifier::SETTING_BOOKED_EMAIL] = $request->boolean('notify_appointment_booked_email'); + $settings[AppointmentPatientNotifier::SETTING_VIDEO_SMS] = $request->boolean('notify_video_scheduled_sms'); + $settings[AppointmentPatientNotifier::SETTING_VIDEO_EMAIL] = $request->boolean('notify_video_scheduled_email'); + if ($wantsQueue && $queue->configured()) { $branch = Branch::owned($owner)->where('organization_id', $organization->id)->orderBy('name')->first(); try { diff --git a/app/Services/Care/AppointmentPatientNotifier.php b/app/Services/Care/AppointmentPatientNotifier.php new file mode 100644 index 0000000..be12f83 --- /dev/null +++ b/app/Services/Care/AppointmentPatientNotifier.php @@ -0,0 +1,241 @@ +settings, $setting, false); + } + + public function notifyAppointmentBooked(Organization $organization, Appointment $appointment): void + { + $appointment->loadMissing(['patient', 'branch', 'practitioner']); + $patient = $appointment->patient; + if (! $patient) { + return; + } + + $when = $appointment->scheduled_at?->timezone($organization->timezone)->format('D, d M Y \a\t H:i') ?? 'the scheduled time'; + $branch = $appointment->branch?->name ?? $organization->name; + $practitioner = $appointment->practitioner?->name; + + if ($this->organizationWants($organization, self::SETTING_BOOKED_SMS)) { + $parts = [ + $organization->name.': appointment confirmed for '.$when.' at '.$branch.'.', + ]; + if ($practitioner) { + $parts[] = 'With '.$practitioner.'.'; + } + $parts[] = 'Reply or call the clinic if you need to reschedule.'; + $this->sendSms($organization, $patient, implode(' ', $parts), 'appointment.booked_sms'); + } + + if ($this->organizationWants($organization, self::SETTING_BOOKED_EMAIL)) { + $lines = [ + 'Hello '.$patient->fullName().',', + '', + 'Your appointment at '.$organization->name.' has been scheduled.', + '', + 'When: '.$when, + 'Where: '.$branch, + ]; + if ($practitioner) { + $lines[] = 'With: '.$practitioner; + } + if ($appointment->reason) { + $lines[] = 'Reason: '.$appointment->reason; + } + $lines[] = ''; + $lines[] = 'If you need to reschedule, please contact the clinic.'; + + $this->sendEmail( + $organization, + $patient, + 'Appointment confirmed — '.$organization->name, + implode("\n", $lines), + 'appointment.booked_email', + ); + } + } + + public function notifyVideoScheduled(Organization $organization, Appointment $appointment): void + { + $appointment->loadMissing(['patient', 'branch']); + $patient = $appointment->patient; + $joinUrl = (string) ($appointment->meet_join_url ?? ''); + if (! $patient || $joinUrl === '') { + return; + } + + $when = $appointment->scheduled_at?->timezone($organization->timezone)->format('D, d M Y \a\t H:i') ?? 'your appointment time'; + + if ($this->organizationWants($organization, self::SETTING_VIDEO_SMS)) { + $this->sendSms( + $organization, + $patient, + $organization->name.': video visit on '.$when.'. Join: '.$joinUrl, + 'appointment.video_sms', + ); + } + + // Video email is delivered by Ladill Meet via invite_emails when the setting is on. + // Also send a Care-branded copy when Bird is connected so branding matches other notices. + if ($this->organizationWants($organization, self::SETTING_VIDEO_EMAIL)) { + $lines = [ + 'Hello '.$patient->fullName().',', + '', + 'A video visit has been scheduled with '.$organization->name.'.', + '', + 'When: '.$when, + 'Join link: '.$joinUrl, + '', + 'Open the link at the appointment time to join.', + ]; + + $this->sendEmail( + $organization, + $patient, + 'Video visit — '.$organization->name, + implode("\n", $lines), + 'appointment.video_email', + ); + } + } + + /** + * When true, Meet should also email the patient join invite. + * Prefer Care-branded email when Bird is connected; otherwise fall back to Meet invites. + */ + public function shouldInvitePatientEmailViaMeet(Organization $organization): bool + { + if (! $this->organizationWants($organization, self::SETTING_VIDEO_EMAIL)) { + return false; + } + + $credential = $this->credentials->forOrganization($organization); + + // Avoid double emails when Care Bird can send the branded notice. + return ! $credential->hasValidBird(); + } + + private function sendSms(Organization $organization, Patient $patient, string $message, string $auditAction): void + { + $phone = trim((string) $patient->phone); + if ($phone === '') { + return; + } + + try { + $credential = $this->credentials->forOrganization($organization); + if (! $credential->hasValidSms()) { + return; + } + + $apiKey = $credential->smsApiKey(); + if (! $apiKey) { + return; + } + + $sent = $this->sms->send($apiKey, $phone, $message, (string) $credential->sms_sender_id); + AuditLogger::record( + $organization->owner_ref, + $sent ? $auditAction.'_sent' : $auditAction.'_failed', + $organization->id, + $organization->owner_ref, + Patient::class, + $patient->id, + [ + 'to' => $phone, + 'error' => $sent ? null : $this->sms->lastError(), + ], + ); + } catch (\Throwable $e) { + Log::warning('Care appointment SMS notification failed', [ + 'organization_id' => $organization->id, + 'patient_id' => $patient->id, + 'error' => $e->getMessage(), + ]); + } + } + + private function sendEmail( + Organization $organization, + Patient $patient, + string $subject, + string $body, + string $auditAction, + ): void { + $to = (string) $patient->email; + if (! filter_var($to, FILTER_VALIDATE_EMAIL)) { + return; + } + + try { + $credential = $this->credentials->forOrganization($organization); + if (! $credential->hasValidBird()) { + return; + } + + $apiKey = $credential->birdApiKey(); + if (! $apiKey) { + return; + } + + $html = OrganizationBranding::wrapEmailHtml(nl2br(e($body)), $organization); + $sent = $this->email->send( + $apiKey, + (string) $credential->bird_from_email, + $credential->bird_from_name, + $to, + $subject, + $html, + $body, + ); + + AuditLogger::record( + $organization->owner_ref, + $sent ? $auditAction.'_sent' : $auditAction.'_failed', + $organization->id, + $organization->owner_ref, + Patient::class, + $patient->id, + [ + 'to' => $to, + 'subject' => $subject, + 'error' => $sent ? null : $this->email->lastError(), + ], + ); + } catch (\Throwable $e) { + Log::warning('Care appointment email notification failed', [ + 'organization_id' => $organization->id, + 'patient_id' => $patient->id, + 'error' => $e->getMessage(), + ]); + } + } +} diff --git a/app/Services/Care/AppointmentService.php b/app/Services/Care/AppointmentService.php index 87e13ee..5daadc2 100644 --- a/app/Services/Care/AppointmentService.php +++ b/app/Services/Care/AppointmentService.php @@ -18,6 +18,7 @@ class AppointmentService { public function __construct( protected VisitService $visits, + protected AppointmentPatientNotifier $notifier, ) {} public function queryForOrganization(string $ownerRef, int $organizationId): Builder @@ -101,7 +102,10 @@ class AppointmentService AuditLogger::record($ownerRef, 'appointment.created', $organization->id, $actorRef, Appointment::class, $appointment->id); - return $appointment->load(['patient', 'practitioner', 'branch']); + $appointment = $appointment->load(['patient', 'practitioner', 'branch']); + $this->notifier->notifyAppointmentBooked($organization, $appointment); + + return $appointment; } /** diff --git a/app/Services/Messaging/CustomerEmailClient.php b/app/Services/Messaging/CustomerEmailClient.php index 12c58e0..6d7ee3b 100644 --- a/app/Services/Messaging/CustomerEmailClient.php +++ b/app/Services/Messaging/CustomerEmailClient.php @@ -27,7 +27,15 @@ class CustomerEmailClient ->get($this->base().'/me'); if ($res->status() === 401) { - return ['ok' => false, 'error' => 'Invalid Bird API key.', 'status' => 401]; + $platformError = trim((string) ($res->json('error') ?: '')); + + return [ + 'ok' => false, + 'error' => $platformError !== '' + ? $platformError + : 'Invalid Bird API key. Use the HTTP API key from bird.ladill.com (starts with lsk_live_).', + 'status' => 401, + ]; } if ($res->failed()) { diff --git a/app/Services/Messaging/MessagingCredentialsService.php b/app/Services/Messaging/MessagingCredentialsService.php index f5e2e57..0685e92 100644 --- a/app/Services/Messaging/MessagingCredentialsService.php +++ b/app/Services/Messaging/MessagingCredentialsService.php @@ -25,7 +25,7 @@ class MessagingCredentialsService */ public function validateAndSaveSms(Organization $organization, string $apiKey, string $senderId): array { - $apiKey = trim($apiKey); + $apiKey = $this->sanitizeApiKey($apiKey); $senderId = trim($senderId); if (! str_starts_with($apiKey, 'lsk_sms_live_')) { @@ -83,12 +83,13 @@ class MessagingCredentialsService */ public function validateAndSaveBird(Organization $organization, string $apiKey, string $fromEmail, ?string $fromName): array { - $apiKey = trim($apiKey); + $apiKey = $this->sanitizeApiKey($apiKey); $fromEmail = strtolower(trim($fromEmail)); $fromName = trim((string) $fromName); - if (! str_starts_with($apiKey, 'lsk_live_') && ! str_starts_with($apiKey, 'lsk_acct_')) { - return ['ok' => false, 'error' => 'Bird API keys must start with lsk_live_ or lsk_acct_.']; + // Platform SMTP relay only accepts HTTP API keys issued as lsk_live_… + if (! str_starts_with($apiKey, 'lsk_live_')) { + return ['ok' => false, 'error' => 'Bird API keys must start with lsk_live_. Copy the HTTP API key from bird.ladill.com (not the mailbox password).']; } if (! filter_var($fromEmail, FILTER_VALIDATE_EMAIL)) { @@ -97,13 +98,14 @@ class MessagingCredentialsService $me = $this->email->me($apiKey); if (! ($me['ok'] ?? false)) { + $error = $me['error'] ?? 'Invalid Bird API key.'; $credential = $this->forOrganization($organization); $credential->update([ 'bird_status' => MessagingCredential::STATUS_INVALID, - 'bird_last_error' => $me['error'] ?? 'Invalid Bird API key.', + 'bird_last_error' => $error, ]); - return ['ok' => false, 'error' => $me['error'] ?? 'Invalid Bird API key.', 'credential' => $credential]; + return ['ok' => false, 'error' => $error, 'credential' => $credential]; } $domains = collect($me['data']['verified_domains'] ?? [])->map(fn ($d) => strtolower((string) $d))->all(); @@ -164,4 +166,13 @@ class MessagingCredentialsService return $credential->fresh(); } + + private function sanitizeApiKey(string $apiKey): string + { + $apiKey = trim($apiKey); + // Strip zero-width / BOM / whitespace that often sneaks in on paste. + $cleaned = preg_replace('/[\x{200B}-\x{200D}\x{FEFF}\s]+/u', '', $apiKey); + + return is_string($cleaned) ? $cleaned : $apiKey; + } } diff --git a/resources/views/care/integrations/edit.blade.php b/resources/views/care/integrations/edit.blade.php index f711db7..06e02b3 100644 --- a/resources/views/care/integrations/edit.blade.php +++ b/resources/views/care/integrations/edit.blade.php @@ -87,6 +87,7 @@ +

Use the HTTP API key from bird.ladill.com (starts with lsk_live_). Do not use the mailbox password.

diff --git a/resources/views/care/settings/edit.blade.php b/resources/views/care/settings/edit.blade.php index efeb6eb..a15b6fa 100644 --- a/resources/views/care/settings/edit.blade.php +++ b/resources/views/care/settings/edit.blade.php @@ -53,6 +53,64 @@ Open messaging integrations → + +
+
+

When an appointment is booked

+
+ + +
+
+ +
+

When a video visit is scheduled

+
+ + +
+
+
+
+