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(); 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); const templates = (settings.templates as Record) || {}; return res.status(200).json({ id: settings.id, accountSid: settings.accountSid, authToken: settings.authToken, phoneNumber: settings.phoneNumber, greetingMessage: settings.greetingMessage, twimlAppSid: templates["_twiml_app_sid"] || null, }); } 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, twimlAppSid } = 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, twimlAppSid: twimlAppSid?.trim() || null, }); const templates = (settings.templates as Record) || {}; return res.status(200).json({ id: settings.id, accountSid: settings.accountSid, authToken: settings.authToken, phoneNumber: settings.phoneNumber, greetingMessage: settings.greetingMessage, twimlAppSid: templates["_twiml_app_sid"] || null, }); } 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, startFlow } = 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) { const pid = Number(patientId); await storage.createCommunication({ patientId: pid, userId, channel: "sms", direction: "outbound", status: "sent", body: message, twilioSid: twilioMsg.sid, }); // Set conversation stage based on which flow was started if (startFlow === "new_patient") { 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); } } 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" }); } }); // 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, address, phone, and reminder SMS template const officeContact = await storage.getOfficeContact(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 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 come to our office at {officeAddress}. 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(/\{officeAddress\}/g, officeAddress) .replace(/\{officePhone\}/g, officePhone) .replace(/\{twilioPhone\}/g, settings.phoneNumber) .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" }); } }); // POST /api/twilio/send-reschedule-batch router.post("/send-reschedule-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." }); } // Find the "Reschedule by office" template from the SMS template list const templateList = await storage.getSmsTemplateList(userId); const rescheduleTemplate = templateList.find((t) => t.name.toLowerCase().includes("reschedule") && t.name.toLowerCase().includes("office") ) || templateList.find((t) => t.name.toLowerCase().includes("reschedule")); const DEFAULT_RESCHEDULE_SMS = "Hi {firstName}, this is {officeName}. We need to reschedule your appointment. Please reply to let us know your availability. Thank you!"; const templateBody = rescheduleTemplate?.body?.trim() || DEFAULT_RESCHEDULE_SMS; const officeContact = await storage.getOfficeContact(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 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" }, }); 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; 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(/\{officeAddress\}/g, officeAddress) .replace(/\{officePhone\}/g, officePhone) .replace(/\{twilioPhone\}/g, settings.phoneNumber) .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 startRescheduleConversation(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 reschedule messages" }); } }); // GET /api/twilio/sms-template-list router.get("/sms-template-list", async (req: Request, res: Response): Promise => { try { const userId = req.user?.id; if (!userId) return res.status(401).json({ message: "Unauthorized" }); const list = await storage.getSmsTemplateList(userId); return res.status(200).json(list); } catch (err) { return res.status(500).json({ error: "Failed to fetch SMS templates", details: String(err) }); } }); // PUT /api/twilio/sms-template-list router.put("/sms-template-list", async (req: Request, res: Response): Promise => { try { const userId = req.user?.id; if (!userId) return res.status(401).json({ message: "Unauthorized" }); const templates = req.body; if (!Array.isArray(templates)) return res.status(400).json({ message: "Expected an array of templates" }); await storage.saveSmsTemplateList(userId, templates); return res.status(200).json({ ok: true }); } catch (err) { return res.status(500).json({ error: "Failed to save SMS templates", details: String(err) }); } }); // 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/after-hours-handoff router.get("/after-hours-handoff", async (req: Request, res: Response): Promise => { try { const userId = req.user?.id; if (!userId) return res.status(401).json({ message: "Unauthorized" }); return res.status(200).json({ enabled: await getAfterHoursHandoff(userId) }); } catch (err) { return res.status(500).json({ error: "Failed to get after-hours handoff state" }); } }); // PUT /api/twilio/after-hours-handoff router.put("/after-hours-handoff", async (req: Request, res: Response): Promise => { try { const userId = req.user?.id; if (!userId) return res.status(401).json({ message: "Unauthorized" }); const { enabled } = req.body; if (typeof enabled !== "boolean") return res.status(400).json({ message: "enabled must be a boolean" }); await setAfterHoursHandoff(userId, enabled); return res.status(200).json({ enabled }); } catch (err) { return res.status(500).json({ error: "Failed to set after-hours handoff state" }); } }); // GET /api/twilio/ai-handoff/:patientId router.get("/ai-handoff/:patientId", async (req: Request, res: Response): Promise => { try { const userId = req.user?.id; if (!userId) return res.status(401).json({ message: "Unauthorized" }); const patientId = parseInt(req.params.patientId); if (isNaN(patientId)) return res.status(400).json({ message: "Invalid patientId" }); return res.status(200).json({ enabled: await getHandoff(userId, patientId) }); } catch (err) { return res.status(500).json({ error: "Failed to get AI handoff state" }); } }); // PUT /api/twilio/ai-handoff/:patientId router.put("/ai-handoff/:patientId", async (req: Request, res: Response): Promise => { try { const userId = req.user?.id; if (!userId) return res.status(401).json({ message: "Unauthorized" }); const patientId = parseInt(req.params.patientId); if (isNaN(patientId)) return res.status(400).json({ message: "Invalid patientId" }); const { enabled } = req.body; if (typeof enabled !== "boolean") return res.status(400).json({ message: "enabled must be a boolean" }); await setHandoff(userId, patientId, enabled); return res.status(200).json({ enabled }); } catch (err) { return res.status(500).json({ error: "Failed to set AI handoff state" }); } }); // 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/voice-token // Returns a short-lived Twilio Access Token so the browser Voice SDK can place calls. router.post("/voice-token", 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(400).json({ message: "Twilio is not configured. Please add your Twilio credentials in Settings." }); } const templates = (settings.templates as Record) || {}; const twimlAppSid = templates["_twiml_app_sid"]?.trim(); if (!twimlAppSid) { return res.status(400).json({ message: "TwiML App SID is not configured. Please add it in Twilio Settings." }); } const AccessToken = twilio.jwt.AccessToken; const VoiceGrant = AccessToken.VoiceGrant; const token = new AccessToken( settings.accountSid, settings.accountSid, settings.authToken, { identity: `user-${userId}`, ttl: 3600 } ); const voiceGrant = new VoiceGrant({ outgoingApplicationSid: twimlAppSid, incomingAllow: false }); token.addGrant(voiceGrant); return res.status(200).json({ token: token.toJwt(), phoneNumber: settings.phoneNumber }); } catch (err: any) { return res.status(500).json({ error: err.message || "Failed to generate voice token" }); } }); // 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;