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:
ff
2026-06-02 22:24:53 -04:00
parent be90966f6e
commit ddcc49b72c
9 changed files with 409 additions and 4 deletions

View File

@@ -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;

View File

@@ -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 {

View File

@@ -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 },
});
},