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:
Gitead
2026-05-07 23:21:06 -04:00
parent 86dd685342
commit 9908e5b5fd
317 changed files with 6533 additions and 274 deletions

View File

@@ -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;

View File

@@ -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, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;")
.replace(/"/g, "&quot;").replace(/'/g, "&apos;");
}
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, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&apos;");
}
export default router;

View File

@@ -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 {