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, "&").replace(//g, ">") .replace(/"/g, """).replace(/'/g, "'"); } /** Send multiple SMS messages in guaranteed order via a single TwiML response. */ function twimlMessages(...texts: string[]): string { const body = texts.map(t => `${escapeXml(t)}`).join(""); return `${body}`; } function twimlReply(text: string): string { return `${escapeXml(text)}`; } function empty(): string { return ""; } /** Get the patient's next scheduled appointment as a human-readable string. */ async function getAppointmentDatetime(patientId: number): Promise { 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 { 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 { 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 { 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 = { 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 = { 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 = { 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 = { 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 = { 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 = { 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(); router.post("/webhook/sms", async (req: Request, res: Response): Promise => { 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 = { 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 = { 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 = { 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 = { 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 = { 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 = { 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 = { 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 = { 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 = { 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 = { 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 = { 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 = { 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 = { 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 = { 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 = { 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 = { 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 = { 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 = { 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 = { 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 = { 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 = { 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 = { 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 = { 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 = { 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 = { 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 = { 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 => { 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(` ${greeting} We did not receive a recording. Goodbye. `); } catch (err) { res.set("Content-Type", "text/xml"); return res.send(`Thank you for calling. Please try again later.`); } }); // ── POST /api/twilio/webhook/voice-recording ────────────────────────────────── router.post("/webhook/voice-recording", async (req: Request, res: Response): Promise => { 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;