diff --git a/apps/Backend/src/ai/reschedule-graph.ts b/apps/Backend/src/ai/reschedule-graph.ts
index d8e66bc7..84ae8138 100644
--- a/apps/Backend/src/ai/reschedule-graph.ts
+++ b/apps/Backend/src/ai/reschedule-graph.ts
@@ -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 = {
+ 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 = {
+ 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 = {
+ 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 = {
+ 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 = {
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'.",
diff --git a/apps/Backend/src/routes/twilio-webhooks.ts b/apps/Backend/src/routes/twilio-webhooks.ts
index 125c3fa5..23a6f80b 100644
--- a/apps/Backend/src/routes/twilio-webhooks.ts
+++ b/apps/Backend/src/routes/twilio-webhooks.ts
@@ -293,6 +293,20 @@ router.post("/webhook/sms", async (req: Request, res: Response): Promise =>
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 =>
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);
}
}
diff --git a/apps/Backend/src/routes/twilio.ts b/apps/Backend/src/routes/twilio.ts
index 8f0a0a49..7d47a103 100644
--- a/apps/Backend/src/routes/twilio.ts
+++ b/apps/Backend/src/routes/twilio.ts
@@ -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 => {
+ 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();
+
+ 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 => {
try {
diff --git a/apps/Frontend/src/components/settings/ai-chat-settings-card.tsx b/apps/Frontend/src/components/settings/ai-chat-settings-card.tsx
index 80883949..9334eda9 100644
--- a/apps/Frontend/src/components/settings/ai-chat-settings-card.tsx
+++ b/apps/Frontend/src/components/settings/ai-chat-settings-card.tsx
@@ -57,64 +57,61 @@ function newId() {
return Date.now().toString(36) + Math.random().toString(36).slice(2);
}
-// ─── LangGraph flow diagram (SVG) ─────────────────────────────────────────────
+// ─── Combined Reminder & Reschedule flow diagram (SVG) ────────────────────────
function LangGraphFlow() {
- const W = 640;
- const cx = 320; // top-section center
+ const W = 700;
+ const cx = 350;
const nW = 210;
- const nx = cx - nW / 2;
+ const nx = cx - nW / 2; // 245
- // ── Top sequence ──────────────────────────────────────────────────────────
- const n1y = 16, n1h = 52;
- const n2y = 84, n2h = 52;
- const n3y = 152, n3h = 76; // AI classifies + sends MSG 1
+ // ── Dual entry nodes ──────────────────────────────────────────────────────
+ const entryW = 192;
+ const lEntryCx = 155;
+ const rEntryCx = 545;
+ const e1y = 14, e1h = 56;
- const forkHY = n3y + n3h + 22; // 250
+ // Merge connector (horizontal line where both branches meet)
+ const mergeY = e1y + e1h + 14; // 84
- // Branch centers
- const lcx = 138; // YES (left)
- const rcx = 490; // NO (right)
+ // ── Center sequence ───────────────────────────────────────────────────────
+ const n2y = mergeY + 14, n2h = 52; // Patient replies y=98
+ const n3y = n2y + n2h + 14, n3h = 84; // AI classifies y=164
+
+ const forkHY = n3y + n3h + 22; // 270
+
+ // ── Branch centers ────────────────────────────────────────────────────────
+ const lcx = 140; // YES (left)
+ const rcx = 490; // NO / Reschedule (right)
// ── YES branch ────────────────────────────────────────────────────────────
- const yes1y = forkHY + 50; // 300
- const yes1h = 78;
- const yes2y = yes1y + yes1h + 14; // 392
- const yes2h = 52;
+ const yesW = 195;
+ const yes1y = forkHY + 50, yes1h = 88; // y=320
+ const yes2y = yes1y + yes1h + 12, yes2h = 52; // y=460
- // ── NO branch – step-by-step ──────────────────────────────────────────────
- const noW = 226;
- const no1y = forkHY + 50; // 300 — "When would you like to reschedule?"
- const no1h = 62;
- const no2y = no1y + no1h + 14; // 376 — patient gives date + day-open check
- const no2h = 80;
- const no3y = no2y + no2h + 14; // 470 — "What time on [date]?"
- const no3h = 62;
- const no4y = no3y + no3h + 14; // 546 — patient gives time + hours check
- const no4h = 80;
- const no5y = no4y + no4h + 14; // 640 — "Just to confirm — [date at time]?"
- const no5h = 62;
- const no6y = no5y + no5h + 14; // 716 — patient confirms YES / NO
- const no6h = 72;
- const no7y = no6y + no6h + 14; // 802 — slot availability check
- const no7h = 80;
- const no8y = no7y + no7h + 14; // 896 — DB move + AI badge
- const no8h = 78;
- const no9y = no8y + no8h + 14; // 988 — patient thanks / closing
- const no9h = 54;
+ // ── NO / Reschedule branch ────────────────────────────────────────────────
+ const noW = 208;
+ const failX = rcx + noW / 2 + 8; // 602
+ const failW = 92;
- const totalH = Math.max(yes2y + yes2h, no9y + no9h) + 24;
+ const no1y = forkHY + 50, no1h = 84; // y=320 ask date / shortcut
+ const no2y = no1y + no1h + 14, no2h = 82; // y=418 day check
+ const no3y = no2y + no2h + 14, no3h = 64; // y=514 ask time
+ const no4y = no3y + no3h + 14, no4h = 82; // y=592 time check
+ const no5y = no4y + no4h + 14, no5h = 64; // y=688 confirm datetime
+ const no6y = no5y + no5h + 14, no6h = 74; // y=766 patient YES/NO
+ const no7y = no6y + no6h + 14, no7h = 82; // y=854 slot check
+ const no8y = no7y + no7h + 14, no8h = 80; // y=950 move appointment
+ const no9y = no8y + no8h + 14, no9h = 54; // y=1044 patient thanks
- // Helper: failure annotation box to the right of a check node
- const failX = rcx + noW / 2 + 8;
- const failW = 122;
+ const totalH = Math.max(yes2y + yes2h, no9y + no9h) + 28;
return (
@@ -892,7 +914,7 @@ export function AiChatSettingsCard() {
- Appointment Reminder Flow
+ Reminder & Reschedule Flow
New Patient / After-Hours Flow
diff --git a/apps/Frontend/src/pages/appointments-page.tsx b/apps/Frontend/src/pages/appointments-page.tsx
index cb7798e2..4a53cec8 100755
--- a/apps/Frontend/src/pages/appointments-page.tsx
+++ b/apps/Frontend/src/pages/appointments-page.tsx
@@ -166,6 +166,8 @@ export default function AppointmentsPage() {
const [isSendingReminders, setIsSendingReminders] = useState(false);
const [reminderAiFollowUp, setReminderAiFollowUp] = useState(true);
const [selectedRescheduleColumns, setSelectedRescheduleColumns] = useState>(new Set());
+ const [isSendingReschedule, setIsSendingReschedule] = useState(false);
+ const [rescheduleAiFollowUp, setRescheduleAiFollowUp] = useState(true);
const [selectedDownloadPdfColumns, setSelectedDownloadPdfColumns] = useState>(new Set());
const [isDownloadingClaimPdfs, setIsDownloadingClaimPdfs] = useState(false);
const [columnLabels, setColumnLabels] = useState>({});
@@ -1260,6 +1262,27 @@ export default function AppointmentsPage() {
}
};
+ const handleSendRescheduleForColumn = async () => {
+ if (!user || selectedRescheduleColumns.size === 0) return;
+ setIsSendingReschedule(true);
+ try {
+ const res = await apiRequest("POST", "/api/twilio/send-reschedule-batch", {
+ date: formattedSelectedDate,
+ staffIds: Array.from(selectedRescheduleColumns),
+ aiFollowUp: rescheduleAiFollowUp,
+ });
+ const { sent, skipped } = await res.json();
+ toast({
+ title: "Reschedule Messages Sent",
+ description: `Sent ${sent} message${sent !== 1 ? "s" : ""}${skipped > 0 ? `, skipped ${skipped} (no phone)` : ""}.`,
+ });
+ } catch (err: any) {
+ toast({ title: "Failed to Send Reschedule Messages", description: err?.message ?? String(err), variant: "destructive" });
+ } finally {
+ setIsSendingReschedule(false);
+ }
+ };
+
const handleDownloadClaimPdfs = async () => {
if (!user || selectedDownloadPdfColumns.size === 0) return;
const staffIdsParam = Array.from(selectedDownloadPdfColumns).join(",");
@@ -1458,10 +1481,21 @@ export default function AppointmentsPage() {
{/* Reschedule for Column section */}
{staffMembers.map((staff, index) => (
))}
+
+
+ AI follow up
+
{/* Download Claim PDF for Column section */}