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); 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, 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 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 { 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/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;