feat: reschedule-by-office batch SMS, AI follow-up toggle, date-shortcut fix, combined flow diagram
- Add Reschedule for Column button on schedule page with AI follow-up toggle (default on)
- Add POST /api/twilio/send-reschedule-batch — sends Reschedule by Office template, starts AI reschedule conversation per patient
- Add {officePhone} (office call-in number) and {twilioPhone} (SMS number) variable replacement in both batch endpoints
- Fix broken variable names in Reschedule by Office template ({office phone number) → {officePhone}, {Twilio phone number} → {twilioPhone})
- Fix reschedule-graph: when patient replies with date in same message as YES/NO (e.g. "ok, 5/18"), AI now checks day open and asks for time instead of asking "what day and time?"
- Fix twilio-webhooks: same date-shortcut logic for reminder flow — "no, 5/18" skips "when to reschedule?" and goes straight to day check
- Update LangGraph SVG: rename to Reminder & Reschedule Flow, combine both entry points (Reminder SMS + Reschedule SMS) into one diagram with date-shortcut annotations
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -837,8 +837,84 @@ export async function runRescheduleStep(
|
||||
return { reply, nextStage: "done" };
|
||||
}
|
||||
|
||||
// Patient confirmed they want to reschedule — move straight to datetime request
|
||||
// Patient confirmed they want to reschedule.
|
||||
// First check if they already included a date (and optionally time) in the same message.
|
||||
if (yes(t)) {
|
||||
const hasTime = messageHasTime(message);
|
||||
|
||||
if (hasTime) {
|
||||
// Try to parse full datetime from the message
|
||||
const parsed = await parseDatetimeFromMessage(message, apiKey);
|
||||
if (parsed) {
|
||||
const { date, startTime, displayLabel } = parsed;
|
||||
const dayCheck = await isOfficeDayOpen(date, userId);
|
||||
if (!dayCheck.open) {
|
||||
const datePart = displayLabel.split(" at ")[0] ?? displayLabel;
|
||||
const fallbacks: Record<string, string> = {
|
||||
English: `Our office is closed on ${datePart} (${dayCheck.displayDay}). Can you please choose another day?`,
|
||||
Spanish: `Nuestra oficina está cerrada el ${datePart} (${dayCheck.displayDay}). ¿Puede elegir otro día?`,
|
||||
Portuguese: `Nosso consultório está fechado em ${datePart} (${dayCheck.displayDay}). Pode escolher outro dia?`,
|
||||
Mandarin: `我们诊所在 ${datePart}(${dayCheck.displayDay})不开放。请您选择另一天好吗?`,
|
||||
Cantonese: `我們診所在 ${datePart}(${dayCheck.displayDay})不開放。請您選擇另一天好嗎?`,
|
||||
Arabic: `مكتبنا مغلق في ${datePart} (${dayCheck.displayDay}). هل يمكنك اختيار يوم آخر؟`,
|
||||
"Haitian Creole": `Biwo nou fèmen nan ${datePart} (${dayCheck.displayDay}). Tanpri chwazi yon lòt jou?`,
|
||||
};
|
||||
return { reply: fallbacks[lang] ?? fallbacks["English"]!, nextStage: "asked_reschedule_datetime" };
|
||||
}
|
||||
setPendingReschedule(userId, patientId, { newDate: date, dayLabel: displayLabel, startTime });
|
||||
const fallbacks: Record<string, string> = {
|
||||
English: `Just to confirm — do you prefer ${displayLabel}?`,
|
||||
Spanish: `Solo para confirmar — ¿prefiere el ${displayLabel}?`,
|
||||
Portuguese: `Só para confirmar — você prefere ${displayLabel}?`,
|
||||
Mandarin: `确认一下——您希望的时间是 ${displayLabel} 吗?`,
|
||||
Cantonese: `確認一下——您希望的時間是 ${displayLabel} 嗎?`,
|
||||
Arabic: `فقط للتأكيد — هل تفضل ${displayLabel}؟`,
|
||||
"Haitian Creole": `Jis pou konfime — èske ou prefere ${displayLabel}?`,
|
||||
};
|
||||
const confirmReply = await llmReply(
|
||||
`You are a friendly dental office AI assistant. The patient mentioned a date/time that you interpreted as "${displayLabel}". Ask them in ${lang} to confirm. 1 sentence, natural and friendly. No formatting.`,
|
||||
`Patient said: "${message}"`, fallbacks[lang] ?? fallbacks["English"]!, apiKey,
|
||||
);
|
||||
return { reply: confirmReply, nextStage: "asked_reschedule_confirm_datetime" };
|
||||
}
|
||||
}
|
||||
|
||||
// Try to parse a date-only from the message
|
||||
const parsedDate = await parseDateOnlyFromMessage(message, apiKey);
|
||||
if (parsedDate) {
|
||||
const { date, dateLabel } = parsedDate;
|
||||
const dayCheck = await isOfficeDayOpen(date, userId);
|
||||
if (!dayCheck.open) {
|
||||
const fallbacks: Record<string, string> = {
|
||||
English: `Our office is closed on ${dateLabel} (${dayCheck.displayDay}). Can you please choose another day?`,
|
||||
Spanish: `Nuestra oficina está cerrada el ${dateLabel} (${dayCheck.displayDay}). ¿Puede elegir otro día?`,
|
||||
Portuguese: `Nosso consultório está fechado em ${dateLabel} (${dayCheck.displayDay}). Pode escolher outro dia?`,
|
||||
Mandarin: `我们诊所在 ${dateLabel}(${dayCheck.displayDay})不开放。请您选择另一天好吗?`,
|
||||
Cantonese: `我們診所在 ${dateLabel}(${dayCheck.displayDay})不開放。請您選擇另一天好嗎?`,
|
||||
Arabic: `مكتبنا مغلق في ${dateLabel} (${dayCheck.displayDay}). هل يمكنك اختيار يوم آخر؟`,
|
||||
"Haitian Creole": `Biwo nou fèmen nan ${dateLabel} (${dayCheck.displayDay}). Tanpri chwazi yon lòt jou?`,
|
||||
};
|
||||
return { reply: fallbacks[lang] ?? fallbacks["English"]!, nextStage: "asked_reschedule_datetime" };
|
||||
}
|
||||
// Day is open — save and ask for time
|
||||
setPendingReschedule(userId, patientId, { newDate: date, dayLabel: dateLabel });
|
||||
const fallbacks: Record<string, string> = {
|
||||
English: `What time do you prefer on ${dateLabel}?`,
|
||||
Spanish: `¿A qué hora prefiere el ${dateLabel}?`,
|
||||
Portuguese: `Que horário você prefere em ${dateLabel}?`,
|
||||
Mandarin: `您希望在 ${dateLabel} 几点?`,
|
||||
Cantonese: `您希望在 ${dateLabel} 幾點?`,
|
||||
Arabic: `ما الوقت الذي تفضله في ${dateLabel}؟`,
|
||||
"Haitian Creole": `Ki lè ou prefere nan ${dateLabel}?`,
|
||||
};
|
||||
const askTimeReply = await llmReply(
|
||||
`You are a friendly dental office assistant. The patient wants ${dateLabel}. Ask them in ${lang} what time they prefer on that day. 1 sentence, no formatting.`,
|
||||
`Patient wants ${dateLabel} but gave no time.`, fallbacks[lang] ?? fallbacks["English"]!, apiKey,
|
||||
);
|
||||
return { reply: askTimeReply, nextStage: "asked_reschedule_time_for_date" };
|
||||
}
|
||||
|
||||
// No date found — ask for day and time
|
||||
const fallbacks: Record<string, string> = {
|
||||
English: "What day and time would you like? For example: 'Monday at 10am' or 'next Tuesday afternoon'.",
|
||||
Spanish: "¿Qué día y hora prefiere? Por ejemplo: 'lunes a las 10am' o 'martes por la tarde'.",
|
||||
|
||||
@@ -293,6 +293,20 @@ router.post("/webhook/sms", async (req: Request, res: Response): Promise<any> =>
|
||||
await saveOutbound(patient.id, introText);
|
||||
}
|
||||
|
||||
// If patient said "no" but already included a date (e.g. "no, 5/18"),
|
||||
// skip "when to reschedule?" and go straight to date processing
|
||||
if (intent === "no") {
|
||||
const hasDateInMessage =
|
||||
/\b\d{1,2}[\/\-]\d{1,2}\b/.test(Body) ||
|
||||
/\b(monday|tuesday|wednesday|thursday|friday|saturday|sunday|tomorrow|next week)\b/i.test(Body);
|
||||
if (hasDateInMessage) {
|
||||
const { reply: rescheduleReply, nextStage: rescheduleNextStage } = await runRescheduleStep(
|
||||
Body, "asked_reschedule_datetime", language, patient.id, aiSettings.apiKey, patient.userId
|
||||
);
|
||||
return reply(rescheduleReply, rescheduleNextStage);
|
||||
}
|
||||
}
|
||||
|
||||
// Send message 2 (yes/no response) via TwiML — queued SECOND
|
||||
return reply(intentReply, nextStage);
|
||||
}
|
||||
@@ -313,6 +327,20 @@ router.post("/webhook/sms", async (req: Request, res: Response): Promise<any> =>
|
||||
if (intent === "no") nextStage = "asked_reschedule_datetime";
|
||||
else if (intent === "wants_appointment") nextStage = "asked_new_or_existing";
|
||||
else nextStage = "done";
|
||||
|
||||
// If patient said "no" but already included a date, skip straight to date processing
|
||||
if (intent === "no") {
|
||||
const hasDateInMessage =
|
||||
/\b\d{1,2}[\/\-]\d{1,2}\b/.test(Body) ||
|
||||
/\b(monday|tuesday|wednesday|thursday|friday|saturday|sunday|tomorrow|next week)\b/i.test(Body);
|
||||
if (hasDateInMessage) {
|
||||
const { reply: rescheduleReply, nextStage: rescheduleNextStage } = await runRescheduleStep(
|
||||
Body, "asked_reschedule_datetime", language, patient.id, aiSettings.apiKey, patient.userId
|
||||
);
|
||||
return reply(rescheduleReply, rescheduleNextStage);
|
||||
}
|
||||
}
|
||||
|
||||
return reply(aiReply, nextStage);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -132,7 +132,7 @@ router.post("/send-reminders-batch", async (req: Request, res: Response): Promis
|
||||
return res.status(400).json({ message: "Twilio is not configured. Please add your Twilio credentials in Settings." });
|
||||
}
|
||||
|
||||
// Resolve office name, address, and reminder SMS template
|
||||
// Resolve office name, address, phone, and reminder SMS template
|
||||
const officeContact = await storage.getOfficeContact(userId);
|
||||
const officeName = (officeContact as any)?.officeName?.trim() || "";
|
||||
const officeAddress = [
|
||||
@@ -141,6 +141,7 @@ router.post("/send-reminders-batch", async (req: Request, res: Response): Promis
|
||||
(officeContact as any)?.state?.trim(),
|
||||
(officeContact as any)?.zipCode?.trim(),
|
||||
].filter(Boolean).join(", ");
|
||||
const officePhone = (officeContact as any)?.phoneNumber?.trim() || "";
|
||||
const chatTemplates = await storage.getAiChatTemplates(userId);
|
||||
|
||||
const DEFAULT_REMINDER_SMS =
|
||||
@@ -194,6 +195,8 @@ router.post("/send-reminders-batch", async (req: Request, res: Response): Promis
|
||||
.replace(/\{firstName\}/g, patient.firstName ?? "")
|
||||
.replace(/\{officeName\}/g, officeName)
|
||||
.replace(/\{officeAddress\}/g, officeAddress)
|
||||
.replace(/\{officePhone\}/g, officePhone)
|
||||
.replace(/\{twilioPhone\}/g, settings.phoneNumber)
|
||||
.replace(/\{appointmentDate\}/g, apptDate)
|
||||
.replace(/\{appointmentTime\}/g, apptTime)
|
||||
.replace(/\{date\}/g, apptDate)
|
||||
@@ -229,6 +232,122 @@ router.post("/send-reminders-batch", async (req: Request, res: Response): Promis
|
||||
}
|
||||
});
|
||||
|
||||
// POST /api/twilio/send-reschedule-batch
|
||||
router.post("/send-reschedule-batch", async (req: Request, res: Response): Promise<any> => {
|
||||
try {
|
||||
const userId = req.user?.id;
|
||||
if (!userId) return res.status(401).json({ message: "Unauthorized" });
|
||||
|
||||
const { date, staffIds, aiFollowUp = true } = req.body as { date: string; staffIds: number[]; aiFollowUp?: boolean };
|
||||
if (!date || !Array.isArray(staffIds) || staffIds.length === 0) {
|
||||
return res.status(400).json({ message: "date and staffIds are required" });
|
||||
}
|
||||
|
||||
const settings = await storage.getTwilioSettings(userId);
|
||||
if (!settings) {
|
||||
return res.status(400).json({ message: "Twilio is not configured. Please add your Twilio credentials in Settings." });
|
||||
}
|
||||
|
||||
// Find the "Reschedule by office" template from the SMS template list
|
||||
const templateList = await storage.getSmsTemplateList(userId);
|
||||
const rescheduleTemplate = templateList.find((t) =>
|
||||
t.name.toLowerCase().includes("reschedule") && t.name.toLowerCase().includes("office")
|
||||
) || templateList.find((t) => t.name.toLowerCase().includes("reschedule"));
|
||||
|
||||
const DEFAULT_RESCHEDULE_SMS =
|
||||
"Hi {firstName}, this is {officeName}. We need to reschedule your appointment. Please reply to let us know your availability. Thank you!";
|
||||
const templateBody = rescheduleTemplate?.body?.trim() || DEFAULT_RESCHEDULE_SMS;
|
||||
|
||||
const officeContact = await storage.getOfficeContact(userId);
|
||||
const officeName = (officeContact as any)?.officeName?.trim() || "";
|
||||
const officeAddress = [
|
||||
(officeContact as any)?.streetAddress?.trim(),
|
||||
(officeContact as any)?.city?.trim(),
|
||||
(officeContact as any)?.state?.trim(),
|
||||
(officeContact as any)?.zipCode?.trim(),
|
||||
].filter(Boolean).join(", ");
|
||||
const officePhone = (officeContact as any)?.phoneNumber?.trim() || "";
|
||||
|
||||
const dayStart = new Date(date);
|
||||
dayStart.setUTCHours(0, 0, 0, 0);
|
||||
const dayEnd = new Date(date);
|
||||
dayEnd.setUTCHours(23, 59, 59, 999);
|
||||
|
||||
const appointments = await db.appointment.findMany({
|
||||
where: {
|
||||
staffId: { in: staffIds },
|
||||
date: { gte: dayStart, lte: dayEnd },
|
||||
status: { not: "cancelled" },
|
||||
patient: { userId },
|
||||
},
|
||||
include: {
|
||||
patient: { select: { id: true, firstName: true, phone: true } },
|
||||
},
|
||||
orderBy: { startTime: "asc" },
|
||||
});
|
||||
|
||||
const months = ["January","February","March","April","May","June","July","August","September","October","November","December"];
|
||||
const formatApptDate = (d: Date | string) => {
|
||||
const dt = new Date(d);
|
||||
return `${months[dt.getUTCMonth()]} ${dt.getUTCDate()}, ${dt.getUTCFullYear()}`;
|
||||
};
|
||||
|
||||
const client = getTwilioClient(settings.accountSid, settings.authToken);
|
||||
let sent = 0;
|
||||
let skipped = 0;
|
||||
const seen = new Set<number>();
|
||||
|
||||
for (const appt of appointments) {
|
||||
const patient = appt.patient;
|
||||
if (!patient?.phone || seen.has(patient.id)) { skipped++; continue; }
|
||||
seen.add(patient.id);
|
||||
|
||||
const apptDate = formatApptDate(appt.date);
|
||||
const apptTime = typeof appt.startTime === "string"
|
||||
? appt.startTime.substring(0, 5)
|
||||
: String(appt.startTime);
|
||||
|
||||
const message = templateBody
|
||||
.replace(/\{firstName\}/g, patient.firstName ?? "")
|
||||
.replace(/\{officeName\}/g, officeName)
|
||||
.replace(/\{officeAddress\}/g, officeAddress)
|
||||
.replace(/\{officePhone\}/g, officePhone)
|
||||
.replace(/\{twilioPhone\}/g, settings.phoneNumber)
|
||||
.replace(/\{appointmentDate\}/g, apptDate)
|
||||
.replace(/\{appointmentTime\}/g, apptTime)
|
||||
.replace(/\{date\}/g, apptDate)
|
||||
.replace(/\{time\}/g, apptTime);
|
||||
|
||||
try {
|
||||
const twilioMsg = await client.messages.create({
|
||||
body: message,
|
||||
from: settings.phoneNumber,
|
||||
to: patient.phone,
|
||||
});
|
||||
await storage.createCommunication({
|
||||
patientId: patient.id,
|
||||
userId,
|
||||
channel: "sms",
|
||||
direction: "outbound",
|
||||
status: "sent",
|
||||
body: message,
|
||||
twilioSid: twilioMsg.sid,
|
||||
});
|
||||
if (aiFollowUp) {
|
||||
await startRescheduleConversation(userId, patient.id);
|
||||
}
|
||||
sent++;
|
||||
} catch {
|
||||
skipped++;
|
||||
}
|
||||
}
|
||||
|
||||
return res.status(200).json({ sent, skipped });
|
||||
} catch (err: any) {
|
||||
return res.status(500).json({ error: err.message || "Failed to send reschedule messages" });
|
||||
}
|
||||
});
|
||||
|
||||
// GET /api/twilio/sms-template-list
|
||||
router.get("/sms-template-list", async (req: Request, res: Response): Promise<any> => {
|
||||
try {
|
||||
|
||||
Reference in New Issue
Block a user