feat: add AI recall calling — books existing patients for routine cleanings

Adds a new recall conversation flow (recall-graph.ts) triggered from the
Patient Connection "AI Call" button: offers an existing patient a routine
exam & cleaning, classifies their yes/no reply, then books directly onto
the schedule (Column A, 30-minute slots, real conflict checking against
office hours and existing appointments) instead of only capturing a
preference for staff to follow up on.

Also adds a "Voice call" label in the message thread to distinguish call
transcripts from SMS, and updates the Call Templates settings UI with an
editable recall greeting.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
ff
2026-08-02 23:13:09 -04:00
co-authored by Claude Sonnet 5
parent 06dc63c53e
commit cef928be97
10 changed files with 588 additions and 39 deletions
+2 -2
View File
@@ -240,8 +240,8 @@ router.put("/call-templates", async (req: Request, res: Response): Promise<any>
try {
const userId = req.user?.id;
if (!userId) return res.status(401).json({ message: "Unauthorized" });
const { greeting, reminderGreeting, newPatientGreeting, generalFallback } = req.body;
await storage.saveAiCallTemplates(userId, { greeting, reminderGreeting, newPatientGreeting, generalFallback });
const { greeting, reminderGreeting, newPatientGreeting, generalFallback, recallGreeting } = req.body;
await storage.saveAiCallTemplates(userId, { greeting, reminderGreeting, newPatientGreeting, generalFallback, recallGreeting });
const updated = await storage.getAiCallTemplates(userId);
return res.status(200).json(updated);
} catch (err) {
+10 -5
View File
@@ -19,7 +19,7 @@ import {
} from "../ai/patient-conversation-router";
import {
getHandoff, getAfterHoursHandoff,
getStage, setStage, resetConversation,
getStage, setStage, resetConversation, startRecallConversation,
setPendingReschedule, getPendingReschedule, clearPendingReschedule,
type ConversationStage,
} from "../ai/aiHandoffStore";
@@ -696,6 +696,7 @@ router.post("/webhook/ai-voice", async (req: Request, res: Response): Promise<an
try {
const { CallSid, SpeechResult } = req.body as { CallSid?: string; SpeechResult?: string };
const patientIdParam = req.query.patientId as string | undefined;
const purposeParam = req.query.purpose as string | undefined;
if (!CallSid) return res.send(aiVoiceHangup("Sorry, something went wrong. Goodbye."));
let session = aiCallSessions.get(CallSid);
@@ -713,13 +714,17 @@ router.post("/webhook/ai-voice", async (req: Request, res: Response): Promise<an
const language = patient.preferredLanguage || "English";
const locale = voiceLocaleFor(language);
const isRecallCall = purposeParam === "recall";
// Resume an in-progress cross-channel conversation (e.g. mid-reschedule
// from a prior text) if one exists; otherwise start a fresh reminder-style
// check-in call, matching the default the reminder-SMS batch send uses.
// from a prior text) if one exists; otherwise start a fresh check-in call.
// A "recall" call (staff-triggered from Patient Connection, offering an
// existing patient a routine exam & cleaning) seeds a different stage
// than the default reminder-style check-in.
const currentStage = await getStage(patient.userId, patient.id);
if (currentStage === "initial" || currentStage === "done") {
await resetConversation(patient.userId, patient.id);
if (isRecallCall) await startRecallConversation(patient.userId, patient.id);
else await resetConversation(patient.userId, patient.id);
}
const officeContact = await storage.getOfficeContact(patient.userId);
@@ -732,7 +737,7 @@ router.post("/webhook/ai-voice", async (req: Request, res: Response): Promise<an
};
aiCallSessions.set(CallSid, session);
const rawCallTemplate = callTemplates.greeting?.trim() ||
const rawCallTemplate = (isRecallCall ? callTemplates.recallGreeting?.trim() : callTemplates.greeting?.trim()) ||
"Hi {firstName}, this is Lisa, the AI assistant at {officeName}. How can I help you today?";
const greeting = rawCallTemplate
.replace(/\{firstName\}/g, patient.firstName || "there")
+3 -2
View File
@@ -127,7 +127,7 @@ router.post("/make-ai-call", async (req: Request, res: Response): Promise<any> =
const userId = req.user?.id;
if (!userId) return res.status(401).json({ message: "Unauthorized" });
const { to, patientId } = req.body;
const { to, patientId, purpose } = req.body;
if (!to || !patientId) return res.status(400).json({ message: "to and patientId are required" });
const settings = await storage.getTwilioSettings(userId);
@@ -149,7 +149,8 @@ router.post("/make-ai-call", async (req: Request, res: Response): Promise<any> =
} catch (err: any) {
return res.status(500).json({ message: err.message });
}
const webhookUrl = `${publicBaseUrl}/api/twilio/webhook/ai-voice?patientId=${patient.id}`;
const callPurpose = purpose === "recall" ? "recall" : "reminder";
const webhookUrl = `${publicBaseUrl}/api/twilio/webhook/ai-voice?patientId=${patient.id}&purpose=${callPurpose}`;
const client = getTwilioClient(settings.accountSid, settings.authToken);
const call = await client.calls.create({