diff --git a/apps/Backend/src/routes/ai-settings.ts b/apps/Backend/src/routes/ai-settings.ts index 87b42e33..72512f5d 100644 --- a/apps/Backend/src/routes/ai-settings.ts +++ b/apps/Backend/src/routes/ai-settings.ts @@ -53,8 +53,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 } = req.body; - await storage.saveAiChatTemplates(userId, { reminderGreeting, newPatientGreeting, generalFallback, rescheduleGreeting }); + const { reminderGreeting, newPatientGreeting, generalFallback, rescheduleGreeting, reminderSms } = req.body; + await storage.saveAiChatTemplates(userId, { reminderGreeting, newPatientGreeting, generalFallback, rescheduleGreeting, reminderSms }); const updated = await storage.getAiChatTemplates(userId); return res.status(200).json(updated); } catch (err) { diff --git a/apps/Backend/src/routes/twilio.ts b/apps/Backend/src/routes/twilio.ts index 23e6590e..b5bef69f 100644 --- a/apps/Backend/src/routes/twilio.ts +++ b/apps/Backend/src/routes/twilio.ts @@ -1,6 +1,7 @@ import express, { Request, Response } from "express"; 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"; const router = express.Router(); @@ -97,7 +98,10 @@ router.post("/send-sms", async (req: Request, res: Response): Promise => { await startNewPatientConversation(userId, pid); } else if (startFlow === "reschedule") { await startRescheduleConversation(userId, pid); + } else if (startFlow === "no_follow_up") { + // AI follow-up disabled — leave existing stage untouched } else { + // "reminder" or unspecified → start reminder AI flow await resetConversation(userId, pid); } } @@ -108,6 +112,116 @@ router.post("/send-sms", async (req: Request, res: Response): Promise => { } }); +// POST /api/twilio/send-reminders-batch +router.post("/send-reminders-batch", async (req: Request, res: Response): Promise => { + try { + const userId = req.user?.id; + if (!userId) return res.status(401).json({ message: "Unauthorized" }); + + const { date, staffIds, aiFollowUp = true } = req.body as { + date: string; + staffIds: number[]; + aiFollowUp?: boolean; + }; + if (!date || !Array.isArray(staffIds) || staffIds.length === 0) { + return res.status(400).json({ message: "date and staffIds 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." }); + } + + // Resolve office name and reminder SMS template + const officeContact = await storage.getOfficeContact(userId); + const officeName = (officeContact as any)?.officeName?.trim() || ""; + const chatTemplates = await storage.getAiChatTemplates(userId); + + const DEFAULT_REMINDER_SMS = + "Hi {firstName}, this is a reminder from {officeName}. You have an appointment on {appointmentDate} at {appointmentTime}. Please reply YES to confirm or NO to reschedule. Thank you!"; + const templateBody = chatTemplates.reminderSms?.trim() || DEFAULT_REMINDER_SMS; + + // Fetch appointments for the selected staff columns on the given date + const dayStart = new Date(date); + dayStart.setUTCHours(0, 0, 0, 0); + const dayEnd = new Date(date); + dayEnd.setUTCHours(23, 59, 59, 999); + + const appointments = await db.appointment.findMany({ + where: { + staffId: { in: staffIds }, + date: { gte: dayStart, lte: dayEnd }, + status: { not: "cancelled" }, + patient: { userId }, + }, + include: { + patient: { select: { id: true, firstName: true, phone: true } }, + }, + orderBy: { startTime: "asc" }, + }); + + // Format a date string as "May 9, 2026" + const months = ["January","February","March","April","May","June","July","August","September","October","November","December"]; + const formatApptDate = (d: Date | string) => { + const dt = new Date(d); + return `${months[dt.getUTCMonth()]} ${dt.getUTCDate()}, ${dt.getUTCFullYear()}`; + }; + + const client = getTwilioClient(settings.accountSid, settings.authToken); + let sent = 0; + let skipped = 0; + + // Deduplicate by patientId — send at most one reminder per patient per run + const seen = new Set(); + + for (const appt of appointments) { + const patient = appt.patient; + if (!patient?.phone || seen.has(patient.id)) { skipped++; continue; } + seen.add(patient.id); + + const apptDate = formatApptDate(appt.date); + const apptTime = typeof appt.startTime === "string" + ? appt.startTime.substring(0, 5) + : String(appt.startTime); + + const message = templateBody + .replace(/\{firstName\}/g, patient.firstName ?? "") + .replace(/\{officeName\}/g, officeName) + .replace(/\{appointmentDate\}/g, apptDate) + .replace(/\{appointmentTime\}/g, apptTime) + .replace(/\{date\}/g, apptDate) + .replace(/\{time\}/g, apptTime); + + try { + const twilioMsg = await client.messages.create({ + body: message, + from: settings.phoneNumber, + to: patient.phone, + }); + await storage.createCommunication({ + patientId: patient.id, + userId, + channel: "sms", + direction: "outbound", + status: "sent", + body: message, + twilioSid: twilioMsg.sid, + }); + if (aiFollowUp) { + await resetConversation(userId, patient.id); + } + sent++; + } catch { + skipped++; + } + } + + return res.status(200).json({ sent, skipped }); + } catch (err: any) { + return res.status(500).json({ error: err.message || "Failed to send reminders" }); + } +}); + // GET /api/twilio/templates router.get("/templates", 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 7d57ad7d..70e3a3cc 100644 --- a/apps/Backend/src/storage/twilio-storage.ts +++ b/apps/Backend/src/storage/twilio-storage.ts @@ -67,10 +67,11 @@ export const twilioStorage = { newPatientGreeting: all["_ai_chat_new_patient_greeting"] ?? "", generalFallback: all["_ai_chat_general_fallback"] ?? "", rescheduleGreeting: all["_ai_chat_reschedule_greeting"] ?? "", + reminderSms: all["_ai_chat_reminder_sms"] ?? "", }; }, - async saveAiChatTemplates(userId: number, templates: { reminderGreeting?: string; newPatientGreeting?: string; generalFallback?: string; rescheduleGreeting?: string }) { + async saveAiChatTemplates(userId: number, templates: { reminderGreeting?: string; newPatientGreeting?: string; generalFallback?: string; rescheduleGreeting?: string; reminderSms?: string }) { const settings = await db.twilioSettings.findUnique({ where: { userId } }); const existing = (settings?.templates as Record) || {}; const updated: Record = { ...existing }; @@ -78,6 +79,7 @@ export const twilioStorage = { if (templates.newPatientGreeting !== undefined) updated["_ai_chat_new_patient_greeting"] = templates.newPatientGreeting; 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; return db.twilioSettings.upsert({ where: { userId }, update: { templates: updated }, diff --git a/apps/Frontend/src/components/settings/ai-chat-templates-card.tsx b/apps/Frontend/src/components/settings/ai-chat-templates-card.tsx index ab6cbb46..b8e88325 100644 --- a/apps/Frontend/src/components/settings/ai-chat-templates-card.tsx +++ b/apps/Frontend/src/components/settings/ai-chat-templates-card.tsx @@ -12,6 +12,7 @@ type AiChatTemplates = { newPatientGreeting: string; generalFallback: string; rescheduleGreeting: string; + reminderSms: string; }; type OfficeContact = { @@ -19,6 +20,8 @@ type OfficeContact = { }; const DEFAULTS = { + reminderSms: + "Hi {firstName}, this is a reminder from {officeName}. You have an appointment on {appointmentDate} at {appointmentTime}. Please reply YES to confirm or NO to reschedule. Thank you!", reminderGreeting: "Hi! My name is Lisa, the dedicated AI assistant at {officeName}. I can confirm or reschedule your appointment and answer general questions 24/7. How can I help you today?", newPatientGreeting: @@ -36,6 +39,7 @@ function preview(text: string, officeName: string) { export function AiChatTemplatesCard() { const { toast } = useToast(); + const [reminderSms, setReminderSms] = useState(DEFAULTS.reminderSms); const [reminderGreeting, setReminderGreeting] = useState(DEFAULTS.reminderGreeting); const [newPatientGreeting, setNewPatientGreeting] = useState(DEFAULTS.newPatientGreeting); const [generalFallback, setGeneralFallback] = useState(DEFAULTS.generalFallback); @@ -67,6 +71,7 @@ export function AiChatTemplatesCard() { useEffect(() => { if (templates && !initialized.current) { initialized.current = true; + setReminderSms(templates.reminderSms || DEFAULTS.reminderSms); setReminderGreeting(templates.reminderGreeting || DEFAULTS.reminderGreeting); setNewPatientGreeting(templates.newPatientGreeting || DEFAULTS.newPatientGreeting); setGeneralFallback(templates.generalFallback || DEFAULTS.generalFallback); @@ -95,6 +100,7 @@ export function AiChatTemplatesCard() { const handleSubmit = (e: React.FormEvent) => { e.preventDefault(); saveMutation.mutate({ + reminderSms: reminderSms.trim() || DEFAULTS.reminderSms, reminderGreeting: reminderGreeting.trim() || DEFAULTS.reminderGreeting, newPatientGreeting: newPatientGreeting.trim() || DEFAULTS.newPatientGreeting, generalFallback: generalFallback.trim() || DEFAULTS.generalFallback, @@ -105,6 +111,15 @@ export function AiChatTemplatesCard() { const officeName = officeContact?.officeName?.trim() || ""; const templates_list = [ + { + key: "reminderSms" as const, + icon: , + label: "Reminder SMS Text", + description: "Outgoing text sent from the Schedule page. Supports: {firstName}, {officeName}, {appointmentDate}, {appointmentTime}.", + value: reminderSms, + onChange: setReminderSms, + placeholder: DEFAULTS.reminderSms, + }, { key: "reminder" as const, icon: , @@ -152,9 +167,12 @@ export function AiChatTemplatesCard() {

AI Chat Templates

- Customize how your AI assistant introduces itself and responds to patients. Use{" "} + Customize the reminder SMS and AI reply templates. Available variables:{" "} + {"{firstName}"}{" "} {"{officeName}"}{" "} - as a placeholder — it will be replaced automatically with your dental office name. + {"{appointmentDate}"}{" "} + {"{appointmentTime}"}{" "} + — replaced automatically when reminders are sent.

{/* Office name hint */} @@ -208,6 +226,7 @@ export function AiChatTemplatesCard() { variant="ghost" className="text-xs text-muted-foreground" onClick={() => { + setReminderSms(DEFAULTS.reminderSms); setReminderGreeting(DEFAULTS.reminderGreeting); setNewPatientGreeting(DEFAULTS.newPatientGreeting); setGeneralFallback(DEFAULTS.generalFallback); diff --git a/apps/Frontend/src/pages/appointments-page.tsx b/apps/Frontend/src/pages/appointments-page.tsx index 804a330a..4b2bd0b9 100755 --- a/apps/Frontend/src/pages/appointments-page.tsx +++ b/apps/Frontend/src/pages/appointments-page.tsx @@ -158,6 +158,9 @@ export default function AppointmentsPage() { const [selectedClaimColumns, setSelectedClaimColumns] = useState>(new Set()); const [isClaimingColumn, setIsClaimingColumn] = useState(false); const [selectedReminderColumns, setSelectedReminderColumns] = useState>(new Set()); + const [isSendingReminders, setIsSendingReminders] = useState(false); + const [reminderAiFollowUp, setReminderAiFollowUp] = useState(true); + const [selectedRescheduleColumns, setSelectedRescheduleColumns] = useState>(new Set()); const [selectedDownloadPdfColumns, setSelectedDownloadPdfColumns] = useState>(new Set()); const [isDownloadingClaimPdfs, setIsDownloadingClaimPdfs] = useState(false); const [columnLabels, setColumnLabels] = useState>({}); @@ -187,6 +190,15 @@ export default function AppointmentsPage() { }); }; + const toggleRescheduleColumn = (staffId: number) => { + setSelectedRescheduleColumns((prev) => { + const next = new Set(prev); + if (next.has(staffId)) next.delete(staffId); + else next.add(staffId); + return next; + }); + }; + const toggleDownloadPdfColumn = (staffId: number) => { setSelectedDownloadPdfColumns((prev) => { const next = new Set(prev); @@ -1202,6 +1214,27 @@ export default function AppointmentsPage() { } }; + const handleSendRemindersForColumn = async () => { + if (!user || selectedReminderColumns.size === 0) return; + setIsSendingReminders(true); + try { + const res = await apiRequest("POST", "/api/twilio/send-reminders-batch", { + date: formattedSelectedDate, + staffIds: Array.from(selectedReminderColumns), + aiFollowUp: reminderAiFollowUp, + }); + const { sent, skipped } = await res.json(); + toast({ + title: "Text Reminders Sent", + description: `Sent ${sent} reminder${sent !== 1 ? "s" : ""}${skipped > 0 ? `, skipped ${skipped} (no phone)` : ""}.`, + }); + } catch (err: any) { + toast({ title: "Failed to Send Reminders", description: err?.message ?? String(err), variant: "destructive" }); + } finally { + setIsSendingReminders(false); + } + }; + const handleDownloadClaimPdfs = async () => { if (!user || selectedDownloadPdfColumns.size === 0) return; const staffIdsParam = Array.from(selectedDownloadPdfColumns).join(","); @@ -1349,10 +1382,21 @@ export default function AppointmentsPage() { {/* Text Reminder for Column section */}
{staffMembers.map((staff, index) => ( ))} +
+ + AI follow up +
+
+ + {/* Reschedule for Column section */} +
+ + {staffMembers.map((staff, index) => ( + + ))}
{/* Download Claim PDF for Column section */}