Files
DentalManagementMH07/apps/Backend/src/routes/twilio.ts
ff cef928be97 feat: add AI recall calling — books existing patients for routine cleanings
Adds a new recall conversation flow (recall-graph.ts) triggered from the
Patient Connection "AI Call" button: offers an existing patient a routine
exam & cleaning, classifies their yes/no reply, then books directly onto
the schedule (Column A, 30-minute slots, real conflict checking against
office hours and existing appointments) instead of only capturing a
preference for staff to follow up on.

Also adds a "Voice call" label in the message thread to distinguish call
transcripts from SMS, and updates the Call Templates settings UI with an
editable recall greeting.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-02 23:13:09 -04:00

604 lines
23 KiB
TypeScript

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";
import { resolveAiProvider } from "../ai/llm-factory";
import { getTwilioPublicBaseUrl } from "../utils/publicUrl";
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<any> => {
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<string, string>) || {};
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<any> => {
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<string, string>) || {};
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<any> => {
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/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<any> => {
try {
const userId = req.user?.id;
if (!userId) return res.status(401).json({ message: "Unauthorized" });
const { to, patientId, purpose } = 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 callPurpose = purpose === "recall" ? "recall" : "reminder";
const webhookUrl = `${publicBaseUrl}/api/twilio/webhook/ai-voice?patientId=${patient.id}&purpose=${callPurpose}`;
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<any> => {
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<number>();
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<any> => {
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<number>();
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<any> => {
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<any> => {
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<any> => {
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<any> => {
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<any> => {
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<any> => {
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<any> => {
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<any> => {
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<any> => {
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<any> => {
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<string, string>) || {};
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<any> => {
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 = `<Response><Say>${twimlMessage}</Say></Response>`;
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;