From 56892696904c543fca757a2ba5f5356da69dc725 Mon Sep 17 00:00:00 2001 From: Gitead Date: Sat, 2 May 2026 20:18:58 -0400 Subject: [PATCH] feat: add Twilio SMS/call integration with settings, templates, and conversation history --- apps/Backend/package.json | 1 + apps/Backend/src/app.ts | 4 + apps/Backend/src/routes/index.ts | 2 + apps/Backend/src/routes/patients.ts | 13 ++ apps/Backend/src/routes/twilio-webhooks.ts | 107 +++++++++ apps/Backend/src/routes/twilio.ts | 183 +++++++++++++++ apps/Backend/src/storage/index.ts | 2 + apps/Backend/src/storage/twilio-storage.ts | 72 ++++++ .../patient-connection/message-thread.tsx | 5 +- .../patient-connection/sms-template-diaog.tsx | 208 +++++++++--------- .../settings/twilio-settings-card.tsx | 170 ++++++++++++++ .../src/pages/patient-connection-page.tsx | 164 +++++++------- apps/Frontend/src/pages/settings-page.tsx | 6 + .../migration.sql | 12 + .../migration.sql | 1 + .../migration.sql | 1 + packages/db/prisma/schema.prisma | 15 ++ 17 files changed, 770 insertions(+), 196 deletions(-) create mode 100644 apps/Backend/src/routes/twilio-webhooks.ts create mode 100644 apps/Backend/src/routes/twilio.ts create mode 100644 apps/Backend/src/storage/twilio-storage.ts create mode 100644 apps/Frontend/src/components/settings/twilio-settings-card.tsx create mode 100644 packages/db/prisma/migrations/20260502000000_add_twilio_settings/migration.sql create mode 100644 packages/db/prisma/migrations/20260502000001_twilio_greeting/migration.sql create mode 100644 packages/db/prisma/migrations/20260502000002_twilio_templates/migration.sql diff --git a/apps/Backend/package.json b/apps/Backend/package.json index 7e256db..efc2ca2 100755 --- a/apps/Backend/package.json +++ b/apps/Backend/package.json @@ -29,6 +29,7 @@ "passport-local": "^1.0.0", "pdfkit": "^0.17.2", "socket.io": "^4.8.1", + "twilio": "^6.0.0", "ws": "^8.18.0", "zod": "^3.24.2", "zod-validation-error": "^3.4.0" diff --git a/apps/Backend/src/app.ts b/apps/Backend/src/app.ts index 7166e05..0083292 100755 --- a/apps/Backend/src/app.ts +++ b/apps/Backend/src/app.ts @@ -4,6 +4,7 @@ import routes from "./routes"; import { errorHandler } from "./middlewares/error.middleware"; import { apiLogger } from "./middlewares/logger.middleware"; import authRoutes from "./routes/auth"; +import twilioWebhookRoutes from "./routes/twilio-webhooks"; import { authenticateJWT } from "./middlewares/auth.middleware"; import dotenv from "dotenv"; import { startBackupCron } from "./cron/backupCheck"; @@ -72,6 +73,9 @@ app.use( app.use("/uploads", express.static(path.join(process.cwd(), "uploads"))); app.use("/api/auth", authRoutes); +// Twilio webhooks are public — Twilio sends no JWT token +app.use("/api/twilio", express.urlencoded({ extended: false }), twilioWebhookRoutes); +// All other API routes require JWT app.use("/api", authenticateJWT, routes); app.use(errorHandler); diff --git a/apps/Backend/src/routes/index.ts b/apps/Backend/src/routes/index.ts index 9c1796d..ed6b367 100755 --- a/apps/Backend/src/routes/index.ts +++ b/apps/Backend/src/routes/index.ts @@ -24,6 +24,7 @@ import cloudStorageRoutes from "./cloud-storage"; import paymentsReportsRoutes from "./payments-reports"; import exportPaymentsReportsRoutes from "./export-payments-reports"; import jobMonitorRoutes from "./job-monitor"; +import twilioRoutes from "./twilio"; const router = Router(); @@ -52,5 +53,6 @@ router.use("/cloud-storage", cloudStorageRoutes); router.use("/payments-reports", paymentsReportsRoutes); router.use("/export-payments-reports", exportPaymentsReportsRoutes); router.use("/job-monitor", jobMonitorRoutes); +router.use("/twilio", twilioRoutes); export default router; diff --git a/apps/Backend/src/routes/patients.ts b/apps/Backend/src/routes/patients.ts index 9f4b213..80787b7 100755 --- a/apps/Backend/src/routes/patients.ts +++ b/apps/Backend/src/routes/patients.ts @@ -374,4 +374,17 @@ router.get( } ); +// GET /api/patients/:id/communications +router.get("/:id/communications", async (req: Request, res: Response): Promise => { + try { + const patientId = parseInt(req.params.id); + if (isNaN(patientId)) return res.status(400).json({ message: "Invalid patient ID" }); + + const communications = await storage.getCommunicationsByPatient(patientId); + return res.status(200).json(communications); + } catch (err) { + return res.status(500).json({ error: "Failed to fetch communications", details: String(err) }); + } +}); + export default router; diff --git a/apps/Backend/src/routes/twilio-webhooks.ts b/apps/Backend/src/routes/twilio-webhooks.ts new file mode 100644 index 0000000..6fe0c8e --- /dev/null +++ b/apps/Backend/src/routes/twilio-webhooks.ts @@ -0,0 +1,107 @@ +import express, { Request, Response } from "express"; +import { storage } from "../storage"; +import { prisma as db } from "@repo/db/client"; + +const router = express.Router(); + +// POST /api/twilio/webhook/sms (Twilio posts inbound SMS here — no auth) +router.post("/webhook/sms", async (req: Request, res: Response): Promise => { + try { + const { From, Body, MessageSid } = req.body; + + const normalizedFrom = (From || "").replace(/\D/g, ""); + const allPatients = await db.patient.findMany({ select: { id: true, phone: true } }); + const patient = allPatients.find( + (p: { id: number; phone: string | null }) => p.phone && p.phone.replace(/\D/g, "") === normalizedFrom + ); + + if (patient) { + await storage.createCommunication({ + patientId: patient.id, + channel: "sms", + direction: "inbound", + status: "delivered", + body: Body, + twilioSid: MessageSid, + }); + } + + res.set("Content-Type", "text/xml"); + return res.send(""); + } catch (err) { + res.set("Content-Type", "text/xml"); + return res.send(""); + } +}); + +// POST /api/twilio/webhook/voice (Twilio posts here when someone calls — no auth) +router.post("/webhook/voice", async (req: Request, res: Response): Promise => { + try { + const { From, CallSid } = req.body; + + const normalizedFrom = (From || "").replace(/\D/g, ""); + const allPatients = await db.patient.findMany({ select: { id: true, phone: true, userId: true } }); + const patient = allPatients.find( + (p: { id: number; phone: string | null; userId: number }) => p.phone && p.phone.replace(/\D/g, "") === normalizedFrom + ); + + let greeting = "Thank you for calling. Please leave a message after the beep and we will get back to you shortly."; + if (patient) { + const settings = await storage.getTwilioSettings(patient.userId); + if (settings?.greetingMessage?.trim()) { + greeting = settings.greetingMessage.trim(); + } + } + + if (patient) { + await storage.createCommunication({ + patientId: patient.id, + channel: "voice", + direction: "inbound", + status: "completed", + body: "(Inbound call — voicemail below)", + twilioSid: CallSid, + }); + } + + const recordingCallbackUrl = `${process.env.BASE_URL || "https://communitydentistsoflowell.mydentalofficemanagement.com"}/api/twilio/webhook/voice-recording`; + + const twiml = ` + + ${greeting} + + We did not receive a recording. Goodbye. +`; + + res.set("Content-Type", "text/xml"); + return res.send(twiml); + } catch (err) { + res.set("Content-Type", "text/xml"); + return res.send(`Thank you for calling. Please try again later.`); + } +}); + +// POST /api/twilio/webhook/voice-recording (Twilio posts recording URL here — no auth) +router.post("/webhook/voice-recording", async (req: Request, res: Response): Promise => { + try { + const { CallSid, RecordingUrl } = req.body; + + if (RecordingUrl && CallSid) { + const comm = await db.communication.findFirst({ where: { twilioSid: CallSid } }); + if (comm) { + await db.communication.update({ + where: { id: comm.id }, + data: { body: `Voicemail: ${RecordingUrl}.mp3` }, + }); + } + } + + res.set("Content-Type", "text/xml"); + return res.send(""); + } catch (err) { + res.set("Content-Type", "text/xml"); + return res.send(""); + } +}); + +export default router; diff --git a/apps/Backend/src/routes/twilio.ts b/apps/Backend/src/routes/twilio.ts new file mode 100644 index 0000000..b94480b --- /dev/null +++ b/apps/Backend/src/routes/twilio.ts @@ -0,0 +1,183 @@ +import express, { Request, Response } from "express"; +import twilio from "twilio"; +import { storage } from "../storage"; + +const router = express.Router(); + +function getTwilioClient(accountSid: string, authToken: string) { + return twilio(accountSid, authToken); +} + +// GET /api/twilio/settings +router.get("/settings", async (req: Request, res: Response): Promise => { + try { + const userId = req.user?.id; + if (!userId) return res.status(401).json({ message: "Unauthorized" }); + + const settings = await storage.getTwilioSettings(userId); + if (!settings) return res.status(200).json(null); + + return res.status(200).json({ + id: settings.id, + accountSid: settings.accountSid, + authToken: settings.authToken, + phoneNumber: settings.phoneNumber, + greetingMessage: settings.greetingMessage, + }); + } catch (err) { + return res.status(500).json({ error: "Failed to fetch Twilio settings", details: String(err) }); + } +}); + +// PUT /api/twilio/settings +router.put("/settings", async (req: Request, res: Response): Promise => { + try { + const userId = req.user?.id; + if (!userId) return res.status(401).json({ message: "Unauthorized" }); + + const { accountSid, authToken, phoneNumber, greetingMessage } = req.body; + if (!accountSid?.trim() || !authToken?.trim() || !phoneNumber?.trim()) { + return res.status(400).json({ message: "accountSid, authToken, and phoneNumber are required" }); + } + + const settings = await storage.upsertTwilioSettings(userId, { + accountSid: accountSid.trim(), + authToken: authToken.trim(), + phoneNumber: phoneNumber.trim(), + greetingMessage: greetingMessage?.trim() || null, + }); + + return res.status(200).json({ + id: settings.id, + accountSid: settings.accountSid, + authToken: settings.authToken, + phoneNumber: settings.phoneNumber, + greetingMessage: settings.greetingMessage, + }); + } catch (err) { + return res.status(500).json({ error: "Failed to save Twilio settings", details: String(err) }); + } +}); + +// POST /api/twilio/send-sms +router.post("/send-sms", async (req: Request, res: Response): Promise => { + try { + const userId = req.user?.id; + if (!userId) return res.status(401).json({ message: "Unauthorized" }); + + const { to, message, patientId } = req.body; + if (!to || !message) return res.status(400).json({ message: "to and message 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 client = getTwilioClient(settings.accountSid, settings.authToken); + const twilioMsg = await client.messages.create({ + body: message, + from: settings.phoneNumber, + to, + }); + + if (patientId) { + await storage.createCommunication({ + patientId: Number(patientId), + userId, + channel: "sms", + direction: "outbound", + status: "sent", + body: message, + twilioSid: twilioMsg.sid, + }); + } + + return res.status(200).json({ sid: twilioMsg.sid, status: twilioMsg.status }); + } catch (err: any) { + return res.status(500).json({ error: err.message || "Failed to send SMS" }); + } +}); + +// GET /api/twilio/templates +router.get("/templates", async (req: Request, res: Response): Promise => { + try { + const userId = req.user?.id; + if (!userId) return res.status(401).json({ message: "Unauthorized" }); + const templates = await storage.getTemplates(userId); + return res.status(200).json(templates); + } catch (err) { + return res.status(500).json({ error: "Failed to fetch templates", details: String(err) }); + } +}); + +// PUT /api/twilio/templates/:key +router.put("/templates/:key", async (req: Request, res: Response): Promise => { + try { + const userId = req.user?.id; + if (!userId) return res.status(401).json({ message: "Unauthorized" }); + const { key } = req.params; + const { body } = req.body; + if (!key || !body?.trim()) return res.status(400).json({ message: "key and body are required" }); + await storage.saveTemplate(userId, key, body.trim()); + return res.status(200).json({ key, body: body.trim() }); + } catch (err) { + return res.status(500).json({ error: "Failed to save template", details: String(err) }); + } +}); + +// GET /api/twilio/recent-communications +router.get("/recent-communications", async (req: Request, res: Response): Promise => { + try { + const userId = req.user?.id; + if (!userId) return res.status(401).json({ message: "Unauthorized" }); + + const limit = Math.min(50, parseInt(req.query.limit as string) || 20); + const communications = await storage.getRecentCommunicationsByUser(userId, limit); + return res.status(200).json(communications); + } catch (err) { + return res.status(500).json({ error: "Failed to fetch recent communications", details: String(err) }); + } +}); + +// POST /api/twilio/make-call +router.post("/make-call", async (req: Request, res: Response): Promise => { + try { + const userId = req.user?.id; + if (!userId) return res.status(401).json({ message: "Unauthorized" }); + + const { to, message, patientId } = req.body; + if (!to) return res.status(400).json({ message: "to is 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 twimlMessage = message || "Hello, this is a call from your dental office."; + const twiml = `${twimlMessage}`; + + const client = getTwilioClient(settings.accountSid, settings.authToken); + const call = await client.calls.create({ + twiml, + from: settings.phoneNumber, + to, + }); + + if (patientId) { + await storage.createCommunication({ + patientId: Number(patientId), + userId, + channel: "voice", + direction: "outbound", + status: "queued", + twilioSid: call.sid, + }); + } + + return res.status(200).json({ sid: call.sid, status: call.status }); + } catch (err: any) { + return res.status(500).json({ error: err.message || "Failed to make call" }); + } +}); + +export default router; diff --git a/apps/Backend/src/storage/index.ts b/apps/Backend/src/storage/index.ts index dca0493..7a2eeef 100755 --- a/apps/Backend/src/storage/index.ts +++ b/apps/Backend/src/storage/index.ts @@ -17,6 +17,7 @@ import { paymentsReportsStorage } from './payments-reports-storage'; import { patientDocumentsStorage } from './patientDocuments-storage'; import * as exportPaymentsReportsStorage from "./export-payments-reports-storage"; import { cronJobLogStorage } from "./cron-job-log-storage"; +import { twilioStorage } from "./twilio-storage"; export const storage = { @@ -37,6 +38,7 @@ export const storage = { ...patientDocumentsStorage, ...exportPaymentsReportsStorage, ...cronJobLogStorage, + ...twilioStorage, }; diff --git a/apps/Backend/src/storage/twilio-storage.ts b/apps/Backend/src/storage/twilio-storage.ts new file mode 100644 index 0000000..9510b83 --- /dev/null +++ b/apps/Backend/src/storage/twilio-storage.ts @@ -0,0 +1,72 @@ +import { prisma as db } from "@repo/db/client"; + +export type TwilioSettingsData = { + accountSid: string; + authToken: string; + phoneNumber: string; + greetingMessage?: string | null; +}; + +export type CommunicationCreateData = { + patientId: number; + userId?: number; + channel: "sms" | "voice"; + direction: "outbound" | "inbound"; + status: "queued" | "sent" | "delivered" | "failed" | "completed" | "busy" | "no_answer"; + body?: string; + callDuration?: number; + twilioSid?: string; +}; + +export const twilioStorage = { + async getTwilioSettings(userId: number) { + return db.twilioSettings.findUnique({ where: { userId } }); + }, + + async upsertTwilioSettings(userId: number, data: TwilioSettingsData) { + return db.twilioSettings.upsert({ + where: { userId }, + update: data, + create: { userId, ...data }, + }); + }, + + async createCommunication(data: CommunicationCreateData) { + return db.communication.create({ data: data as any }); + }, + + async getCommunicationsByPatient(patientId: number) { + return db.communication.findMany({ + where: { patientId }, + orderBy: { createdAt: "asc" }, + }); + }, + + async getTemplates(userId: number): Promise> { + const settings = await db.twilioSettings.findUnique({ where: { userId } }); + if (!settings?.templates) return {}; + return settings.templates as Record; + }, + + async saveTemplate(userId: number, key: string, body: string) { + const settings = await db.twilioSettings.findUnique({ where: { userId } }); + const existing = (settings?.templates as Record) || {}; + const updated = { ...existing, [key]: body }; + return db.twilioSettings.upsert({ + where: { userId }, + update: { templates: updated }, + create: { userId, accountSid: "", authToken: "", phoneNumber: "", templates: updated }, + }); + }, + + async getRecentCommunicationsByUser(userId: number, limit = 20) { + return db.communication.findMany({ + where: { patient: { userId } }, + orderBy: { createdAt: "desc" }, + take: limit, + include: { + patient: { select: { id: true, firstName: true, lastName: true, phone: true } }, + }, + }); + }, +}; diff --git a/apps/Frontend/src/components/patient-connection/message-thread.tsx b/apps/Frontend/src/components/patient-connection/message-thread.tsx index 3d48ba4..eb18f22 100755 --- a/apps/Frontend/src/components/patient-connection/message-thread.tsx +++ b/apps/Frontend/src/components/patient-connection/message-thread.tsx @@ -21,10 +21,7 @@ export function MessageThread({ patient, onBack }: MessageThreadProps) { const { data: communications = [], isLoading } = useQuery({ queryKey: ["/api/patients", patient.id, "communications"], queryFn: async () => { - const res = await fetch(`/api/patients/${patient.id}/communications`, { - credentials: "include", - }); - if (!res.ok) throw new Error("Failed to fetch communications"); + const res = await apiRequest("GET", `/api/patients/${patient.id}/communications`); return res.json(); }, refetchInterval: 5000, // Refresh every 5 seconds to get new messages diff --git a/apps/Frontend/src/components/patient-connection/sms-template-diaog.tsx b/apps/Frontend/src/components/patient-connection/sms-template-diaog.tsx index 3f74254..70683f2 100755 --- a/apps/Frontend/src/components/patient-connection/sms-template-diaog.tsx +++ b/apps/Frontend/src/components/patient-connection/sms-template-diaog.tsx @@ -1,5 +1,5 @@ -import { useState } from "react"; -import { useMutation } from "@tanstack/react-query"; +import { useState, useEffect } from "react"; +import { useQuery, useMutation } from "@tanstack/react-query"; import { Button } from "@/components/ui/button"; import { Dialog, @@ -18,9 +18,9 @@ import { } from "@/components/ui/select"; import { Textarea } from "@/components/ui/textarea"; import { Label } from "@/components/ui/label"; -import { MessageSquare, Send, Loader2 } from "lucide-react"; +import { MessageSquare, Send, Loader2, Save } from "lucide-react"; import { useToast } from "@/hooks/use-toast"; -import { apiRequest } from "@/lib/queryClient"; +import { apiRequest, queryClient } from "@/lib/queryClient"; import type { Patient } from "@repo/db/types"; interface SmsTemplateDialogProps { @@ -29,119 +29,109 @@ interface SmsTemplateDialogProps { patient: Patient | null; } -const MESSAGE_TEMPLATES = { +const DEFAULT_TEMPLATES: Record = { appointment_reminder: { name: "Appointment Reminder", - template: (firstName: string) => + body: (firstName: string) => `Hi ${firstName}, this is your dental office. Reminder: You have an appointment scheduled. Please confirm or call us if you need to reschedule.`, - }, + } as any, appointment_confirmation: { name: "Appointment Confirmation", - template: (firstName: string) => + body: (firstName: string) => `Hi ${firstName}, your appointment has been confirmed. We look forward to seeing you! If you have any questions, please call our office.`, - }, + } as any, follow_up: { name: "Follow-Up", - template: (firstName: string) => + body: (firstName: string) => `Hi ${firstName}, thank you for visiting our dental office. How are you feeling after your treatment? Please let us know if you have any concerns.`, - }, + } as any, payment_reminder: { name: "Payment Reminder", - template: (firstName: string) => + body: (firstName: string) => `Hi ${firstName}, this is a friendly reminder about your outstanding balance. Please contact our office to discuss payment options.`, - }, + } as any, general: { name: "General Message", - template: (firstName: string) => - `Hi ${firstName}, this is your dental office. `, - }, + body: (firstName: string) => `Hi ${firstName}, this is your dental office. `, + } as any, custom: { name: "Custom Message", - template: () => "", - }, + body: () => "", + } as any, }; +const TEMPLATE_KEYS = Object.keys(DEFAULT_TEMPLATES); + +function getDefaultBody(key: string, firstName: string): string { + const t = DEFAULT_TEMPLATES[key]; + if (!t) return ""; + return typeof t.body === "function" ? (t.body as any)(firstName) : t.body; +} + export function SmsTemplateDialog({ open, onOpenChange, patient, }: SmsTemplateDialogProps) { - const [selectedTemplate, setSelectedTemplate] = useState< - keyof typeof MESSAGE_TEMPLATES - >("appointment_reminder"); - const [customMessage, setCustomMessage] = useState(""); + const [selectedKey, setSelectedKey] = useState("appointment_reminder"); + const [messageText, setMessageText] = useState(""); const { toast } = useToast(); - const sendSmsMutation = useMutation({ - mutationFn: async ({ - to, - message, - patientId, - }: { - to: string; - message: string; - patientId: number; - }) => { + const { data: savedTemplates = {} } = useQuery>({ + queryKey: ["/api/twilio/templates"], + enabled: open, + }); + + // Resolve effective body for a given key (saved override or default) + const resolveBody = (key: string) => { + if (key === "custom") return ""; + if (savedTemplates[key]) { + // Replace placeholder first name with actual patient name + return savedTemplates[key].replace(/^Hi \w+,/, `Hi ${patient?.firstName ?? ""},`); + } + return getDefaultBody(key, patient?.firstName ?? ""); + }; + + // When dialog opens or template changes, populate message + useEffect(() => { + if (open) setMessageText(resolveBody(selectedKey)); + }, [open, selectedKey, savedTemplates, patient?.firstName]); + + const sendMutation = useMutation({ + mutationFn: async (message: string) => { return apiRequest("POST", "/api/twilio/send-sms", { - to, + to: patient!.phone, message, - patientId, + patientId: patient!.id, }); }, onSuccess: () => { - toast({ - title: "SMS Sent Successfully", - description: `Message sent to ${patient?.firstName} ${patient?.lastName}`, - }); + toast({ title: "SMS Sent", description: `Message sent to ${patient?.firstName} ${patient?.lastName}` }); onOpenChange(false); - // Reset state - setSelectedTemplate("appointment_reminder"); - setCustomMessage(""); + setSelectedKey("appointment_reminder"); + setMessageText(""); }, - onError: (error: any) => { - toast({ - title: "Failed to Send SMS", - description: - error.message || - "Please check your Twilio configuration and try again.", - variant: "destructive", - }); + onError: (err: any) => { + toast({ title: "Failed to Send SMS", description: err.message || "Please check your Twilio configuration.", variant: "destructive" }); }, }); - const getMessage = () => { - if (!patient) return ""; + const updateMutation = useMutation({ + mutationFn: async ({ key, body }: { key: string; body: string }) => { + return apiRequest("PUT", `/api/twilio/templates/${key}`, { body }); + }, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ["/api/twilio/templates"] }); + toast({ title: "Template Updated", description: "This template will be used going forward." }); + }, + onError: (err: any) => { + toast({ title: "Failed to Update Template", description: err.message, variant: "destructive" }); + }, + }); - if (selectedTemplate === "custom") { - return customMessage; - } - - return MESSAGE_TEMPLATES[selectedTemplate].template(patient.firstName); - }; - - const handleSend = () => { - if (!patient || !patient.phone) return; - - const message = getMessage(); - if (!message.trim()) return; - - sendSmsMutation.mutate({ - to: patient.phone, - message: message, - patientId: Number(patient.id), - }); - }; - - const handleTemplateChange = (value: string) => { - const templateKey = value as keyof typeof MESSAGE_TEMPLATES; - setSelectedTemplate(templateKey); - - // Pre-fill custom message if not custom template - if (templateKey !== "custom" && patient) { - setCustomMessage( - MESSAGE_TEMPLATES[templateKey].template(patient.firstName) - ); - } + const handleTemplateChange = (key: string) => { + setSelectedKey(key); + setMessageText(resolveBody(key)); }; return ( @@ -153,24 +143,22 @@ export function SmsTemplateDialog({ Send SMS to {patient?.firstName} {patient?.lastName} - Choose a message template or write a custom message + Choose a template or write a custom message
- - + - {Object.entries(MESSAGE_TEMPLATES).map(([key, value]) => ( + {TEMPLATE_KEYS.map((key) => ( - {value.name} + {DEFAULT_TEMPLATES[key]?.name ?? key} + {savedTemplates[key] ? " ✎" : ""} ))} @@ -178,13 +166,29 @@ export function SmsTemplateDialog({
- +
+ + {selectedKey !== "custom" && ( + + )} +