Files
DentalManagementMH05/apps/Backend/src/routes/twilio-webhooks.ts
Gitead c71624f7e7 feat: enhance new-patient AI chat flow with full scheduling and eligibility
- Add 3-message intro (self-intro → empathetic ack → new/existing question) via single TwiML response to guarantee delivery order
- Detect reschedule intent from first message; look up existing appointment date
- New patient flow: ask insurance type → MassHealth consent → member ID + DOB → Selenium eligibility check
- Post-eligibility: active → ask appointment date/time with office-hours validation; inactive → ask other insurance or collect contact info
- Date/time collection mirrors reschedule flow: check office day open, ask time, validate against office hours
- Auto-create appointment in schedule for known patients on confirmation; use first available staff member
- Add openPhoneReply toggle (Settings → AI Chat) to respond to any number at any time
- Add 5-minute inactivity timeout: reset conversation to initial stage and clear pending state
- Normalize MassHealth DOB to zero-padded MM/DD/YYYY before Selenium submission
- Expand isExistingPatient classifier to recognize "old patient", "old", "previous", "prior"
- Existing patient confirmation message now acknowledges patient type before asking about insurance

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-15 00:00:56 -04:00

1207 lines
70 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import express, { Request, Response } from "express";
import twilio from "twilio";
import { storage } from "../storage";
import { prisma as db } from "@repo/db/client";
import { runReminderGraph } from "../ai/reminder-graph";
import { runNewPatientStep } from "../ai/new-patient-graph";
import {
runRescheduleStep,
parseDateOnlyFromMessage,
parseTime,
isOfficeDayOpen,
isWithinOfficeHours,
getOfficeHoursDisplay,
timeLabel,
} from "../ai/reschedule-graph";
import { ChatGoogleGenerativeAI } from "@langchain/google-genai";
import { runEligibilityProcessor } from "../queue/processors/eligibilityProcessor";
import {
getHandoff, getAfterHoursHandoff,
getStage, setStage,
setPendingReschedule, getPendingReschedule, clearPendingReschedule,
type ConversationStage,
} from "../ai/aiHandoffStore";
const router = express.Router();
// ── Helpers ───────────────────────────────────────────────────────────────────
function escapeXml(text: string): string {
return text
.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;")
.replace(/"/g, "&quot;").replace(/'/g, "&apos;");
}
/** 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>";
}
/** Get the patient's next scheduled appointment as a human-readable string. */
async function getAppointmentDatetime(patientId: number): Promise<string> {
const today = new Date();
today.setHours(0, 0, 0, 0);
const appt = await db.appointment.findFirst({
where: { patientId, status: "scheduled", date: { gte: today } },
orderBy: { date: "asc" },
});
if (!appt) return "";
const months = ["January","February","March","April","May","June",
"July","August","September","October","November","December"];
const d = new Date(appt.date);
return `${months[d.getUTCMonth()]} ${d.getUTCDate()}, ${d.getUTCFullYear()} at ${appt.startTime}`;
}
/** 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;
}
/** Substitute {officeName} in a template string. */
function applyOfficeName(template: string, name: string): string {
return template.replace(/\{officeName\}/g, name || "our dental office");
}
/** 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,
});
}
/**
* Extract MassHealth Member ID and date of birth from a free-text SMS.
* Tries regex first, falls back to LLM extraction.
*/
/** Normalize a DOB string to zero-padded MM/DD/YYYY required by MassHealth. */
function normalizeDob(raw: string): string {
const parts = raw.split(/[\/\-\.]/);
if (parts.length !== 3) return raw;
const [m, d, y] = parts;
const mm = String(parseInt(m!, 10)).padStart(2, "0");
const dd = String(parseInt(d!, 10)).padStart(2, "0");
const yyyy = y!.length === 2 ? `20${y}` : y!;
return `${mm}/${dd}/${yyyy}`;
}
async function parseMassHealthInfo(
message: string,
apiKey: string
): Promise<{ memberId: string | null; dob: string | null }> {
// Regex: member IDs are typically 8-12 digits; DOB as MM/DD/YYYY or similar
const idMatch = message.match(/\b(\d{8,12})\b/);
const dobMatch = message.match(/\b(\d{1,2})[\/\-\.](\d{1,2})[\/\-\.](\d{2,4})\b/);
if (idMatch && dobMatch) {
const [, m, d, y] = dobMatch;
const year = y!.length === 2 ? `20${y}` : y;
return { memberId: idMatch[1]!, dob: normalizeDob(`${m}/${d}/${year}`) };
}
// Fall back to LLM structured extraction
try {
const llm = new ChatGoogleGenerativeAI({ model: "gemini-1.5-flash", apiKey });
const res = await llm.invoke([
{
role: "system",
content:
'Extract the insurance member ID and date of birth from the patient message. ' +
'Return ONLY valid JSON: {"memberId":"...","dob":"MM/DD/YYYY"}. Use null for missing fields.',
},
{ role: "user", content: message },
]);
const raw = String(res.content).replace(/```json|```/g, "").trim();
const json = JSON.parse(raw);
const dob = json.dob ? normalizeDob(String(json.dob)) : null;
return { memberId: json.memberId ?? null, dob };
} catch {
return { memberId: null, dob: null };
}
}
/**
* Run MassHealth eligibility check in the background (after replying to patient)
* and send the result as a follow-up SMS.
*/
async function runMassHealthCheckAndNotify(
patient: { id: number; userId: number; phone: string | null; preferredLanguage: string | null },
memberId: string,
dob: string,
apiKey: string,
isExistingPatient = false
): Promise<void> {
try {
const credentials = await storage.getInsuranceCredentialByUserAndSiteKey(patient.userId, "MH");
if (!credentials) return;
const twilioSettings = await storage.getTwilioSettings(patient.userId);
if (!twilioSettings || !patient.phone) return;
// Run Selenium eligibility check directly via the processor
await runEligibilityProcessor({
userId: patient.userId,
insuranceId: memberId,
formDob: dob,
enrichedPayload: {
memberId,
dateOfBirth: dob,
insuranceSiteKey: "MH",
massdhpUsername: credentials.username,
massdhpPassword: credentials.password,
},
});
// Re-fetch updated patient status
const updated = await db.patient.findUnique({
where: { id: patient.id },
select: { status: true, firstName: true },
});
const lang = patient.preferredLanguage || "English";
const active = updated?.status === "ACTIVE";
// ── ACTIVE ────────────────────────────────────────────────────────────────
const activeMessages: Record<string, string> = {
English: "Great news! Your MassHealth coverage is active. We can schedule an appointment for you! What date and time would you prefer?",
Spanish: "¡Buenas noticias! Su cobertura de MassHealth está activa. ¡Podemos programar una cita para usted! ¿Qué fecha y hora prefiere?",
Portuguese: "Ótimas notícias! Sua cobertura MassHealth está ativa. Podemos agendar uma consulta para você! Qual data e horário prefere?",
Mandarin: "好消息您的MassHealth保险有效。我们可以为您安排预约您希望什么日期和时间",
Cantonese: "好消息您的MassHealth保險有效。我們可以為您安排預約您希望什麼日期和時間",
Arabic: "أخبار رائعة! تغطيتك من MassHealth نشطة. يمكننا تحديد موعد لك! ما التاريخ والوقت المفضل لديك؟",
"Haitian Creole": "Bon nouvèl! Asirans MassHealth ou aktif. Nou ka planifye yon randevou pou ou! Ki dat ak lè ou prefere?",
};
// ── INACTIVE: new patient → ask other insurance; existing → ask self-pay ──
const inactiveMessagesNew: Record<string, string> = {
English: "Unfortunately, your MassHealth coverage appears to be inactive. Do you have any other insurance?",
Spanish: "Lamentablemente, su cobertura de MassHealth parece estar inactiva. ¿Tiene algún otro seguro?",
Portuguese: "Infelizmente, sua cobertura MassHealth parece estar inativa. Você tem algum outro plano de saúde?",
Mandarin: "很遗憾您的MassHealth保险似乎无效。您还有其他保险吗",
Cantonese: "很遺憾您的MassHealth保險似乎無效。您還有其他保險嗎",
Arabic: "للأسف، تغطيتك من MassHealth تبدو غير نشطة. هل لديك أي تأمين آخر؟",
"Haitian Creole": "Malerezman, kouvèti MassHealth ou parèt inaktif. Èske ou gen yon lòt asirans?",
};
const inactiveMessagesExisting: Record<string, string> = {
English: "We checked your MassHealth coverage. Unfortunately the plan appears inactive or could not be verified. Would you still like to schedule an examination appointment as a self-pay patient?",
Spanish: "Verificamos su cobertura de MassHealth. Lamentablemente el plan aparece inactivo o no pudo ser verificado. ¿Le gustaría programar una cita de examen como paciente de pago particular?",
Portuguese: "Verificamos sua cobertura MassHealth. Infelizmente o plano parece inativo ou não pôde ser verificado. Gostaria de agendar uma consulta de exame como paciente particular?",
Mandarin: "我们查看了您的MassHealth保险。遗憾的是保险似乎无效或无法验证。您仍然希望以自费方式预约检查吗",
Cantonese: "我們查看了您的MassHealth保險。遺憾地保險似乎無效或無法核實。您仍然希望以自費方式預約檢查嗎",
Arabic: "تحققنا من تغطيتك من MassHealth. للأسف يبدو أن الخطة غير نشطة أو لا يمكن التحقق منها. هل تودّ تحديد موعد فحص كمريض يدفع من حسابه الخاص؟",
"Haitian Creole": "Nou te verifye kouvèti MassHealth ou. Malerezman plan an sanble inaktif oswa pa ka verifye. Èske ou ta renmen pran yon randevou egzamen kòm pasyan ki peye poukont li?",
};
const resultText = active
? (activeMessages[lang] ?? activeMessages["English"]!)
: isExistingPatient
? (inactiveMessagesExisting[lang] ?? inactiveMessagesExisting["English"]!)
: (inactiveMessagesNew[lang] ?? inactiveMessagesNew["English"]!);
const nextStage: ConversationStage = active
? "asked_appointment_time"
: isExistingPatient
? "asked_self_pay"
: "asked_other_insurance_after_inactive";
// Send follow-up question via Twilio
const client = twilio(twilioSettings.accountSid, twilioSettings.authToken);
await client.messages.create({
body: resultText,
from: twilioSettings.phoneNumber,
to: patient.phone,
});
// Persist and advance stage
await saveOutbound(patient.id, resultText);
await setStage(patient.userId, patient.id, nextStage);
} catch {
// Silent — don't crash the main request
}
}
// ── 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?",
};
// ── 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, "");
const allPatients = await db.patient.findMany({
select: { id: true, phone: true, userId: true, preferredLanguage: true },
});
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);
if (!aiSettings?.apiKey) {
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, aiSettings.apiKey);
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, aiSettings.apiKey);
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, aiSettings.apiKey, chatTemplates.generalFallback
);
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);
if (!aiSettings?.apiKey) {
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);
// Reset conversation if idle for more than 5 minutes
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() > CONVO_TIMEOUT_MS) {
clearPendingReschedule(patient.userId, patient.id);
await setStage(patient.userId, patient.id, "initial");
stage = "initial";
}
}
const officeContact = await storage.getOfficeContact(patient.userId);
const officeName = (officeContact as any)?.officeName?.trim() || "";
// ── Helper: send reply + set stage ─────────────────────────────────────
const reply = async (text: string, nextStage: ConversationStage) => {
await saveOutbound(patient.id, text);
await setStage(patient.userId, patient.id, nextStage);
res.set("Content-Type", "text/xml");
return res.send(twimlReply(text));
};
// ── Stage: reminder_initial → two messages: 1) AI intro, 2) intent response ──
if (stage === "reminder_initial") {
const rawGreeting = chatTemplates.reminderGreeting ||
`Hi! My name is Lisa, the dedicated AI assistant at {officeName}. I can confirm or reschedule your appointment and answer general questions 24/7.`;
const introText = applyOfficeName(rawGreeting, officeName);
// Use Google AI (LangGraph) to read the patient's reply and classify yes/no
const apptDatetime = await getAppointmentDatetime(patient.id);
const { reply: intentReply, intent } = await runReminderGraph(
Body, aiSettings.apiKey, language, apptDatetime,
chatTemplates.rescheduleGreeting, chatTemplates.generalFallback
);
if (intentReply) {
let nextStage: ConversationStage;
if (intent === "no") nextStage = "asked_reschedule_datetime";
else if (intent === "wants_appointment") nextStage = "asked_new_or_existing";
else nextStage = "done";
// Send message 1 (AI intro) via REST API — queued FIRST in Twilio so it arrives first
const twilioSettings = await storage.getTwilioSettings(patient.userId);
if (twilioSettings) {
const client = twilio(twilioSettings.accountSid, twilioSettings.authToken);
await client.messages.create({ body: introText, from: twilioSettings.phoneNumber, to: From });
await saveOutbound(patient.id, introText);
}
// If patient said "no" but already included a date (e.g. "no, 5/18"),
// skip "when to reschedule?" and go straight to date processing
if (intent === "no") {
const hasDateInMessage =
/\b\d{1,2}[\/\-]\d{1,2}\b/.test(Body) ||
/\b(monday|tuesday|wednesday|thursday|friday|saturday|sunday|tomorrow|next week)\b/i.test(Body);
if (hasDateInMessage) {
const { reply: rescheduleReply, nextStage: rescheduleNextStage } = await runRescheduleStep(
Body, "asked_reschedule_datetime", language, patient.id, aiSettings.apiKey, patient.userId
);
return reply(rescheduleReply, rescheduleNextStage);
}
}
// Send message 2 (yes/no response) via TwiML — queued SECOND
return reply(intentReply, nextStage);
}
// No clear intent detected — send only the intro and wait for next reply
return reply(introText, "greeted");
}
// ── Stage: greeted → classify yes/no for appointment reminder ────────
if (stage === "greeted") {
const apptDatetime = await getAppointmentDatetime(patient.id);
const { reply: aiReply, intent } = await runReminderGraph(
Body, aiSettings.apiKey, language, apptDatetime,
chatTemplates.rescheduleGreeting, chatTemplates.generalFallback
);
if (aiReply) {
let nextStage: ConversationStage;
if (intent === "no") nextStage = "asked_reschedule_datetime";
else if (intent === "wants_appointment") nextStage = "asked_new_or_existing";
else nextStage = "done";
// If patient said "no" but already included a date, skip straight to date processing
if (intent === "no") {
const hasDateInMessage =
/\b\d{1,2}[\/\-]\d{1,2}\b/.test(Body) ||
/\b(monday|tuesday|wednesday|thursday|friday|saturday|sunday|tomorrow|next week)\b/i.test(Body);
if (hasDateInMessage) {
const { reply: rescheduleReply, nextStage: rescheduleNextStage } = await runRescheduleStep(
Body, "asked_reschedule_datetime", language, patient.id, aiSettings.apiKey, patient.userId
);
return reply(rescheduleReply, rescheduleNextStage);
}
}
return reply(aiReply, nextStage);
}
}
// ── Rescheduling flow stages ───────────────────────────────────────────
const rescheduleStages: ConversationStage[] = [
"asked_reschedule_confirm", "asked_reschedule_preference",
"asked_reschedule_asap", "asked_reschedule_next_week",
"asked_reschedule_time", "asked_reschedule_datetime",
"asked_reschedule_time_for_date", "asked_reschedule_confirm_datetime",
];
if (rescheduleStages.includes(stage)) {
const { reply: aiReply, nextStage } = await runRescheduleStep(
Body, stage, language, patient.id, aiSettings.apiKey, patient.userId
);
return reply(aiReply, nextStage);
}
// ── Stage: awaiting MassHealth member ID + DOB ────────────────────────
if (stage === "awaiting_masshealth_info") {
const { memberId, dob } = await parseMassHealthInfo(Body, aiSettings.apiKey);
if (!memberId || !dob) {
// Couldn't parse — ask again with a clearer format hint
const retryMessages: Record<string, string> = {
English: "I couldn't read your Member ID and date of birth. Please reply in this format: Member ID: 12345678 DOB: 01/01/1990",
Spanish: "No pude leer su número de miembro y fecha de nacimiento. Por favor responda así: ID: 12345678 Fecha: 01/01/1990",
Portuguese: "Não consegui ler seu número de membro e data de nascimento. Por favor responda assim: ID: 12345678 Data: 01/01/1990",
Mandarin: "我无法读取您的会员ID和出生日期。请按以下格式回复ID: 12345678 生日: 01/01/1990",
Cantonese: "我無法讀取您的會員ID和出生日期。請按以下格式回覆ID: 12345678 生日: 01/01/1990",
Arabic: "لم أتمكن من قراءة رقم العضوية وتاريخ الميلاد. يرجى الرد بالصيغة التالية: ID: 12345678 DOB: 01/01/1990",
"Haitian Creole": "Mwen pa t ka li ID manm ou ak dat nesans. Tanpri reponn konsa: ID: 12345678 DOB: 01/01/1990",
};
const retryMsg = retryMessages[language] ?? retryMessages["English"]!;
return reply(retryMsg, "awaiting_masshealth_info");
}
// Immediately confirm to the patient and start the check in background
const checkingMessages: Record<string, string> = {
English: "Thank you! I'm checking your MassHealth eligibility now. I'll send you the result in a moment.",
Spanish: "¡Gracias! Estoy verificando su elegibilidad de MassHealth ahora. Le enviaré el resultado en un momento.",
Portuguese: "Obrigado! Estou verificando sua elegibilidade MassHealth agora. Enviarei o resultado em instantes.",
Mandarin: "谢谢我正在查询您的MassHealth资格。稍后我会发送结果给您。",
Cantonese: "多謝我正在查詢您的MassHealth資格。稍後我會發送結果給您。",
Arabic: "شكراً! أقوم بالتحقق من أهليتك في MassHealth الآن. سأرسل لك النتيجة قريباً.",
"Haitian Creole": "Mèsi! Mwen ap verifye kalifikasyon MassHealth ou kounye a. M ap voye rezilta a nan yon ti moman.",
};
const checkingMsg = checkingMessages[language] ?? checkingMessages["English"]!;
// Reply now — Selenium runs in the background
await saveOutbound(patient.id, checkingMsg);
await setStage(patient.userId, patient.id, "done");
res.set("Content-Type", "text/xml");
res.send(twimlReply(checkingMsg));
// Fire-and-forget: run check and send result SMS when complete
runMassHealthCheckAndNotify(patient, memberId, dob, aiSettings.apiKey).catch(() => {});
return;
}
// ── Stage: existing patient said YES to same insurance ───────────────
// Special case: if they have MassHealth on file, run Selenium check
// automatically (we already have their member ID + DOB in DB).
if (stage === "asked_existing_insurance") {
const saysYes = /yes|same|still have|haven't changed|no change|yep|yeah|sí|si|sim|好的|نعم|wi/i.test(Body);
if (saysYes) {
const patientRecord = await db.patient.findUnique({
where: { id: patient.id },
select: { insuranceProvider: true, insuranceId: true, dateOfBirth: true },
});
const isMassHealth = /masshealth|mass health|masscare|medicaid/i.test(
patientRecord?.insuranceProvider ?? ""
);
if (isMassHealth && patientRecord?.insuranceId) {
// Format DOB as MM/DD/YYYY for Selenium
let dobStr = "";
if (patientRecord.dateOfBirth) {
const d = new Date(patientRecord.dateOfBirth);
const mm = String(d.getUTCMonth() + 1).padStart(2, "0");
const dd = String(d.getUTCDate()).padStart(2, "0");
const yy = d.getUTCFullYear();
dobStr = `${mm}/${dd}/${yy}`;
}
const checkingMessages: Record<string, string> = {
English: "Please wait about 30-60 seconds! I'm double-checking your MassHealth coverage right now.",
Spanish: "¡Por favor espere unos 30-60 segundos! Estoy verificando su cobertura de MassHealth ahora mismo.",
Portuguese: "Por favor aguarde cerca de 30-60 segundos! Estou verificando sua cobertura MassHealth agora.",
Mandarin: "请等待约30-60秒我现在正在为您核查MassHealth保险。",
Cantonese: "請等待約30-60秒我現在正在為您核查MassHealth保險。",
Arabic: "يرجى الانتظار حوالي 30-60 ثانية! أقوم الآن بالتحقق من تغطيتك في MassHealth.",
"Haitian Creole": "Tanpri tann anviwon 30-60 segonn! Mwen ap verifye kouvèti MassHealth ou kounye a.",
};
const checkingMsg = checkingMessages[language] ?? checkingMessages["English"]!;
await saveOutbound(patient.id, checkingMsg);
await setStage(patient.userId, patient.id, "done");
res.set("Content-Type", "text/xml");
res.send(twimlReply(checkingMsg));
// Fire-and-forget Selenium check; existing patient gets simpler result
runMassHealthCheckAndNotify(
patient, patientRecord.insuranceId, dobStr, aiSettings.apiKey, true
).catch(() => {});
return;
}
}
// Not MassHealth or said NO — fall through to normal graph handling
}
// ── Stage: asked_new_or_reschedule ────────────────────────────────────
if (stage === "asked_new_or_reschedule") {
const isReschedule = /reschedule|rescheduler|change|modify|move|different|reprogramar|reagendar|cambiar|mudar|\b2\b/i.test(Body);
const isNew = /new.*appoint|make.*appoint|book.*appoint|new patient|first.*time|nueva cita|nova consulta|\b1\b/i.test(Body);
if (isReschedule) {
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è?`,
};
return reply(foundMsgs[language] ?? foundMsgs["English"]!, "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.",
};
return reply(notFoundMsgs[language] ?? notFoundMsgs["English"]!, "done");
}
}
// "new appointment" or ambiguous → ask new/existing patient
const newApptMsgs: Record<string, string> = {
English: "No problem! To get started, are you an existing patient or a new patient?",
Spanish: "¡Sin problema! Para comenzar, ¿es usted un paciente existente o un paciente nuevo?",
Portuguese: "Sem problema! Para começar, você é um paciente existente ou um novo paciente?",
Mandarin: "没问题!请问您是现有患者还是新患者?",
Cantonese: "沒問題!請問您是現有病人還是新病人?",
Arabic: "لا مشكلة! للبدء، هل أنت مريض حالي أم مريض جديد؟",
"Haitian Creole": "Pa gen pwoblèm! Pou kòmanse, èske ou se yon pasyan egzistan oswa yon nouvo pasyan?",
};
return reply(newApptMsgs[language] ?? newApptMsgs["English"]!, "asked_new_or_existing");
}
// ── Stage: asked_appointment_time → parse date, check office hours ───
if (stage === "asked_appointment_time") {
const parsedDate = await parseDateOnlyFromMessage(Body, aiSettings.apiKey);
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 reply(msgs[language] ?? msgs["English"]!, "asked_appointment_time");
}
const { date, dateLabel } = parsedDate;
const dayCheck = await isOfficeDayOpen(date, patient.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 reply(msgs[language] ?? msgs["English"]!, "asked_appointment_time");
}
// Day is open — save pending date and ask for preferred time
setPendingReschedule(patient.userId, patient.id, { newDate: date, dayLabel: dateLabel });
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 reply(askTimeMsgs[language] ?? askTimeMsgs["English"]!, "asked_new_appt_time_for_date");
}
// ── Stage: asked_new_appt_time_for_date → parse time, check hours ────
if (stage === "asked_new_appt_time_for_date") {
const pending = getPendingReschedule(patient.userId, patient.id);
if (!pending) {
return reply("I lost track of the date. What day would you prefer?", "asked_appointment_time");
}
const startTime = await parseTime(Body, aiSettings.apiKey);
if (!startTime) {
const msgs: Record<string, string> = {
English: `I didn't catch the time. What time do you prefer on ${pending.dayLabel}? For example: '10am' or '2pm'.`,
Spanish: `No entendí la hora. ¿Qué hora prefiere el ${pending.dayLabel}? Por ejemplo: '10am' o '2pm'.`,
Portuguese: `Não entendi o horário. Que hora você prefere em ${pending.dayLabel}? Por exemplo: '10h' ou '14h'.`,
Mandarin: `我没听清时间。您在 ${pending.dayLabel} 几点例如上午10点或下午2点。`,
Cantonese: `我沒聽清時間。您在 ${pending.dayLabel} 幾點例如上午10點或下午2點。`,
Arabic: `لم أفهم الوقت. ما الوقت الذي تفضله في ${pending.dayLabel}؟ مثلاً: 10 صباحاً أو 2 مساءً.`,
"Haitian Creole": `Mwen pa konprann lè a. Ki lè ou prefere nan ${pending.dayLabel}? Pa egzanp: 10am oswa 2pm.`,
};
return reply(msgs[language] ?? msgs["English"]!, "asked_new_appt_time_for_date");
}
const withinHours = await isWithinOfficeHours(pending.newDate, startTime, patient.userId);
if (!withinHours) {
const hoursDisplay = await getOfficeHoursDisplay(pending.newDate, patient.userId);
const timeLbl = timeLabel(startTime);
const msgs: Record<string, string> = {
English: hoursDisplay
? `Our office is not available at ${timeLbl} on ${pending.dayLabel}. Our hours are ${hoursDisplay}. What other time do you prefer?`
: `Our office is not available at ${timeLbl} on ${pending.dayLabel}. What other time do you prefer?`,
Spanish: hoursDisplay
? `Nuestra oficina no está disponible a las ${timeLbl} el ${pending.dayLabel}. Nuestro horario es ${hoursDisplay}. ¿Qué otro horario prefiere?`
: `Nuestra oficina no está disponible a las ${timeLbl} el ${pending.dayLabel}. ¿Qué otro horario prefiere?`,
Portuguese: hoursDisplay
? `Nosso consultório não está disponível às ${timeLbl} em ${pending.dayLabel}. Nosso horário é ${hoursDisplay}. Que outro horário você prefere?`
: `Nosso consultório não está disponível às ${timeLbl} em ${pending.dayLabel}. Que outro horário você prefere?`,
Mandarin: hoursDisplay
? `我们诊所在 ${pending.dayLabel} ${timeLbl} 不开放。工作时间是 ${hoursDisplay}。您希望改什么时间?`
: `我们诊所在 ${pending.dayLabel} ${timeLbl} 不开放。您希望改什么时间?`,
Cantonese: hoursDisplay
? `我們診所在 ${pending.dayLabel} ${timeLbl} 不開放。工作時間是 ${hoursDisplay}。您希望改什麼時間?`
: `我們診所在 ${pending.dayLabel} ${timeLbl} 不開放。您希望改什麼時間?`,
Arabic: hoursDisplay
? `مكتبنا غير متاح في ${timeLbl} يوم ${pending.dayLabel}. ساعات العمل: ${hoursDisplay}. ما وقت آخر تفضله؟`
: `مكتبنا غير متاح في ${timeLbl} يوم ${pending.dayLabel}. ما وقت آخر تفضله؟`,
"Haitian Creole": hoursDisplay
? `Biwo nou pa disponib a ${timeLbl} nan ${pending.dayLabel}. Orè nou se ${hoursDisplay}. Ki lòt lè ou prefere?`
: `Biwo nou pa disponib a ${timeLbl} nan ${pending.dayLabel}. Ki lòt lè ou prefere?`,
};
return reply(msgs[language] ?? msgs["English"]!, "asked_new_appt_time_for_date");
}
clearPendingReschedule(patient.userId, patient.id);
const apptLabel = `${pending.dayLabel} at ${timeLabel(startTime)}`;
// Calculate end time (60-minute default slot)
const [nh, nm] = startTime.split(":").map(Number);
const endTotal = nh! * 60 + nm! + 60;
const endTime = `${String(Math.floor(endTotal / 60)).padStart(2, "0")}:${String(endTotal % 60).padStart(2, "0")}`;
// Look up patient name + find first staff for this office
const [patientRecord, firstStaff] = await Promise.all([
db.patient.findUnique({
where: { id: patient.id },
select: { firstName: true, lastName: true },
}),
db.staff.findFirst({
where: { userId: patient.userId },
orderBy: { id: "asc" },
}),
]);
// Create the appointment if a staff member exists
if (firstStaff) {
try {
await storage.createAppointment({
patientId: patient.id,
userId: patient.userId,
staffId: firstStaff.id,
title: `AI Scheduled - ${(patientRecord?.firstName ?? "") + " " + (patientRecord?.lastName ?? "")}`.trim(),
date: pending.newDate,
startTime,
endTime,
type: "checkup",
status: "scheduled",
movedByAi: true,
} as any);
} catch { /* silent — message still sent, staff can create manually */ }
}
const confirmMsgs: Record<string, string> = {
English: `Thank you! Your preferred appointment at ${apptLabel} was scheduled. Our receptionist will confirm it with you shortly.`,
Spanish: `¡Gracias! Su cita preferida el ${apptLabel} fue programada. Nuestra recepcionista lo confirmará con usted en breve.`,
Portuguese: `Obrigado! Sua consulta preferida em ${apptLabel} foi agendada. Nossa recepcionista confirmará com você em breve.`,
Mandarin: `谢谢!您在 ${apptLabel} 的预约已安排。我们的前台将很快与您确认。`,
Cantonese: `多謝!您在 ${apptLabel} 的預約已安排。我們的接待員將很快與您確認。`,
Arabic: `شكراً! تم جدولة موعدك في ${apptLabel}. ستتواصل معك موظفة الاستقبال قريباً لتأكيده.`,
"Haitian Creole": `Mèsi! Randevou ou a nan ${apptLabel} pwograme. Resepsyonis nou an pral konfime li ak ou byento.`,
};
return reply(confirmMsgs[language] ?? confirmMsgs["English"]!, "done");
}
// ── Stage: new_patient_greeted + multi-step new patient stages ────────
const newPatientStages: 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 (newPatientStages.includes(stage)) {
const { reply: aiReply, nextStage } = await runNewPatientStep(
Body, stage, language, aiSettings.apiKey, chatTemplates.generalFallback
);
return reply(aiReply, nextStage);
}
// ── Stage: done → closing thank-you reply ────────────────────────────
// When the patient sends a thank-you / acknowledgement after the conversation
// is complete, reply warmly with their upcoming appointment time.
if (stage === "done") {
const isThanks = /\b(thank|thanks|thank you|ty|ok|okay|great|perfect|sounds good|got it|understood|alright|appreciate|wonderful|excellent|awesome|cool|nice|good)\b/i.test(Body);
if (isThanks) {
const apptDatetime = await getAppointmentDatetime(patient.id);
const CLOSING: Record<string, string> = {
English: apptDatetime
? `Thank you for choosing our office! We look forward to seeing you on ${apptDatetime}.`
: `Thank you for choosing our office! We look forward to seeing you soon.`,
Spanish: apptDatetime
? `¡Gracias por elegirnos! Le esperamos el ${apptDatetime}.`
: `¡Gracias por elegirnos! Le esperamos pronto.`,
Portuguese: apptDatetime
? `Obrigado por nos escolher! Aguardamos sua visita em ${apptDatetime}.`
: `Obrigado por nos escolher! Aguardamos sua visita em breve.`,
Mandarin: apptDatetime
? `感谢您选择我们!期待在 ${apptDatetime} 见到您。`
: `感谢您选择我们!期待很快见到您。`,
Cantonese: apptDatetime
? `感謝您選擇我們!期待在 ${apptDatetime} 見到您。`
: `感謝您選擇我們!期待很快見到您。`,
Arabic: apptDatetime
? `شكراً لاختيارك عيادتنا! نتطلع إلى رؤيتك في ${apptDatetime}.`
: `شكراً لاختيارك عيادتنا! نتطلع إلى رؤيتك قريباً.`,
"Haitian Creole": apptDatetime
? `Mèsi dèske ou chwazi nou! N'ap tann ou ${apptDatetime}.`
: `Mèsi dèske ou chwazi nou! N'ap tann ou byento.`,
};
const fallback = CLOSING[language] ?? CLOSING["English"]!;
if (aiSettings?.apiKey && apptDatetime) {
try {
const { ChatGoogleGenerativeAI } = await import("@langchain/google-genai");
const llm = new ChatGoogleGenerativeAI({ model: "gemini-1.5-flash", apiKey: aiSettings.apiKey });
const res = await llm.invoke([
{
role: "system",
content: `You are a friendly dental office AI assistant. The patient just said "${Body}" after completing a conversation. Reply warmly in ${language}, thanking them for choosing the office and reminding them of their upcoming appointment on ${apptDatetime}. 1-2 sentences, no formatting.`,
},
{ role: "user", content: Body },
]);
const aiMsg = String(res.content).trim();
if (aiMsg) return reply(aiMsg, "done");
} catch { /* fall through to fallback */ }
}
return reply(fallback, "done");
}
}
// ── 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, "");
const allPatients = await db.patient.findMany({ select: { id: true, phone: true, userId: true } });
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 = `${process.env.BASE_URL || "https://communitydentistsoflowell.mydentalofficemanagement.com"}/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());
}
});
export default router;