feat: AI chat system with LangGraph, multi-step patient flows, and appointment rescheduling
- Add floating chat window Hand-off to AI toggle (per-patient) and after-hours AI toggle (global) - Add LangGraph-powered appointment reminder flow: AI introduces itself, classifies YES/NO, handles confirmation with appointment date/time - Add multi-step rescheduling flow: ASAP vs next week, tomorrow offer, Mon/Tue/Wed picker, morning/afternoon time slot — automatically updates appointment in DB - Add new patient / after-hours flow: new vs existing patient, dental insurance check, MassHealth Selenium eligibility check (auto-uses saved member ID + DOB for existing patients), self-pay fallback - Add AI Chat Settings page (Settings → Advanced) with editable greeting templates and LangGraph flow diagrams for both reminder and new-patient flows - Add Schedule a New Patient template option in chat window, starts new-patient conversation flow - Add GET/PUT endpoints for AI handoff, after-hours handoff, and AI chat templates - Add multilingual support (7 languages) across all AI reply nodes with LLM generation and hardcoded fallbacks - Add pending reschedule in-memory store and conversation stage tracking across all flows Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -36,4 +36,30 @@ router.put("/settings", async (req: Request, res: Response): Promise<any> => {
|
||||
}
|
||||
});
|
||||
|
||||
// GET /api/ai/chat-templates
|
||||
router.get("/chat-templates", async (req: Request, res: Response): Promise<any> => {
|
||||
try {
|
||||
const userId = req.user?.id;
|
||||
if (!userId) return res.status(401).json({ message: "Unauthorized" });
|
||||
const templates = await storage.getAiChatTemplates(userId);
|
||||
return res.status(200).json(templates);
|
||||
} catch (err) {
|
||||
return res.status(500).json({ error: "Failed to fetch AI chat templates", details: String(err) });
|
||||
}
|
||||
});
|
||||
|
||||
// PUT /api/ai/chat-templates
|
||||
router.put("/chat-templates", async (req: Request, res: Response): Promise<any> => {
|
||||
try {
|
||||
const userId = req.user?.id;
|
||||
if (!userId) return res.status(401).json({ message: "Unauthorized" });
|
||||
const { reminderGreeting, newPatientGreeting, generalFallback } = req.body;
|
||||
await storage.saveAiChatTemplates(userId, { reminderGreeting, newPatientGreeting, generalFallback });
|
||||
const updated = await storage.getAiChatTemplates(userId);
|
||||
return res.status(200).json(updated);
|
||||
} catch (err) {
|
||||
return res.status(500).json({ error: "Failed to save AI chat templates", details: String(err) });
|
||||
}
|
||||
});
|
||||
|
||||
export default router;
|
||||
|
||||
@@ -1,64 +1,442 @@
|
||||
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 } from "../ai/reschedule-graph";
|
||||
import { ChatGoogleGenerativeAI } from "@langchain/google-genai";
|
||||
import { runEligibilityProcessor } from "../queue/processors/eligibilityProcessor";
|
||||
import {
|
||||
getHandoff, getAfterHoursHandoff,
|
||||
getStage, setStage,
|
||||
type ConversationStage,
|
||||
} from "../ai/aiHandoffStore";
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
// POST /api/twilio/webhook/sms (Twilio posts inbound SMS here — no auth)
|
||||
// ── Helpers ───────────────────────────────────────────────────────────────────
|
||||
|
||||
function escapeXml(text: string): string {
|
||||
return text
|
||||
.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">")
|
||||
.replace(/"/g, """).replace(/'/g, "'");
|
||||
}
|
||||
|
||||
function twimlReply(text: string): string {
|
||||
return `<?xml version="1.0" encoding="UTF-8"?><Response><Message>${escapeXml(text)}</Message></Response>`;
|
||||
}
|
||||
|
||||
function empty(): string {
|
||||
return "<Response></Response>";
|
||||
}
|
||||
|
||||
/** Get the patient's next scheduled appointment as a human-readable string. */
|
||||
async function getAppointmentDatetime(patientId: number): Promise<string> {
|
||||
const today = new Date();
|
||||
today.setHours(0, 0, 0, 0);
|
||||
const appt = await db.appointment.findFirst({
|
||||
where: { patientId, status: "scheduled", date: { gte: today } },
|
||||
orderBy: { date: "asc" },
|
||||
});
|
||||
if (!appt) return "";
|
||||
const months = ["January","February","March","April","May","June",
|
||||
"July","August","September","October","November","December"];
|
||||
const d = new Date(appt.date);
|
||||
return `${months[d.getUTCMonth()]} ${d.getUTCDate()}, ${d.getUTCFullYear()} at ${appt.startTime}`;
|
||||
}
|
||||
|
||||
/** Check if right now is outside office hours for the given user. */
|
||||
async function isAfterHours(userId: number): Promise<boolean> {
|
||||
const record = await storage.getOfficeHours(userId);
|
||||
if (!record?.data) return false; // no hours configured → treat as in-hours
|
||||
|
||||
const data = record.data as any;
|
||||
const now = new Date();
|
||||
const days = ["sunday","monday","tuesday","wednesday","thursday","friday","saturday"];
|
||||
const day = days[now.getDay()];
|
||||
|
||||
const slot = data.doctors?.[day];
|
||||
if (!slot?.enabled) return true; // office closed today
|
||||
|
||||
const hhmm = `${String(now.getHours()).padStart(2,"0")}:${String(now.getMinutes()).padStart(2,"0")}`;
|
||||
if (hhmm >= slot.amStart && hhmm <= slot.amEnd) return false;
|
||||
if (hhmm >= slot.pmStart && hhmm <= slot.pmEnd) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
/** Substitute {officeName} in a template string. */
|
||||
function applyOfficeName(template: string, name: string): string {
|
||||
return template.replace(/\{officeName\}/g, name || "our dental office");
|
||||
}
|
||||
|
||||
/** Save an outbound message and return the text. */
|
||||
async function saveOutbound(patientId: number, body: string): Promise<void> {
|
||||
await storage.createCommunication({
|
||||
patientId, channel: "sms", direction: "outbound", status: "sent", body,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract MassHealth Member ID and date of birth from a free-text SMS.
|
||||
* Tries regex first, falls back to LLM extraction.
|
||||
*/
|
||||
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: `${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);
|
||||
return { memberId: json.memberId ?? null, dob: json.dob ?? null };
|
||||
} catch {
|
||||
return { memberId: null, dob: null };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Run MassHealth eligibility check in the background (after replying to patient)
|
||||
* and send the result as a follow-up SMS.
|
||||
*/
|
||||
async function runMassHealthCheckAndNotify(
|
||||
patient: { id: number; userId: number; phone: string | null; preferredLanguage: string | null },
|
||||
memberId: string,
|
||||
dob: string,
|
||||
apiKey: string,
|
||||
isExistingPatient = false
|
||||
): Promise<void> {
|
||||
try {
|
||||
const credentials = await storage.getInsuranceCredentialByUserAndSiteKey(patient.userId, "MH");
|
||||
if (!credentials) return;
|
||||
|
||||
const twilioSettings = await storage.getTwilioSettings(patient.userId);
|
||||
if (!twilioSettings || !patient.phone) return;
|
||||
|
||||
// Run Selenium eligibility check directly via the processor
|
||||
await runEligibilityProcessor({
|
||||
userId: patient.userId,
|
||||
insuranceId: memberId,
|
||||
formDob: dob,
|
||||
enrichedPayload: {
|
||||
memberId,
|
||||
dateOfBirth: dob,
|
||||
insuranceSiteKey: "MH",
|
||||
massdhpUsername: credentials.username,
|
||||
massdhpPassword: credentials.password,
|
||||
},
|
||||
});
|
||||
|
||||
// Re-fetch updated patient status
|
||||
const updated = await db.patient.findUnique({
|
||||
where: { id: patient.id },
|
||||
select: { status: true, firstName: true },
|
||||
});
|
||||
|
||||
const lang = patient.preferredLanguage || "English";
|
||||
const active = updated?.status === "ACTIVE";
|
||||
|
||||
// ── ACTIVE: 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?",
|
||||
};
|
||||
|
||||
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è?",
|
||||
};
|
||||
|
||||
const activeMessages = isExistingPatient ? activeMessagesExisting : activeMessagesNew;
|
||||
|
||||
// ── INACTIVE: offer self-pay examination ────────────────────────────
|
||||
const inactiveMessages: Record<string, string> = {
|
||||
English: "We checked your MassHealth coverage. Unfortunately the plan appears inactive or could not be verified. Would you still like to schedule an examination appointment as a self-pay patient?",
|
||||
Spanish: "Verificamos su cobertura de MassHealth. Lamentablemente el plan aparece inactivo o no pudo ser verificado. ¿Le gustaría programar una cita de examen como paciente de pago particular?",
|
||||
Portuguese: "Verificamos sua cobertura MassHealth. Infelizmente o plano parece inativo ou não pôde ser verificado. Gostaria de agendar uma consulta de exame como paciente particular?",
|
||||
Mandarin: "我们查看了您的MassHealth保险。遗憾的是,保险似乎无效或无法验证。您仍然希望以自费方式预约检查吗?",
|
||||
Cantonese: "我們查看了您的MassHealth保險。遺憾地,保險似乎無效或無法核實。您仍然希望以自費方式預約檢查嗎?",
|
||||
Arabic: "تحققنا من تغطيتك من MassHealth. للأسف يبدو أن الخطة غير نشطة أو لا يمكن التحقق منها. هل تودّ تحديد موعد فحص كمريض يدفع من حسابه الخاص؟",
|
||||
"Haitian Creole": "Nou te verifye kouvèti MassHealth ou. Malerezman plan an sanble inaktif oswa pa ka verifye. Èske ou ta renmen pran yon randevou egzamen kòm pasyan ki peye poukont li?",
|
||||
};
|
||||
|
||||
const resultText = active
|
||||
? (activeMessages[lang] ?? activeMessages["English"]!)
|
||||
: (inactiveMessages[lang] ?? inactiveMessages["English"]!);
|
||||
|
||||
const nextStage: ConversationStage = active
|
||||
? (isExistingPatient ? "asked_appointment_time" : "asked_appointment_preference")
|
||||
: "asked_self_pay";
|
||||
|
||||
// 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);
|
||||
setStage(patient.userId, patient.id, nextStage);
|
||||
|
||||
} catch {
|
||||
// Silent — don't crash the main request
|
||||
}
|
||||
}
|
||||
|
||||
// ── POST /api/twilio/webhook/sms ──────────────────────────────────────────────
|
||||
|
||||
router.post("/webhook/sms", async (req: Request, res: Response): Promise<any> => {
|
||||
try {
|
||||
const { From, Body, MessageSid } = req.body;
|
||||
|
||||
const normalizedFrom = (From || "").replace(/\D/g, "");
|
||||
const allPatients = await db.patient.findMany({ select: { id: true, phone: true, userId: true } });
|
||||
const allPatients = await db.patient.findMany({
|
||||
select: { id: true, phone: true, userId: true, preferredLanguage: true },
|
||||
});
|
||||
const patient = allPatients.find(
|
||||
(p: { id: number; phone: string | null; userId: number }) => p.phone && p.phone.replace(/\D/g, "") === normalizedFrom
|
||||
(p) => p.phone && p.phone.replace(/\D/g, "") === normalizedFrom
|
||||
);
|
||||
|
||||
if (patient) {
|
||||
// Save the inbound message
|
||||
await storage.createCommunication({
|
||||
patientId: patient.id,
|
||||
channel: "sms",
|
||||
direction: "inbound",
|
||||
status: "delivered",
|
||||
body: Body,
|
||||
twilioSid: MessageSid,
|
||||
});
|
||||
if (!patient) {
|
||||
res.set("Content-Type", "text/xml");
|
||||
return res.send(empty());
|
||||
}
|
||||
|
||||
// Run AI graph if API key is configured
|
||||
const aiSettings = await storage.getAiSettings(patient.userId);
|
||||
if (aiSettings?.apiKey) {
|
||||
const { reply, intent } = await runReminderGraph(Body, aiSettings.apiKey);
|
||||
// Save inbound message
|
||||
await storage.createCommunication({
|
||||
patientId: patient.id, channel: "sms", direction: "inbound",
|
||||
status: "delivered", body: Body, twilioSid: MessageSid,
|
||||
});
|
||||
|
||||
if (reply) {
|
||||
// Save the AI outbound reply
|
||||
await storage.createCommunication({
|
||||
patientId: patient.id,
|
||||
channel: "sms",
|
||||
direction: "outbound",
|
||||
status: "sent",
|
||||
body: reply,
|
||||
});
|
||||
// Per-patient handoff toggle must be ON
|
||||
if (!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";
|
||||
const stage = getStage(patient.userId, patient.id);
|
||||
|
||||
// ── Helper: send reply + set stage ─────────────────────────────────────
|
||||
const reply = async (text: string, nextStage: ConversationStage) => {
|
||||
await saveOutbound(patient.id, text);
|
||||
setStage(patient.userId, patient.id, nextStage);
|
||||
res.set("Content-Type", "text/xml");
|
||||
return res.send(twimlReply(text));
|
||||
};
|
||||
|
||||
// ── Stage: reminder_initial → send reminder greeting ─────────────────
|
||||
if (stage === "reminder_initial") {
|
||||
const chatTemplates = await storage.getAiChatTemplates(patient.userId);
|
||||
const officeContact = await storage.getOfficeContact(patient.userId);
|
||||
const officeName = (officeContact as any)?.officeName?.trim() || "";
|
||||
|
||||
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. I will reply your message at any time you need.`;
|
||||
|
||||
return reply(applyOfficeName(rawGreeting, officeName), "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);
|
||||
if (aiReply) {
|
||||
// YES → done; NO → start rescheduling flow
|
||||
const nextStage: ConversationStage = intent === "no" ? "asked_reschedule_confirm" : "done";
|
||||
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",
|
||||
];
|
||||
if (rescheduleStages.includes(stage)) {
|
||||
const { reply: aiReply, nextStage } = await runRescheduleStep(
|
||||
Body, stage, language, patient.id, aiSettings.apiKey, patient.userId
|
||||
);
|
||||
return reply(aiReply, nextStage);
|
||||
}
|
||||
|
||||
// ── Stage: awaiting MassHealth member ID + DOB ────────────────────────
|
||||
if (stage === "awaiting_masshealth_info") {
|
||||
const { memberId, dob } = await parseMassHealthInfo(Body, aiSettings.apiKey);
|
||||
|
||||
if (!memberId || !dob) {
|
||||
// Couldn't parse — ask again with a clearer format hint
|
||||
const retryMessages: Record<string, string> = {
|
||||
English: "I couldn't read your Member ID and date of birth. Please reply in this format: Member ID: 12345678 DOB: 01/01/1990",
|
||||
Spanish: "No pude leer su número de miembro y fecha de nacimiento. Por favor responda así: ID: 12345678 Fecha: 01/01/1990",
|
||||
Portuguese: "Não consegui ler seu número de membro e data de nascimento. Por favor responda assim: ID: 12345678 Data: 01/01/1990",
|
||||
Mandarin: "我无法读取您的会员ID和出生日期。请按以下格式回复:ID: 12345678 生日: 01/01/1990",
|
||||
Cantonese: "我無法讀取您的會員ID和出生日期。請按以下格式回覆:ID: 12345678 生日: 01/01/1990",
|
||||
Arabic: "لم أتمكن من قراءة رقم العضوية وتاريخ الميلاد. يرجى الرد بالصيغة التالية: ID: 12345678 DOB: 01/01/1990",
|
||||
"Haitian Creole": "Mwen pa t ka li ID manm ou ak dat nesans. Tanpri reponn konsa: ID: 12345678 DOB: 01/01/1990",
|
||||
};
|
||||
const retryMsg = retryMessages[language] ?? retryMessages["English"]!;
|
||||
return reply(retryMsg, "awaiting_masshealth_info");
|
||||
}
|
||||
|
||||
// Immediately confirm to the patient and start the check in background
|
||||
const checkingMessages: Record<string, string> = {
|
||||
English: "Thank you! I'm checking your MassHealth eligibility now. I'll send you the result in a moment.",
|
||||
Spanish: "¡Gracias! Estoy verificando su elegibilidad de MassHealth ahora. Le enviaré el resultado en un momento.",
|
||||
Portuguese: "Obrigado! Estou verificando sua elegibilidade MassHealth agora. Enviarei o resultado em instantes.",
|
||||
Mandarin: "谢谢!我正在查询您的MassHealth资格。稍后我会发送结果给您。",
|
||||
Cantonese: "多謝!我正在查詢您的MassHealth資格。稍後我會發送結果給您。",
|
||||
Arabic: "شكراً! أقوم بالتحقق من أهليتك في MassHealth الآن. سأرسل لك النتيجة قريباً.",
|
||||
"Haitian Creole": "Mèsi! Mwen ap verifye kalifikasyon MassHealth ou kounye a. M ap voye rezilta a nan yon ti moman.",
|
||||
};
|
||||
const checkingMsg = checkingMessages[language] ?? checkingMessages["English"]!;
|
||||
|
||||
// Reply now — Selenium runs in the background
|
||||
await saveOutbound(patient.id, checkingMsg);
|
||||
setStage(patient.userId, patient.id, "done");
|
||||
res.set("Content-Type", "text/xml");
|
||||
res.send(twimlReply(checkingMsg));
|
||||
|
||||
// Fire-and-forget: run check and send result SMS when complete
|
||||
runMassHealthCheckAndNotify(patient, memberId, dob, aiSettings.apiKey).catch(() => {});
|
||||
return;
|
||||
}
|
||||
|
||||
// ── Stage: existing patient said YES to same insurance ───────────────
|
||||
// Special case: if they have MassHealth on file, run Selenium check
|
||||
// automatically (we already have their member ID + DOB in DB).
|
||||
if (stage === "asked_existing_insurance") {
|
||||
const saysYes = /yes|same|still have|haven't changed|no change|yep|yeah|sí|si|sim|好的|نعم|wi/i.test(Body);
|
||||
|
||||
if (saysYes) {
|
||||
const patientRecord = await db.patient.findUnique({
|
||||
where: { id: patient.id },
|
||||
select: { insuranceProvider: true, insuranceId: true, dateOfBirth: true },
|
||||
});
|
||||
|
||||
const isMassHealth = /masshealth|mass health|masscare|medicaid/i.test(
|
||||
patientRecord?.insuranceProvider ?? ""
|
||||
);
|
||||
|
||||
if (isMassHealth && patientRecord?.insuranceId) {
|
||||
// Format DOB as MM/DD/YYYY for Selenium
|
||||
let dobStr = "";
|
||||
if (patientRecord.dateOfBirth) {
|
||||
const d = new Date(patientRecord.dateOfBirth);
|
||||
const mm = String(d.getUTCMonth() + 1).padStart(2, "0");
|
||||
const dd = String(d.getUTCDate()).padStart(2, "0");
|
||||
const yy = d.getUTCFullYear();
|
||||
dobStr = `${mm}/${dd}/${yy}`;
|
||||
}
|
||||
|
||||
const checkingMessages: Record<string, string> = {
|
||||
English: "Please wait about 30-60 seconds! I'm double-checking your MassHealth coverage right now.",
|
||||
Spanish: "¡Por favor espere unos 30-60 segundos! Estoy verificando su cobertura de MassHealth ahora mismo.",
|
||||
Portuguese: "Por favor aguarde cerca de 30-60 segundos! Estou verificando sua cobertura MassHealth agora.",
|
||||
Mandarin: "请等待约30-60秒!我现在正在为您核查MassHealth保险。",
|
||||
Cantonese: "請等待約30-60秒!我現在正在為您核查MassHealth保險。",
|
||||
Arabic: "يرجى الانتظار حوالي 30-60 ثانية! أقوم الآن بالتحقق من تغطيتك في MassHealth.",
|
||||
"Haitian Creole": "Tanpri tann anviwon 30-60 segonn! Mwen ap verifye kouvèti MassHealth ou kounye a.",
|
||||
};
|
||||
const checkingMsg = checkingMessages[language] ?? checkingMessages["English"]!;
|
||||
|
||||
await saveOutbound(patient.id, checkingMsg);
|
||||
setStage(patient.userId, patient.id, "done");
|
||||
res.set("Content-Type", "text/xml");
|
||||
return res.send(
|
||||
`<?xml version="1.0" encoding="UTF-8"?><Response><Message>${escapeXml(reply)}</Message></Response>`
|
||||
);
|
||||
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: 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",
|
||||
];
|
||||
if (newPatientStages.includes(stage)) {
|
||||
const { reply: aiReply, nextStage } = await runNewPatientStep(
|
||||
Body, stage, language, aiSettings.apiKey
|
||||
);
|
||||
return reply(aiReply, nextStage);
|
||||
}
|
||||
|
||||
// ── Stage: initial (no active conversation) ───────────────────────────
|
||||
// Check after-hours: if enabled and currently outside office hours → start new-patient flow
|
||||
if (stage === "initial" || stage === "done") {
|
||||
const afterHoursEnabled = getAfterHoursHandoff(patient.userId);
|
||||
const outsideHours = await isAfterHours(patient.userId);
|
||||
|
||||
if (afterHoursEnabled && outsideHours) {
|
||||
const chatTemplates = await storage.getAiChatTemplates(patient.userId);
|
||||
const officeContact = await storage.getOfficeContact(patient.userId);
|
||||
const officeName = (officeContact as any)?.officeName?.trim() || "";
|
||||
|
||||
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?`;
|
||||
|
||||
return reply(applyOfficeName(rawGreeting, officeName), "new_patient_greeted");
|
||||
}
|
||||
}
|
||||
|
||||
res.set("Content-Type", "text/xml");
|
||||
return res.send("<Response></Response>");
|
||||
return res.send(empty());
|
||||
|
||||
} catch (err) {
|
||||
res.set("Content-Type", "text/xml");
|
||||
return res.send("<Response></Response>");
|
||||
return res.send(empty());
|
||||
}
|
||||
});
|
||||
|
||||
// POST /api/twilio/webhook/voice (Twilio posts here when someone calls — no auth)
|
||||
// ── POST /api/twilio/webhook/voice ────────────────────────────────────────────
|
||||
|
||||
router.post("/webhook/voice", async (req: Request, res: Response): Promise<any> => {
|
||||
try {
|
||||
const { From, CallSid } = req.body;
|
||||
@@ -72,69 +450,51 @@ router.post("/webhook/voice", async (req: Request, res: Response): Promise<any>
|
||||
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 (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,
|
||||
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`;
|
||||
|
||||
const twiml = `<?xml version="1.0" encoding="UTF-8"?>
|
||||
res.set("Content-Type", "text/xml");
|
||||
return res.send(`<?xml version="1.0" encoding="UTF-8"?>
|
||||
<Response>
|
||||
<Say voice="alice">${greeting}</Say>
|
||||
<Record maxLength="120" action="${recordingCallbackUrl}" transcribeCallback="${recordingCallbackUrl}" playBeep="true"/>
|
||||
<Say voice="alice">We did not receive a recording. Goodbye.</Say>
|
||||
</Response>`;
|
||||
|
||||
res.set("Content-Type", "text/xml");
|
||||
return res.send(twiml);
|
||||
</Response>`);
|
||||
} catch (err) {
|
||||
res.set("Content-Type", "text/xml");
|
||||
return res.send(`<?xml version="1.0" encoding="UTF-8"?><Response><Say>Thank you for calling. Please try again later.</Say></Response>`);
|
||||
}
|
||||
});
|
||||
|
||||
// POST /api/twilio/webhook/voice-recording (Twilio posts recording URL here — no auth)
|
||||
// ── POST /api/twilio/webhook/voice-recording ──────────────────────────────────
|
||||
|
||||
router.post("/webhook/voice-recording", async (req: Request, res: Response): Promise<any> => {
|
||||
try {
|
||||
const { CallSid, RecordingUrl } = req.body;
|
||||
|
||||
if (RecordingUrl && CallSid) {
|
||||
const comm = await db.communication.findFirst({ where: { twilioSid: CallSid } });
|
||||
if (comm) {
|
||||
await db.communication.update({
|
||||
where: { id: comm.id },
|
||||
data: { body: `Voicemail: ${RecordingUrl}.mp3` },
|
||||
data: { body: `Voicemail: ${RecordingUrl}.mp3` },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
res.set("Content-Type", "text/xml");
|
||||
return res.send("<Response></Response>");
|
||||
return res.send(empty());
|
||||
} catch (err) {
|
||||
res.set("Content-Type", "text/xml");
|
||||
return res.send("<Response></Response>");
|
||||
return res.send(empty());
|
||||
}
|
||||
});
|
||||
|
||||
function escapeXml(text: string): string {
|
||||
return text
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """)
|
||||
.replace(/'/g, "'");
|
||||
}
|
||||
|
||||
export default router;
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import express, { Request, Response } from "express";
|
||||
import twilio from "twilio";
|
||||
import { storage } from "../storage";
|
||||
import { getHandoff, setHandoff, resetConversation, startNewPatientConversation, getAfterHoursHandoff, setAfterHoursHandoff } from "../ai/aiHandoffStore";
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
@@ -65,7 +66,7 @@ router.post("/send-sms", async (req: Request, res: Response): Promise<any> => {
|
||||
const userId = req.user?.id;
|
||||
if (!userId) return res.status(401).json({ message: "Unauthorized" });
|
||||
|
||||
const { to, message, patientId } = req.body;
|
||||
const { to, message, patientId, startFlow } = req.body;
|
||||
if (!to || !message) return res.status(400).json({ message: "to and message are required" });
|
||||
|
||||
const settings = await storage.getTwilioSettings(userId);
|
||||
@@ -81,8 +82,9 @@ router.post("/send-sms", async (req: Request, res: Response): Promise<any> => {
|
||||
});
|
||||
|
||||
if (patientId) {
|
||||
const pid = Number(patientId);
|
||||
await storage.createCommunication({
|
||||
patientId: Number(patientId),
|
||||
patientId: pid,
|
||||
userId,
|
||||
channel: "sms",
|
||||
direction: "outbound",
|
||||
@@ -90,6 +92,12 @@ router.post("/send-sms", async (req: Request, res: Response): Promise<any> => {
|
||||
body: message,
|
||||
twilioSid: twilioMsg.sid,
|
||||
});
|
||||
// Set conversation stage based on which flow was started
|
||||
if (startFlow === "new_patient") {
|
||||
startNewPatientConversation(userId, pid);
|
||||
} else {
|
||||
resetConversation(userId, pid);
|
||||
}
|
||||
}
|
||||
|
||||
return res.status(200).json({ sid: twilioMsg.sid, status: twilioMsg.status });
|
||||
@@ -125,6 +133,60 @@ router.put("/templates/:key", async (req: Request, res: Response): Promise<any>
|
||||
}
|
||||
});
|
||||
|
||||
// GET /api/twilio/after-hours-handoff
|
||||
router.get("/after-hours-handoff", async (req: Request, res: Response): Promise<any> => {
|
||||
try {
|
||||
const userId = req.user?.id;
|
||||
if (!userId) return res.status(401).json({ message: "Unauthorized" });
|
||||
return res.status(200).json({ enabled: getAfterHoursHandoff(userId) });
|
||||
} catch (err) {
|
||||
return res.status(500).json({ error: "Failed to get after-hours handoff state" });
|
||||
}
|
||||
});
|
||||
|
||||
// PUT /api/twilio/after-hours-handoff
|
||||
router.put("/after-hours-handoff", async (req: Request, res: Response): Promise<any> => {
|
||||
try {
|
||||
const userId = req.user?.id;
|
||||
if (!userId) return res.status(401).json({ message: "Unauthorized" });
|
||||
const { enabled } = req.body;
|
||||
if (typeof enabled !== "boolean") return res.status(400).json({ message: "enabled must be a boolean" });
|
||||
setAfterHoursHandoff(userId, enabled);
|
||||
return res.status(200).json({ enabled });
|
||||
} catch (err) {
|
||||
return res.status(500).json({ error: "Failed to set after-hours handoff state" });
|
||||
}
|
||||
});
|
||||
|
||||
// GET /api/twilio/ai-handoff/:patientId
|
||||
router.get("/ai-handoff/:patientId", async (req: Request, res: Response): Promise<any> => {
|
||||
try {
|
||||
const userId = req.user?.id;
|
||||
if (!userId) return res.status(401).json({ message: "Unauthorized" });
|
||||
const patientId = parseInt(req.params.patientId);
|
||||
if (isNaN(patientId)) return res.status(400).json({ message: "Invalid patientId" });
|
||||
return res.status(200).json({ enabled: getHandoff(userId, patientId) });
|
||||
} catch (err) {
|
||||
return res.status(500).json({ error: "Failed to get AI handoff state" });
|
||||
}
|
||||
});
|
||||
|
||||
// PUT /api/twilio/ai-handoff/:patientId
|
||||
router.put("/ai-handoff/:patientId", async (req: Request, res: Response): Promise<any> => {
|
||||
try {
|
||||
const userId = req.user?.id;
|
||||
if (!userId) return res.status(401).json({ message: "Unauthorized" });
|
||||
const patientId = parseInt(req.params.patientId);
|
||||
if (isNaN(patientId)) return res.status(400).json({ message: "Invalid patientId" });
|
||||
const { enabled } = req.body;
|
||||
if (typeof enabled !== "boolean") return res.status(400).json({ message: "enabled must be a boolean" });
|
||||
setHandoff(userId, patientId, enabled);
|
||||
return res.status(200).json({ enabled });
|
||||
} catch (err) {
|
||||
return res.status(500).json({ error: "Failed to set AI handoff state" });
|
||||
}
|
||||
});
|
||||
|
||||
// GET /api/twilio/recent-communications
|
||||
router.get("/recent-communications", async (req: Request, res: Response): Promise<any> => {
|
||||
try {
|
||||
|
||||
Reference in New Issue
Block a user