feat: add Twilio SMS/call integration with settings, templates, and conversation history
This commit is contained in:
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
107
apps/Backend/src/routes/twilio-webhooks.ts
Normal file
107
apps/Backend/src/routes/twilio-webhooks.ts
Normal 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;
|
||||
183
apps/Backend/src/routes/twilio.ts
Normal file
183
apps/Backend/src/routes/twilio.ts
Normal 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;
|
||||
Reference in New Issue
Block a user