feat: add Twilio SMS/call integration with settings, templates, and conversation history

This commit is contained in:
Gitead
2026-05-02 20:18:58 -04:00
parent b73b8c97c6
commit 5689269690
17 changed files with 770 additions and 196 deletions

View File

@@ -29,6 +29,7 @@
"passport-local": "^1.0.0",
"pdfkit": "^0.17.2",
"socket.io": "^4.8.1",
"twilio": "^6.0.0",
"ws": "^8.18.0",
"zod": "^3.24.2",
"zod-validation-error": "^3.4.0"

View File

@@ -4,6 +4,7 @@ import routes from "./routes";
import { errorHandler } from "./middlewares/error.middleware";
import { apiLogger } from "./middlewares/logger.middleware";
import authRoutes from "./routes/auth";
import twilioWebhookRoutes from "./routes/twilio-webhooks";
import { authenticateJWT } from "./middlewares/auth.middleware";
import dotenv from "dotenv";
import { startBackupCron } from "./cron/backupCheck";
@@ -72,6 +73,9 @@ app.use(
app.use("/uploads", express.static(path.join(process.cwd(), "uploads")));
app.use("/api/auth", authRoutes);
// Twilio webhooks are public — Twilio sends no JWT token
app.use("/api/twilio", express.urlencoded({ extended: false }), twilioWebhookRoutes);
// All other API routes require JWT
app.use("/api", authenticateJWT, routes);
app.use(errorHandler);

View File

@@ -24,6 +24,7 @@ import cloudStorageRoutes from "./cloud-storage";
import paymentsReportsRoutes from "./payments-reports";
import exportPaymentsReportsRoutes from "./export-payments-reports";
import jobMonitorRoutes from "./job-monitor";
import twilioRoutes from "./twilio";
const router = Router();
@@ -52,5 +53,6 @@ router.use("/cloud-storage", cloudStorageRoutes);
router.use("/payments-reports", paymentsReportsRoutes);
router.use("/export-payments-reports", exportPaymentsReportsRoutes);
router.use("/job-monitor", jobMonitorRoutes);
router.use("/twilio", twilioRoutes);
export default router;

View File

@@ -374,4 +374,17 @@ router.get(
}
);
// GET /api/patients/:id/communications
router.get("/:id/communications", async (req: Request, res: Response): Promise<any> => {
try {
const patientId = parseInt(req.params.id);
if (isNaN(patientId)) return res.status(400).json({ message: "Invalid patient ID" });
const communications = await storage.getCommunicationsByPatient(patientId);
return res.status(200).json(communications);
} catch (err) {
return res.status(500).json({ error: "Failed to fetch communications", details: String(err) });
}
});
export default router;

View File

@@ -0,0 +1,107 @@
import express, { Request, Response } from "express";
import { storage } from "../storage";
import { prisma as db } from "@repo/db/client";
const router = express.Router();
// POST /api/twilio/webhook/sms (Twilio posts inbound SMS here — no auth)
router.post("/webhook/sms", async (req: Request, res: Response): Promise<any> => {
try {
const { From, Body, MessageSid } = req.body;
const normalizedFrom = (From || "").replace(/\D/g, "");
const allPatients = await db.patient.findMany({ select: { id: true, phone: true } });
const patient = allPatients.find(
(p: { id: number; phone: string | null }) => p.phone && p.phone.replace(/\D/g, "") === normalizedFrom
);
if (patient) {
await storage.createCommunication({
patientId: patient.id,
channel: "sms",
direction: "inbound",
status: "delivered",
body: Body,
twilioSid: MessageSid,
});
}
res.set("Content-Type", "text/xml");
return res.send("<Response></Response>");
} catch (err) {
res.set("Content-Type", "text/xml");
return res.send("<Response></Response>");
}
});
// POST /api/twilio/webhook/voice (Twilio posts here when someone calls — no auth)
router.post("/webhook/voice", async (req: Request, res: Response): Promise<any> => {
try {
const { From, CallSid } = req.body;
const normalizedFrom = (From || "").replace(/\D/g, "");
const allPatients = await db.patient.findMany({ select: { id: true, phone: true, userId: true } });
const patient = allPatients.find(
(p: { id: number; phone: string | null; userId: number }) => p.phone && p.phone.replace(/\D/g, "") === normalizedFrom
);
let greeting = "Thank you for calling. Please leave a message after the beep and we will get back to you shortly.";
if (patient) {
const settings = await storage.getTwilioSettings(patient.userId);
if (settings?.greetingMessage?.trim()) {
greeting = settings.greetingMessage.trim();
}
}
if (patient) {
await storage.createCommunication({
patientId: patient.id,
channel: "voice",
direction: "inbound",
status: "completed",
body: "(Inbound call — voicemail below)",
twilioSid: CallSid,
});
}
const recordingCallbackUrl = `${process.env.BASE_URL || "https://communitydentistsoflowell.mydentalofficemanagement.com"}/api/twilio/webhook/voice-recording`;
const twiml = `<?xml version="1.0" encoding="UTF-8"?>
<Response>
<Say voice="alice">${greeting}</Say>
<Record maxLength="120" action="${recordingCallbackUrl}" transcribeCallback="${recordingCallbackUrl}" playBeep="true"/>
<Say voice="alice">We did not receive a recording. Goodbye.</Say>
</Response>`;
res.set("Content-Type", "text/xml");
return res.send(twiml);
} catch (err) {
res.set("Content-Type", "text/xml");
return res.send(`<?xml version="1.0" encoding="UTF-8"?><Response><Say>Thank you for calling. Please try again later.</Say></Response>`);
}
});
// POST /api/twilio/webhook/voice-recording (Twilio posts recording URL here — no auth)
router.post("/webhook/voice-recording", async (req: Request, res: Response): Promise<any> => {
try {
const { CallSid, RecordingUrl } = req.body;
if (RecordingUrl && CallSid) {
const comm = await db.communication.findFirst({ where: { twilioSid: CallSid } });
if (comm) {
await db.communication.update({
where: { id: comm.id },
data: { body: `Voicemail: ${RecordingUrl}.mp3` },
});
}
}
res.set("Content-Type", "text/xml");
return res.send("<Response></Response>");
} catch (err) {
res.set("Content-Type", "text/xml");
return res.send("<Response></Response>");
}
});
export default router;

View File

@@ -0,0 +1,183 @@
import express, { Request, Response } from "express";
import twilio from "twilio";
import { storage } from "../storage";
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 } = 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) {
await storage.createCommunication({
patientId: Number(patientId),
userId,
channel: "sms",
direction: "outbound",
status: "sent",
body: message,
twilioSid: twilioMsg.sid,
});
}
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" });
}
});
// 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/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;

View File

@@ -17,6 +17,7 @@ import { paymentsReportsStorage } from './payments-reports-storage';
import { patientDocumentsStorage } from './patientDocuments-storage';
import * as exportPaymentsReportsStorage from "./export-payments-reports-storage";
import { cronJobLogStorage } from "./cron-job-log-storage";
import { twilioStorage } from "./twilio-storage";
export const storage = {
@@ -37,6 +38,7 @@ export const storage = {
...patientDocumentsStorage,
...exportPaymentsReportsStorage,
...cronJobLogStorage,
...twilioStorage,
};

View File

@@ -0,0 +1,72 @@
import { prisma as db } from "@repo/db/client";
export type TwilioSettingsData = {
accountSid: string;
authToken: string;
phoneNumber: string;
greetingMessage?: string | null;
};
export type CommunicationCreateData = {
patientId: number;
userId?: number;
channel: "sms" | "voice";
direction: "outbound" | "inbound";
status: "queued" | "sent" | "delivered" | "failed" | "completed" | "busy" | "no_answer";
body?: string;
callDuration?: number;
twilioSid?: string;
};
export const twilioStorage = {
async getTwilioSettings(userId: number) {
return db.twilioSettings.findUnique({ where: { userId } });
},
async upsertTwilioSettings(userId: number, data: TwilioSettingsData) {
return db.twilioSettings.upsert({
where: { userId },
update: data,
create: { userId, ...data },
});
},
async createCommunication(data: CommunicationCreateData) {
return db.communication.create({ data: data as any });
},
async getCommunicationsByPatient(patientId: number) {
return db.communication.findMany({
where: { patientId },
orderBy: { createdAt: "asc" },
});
},
async getTemplates(userId: number): Promise<Record<string, string>> {
const settings = await db.twilioSettings.findUnique({ where: { userId } });
if (!settings?.templates) return {};
return settings.templates as Record<string, string>;
},
async saveTemplate(userId: number, key: string, body: string) {
const settings = await db.twilioSettings.findUnique({ where: { userId } });
const existing = (settings?.templates as Record<string, string>) || {};
const updated = { ...existing, [key]: body };
return db.twilioSettings.upsert({
where: { userId },
update: { templates: updated },
create: { userId, accountSid: "", authToken: "", phoneNumber: "", templates: updated },
});
},
async getRecentCommunicationsByUser(userId: number, limit = 20) {
return db.communication.findMany({
where: { patient: { userId } },
orderBy: { createdAt: "desc" },
take: limit,
include: {
patient: { select: { id: true, firstName: true, lastName: true, phone: true } },
},
});
},
};