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>
This commit is contained in:
Gitead
2026-05-15 00:00:56 -04:00
parent c1f55778ca
commit c71624f7e7
53 changed files with 1078 additions and 124 deletions

View File

@@ -12,7 +12,11 @@ router.get("/settings", async (req: Request, res: Response): Promise<any> => {
const settings = await storage.getAiSettings(userId);
if (!settings) return res.status(200).json(null);
return res.status(200).json({ id: settings.id, apiKey: settings.apiKey });
return res.status(200).json({
id: settings.id,
apiKey: settings.apiKey,
openPhoneReply: settings.openPhoneReply ?? false,
});
} catch (err) {
return res.status(500).json({ error: "Failed to fetch AI settings", details: String(err) });
}
@@ -36,6 +40,37 @@ router.put("/settings", async (req: Request, res: Response): Promise<any> => {
}
});
// GET /api/ai/advanced-settings
router.get("/advanced-settings", async (req: Request, res: Response): Promise<any> => {
try {
const userId = req.user?.id;
if (!userId) return res.status(401).json({ message: "Unauthorized" });
const openPhoneReply = await storage.getOpenPhoneReply(userId);
return res.status(200).json({ openPhoneReply });
} catch (err) {
return res.status(500).json({ error: "Failed to fetch advanced settings", details: String(err) });
}
});
// PUT /api/ai/advanced-settings
router.put("/advanced-settings", async (req: Request, res: Response): Promise<any> => {
try {
const userId = req.user?.id;
if (!userId) return res.status(401).json({ message: "Unauthorized" });
const { openPhoneReply } = req.body;
if (typeof openPhoneReply !== "boolean") {
return res.status(400).json({ message: "openPhoneReply must be a boolean" });
}
await storage.setOpenPhoneReply(userId, openPhoneReply);
return res.status(200).json({ openPhoneReply });
} catch (err) {
return res.status(500).json({ error: "Failed to save advanced settings", details: String(err) });
}
});
// GET /api/ai/chat-templates
router.get("/chat-templates", async (req: Request, res: Response): Promise<any> => {
try {

View File

@@ -4,12 +4,21 @@ 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 } from "../ai/reschedule-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";
@@ -23,6 +32,12 @@ function escapeXml(text: string): string {
.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>`;
}
@@ -81,6 +96,17 @@ async function saveOutbound(patientId: number, body: string): Promise<void> {
* 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
@@ -92,7 +118,7 @@ async function parseMassHealthInfo(
if (idMatch && dobMatch) {
const [, m, d, y] = dobMatch;
const year = y!.length === 2 ? `20${y}` : y;
return { memberId: idMatch[1]!, dob: `${m}/${d}/${year}` };
return { memberId: idMatch[1]!, dob: normalizeDob(`${m}/${d}/${year}`) };
}
// Fall back to LLM structured extraction
@@ -109,7 +135,8 @@ async function parseMassHealthInfo(
]);
const raw = String(res.content).replace(/```json|```/g, "").trim();
const json = JSON.parse(raw);
return { memberId: json.memberId ?? null, dob: json.dob ?? null };
const dob = json.dob ? normalizeDob(String(json.dob)) : null;
return { memberId: json.memberId ?? null, dob };
} catch {
return { memberId: null, dob: null };
}
@@ -156,31 +183,29 @@ async function runMassHealthCheckAndNotify(
const lang = patient.preferredLanguage || "English";
const active = updated?.status === "ACTIVE";
// ── ACTIVE: existing patient → simple scheduling; new patient → preference
const activeMessagesExisting: Record<string, string> = {
English: "Great news! Your MassHealth coverage is active. When would you like to come in for your appointment?",
Spanish: "¡Buenas noticias! Su cobertura de MassHealth está activa. ¿Cuándo le gustaría venir para su cita?",
Portuguese: "Ótimas notícias! Sua cobertura MassHealth está ativa. Quando gostaria de vir para sua consulta?",
Mandarin: "好消息您的MassHealth保险有效。您想什么时候来预约",
Cantonese: "好消息您的MassHealth保險有效。您想幾時來預約",
Arabic: "أخبار رائعة! تغطيتك من MassHealth نشطة. متى تودّ الحضور لموعدك؟",
"Haitian Creole": "Bon nouvèl! Asirans MassHealth ou aktif. Ki lè ou ta renmen vini pou randevou ou?",
// ── 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?",
};
const activeMessagesNew: Record<string, string> = {
English: "Great news! Your MassHealth coverage is active. When would you like to come in? Are you looking for a routine check-up and teeth cleaning, or do you have a tooth problem or pain?",
Spanish: "¡Buenas noticias! Su cobertura de MassHealth está activa. ¿Cuándo le gustaría venir? ¿Busca una revisión rutinaria y limpieza dental, o tiene algún problema dental o dolor?",
Portuguese: "Ótimas notícias! Sua cobertura MassHealth está ativa. Quando gostaria de vir? Você busca uma consulta de rotina e limpeza, ou tem algum problema dentário ou dor?",
Mandarin: "好消息您的MassHealth保险有效。您想什么时候来您是想做常规检查和洗牙还是您有牙齿问题或疼痛",
Cantonese: "好消息!您的MassHealth保險有效。您想幾時來?您是想做例行檢查和洗牙,還是您有牙齒問題或疼痛",
Arabic: "أخبار رائعة! تغطيتك من MassHealth نشطة. متى تودّ الحضور؟ هل تبحث عن فحص روتيني وتنظيف أسنان، أم أن لديك مشكلة في الأسنان أو ألماً؟",
"Haitian Creole": "Bon nouvèl! Asirans MassHealth ou aktif. Ki lè ou ta renmen vini? Èske ou ap chèche yon egzamen woutin ak netwayaj dan, oswa ou gen pwoblèm dan oswa doulè?",
// ── 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 activeMessages = isExistingPatient ? activeMessagesExisting : activeMessagesNew;
// ── INACTIVE: offer self-pay examination ────────────────────────────
const inactiveMessages: Record<string, string> = {
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?",
@@ -191,12 +216,16 @@ async function runMassHealthCheckAndNotify(
};
const resultText = active
? (activeMessages[lang] ?? activeMessages["English"]!)
: (inactiveMessages[lang] ?? inactiveMessages["English"]!);
? (activeMessages[lang] ?? activeMessages["English"]!)
: isExistingPatient
? (inactiveMessagesExisting[lang] ?? inactiveMessagesExisting["English"]!)
: (inactiveMessagesNew[lang] ?? inactiveMessagesNew["English"]!);
const nextStage: ConversationStage = active
? (isExistingPatient ? "asked_appointment_time" : "asked_appointment_preference")
: "asked_self_pay";
? "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);
@@ -215,11 +244,64 @@ async function runMassHealthCheckAndNotify(
}
}
// ── 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, Body, MessageSid } = req.body;
const { From, To, Body, MessageSid } = req.body;
const normalizedFrom = (From || "").replace(/\D/g, "");
const allPatients = await db.patient.findMany({
@@ -230,6 +312,262 @@ router.post("/webhook/sms", async (req: Request, res: Response): Promise<any> =>
);
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());
}
@@ -252,9 +590,22 @@ router.post("/webhook/sms", async (req: Request, res: Response): Promise<any> =>
return res.send(empty());
}
const language = patient.preferredLanguage || "English";
const stage = await getStage(patient.userId, patient.id);
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() || "";
@@ -454,12 +805,200 @@ router.post("/webhook/sms", async (req: Request, res: Response): Promise<any> =>
// 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_existing_insurance",
"asked_appointment_time",
"asked_appointment_preference", "asked_self_pay",
"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(
@@ -518,17 +1057,78 @@ router.post("/webhook/sms", async (req: Request, res: Response): Promise<any> =>
}
}
// ── Stage: initial (no active conversation) ───────────────────────────
// Check after-hours: if enabled and currently outside office hours → start new-patient flow
// ── 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 (afterHoursEnabled && outsideHours) {
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. How can I help you today?`;
`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);
return reply(applyOfficeName(rawGreeting, officeName), "new_patient_greeted");
// 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));
}
}