- 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>
362 lines
13 KiB
TypeScript
362 lines
13 KiB
TypeScript
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();
|
|
|
|
function getTwilioClient(accountSid: string, authToken: string) {
|
|
return twilio(accountSid, authToken);
|
|
}
|
|
|
|
// GET /api/twilio/settings
|
|
router.get("/settings", async (req: Request, res: Response): Promise<any> => {
|
|
try {
|
|
const userId = req.user?.id;
|
|
if (!userId) return res.status(401).json({ message: "Unauthorized" });
|
|
|
|
const settings = await storage.getTwilioSettings(userId);
|
|
if (!settings) return res.status(200).json(null);
|
|
|
|
return res.status(200).json({
|
|
id: settings.id,
|
|
accountSid: settings.accountSid,
|
|
authToken: settings.authToken,
|
|
phoneNumber: settings.phoneNumber,
|
|
greetingMessage: settings.greetingMessage,
|
|
});
|
|
} catch (err) {
|
|
return res.status(500).json({ error: "Failed to fetch Twilio settings", details: String(err) });
|
|
}
|
|
});
|
|
|
|
// PUT /api/twilio/settings
|
|
router.put("/settings", async (req: Request, res: Response): Promise<any> => {
|
|
try {
|
|
const userId = req.user?.id;
|
|
if (!userId) return res.status(401).json({ message: "Unauthorized" });
|
|
|
|
const { accountSid, authToken, phoneNumber, greetingMessage } = req.body;
|
|
if (!accountSid?.trim() || !authToken?.trim() || !phoneNumber?.trim()) {
|
|
return res.status(400).json({ message: "accountSid, authToken, and phoneNumber are required" });
|
|
}
|
|
|
|
const settings = await storage.upsertTwilioSettings(userId, {
|
|
accountSid: accountSid.trim(),
|
|
authToken: authToken.trim(),
|
|
phoneNumber: phoneNumber.trim(),
|
|
greetingMessage: greetingMessage?.trim() || null,
|
|
});
|
|
|
|
return res.status(200).json({
|
|
id: settings.id,
|
|
accountSid: settings.accountSid,
|
|
authToken: settings.authToken,
|
|
phoneNumber: settings.phoneNumber,
|
|
greetingMessage: settings.greetingMessage,
|
|
});
|
|
} catch (err) {
|
|
return res.status(500).json({ error: "Failed to save Twilio settings", details: String(err) });
|
|
}
|
|
});
|
|
|
|
// POST /api/twilio/send-sms
|
|
router.post("/send-sms", async (req: Request, res: Response): Promise<any> => {
|
|
try {
|
|
const userId = req.user?.id;
|
|
if (!userId) return res.status(401).json({ message: "Unauthorized" });
|
|
|
|
const { to, message, patientId, startFlow } = req.body;
|
|
if (!to || !message) return res.status(400).json({ message: "to and message 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." });
|
|
}
|
|
|
|
const client = getTwilioClient(settings.accountSid, settings.authToken);
|
|
const twilioMsg = await client.messages.create({
|
|
body: message,
|
|
from: settings.phoneNumber,
|
|
to,
|
|
});
|
|
|
|
if (patientId) {
|
|
const pid = Number(patientId);
|
|
await storage.createCommunication({
|
|
patientId: pid,
|
|
userId,
|
|
channel: "sms",
|
|
direction: "outbound",
|
|
status: "sent",
|
|
body: message,
|
|
twilioSid: twilioMsg.sid,
|
|
});
|
|
// Set conversation stage based on which flow was started
|
|
if (startFlow === "new_patient") {
|
|
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);
|
|
}
|
|
}
|
|
|
|
return res.status(200).json({ sid: twilioMsg.sid, status: twilioMsg.status });
|
|
} catch (err: any) {
|
|
return res.status(500).json({ error: err.message || "Failed to send SMS" });
|
|
}
|
|
});
|
|
|
|
// 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 {
|
|
const userId = req.user?.id;
|
|
if (!userId) return res.status(401).json({ message: "Unauthorized" });
|
|
const templates = await storage.getTemplates(userId);
|
|
return res.status(200).json(templates);
|
|
} catch (err) {
|
|
return res.status(500).json({ error: "Failed to fetch templates", details: String(err) });
|
|
}
|
|
});
|
|
|
|
// PUT /api/twilio/templates/:key
|
|
router.put("/templates/:key", async (req: Request, res: Response): Promise<any> => {
|
|
try {
|
|
const userId = req.user?.id;
|
|
if (!userId) return res.status(401).json({ message: "Unauthorized" });
|
|
const { key } = req.params;
|
|
const { body } = req.body;
|
|
if (!key || !body?.trim()) return res.status(400).json({ message: "key and body are required" });
|
|
await storage.saveTemplate(userId, key, body.trim());
|
|
return res.status(200).json({ key, body: body.trim() });
|
|
} catch (err) {
|
|
return res.status(500).json({ error: "Failed to save template", details: String(err) });
|
|
}
|
|
});
|
|
|
|
// GET /api/twilio/after-hours-handoff
|
|
router.get("/after-hours-handoff", async (req: Request, res: Response): Promise<any> => {
|
|
try {
|
|
const userId = req.user?.id;
|
|
if (!userId) return res.status(401).json({ message: "Unauthorized" });
|
|
return res.status(200).json({ enabled: await getAfterHoursHandoff(userId) });
|
|
} catch (err) {
|
|
return res.status(500).json({ error: "Failed to get after-hours handoff state" });
|
|
}
|
|
});
|
|
|
|
// PUT /api/twilio/after-hours-handoff
|
|
router.put("/after-hours-handoff", async (req: Request, res: Response): Promise<any> => {
|
|
try {
|
|
const userId = req.user?.id;
|
|
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" });
|
|
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" });
|
|
}
|
|
});
|
|
|
|
// GET /api/twilio/ai-handoff/:patientId
|
|
router.get("/ai-handoff/:patientId", async (req: Request, res: Response): Promise<any> => {
|
|
try {
|
|
const userId = req.user?.id;
|
|
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: await getHandoff(userId, patientId) });
|
|
} catch (err) {
|
|
return res.status(500).json({ error: "Failed to get AI handoff state" });
|
|
}
|
|
});
|
|
|
|
// PUT /api/twilio/ai-handoff/:patientId
|
|
router.put("/ai-handoff/:patientId", async (req: Request, res: Response): Promise<any> => {
|
|
try {
|
|
const userId = req.user?.id;
|
|
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" });
|
|
const { enabled } = req.body;
|
|
if (typeof enabled !== "boolean") return res.status(400).json({ message: "enabled must be a boolean" });
|
|
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" });
|
|
}
|
|
});
|
|
|
|
// GET /api/twilio/recent-communications
|
|
router.get("/recent-communications", async (req: Request, res: Response): Promise<any> => {
|
|
try {
|
|
const userId = req.user?.id;
|
|
if (!userId) return res.status(401).json({ message: "Unauthorized" });
|
|
|
|
const limit = Math.min(50, parseInt(req.query.limit as string) || 20);
|
|
const communications = await storage.getRecentCommunicationsByUser(userId, limit);
|
|
return res.status(200).json(communications);
|
|
} catch (err) {
|
|
return res.status(500).json({ error: "Failed to fetch recent communications", details: String(err) });
|
|
}
|
|
});
|
|
|
|
// POST /api/twilio/make-call
|
|
router.post("/make-call", async (req: Request, res: Response): Promise<any> => {
|
|
try {
|
|
const userId = req.user?.id;
|
|
if (!userId) return res.status(401).json({ message: "Unauthorized" });
|
|
|
|
const { to, message, patientId } = req.body;
|
|
if (!to) return res.status(400).json({ message: "to is 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." });
|
|
}
|
|
|
|
const twimlMessage = message || "Hello, this is a call from your dental office.";
|
|
const twiml = `<Response><Say>${twimlMessage}</Say></Response>`;
|
|
|
|
const client = getTwilioClient(settings.accountSid, settings.authToken);
|
|
const call = await client.calls.create({
|
|
twiml,
|
|
from: settings.phoneNumber,
|
|
to,
|
|
});
|
|
|
|
if (patientId) {
|
|
await storage.createCommunication({
|
|
patientId: Number(patientId),
|
|
userId,
|
|
channel: "voice",
|
|
direction: "outbound",
|
|
status: "queued",
|
|
twilioSid: call.sid,
|
|
});
|
|
}
|
|
|
|
return res.status(200).json({ sid: call.sid, status: call.status });
|
|
} catch (err: any) {
|
|
return res.status(500).json({ error: err.message || "Failed to make call" });
|
|
}
|
|
});
|
|
|
|
export default router;
|