From 089e94ba8842d78dc1b0924a4e8cdd2cf58de37a Mon Sep 17 00:00:00 2001 From: ff Date: Fri, 31 Jul 2026 20:14:09 -0400 Subject: [PATCH] feat: add AI-driven phone calls to patient connection Adds an "AI Call" option alongside Call/SMS/Chat that places an outbound call where Lisa converses live with the patient via Twilio speech gather + TTS, using the same conversational AI as chat. Includes a separate, user-editable Call Template (Settings > AI Chat/Call Settings) so the call greeting can be customized independently of SMS templates. Also fixes the outbound-call webhook URL to always use the fixed public Twilio hostname (CLOUDFLARE_HOST) instead of deriving it from the triggering request, since staff-browser requests arrive over the LAN-only hostname, which Twilio can never reach. Co-Authored-By: Claude Sonnet 5 --- apps/Backend/src/ai/voice-assistant.ts | 65 ++++++++ apps/Backend/src/routes/ai-settings.ts | 4 +- apps/Backend/src/routes/twilio-webhooks.ts | 144 +++++++++++++++++- apps/Backend/src/routes/twilio.ts | 48 ++++++ apps/Backend/src/storage/twilio-storage.ts | 4 +- apps/Backend/src/utils/publicUrl.ts | 33 ++++ .../src/components/layout/sidebar.tsx | 2 +- .../settings/ai-chat-settings-card.tsx | 18 ++- .../src/pages/patient-connection-page.tsx | 52 +++++++ 9 files changed, 364 insertions(+), 6 deletions(-) create mode 100644 apps/Backend/src/ai/voice-assistant.ts create mode 100644 apps/Backend/src/utils/publicUrl.ts diff --git a/apps/Backend/src/ai/voice-assistant.ts b/apps/Backend/src/ai/voice-assistant.ts new file mode 100644 index 00000000..b5257e86 --- /dev/null +++ b/apps/Backend/src/ai/voice-assistant.ts @@ -0,0 +1,65 @@ +import { getLlm, type AiProvider } from "./llm-factory"; + +export interface VoiceTurn { + role: "assistant" | "user"; + text: string; +} + +export interface VoiceAssistantContext { + firstName: string; + officeName: string; + officeAddress: string; + officePhone: string; + appointmentDatetime: string; +} + +const CLOSING_PATTERNS = + /\b(bye|goodbye|good bye|that'?s all|nothing else|no thanks|no thank you|i'?m good|that'?s it|hang up)\b/i; + +export function soundsLikeGoodbye(text: string): boolean { + return CLOSING_PATTERNS.test(text.trim()); +} + +/** + * Generate Lisa's next spoken reply given the call transcript so far. + * Falls back to a plain closing line if the LLM call fails, so a broken + * key never leaves the caller stuck mid-call. + */ +export async function runVoiceAssistantTurn( + history: VoiceTurn[], + ctx: VoiceAssistantContext, + apiKey: string, + provider: AiProvider = "google", + model?: string +): Promise { + const fallback = + "I'm sorry, I'm having trouble right now — our staff will follow up with you shortly. Thank you for calling, goodbye."; + + try { + const llm = getLlm(provider, apiKey, model); + const system = [ + `You are Lisa, a friendly AI phone assistant for ${ctx.officeName || "a dental office"}.`, + `You are speaking live by phone with ${ctx.firstName || "the patient"}.`, + ctx.appointmentDatetime + ? `Their next appointment is on ${ctx.appointmentDatetime}.` + : `They have no upcoming appointment on file.`, + ctx.officeAddress ? `Office address: ${ctx.officeAddress}.` : "", + ctx.officePhone ? `Office phone: ${ctx.officePhone}.` : "", + "You can confirm their appointment and answer general questions about the practice.", + "If they want to reschedule or need something you can't resolve yourself, tell them a staff member will call or text them to confirm — never claim you rebooked or changed anything yourself.", + "This reply will be read aloud by text-to-speech: keep it conversational and SHORT, 1-2 sentences, no formatting, no lists.", + "If they indicate they're done (goodbye, that's all, etc.), give a brief warm goodbye.", + ].filter(Boolean).join(" "); + + const messages = [ + { role: "system", content: system }, + ...history.map((t) => ({ role: t.role === "assistant" ? "assistant" : "user", content: t.text })), + ]; + + const res = await llm.invoke(messages as any); + const text = String(res.content).trim(); + return text || fallback; + } catch { + return fallback; + } +} diff --git a/apps/Backend/src/routes/ai-settings.ts b/apps/Backend/src/routes/ai-settings.ts index 9c6910c5..d9712695 100644 --- a/apps/Backend/src/routes/ai-settings.ts +++ b/apps/Backend/src/routes/ai-settings.ts @@ -214,8 +214,8 @@ router.put("/chat-templates", async (req: Request, res: Response): Promise try { const userId = req.user?.id; if (!userId) return res.status(401).json({ message: "Unauthorized" }); - const { reminderGreeting, newPatientGreeting, generalFallback, rescheduleGreeting, reminderSms } = req.body; - await storage.saveAiChatTemplates(userId, { reminderGreeting, newPatientGreeting, generalFallback, rescheduleGreeting, reminderSms }); + const { reminderGreeting, newPatientGreeting, generalFallback, rescheduleGreeting, reminderSms, callTemplate } = req.body; + await storage.saveAiChatTemplates(userId, { reminderGreeting, newPatientGreeting, generalFallback, rescheduleGreeting, reminderSms, callTemplate }); const updated = await storage.getAiChatTemplates(userId); return res.status(200).json(updated); } catch (err) { diff --git a/apps/Backend/src/routes/twilio-webhooks.ts b/apps/Backend/src/routes/twilio-webhooks.ts index 9e205af3..33e06fe2 100644 --- a/apps/Backend/src/routes/twilio-webhooks.ts +++ b/apps/Backend/src/routes/twilio-webhooks.ts @@ -14,6 +14,8 @@ import { timeLabel, } from "../ai/reschedule-graph"; import { getLlm, resolveAiProvider } from "../ai/llm-factory"; +import { runVoiceAssistantTurn, soundsLikeGoodbye } from "../ai/voice-assistant"; +import { getPublicBaseUrl } from "../utils/publicUrl"; import { runEligibilityProcessor } from "../queue/processors/eligibilityProcessor"; import { getHandoff, getAfterHoursHandoff, @@ -1169,7 +1171,7 @@ router.post("/webhook/voice", async (req: Request, res: Response): Promise }); } - const recordingCallbackUrl = `${process.env.BASE_URL || "https://communitydentistsoflowell.mydentalofficemanagement.com"}/api/twilio/webhook/voice-recording`; + const recordingCallbackUrl = `${getPublicBaseUrl(req)}/api/twilio/webhook/voice-recording`; res.set("Content-Type", "text/xml"); return res.send(` @@ -1228,4 +1230,144 @@ router.post("/webhook/voice-browser", async (req: Request, res: Response): Promi } }); +// ── AI phone call (outbound "AI Call" from Patient Connection) ─────────────── + +const MAX_AI_CALL_TURNS = 12; + +interface AiCallSession { + patientId: number; + userId: number; + history: { role: "assistant" | "user"; text: string }[]; + turns: number; +} + +const aiCallSessions = new Map(); + +function aiVoiceTwiml(sayText: string, gatherActionUrl: string): string { + // action is always set, so Twilio posts back to it on both speech and + // silence timeout — nothing after would ever run. + return ` + + + ${escapeXml(sayText)} + +`; +} + +function aiVoiceHangup(sayText: string): string { + return ` + + ${escapeXml(sayText)} + +`; +} + +// POST /api/twilio/webhook/ai-voice +// Entry point + conversation loop for outbound AI-driven calls placed via +// POST /api/twilio/make-ai-call. Twilio hits this once when the call connects +// (no CallSid session yet) and again after every with SpeechResult. +router.post("/webhook/ai-voice", async (req: Request, res: Response): Promise => { + res.set("Content-Type", "text/xml"); + try { + const { CallSid, SpeechResult } = req.body as { CallSid?: string; SpeechResult?: string }; + const patientIdParam = req.query.patientId as string | undefined; + if (!CallSid) return res.send(aiVoiceHangup("Sorry, something went wrong. Goodbye.")); + + let session = aiCallSessions.get(CallSid); + + // ── First hit for this call: set up session + greet ────────────────── + if (!session) { + const patientId = parseInt(patientIdParam || "", 10); + const patient = !isNaN(patientId) + ? await db.patient.findUnique({ where: { id: patientId }, select: { id: true, userId: true, firstName: true } }) + : null; + if (!patient) return res.send(aiVoiceHangup("Sorry, we could not find your patient record. Goodbye.")); + + session = { patientId: patient.id, userId: patient.userId, history: [], turns: 0 }; + aiCallSessions.set(CallSid, session); + + const officeContact = await storage.getOfficeContact(patient.userId); + const officeName = (officeContact as any)?.officeName?.trim() || ""; + const officeAddress = [ + (officeContact as any)?.streetAddress?.trim(), + (officeContact as any)?.city?.trim(), + (officeContact as any)?.state?.trim(), + (officeContact as any)?.zipCode?.trim(), + ].filter(Boolean).join(", "); + const officePhone = (officeContact as any)?.phoneNumber?.trim() || ""; + const appointmentDatetime = await getAppointmentDatetime(patient.id); + + const chatTemplates = await storage.getAiChatTemplates(patient.userId); + const rawCallTemplate = chatTemplates.callTemplate?.trim() || + "Hi {firstName}, this is Lisa, the AI assistant at {officeName}. How can I help you today?"; + const greeting = rawCallTemplate + .replace(/\{firstName\}/g, patient.firstName || "there") + .replace(/\{officeName\}/g, officeName || "our office"); + + session.history.push({ role: "assistant", text: greeting }); + await storage.createCommunication({ + patientId: patient.id, userId: patient.userId, channel: "voice", + direction: "outbound", status: "completed", body: greeting, twilioSid: CallSid, + }); + + // Stash office context on the session for later turns + (session as any).ctx = { officeName, officeAddress, officePhone, appointmentDatetime }; + + const actionUrl = `${getPublicBaseUrl(req)}/api/twilio/webhook/ai-voice?patientId=${patient.id}`; + return res.send(aiVoiceTwiml(greeting, actionUrl)); + } + + // ── Caller stayed silent past the speech timeout ────────────────────── + if (!SpeechResult?.trim()) { + aiCallSessions.delete(CallSid); + return res.send(aiVoiceHangup("I didn't catch a response. Our staff will follow up with you if needed. Goodbye.")); + } + + session.history.push({ role: "user", text: SpeechResult.trim() }); + await storage.createCommunication({ + patientId: session.patientId, userId: session.userId, channel: "voice", + direction: "inbound", status: "completed", body: SpeechResult.trim(), twilioSid: CallSid, + }); + + session.turns++; + + // ── Caller said something that sounds like goodbye, or we've hit the cap ── + if (soundsLikeGoodbye(SpeechResult) || session.turns >= MAX_AI_CALL_TURNS) { + aiCallSessions.delete(CallSid); + return res.send(aiVoiceHangup("Thank you for calling, have a great day. Goodbye.")); + } + + const aiSettings = await storage.getAiSettings(session.userId); + const activeAi = resolveAiProvider(aiSettings ?? {}); + if (!activeAi) { + aiCallSessions.delete(CallSid); + return res.send(aiVoiceHangup("Our AI assistant is not available right now. Our staff will follow up with you. Goodbye.")); + } + + const patientRow = await db.patient.findUnique({ where: { id: session.patientId }, select: { firstName: true } }); + const ctx = (session as any).ctx || {}; + const reply = await runVoiceAssistantTurn( + session.history, + { firstName: patientRow?.firstName || "", ...ctx }, + activeAi.key, activeAi.provider, activeAi.model + ); + + session.history.push({ role: "assistant", text: reply }); + await storage.createCommunication({ + patientId: session.patientId, userId: session.userId, channel: "voice", + direction: "outbound", status: "completed", body: reply, twilioSid: CallSid, + }); + + if (soundsLikeGoodbye(reply)) { + aiCallSessions.delete(CallSid); + return res.send(aiVoiceHangup(reply)); + } + + const actionUrl = `${getPublicBaseUrl(req)}/api/twilio/webhook/ai-voice?patientId=${session.patientId}`; + return res.send(aiVoiceTwiml(reply, actionUrl)); + } catch (err) { + return res.send(aiVoiceHangup("Sorry, something went wrong on our end. Goodbye.")); + } +}); + export default router; diff --git a/apps/Backend/src/routes/twilio.ts b/apps/Backend/src/routes/twilio.ts index be48edc4..3b36fe5b 100644 --- a/apps/Backend/src/routes/twilio.ts +++ b/apps/Backend/src/routes/twilio.ts @@ -3,6 +3,8 @@ import twilio from "twilio"; import { storage } from "../storage"; import { prisma as db } from "@repo/db/client"; import { getHandoff, setHandoff, resetConversation, startNewPatientConversation, startRescheduleConversation, getAfterHoursHandoff, setAfterHoursHandoff } from "../ai/aiHandoffStore"; +import { resolveAiProvider } from "../ai/llm-factory"; +import { getTwilioPublicBaseUrl } from "../utils/publicUrl"; const router = express.Router(); @@ -117,6 +119,52 @@ router.post("/send-sms", async (req: Request, res: Response): Promise => { } }); +// POST /api/twilio/make-ai-call +// Places an outbound call where Lisa (the AI assistant) conducts the whole +// conversation live over the phone — see /webhook/ai-voice for the loop. +router.post("/make-ai-call", async (req: Request, res: Response): Promise => { + try { + const userId = req.user?.id; + if (!userId) return res.status(401).json({ message: "Unauthorized" }); + + const { to, patientId } = req.body; + if (!to || !patientId) return res.status(400).json({ message: "to and patientId are required" }); + + const settings = await storage.getTwilioSettings(userId); + if (!settings) { + return res.status(400).json({ message: "Twilio is not configured. Please add your Twilio credentials in Settings." }); + } + + const aiSettings = await storage.getAiSettings(userId); + if (!resolveAiProvider(aiSettings ?? {})) { + return res.status(400).json({ message: "AI is not configured. Please add an AI provider key in Settings before placing an AI call." }); + } + + const patient = await db.patient.findFirst({ where: { id: Number(patientId), userId } }); + if (!patient) return res.status(404).json({ message: "Patient not found" }); + + let publicBaseUrl: string; + try { + publicBaseUrl = getTwilioPublicBaseUrl(); + } catch (err: any) { + return res.status(500).json({ message: err.message }); + } + const webhookUrl = `${publicBaseUrl}/api/twilio/webhook/ai-voice?patientId=${patient.id}`; + + const client = getTwilioClient(settings.accountSid, settings.authToken); + const call = await client.calls.create({ + url: webhookUrl, + method: "POST", + from: settings.phoneNumber, + to, + }); + + return res.status(200).json({ sid: call.sid, status: call.status }); + } catch (err: any) { + return res.status(500).json({ error: err.message || "Failed to place AI call" }); + } +}); + // POST /api/twilio/send-reminders-batch router.post("/send-reminders-batch", async (req: Request, res: Response): Promise => { try { diff --git a/apps/Backend/src/storage/twilio-storage.ts b/apps/Backend/src/storage/twilio-storage.ts index 96683067..d520fd78 100644 --- a/apps/Backend/src/storage/twilio-storage.ts +++ b/apps/Backend/src/storage/twilio-storage.ts @@ -77,10 +77,11 @@ export const twilioStorage = { generalFallback: all["_ai_chat_general_fallback"] ?? "", rescheduleGreeting: all["_ai_chat_reschedule_greeting"] ?? "", reminderSms: all["_ai_chat_reminder_sms"] ?? "", + callTemplate: all["_ai_chat_call_template"] ?? "", }; }, - async saveAiChatTemplates(userId: number, templates: { reminderGreeting?: string; newPatientGreeting?: string; generalFallback?: string; rescheduleGreeting?: string; reminderSms?: string }) { + async saveAiChatTemplates(userId: number, templates: { reminderGreeting?: string; newPatientGreeting?: string; generalFallback?: string; rescheduleGreeting?: string; reminderSms?: string; callTemplate?: string }) { const settings = await db.twilioSettings.findUnique({ where: { userId } }); const existing = (settings?.templates as Record) || {}; const updated: Record = { ...existing }; @@ -89,6 +90,7 @@ export const twilioStorage = { if (templates.generalFallback !== undefined) updated["_ai_chat_general_fallback"] = templates.generalFallback; if (templates.rescheduleGreeting !== undefined) updated["_ai_chat_reschedule_greeting"] = templates.rescheduleGreeting; if (templates.reminderSms !== undefined) updated["_ai_chat_reminder_sms"] = templates.reminderSms; + if (templates.callTemplate !== undefined) updated["_ai_chat_call_template"] = templates.callTemplate; return db.twilioSettings.upsert({ where: { userId }, update: { templates: updated }, diff --git a/apps/Backend/src/utils/publicUrl.ts b/apps/Backend/src/utils/publicUrl.ts new file mode 100644 index 00000000..a375a888 --- /dev/null +++ b/apps/Backend/src/utils/publicUrl.ts @@ -0,0 +1,33 @@ +import type { Request } from "express"; + +/** + * Public HTTPS origin this request arrived on (e.g. https://broadwaydental.mydentalofficemanagement.com). + * Derived from the Host header rather than an env var so it's automatically correct + * per-office — each office's nginx forwards its own public hostname in Host, + * and Cloudflare/Let's Encrypt terminate TLS in front of it either way. + * + * Only valid for requests that actually arrived via the public tunnel (i.e. inside + * a Twilio webhook handler). For anything triggered from the staff browser — which + * reaches the backend over the LAN-only local-* hostname — use getTwilioPublicBaseUrl() + * instead, since Twilio can never reach that LAN hostname to fetch a callback URL. + */ +export function getPublicBaseUrl(req: Request): string { + return `https://${req.get("host")}`; +} + +/** + * Fixed public origin Twilio must use to call back into this office's server + * (e.g. for outbound-call TwiML webhooks). Comes from CLOUDFLARE_HOST, the + * per-office env var already reserved for this — not derived from the + * triggering request, since that request may have arrived over the LAN-only + * hostname, which Twilio cannot reach. + */ +export function getTwilioPublicBaseUrl(): string { + const host = process.env.CLOUDFLARE_HOST?.trim(); + if (!host) { + throw new Error( + "CLOUDFLARE_HOST is not set — required to build a Twilio-reachable callback URL. Set it in .env to this office's public tunnel hostname." + ); + } + return `https://${host}`; +} diff --git a/apps/Frontend/src/components/layout/sidebar.tsx b/apps/Frontend/src/components/layout/sidebar.tsx index 507a2812..36ef2a3b 100755 --- a/apps/Frontend/src/components/layout/sidebar.tsx +++ b/apps/Frontend/src/components/layout/sidebar.tsx @@ -311,7 +311,7 @@ export function Sidebar() { icon: , }, { - name: "AI Chat Settings", + name: "AI Chat/Call Settings", path: "/settings/aichat", icon: , }, diff --git a/apps/Frontend/src/components/settings/ai-chat-settings-card.tsx b/apps/Frontend/src/components/settings/ai-chat-settings-card.tsx index 6505456a..47dd9a0d 100644 --- a/apps/Frontend/src/components/settings/ai-chat-settings-card.tsx +++ b/apps/Frontend/src/components/settings/ai-chat-settings-card.tsx @@ -7,7 +7,7 @@ import { Textarea } from "@/components/ui/textarea"; import { Switch } from "@/components/ui/switch"; import { useToast } from "@/hooks/use-toast"; import { apiRequest, queryClient } from "@/lib/queryClient"; -import { Bot, CalendarCheck, UserPlus, MessageCircle, Info, GitFork, MessageSquare, Trash2, Plus, Zap, SlidersHorizontal, BookMarked, ChevronDown, ChevronUp } from "lucide-react"; +import { Bot, CalendarCheck, UserPlus, MessageCircle, Info, GitFork, MessageSquare, Trash2, Plus, Zap, SlidersHorizontal, BookMarked, ChevronDown, ChevronUp, PhoneCall } from "lucide-react"; import { Tabs, TabsList, TabsTrigger, TabsContent } from "@/components/ui/tabs"; // ─── Types ──────────────────────────────────────────────────────────────────── @@ -18,6 +18,7 @@ type AiChatTemplates = { reminderGreeting: string; newPatientGreeting: string; generalFallback: string; + callTemplate: string; }; type OfficeContact = { @@ -36,6 +37,8 @@ const DEFAULTS = { "Hi! My name is Lisa, the dedicated AI assistant at {officeName}. I can help you schedule an appointment, check your insurance, and answer general questions 24/7. How can I help you today?", generalFallback: "Hi! My name is Lisa, the dedicated AI assistant at {officeName}. How can I help you today?", + callTemplate: + "Hi {firstName}, this is Lisa, the AI assistant at {officeName}. How can I help you today?", }; const DEFAULT_SMS_TEMPLATES = [ @@ -1009,6 +1012,7 @@ export function AiChatSettingsCard() { const [reminderGreeting, setReminderGreeting] = useState(DEFAULTS.reminderGreeting); const [newPatientGreeting, setNewPatientGreeting] = useState(DEFAULTS.newPatientGreeting); const [generalFallback, setGeneralFallback] = useState(DEFAULTS.generalFallback); + const [callTemplate, setCallTemplate] = useState(DEFAULTS.callTemplate); const initialized = useRef(false); const [openPhoneReply, setOpenPhoneReply] = useState(false); @@ -1087,6 +1091,7 @@ export function AiChatSettingsCard() { setReminderGreeting(templates.reminderGreeting || DEFAULTS.reminderGreeting); setNewPatientGreeting(templates.newPatientGreeting || DEFAULTS.newPatientGreeting); setGeneralFallback(templates.generalFallback || DEFAULTS.generalFallback); + setCallTemplate(templates.callTemplate || DEFAULTS.callTemplate); } }, [templates]); @@ -1144,6 +1149,7 @@ export function AiChatSettingsCard() { reminderGreeting: reminderGreeting.trim() || DEFAULTS.reminderGreeting, newPatientGreeting: newPatientGreeting.trim() || DEFAULTS.newPatientGreeting, generalFallback: generalFallback.trim() || DEFAULTS.generalFallback, + callTemplate: callTemplate.trim() || DEFAULTS.callTemplate, }); }; @@ -1191,6 +1197,15 @@ export function AiChatSettingsCard() { onChange: setGeneralFallback, placeholder: DEFAULTS.generalFallback, }, + { + key: "call", + icon: , + label: "Call Template", + description: "Lisa's opening line when placing an AI Call to a patient. Separate from the SMS chat templates above — use {firstName} and {officeName} as placeholders.", + value: callTemplate, + onChange: setCallTemplate, + placeholder: DEFAULTS.callTemplate, + }, ]; return ( @@ -1393,6 +1408,7 @@ export function AiChatSettingsCard() { setReminderGreeting(DEFAULTS.reminderGreeting); setNewPatientGreeting(DEFAULTS.newPatientGreeting); setGeneralFallback(DEFAULTS.generalFallback); + setCallTemplate(DEFAULTS.callTemplate); }} > Reset to defaults diff --git a/apps/Frontend/src/pages/patient-connection-page.tsx b/apps/Frontend/src/pages/patient-connection-page.tsx index 4e9d74ba..d6e1427a 100755 --- a/apps/Frontend/src/pages/patient-connection-page.tsx +++ b/apps/Frontend/src/pages/patient-connection-page.tsx @@ -24,6 +24,7 @@ import { Send, X, MoonStar, + Bot, } from "lucide-react"; import { SmsTemplateDialog } from "@/components/patient-connection/sms-template-diaog"; import { MessageThread } from "@/components/patient-connection/message-thread"; @@ -103,6 +104,32 @@ export default function PatientConnectionPage() { }, }); + const makeAiCallMutation = useMutation({ + mutationFn: async ({ + to, + patientId, + }: { + to: string; + patientId: number; + }) => { + return apiRequest("POST", "/api/twilio/make-ai-call", { to, patientId }); + }, + onSuccess: () => { + toast({ + title: "AI Call Started", + description: "Lisa is calling the patient now.", + }); + }, + onError: (error: any) => { + toast({ + title: "AI Call Failed", + description: + error.message || "Unable to place the AI call. Please try again.", + variant: "destructive", + }); + }, + }); + // Fetch all patients from database const { data: patients = [], isLoading } = useQuery({ queryKey: ["/api/patients"], @@ -139,6 +166,22 @@ export default function PatientConnectionPage() { }); }; + // Handle AI-driven call via Twilio + const handleAiCall = (patient: Patient) => { + if (!patient.phone?.trim()) { + toast({ + title: "No Phone Number", + description: "This patient does not have a phone number on file.", + variant: "destructive", + }); + return; + } + makeAiCallMutation.mutate({ + to: patient.phone, + patientId: Number(patient.id), + }); + }; + // Handle sending SMS const handleSMS = (patient: Patient) => { setSelectedPatient(patient); @@ -343,6 +386,15 @@ export default function PatientConnectionPage() { Call +