feat: add in-browser dial pad with Twilio Voice SDK
- New DialPad component on Patient Connection page: clickable keypad, call/hangup/mute buttons, duration timer, keyboard input support - Backend: POST /api/twilio/voice-token issues Access Token for browser Device; POST /api/twilio/webhook/voice-browser is the TwiML webhook Twilio calls to bridge the browser to the patient's phone - TwiML App SID field added to Twilio Settings (stored in templates JSON) - README: one-time Twilio Console setup instructions for the dial pad Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -1203,4 +1203,26 @@ router.post("/webhook/voice-recording", async (req: Request, res: Response): Pro
|
||||
}
|
||||
});
|
||||
|
||||
// POST /api/twilio/webhook/voice-browser
|
||||
// Called by Twilio when a browser Device (Voice SDK) places an outbound call.
|
||||
// Looks up the office's Twilio phone number by AccountSid and dials the patient.
|
||||
router.post("/webhook/voice-browser", async (req: Request, res: Response): Promise<any> => {
|
||||
try {
|
||||
const { To, AccountSid } = req.body as { To?: string; AccountSid?: string };
|
||||
if (!To || !AccountSid) {
|
||||
res.set("Content-Type", "text/xml");
|
||||
return res.send("<Response><Say>Missing call parameters.</Say></Response>");
|
||||
}
|
||||
const settings = await db.twilioSettings.findFirst({ where: { accountSid: AccountSid } });
|
||||
const callerId = settings?.phoneNumber || "";
|
||||
res.set("Content-Type", "text/xml");
|
||||
return res.send(
|
||||
`<Response><Dial callerId="${escapeXml(callerId)}"><Number>${escapeXml(To)}</Number></Dial></Response>`
|
||||
);
|
||||
} catch {
|
||||
res.set("Content-Type", "text/xml");
|
||||
return res.send("<Response><Say>An error occurred. Please try again.</Say></Response>");
|
||||
}
|
||||
});
|
||||
|
||||
export default router;
|
||||
|
||||
@@ -19,12 +19,14 @@ router.get("/settings", async (req: Request, res: Response): Promise<any> => {
|
||||
const settings = await storage.getTwilioSettings(userId);
|
||||
if (!settings) return res.status(200).json(null);
|
||||
|
||||
const templates = (settings.templates as Record<string, string>) || {};
|
||||
return res.status(200).json({
|
||||
id: settings.id,
|
||||
accountSid: settings.accountSid,
|
||||
authToken: settings.authToken,
|
||||
phoneNumber: settings.phoneNumber,
|
||||
greetingMessage: settings.greetingMessage,
|
||||
twimlAppSid: templates["_twiml_app_sid"] || null,
|
||||
});
|
||||
} catch (err) {
|
||||
return res.status(500).json({ error: "Failed to fetch Twilio settings", details: String(err) });
|
||||
@@ -37,7 +39,7 @@ router.put("/settings", async (req: Request, res: Response): Promise<any> => {
|
||||
const userId = req.user?.id;
|
||||
if (!userId) return res.status(401).json({ message: "Unauthorized" });
|
||||
|
||||
const { accountSid, authToken, phoneNumber, greetingMessage } = req.body;
|
||||
const { accountSid, authToken, phoneNumber, greetingMessage, twimlAppSid } = req.body;
|
||||
if (!accountSid?.trim() || !authToken?.trim() || !phoneNumber?.trim()) {
|
||||
return res.status(400).json({ message: "accountSid, authToken, and phoneNumber are required" });
|
||||
}
|
||||
@@ -47,14 +49,17 @@ router.put("/settings", async (req: Request, res: Response): Promise<any> => {
|
||||
authToken: authToken.trim(),
|
||||
phoneNumber: phoneNumber.trim(),
|
||||
greetingMessage: greetingMessage?.trim() || null,
|
||||
twimlAppSid: twimlAppSid?.trim() || null,
|
||||
});
|
||||
|
||||
const templates = (settings.templates as Record<string, string>) || {};
|
||||
return res.status(200).json({
|
||||
id: settings.id,
|
||||
accountSid: settings.accountSid,
|
||||
authToken: settings.authToken,
|
||||
phoneNumber: settings.phoneNumber,
|
||||
greetingMessage: settings.greetingMessage,
|
||||
twimlAppSid: templates["_twiml_app_sid"] || null,
|
||||
});
|
||||
} catch (err) {
|
||||
return res.status(500).json({ error: "Failed to save Twilio settings", details: String(err) });
|
||||
@@ -469,6 +474,42 @@ router.get("/recent-communications", async (req: Request, res: Response): Promis
|
||||
}
|
||||
});
|
||||
|
||||
// POST /api/twilio/voice-token
|
||||
// Returns a short-lived Twilio Access Token so the browser Voice SDK can place calls.
|
||||
router.post("/voice-token", 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(400).json({ message: "Twilio is not configured. Please add your Twilio credentials in Settings." });
|
||||
}
|
||||
|
||||
const templates = (settings.templates as Record<string, string>) || {};
|
||||
const twimlAppSid = templates["_twiml_app_sid"]?.trim();
|
||||
if (!twimlAppSid) {
|
||||
return res.status(400).json({ message: "TwiML App SID is not configured. Please add it in Twilio Settings." });
|
||||
}
|
||||
|
||||
const AccessToken = twilio.jwt.AccessToken;
|
||||
const VoiceGrant = AccessToken.VoiceGrant;
|
||||
|
||||
const token = new AccessToken(
|
||||
settings.accountSid,
|
||||
settings.accountSid,
|
||||
settings.authToken,
|
||||
{ identity: `user-${userId}`, ttl: 3600 }
|
||||
);
|
||||
const voiceGrant = new VoiceGrant({ outgoingApplicationSid: twimlAppSid, incomingAllow: false });
|
||||
token.addGrant(voiceGrant);
|
||||
|
||||
return res.status(200).json({ token: token.toJwt(), phoneNumber: settings.phoneNumber });
|
||||
} catch (err: any) {
|
||||
return res.status(500).json({ error: err.message || "Failed to generate voice token" });
|
||||
}
|
||||
});
|
||||
|
||||
// POST /api/twilio/make-call
|
||||
router.post("/make-call", async (req: Request, res: Response): Promise<any> => {
|
||||
try {
|
||||
|
||||
@@ -5,6 +5,7 @@ export type TwilioSettingsData = {
|
||||
authToken: string;
|
||||
phoneNumber: string;
|
||||
greetingMessage?: string | null;
|
||||
twimlAppSid?: string | null;
|
||||
};
|
||||
|
||||
export type CommunicationCreateData = {
|
||||
@@ -24,10 +25,18 @@ export const twilioStorage = {
|
||||
},
|
||||
|
||||
async upsertTwilioSettings(userId: number, data: TwilioSettingsData) {
|
||||
const { twimlAppSid, ...rest } = data;
|
||||
const existing = await db.twilioSettings.findUnique({ where: { userId } });
|
||||
const existingTemplates = (existing?.templates as Record<string, string>) || {};
|
||||
const templates: Record<string, string> = { ...existingTemplates };
|
||||
if (twimlAppSid !== undefined) {
|
||||
if (twimlAppSid) templates["_twiml_app_sid"] = twimlAppSid;
|
||||
else delete templates["_twiml_app_sid"];
|
||||
}
|
||||
return db.twilioSettings.upsert({
|
||||
where: { userId },
|
||||
update: data,
|
||||
create: { userId, ...data },
|
||||
update: { ...rest, templates },
|
||||
create: { userId, ...rest, templates },
|
||||
});
|
||||
},
|
||||
|
||||
|
||||
Reference in New Issue
Block a user