feat: schedule page SMS reminders with AI follow-up and reschedule column
- Add Text Reminder for Column button with per-column checkboxes and AI follow-up toggle (default on)
- Batch reminder endpoint resolves {firstName}, {officeName}, {appointmentDate}, {appointmentTime} from AI chat templates
- Add Reschedule for Column UI (logic TBD)
- Move Download Claim PDF for Column below Reschedule for Column
- Add reminderSms template field to AI Chat Settings with variable hints
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -53,8 +53,8 @@ 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, rescheduleGreeting } = req.body;
|
||||
await storage.saveAiChatTemplates(userId, { reminderGreeting, newPatientGreeting, generalFallback, rescheduleGreeting });
|
||||
const { reminderGreeting, newPatientGreeting, generalFallback, rescheduleGreeting, reminderSms } = req.body;
|
||||
await storage.saveAiChatTemplates(userId, { reminderGreeting, newPatientGreeting, generalFallback, rescheduleGreeting, reminderSms });
|
||||
const updated = await storage.getAiChatTemplates(userId);
|
||||
return res.status(200).json(updated);
|
||||
} catch (err) {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import express, { Request, Response } from "express";
|
||||
import twilio from "twilio";
|
||||
import { storage } from "../storage";
|
||||
import { prisma as db } from "@repo/db/client";
|
||||
import { getHandoff, setHandoff, resetConversation, startNewPatientConversation, startRescheduleConversation, getAfterHoursHandoff, setAfterHoursHandoff } from "../ai/aiHandoffStore";
|
||||
|
||||
const router = express.Router();
|
||||
@@ -97,7 +98,10 @@ router.post("/send-sms", async (req: Request, res: Response): Promise<any> => {
|
||||
await startNewPatientConversation(userId, pid);
|
||||
} else if (startFlow === "reschedule") {
|
||||
await startRescheduleConversation(userId, pid);
|
||||
} else if (startFlow === "no_follow_up") {
|
||||
// AI follow-up disabled — leave existing stage untouched
|
||||
} else {
|
||||
// "reminder" or unspecified → start reminder AI flow
|
||||
await resetConversation(userId, pid);
|
||||
}
|
||||
}
|
||||
@@ -108,6 +112,116 @@ router.post("/send-sms", async (req: Request, res: Response): Promise<any> => {
|
||||
}
|
||||
});
|
||||
|
||||
// POST /api/twilio/send-reminders-batch
|
||||
router.post("/send-reminders-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." });
|
||||
}
|
||||
|
||||
// Resolve office name and reminder SMS template
|
||||
const officeContact = await storage.getOfficeContact(userId);
|
||||
const officeName = (officeContact as any)?.officeName?.trim() || "";
|
||||
const chatTemplates = await storage.getAiChatTemplates(userId);
|
||||
|
||||
const DEFAULT_REMINDER_SMS =
|
||||
"Hi {firstName}, this is a reminder from {officeName}. You have an appointment on {appointmentDate} at {appointmentTime}. Please reply YES to confirm or NO to reschedule. Thank you!";
|
||||
const templateBody = chatTemplates.reminderSms?.trim() || DEFAULT_REMINDER_SMS;
|
||||
|
||||
// Fetch appointments for the selected staff columns on the given date
|
||||
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" },
|
||||
});
|
||||
|
||||
// Format a date string as "May 9, 2026"
|
||||
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;
|
||||
|
||||
// Deduplicate by patientId — send at most one reminder per patient per run
|
||||
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(/\{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 resetConversation(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 reminders" });
|
||||
}
|
||||
});
|
||||
|
||||
// GET /api/twilio/templates
|
||||
router.get("/templates", async (req: Request, res: Response): Promise<any> => {
|
||||
try {
|
||||
|
||||
@@ -67,10 +67,11 @@ export const twilioStorage = {
|
||||
newPatientGreeting: all["_ai_chat_new_patient_greeting"] ?? "",
|
||||
generalFallback: all["_ai_chat_general_fallback"] ?? "",
|
||||
rescheduleGreeting: all["_ai_chat_reschedule_greeting"] ?? "",
|
||||
reminderSms: all["_ai_chat_reminder_sms"] ?? "",
|
||||
};
|
||||
},
|
||||
|
||||
async saveAiChatTemplates(userId: number, templates: { reminderGreeting?: string; newPatientGreeting?: string; generalFallback?: string; rescheduleGreeting?: string }) {
|
||||
async saveAiChatTemplates(userId: number, templates: { reminderGreeting?: string; newPatientGreeting?: string; generalFallback?: string; rescheduleGreeting?: string; reminderSms?: string }) {
|
||||
const settings = await db.twilioSettings.findUnique({ where: { userId } });
|
||||
const existing = (settings?.templates as Record<string, string>) || {};
|
||||
const updated: Record<string, string> = { ...existing };
|
||||
@@ -78,6 +79,7 @@ export const twilioStorage = {
|
||||
if (templates.newPatientGreeting !== undefined) updated["_ai_chat_new_patient_greeting"] = templates.newPatientGreeting;
|
||||
if (templates.generalFallback !== undefined) updated["_ai_chat_general_fallback"] = templates.generalFallback;
|
||||
if (templates.rescheduleGreeting !== undefined) updated["_ai_chat_reschedule_greeting"] = templates.rescheduleGreeting;
|
||||
if (templates.reminderSms !== undefined) updated["_ai_chat_reminder_sms"] = templates.reminderSms;
|
||||
return db.twilioSettings.upsert({
|
||||
where: { userId },
|
||||
update: { templates: updated },
|
||||
|
||||
Reference in New Issue
Block a user