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(), ]); } } }