From ddcc49b72c5936a8407aec3e23f0d279af30b94a Mon Sep 17 00:00:00 2001 From: ff Date: Tue, 2 Jun 2026 22:24:53 -0400 Subject: [PATCH] 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 --- README.md | 19 ++ apps/Backend/src/routes/twilio-webhooks.ts | 22 ++ apps/Backend/src/routes/twilio.ts | 43 ++- apps/Backend/src/storage/twilio-storage.ts | 13 +- apps/Frontend/package.json | 1 + .../patient-connection/dial-pad.tsx | 246 ++++++++++++++++++ .../settings/twilio-settings-card.tsx | 21 +- .../src/pages/patient-connection-page.tsx | 6 + package-lock.json | 42 +++ 9 files changed, 409 insertions(+), 4 deletions(-) create mode 100644 apps/Frontend/src/components/patient-connection/dial-pad.tsx diff --git a/README.md b/README.md index 9bd3010c..1e3f6da3 100644 --- a/README.md +++ b/README.md @@ -390,6 +390,25 @@ Each office runs its own `cloudflared` tunnel on its own PC. Ports never conflic --- +## Twilio In-Browser Calling Setup (Dial Pad) + +The dial pad on the Patient Connection page lets staff make real phone calls directly through the browser (mic + speaker) using Twilio Voice SDK. One-time setup is required in the Twilio Console. + +### One-time Twilio Console setup (required before first call) + +1. Go to **Twilio Console → Explore Products → Voice → TwiML Apps** +2. Click **Create new TwiML App** +3. Set the **Voice Request URL** to: + ``` + https://communitydentistsoflowell.mydentalofficemanagement.com/api/twilio/webhook/voice-browser + ``` +4. Save — copy the **TwiML App SID** (starts with `AP`) +5. In the dental app, go to **Settings → Twilio Settings → TwiML App SID** → paste the SID and save + +Once saved, the dial pad on the Patient Connection page is fully functional. The staff member's browser mic/speaker is used for the call; the patient receives a normal phone call from the office Twilio number. + +--- + ## License Key Generator The license key generator is a private tool that lives only on your dev PC. Use it to generate a new license key for any office every 3 months. diff --git a/apps/Backend/src/routes/twilio-webhooks.ts b/apps/Backend/src/routes/twilio-webhooks.ts index dad2ec40..33ddafa5 100644 --- a/apps/Backend/src/routes/twilio-webhooks.ts +++ b/apps/Backend/src/routes/twilio-webhooks.ts @@ -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 => { + try { + const { To, AccountSid } = req.body as { To?: string; AccountSid?: string }; + if (!To || !AccountSid) { + res.set("Content-Type", "text/xml"); + return res.send("Missing call parameters."); + } + const settings = await db.twilioSettings.findFirst({ where: { accountSid: AccountSid } }); + const callerId = settings?.phoneNumber || ""; + res.set("Content-Type", "text/xml"); + return res.send( + `${escapeXml(To)}` + ); + } catch { + res.set("Content-Type", "text/xml"); + return res.send("An error occurred. Please try again."); + } +}); + export default router; diff --git a/apps/Backend/src/routes/twilio.ts b/apps/Backend/src/routes/twilio.ts index 7d47a103..be48edc4 100644 --- a/apps/Backend/src/routes/twilio.ts +++ b/apps/Backend/src/routes/twilio.ts @@ -19,12 +19,14 @@ router.get("/settings", async (req: Request, res: Response): Promise => { const settings = await storage.getTwilioSettings(userId); if (!settings) return res.status(200).json(null); + const templates = (settings.templates as Record) || {}; 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 => { 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 => { authToken: authToken.trim(), phoneNumber: phoneNumber.trim(), greetingMessage: greetingMessage?.trim() || null, + twimlAppSid: twimlAppSid?.trim() || null, }); + const templates = (settings.templates as Record) || {}; 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 => { + 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) || {}; + 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 => { try { diff --git a/apps/Backend/src/storage/twilio-storage.ts b/apps/Backend/src/storage/twilio-storage.ts index 86ac5409..41391908 100644 --- a/apps/Backend/src/storage/twilio-storage.ts +++ b/apps/Backend/src/storage/twilio-storage.ts @@ -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) || {}; + const templates: Record = { ...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 }, }); }, diff --git a/apps/Frontend/package.json b/apps/Frontend/package.json index fd024e20..de13f573 100755 --- a/apps/Frontend/package.json +++ b/apps/Frontend/package.json @@ -48,6 +48,7 @@ "@tailwindcss/vite": "^4.1.6", "@tanstack/react-query": "^5.60.5", "@tanstack/react-table": "^8.21.3", + "@twilio/voice-sdk": "^2.18.3", "autoprefixer": "^10.4.20", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", diff --git a/apps/Frontend/src/components/patient-connection/dial-pad.tsx b/apps/Frontend/src/components/patient-connection/dial-pad.tsx new file mode 100644 index 00000000..1d6e7711 --- /dev/null +++ b/apps/Frontend/src/components/patient-connection/dial-pad.tsx @@ -0,0 +1,246 @@ +import { useState, useEffect, useRef, useCallback } from "react"; +import { Device, Call } from "@twilio/voice-sdk"; +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { Button } from "@/components/ui/button"; +import { Phone, PhoneOff, Mic, MicOff, Delete } from "lucide-react"; +import { useToast } from "@/hooks/use-toast"; +import { apiRequest } from "@/lib/queryClient"; + +type CallStatus = "idle" | "requesting-token" | "connecting" | "connected" | "error"; + +const KEYS = [ + ["1", "2", "3"], + ["4", "5", "6"], + ["7", "8", "9"], + ["*", "0", "#"], +]; + +function formatDuration(seconds: number): string { + const m = Math.floor(seconds / 60).toString().padStart(2, "0"); + const s = (seconds % 60).toString().padStart(2, "0"); + return `${m}:${s}`; +} + +export function DialPad() { + const [dialedNumber, setDialedNumber] = useState(""); + const [status, setStatus] = useState("idle"); + const [isMuted, setIsMuted] = useState(false); + const [duration, setDuration] = useState(0); + const { toast } = useToast(); + + const deviceRef = useRef(null); + const callRef = useRef(null); + const timerRef = useRef | null>(null); + + const stopTimer = useCallback(() => { + if (timerRef.current) { + clearInterval(timerRef.current); + timerRef.current = null; + } + }, []); + + const destroyDevice = useCallback(() => { + callRef.current?.disconnect(); + callRef.current = null; + deviceRef.current?.destroy(); + deviceRef.current = null; + }, []); + + useEffect(() => { + return () => { + stopTimer(); + destroyDevice(); + }; + }, [stopTimer, destroyDevice]); + + // Keyboard input support + useEffect(() => { + const handleKey = (e: KeyboardEvent) => { + if (status !== "idle") return; + const valid = "0123456789*#"; + if (valid.includes(e.key)) { + setDialedNumber((n) => (n.length < 16 ? n + e.key : n)); + } else if (e.key === "Backspace") { + setDialedNumber((n) => n.slice(0, -1)); + } + }; + window.addEventListener("keydown", handleKey); + return () => window.removeEventListener("keydown", handleKey); + }, [status]); + + const pressKey = (key: string) => { + if (status !== "idle") return; + setDialedNumber((n) => (n.length < 16 ? n + key : n)); + }; + + const handleBackspace = () => { + if (status !== "idle") return; + setDialedNumber((n) => n.slice(0, -1)); + }; + + const handleCall = async () => { + if (!dialedNumber.trim()) { + toast({ title: "Enter a number", description: "Please dial a phone number first.", variant: "destructive" }); + return; + } + + setStatus("requesting-token"); + setDuration(0); + setIsMuted(false); + + try { + const res = await apiRequest("POST", "/api/twilio/voice-token"); + if (!res.ok) { + const err = await res.json().catch(() => null); + throw new Error(err?.message || "Failed to get voice token"); + } + const { token } = await res.json(); + + const device = new Device(token, { logLevel: "error" }); + deviceRef.current = device; + + await device.register(); + setStatus("connecting"); + + // Normalize to E.164 if it looks like a 10-digit US number + let toNumber = dialedNumber.replace(/\D/g, ""); + if (toNumber.length === 10) toNumber = "+1" + toNumber; + else if (!toNumber.startsWith("+")) toNumber = "+" + toNumber; + + const call = await device.connect({ params: { To: toNumber } }); + callRef.current = call; + + call.on("accept", () => { + setStatus("connected"); + timerRef.current = setInterval(() => setDuration((d) => d + 1), 1000); + }); + + call.on("disconnect", () => { + stopTimer(); + destroyDevice(); + setStatus("idle"); + setDuration(0); + setIsMuted(false); + }); + + call.on("error", (err: any) => { + stopTimer(); + destroyDevice(); + setStatus("error"); + toast({ title: "Call Error", description: err?.message || "Call failed.", variant: "destructive" }); + setTimeout(() => setStatus("idle"), 3000); + }); + } catch (err: any) { + destroyDevice(); + setStatus("error"); + toast({ title: "Call Failed", description: err?.message || "Unable to place call.", variant: "destructive" }); + setTimeout(() => setStatus("idle"), 3000); + } + }; + + const handleHangup = () => { + callRef.current?.disconnect(); + }; + + const handleMute = () => { + if (!callRef.current) return; + const next = !isMuted; + callRef.current.mute(next); + setIsMuted(next); + }; + + const statusLabel: Record = { + idle: "Ready", + "requesting-token": "Initializing...", + connecting: "Connecting...", + connected: `In Call ${formatDuration(duration)}`, + error: "Error", + }; + + const statusColor: Record = { + idle: "text-muted-foreground", + "requesting-token": "text-amber-500", + connecting: "text-amber-500", + connected: "text-green-600", + error: "text-red-500", + }; + + const isInCall = status === "connected" || status === "connecting"; + const isBusy = status === "requesting-token" || status === "connecting"; + + return ( + + + + + Dial Pad + + + +
+ {/* Number display */} +
+ + {dialedNumber || Enter number} + + {dialedNumber && status === "idle" && ( + + )} +
+ + {/* Keypad */} +
+ {KEYS.flat().map((key) => ( + + ))} +
+ + {/* Call / Hangup */} +
+ {!isInCall ? ( + + ) : ( + <> + + + + )} +
+ + {/* Status */} +

+ {statusLabel[status]} +

+
+
+
+ ); +} diff --git a/apps/Frontend/src/components/settings/twilio-settings-card.tsx b/apps/Frontend/src/components/settings/twilio-settings-card.tsx index 16a8f8ff..2da11c0d 100644 --- a/apps/Frontend/src/components/settings/twilio-settings-card.tsx +++ b/apps/Frontend/src/components/settings/twilio-settings-card.tsx @@ -11,6 +11,7 @@ type TwilioSettings = { authToken: string; phoneNumber: string; greetingMessage?: string | null; + twimlAppSid?: string | null; }; export function TwilioSettingsCard() { @@ -19,6 +20,7 @@ export function TwilioSettingsCard() { const [authToken, setAuthToken] = useState(""); const [phoneNumber, setPhoneNumber] = useState(""); const [greetingMessage, setGreetingMessage] = useState(""); + const [twimlAppSid, setTwimlAppSid] = useState(""); const [showAuthToken, setShowAuthToken] = useState(false); const { data: settings, isLoading } = useQuery({ @@ -36,6 +38,7 @@ export function TwilioSettingsCard() { setAuthToken(settings.authToken ?? ""); setPhoneNumber(settings.phoneNumber ?? ""); setGreetingMessage(settings.greetingMessage ?? ""); + setTwimlAppSid(settings.twimlAppSid ?? ""); } }, [settings]); @@ -60,7 +63,7 @@ export function TwilioSettingsCard() { const handleSubmit = (e: React.FormEvent) => { e.preventDefault(); if (!accountSid.trim() || !authToken.trim() || !phoneNumber.trim()) return; - saveMutation.mutate({ accountSid: accountSid.trim(), authToken: authToken.trim(), phoneNumber: phoneNumber.trim(), greetingMessage: greetingMessage.trim() || null }); + saveMutation.mutate({ accountSid: accountSid.trim(), authToken: authToken.trim(), phoneNumber: phoneNumber.trim(), greetingMessage: greetingMessage.trim() || null, twimlAppSid: twimlAppSid.trim() || null }); }; const isConfigured = !!(settings?.accountSid && settings?.authToken && settings?.phoneNumber); @@ -144,6 +147,22 @@ export function TwilioSettingsCard() {

This message plays when a patient calls your Twilio number. Leave blank to use the default.

+
+ + setTwimlAppSid(e.target.value)} + className="mt-1 p-2 border rounded w-full font-mono text-sm" + placeholder="APxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx" + /> +

+ In Twilio Console → TwiML Apps → create one → set Voice URL to{" "} + https://communitydentistsoflowell.mydentalofficemanagement.com/api/twilio/webhook/voice-browser + {" "}then paste the App SID here. Required for the dial pad. +

+
+