feat: migrate SMS/voice to Telnyx (TeXML), keep browser dial pad on Twilio
Twilio now handles only the in-browser dial pad (Access Token + browser-Device outbound TwiML); Telnyx takes over SMS, inbound voicemail, the AI voice conversation loop, and staff-triggered outbound calls via TeXML. Adds TelnyxSettings model, Communication.provider discriminator, new comms/routes modules, and a real Telnyx Settings page. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -33,6 +33,7 @@ import paymentsReportsRoutes from "./payments-reports";
|
||||
import exportPaymentsReportsRoutes from "./export-payments-reports";
|
||||
import jobMonitorRoutes from "./job-monitor";
|
||||
import twilioRoutes from "./twilio";
|
||||
import telnyxRoutes from "./telnyx";
|
||||
import aiSettingsRoutes from "./ai-settings";
|
||||
import officeHoursRoutes from "./office-hours";
|
||||
import officeContactRoutes from "./office-contact";
|
||||
@@ -85,6 +86,7 @@ router.use("/export-payments-reports", exportPaymentsReportsRoutes);
|
||||
router.use("/job-monitor", jobMonitorRoutes);
|
||||
router.use("/type-agent", typeAgentRoutes);
|
||||
router.use("/twilio", twilioRoutes);
|
||||
router.use("/telnyx", telnyxRoutes);
|
||||
router.use("/ai", aiSettingsRoutes);
|
||||
router.use("/office-hours", officeHoursRoutes);
|
||||
router.use("/office-contact", officeContactRoutes);
|
||||
|
||||
@@ -0,0 +1,897 @@
|
||||
import express, { Request, Response } from "express";
|
||||
import { storage } from "../storage";
|
||||
import { DEFAULT_GREETING, DEFAULT_RECALL_GREETING } from "../storage/twilio-storage";
|
||||
import { prisma as db } from "@repo/db/client";
|
||||
import { sendTelnyxSms } from "../comms/telnyx-client";
|
||||
import { runNewPatientStep } from "../ai/new-patient-graph";
|
||||
import {
|
||||
parseDateOnlyFromMessage,
|
||||
parseTime,
|
||||
isOfficeDayOpen,
|
||||
isWithinOfficeHours,
|
||||
getOfficeHoursDisplay,
|
||||
timeLabel,
|
||||
} from "../ai/reschedule-graph";
|
||||
import { getLlm, resolveAiProvider, type AiProvider } from "../ai/llm-factory";
|
||||
import { soundsLikeGoodbye } from "../ai/voice-assistant";
|
||||
import { getPublicBaseUrl } from "../utils/publicUrl";
|
||||
import {
|
||||
routePatientTurn, getAppointmentDatetime, applyOfficeName,
|
||||
} from "../ai/patient-conversation-router";
|
||||
import {
|
||||
getHandoff, getAfterHoursHandoff,
|
||||
getStage, setStage, resetConversation, startRecallConversation,
|
||||
setPendingReschedule, getPendingReschedule, clearPendingReschedule,
|
||||
type ConversationStage,
|
||||
} from "../ai/aiHandoffStore";
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
// ── Helpers ───────────────────────────────────────────────────────────────────
|
||||
|
||||
function escapeXml(text: string): string {
|
||||
return text
|
||||
.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">")
|
||||
.replace(/"/g, """).replace(/'/g, "'");
|
||||
}
|
||||
|
||||
function ok(res: Response) {
|
||||
return res.status(200).json({ ok: true });
|
||||
}
|
||||
|
||||
type TelnyxSmsSettings = { apiKey: string; phoneNumber: string; messagingProfileId?: string | null };
|
||||
|
||||
/** Telnyx has no synchronous TeXML-style SMS reply — every reply is a
|
||||
* separate outbound API call. Sequential await preserves ordering. */
|
||||
async function sendReplies(settings: TelnyxSmsSettings, to: string, texts: string[]): Promise<void> {
|
||||
for (const text of texts) {
|
||||
await sendTelnyxSms({
|
||||
apiKey: settings.apiKey, from: settings.phoneNumber, to, text,
|
||||
messagingProfileId: settings.messagingProfileId,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/** Check if right now is outside office hours for the given user. */
|
||||
async function isAfterHours(userId: number): Promise<boolean> {
|
||||
const record = await storage.getOfficeHours(userId);
|
||||
if (!record?.data) return false; // no hours configured → treat as in-hours
|
||||
|
||||
const data = record.data as any;
|
||||
const now = new Date();
|
||||
const days = ["sunday","monday","tuesday","wednesday","thursday","friday","saturday"];
|
||||
const day = days[now.getDay()];
|
||||
|
||||
const slot = data.doctors?.[day];
|
||||
if (!slot?.enabled) return true; // office closed today
|
||||
|
||||
const hhmm = `${String(now.getHours()).padStart(2,"0")}:${String(now.getMinutes()).padStart(2,"0")}`;
|
||||
if (hhmm >= slot.amStart && hhmm <= slot.amEnd) return false;
|
||||
if (hhmm >= slot.pmStart && hhmm <= slot.pmEnd) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
/** Save an outbound message and return the text. */
|
||||
async function saveOutbound(patientId: number, body: string): Promise<void> {
|
||||
await storage.createCommunication({
|
||||
patientId, channel: "sms", direction: "outbound", status: "sent", body, provider: "telnyx",
|
||||
});
|
||||
}
|
||||
|
||||
// ── Empathetic one-liner (instant keyword-based, no API latency) ─────────────
|
||||
|
||||
function getEmpatheticAck(message: string, language: string): string {
|
||||
const hasPain = /pain|hurt|ache|toothache|emergency|urgent|asap|bleeding|broke|crack|fell out|swollen|infection|abscess/i.test(message);
|
||||
|
||||
const painMsgs: Record<string, string> = {
|
||||
English: "Sorry to hear that! We are here to help you.",
|
||||
Spanish: "¡Lo sentimos! Estamos aquí para ayudarle.",
|
||||
Portuguese: "Lamentamos isso! Estamos aqui para ajudá-lo.",
|
||||
Mandarin: "很遗憾听到这个消息!我们在这里帮助您。",
|
||||
Cantonese: "很遺憾聽到這個消息!我們在這裡幫助您。",
|
||||
Arabic: "آسف لسماع ذلك! نحن هنا لمساعدتك.",
|
||||
"Haitian Creole": "Nou regrèt tande sa! Nou la pou ede ou.",
|
||||
};
|
||||
const normalMsgs: Record<string, string> = {
|
||||
English: "No problem!",
|
||||
Spanish: "¡Sin problema!",
|
||||
Portuguese: "Sem problema!",
|
||||
Mandarin: "没问题!",
|
||||
Cantonese: "沒問題!",
|
||||
Arabic: "لا مشكلة!",
|
||||
"Haitian Creole": "Pa gen pwoblèm!",
|
||||
};
|
||||
|
||||
return hasPain
|
||||
? (painMsgs[language] ?? painMsgs["English"]!)
|
||||
: (normalMsgs[language] ?? normalMsgs["English"]!);
|
||||
}
|
||||
|
||||
// ── Stale-conversation handling (SMS) ─────────────────────────────────────────
|
||||
// Unlike a phone call, a long gap between texts doesn't necessarily mean the
|
||||
// patient walked away — replies are often just slow. So instead of hard
|
||||
// resetting on any idle gap, we only second-guess the stored stage once the
|
||||
// gap crosses STALE_SMS_THRESHOLD_MS, and even then we ask the LLM to look at
|
||||
// the recent history before deciding whether the new text is still answering
|
||||
// the last question or reads like a fresh, unrelated conversation.
|
||||
|
||||
const STALE_SMS_THRESHOLD_MS = 3 * 60 * 60 * 1000; // 3 hours
|
||||
|
||||
const STILL_HERE_MSGS: Record<string, string> = {
|
||||
English: "It's been a while since our last message. How can I help you today?",
|
||||
Spanish: "Ha pasado un tiempo desde nuestro último mensaje. ¿Cómo puedo ayudarle hoy?",
|
||||
Portuguese: "Já faz um tempo desde nossa última mensagem. Como posso ajudá-lo hoje?",
|
||||
Mandarin: "距离我们上次的消息已经有一段时间了。今天我能帮您什么?",
|
||||
Cantonese: "距離我們上次嘅消息已經有一段時間喇。今日我可以點幫您?",
|
||||
Arabic: "لقد مر بعض الوقت منذ آخر رسالة لنا. كيف يمكنني مساعدتك اليوم؟",
|
||||
"Haitian Creole": "Sa gen yon ti tan depi dènye mesaj nou. Kijan mwen ka ede ou jodi a?",
|
||||
};
|
||||
|
||||
/**
|
||||
* After a long idle gap, ask the LLM whether the patient's new text still
|
||||
* answers what was last asked (recent history + stage in context) or reads
|
||||
* like an unrelated, fresh message. Defaults to "continue" on any failure —
|
||||
* an LLM call error shouldn't be the reason a legitimate reply gets bounced.
|
||||
*/
|
||||
async function isLikelyContinuation(
|
||||
recentHistory: { role: "user" | "assistant"; text: string }[],
|
||||
newMessage: string,
|
||||
stage: ConversationStage,
|
||||
apiKey: string,
|
||||
provider: AiProvider,
|
||||
model?: string,
|
||||
): Promise<boolean> {
|
||||
if (recentHistory.length === 0) return true;
|
||||
try {
|
||||
const llm = getLlm(provider, apiKey, model);
|
||||
const transcript = recentHistory
|
||||
.map(m => `${m.role === "assistant" ? "Office" : "Patient"}: ${m.text}`)
|
||||
.join("\n");
|
||||
const res = await llm.invoke([
|
||||
{
|
||||
role: "system",
|
||||
content:
|
||||
`You are helping a dental office text-messaging assistant resume a conversation with a patient after a long gap of silence. ` +
|
||||
`Here is the recent conversation (oldest first):\n${transcript}\n\n` +
|
||||
`The assistant is currently waiting on stage "${stage}" of a booking flow. ` +
|
||||
`The patient just sent a new message after the long gap: "${newMessage}"\n\n` +
|
||||
`Does this new message still look like it's answering/continuing that same conversation, or does it read like an unrelated, fresh message (e.g. a new question, a greeting, a different topic)? ` +
|
||||
`Reply with ONLY one word: "continue" or "restart".`,
|
||||
},
|
||||
]);
|
||||
return !/restart/i.test(String(res.content));
|
||||
} catch {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
// ── POST /api/telnyx/webhook/sms ──────────────────────────────────────────────
|
||||
|
||||
const CONVO_TIMEOUT_MS = 5 * 60 * 1000; // 5 minutes
|
||||
|
||||
// In-memory conversation state for unknown phone numbers (no patient record yet).
|
||||
// Keyed by normalized "From" number.
|
||||
const unknownPhoneConvos = new Map<string, {
|
||||
userId: number;
|
||||
stage: ConversationStage;
|
||||
language: string;
|
||||
lastActivityAt: Date;
|
||||
pendingApptDate?: { date: Date; dateLabel: string };
|
||||
}>();
|
||||
|
||||
router.post("/webhook/sms", async (req: Request, res: Response): Promise<any> => {
|
||||
try {
|
||||
// Telnyx webhooks are JSON with a nested event envelope, not Twilio's flat
|
||||
// form-encoded From/To/Body/MessageSid. Ignore any event type other than
|
||||
// an inbound message — Telnyx may fan message.sent/message.finalized into
|
||||
// the same webhook URL depending on portal config.
|
||||
const event = (req.body as any)?.data;
|
||||
if (!event || event.event_type !== "message.received") return ok(res);
|
||||
const payload = event.payload || {};
|
||||
const From = payload?.from?.phone_number || "";
|
||||
const To = payload?.to?.[0]?.phone_number || "";
|
||||
const Body = payload?.text || "";
|
||||
const MessageSid = payload?.id;
|
||||
|
||||
const normalizedFrom = (From || "").replace(/\D/g, "");
|
||||
// Ordered by id so that when duplicate patients share a phone number
|
||||
// (one record per insurance), the earliest-created record is the default.
|
||||
const allPatients = await db.patient.findMany({
|
||||
select: { id: true, phone: true, userId: true, preferredLanguage: true },
|
||||
orderBy: { id: "asc" },
|
||||
});
|
||||
const patient = allPatients.find(
|
||||
(p) => p.phone && p.phone.replace(/\D/g, "") === normalizedFrom
|
||||
);
|
||||
|
||||
if (!patient) {
|
||||
// Unknown number — look up office by the Telnyx "To" number
|
||||
const normalizedTo = (To || "").replace(/\D/g, "");
|
||||
const telnyxRow = await db.telnyxSettings.findFirst({
|
||||
where: { phoneNumber: { contains: normalizedTo.slice(-10) } },
|
||||
});
|
||||
if (!telnyxRow) return ok(res);
|
||||
|
||||
const userId = telnyxRow.userId;
|
||||
const openPhoneReply = await storage.getOpenPhoneReply(userId);
|
||||
if (!openPhoneReply) return ok(res);
|
||||
|
||||
// Fetch required context for this office
|
||||
const aiSettings = await storage.getAiSettings(userId);
|
||||
const activeAiU = resolveAiProvider(aiSettings ?? {});
|
||||
if (!activeAiU) return ok(res);
|
||||
|
||||
const chatTemplates = await storage.getAiChatTemplates(userId);
|
||||
const officeContact = await storage.getOfficeContact(userId);
|
||||
const officeName = (officeContact as any)?.officeName?.trim() || "";
|
||||
const convo = unknownPhoneConvos.get(normalizedFrom);
|
||||
let stage = convo?.stage ?? "initial";
|
||||
const language = convo?.language ?? "English";
|
||||
|
||||
// Reset conversation if idle for more than 5 minutes
|
||||
if (
|
||||
stage !== "initial" && stage !== "done" &&
|
||||
convo?.lastActivityAt &&
|
||||
Date.now() - convo.lastActivityAt.getTime() > CONVO_TIMEOUT_MS
|
||||
) {
|
||||
stage = "initial";
|
||||
unknownPhoneConvos.set(normalizedFrom, { userId, stage: "initial", language, lastActivityAt: new Date() });
|
||||
}
|
||||
|
||||
const replyUnknown = async (
|
||||
text: string,
|
||||
nextStage: ConversationStage,
|
||||
pendingApptDate?: { date: Date; dateLabel: string },
|
||||
) => {
|
||||
unknownPhoneConvos.set(normalizedFrom, {
|
||||
userId, stage: nextStage, language,
|
||||
lastActivityAt: new Date(),
|
||||
...(pendingApptDate ? { pendingApptDate } : { pendingApptDate: convo?.pendingApptDate }),
|
||||
});
|
||||
await sendReplies(telnyxRow, From, [text]);
|
||||
return ok(res);
|
||||
};
|
||||
|
||||
if (stage === "initial" || stage === "done") {
|
||||
// MSG 1: AI intro
|
||||
const rawGreeting = chatTemplates.newPatientGreeting ||
|
||||
`Hi! My name is Lisa, the dedicated AI assistant at {officeName}. I can help you schedule an appointment, check your insurance, and answer general questions 24/7.`;
|
||||
const introText = applyOfficeName(rawGreeting, officeName);
|
||||
|
||||
// MSG 2: Empathetic acknowledgment
|
||||
const empatheticText = getEmpatheticAck(Body, language);
|
||||
|
||||
// MSG 3: Detect intent from first message
|
||||
const isReschedule = /reschedule|rescheduler|change.*appoint|modify.*appoint|move.*appoint|reprogramar|reagendar|cambiar|mudar/i.test(Body);
|
||||
let msg3Unk: string;
|
||||
let nextStage3Unk: ConversationStage;
|
||||
|
||||
if (isReschedule) {
|
||||
const notFoundMsgs: Record<string, string> = {
|
||||
English: "I couldn't find an appointment associated with your number. Please leave your name and a good callback number and our receptionist will assist you as soon as possible.",
|
||||
Spanish: "No encontré una cita asociada a su número. Por favor deje su nombre y un número de contacto y nuestra recepcionista le atenderá lo antes posible.",
|
||||
Portuguese: "Não encontrei uma consulta associada ao seu número. Por favor deixe seu nome e um número de contato e nossa recepcionista lhe atenderá o mais breve possível.",
|
||||
Mandarin: "我找不到与您号码关联的预约。请留下您的姓名和联系电话,我们的前台将尽快与您联系。",
|
||||
Cantonese: "我找不到與您號碼關聯的預約。請留下您的姓名和聯絡電話,我們的接待員將盡快與您聯絡。",
|
||||
Arabic: "لم أجد موعداً مرتبطاً برقمك. يرجى ترك اسمك ورقم هاتف للتواصل وسيساعدك موظف الاستقبال في أقرب وقت.",
|
||||
"Haitian Creole": "Mwen pa jwenn yon randevou ki asosye ak nimewo ou. Tanpri kite non ou ak yon nimewo pou rele epi resepsyonis nou an pral ede ou pi vit posib.",
|
||||
};
|
||||
msg3Unk = notFoundMsgs[language] ?? notFoundMsgs["English"]!;
|
||||
nextStage3Unk = "collecting_contact_info";
|
||||
} else {
|
||||
const newOrExistingMsgs: Record<string, string> = {
|
||||
English: "Can you please tell me if you are a new or existing patient?",
|
||||
Spanish: "¿Puede decirme si es usted un paciente nuevo o existente?",
|
||||
Portuguese: "Pode me dizer se você é um paciente novo ou existente?",
|
||||
Mandarin: "请问您是新患者还是现有患者?",
|
||||
Cantonese: "請問您是新病人還是現有病人?",
|
||||
Arabic: "هل يمكنك إخباري إذا كنت مريضاً جديداً أم حالياً؟",
|
||||
"Haitian Creole": "Èske ou ka di mwen si ou se yon nouvo pasyan oswa yon pasyan egzistan?",
|
||||
};
|
||||
msg3Unk = newOrExistingMsgs[language] ?? newOrExistingMsgs["English"]!;
|
||||
nextStage3Unk = "asked_new_or_existing";
|
||||
}
|
||||
|
||||
// Update store then send all 3 in guaranteed order
|
||||
unknownPhoneConvos.set(normalizedFrom, {
|
||||
userId, stage: nextStage3Unk, language, lastActivityAt: new Date(),
|
||||
});
|
||||
await sendReplies(telnyxRow, From, [introText, empatheticText, msg3Unk]);
|
||||
return ok(res);
|
||||
}
|
||||
|
||||
// ── Unknown: asked_new_or_reschedule (fallback for in-progress conversations) ──
|
||||
if (stage === "asked_new_or_reschedule") {
|
||||
const isReschedule = /reschedule|rescheduler|change|modify|move|reprogramar|reagendar|cambiar|mudar/i.test(Body);
|
||||
if (isReschedule) {
|
||||
const notFoundMsgs: Record<string, string> = {
|
||||
English: "I couldn't find an appointment associated with your number. Please leave your name and a good callback number and our receptionist will assist you as soon as possible.",
|
||||
Spanish: "No encontré una cita asociada a su número. Por favor deje su nombre y un número de contacto y nuestra recepcionista le atenderá lo antes posible.",
|
||||
Portuguese: "Não encontrei uma consulta associada ao seu número. Por favor deixe seu nome e um número de contato e nossa recepcionista lhe atenderá o mais breve possível.",
|
||||
Mandarin: "我找不到与您号码关联的预约。请留下您的姓名和联系电话,我们的前台将尽快与您联系。",
|
||||
Cantonese: "我找不到與您號碼關聯的預約。請留下您的姓名和聯絡電話,我們的接待員將盡快與您聯絡。",
|
||||
Arabic: "لم أجد موعداً مرتبطاً برقمك. يرجى ترك اسمك ورقم هاتف للتواصل وسيساعدك موظف الاستقبال في أقرب وقت.",
|
||||
"Haitian Creole": "Mwen pa jwenn yon randevou ki asosye ak nimewo ou. Tanpri kite non ou ak yon nimewo pou rele epi resepsyonis nou an pral ede ou pi vit posib.",
|
||||
};
|
||||
return await replyUnknown(notFoundMsgs[language] ?? notFoundMsgs["English"]!, "collecting_contact_info");
|
||||
}
|
||||
const newOrExistingMsgs: Record<string, string> = {
|
||||
English: "Can you please tell me if you are a new or existing patient?",
|
||||
Spanish: "¿Puede decirme si es usted un paciente nuevo o existente?",
|
||||
Portuguese: "Pode me dizer se você é um paciente novo ou existente?",
|
||||
Mandarin: "请问您是新患者还是现有患者?",
|
||||
Cantonese: "請問您是新病人還是現有病人?",
|
||||
Arabic: "هل يمكنك إخباري إذا كنت مريضاً جديداً أم حالياً؟",
|
||||
"Haitian Creole": "Èske ou ka di mwen si ou se yon nouvo pasyan oswa yon pasyan egzistan?",
|
||||
};
|
||||
return await replyUnknown(newOrExistingMsgs[language] ?? newOrExistingMsgs["English"]!, "asked_new_or_existing");
|
||||
}
|
||||
|
||||
// ── Unknown: asked_appointment_time → parse date, check office hours ──
|
||||
if (stage === "asked_appointment_time") {
|
||||
const parsedDate = await parseDateOnlyFromMessage(Body, activeAiU.key, activeAiU.provider, activeAiU.model);
|
||||
if (!parsedDate) {
|
||||
const msgs: Record<string, string> = {
|
||||
English: "I didn't catch that. What day would you prefer? For example: 'May 28', 'next Monday', or '5/28'.",
|
||||
Spanish: "No entendí. ¿Qué día prefiere? Por ejemplo: '28 de mayo', 'próximo lunes' o '28/5'.",
|
||||
Portuguese: "Não entendi. Que dia você prefere? Por exemplo: '28 de maio', 'próxima segunda' ou '28/5'.",
|
||||
Mandarin: "我没听清。您希望哪天?例如:'5月28日'、'下周一'或'5/28'。",
|
||||
Cantonese: "我沒聽清。您希望哪天?例如:'5月28日'、'下週一'或'5/28'。",
|
||||
Arabic: "لم أفهم. ما اليوم الذي تفضله؟ مثلاً: '28 مايو' أو '5/28'.",
|
||||
"Haitian Creole": "Mwen pa konprann. Ki jou ou prefere? Pa egzanp: '28 me', 'Lendi pwochèn', oswa '5/28'.",
|
||||
};
|
||||
return await replyUnknown(msgs[language] ?? msgs["English"]!, "asked_appointment_time");
|
||||
}
|
||||
const { date, dateLabel } = parsedDate;
|
||||
const dayCheck = await isOfficeDayOpen(date, userId);
|
||||
if (!dayCheck.open) {
|
||||
const msgs: Record<string, string> = {
|
||||
English: `Our office is closed on ${dateLabel} (${dayCheck.displayDay}). Can you please choose another day?`,
|
||||
Spanish: `Nuestra oficina está cerrada el ${dateLabel} (${dayCheck.displayDay}). ¿Puede elegir otro día?`,
|
||||
Portuguese: `Nosso consultório está fechado em ${dateLabel} (${dayCheck.displayDay}). Pode escolher outro dia?`,
|
||||
Mandarin: `我们诊所在 ${dateLabel}(${dayCheck.displayDay})不开放。请您选择另一天好吗?`,
|
||||
Cantonese: `我們診所在 ${dateLabel}(${dayCheck.displayDay})不開放。請您選擇另一天好嗎?`,
|
||||
Arabic: `مكتبنا مغلق في ${dateLabel} (${dayCheck.displayDay}). هل يمكنك اختيار يوم آخر؟`,
|
||||
"Haitian Creole": `Biwo nou fèmen nan ${dateLabel} (${dayCheck.displayDay}). Tanpri chwazi yon lòt jou?`,
|
||||
};
|
||||
return await replyUnknown(msgs[language] ?? msgs["English"]!, "asked_appointment_time");
|
||||
}
|
||||
const askTimeMsgs: Record<string, string> = {
|
||||
English: `What time do you prefer on ${dateLabel}?`,
|
||||
Spanish: `¿A qué hora prefiere el ${dateLabel}?`,
|
||||
Portuguese: `Que horário você prefere em ${dateLabel}?`,
|
||||
Mandarin: `您希望在 ${dateLabel} 几点?`,
|
||||
Cantonese: `您希望在 ${dateLabel} 幾點?`,
|
||||
Arabic: `ما الوقت الذي تفضله في ${dateLabel}؟`,
|
||||
"Haitian Creole": `Ki lè ou prefere nan ${dateLabel}?`,
|
||||
};
|
||||
return await replyUnknown(
|
||||
askTimeMsgs[language] ?? askTimeMsgs["English"]!,
|
||||
"asked_new_appt_time_for_date",
|
||||
{ date, dateLabel },
|
||||
);
|
||||
}
|
||||
|
||||
// ── Unknown: asked_new_appt_time_for_date → parse time, check hours ───
|
||||
if (stage === "asked_new_appt_time_for_date") {
|
||||
const pendingApptDate = convo?.pendingApptDate;
|
||||
if (!pendingApptDate) {
|
||||
return await replyUnknown(
|
||||
"I lost track of the date. What day would you prefer?",
|
||||
"asked_appointment_time",
|
||||
);
|
||||
}
|
||||
const startTime = await parseTime(Body, activeAiU.key, activeAiU.provider, activeAiU.model);
|
||||
if (!startTime) {
|
||||
const msgs: Record<string, string> = {
|
||||
English: `I didn't catch the time. What time do you prefer on ${pendingApptDate.dateLabel}? For example: '10am' or '2pm'.`,
|
||||
Spanish: `No entendí la hora. ¿Qué hora prefiere el ${pendingApptDate.dateLabel}? Por ejemplo: '10am' o '2pm'.`,
|
||||
Portuguese: `Não entendi o horário. Que hora você prefere em ${pendingApptDate.dateLabel}? Por exemplo: '10h' ou '14h'.`,
|
||||
Mandarin: `我没听清时间。您在 ${pendingApptDate.dateLabel} 几点?例如:上午10点或下午2点。`,
|
||||
Cantonese: `我沒聽清時間。您在 ${pendingApptDate.dateLabel} 幾點?例如:上午10點或下午2點。`,
|
||||
Arabic: `لم أفهم الوقت. ما الوقت الذي تفضله في ${pendingApptDate.dateLabel}؟ مثلاً: 10 صباحاً أو 2 مساءً.`,
|
||||
"Haitian Creole": `Mwen pa konprann lè a. Ki lè ou prefere nan ${pendingApptDate.dateLabel}? Pa egzanp: 10am oswa 2pm.`,
|
||||
};
|
||||
return await replyUnknown(msgs[language] ?? msgs["English"]!, "asked_new_appt_time_for_date");
|
||||
}
|
||||
const withinHours = await isWithinOfficeHours(pendingApptDate.date, startTime, userId);
|
||||
if (!withinHours) {
|
||||
const hoursDisplay = await getOfficeHoursDisplay(pendingApptDate.date, userId);
|
||||
const timeLbl = timeLabel(startTime);
|
||||
const msgs: Record<string, string> = {
|
||||
English: hoursDisplay
|
||||
? `Our office is not available at ${timeLbl} on ${pendingApptDate.dateLabel}. Our hours are ${hoursDisplay}. What other time do you prefer?`
|
||||
: `Our office is not available at ${timeLbl} on ${pendingApptDate.dateLabel}. What other time do you prefer?`,
|
||||
Spanish: hoursDisplay
|
||||
? `Nuestra oficina no está disponible a las ${timeLbl} el ${pendingApptDate.dateLabel}. Nuestro horario es ${hoursDisplay}. ¿Qué otro horario prefiere?`
|
||||
: `Nuestra oficina no está disponible a las ${timeLbl} el ${pendingApptDate.dateLabel}. ¿Qué otro horario prefiere?`,
|
||||
Portuguese: hoursDisplay
|
||||
? `Nosso consultório não está disponível às ${timeLbl} em ${pendingApptDate.dateLabel}. Nosso horário é ${hoursDisplay}. Que outro horário você prefere?`
|
||||
: `Nosso consultório não está disponível às ${timeLbl} em ${pendingApptDate.dateLabel}. Que outro horário você prefere?`,
|
||||
Mandarin: hoursDisplay
|
||||
? `我们诊所在 ${pendingApptDate.dateLabel} ${timeLbl} 不开放。工作时间是 ${hoursDisplay}。您希望改什么时间?`
|
||||
: `我们诊所在 ${pendingApptDate.dateLabel} ${timeLbl} 不开放。您希望改什么时间?`,
|
||||
Cantonese: hoursDisplay
|
||||
? `我們診所在 ${pendingApptDate.dateLabel} ${timeLbl} 不開放。工作時間是 ${hoursDisplay}。您希望改什麼時間?`
|
||||
: `我們診所在 ${pendingApptDate.dateLabel} ${timeLbl} 不開放。您希望改什麼時間?`,
|
||||
Arabic: hoursDisplay
|
||||
? `مكتبنا غير متاح في ${timeLbl} يوم ${pendingApptDate.dateLabel}. ساعات العمل: ${hoursDisplay}. ما وقت آخر تفضله؟`
|
||||
: `مكتبنا غير متاح في ${timeLbl} يوم ${pendingApptDate.dateLabel}. ما وقت آخر تفضله؟`,
|
||||
"Haitian Creole": hoursDisplay
|
||||
? `Biwo nou pa disponib a ${timeLbl} nan ${pendingApptDate.dateLabel}. Orè nou se ${hoursDisplay}. Ki lòt lè ou prefere?`
|
||||
: `Biwo nou pa disponib a ${timeLbl} nan ${pendingApptDate.dateLabel}. Ki lòt lè ou prefere?`,
|
||||
};
|
||||
return await replyUnknown(msgs[language] ?? msgs["English"]!, "asked_new_appt_time_for_date");
|
||||
}
|
||||
const apptLabel = `${pendingApptDate.dateLabel} at ${timeLabel(startTime)}`;
|
||||
const confirmMsgs: Record<string, string> = {
|
||||
English: `Thank you! Your preferred appointment is ${apptLabel}. Our receptionist will confirm the details with you shortly.`,
|
||||
Spanish: `¡Gracias! Su cita preferida es el ${apptLabel}. Nuestra recepcionista confirmará los detalles con usted en breve.`,
|
||||
Portuguese: `Obrigado! Sua consulta preferida é em ${apptLabel}. Nossa recepcionista confirmará os detalhes em breve.`,
|
||||
Mandarin: `谢谢!您的预约时间为 ${apptLabel}。我们的前台将很快与您确认详情。`,
|
||||
Cantonese: `多謝!您的預約時間為 ${apptLabel}。我們的接待員將很快與您確認詳情。`,
|
||||
Arabic: `شكراً! موعدك المفضل هو ${apptLabel}. ستتواصل معك موظفة الاستقبال قريباً لتأكيد التفاصيل.`,
|
||||
"Haitian Creole": `Mèsi! Randevou ou a se ${apptLabel}. Resepsyonis nou an pral konfime detay yo ak ou byento.`,
|
||||
};
|
||||
return await replyUnknown(confirmMsgs[language] ?? confirmMsgs["English"]!, "done");
|
||||
}
|
||||
|
||||
// Multi-step new patient stages for unknown numbers
|
||||
const unknownNewPatientStages: ConversationStage[] = [
|
||||
"new_patient_greeted", "asked_new_or_existing",
|
||||
"asked_new_patient_insurance", "asked_insurance_type",
|
||||
"asked_masshealth_check_consent", "asked_existing_insurance",
|
||||
"asked_appointment_preference",
|
||||
"asked_self_pay", "asked_other_insurance_after_inactive",
|
||||
"collecting_contact_info",
|
||||
];
|
||||
if (unknownNewPatientStages.includes(stage)) {
|
||||
const { reply: aiReply, nextStage } = await runNewPatientStep(
|
||||
Body, stage, language, activeAiU.key, chatTemplates.generalFallback, activeAiU.provider, activeAiU.model
|
||||
);
|
||||
return await replyUnknown(aiReply, nextStage);
|
||||
}
|
||||
|
||||
return ok(res);
|
||||
}
|
||||
|
||||
// Save inbound message
|
||||
await storage.createCommunication({
|
||||
patientId: patient.id, channel: "sms", direction: "inbound",
|
||||
status: "delivered", body: Body, twilioSid: MessageSid, provider: "telnyx",
|
||||
});
|
||||
|
||||
// Per-patient handoff toggle must be ON
|
||||
if (!await getHandoff(patient.userId, patient.id)) return ok(res);
|
||||
|
||||
const aiSettings = await storage.getAiSettings(patient.userId);
|
||||
const activeAi = resolveAiProvider(aiSettings ?? {});
|
||||
if (!activeAi) return ok(res);
|
||||
|
||||
const telnyxSettings = await storage.getTelnyxSettings(patient.userId);
|
||||
if (!telnyxSettings) return ok(res);
|
||||
|
||||
const language = patient.preferredLanguage || "English";
|
||||
let stage = await getStage(patient.userId, patient.id);
|
||||
const chatTemplates = await storage.getAiChatTemplates(patient.userId);
|
||||
|
||||
// Long idle gap: don't blindly resume (SMS replies are often just slow),
|
||||
// but don't blindly reset either — check whether the new text still reads
|
||||
// like a continuation before deciding.
|
||||
if (stage !== "initial" && stage !== "done") {
|
||||
const convRow = await db.patientConversation.findUnique({
|
||||
where: { patientId: patient.id },
|
||||
select: { updatedAt: true },
|
||||
});
|
||||
if (convRow?.updatedAt && Date.now() - convRow.updatedAt.getTime() > STALE_SMS_THRESHOLD_MS) {
|
||||
const recentComms = await storage.getCommunicationsByPatient(patient.id);
|
||||
const recentHistory = recentComms.slice(-5).map((c: any) => ({
|
||||
role: (c.direction === "outbound" ? "assistant" : "user") as "assistant" | "user",
|
||||
text: c.body || "",
|
||||
}));
|
||||
const continuing = await isLikelyContinuation(
|
||||
recentHistory, Body, stage, activeAi.key, activeAi.provider, activeAi.model
|
||||
);
|
||||
if (!continuing) {
|
||||
clearPendingReschedule(patient.userId, patient.id);
|
||||
await setStage(patient.userId, patient.id, "initial");
|
||||
stage = "initial";
|
||||
const stillHereText = STILL_HERE_MSGS[language] ?? STILL_HERE_MSGS["English"]!;
|
||||
await saveOutbound(patient.id, stillHereText);
|
||||
await sendReplies(telnyxSettings, From, [stillHereText]);
|
||||
return ok(res);
|
||||
}
|
||||
// Looks like a continuation — fall through and resume the stored stage.
|
||||
}
|
||||
}
|
||||
const officeContact = await storage.getOfficeContact(patient.userId);
|
||||
const officeName = (officeContact as any)?.officeName?.trim() || "";
|
||||
|
||||
// ── Shared business rules (reminder/reschedule/new-patient graphs, plus the
|
||||
// MassHealth and appointment-booking stages) — the exact same code path the
|
||||
// AI voice call webhook uses, so a patient gets identical guardrails and
|
||||
// outcomes regardless of channel.
|
||||
const routed = await routePatientTurn({
|
||||
patient, stage, message: Body, activeAi, chatTemplates, officeName,
|
||||
});
|
||||
|
||||
if (routed.segments.length > 0) {
|
||||
for (const seg of routed.segments) await saveOutbound(patient.id, seg);
|
||||
await sendReplies(telnyxSettings, From, routed.segments);
|
||||
return ok(res);
|
||||
}
|
||||
|
||||
// ── Stage: initial / done (patient texts in fresh) ───────────────────
|
||||
if (stage === "initial" || stage === "done") {
|
||||
const openPhoneReply = await storage.getOpenPhoneReply(patient.userId);
|
||||
const afterHoursEnabled = await getAfterHoursHandoff(patient.userId);
|
||||
const outsideHours = await isAfterHours(patient.userId);
|
||||
|
||||
if (openPhoneReply || (afterHoursEnabled && outsideHours)) {
|
||||
// MSG 1: AI self-introduction
|
||||
const rawGreeting = chatTemplates.newPatientGreeting ||
|
||||
`Hi! My name is Lisa, the dedicated AI assistant at {officeName}. I can help you schedule an appointment, check your insurance, and answer general questions 24/7.`;
|
||||
const introText = applyOfficeName(rawGreeting, officeName);
|
||||
|
||||
// MSG 2: Empathetic acknowledgment of the patient's message
|
||||
const empatheticText = getEmpatheticAck(Body, language);
|
||||
|
||||
// MSG 3: Detect reschedule vs new appointment from initial text
|
||||
const isRescheduleIntent = /reschedule|rescheduler|change.*appoint|modify.*appoint|move.*appoint|reprogramar|reagendar|cambiar|mudar/i.test(Body);
|
||||
|
||||
let msg3: string;
|
||||
let nextStage3: ConversationStage;
|
||||
|
||||
if (isRescheduleIntent) {
|
||||
const apptDatetime = await getAppointmentDatetime(patient.id);
|
||||
if (apptDatetime) {
|
||||
const foundMsgs: Record<string, string> = {
|
||||
English: `Ok. Just to confirm, your current appointment is on ${apptDatetime}. When would you like to reschedule?`,
|
||||
Spanish: `De acuerdo. Solo para confirmar, su cita actual es el ${apptDatetime}. ¿Cuándo le gustaría reprogramarla?`,
|
||||
Portuguese: `Ok. Só para confirmar, sua consulta atual é em ${apptDatetime}. Quando você gostaria de reagendar?`,
|
||||
Mandarin: `好的。请确认一下,您当前的预约是 ${apptDatetime}。您想重新安排到什么时候?`,
|
||||
Cantonese: `好的。請確認一下,您目前的預約是 ${apptDatetime}。您想重新安排到什麼時候?`,
|
||||
Arabic: `حسناً. فقط للتأكيد، موعدك الحالي في ${apptDatetime}. متى تريد إعادة الجدولة؟`,
|
||||
"Haitian Creole": `Oke. Jis pou konfime, randevou aktyèl ou a se ${apptDatetime}. Ki lè ou ta renmen reprogramè?`,
|
||||
};
|
||||
msg3 = foundMsgs[language] ?? foundMsgs["English"]!;
|
||||
nextStage3 = "asked_reschedule_datetime";
|
||||
} else {
|
||||
const notFoundMsgs: Record<string, string> = {
|
||||
English: "Ok. I could not find your current appointment. Please tell me when you'd like to reschedule and our receptionist will contact you as soon as possible.",
|
||||
Spanish: "De acuerdo. No pude encontrar su cita actual. Por favor díganos cuándo le gustaría reprogramar y nuestra recepcionista le contactará lo antes posible.",
|
||||
Portuguese: "Ok. Não consegui encontrar sua consulta atual. Por favor, diga-nos quando você gostaria de reagendar e nossa recepcionista entrará em contato o mais breve possível.",
|
||||
Mandarin: "好的。我找不到您当前的预约。请告诉我您希望重新安排的时间,我们的前台将尽快与您联系。",
|
||||
Cantonese: "好的。我找不到您目前的預約。請告訴我您希望重新安排的時間,我們的接待員將盡快與您聯絡。",
|
||||
Arabic: "حسناً. لم أجد موعدك الحالي. يرجى إخبارنا بالوقت الذي تريد إعادة الجدولة إليه وسيتصل بك موظف الاستقبال في أقرب وقت ممكن.",
|
||||
"Haitian Creole": "Oke. Mwen pa t jwenn randevou aktyèl ou. Tanpri di nou ki lè ou ta renmen reprogramè epi resepsyonis nou an pral kontakte ou pi vit posib.",
|
||||
};
|
||||
msg3 = notFoundMsgs[language] ?? notFoundMsgs["English"]!;
|
||||
nextStage3 = "done";
|
||||
}
|
||||
} else {
|
||||
// New appointment (or any other intent) → ask new or existing patient
|
||||
const newOrExistingMsgs: Record<string, string> = {
|
||||
English: "Can you please tell me if you are a new or existing patient?",
|
||||
Spanish: "¿Puede decirme si es usted un paciente nuevo o existente?",
|
||||
Portuguese: "Pode me dizer se você é um paciente novo ou existente?",
|
||||
Mandarin: "请问您是新患者还是现有患者?",
|
||||
Cantonese: "請問您是新病人還是現有病人?",
|
||||
Arabic: "هل يمكنك إخباري إذا كنت مريضاً جديداً أم حالياً؟",
|
||||
"Haitian Creole": "Èske ou ka di mwen si ou se yon nouvo pasyan oswa yon pasyan egzistan?",
|
||||
};
|
||||
msg3 = newOrExistingMsgs[language] ?? newOrExistingMsgs["English"]!;
|
||||
nextStage3 = "asked_new_or_existing";
|
||||
}
|
||||
|
||||
// Save all 3 outbound messages and set stage
|
||||
await saveOutbound(patient.id, introText);
|
||||
await saveOutbound(patient.id, empatheticText);
|
||||
await saveOutbound(patient.id, msg3);
|
||||
await setStage(patient.userId, patient.id, nextStage3);
|
||||
|
||||
await sendReplies(telnyxSettings, From, [introText, empatheticText, msg3]);
|
||||
return ok(res);
|
||||
}
|
||||
}
|
||||
|
||||
return ok(res);
|
||||
|
||||
} catch (err) {
|
||||
return ok(res);
|
||||
}
|
||||
});
|
||||
|
||||
// ── POST /api/telnyx/webhook/voice ────────────────────────────────────────────
|
||||
|
||||
router.post("/webhook/voice", async (req: Request, res: Response): Promise<any> => {
|
||||
try {
|
||||
const { From, CallSid } = req.body;
|
||||
|
||||
const normalizedFrom = (From || "").replace(/\D/g, "");
|
||||
// Ordered by id — earliest-created record wins when duplicates share a phone.
|
||||
const allPatients = await db.patient.findMany({ select: { id: true, phone: true, userId: true }, orderBy: { id: "asc" } });
|
||||
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.getTelnyxSettings(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, provider: "telnyx",
|
||||
});
|
||||
}
|
||||
|
||||
const recordingCallbackUrl = `${getPublicBaseUrl(req)}/api/telnyx/webhook/voice-recording`;
|
||||
|
||||
res.set("Content-Type", "text/xml");
|
||||
return res.send(`<?xml version="1.0" encoding="UTF-8"?>
|
||||
<Response>
|
||||
<Say voice="alice">${greeting}</Say>
|
||||
<Record maxLength="120" action="${recordingCallbackUrl}" transcribeCallback="${recordingCallbackUrl}" playBeep="true"/>
|
||||
<Say voice="alice">We did not receive a recording. Goodbye.</Say>
|
||||
</Response>`);
|
||||
} catch (err) {
|
||||
res.set("Content-Type", "text/xml");
|
||||
return res.send(`<?xml version="1.0" encoding="UTF-8"?><Response><Say>Thank you for calling. Please try again later.</Say></Response>`);
|
||||
}
|
||||
});
|
||||
|
||||
// ── POST /api/telnyx/webhook/voice-recording ──────────────────────────────────
|
||||
|
||||
router.post("/webhook/voice-recording", async (req: Request, res: Response): Promise<any> => {
|
||||
try {
|
||||
const { CallSid, RecordingUrl } = req.body;
|
||||
if (RecordingUrl && CallSid) {
|
||||
const comm = await db.communication.findFirst({ where: { twilioSid: CallSid, provider: "telnyx" } });
|
||||
if (comm) {
|
||||
await db.communication.update({
|
||||
where: { id: comm.id },
|
||||
data: { body: `Voicemail: ${RecordingUrl}.mp3` },
|
||||
});
|
||||
}
|
||||
}
|
||||
res.set("Content-Type", "text/xml");
|
||||
return res.send("<Response></Response>");
|
||||
} catch (err) {
|
||||
res.set("Content-Type", "text/xml");
|
||||
return res.send("<Response></Response>");
|
||||
}
|
||||
});
|
||||
|
||||
// ── POST /api/telnyx/webhook/say ──────────────────────────────────────────────
|
||||
// Minimal helper endpoint for /make-call's one-shot outbound calls — avoids
|
||||
// relying on an unverified "inline TeXML on call creation" SDK feature.
|
||||
router.post("/webhook/say", async (req: Request, res: Response): Promise<any> => {
|
||||
const text = typeof req.query.text === "string" ? req.query.text : "Hello, this is a call from your dental office.";
|
||||
res.set("Content-Type", "text/xml");
|
||||
return res.send(`<?xml version="1.0" encoding="UTF-8"?><Response><Say>${escapeXml(text)}</Say></Response>`);
|
||||
});
|
||||
|
||||
// ── AI phone call (outbound "AI Call" from Patient Connection) ───────────────
|
||||
|
||||
const MAX_AI_CALL_TURNS = 12;
|
||||
|
||||
interface AiCallSession {
|
||||
patientId: number;
|
||||
userId: number;
|
||||
phone: string | null;
|
||||
language: string;
|
||||
officeName: string;
|
||||
chatTemplates: Record<string, string>;
|
||||
history: { role: "assistant" | "user"; text: string }[];
|
||||
turns: number;
|
||||
}
|
||||
|
||||
const aiCallSessions = new Map<string, AiCallSession>();
|
||||
|
||||
interface VoiceLocale { gatherLang: string; sayVoice: string; }
|
||||
|
||||
/** English/Spanish only for now — Telnyx TeXML's native voice/recognition
|
||||
* coverage for the app's other supported languages (Mandarin, Cantonese,
|
||||
* Arabic, Haitian Creole) is inconsistent, so those fall back to English on
|
||||
* calls. NOTE: voice names below (Polly.*) are carried over from the Twilio
|
||||
* implementation — verify Telnyx TeXML's <Say voice="..."> naming convention
|
||||
* against a real test call before relying on this. */
|
||||
function voiceLocaleFor(language: string): VoiceLocale {
|
||||
if (language === "Spanish") return { gatherLang: "es-US", sayVoice: "Polly.Lupe" };
|
||||
return { gatherLang: "en-US", sayVoice: "Polly.Joanna" };
|
||||
}
|
||||
|
||||
// Spoken greeting is plain text, not LLM-generated, so it can't follow the
|
||||
// patient's language on its own — translate it ourselves when the office
|
||||
// hasn't overridden the default wording. Matches voiceLocaleFor's English/
|
||||
// Spanish-only voice coverage above.
|
||||
const GREETING_TRANSLATIONS: Record<string, string> = {
|
||||
Spanish: "Hola {firstName}, soy Lisa, la asistente de inteligencia artificial de {officeName}. ¿En qué puedo ayudarle hoy?",
|
||||
};
|
||||
const RECALL_GREETING_TRANSLATIONS: Record<string, string> = {
|
||||
Spanish: "Hola {firstName}, soy Lisa de {officeName}. Queríamos avisarle que ya le corresponde un examen y limpieza de rutina, que la mayoría de los seguros cubren sin costo. ¿Le gustaría programar una cita?",
|
||||
};
|
||||
|
||||
/** Picks the greeting template to speak: the office's custom wording as-is if
|
||||
* they've edited it (we can't auto-translate free text they wrote), otherwise
|
||||
* the default translated into the patient's language when available. */
|
||||
function resolveGreetingTemplate(rawTemplate: string, defaultTemplate: string, translations: Record<string, string>, language: string): string {
|
||||
if (rawTemplate.trim() !== defaultTemplate) return rawTemplate;
|
||||
return translations[language] ?? defaultTemplate;
|
||||
}
|
||||
|
||||
const ENGLISH_LOCALE: VoiceLocale = { gatherLang: "en-US", sayVoice: "Polly.Joanna" };
|
||||
|
||||
function aiVoiceTwiml(sayText: string, gatherActionUrl: string, locale: VoiceLocale): string {
|
||||
// action is always set, so the platform posts back to it on both speech and
|
||||
// silence timeout — nothing after </Gather> would ever run.
|
||||
return `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<Response>
|
||||
<Gather input="speech" language="${locale.gatherLang}" speechTimeout="auto" timeout="15" action="${gatherActionUrl}" method="POST">
|
||||
<Say voice="${locale.sayVoice}">${escapeXml(sayText)}</Say>
|
||||
</Gather>
|
||||
</Response>`;
|
||||
}
|
||||
|
||||
function aiVoiceHangup(sayText: string, locale: VoiceLocale = ENGLISH_LOCALE): string {
|
||||
return `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<Response>
|
||||
<Say voice="${locale.sayVoice}">${escapeXml(sayText)}</Say>
|
||||
<Hangup/>
|
||||
</Response>`;
|
||||
}
|
||||
|
||||
// POST /api/telnyx/webhook/ai-voice
|
||||
// Entry point + conversation loop for outbound AI-driven calls placed via
|
||||
// POST /api/telnyx/make-ai-call. TeXML hits this once when the call connects
|
||||
// (no CallSid session yet) and again after every <Gather> with SpeechResult.
|
||||
// Each spoken turn is routed through the same routePatientTurn() logic the
|
||||
// SMS webhook uses, so calls follow identical rules/guardrails, in the
|
||||
// patient's preferred language (English/Spanish supported for voice).
|
||||
router.post("/webhook/ai-voice", async (req: Request, res: Response): Promise<any> => {
|
||||
res.set("Content-Type", "text/xml");
|
||||
try {
|
||||
const { CallSid, SpeechResult } = req.body as { CallSid?: string; SpeechResult?: string };
|
||||
const patientIdParam = req.query.patientId as string | undefined;
|
||||
const purposeParam = req.query.purpose as string | undefined;
|
||||
if (!CallSid) return res.send(aiVoiceHangup("Sorry, something went wrong. Goodbye."));
|
||||
|
||||
let session = aiCallSessions.get(CallSid);
|
||||
|
||||
// ── First hit for this call: set up session + greet ──────────────────
|
||||
if (!session) {
|
||||
const patientId = parseInt(patientIdParam || "", 10);
|
||||
const patient = !isNaN(patientId)
|
||||
? await db.patient.findUnique({
|
||||
where: { id: patientId },
|
||||
select: { id: true, userId: true, firstName: true, phone: true, preferredLanguage: true },
|
||||
})
|
||||
: null;
|
||||
if (!patient) return res.send(aiVoiceHangup("Sorry, we could not find your patient record. Goodbye."));
|
||||
|
||||
const language = patient.preferredLanguage || "English";
|
||||
const locale = voiceLocaleFor(language);
|
||||
const isRecallCall = purposeParam === "recall";
|
||||
|
||||
// Resume an in-progress cross-channel conversation (e.g. mid-reschedule
|
||||
// from a prior text) if one exists; otherwise start a fresh check-in call.
|
||||
// A "recall" call (staff-triggered from Patient Connection, offering an
|
||||
// existing patient a routine exam & cleaning) seeds a different stage
|
||||
// than the default reminder-style check-in.
|
||||
// Either kind of call always speaks its own pitch fresh (see greeting
|
||||
// below), so it must always reset the stage — even if a prior call of
|
||||
// either kind left the patient stuck mid-flow (e.g. "recall_asked_datetime"
|
||||
// or "asked_reschedule_datetime"). Otherwise the reply below gets
|
||||
// interpreted against that stale leftover stage instead of the fresh
|
||||
// pitch that was just spoken.
|
||||
if (isRecallCall) {
|
||||
await startRecallConversation(patient.userId, patient.id);
|
||||
} else {
|
||||
await resetConversation(patient.userId, patient.id);
|
||||
}
|
||||
|
||||
const officeContact = await storage.getOfficeContact(patient.userId);
|
||||
const officeName = (officeContact as any)?.officeName?.trim() || "";
|
||||
const callTemplates = await storage.getAiCallTemplates(patient.userId);
|
||||
|
||||
session = {
|
||||
patientId: patient.id, userId: patient.userId, phone: patient.phone,
|
||||
language, officeName, chatTemplates: callTemplates, history: [], turns: 0,
|
||||
};
|
||||
aiCallSessions.set(CallSid, session);
|
||||
|
||||
const rawCallTemplate = (isRecallCall ? callTemplates.recallGreeting?.trim() : callTemplates.greeting?.trim()) ||
|
||||
(isRecallCall ? DEFAULT_RECALL_GREETING : DEFAULT_GREETING);
|
||||
const callTemplate = isRecallCall
|
||||
? resolveGreetingTemplate(rawCallTemplate, DEFAULT_RECALL_GREETING, RECALL_GREETING_TRANSLATIONS, language)
|
||||
: resolveGreetingTemplate(rawCallTemplate, DEFAULT_GREETING, GREETING_TRANSLATIONS, language);
|
||||
const greeting = callTemplate
|
||||
.replace(/\{firstName\}/g, patient.firstName || "there")
|
||||
.replace(/\{officeName\}/g, officeName || "our office");
|
||||
|
||||
session.history.push({ role: "assistant", text: greeting });
|
||||
await storage.createCommunication({
|
||||
patientId: patient.id, userId: patient.userId, channel: "voice",
|
||||
direction: "outbound", status: "completed", body: greeting, twilioSid: CallSid, provider: "telnyx",
|
||||
});
|
||||
|
||||
const actionUrl = `${getPublicBaseUrl(req)}/api/telnyx/webhook/ai-voice?patientId=${patient.id}`;
|
||||
return res.send(aiVoiceTwiml(greeting, actionUrl, locale));
|
||||
}
|
||||
|
||||
const locale = voiceLocaleFor(session.language);
|
||||
|
||||
// ── Caller stayed silent past the speech timeout ──────────────────────
|
||||
// A call is a single continuous conversation, unlike SMS — if it ends
|
||||
// here, mid-flow or not, there's nothing left to resume, so reset the
|
||||
// stage now instead of leaving the patient stuck for their next call/text.
|
||||
if (!SpeechResult?.trim()) {
|
||||
aiCallSessions.delete(CallSid);
|
||||
await setStage(session.userId, session.patientId, "done");
|
||||
return res.send(aiVoiceHangup("I didn't catch a response. Our staff will follow up with you if needed. Goodbye.", locale));
|
||||
}
|
||||
|
||||
session.history.push({ role: "user", text: SpeechResult.trim() });
|
||||
await storage.createCommunication({
|
||||
patientId: session.patientId, userId: session.userId, channel: "voice",
|
||||
direction: "inbound", status: "completed", body: SpeechResult.trim(), twilioSid: CallSid, provider: "telnyx",
|
||||
});
|
||||
|
||||
session.turns++;
|
||||
|
||||
// ── Caller said something that sounds like goodbye, or we've hit the cap ──
|
||||
if (soundsLikeGoodbye(SpeechResult) || session.turns >= MAX_AI_CALL_TURNS) {
|
||||
aiCallSessions.delete(CallSid);
|
||||
await setStage(session.userId, session.patientId, "done");
|
||||
return res.send(aiVoiceHangup("Thank you for calling, have a great day. Goodbye.", locale));
|
||||
}
|
||||
|
||||
const aiSettings = await storage.getAiSettings(session.userId);
|
||||
const activeAi = resolveAiProvider(aiSettings ?? {});
|
||||
if (!activeAi) {
|
||||
aiCallSessions.delete(CallSid);
|
||||
await setStage(session.userId, session.patientId, "done");
|
||||
return res.send(aiVoiceHangup("Our AI assistant is not available right now. Our staff will follow up with you. Goodbye.", locale));
|
||||
}
|
||||
|
||||
const stage = await getStage(session.userId, session.patientId);
|
||||
const routed = await routePatientTurn({
|
||||
patient: {
|
||||
id: session.patientId, userId: session.userId,
|
||||
phone: session.phone, preferredLanguage: session.language,
|
||||
},
|
||||
stage, message: SpeechResult.trim(), activeAi,
|
||||
chatTemplates: session.chatTemplates, officeName: session.officeName,
|
||||
});
|
||||
|
||||
const reply = routed.segments.join(" ").trim() ||
|
||||
"Sorry, I didn't quite catch that — could you say that again?";
|
||||
|
||||
session.history.push({ role: "assistant", text: reply });
|
||||
await storage.createCommunication({
|
||||
patientId: session.patientId, userId: session.userId, channel: "voice",
|
||||
direction: "outbound", status: "completed", body: reply, twilioSid: CallSid, provider: "telnyx",
|
||||
});
|
||||
|
||||
if (routed.nextStage === "done" || soundsLikeGoodbye(reply)) {
|
||||
aiCallSessions.delete(CallSid);
|
||||
// routePatientTurn() already persisted nextStage — but if we're hanging
|
||||
// up because the reply sounded like a goodbye while nextStage was still
|
||||
// mid-flow, force it to "done" so the next call/text doesn't resume there.
|
||||
if (routed.nextStage !== "done") {
|
||||
await setStage(session.userId, session.patientId, "done");
|
||||
}
|
||||
return res.send(aiVoiceHangup(reply, locale));
|
||||
}
|
||||
|
||||
const actionUrl = `${getPublicBaseUrl(req)}/api/telnyx/webhook/ai-voice?patientId=${session.patientId}`;
|
||||
return res.send(aiVoiceTwiml(reply, actionUrl, locale));
|
||||
} catch (err) {
|
||||
return res.send(aiVoiceHangup("Sorry, something went wrong on our end. Goodbye."));
|
||||
}
|
||||
});
|
||||
|
||||
export default router;
|
||||
@@ -0,0 +1,581 @@
|
||||
import express, { Request, Response } from "express";
|
||||
import { storage } from "../storage";
|
||||
import { prisma as db } from "@repo/db/client";
|
||||
import { sendTelnyxSms, placeTexmlCall } from "../comms/telnyx-client";
|
||||
import { getHandoff, setHandoff, resetConversation, startNewPatientConversation, startRescheduleConversation, getAfterHoursHandoff, setAfterHoursHandoff } from "../ai/aiHandoffStore";
|
||||
import { resolveAiProvider } from "../ai/llm-factory";
|
||||
import { getPublicTunnelBaseUrl } from "../utils/publicUrl";
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
// GET /api/telnyx/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.getTelnyxSettings(userId);
|
||||
if (!settings) return res.status(200).json(null);
|
||||
|
||||
return res.status(200).json({
|
||||
id: settings.id,
|
||||
apiKey: settings.apiKey,
|
||||
accountSid: settings.accountSid,
|
||||
phoneNumber: settings.phoneNumber,
|
||||
texmlApplicationId: settings.texmlApplicationId,
|
||||
messagingProfileId: settings.messagingProfileId,
|
||||
webhookPublicKey: settings.webhookPublicKey,
|
||||
greetingMessage: settings.greetingMessage,
|
||||
});
|
||||
} catch (err) {
|
||||
return res.status(500).json({ error: "Failed to fetch Telnyx settings", details: String(err) });
|
||||
}
|
||||
});
|
||||
|
||||
// PUT /api/telnyx/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 { apiKey, accountSid, phoneNumber, texmlApplicationId, messagingProfileId, webhookPublicKey, greetingMessage } = req.body;
|
||||
if (!apiKey?.trim() || !phoneNumber?.trim()) {
|
||||
return res.status(400).json({ message: "apiKey and phoneNumber are required" });
|
||||
}
|
||||
|
||||
const settings = await storage.upsertTelnyxSettings(userId, {
|
||||
apiKey: apiKey.trim(),
|
||||
accountSid: accountSid?.trim() || null,
|
||||
phoneNumber: phoneNumber.trim(),
|
||||
texmlApplicationId: texmlApplicationId?.trim() || null,
|
||||
messagingProfileId: messagingProfileId?.trim() || null,
|
||||
webhookPublicKey: webhookPublicKey?.trim() || null,
|
||||
greetingMessage: greetingMessage?.trim() || null,
|
||||
});
|
||||
|
||||
return res.status(200).json({
|
||||
id: settings.id,
|
||||
apiKey: settings.apiKey,
|
||||
accountSid: settings.accountSid,
|
||||
phoneNumber: settings.phoneNumber,
|
||||
texmlApplicationId: settings.texmlApplicationId,
|
||||
messagingProfileId: settings.messagingProfileId,
|
||||
webhookPublicKey: settings.webhookPublicKey,
|
||||
greetingMessage: settings.greetingMessage,
|
||||
});
|
||||
} catch (err) {
|
||||
return res.status(500).json({ error: "Failed to save Telnyx settings", details: String(err) });
|
||||
}
|
||||
});
|
||||
|
||||
// POST /api/telnyx/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.getTelnyxSettings(userId);
|
||||
if (!settings) {
|
||||
return res.status(400).json({ message: "Telnyx is not configured. Please add your Telnyx credentials in Settings." });
|
||||
}
|
||||
|
||||
const sent = await sendTelnyxSms({
|
||||
apiKey: settings.apiKey, from: settings.phoneNumber, to, text: message,
|
||||
messagingProfileId: settings.messagingProfileId,
|
||||
});
|
||||
|
||||
if (patientId) {
|
||||
const pid = Number(patientId);
|
||||
await storage.createCommunication({
|
||||
patientId: pid,
|
||||
userId,
|
||||
channel: "sms",
|
||||
direction: "outbound",
|
||||
status: "sent",
|
||||
body: message,
|
||||
twilioSid: sent.id,
|
||||
provider: "telnyx",
|
||||
});
|
||||
// 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: sent.id, status: sent.status });
|
||||
} catch (err: any) {
|
||||
return res.status(500).json({ error: err.message || "Failed to send SMS" });
|
||||
}
|
||||
});
|
||||
|
||||
// POST /api/telnyx/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.getTelnyxSettings(userId);
|
||||
if (!settings) {
|
||||
return res.status(400).json({ message: "Telnyx is not configured. Please add your Telnyx credentials in Settings." });
|
||||
}
|
||||
if (!settings.accountSid) {
|
||||
return res.status(400).json({ message: "Telnyx Account SID is not configured. Please add it in Telnyx 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 = getPublicTunnelBaseUrl();
|
||||
} catch (err: any) {
|
||||
return res.status(500).json({ message: err.message });
|
||||
}
|
||||
const callPurpose = purpose === "recall" ? "recall" : "reminder";
|
||||
const webhookUrl = `${publicBaseUrl}/api/telnyx/webhook/ai-voice?patientId=${patient.id}&purpose=${callPurpose}`;
|
||||
|
||||
const call = await placeTexmlCall({
|
||||
apiKey: settings.apiKey,
|
||||
accountSid: settings.accountSid,
|
||||
texmlApplicationId: settings.texmlApplicationId,
|
||||
from: settings.phoneNumber,
|
||||
to,
|
||||
webhookUrl,
|
||||
});
|
||||
|
||||
return res.status(200).json({ sid: call.id, status: call.status });
|
||||
} catch (err: any) {
|
||||
return res.status(500).json({ error: err.message || "Failed to place AI call" });
|
||||
}
|
||||
});
|
||||
|
||||
// POST /api/telnyx/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.getTelnyxSettings(userId);
|
||||
if (!settings) {
|
||||
return res.status(400).json({ message: "Telnyx is not configured. Please add your Telnyx 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()}`;
|
||||
};
|
||||
|
||||
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 msg = await sendTelnyxSms({
|
||||
apiKey: settings.apiKey, from: settings.phoneNumber, to: patient.phone, text: message,
|
||||
messagingProfileId: settings.messagingProfileId,
|
||||
});
|
||||
await storage.createCommunication({
|
||||
patientId: patient.id,
|
||||
userId,
|
||||
channel: "sms",
|
||||
direction: "outbound",
|
||||
status: "sent",
|
||||
body: message,
|
||||
twilioSid: msg.id,
|
||||
provider: "telnyx",
|
||||
});
|
||||
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/telnyx/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.getTelnyxSettings(userId);
|
||||
if (!settings) {
|
||||
return res.status(400).json({ message: "Telnyx is not configured. Please add your Telnyx 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()}`;
|
||||
};
|
||||
|
||||
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 msg = await sendTelnyxSms({
|
||||
apiKey: settings.apiKey, from: settings.phoneNumber, to: patient.phone, text: message,
|
||||
messagingProfileId: settings.messagingProfileId,
|
||||
});
|
||||
await storage.createCommunication({
|
||||
patientId: patient.id,
|
||||
userId,
|
||||
channel: "sms",
|
||||
direction: "outbound",
|
||||
status: "sent",
|
||||
body: message,
|
||||
twilioSid: msg.id,
|
||||
provider: "telnyx",
|
||||
});
|
||||
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/telnyx/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/telnyx/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/telnyx/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/telnyx/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/telnyx/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/telnyx/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/telnyx/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/telnyx/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/telnyx/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/telnyx/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.getTelnyxSettings(userId);
|
||||
if (!settings) {
|
||||
return res.status(400).json({ message: "Telnyx is not configured. Please add your Telnyx credentials in Settings." });
|
||||
}
|
||||
if (!settings.accountSid) {
|
||||
return res.status(400).json({ message: "Telnyx Account SID is not configured. Please add it in Telnyx Settings." });
|
||||
}
|
||||
|
||||
let publicBaseUrl: string;
|
||||
try {
|
||||
publicBaseUrl = getPublicTunnelBaseUrl();
|
||||
} catch (err: any) {
|
||||
return res.status(500).json({ message: err.message });
|
||||
}
|
||||
|
||||
const twimlMessage = message || "Hello, this is a call from your dental office.";
|
||||
const webhookUrl = `${publicBaseUrl}/api/telnyx/webhook/say?text=${encodeURIComponent(twimlMessage)}`;
|
||||
|
||||
const call = await placeTexmlCall({
|
||||
apiKey: settings.apiKey,
|
||||
accountSid: settings.accountSid,
|
||||
texmlApplicationId: settings.texmlApplicationId,
|
||||
from: settings.phoneNumber,
|
||||
to,
|
||||
webhookUrl,
|
||||
});
|
||||
|
||||
if (patientId) {
|
||||
await storage.createCommunication({
|
||||
patientId: Number(patientId),
|
||||
userId,
|
||||
channel: "voice",
|
||||
direction: "outbound",
|
||||
status: "queued",
|
||||
twilioSid: call.id,
|
||||
provider: "telnyx",
|
||||
});
|
||||
}
|
||||
|
||||
return res.status(200).json({ sid: call.id, status: call.status });
|
||||
} catch (err: any) {
|
||||
return res.status(500).json({ error: err.message || "Failed to make call" });
|
||||
}
|
||||
});
|
||||
|
||||
export default router;
|
||||
@@ -1,699 +1,17 @@
|
||||
// NOTE: Twilio is now used only for the in-browser dial pad. This webhook
|
||||
// handles the browser-Device outbound-call leg only — everything else (SMS,
|
||||
// inbound/AI voice) moved to Telnyx, see routes/telnyx-webhooks.ts.
|
||||
import express, { Request, Response } from "express";
|
||||
import twilio from "twilio";
|
||||
import { storage } from "../storage";
|
||||
import { DEFAULT_GREETING, DEFAULT_RECALL_GREETING } from "../storage/twilio-storage";
|
||||
import { prisma as db } from "@repo/db/client";
|
||||
import { runNewPatientStep } from "../ai/new-patient-graph";
|
||||
import {
|
||||
parseDateOnlyFromMessage,
|
||||
parseTime,
|
||||
isOfficeDayOpen,
|
||||
isWithinOfficeHours,
|
||||
getOfficeHoursDisplay,
|
||||
timeLabel,
|
||||
} from "../ai/reschedule-graph";
|
||||
import { getLlm, resolveAiProvider, type AiProvider } from "../ai/llm-factory";
|
||||
import { soundsLikeGoodbye } from "../ai/voice-assistant";
|
||||
import { getPublicBaseUrl } from "../utils/publicUrl";
|
||||
import {
|
||||
routePatientTurn, getAppointmentDatetime, applyOfficeName,
|
||||
} from "../ai/patient-conversation-router";
|
||||
import {
|
||||
getHandoff, getAfterHoursHandoff,
|
||||
getStage, setStage, resetConversation, startRecallConversation,
|
||||
setPendingReschedule, getPendingReschedule, clearPendingReschedule,
|
||||
type ConversationStage,
|
||||
} from "../ai/aiHandoffStore";
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
// ── Helpers ───────────────────────────────────────────────────────────────────
|
||||
|
||||
function escapeXml(text: string): string {
|
||||
return text
|
||||
.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">")
|
||||
.replace(/"/g, """).replace(/'/g, "'");
|
||||
}
|
||||
|
||||
/** Send multiple SMS messages in guaranteed order via a single TwiML response. */
|
||||
function twimlMessages(...texts: string[]): string {
|
||||
const body = texts.map(t => `<Message>${escapeXml(t)}</Message>`).join("");
|
||||
return `<?xml version="1.0" encoding="UTF-8"?><Response>${body}</Response>`;
|
||||
}
|
||||
|
||||
function twimlReply(text: string): string {
|
||||
return `<?xml version="1.0" encoding="UTF-8"?><Response><Message>${escapeXml(text)}</Message></Response>`;
|
||||
}
|
||||
|
||||
function empty(): string {
|
||||
return "<Response></Response>";
|
||||
}
|
||||
|
||||
/** Check if right now is outside office hours for the given user. */
|
||||
async function isAfterHours(userId: number): Promise<boolean> {
|
||||
const record = await storage.getOfficeHours(userId);
|
||||
if (!record?.data) return false; // no hours configured → treat as in-hours
|
||||
|
||||
const data = record.data as any;
|
||||
const now = new Date();
|
||||
const days = ["sunday","monday","tuesday","wednesday","thursday","friday","saturday"];
|
||||
const day = days[now.getDay()];
|
||||
|
||||
const slot = data.doctors?.[day];
|
||||
if (!slot?.enabled) return true; // office closed today
|
||||
|
||||
const hhmm = `${String(now.getHours()).padStart(2,"0")}:${String(now.getMinutes()).padStart(2,"0")}`;
|
||||
if (hhmm >= slot.amStart && hhmm <= slot.amEnd) return false;
|
||||
if (hhmm >= slot.pmStart && hhmm <= slot.pmEnd) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
/** Save an outbound message and return the text. */
|
||||
async function saveOutbound(patientId: number, body: string): Promise<void> {
|
||||
await storage.createCommunication({
|
||||
patientId, channel: "sms", direction: "outbound", status: "sent", body,
|
||||
});
|
||||
}
|
||||
|
||||
// ── Empathetic one-liner (instant keyword-based, no API latency) ─────────────
|
||||
|
||||
function getEmpatheticAck(message: string, language: string): string {
|
||||
const hasPain = /pain|hurt|ache|toothache|emergency|urgent|asap|bleeding|broke|crack|fell out|swollen|infection|abscess/i.test(message);
|
||||
|
||||
const painMsgs: Record<string, string> = {
|
||||
English: "Sorry to hear that! We are here to help you.",
|
||||
Spanish: "¡Lo sentimos! Estamos aquí para ayudarle.",
|
||||
Portuguese: "Lamentamos isso! Estamos aqui para ajudá-lo.",
|
||||
Mandarin: "很遗憾听到这个消息!我们在这里帮助您。",
|
||||
Cantonese: "很遺憾聽到這個消息!我們在這裡幫助您。",
|
||||
Arabic: "آسف لسماع ذلك! نحن هنا لمساعدتك.",
|
||||
"Haitian Creole": "Nou regrèt tande sa! Nou la pou ede ou.",
|
||||
};
|
||||
const normalMsgs: Record<string, string> = {
|
||||
English: "No problem!",
|
||||
Spanish: "¡Sin problema!",
|
||||
Portuguese: "Sem problema!",
|
||||
Mandarin: "没问题!",
|
||||
Cantonese: "沒問題!",
|
||||
Arabic: "لا مشكلة!",
|
||||
"Haitian Creole": "Pa gen pwoblèm!",
|
||||
};
|
||||
|
||||
return hasPain
|
||||
? (painMsgs[language] ?? painMsgs["English"]!)
|
||||
: (normalMsgs[language] ?? normalMsgs["English"]!);
|
||||
}
|
||||
|
||||
// ── "New appointment or reschedule?" prompt (multilingual) ────────────────────
|
||||
|
||||
const NEW_OR_RESCHEDULE_Q: Record<string, string> = {
|
||||
English: "Just to confirm — would you like to make a new appointment, or would you like to reschedule your current appointment?",
|
||||
Spanish: "Solo para confirmar — ¿desea hacer una nueva cita o reprogramar su cita actual?",
|
||||
Portuguese: "Só para confirmar — você gostaria de marcar uma nova consulta ou reagendar sua consulta atual?",
|
||||
Mandarin: "请问您是想预约新的就诊,还是想更改现有预约?",
|
||||
Cantonese: "請問您是想預約新的就診,還是想更改現有預約?",
|
||||
Arabic: "فقط للتأكيد — هل تريد تحديد موعد جديد أم تعديل موعدك الحالي؟",
|
||||
"Haitian Creole": "Jis pou konfime — èske ou ta renmen pran yon nouvo randevou, oswa èske ou ta renmen reprogramè randevou aktyèl ou?",
|
||||
};
|
||||
|
||||
// ── Stale-conversation handling (SMS) ─────────────────────────────────────────
|
||||
// Unlike a phone call, a long gap between texts doesn't necessarily mean the
|
||||
// patient walked away — replies are often just slow. So instead of hard
|
||||
// resetting on any idle gap, we only second-guess the stored stage once the
|
||||
// gap crosses STALE_SMS_THRESHOLD_MS, and even then we ask the LLM to look at
|
||||
// the recent history before deciding whether the new text is still answering
|
||||
// the last question or reads like a fresh, unrelated conversation.
|
||||
|
||||
const STALE_SMS_THRESHOLD_MS = 3 * 60 * 60 * 1000; // 3 hours
|
||||
|
||||
const STILL_HERE_MSGS: Record<string, string> = {
|
||||
English: "It's been a while since our last message. How can I help you today?",
|
||||
Spanish: "Ha pasado un tiempo desde nuestro último mensaje. ¿Cómo puedo ayudarle hoy?",
|
||||
Portuguese: "Já faz um tempo desde nossa última mensagem. Como posso ajudá-lo hoje?",
|
||||
Mandarin: "距离我们上次的消息已经有一段时间了。今天我能帮您什么?",
|
||||
Cantonese: "距離我們上次嘅消息已經有一段時間喇。今日我可以點幫您?",
|
||||
Arabic: "لقد مر بعض الوقت منذ آخر رسالة لنا. كيف يمكنني مساعدتك اليوم؟",
|
||||
"Haitian Creole": "Sa gen yon ti tan depi dènye mesaj nou. Kijan mwen ka ede ou jodi a?",
|
||||
};
|
||||
|
||||
/**
|
||||
* After a long idle gap, ask the LLM whether the patient's new text still
|
||||
* answers what was last asked (recent history + stage in context) or reads
|
||||
* like an unrelated, fresh message. Defaults to "continue" on any failure —
|
||||
* an LLM call error shouldn't be the reason a legitimate reply gets bounced.
|
||||
*/
|
||||
async function isLikelyContinuation(
|
||||
recentHistory: { role: "user" | "assistant"; text: string }[],
|
||||
newMessage: string,
|
||||
stage: ConversationStage,
|
||||
apiKey: string,
|
||||
provider: AiProvider,
|
||||
model?: string,
|
||||
): Promise<boolean> {
|
||||
if (recentHistory.length === 0) return true;
|
||||
try {
|
||||
const llm = getLlm(provider, apiKey, model);
|
||||
const transcript = recentHistory
|
||||
.map(m => `${m.role === "assistant" ? "Office" : "Patient"}: ${m.text}`)
|
||||
.join("\n");
|
||||
const res = await llm.invoke([
|
||||
{
|
||||
role: "system",
|
||||
content:
|
||||
`You are helping a dental office text-messaging assistant resume a conversation with a patient after a long gap of silence. ` +
|
||||
`Here is the recent conversation (oldest first):\n${transcript}\n\n` +
|
||||
`The assistant is currently waiting on stage "${stage}" of a booking flow. ` +
|
||||
`The patient just sent a new message after the long gap: "${newMessage}"\n\n` +
|
||||
`Does this new message still look like it's answering/continuing that same conversation, or does it read like an unrelated, fresh message (e.g. a new question, a greeting, a different topic)? ` +
|
||||
`Reply with ONLY one word: "continue" or "restart".`,
|
||||
},
|
||||
]);
|
||||
return !/restart/i.test(String(res.content));
|
||||
} catch {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
// ── POST /api/twilio/webhook/sms ──────────────────────────────────────────────
|
||||
|
||||
const CONVO_TIMEOUT_MS = 5 * 60 * 1000; // 5 minutes
|
||||
|
||||
// In-memory conversation state for unknown phone numbers (no patient record yet).
|
||||
// Keyed by normalized "From" number.
|
||||
const unknownPhoneConvos = new Map<string, {
|
||||
userId: number;
|
||||
stage: ConversationStage;
|
||||
language: string;
|
||||
lastActivityAt: Date;
|
||||
pendingApptDate?: { date: Date; dateLabel: string };
|
||||
}>();
|
||||
|
||||
router.post("/webhook/sms", async (req: Request, res: Response): Promise<any> => {
|
||||
try {
|
||||
const { From, To, Body, MessageSid } = req.body;
|
||||
|
||||
const normalizedFrom = (From || "").replace(/\D/g, "");
|
||||
// Ordered by id so that when duplicate patients share a phone number
|
||||
// (one record per insurance), the earliest-created record is the default.
|
||||
const allPatients = await db.patient.findMany({
|
||||
select: { id: true, phone: true, userId: true, preferredLanguage: true },
|
||||
orderBy: { id: "asc" },
|
||||
});
|
||||
const patient = allPatients.find(
|
||||
(p) => p.phone && p.phone.replace(/\D/g, "") === normalizedFrom
|
||||
);
|
||||
|
||||
if (!patient) {
|
||||
// Unknown number — look up office by the Twilio "To" number
|
||||
const normalizedTo = (To || "").replace(/\D/g, "");
|
||||
const twilioRow = await db.twilioSettings.findFirst({
|
||||
where: { phoneNumber: { contains: normalizedTo.slice(-10) } },
|
||||
select: { userId: true },
|
||||
});
|
||||
if (!twilioRow) {
|
||||
res.set("Content-Type", "text/xml");
|
||||
return res.send(empty());
|
||||
}
|
||||
|
||||
const userId = twilioRow.userId;
|
||||
const openPhoneReply = await storage.getOpenPhoneReply(userId);
|
||||
if (!openPhoneReply) {
|
||||
res.set("Content-Type", "text/xml");
|
||||
return res.send(empty());
|
||||
}
|
||||
|
||||
// Fetch required context for this office
|
||||
const aiSettings = await storage.getAiSettings(userId);
|
||||
const activeAiU = resolveAiProvider(aiSettings ?? {});
|
||||
if (!activeAiU) {
|
||||
res.set("Content-Type", "text/xml");
|
||||
return res.send(empty());
|
||||
}
|
||||
|
||||
const chatTemplates = await storage.getAiChatTemplates(userId);
|
||||
const officeContact = await storage.getOfficeContact(userId);
|
||||
const officeName = (officeContact as any)?.officeName?.trim() || "";
|
||||
const convo = unknownPhoneConvos.get(normalizedFrom);
|
||||
let stage = convo?.stage ?? "initial";
|
||||
const language = convo?.language ?? "English";
|
||||
|
||||
// Reset conversation if idle for more than 5 minutes
|
||||
if (
|
||||
stage !== "initial" && stage !== "done" &&
|
||||
convo?.lastActivityAt &&
|
||||
Date.now() - convo.lastActivityAt.getTime() > CONVO_TIMEOUT_MS
|
||||
) {
|
||||
stage = "initial";
|
||||
unknownPhoneConvos.set(normalizedFrom, { userId, stage: "initial", language, lastActivityAt: new Date() });
|
||||
}
|
||||
|
||||
const replyUnknown = (
|
||||
text: string,
|
||||
nextStage: ConversationStage,
|
||||
pendingApptDate?: { date: Date; dateLabel: string },
|
||||
) => {
|
||||
unknownPhoneConvos.set(normalizedFrom, {
|
||||
userId, stage: nextStage, language,
|
||||
lastActivityAt: new Date(),
|
||||
...(pendingApptDate ? { pendingApptDate } : { pendingApptDate: convo?.pendingApptDate }),
|
||||
});
|
||||
res.set("Content-Type", "text/xml");
|
||||
return res.send(twimlReply(text));
|
||||
};
|
||||
|
||||
if (stage === "initial" || stage === "done") {
|
||||
// MSG 1: AI intro
|
||||
const rawGreeting = chatTemplates.newPatientGreeting ||
|
||||
`Hi! My name is Lisa, the dedicated AI assistant at {officeName}. I can help you schedule an appointment, check your insurance, and answer general questions 24/7.`;
|
||||
const introText = applyOfficeName(rawGreeting, officeName);
|
||||
|
||||
// MSG 2: Empathetic acknowledgment
|
||||
const empatheticText = getEmpatheticAck(Body, language);
|
||||
|
||||
// MSG 3: Detect intent from first message
|
||||
const isReschedule = /reschedule|rescheduler|change.*appoint|modify.*appoint|move.*appoint|reprogramar|reagendar|cambiar|mudar/i.test(Body);
|
||||
let msg3Unk: string;
|
||||
let nextStage3Unk: ConversationStage;
|
||||
|
||||
if (isReschedule) {
|
||||
const notFoundMsgs: Record<string, string> = {
|
||||
English: "I couldn't find an appointment associated with your number. Please leave your name and a good callback number and our receptionist will assist you as soon as possible.",
|
||||
Spanish: "No encontré una cita asociada a su número. Por favor deje su nombre y un número de contacto y nuestra recepcionista le atenderá lo antes posible.",
|
||||
Portuguese: "Não encontrei uma consulta associada ao seu número. Por favor deixe seu nome e um número de contato e nossa recepcionista lhe atenderá o mais breve possível.",
|
||||
Mandarin: "我找不到与您号码关联的预约。请留下您的姓名和联系电话,我们的前台将尽快与您联系。",
|
||||
Cantonese: "我找不到與您號碼關聯的預約。請留下您的姓名和聯絡電話,我們的接待員將盡快與您聯絡。",
|
||||
Arabic: "لم أجد موعداً مرتبطاً برقمك. يرجى ترك اسمك ورقم هاتف للتواصل وسيساعدك موظف الاستقبال في أقرب وقت.",
|
||||
"Haitian Creole": "Mwen pa jwenn yon randevou ki asosye ak nimewo ou. Tanpri kite non ou ak yon nimewo pou rele epi resepsyonis nou an pral ede ou pi vit posib.",
|
||||
};
|
||||
msg3Unk = notFoundMsgs[language] ?? notFoundMsgs["English"]!;
|
||||
nextStage3Unk = "collecting_contact_info";
|
||||
} else {
|
||||
const newOrExistingMsgs: Record<string, string> = {
|
||||
English: "Can you please tell me if you are a new or existing patient?",
|
||||
Spanish: "¿Puede decirme si es usted un paciente nuevo o existente?",
|
||||
Portuguese: "Pode me dizer se você é um paciente novo ou existente?",
|
||||
Mandarin: "请问您是新患者还是现有患者?",
|
||||
Cantonese: "請問您是新病人還是現有病人?",
|
||||
Arabic: "هل يمكنك إخباري إذا كنت مريضاً جديداً أم حالياً؟",
|
||||
"Haitian Creole": "Èske ou ka di mwen si ou se yon nouvo pasyan oswa yon pasyan egzistan?",
|
||||
};
|
||||
msg3Unk = newOrExistingMsgs[language] ?? newOrExistingMsgs["English"]!;
|
||||
nextStage3Unk = "asked_new_or_existing";
|
||||
}
|
||||
|
||||
// Update store then send all 3 in one TwiML response (guaranteed order)
|
||||
unknownPhoneConvos.set(normalizedFrom, {
|
||||
userId, stage: nextStage3Unk, language, lastActivityAt: new Date(),
|
||||
});
|
||||
res.set("Content-Type", "text/xml");
|
||||
return res.send(twimlMessages(introText, empatheticText, msg3Unk));
|
||||
}
|
||||
|
||||
// ── Unknown: asked_new_or_reschedule (fallback for in-progress conversations) ──
|
||||
if (stage === "asked_new_or_reschedule") {
|
||||
const isReschedule = /reschedule|rescheduler|change|modify|move|reprogramar|reagendar|cambiar|mudar/i.test(Body);
|
||||
if (isReschedule) {
|
||||
const notFoundMsgs: Record<string, string> = {
|
||||
English: "I couldn't find an appointment associated with your number. Please leave your name and a good callback number and our receptionist will assist you as soon as possible.",
|
||||
Spanish: "No encontré una cita asociada a su número. Por favor deje su nombre y un número de contacto y nuestra recepcionista le atenderá lo antes posible.",
|
||||
Portuguese: "Não encontrei uma consulta associada ao seu número. Por favor deixe seu nome e um número de contato e nossa recepcionista lhe atenderá o mais breve possível.",
|
||||
Mandarin: "我找不到与您号码关联的预约。请留下您的姓名和联系电话,我们的前台将尽快与您联系。",
|
||||
Cantonese: "我找不到與您號碼關聯的預約。請留下您的姓名和聯絡電話,我們的接待員將盡快與您聯絡。",
|
||||
Arabic: "لم أجد موعداً مرتبطاً برقمك. يرجى ترك اسمك ورقم هاتف للتواصل وسيساعدك موظف الاستقبال في أقرب وقت.",
|
||||
"Haitian Creole": "Mwen pa jwenn yon randevou ki asosye ak nimewo ou. Tanpri kite non ou ak yon nimewo pou rele epi resepsyonis nou an pral ede ou pi vit posib.",
|
||||
};
|
||||
return replyUnknown(notFoundMsgs[language] ?? notFoundMsgs["English"]!, "collecting_contact_info");
|
||||
}
|
||||
const newOrExistingMsgs: Record<string, string> = {
|
||||
English: "Can you please tell me if you are a new or existing patient?",
|
||||
Spanish: "¿Puede decirme si es usted un paciente nuevo o existente?",
|
||||
Portuguese: "Pode me dizer se você é um paciente novo ou existente?",
|
||||
Mandarin: "请问您是新患者还是现有患者?",
|
||||
Cantonese: "請問您是新病人還是現有病人?",
|
||||
Arabic: "هل يمكنك إخباري إذا كنت مريضاً جديداً أم حالياً؟",
|
||||
"Haitian Creole": "Èske ou ka di mwen si ou se yon nouvo pasyan oswa yon pasyan egzistan?",
|
||||
};
|
||||
return replyUnknown(newOrExistingMsgs[language] ?? newOrExistingMsgs["English"]!, "asked_new_or_existing");
|
||||
}
|
||||
|
||||
// ── Unknown: asked_appointment_time → parse date, check office hours ──
|
||||
if (stage === "asked_appointment_time") {
|
||||
const parsedDate = await parseDateOnlyFromMessage(Body, activeAiU.key, activeAiU.provider, activeAiU.model);
|
||||
if (!parsedDate) {
|
||||
const msgs: Record<string, string> = {
|
||||
English: "I didn't catch that. What day would you prefer? For example: 'May 28', 'next Monday', or '5/28'.",
|
||||
Spanish: "No entendí. ¿Qué día prefiere? Por ejemplo: '28 de mayo', 'próximo lunes' o '28/5'.",
|
||||
Portuguese: "Não entendi. Que dia você prefere? Por exemplo: '28 de maio', 'próxima segunda' ou '28/5'.",
|
||||
Mandarin: "我没听清。您希望哪天?例如:'5月28日'、'下周一'或'5/28'。",
|
||||
Cantonese: "我沒聽清。您希望哪天?例如:'5月28日'、'下週一'或'5/28'。",
|
||||
Arabic: "لم أفهم. ما اليوم الذي تفضله؟ مثلاً: '28 مايو' أو '5/28'.",
|
||||
"Haitian Creole": "Mwen pa konprann. Ki jou ou prefere? Pa egzanp: '28 me', 'Lendi pwochèn', oswa '5/28'.",
|
||||
};
|
||||
return replyUnknown(msgs[language] ?? msgs["English"]!, "asked_appointment_time");
|
||||
}
|
||||
const { date, dateLabel } = parsedDate;
|
||||
const dayCheck = await isOfficeDayOpen(date, userId);
|
||||
if (!dayCheck.open) {
|
||||
const msgs: Record<string, string> = {
|
||||
English: `Our office is closed on ${dateLabel} (${dayCheck.displayDay}). Can you please choose another day?`,
|
||||
Spanish: `Nuestra oficina está cerrada el ${dateLabel} (${dayCheck.displayDay}). ¿Puede elegir otro día?`,
|
||||
Portuguese: `Nosso consultório está fechado em ${dateLabel} (${dayCheck.displayDay}). Pode escolher outro dia?`,
|
||||
Mandarin: `我们诊所在 ${dateLabel}(${dayCheck.displayDay})不开放。请您选择另一天好吗?`,
|
||||
Cantonese: `我們診所在 ${dateLabel}(${dayCheck.displayDay})不開放。請您選擇另一天好嗎?`,
|
||||
Arabic: `مكتبنا مغلق في ${dateLabel} (${dayCheck.displayDay}). هل يمكنك اختيار يوم آخر؟`,
|
||||
"Haitian Creole": `Biwo nou fèmen nan ${dateLabel} (${dayCheck.displayDay}). Tanpri chwazi yon lòt jou?`,
|
||||
};
|
||||
return replyUnknown(msgs[language] ?? msgs["English"]!, "asked_appointment_time");
|
||||
}
|
||||
const askTimeMsgs: Record<string, string> = {
|
||||
English: `What time do you prefer on ${dateLabel}?`,
|
||||
Spanish: `¿A qué hora prefiere el ${dateLabel}?`,
|
||||
Portuguese: `Que horário você prefere em ${dateLabel}?`,
|
||||
Mandarin: `您希望在 ${dateLabel} 几点?`,
|
||||
Cantonese: `您希望在 ${dateLabel} 幾點?`,
|
||||
Arabic: `ما الوقت الذي تفضله في ${dateLabel}؟`,
|
||||
"Haitian Creole": `Ki lè ou prefere nan ${dateLabel}?`,
|
||||
};
|
||||
return replyUnknown(
|
||||
askTimeMsgs[language] ?? askTimeMsgs["English"]!,
|
||||
"asked_new_appt_time_for_date",
|
||||
{ date, dateLabel },
|
||||
);
|
||||
}
|
||||
|
||||
// ── Unknown: asked_new_appt_time_for_date → parse time, check hours ───
|
||||
if (stage === "asked_new_appt_time_for_date") {
|
||||
const pendingApptDate = convo?.pendingApptDate;
|
||||
if (!pendingApptDate) {
|
||||
return replyUnknown(
|
||||
"I lost track of the date. What day would you prefer?",
|
||||
"asked_appointment_time",
|
||||
);
|
||||
}
|
||||
const startTime = await parseTime(Body, activeAiU.key, activeAiU.provider, activeAiU.model);
|
||||
if (!startTime) {
|
||||
const msgs: Record<string, string> = {
|
||||
English: `I didn't catch the time. What time do you prefer on ${pendingApptDate.dateLabel}? For example: '10am' or '2pm'.`,
|
||||
Spanish: `No entendí la hora. ¿Qué hora prefiere el ${pendingApptDate.dateLabel}? Por ejemplo: '10am' o '2pm'.`,
|
||||
Portuguese: `Não entendi o horário. Que hora você prefere em ${pendingApptDate.dateLabel}? Por exemplo: '10h' ou '14h'.`,
|
||||
Mandarin: `我没听清时间。您在 ${pendingApptDate.dateLabel} 几点?例如:上午10点或下午2点。`,
|
||||
Cantonese: `我沒聽清時間。您在 ${pendingApptDate.dateLabel} 幾點?例如:上午10點或下午2點。`,
|
||||
Arabic: `لم أفهم الوقت. ما الوقت الذي تفضله في ${pendingApptDate.dateLabel}؟ مثلاً: 10 صباحاً أو 2 مساءً.`,
|
||||
"Haitian Creole": `Mwen pa konprann lè a. Ki lè ou prefere nan ${pendingApptDate.dateLabel}? Pa egzanp: 10am oswa 2pm.`,
|
||||
};
|
||||
return replyUnknown(msgs[language] ?? msgs["English"]!, "asked_new_appt_time_for_date");
|
||||
}
|
||||
const withinHours = await isWithinOfficeHours(pendingApptDate.date, startTime, userId);
|
||||
if (!withinHours) {
|
||||
const hoursDisplay = await getOfficeHoursDisplay(pendingApptDate.date, userId);
|
||||
const timeLbl = timeLabel(startTime);
|
||||
const msgs: Record<string, string> = {
|
||||
English: hoursDisplay
|
||||
? `Our office is not available at ${timeLbl} on ${pendingApptDate.dateLabel}. Our hours are ${hoursDisplay}. What other time do you prefer?`
|
||||
: `Our office is not available at ${timeLbl} on ${pendingApptDate.dateLabel}. What other time do you prefer?`,
|
||||
Spanish: hoursDisplay
|
||||
? `Nuestra oficina no está disponible a las ${timeLbl} el ${pendingApptDate.dateLabel}. Nuestro horario es ${hoursDisplay}. ¿Qué otro horario prefiere?`
|
||||
: `Nuestra oficina no está disponible a las ${timeLbl} el ${pendingApptDate.dateLabel}. ¿Qué otro horario prefiere?`,
|
||||
Portuguese: hoursDisplay
|
||||
? `Nosso consultório não está disponível às ${timeLbl} em ${pendingApptDate.dateLabel}. Nosso horário é ${hoursDisplay}. Que outro horário você prefere?`
|
||||
: `Nosso consultório não está disponível às ${timeLbl} em ${pendingApptDate.dateLabel}. Que outro horário você prefere?`,
|
||||
Mandarin: hoursDisplay
|
||||
? `我们诊所在 ${pendingApptDate.dateLabel} ${timeLbl} 不开放。工作时间是 ${hoursDisplay}。您希望改什么时间?`
|
||||
: `我们诊所在 ${pendingApptDate.dateLabel} ${timeLbl} 不开放。您希望改什么时间?`,
|
||||
Cantonese: hoursDisplay
|
||||
? `我們診所在 ${pendingApptDate.dateLabel} ${timeLbl} 不開放。工作時間是 ${hoursDisplay}。您希望改什麼時間?`
|
||||
: `我們診所在 ${pendingApptDate.dateLabel} ${timeLbl} 不開放。您希望改什麼時間?`,
|
||||
Arabic: hoursDisplay
|
||||
? `مكتبنا غير متاح في ${timeLbl} يوم ${pendingApptDate.dateLabel}. ساعات العمل: ${hoursDisplay}. ما وقت آخر تفضله؟`
|
||||
: `مكتبنا غير متاح في ${timeLbl} يوم ${pendingApptDate.dateLabel}. ما وقت آخر تفضله؟`,
|
||||
"Haitian Creole": hoursDisplay
|
||||
? `Biwo nou pa disponib a ${timeLbl} nan ${pendingApptDate.dateLabel}. Orè nou se ${hoursDisplay}. Ki lòt lè ou prefere?`
|
||||
: `Biwo nou pa disponib a ${timeLbl} nan ${pendingApptDate.dateLabel}. Ki lòt lè ou prefere?`,
|
||||
};
|
||||
return replyUnknown(msgs[language] ?? msgs["English"]!, "asked_new_appt_time_for_date");
|
||||
}
|
||||
const apptLabel = `${pendingApptDate.dateLabel} at ${timeLabel(startTime)}`;
|
||||
const confirmMsgs: Record<string, string> = {
|
||||
English: `Thank you! Your preferred appointment is ${apptLabel}. Our receptionist will confirm the details with you shortly.`,
|
||||
Spanish: `¡Gracias! Su cita preferida es el ${apptLabel}. Nuestra recepcionista confirmará los detalles con usted en breve.`,
|
||||
Portuguese: `Obrigado! Sua consulta preferida é em ${apptLabel}. Nossa recepcionista confirmará os detalhes em breve.`,
|
||||
Mandarin: `谢谢!您的预约时间为 ${apptLabel}。我们的前台将很快与您确认详情。`,
|
||||
Cantonese: `多謝!您的預約時間為 ${apptLabel}。我們的接待員將很快與您確認詳情。`,
|
||||
Arabic: `شكراً! موعدك المفضل هو ${apptLabel}. ستتواصل معك موظفة الاستقبال قريباً لتأكيد التفاصيل.`,
|
||||
"Haitian Creole": `Mèsi! Randevou ou a se ${apptLabel}. Resepsyonis nou an pral konfime detay yo ak ou byento.`,
|
||||
};
|
||||
return replyUnknown(confirmMsgs[language] ?? confirmMsgs["English"]!, "done");
|
||||
}
|
||||
|
||||
// Multi-step new patient stages for unknown numbers
|
||||
const unknownNewPatientStages: ConversationStage[] = [
|
||||
"new_patient_greeted", "asked_new_or_existing",
|
||||
"asked_new_patient_insurance", "asked_insurance_type",
|
||||
"asked_masshealth_check_consent", "asked_existing_insurance",
|
||||
"asked_appointment_preference",
|
||||
"asked_self_pay", "asked_other_insurance_after_inactive",
|
||||
"collecting_contact_info",
|
||||
];
|
||||
if (unknownNewPatientStages.includes(stage)) {
|
||||
const { reply: aiReply, nextStage } = await runNewPatientStep(
|
||||
Body, stage, language, activeAiU.key, chatTemplates.generalFallback, activeAiU.provider, activeAiU.model
|
||||
);
|
||||
return replyUnknown(aiReply, nextStage);
|
||||
}
|
||||
|
||||
res.set("Content-Type", "text/xml");
|
||||
return res.send(empty());
|
||||
}
|
||||
|
||||
// Save inbound message
|
||||
await storage.createCommunication({
|
||||
patientId: patient.id, channel: "sms", direction: "inbound",
|
||||
status: "delivered", body: Body, twilioSid: MessageSid,
|
||||
});
|
||||
|
||||
// Per-patient handoff toggle must be ON
|
||||
if (!await getHandoff(patient.userId, patient.id)) {
|
||||
res.set("Content-Type", "text/xml");
|
||||
return res.send(empty());
|
||||
}
|
||||
|
||||
const aiSettings = await storage.getAiSettings(patient.userId);
|
||||
const activeAi = resolveAiProvider(aiSettings ?? {});
|
||||
if (!activeAi) {
|
||||
res.set("Content-Type", "text/xml");
|
||||
return res.send(empty());
|
||||
}
|
||||
|
||||
const language = patient.preferredLanguage || "English";
|
||||
let stage = await getStage(patient.userId, patient.id);
|
||||
const chatTemplates = await storage.getAiChatTemplates(patient.userId);
|
||||
|
||||
// Long idle gap: don't blindly resume (SMS replies are often just slow),
|
||||
// but don't blindly reset either — check whether the new text still reads
|
||||
// like a continuation before deciding.
|
||||
if (stage !== "initial" && stage !== "done") {
|
||||
const convRow = await db.patientConversation.findUnique({
|
||||
where: { patientId: patient.id },
|
||||
select: { updatedAt: true },
|
||||
});
|
||||
if (convRow?.updatedAt && Date.now() - convRow.updatedAt.getTime() > STALE_SMS_THRESHOLD_MS) {
|
||||
const recentComms = await storage.getCommunicationsByPatient(patient.id);
|
||||
const recentHistory = recentComms.slice(-5).map((c: any) => ({
|
||||
role: (c.direction === "outbound" ? "assistant" : "user") as "assistant" | "user",
|
||||
text: c.body || "",
|
||||
}));
|
||||
const continuing = await isLikelyContinuation(
|
||||
recentHistory, Body, stage, activeAi.key, activeAi.provider, activeAi.model
|
||||
);
|
||||
if (!continuing) {
|
||||
clearPendingReschedule(patient.userId, patient.id);
|
||||
await setStage(patient.userId, patient.id, "initial");
|
||||
stage = "initial";
|
||||
const stillHereText = STILL_HERE_MSGS[language] ?? STILL_HERE_MSGS["English"]!;
|
||||
await saveOutbound(patient.id, stillHereText);
|
||||
res.set("Content-Type", "text/xml");
|
||||
return res.send(twimlMessages(stillHereText));
|
||||
}
|
||||
// Looks like a continuation — fall through and resume the stored stage.
|
||||
}
|
||||
}
|
||||
const officeContact = await storage.getOfficeContact(patient.userId);
|
||||
const officeName = (officeContact as any)?.officeName?.trim() || "";
|
||||
|
||||
// ── Shared business rules (reminder/reschedule/new-patient graphs, plus the
|
||||
// MassHealth and appointment-booking stages) — the exact same code path the
|
||||
// AI voice call webhook uses, so a patient gets identical guardrails and
|
||||
// outcomes regardless of channel.
|
||||
const routed = await routePatientTurn({
|
||||
patient, stage, message: Body, activeAi, chatTemplates, officeName,
|
||||
});
|
||||
|
||||
if (routed.segments.length > 0) {
|
||||
for (const seg of routed.segments) await saveOutbound(patient.id, seg);
|
||||
|
||||
// reminder_initial's self-intro (segments[0]) is queued via REST so it's
|
||||
// guaranteed to arrive before the classified reply that follows it.
|
||||
if (stage === "reminder_initial" && routed.segments.length > 1) {
|
||||
const twilioSettings = await storage.getTwilioSettings(patient.userId);
|
||||
if (twilioSettings) {
|
||||
const client = twilio(twilioSettings.accountSid, twilioSettings.authToken);
|
||||
await client.messages.create({ body: routed.segments[0]!, from: twilioSettings.phoneNumber, to: From });
|
||||
}
|
||||
res.set("Content-Type", "text/xml");
|
||||
return res.send(twimlMessages(...routed.segments.slice(1)));
|
||||
}
|
||||
|
||||
res.set("Content-Type", "text/xml");
|
||||
return res.send(twimlMessages(...routed.segments));
|
||||
}
|
||||
|
||||
// ── Stage: initial / done (patient texts in fresh) ───────────────────
|
||||
if (stage === "initial" || stage === "done") {
|
||||
const openPhoneReply = await storage.getOpenPhoneReply(patient.userId);
|
||||
const afterHoursEnabled = await getAfterHoursHandoff(patient.userId);
|
||||
const outsideHours = await isAfterHours(patient.userId);
|
||||
|
||||
if (openPhoneReply || (afterHoursEnabled && outsideHours)) {
|
||||
// MSG 1: AI self-introduction
|
||||
const rawGreeting = chatTemplates.newPatientGreeting ||
|
||||
`Hi! My name is Lisa, the dedicated AI assistant at {officeName}. I can help you schedule an appointment, check your insurance, and answer general questions 24/7.`;
|
||||
const introText = applyOfficeName(rawGreeting, officeName);
|
||||
|
||||
// MSG 2: Empathetic acknowledgment of the patient's message
|
||||
const empatheticText = getEmpatheticAck(Body, language);
|
||||
|
||||
// MSG 3: Detect reschedule vs new appointment from initial text
|
||||
const isRescheduleIntent = /reschedule|rescheduler|change.*appoint|modify.*appoint|move.*appoint|reprogramar|reagendar|cambiar|mudar/i.test(Body);
|
||||
|
||||
let msg3: string;
|
||||
let nextStage3: ConversationStage;
|
||||
|
||||
if (isRescheduleIntent) {
|
||||
const apptDatetime = await getAppointmentDatetime(patient.id);
|
||||
if (apptDatetime) {
|
||||
const foundMsgs: Record<string, string> = {
|
||||
English: `Ok. Just to confirm, your current appointment is on ${apptDatetime}. When would you like to reschedule?`,
|
||||
Spanish: `De acuerdo. Solo para confirmar, su cita actual es el ${apptDatetime}. ¿Cuándo le gustaría reprogramarla?`,
|
||||
Portuguese: `Ok. Só para confirmar, sua consulta atual é em ${apptDatetime}. Quando você gostaria de reagendar?`,
|
||||
Mandarin: `好的。请确认一下,您当前的预约是 ${apptDatetime}。您想重新安排到什么时候?`,
|
||||
Cantonese: `好的。請確認一下,您目前的預約是 ${apptDatetime}。您想重新安排到什麼時候?`,
|
||||
Arabic: `حسناً. فقط للتأكيد، موعدك الحالي في ${apptDatetime}. متى تريد إعادة الجدولة؟`,
|
||||
"Haitian Creole": `Oke. Jis pou konfime, randevou aktyèl ou a se ${apptDatetime}. Ki lè ou ta renmen reprogramè?`,
|
||||
};
|
||||
msg3 = foundMsgs[language] ?? foundMsgs["English"]!;
|
||||
nextStage3 = "asked_reschedule_datetime";
|
||||
} else {
|
||||
const notFoundMsgs: Record<string, string> = {
|
||||
English: "Ok. I could not find your current appointment. Please tell me when you'd like to reschedule and our receptionist will contact you as soon as possible.",
|
||||
Spanish: "De acuerdo. No pude encontrar su cita actual. Por favor díganos cuándo le gustaría reprogramar y nuestra recepcionista le contactará lo antes posible.",
|
||||
Portuguese: "Ok. Não consegui encontrar sua consulta atual. Por favor, diga-nos quando você gostaria de reagendar e nossa recepcionista entrará em contato o mais breve possível.",
|
||||
Mandarin: "好的。我找不到您当前的预约。请告诉我您希望重新安排的时间,我们的前台将尽快与您联系。",
|
||||
Cantonese: "好的。我找不到您目前的預約。請告訴我您希望重新安排的時間,我們的接待員將盡快與您聯絡。",
|
||||
Arabic: "حسناً. لم أجد موعدك الحالي. يرجى إخبارنا بالوقت الذي تريد إعادة الجدولة إليه وسيتصل بك موظف الاستقبال في أقرب وقت ممكن.",
|
||||
"Haitian Creole": "Oke. Mwen pa t jwenn randevou aktyèl ou. Tanpri di nou ki lè ou ta renmen reprogramè epi resepsyonis nou an pral kontakte ou pi vit posib.",
|
||||
};
|
||||
msg3 = notFoundMsgs[language] ?? notFoundMsgs["English"]!;
|
||||
nextStage3 = "done";
|
||||
}
|
||||
} else {
|
||||
// New appointment (or any other intent) → ask new or existing patient
|
||||
const newOrExistingMsgs: Record<string, string> = {
|
||||
English: "Can you please tell me if you are a new or existing patient?",
|
||||
Spanish: "¿Puede decirme si es usted un paciente nuevo o existente?",
|
||||
Portuguese: "Pode me dizer se você é um paciente novo ou existente?",
|
||||
Mandarin: "请问您是新患者还是现有患者?",
|
||||
Cantonese: "請問您是新病人還是現有病人?",
|
||||
Arabic: "هل يمكنك إخباري إذا كنت مريضاً جديداً أم حالياً؟",
|
||||
"Haitian Creole": "Èske ou ka di mwen si ou se yon nouvo pasyan oswa yon pasyan egzistan?",
|
||||
};
|
||||
msg3 = newOrExistingMsgs[language] ?? newOrExistingMsgs["English"]!;
|
||||
nextStage3 = "asked_new_or_existing";
|
||||
}
|
||||
|
||||
// Save all 3 outbound messages and set stage
|
||||
await saveOutbound(patient.id, introText);
|
||||
await saveOutbound(patient.id, empatheticText);
|
||||
await saveOutbound(patient.id, msg3);
|
||||
await setStage(patient.userId, patient.id, nextStage3);
|
||||
|
||||
// Send all 3 in one TwiML response — Twilio guarantees delivery order
|
||||
res.set("Content-Type", "text/xml");
|
||||
return res.send(twimlMessages(introText, empatheticText, msg3));
|
||||
}
|
||||
}
|
||||
|
||||
res.set("Content-Type", "text/xml");
|
||||
return res.send(empty());
|
||||
|
||||
} catch (err) {
|
||||
res.set("Content-Type", "text/xml");
|
||||
return res.send(empty());
|
||||
}
|
||||
});
|
||||
|
||||
// ── POST /api/twilio/webhook/voice ────────────────────────────────────────────
|
||||
|
||||
router.post("/webhook/voice", async (req: Request, res: Response): Promise<any> => {
|
||||
try {
|
||||
const { From, CallSid } = req.body;
|
||||
|
||||
const normalizedFrom = (From || "").replace(/\D/g, "");
|
||||
// Ordered by id — earliest-created record wins when duplicates share a phone.
|
||||
const allPatients = await db.patient.findMany({ select: { id: true, phone: true, userId: true }, orderBy: { id: "asc" } });
|
||||
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 = `${getPublicBaseUrl(req)}/api/twilio/webhook/voice-recording`;
|
||||
|
||||
res.set("Content-Type", "text/xml");
|
||||
return res.send(`<?xml version="1.0" encoding="UTF-8"?>
|
||||
<Response>
|
||||
<Say voice="alice">${greeting}</Say>
|
||||
<Record maxLength="120" action="${recordingCallbackUrl}" transcribeCallback="${recordingCallbackUrl}" playBeep="true"/>
|
||||
<Say voice="alice">We did not receive a recording. Goodbye.</Say>
|
||||
</Response>`);
|
||||
} catch (err) {
|
||||
res.set("Content-Type", "text/xml");
|
||||
return res.send(`<?xml version="1.0" encoding="UTF-8"?><Response><Say>Thank you for calling. Please try again later.</Say></Response>`);
|
||||
}
|
||||
});
|
||||
|
||||
// ── POST /api/twilio/webhook/voice-recording ──────────────────────────────────
|
||||
|
||||
router.post("/webhook/voice-recording", async (req: Request, res: Response): Promise<any> => {
|
||||
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(empty());
|
||||
} catch (err) {
|
||||
res.set("Content-Type", "text/xml");
|
||||
return res.send(empty());
|
||||
}
|
||||
});
|
||||
|
||||
// POST /api/twilio/webhook/voice-browser
|
||||
// Called by Twilio when a browser Device (Voice SDK) places an outbound call.
|
||||
// Looks up the office's Twilio phone number by AccountSid and dials the patient.
|
||||
@@ -716,221 +34,4 @@ router.post("/webhook/voice-browser", async (req: Request, res: Response): Promi
|
||||
}
|
||||
});
|
||||
|
||||
// ── AI phone call (outbound "AI Call" from Patient Connection) ───────────────
|
||||
|
||||
const MAX_AI_CALL_TURNS = 12;
|
||||
|
||||
interface AiCallSession {
|
||||
patientId: number;
|
||||
userId: number;
|
||||
phone: string | null;
|
||||
language: string;
|
||||
officeName: string;
|
||||
chatTemplates: Record<string, string>;
|
||||
history: { role: "assistant" | "user"; text: string }[];
|
||||
turns: number;
|
||||
}
|
||||
|
||||
const aiCallSessions = new Map<string, AiCallSession>();
|
||||
|
||||
interface VoiceLocale { gatherLang: string; sayVoice: string; }
|
||||
|
||||
/** English/Spanish only for now — Twilio's native voice/recognition coverage
|
||||
* for the app's other supported languages (Mandarin, Cantonese, Arabic,
|
||||
* Haitian Creole) is inconsistent, so those fall back to English on calls. */
|
||||
function voiceLocaleFor(language: string): VoiceLocale {
|
||||
if (language === "Spanish") return { gatherLang: "es-US", sayVoice: "Polly.Lupe" };
|
||||
return { gatherLang: "en-US", sayVoice: "Polly.Joanna" };
|
||||
}
|
||||
|
||||
// Spoken greeting is plain text, not LLM-generated, so it can't follow the
|
||||
// patient's language on its own — translate it ourselves when the office
|
||||
// hasn't overridden the default wording. Matches voiceLocaleFor's English/
|
||||
// Spanish-only voice coverage above.
|
||||
const GREETING_TRANSLATIONS: Record<string, string> = {
|
||||
Spanish: "Hola {firstName}, soy Lisa, la asistente de inteligencia artificial de {officeName}. ¿En qué puedo ayudarle hoy?",
|
||||
};
|
||||
const RECALL_GREETING_TRANSLATIONS: Record<string, string> = {
|
||||
Spanish: "Hola {firstName}, soy Lisa de {officeName}. Queríamos avisarle que ya le corresponde un examen y limpieza de rutina, que la mayoría de los seguros cubren sin costo. ¿Le gustaría programar una cita?",
|
||||
};
|
||||
|
||||
/** Picks the greeting template to speak: the office's custom wording as-is if
|
||||
* they've edited it (we can't auto-translate free text they wrote), otherwise
|
||||
* the default translated into the patient's language when available. */
|
||||
function resolveGreetingTemplate(rawTemplate: string, defaultTemplate: string, translations: Record<string, string>, language: string): string {
|
||||
if (rawTemplate.trim() !== defaultTemplate) return rawTemplate;
|
||||
return translations[language] ?? defaultTemplate;
|
||||
}
|
||||
|
||||
const ENGLISH_LOCALE: VoiceLocale = { gatherLang: "en-US", sayVoice: "Polly.Joanna" };
|
||||
|
||||
function aiVoiceTwiml(sayText: string, gatherActionUrl: string, locale: VoiceLocale): string {
|
||||
// action is always set, so Twilio posts back to it on both speech and
|
||||
// silence timeout — nothing after </Gather> would ever run.
|
||||
return `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<Response>
|
||||
<Gather input="speech" language="${locale.gatherLang}" speechTimeout="auto" timeout="15" action="${gatherActionUrl}" method="POST">
|
||||
<Say voice="${locale.sayVoice}">${escapeXml(sayText)}</Say>
|
||||
</Gather>
|
||||
</Response>`;
|
||||
}
|
||||
|
||||
function aiVoiceHangup(sayText: string, locale: VoiceLocale = ENGLISH_LOCALE): string {
|
||||
return `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<Response>
|
||||
<Say voice="${locale.sayVoice}">${escapeXml(sayText)}</Say>
|
||||
<Hangup/>
|
||||
</Response>`;
|
||||
}
|
||||
|
||||
// POST /api/twilio/webhook/ai-voice
|
||||
// Entry point + conversation loop for outbound AI-driven calls placed via
|
||||
// POST /api/twilio/make-ai-call. Twilio hits this once when the call connects
|
||||
// (no CallSid session yet) and again after every <Gather> with SpeechResult.
|
||||
// Each spoken turn is routed through the same routePatientTurn() logic the
|
||||
// SMS webhook uses, so calls follow identical rules/guardrails, in the
|
||||
// patient's preferred language (English/Spanish supported for voice).
|
||||
router.post("/webhook/ai-voice", async (req: Request, res: Response): Promise<any> => {
|
||||
res.set("Content-Type", "text/xml");
|
||||
try {
|
||||
const { CallSid, SpeechResult } = req.body as { CallSid?: string; SpeechResult?: string };
|
||||
const patientIdParam = req.query.patientId as string | undefined;
|
||||
const purposeParam = req.query.purpose as string | undefined;
|
||||
if (!CallSid) return res.send(aiVoiceHangup("Sorry, something went wrong. Goodbye."));
|
||||
|
||||
let session = aiCallSessions.get(CallSid);
|
||||
|
||||
// ── First hit for this call: set up session + greet ──────────────────
|
||||
if (!session) {
|
||||
const patientId = parseInt(patientIdParam || "", 10);
|
||||
const patient = !isNaN(patientId)
|
||||
? await db.patient.findUnique({
|
||||
where: { id: patientId },
|
||||
select: { id: true, userId: true, firstName: true, phone: true, preferredLanguage: true },
|
||||
})
|
||||
: null;
|
||||
if (!patient) return res.send(aiVoiceHangup("Sorry, we could not find your patient record. Goodbye."));
|
||||
|
||||
const language = patient.preferredLanguage || "English";
|
||||
const locale = voiceLocaleFor(language);
|
||||
const isRecallCall = purposeParam === "recall";
|
||||
|
||||
// Resume an in-progress cross-channel conversation (e.g. mid-reschedule
|
||||
// from a prior text) if one exists; otherwise start a fresh check-in call.
|
||||
// A "recall" call (staff-triggered from Patient Connection, offering an
|
||||
// existing patient a routine exam & cleaning) seeds a different stage
|
||||
// than the default reminder-style check-in.
|
||||
// Either kind of call always speaks its own pitch fresh (see greeting
|
||||
// below), so it must always reset the stage — even if a prior call of
|
||||
// either kind left the patient stuck mid-flow (e.g. "recall_asked_datetime"
|
||||
// or "asked_reschedule_datetime"). Otherwise the reply below gets
|
||||
// interpreted against that stale leftover stage instead of the fresh
|
||||
// pitch that was just spoken.
|
||||
if (isRecallCall) {
|
||||
await startRecallConversation(patient.userId, patient.id);
|
||||
} else {
|
||||
await resetConversation(patient.userId, patient.id);
|
||||
}
|
||||
|
||||
const officeContact = await storage.getOfficeContact(patient.userId);
|
||||
const officeName = (officeContact as any)?.officeName?.trim() || "";
|
||||
const callTemplates = await storage.getAiCallTemplates(patient.userId);
|
||||
|
||||
session = {
|
||||
patientId: patient.id, userId: patient.userId, phone: patient.phone,
|
||||
language, officeName, chatTemplates: callTemplates, history: [], turns: 0,
|
||||
};
|
||||
aiCallSessions.set(CallSid, session);
|
||||
|
||||
const rawCallTemplate = (isRecallCall ? callTemplates.recallGreeting?.trim() : callTemplates.greeting?.trim()) ||
|
||||
(isRecallCall ? DEFAULT_RECALL_GREETING : DEFAULT_GREETING);
|
||||
const callTemplate = isRecallCall
|
||||
? resolveGreetingTemplate(rawCallTemplate, DEFAULT_RECALL_GREETING, RECALL_GREETING_TRANSLATIONS, language)
|
||||
: resolveGreetingTemplate(rawCallTemplate, DEFAULT_GREETING, GREETING_TRANSLATIONS, language);
|
||||
const greeting = callTemplate
|
||||
.replace(/\{firstName\}/g, patient.firstName || "there")
|
||||
.replace(/\{officeName\}/g, officeName || "our office");
|
||||
|
||||
session.history.push({ role: "assistant", text: greeting });
|
||||
await storage.createCommunication({
|
||||
patientId: patient.id, userId: patient.userId, channel: "voice",
|
||||
direction: "outbound", status: "completed", body: greeting, twilioSid: CallSid,
|
||||
});
|
||||
|
||||
const actionUrl = `${getPublicBaseUrl(req)}/api/twilio/webhook/ai-voice?patientId=${patient.id}`;
|
||||
return res.send(aiVoiceTwiml(greeting, actionUrl, locale));
|
||||
}
|
||||
|
||||
const locale = voiceLocaleFor(session.language);
|
||||
|
||||
// ── Caller stayed silent past the speech timeout ──────────────────────
|
||||
// A call is a single continuous conversation, unlike SMS — if it ends
|
||||
// here, mid-flow or not, there's nothing left to resume, so reset the
|
||||
// stage now instead of leaving the patient stuck for their next call/text.
|
||||
if (!SpeechResult?.trim()) {
|
||||
aiCallSessions.delete(CallSid);
|
||||
await setStage(session.userId, session.patientId, "done");
|
||||
return res.send(aiVoiceHangup("I didn't catch a response. Our staff will follow up with you if needed. Goodbye.", locale));
|
||||
}
|
||||
|
||||
session.history.push({ role: "user", text: SpeechResult.trim() });
|
||||
await storage.createCommunication({
|
||||
patientId: session.patientId, userId: session.userId, channel: "voice",
|
||||
direction: "inbound", status: "completed", body: SpeechResult.trim(), twilioSid: CallSid,
|
||||
});
|
||||
|
||||
session.turns++;
|
||||
|
||||
// ── Caller said something that sounds like goodbye, or we've hit the cap ──
|
||||
if (soundsLikeGoodbye(SpeechResult) || session.turns >= MAX_AI_CALL_TURNS) {
|
||||
aiCallSessions.delete(CallSid);
|
||||
await setStage(session.userId, session.patientId, "done");
|
||||
return res.send(aiVoiceHangup("Thank you for calling, have a great day. Goodbye.", locale));
|
||||
}
|
||||
|
||||
const aiSettings = await storage.getAiSettings(session.userId);
|
||||
const activeAi = resolveAiProvider(aiSettings ?? {});
|
||||
if (!activeAi) {
|
||||
aiCallSessions.delete(CallSid);
|
||||
await setStage(session.userId, session.patientId, "done");
|
||||
return res.send(aiVoiceHangup("Our AI assistant is not available right now. Our staff will follow up with you. Goodbye.", locale));
|
||||
}
|
||||
|
||||
const stage = await getStage(session.userId, session.patientId);
|
||||
const routed = await routePatientTurn({
|
||||
patient: {
|
||||
id: session.patientId, userId: session.userId,
|
||||
phone: session.phone, preferredLanguage: session.language,
|
||||
},
|
||||
stage, message: SpeechResult.trim(), activeAi,
|
||||
chatTemplates: session.chatTemplates, officeName: session.officeName,
|
||||
});
|
||||
|
||||
const reply = routed.segments.join(" ").trim() ||
|
||||
"Sorry, I didn't quite catch that — could you say that again?";
|
||||
|
||||
session.history.push({ role: "assistant", text: reply });
|
||||
await storage.createCommunication({
|
||||
patientId: session.patientId, userId: session.userId, channel: "voice",
|
||||
direction: "outbound", status: "completed", body: reply, twilioSid: CallSid,
|
||||
});
|
||||
|
||||
if (routed.nextStage === "done" || soundsLikeGoodbye(reply)) {
|
||||
aiCallSessions.delete(CallSid);
|
||||
// routePatientTurn() already persisted nextStage — but if we're hanging
|
||||
// up because the reply sounded like a goodbye while nextStage was still
|
||||
// mid-flow, force it to "done" so the next call/text doesn't resume there.
|
||||
if (routed.nextStage !== "done") {
|
||||
await setStage(session.userId, session.patientId, "done");
|
||||
}
|
||||
return res.send(aiVoiceHangup(reply, locale));
|
||||
}
|
||||
|
||||
const actionUrl = `${getPublicBaseUrl(req)}/api/twilio/webhook/ai-voice?patientId=${session.patientId}`;
|
||||
return res.send(aiVoiceTwiml(reply, actionUrl, locale));
|
||||
} catch (err) {
|
||||
return res.send(aiVoiceHangup("Sorry, something went wrong on our end. Goodbye."));
|
||||
}
|
||||
});
|
||||
|
||||
export default router;
|
||||
|
||||
@@ -1,17 +1,13 @@
|
||||
// NOTE: Twilio is now used only for the in-browser dial pad (Access Token
|
||||
// minting + browser-Device outbound-call TwiML). Everything else — SMS,
|
||||
// inbound/AI voice, staff-triggered outbound calls — moved to Telnyx, see
|
||||
// routes/telnyx.ts and routes/telnyx-webhooks.ts.
|
||||
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 {
|
||||
@@ -27,7 +23,6 @@ router.get("/settings", async (req: Request, res: Response): Promise<any> => {
|
||||
accountSid: settings.accountSid,
|
||||
authToken: settings.authToken,
|
||||
phoneNumber: settings.phoneNumber,
|
||||
greetingMessage: settings.greetingMessage,
|
||||
twimlAppSid: templates["_twiml_app_sid"] || null,
|
||||
});
|
||||
} catch (err) {
|
||||
@@ -41,7 +36,7 @@ router.put("/settings", async (req: Request, res: Response): Promise<any> => {
|
||||
const userId = req.user?.id;
|
||||
if (!userId) return res.status(401).json({ message: "Unauthorized" });
|
||||
|
||||
const { accountSid, authToken, phoneNumber, greetingMessage, twimlAppSid } = req.body;
|
||||
const { accountSid, authToken, phoneNumber, twimlAppSid } = req.body;
|
||||
if (!accountSid?.trim() || !authToken?.trim() || !phoneNumber?.trim()) {
|
||||
return res.status(400).json({ message: "accountSid, authToken, and phoneNumber are required" });
|
||||
}
|
||||
@@ -50,7 +45,6 @@ router.put("/settings", async (req: Request, res: Response): Promise<any> => {
|
||||
accountSid: accountSid.trim(),
|
||||
authToken: authToken.trim(),
|
||||
phoneNumber: phoneNumber.trim(),
|
||||
greetingMessage: greetingMessage?.trim() || null,
|
||||
twimlAppSid: twimlAppSid?.trim() || null,
|
||||
});
|
||||
|
||||
@@ -60,7 +54,6 @@ router.put("/settings", async (req: Request, res: Response): Promise<any> => {
|
||||
accountSid: settings.accountSid,
|
||||
authToken: settings.authToken,
|
||||
phoneNumber: settings.phoneNumber,
|
||||
greetingMessage: settings.greetingMessage,
|
||||
twimlAppSid: templates["_twiml_app_sid"] || null,
|
||||
});
|
||||
} catch (err) {
|
||||
@@ -68,461 +61,6 @@ router.put("/settings", async (req: Request, res: Response): Promise<any> => {
|
||||
}
|
||||
});
|
||||
|
||||
// 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> => {
|
||||
@@ -559,45 +97,4 @@ router.post("/voice-token", async (req: Request, res: Response): Promise<any> =>
|
||||
}
|
||||
});
|
||||
|
||||
// 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;
|
||||
|
||||
Reference in New Issue
Block a user