feat: persist AI conversation state in DB and fix LangGraph flow bugs
- Replace in-memory Maps in aiHandoffStore with DB-backed async functions using new patient_conversation table (stage + aiHandoff per patient) - Add afterHoursEnabled to ai_settings table (persists across restarts) - Fix runtime crash in reschedule-graph: mon/tue/wed variables were out of scope in the next-week fallback branch (ReferenceError) - Wire rescheduleGreeting and generalFallback chat templates through to LangGraph nodes so user-configured messages take effect - Add otherNode to reminder-graph to handle unclassified patient replies (e.g. "I want another appointment") and route to booking flow - Fetch chatTemplates once per webhook request instead of per stage Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -1,93 +1,90 @@
|
||||
// In-memory store for per-patient AI handoff toggle, conversation stage,
|
||||
// and per-user after-hours handoff toggle.
|
||||
// Conversation key: `${userId}:${patientId}`
|
||||
import { prisma as db } from "@repo/db/client";
|
||||
|
||||
export type ConversationStage =
|
||||
| "initial" // default — no active conversation
|
||||
| "reminder_initial" // office sent a reminder, waiting for first patient reply
|
||||
| "greeted" // reminder intro sent, waiting for yes/no
|
||||
| "done" // conversation complete
|
||||
| "new_patient_greeted" // new-patient greeting sent, waiting for patient intent
|
||||
| "asked_new_or_existing" // AI asked "new or existing patient?"
|
||||
| "asked_new_patient_insurance" // AI asked new patient about insurance
|
||||
| "asked_existing_insurance" // AI asked existing patient about same insurance
|
||||
| "asked_appointment_time" // AI asked when they'd like to come
|
||||
| "awaiting_masshealth_info" // AI asked for Member ID + DOB, waiting for reply
|
||||
| "asked_appointment_preference" // Selenium: ACTIVE — AI asked check-up vs problem
|
||||
| "asked_self_pay" // Selenium: INACTIVE — AI asked if self-pay exam
|
||||
| "asked_reschedule_confirm" // AI asked "Would you like to reschedule?"
|
||||
| "asked_reschedule_preference" // AI asked ASAP vs next week
|
||||
| "asked_reschedule_asap" // AI asked "Can you come tomorrow?"
|
||||
| "asked_reschedule_next_week" // AI offered Mon/Tue/Wed next week
|
||||
| "asked_reschedule_time"; // Day confirmed — AI asked morning or afternoon
|
||||
| "initial"
|
||||
| "reminder_initial"
|
||||
| "greeted"
|
||||
| "done"
|
||||
| "new_patient_greeted"
|
||||
| "asked_new_or_existing"
|
||||
| "asked_new_patient_insurance"
|
||||
| "asked_existing_insurance"
|
||||
| "asked_appointment_time"
|
||||
| "awaiting_masshealth_info"
|
||||
| "asked_appointment_preference"
|
||||
| "asked_self_pay"
|
||||
| "asked_reschedule_confirm"
|
||||
| "asked_reschedule_preference"
|
||||
| "asked_reschedule_asap"
|
||||
| "asked_reschedule_next_week"
|
||||
| "asked_reschedule_time";
|
||||
|
||||
const handoffStore = new Map<string, boolean>();
|
||||
const stageStore = new Map<string, ConversationStage>();
|
||||
const afterHoursStore = new Map<number, boolean>(); // keyed by userId
|
||||
// ── Conversation stage + AI handoff per patient (DB-persisted) ────────────────
|
||||
|
||||
export async function getStage(userId: number, patientId: number): Promise<ConversationStage> {
|
||||
const row = await db.patientConversation.findUnique({ where: { patientId } });
|
||||
return (row?.stage as ConversationStage) ?? "initial";
|
||||
}
|
||||
|
||||
export async function setStage(userId: number, patientId: number, stage: ConversationStage): Promise<void> {
|
||||
await db.patientConversation.upsert({
|
||||
where: { patientId },
|
||||
update: { stage },
|
||||
create: { patientId, userId, stage, aiHandoff: true },
|
||||
});
|
||||
}
|
||||
|
||||
export async function getHandoff(userId: number, patientId: number): Promise<boolean> {
|
||||
const row = await db.patientConversation.findUnique({ where: { patientId } });
|
||||
return row?.aiHandoff ?? true;
|
||||
}
|
||||
|
||||
export async function setHandoff(userId: number, patientId: number, enabled: boolean): Promise<void> {
|
||||
await db.patientConversation.upsert({
|
||||
where: { patientId },
|
||||
update: { aiHandoff: enabled },
|
||||
create: { patientId, userId, aiHandoff: enabled, stage: "initial" },
|
||||
});
|
||||
}
|
||||
|
||||
// ── After-hours handoff per user (persisted in ai_settings) ──────────────────
|
||||
|
||||
export async function getAfterHoursHandoff(userId: number): Promise<boolean> {
|
||||
const row = await db.aiSettings.findUnique({ where: { userId } });
|
||||
return row?.afterHoursEnabled ?? true;
|
||||
}
|
||||
|
||||
export async function setAfterHoursHandoff(userId: number, enabled: boolean): Promise<void> {
|
||||
await db.aiSettings.update({ where: { userId }, data: { afterHoursEnabled: enabled } });
|
||||
}
|
||||
|
||||
// ── Conversation starters ─────────────────────────────────────────────────────
|
||||
|
||||
export async function resetConversation(userId: number, patientId: number): Promise<void> {
|
||||
await setStage(userId, patientId, "reminder_initial");
|
||||
}
|
||||
|
||||
export async function startNewPatientConversation(userId: number, patientId: number): Promise<void> {
|
||||
await setStage(userId, patientId, "new_patient_greeted");
|
||||
}
|
||||
|
||||
export async function startRescheduleConversation(userId: number, patientId: number): Promise<void> {
|
||||
await setStage(userId, patientId, "asked_reschedule_confirm");
|
||||
}
|
||||
|
||||
// ── Pending reschedule (in-memory — seconds-lived within a single exchange) ───
|
||||
|
||||
interface PendingReschedule {
|
||||
newDate: Date;
|
||||
dayLabel: string;
|
||||
}
|
||||
|
||||
const pendingRescheduleStore = new Map<string, PendingReschedule>();
|
||||
|
||||
function convKey(userId: number, patientId: number): string {
|
||||
return `${userId}:${patientId}`;
|
||||
}
|
||||
|
||||
// ── Per-patient handoff toggle (default ON) ───────────────────────────────────
|
||||
|
||||
export function getHandoff(userId: number, patientId: number): boolean {
|
||||
const k = convKey(userId, patientId);
|
||||
return handoffStore.has(k) ? handoffStore.get(k)! : true;
|
||||
}
|
||||
|
||||
export function setHandoff(userId: number, patientId: number, enabled: boolean): void {
|
||||
handoffStore.set(convKey(userId, patientId), enabled);
|
||||
}
|
||||
|
||||
// ── Per-user after-hours handoff toggle (default ON) ─────────────────────────
|
||||
|
||||
export function getAfterHoursHandoff(userId: number): boolean {
|
||||
return afterHoursStore.has(userId) ? afterHoursStore.get(userId)! : true;
|
||||
}
|
||||
|
||||
export function setAfterHoursHandoff(userId: number, enabled: boolean): void {
|
||||
afterHoursStore.set(userId, enabled);
|
||||
}
|
||||
|
||||
// ── Conversation stage ────────────────────────────────────────────────────────
|
||||
|
||||
export function getStage(userId: number, patientId: number): ConversationStage {
|
||||
return stageStore.get(convKey(userId, patientId)) ?? "initial";
|
||||
}
|
||||
|
||||
export function setStage(userId: number, patientId: number, stage: ConversationStage): void {
|
||||
stageStore.set(convKey(userId, patientId), stage);
|
||||
}
|
||||
|
||||
// Called when office sends an outbound reminder — marks next patient reply
|
||||
// as the start of a reminder conversation.
|
||||
export function resetConversation(userId: number, patientId: number): void {
|
||||
stageStore.set(convKey(userId, patientId), "reminder_initial");
|
||||
}
|
||||
|
||||
// Called when office sends the new-patient greeting — marks next patient reply
|
||||
// as the start of the new-patient conversation flow.
|
||||
export function startNewPatientConversation(userId: number, patientId: number): void {
|
||||
stageStore.set(convKey(userId, patientId), "new_patient_greeted");
|
||||
}
|
||||
|
||||
// Called when office sends a reschedule greeting — patient's next reply enters
|
||||
// the reschedule flow.
|
||||
export function startRescheduleConversation(userId: number, patientId: number): void {
|
||||
stageStore.set(convKey(userId, patientId), "asked_reschedule_confirm");
|
||||
}
|
||||
|
||||
// ── Pending reschedule data ───────────────────────────────────────────────────
|
||||
// Holds the confirmed new date while AI waits for a time-slot answer.
|
||||
|
||||
interface PendingReschedule {
|
||||
newDate: Date; // JS Date for the new appointment day (midnight UTC)
|
||||
dayLabel: string; // human-readable, e.g. "Tuesday, May 19"
|
||||
}
|
||||
|
||||
const pendingRescheduleStore = new Map<string, PendingReschedule>();
|
||||
|
||||
export function setPendingReschedule(userId: number, patientId: number, data: PendingReschedule): void {
|
||||
pendingRescheduleStore.set(convKey(userId, patientId), data);
|
||||
}
|
||||
|
||||
@@ -5,12 +5,13 @@ import type { ConversationStage } from "./aiHandoffStore";
|
||||
// ── Graph state ───────────────────────────────────────────────────────────────
|
||||
|
||||
const GraphState = Annotation.Root({
|
||||
message: Annotation<string>(),
|
||||
stage: Annotation<string>(),
|
||||
intent: Annotation<string>(),
|
||||
reply: Annotation<string>(),
|
||||
language: Annotation<string>(),
|
||||
nextStage: Annotation<string>(),
|
||||
message: Annotation<string>(),
|
||||
stage: Annotation<string>(),
|
||||
intent: Annotation<string>(),
|
||||
reply: Annotation<string>(),
|
||||
language: Annotation<string>(),
|
||||
nextStage: Annotation<string>(),
|
||||
generalFallback: Annotation<string>(),
|
||||
});
|
||||
|
||||
type GraphStateType = typeof GraphState.State;
|
||||
@@ -377,7 +378,7 @@ async function handleSelfPayNode(state: GraphStateType, config: any) {
|
||||
|
||||
function transferNode(state: GraphStateType) {
|
||||
const lang = state.language || "English";
|
||||
return { reply: transferMsg(lang), nextStage: "done" };
|
||||
return { reply: state.generalFallback || transferMsg(lang), nextStage: "done" };
|
||||
}
|
||||
|
||||
// ── Graph assembly ────────────────────────────────────────────────────────────
|
||||
@@ -419,13 +420,14 @@ const graph = new StateGraph(GraphState)
|
||||
// ── Public API ────────────────────────────────────────────────────────────────
|
||||
|
||||
export async function runNewPatientStep(
|
||||
message: string,
|
||||
stage: ConversationStage,
|
||||
language: string,
|
||||
apiKey: string
|
||||
message: string,
|
||||
stage: ConversationStage,
|
||||
language: string,
|
||||
apiKey: string,
|
||||
generalFallback = ""
|
||||
): Promise<{ reply: string; nextStage: ConversationStage }> {
|
||||
const result = await graph.invoke(
|
||||
{ message, stage, intent: "", reply: "", language, nextStage: "" },
|
||||
{ message, stage, intent: "", reply: "", language, nextStage: "", generalFallback },
|
||||
{ configurable: { apiKey } }
|
||||
);
|
||||
return {
|
||||
|
||||
@@ -7,6 +7,8 @@ const GraphState = Annotation.Root({
|
||||
reply: Annotation<string>(),
|
||||
language: Annotation<string>(),
|
||||
appointmentDatetime: Annotation<string>(),
|
||||
rescheduleGreeting: Annotation<string>(),
|
||||
generalFallback: Annotation<string>(),
|
||||
});
|
||||
|
||||
type GraphStateType = typeof GraphState.State;
|
||||
@@ -27,7 +29,7 @@ function classifyNode(state: GraphStateType) {
|
||||
function routeByIntent(state: GraphStateType): string {
|
||||
if (state.intent === "yes") return "confirm";
|
||||
if (state.intent === "no") return "reschedule";
|
||||
return END;
|
||||
return "other";
|
||||
}
|
||||
|
||||
// ── Confirmation fallbacks (with appointment datetime) ────────────────────────
|
||||
@@ -58,6 +60,30 @@ const RESCHEDULE_FALLBACKS: Record<string, string> = {
|
||||
"Haitian Creole": "Nou konprann! Èske ou ta renmen repwograme randevou ou?",
|
||||
};
|
||||
|
||||
// ── New-appointment fallbacks (other intent, appointment keywords detected) ───
|
||||
|
||||
const NEW_APPT_FALLBACKS: Record<string, string> = {
|
||||
English: "Of course! Are you a new patient or an existing patient?",
|
||||
Spanish: "¡Por supuesto! ¿Es usted un paciente nuevo o ya ha visitado nuestra clínica?",
|
||||
Portuguese: "Claro! Você é um paciente novo ou já veio ao nosso consultório antes?",
|
||||
Mandarin: "当然!您是新患者还是我们的现有患者?",
|
||||
Cantonese: "當然!您係新病人定係我們的舊病人?",
|
||||
Arabic: "بالطبع! هل أنت مريض جديد أم مريض حالي؟",
|
||||
"Haitian Creole": "Byensèten! Èske ou se yon nouvo pasyan oswa yon pasyan egzistan?",
|
||||
};
|
||||
|
||||
// ── General-other fallbacks ───────────────────────────────────────────────────
|
||||
|
||||
const GENERAL_FALLBACKS: Record<string, string> = {
|
||||
English: "Thank you for your message! Our office staff will be happy to assist you shortly.",
|
||||
Spanish: "¡Gracias por su mensaje! El personal de nuestra oficina estará encantado de ayudarle en breve.",
|
||||
Portuguese: "Obrigado pela sua mensagem! Nossa equipe terá prazer em ajudá-lo em breve.",
|
||||
Mandarin: "感谢您的留言!我们的办公室工作人员将很快为您提供帮助。",
|
||||
Cantonese: "感謝您的留言!我們的辦公室工作人員將很快為您提供幫助。",
|
||||
Arabic: "شكراً على رسالتك! سيسعد فريق مكتبنا بمساعدتك قريباً.",
|
||||
"Haitian Creole": "Mèsi pou mesaj ou! Ekip biwo nou an pral kontan ede ou byento.",
|
||||
};
|
||||
|
||||
// ── LangGraph nodes ───────────────────────────────────────────────────────────
|
||||
|
||||
async function confirmNode(state: GraphStateType, config: any) {
|
||||
@@ -88,7 +114,7 @@ async function confirmNode(state: GraphStateType, config: any) {
|
||||
async function rescheduleNode(state: GraphStateType, config: any) {
|
||||
const apiKey: string | undefined = config?.configurable?.apiKey;
|
||||
const lang = state.language || "English";
|
||||
const fallback = RESCHEDULE_FALLBACKS[lang] ?? RESCHEDULE_FALLBACKS["English"]!;
|
||||
const fallback = state.rescheduleGreeting || (RESCHEDULE_FALLBACKS[lang] ?? RESCHEDULE_FALLBACKS["English"]!);
|
||||
|
||||
if (!apiKey) return { reply: fallback };
|
||||
|
||||
@@ -108,30 +134,76 @@ async function rescheduleNode(state: GraphStateType, config: any) {
|
||||
}
|
||||
}
|
||||
|
||||
async function otherNode(state: GraphStateType, config: any) {
|
||||
const apiKey: string | undefined = config?.configurable?.apiKey;
|
||||
const lang = state.language || "English";
|
||||
const text = state.message.toLowerCase();
|
||||
|
||||
const isAppointmentRequest = /appointment|schedule|book|come in|visit|check.?up|cleaning|tooth|teeth|pain|dental|another|new appt/i.test(text);
|
||||
|
||||
if (isAppointmentRequest) {
|
||||
const fallback = NEW_APPT_FALLBACKS[lang] ?? NEW_APPT_FALLBACKS["English"]!;
|
||||
if (!apiKey) return { reply: fallback, intent: "wants_appointment" };
|
||||
try {
|
||||
const llm = new ChatGoogleGenerativeAI({ model: "gemini-1.5-flash", apiKey });
|
||||
const response = await llm.invoke([
|
||||
{
|
||||
role: "system",
|
||||
content: `You are a friendly dental office AI assistant named Lisa. The patient wants to schedule an appointment. Ask them in ${lang} whether they are a new patient or an existing patient. One sentence, no formatting.`,
|
||||
},
|
||||
{ role: "user", content: `Patient said: "${state.message}"` },
|
||||
]);
|
||||
return { reply: String(response.content) || fallback, intent: "wants_appointment" };
|
||||
} catch {
|
||||
return { reply: fallback, intent: "wants_appointment" };
|
||||
}
|
||||
}
|
||||
|
||||
const fallback = state.generalFallback || (GENERAL_FALLBACKS[lang] ?? GENERAL_FALLBACKS["English"]!);
|
||||
if (!apiKey) return { reply: fallback };
|
||||
try {
|
||||
const llm = new ChatGoogleGenerativeAI({ model: "gemini-1.5-flash", apiKey });
|
||||
const response = await llm.invoke([
|
||||
{
|
||||
role: "system",
|
||||
content: `You are a friendly dental office AI assistant named Lisa. Respond helpfully to the patient's message in ${lang}. Keep it to 1-2 sentences, no formatting. For non-dental questions, let them know our office staff can assist.`,
|
||||
},
|
||||
{ role: "user", content: `Patient said: "${state.message}"` },
|
||||
]);
|
||||
return { reply: String(response.content) || fallback };
|
||||
} catch {
|
||||
return { reply: fallback };
|
||||
}
|
||||
}
|
||||
|
||||
// ── Graph ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
const graph = new StateGraph(GraphState)
|
||||
.addNode("classify", classifyNode)
|
||||
.addNode("confirm", confirmNode)
|
||||
.addNode("reschedule", rescheduleNode)
|
||||
.addNode("other", otherNode)
|
||||
.addEdge(START, "classify")
|
||||
.addConditionalEdges("classify", routeByIntent, {
|
||||
confirm: "confirm",
|
||||
reschedule: "reschedule",
|
||||
[END]: END,
|
||||
other: "other",
|
||||
})
|
||||
.addEdge("confirm", END)
|
||||
.addEdge("reschedule", END)
|
||||
.addEdge("other", END)
|
||||
.compile();
|
||||
|
||||
export async function runReminderGraph(
|
||||
patientMessage: string,
|
||||
apiKey: string,
|
||||
language = "English",
|
||||
appointmentDatetime = ""
|
||||
appointmentDatetime = "",
|
||||
rescheduleGreeting = "",
|
||||
generalFallback = ""
|
||||
): Promise<{ reply: string | null; intent: string | null }> {
|
||||
const result = await graph.invoke(
|
||||
{ message: patientMessage, intent: "", reply: "", language, appointmentDatetime },
|
||||
{ message: patientMessage, intent: "", reply: "", language, appointmentDatetime, rescheduleGreeting, generalFallback },
|
||||
{ configurable: { apiKey } }
|
||||
);
|
||||
return {
|
||||
|
||||
@@ -387,6 +387,7 @@ export async function runRescheduleStep(
|
||||
}
|
||||
|
||||
// Day not clearly detected — ask again with the specific options
|
||||
const { mon, tue, wed } = getNextWeekDays();
|
||||
const fallbacks: Record<string, string> = {
|
||||
English: `Which day works best — ${mon}, ${tue}, or ${wed}?`,
|
||||
Spanish: `¿Qué día le viene mejor — el ${mon}, ${tue} o el ${wed}?`,
|
||||
|
||||
@@ -208,7 +208,7 @@ async function runMassHealthCheckAndNotify(
|
||||
|
||||
// Persist and advance stage
|
||||
await saveOutbound(patient.id, resultText);
|
||||
setStage(patient.userId, patient.id, nextStage);
|
||||
await setStage(patient.userId, patient.id, nextStage);
|
||||
|
||||
} catch {
|
||||
// Silent — don't crash the main request
|
||||
@@ -241,7 +241,7 @@ router.post("/webhook/sms", async (req: Request, res: Response): Promise<any> =>
|
||||
});
|
||||
|
||||
// Per-patient handoff toggle must be ON
|
||||
if (!getHandoff(patient.userId, patient.id)) {
|
||||
if (!await getHandoff(patient.userId, patient.id)) {
|
||||
res.set("Content-Type", "text/xml");
|
||||
return res.send(empty());
|
||||
}
|
||||
@@ -252,23 +252,22 @@ router.post("/webhook/sms", async (req: Request, res: Response): Promise<any> =>
|
||||
return res.send(empty());
|
||||
}
|
||||
|
||||
const language = patient.preferredLanguage || "English";
|
||||
const stage = getStage(patient.userId, patient.id);
|
||||
const language = patient.preferredLanguage || "English";
|
||||
const stage = await getStage(patient.userId, patient.id);
|
||||
const chatTemplates = await storage.getAiChatTemplates(patient.userId);
|
||||
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);
|
||||
setStage(patient.userId, patient.id, nextStage);
|
||||
await 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.`;
|
||||
|
||||
@@ -278,10 +277,15 @@ router.post("/webhook/sms", async (req: Request, res: Response): Promise<any> =>
|
||||
// ── 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);
|
||||
const { reply: aiReply, intent } = await runReminderGraph(
|
||||
Body, aiSettings.apiKey, language, apptDatetime,
|
||||
chatTemplates.rescheduleGreeting, chatTemplates.generalFallback
|
||||
);
|
||||
if (aiReply) {
|
||||
// YES → done; NO → start rescheduling flow
|
||||
const nextStage: ConversationStage = intent === "no" ? "asked_reschedule_confirm" : "done";
|
||||
let nextStage: ConversationStage;
|
||||
if (intent === "no") nextStage = "asked_reschedule_confirm";
|
||||
else if (intent === "wants_appointment") nextStage = "asked_new_or_existing";
|
||||
else nextStage = "done";
|
||||
return reply(aiReply, nextStage);
|
||||
}
|
||||
}
|
||||
@@ -332,7 +336,7 @@ router.post("/webhook/sms", async (req: Request, res: Response): Promise<any> =>
|
||||
|
||||
// Reply now — Selenium runs in the background
|
||||
await saveOutbound(patient.id, checkingMsg);
|
||||
setStage(patient.userId, patient.id, "done");
|
||||
await setStage(patient.userId, patient.id, "done");
|
||||
res.set("Content-Type", "text/xml");
|
||||
res.send(twimlReply(checkingMsg));
|
||||
|
||||
@@ -380,7 +384,7 @@ router.post("/webhook/sms", async (req: Request, res: Response): Promise<any> =>
|
||||
const checkingMsg = checkingMessages[language] ?? checkingMessages["English"]!;
|
||||
|
||||
await saveOutbound(patient.id, checkingMsg);
|
||||
setStage(patient.userId, patient.id, "done");
|
||||
await setStage(patient.userId, patient.id, "done");
|
||||
res.set("Content-Type", "text/xml");
|
||||
res.send(twimlReply(checkingMsg));
|
||||
|
||||
@@ -403,7 +407,7 @@ router.post("/webhook/sms", async (req: Request, res: Response): Promise<any> =>
|
||||
];
|
||||
if (newPatientStages.includes(stage)) {
|
||||
const { reply: aiReply, nextStage } = await runNewPatientStep(
|
||||
Body, stage, language, aiSettings.apiKey
|
||||
Body, stage, language, aiSettings.apiKey, chatTemplates.generalFallback
|
||||
);
|
||||
return reply(aiReply, nextStage);
|
||||
}
|
||||
@@ -411,14 +415,10 @@ router.post("/webhook/sms", async (req: Request, res: Response): Promise<any> =>
|
||||
// ── Stage: initial (no active conversation) ───────────────────────────
|
||||
// Check after-hours: if enabled and currently outside office hours → start new-patient flow
|
||||
if (stage === "initial" || stage === "done") {
|
||||
const afterHoursEnabled = getAfterHoursHandoff(patient.userId);
|
||||
const afterHoursEnabled = await 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?`;
|
||||
|
||||
|
||||
@@ -94,11 +94,11 @@ router.post("/send-sms", async (req: Request, res: Response): Promise<any> => {
|
||||
});
|
||||
// Set conversation stage based on which flow was started
|
||||
if (startFlow === "new_patient") {
|
||||
startNewPatientConversation(userId, pid);
|
||||
await startNewPatientConversation(userId, pid);
|
||||
} else if (startFlow === "reschedule") {
|
||||
startRescheduleConversation(userId, pid);
|
||||
await startRescheduleConversation(userId, pid);
|
||||
} else {
|
||||
resetConversation(userId, pid);
|
||||
await resetConversation(userId, pid);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -140,7 +140,7 @@ router.get("/after-hours-handoff", async (req: Request, res: Response): Promise<
|
||||
try {
|
||||
const userId = req.user?.id;
|
||||
if (!userId) return res.status(401).json({ message: "Unauthorized" });
|
||||
return res.status(200).json({ enabled: getAfterHoursHandoff(userId) });
|
||||
return res.status(200).json({ enabled: await getAfterHoursHandoff(userId) });
|
||||
} catch (err) {
|
||||
return res.status(500).json({ error: "Failed to get after-hours handoff state" });
|
||||
}
|
||||
@@ -153,7 +153,7 @@ router.put("/after-hours-handoff", async (req: Request, res: Response): Promise<
|
||||
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);
|
||||
await setAfterHoursHandoff(userId, enabled);
|
||||
return res.status(200).json({ enabled });
|
||||
} catch (err) {
|
||||
return res.status(500).json({ error: "Failed to set after-hours handoff state" });
|
||||
@@ -167,7 +167,7 @@ router.get("/ai-handoff/:patientId", async (req: Request, res: Response): Promis
|
||||
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) });
|
||||
return res.status(200).json({ enabled: await getHandoff(userId, patientId) });
|
||||
} catch (err) {
|
||||
return res.status(500).json({ error: "Failed to get AI handoff state" });
|
||||
}
|
||||
@@ -182,7 +182,7 @@ router.put("/ai-handoff/:patientId", async (req: Request, res: Response): Promis
|
||||
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);
|
||||
await setHandoff(userId, patientId, enabled);
|
||||
return res.status(200).json({ enabled });
|
||||
} catch (err) {
|
||||
return res.status(500).json({ error: "Failed to set AI handoff state" });
|
||||
|
||||
Reference in New Issue
Block a user