diff --git a/app/features/core/components/notification.tsx b/app/features/core/components/notification.tsx index db8c0d3..0669333 100644 --- a/app/features/core/components/notification.tsx +++ b/app/features/core/components/notification.tsx @@ -4,11 +4,13 @@ import { Transition } from "@headlessui/react"; import { useAtom } from "jotai"; import useNotifications, { notificationDataAtom } from "~/features/core/hooks/use-notifications"; +import useCall from "~/features/phone-calls/hooks/use-call"; export default function Notification() { useNotifications(); const navigate = useNavigate(); const [notificationData] = useAtom(notificationDataAtom); + const [call, setCall] = useCall(); const [show, setShow] = useState(notificationData !== null); const close = () => setShow(false); const actions = buildActions(); @@ -78,7 +80,7 @@ export default function Notification() { message: [ { title: "Reply", - onClick: () => { + onClick() { navigate(`/messages/${encodeURIComponent(notificationData.data.recipient)}`); close(); }, @@ -86,8 +88,21 @@ export default function Notification() { { title: "Close", onClick: close }, ], call: [ - { title: "Answer", onClick: close }, - { title: "Decline", onClick: close }, + { + title: "Answer", + onClick() { + navigate(`/incoming-call/${encodeURIComponent(notificationData.data.recipient)}`); + close(); + }, + }, + { + title: "Decline", + onClick() { + call?.reject(); + setCall(null); + close(); + }, + }, ], }[notificationData.data.type]; } diff --git a/app/features/core/hooks/use-notifications.client.ts b/app/features/core/hooks/use-notifications.client.ts index 8f0c69b..8b8cc08 100644 --- a/app/features/core/hooks/use-notifications.client.ts +++ b/app/features/core/hooks/use-notifications.client.ts @@ -78,7 +78,7 @@ export default function useNotifications() { function urlBase64ToUint8Array(base64String: string) { const padding = "=".repeat((4 - (base64String.length % 4)) % 4); - const base64 = (base64String + padding).replace(/\-/g, "+").replace(/_/g, "/"); + const base64 = (base64String + padding).replaceAll("-", "+").replaceAll("_", "/"); const rawData = window.atob(base64); const outputArray = new Uint8Array(rawData.length); diff --git a/app/features/core/hooks/use-notifications.ts b/app/features/core/hooks/use-notifications.ts index 9c9d447..c4dde32 100644 --- a/app/features/core/hooks/use-notifications.ts +++ b/app/features/core/hooks/use-notifications.ts @@ -19,7 +19,7 @@ export default function useNotifications() { channel.removeEventListener("message", eventHandler); channel.close(); }; - }, []); + }, [setNotificationData]); useEffect(() => { if (!notificationData) { @@ -28,7 +28,7 @@ export default function useNotifications() { const timeout = setTimeout(() => setNotificationData(null), 5000); return () => clearTimeout(timeout); - }, [notificationData]); + }, [notificationData, setNotificationData]); } export const notificationDataAtom = atom(null); diff --git a/app/features/phone-calls/hooks/use-call.ts b/app/features/phone-calls/hooks/use-call.ts new file mode 100644 index 0000000..0f53412 --- /dev/null +++ b/app/features/phone-calls/hooks/use-call.ts @@ -0,0 +1,60 @@ +import { useCallback, useEffect } from "react"; +import type { Call } from "@twilio/voice-sdk"; +import { atom, useAtom } from "jotai"; + +export default function useCall() { + const [call, setCall] = useAtom(callAtom); + const endCall = useCallback( + function endCallFn() { + call?.removeListener("cancel", endCall); + call?.removeListener("disconnect", endCall); + call?.disconnect(); + setCall(null); + }, + [call, setCall], + ); + const onError = useCallback( + function onErrorFn(error: any) { + call?.removeListener("cancel", endCall); + call?.removeListener("disconnect", endCall); + call?.disconnect(); + setCall(null); + throw error; // TODO: might not get caught by error boundary + }, + [call, setCall, endCall], + ); + + const eventHandlers = [ + ["error", onError], + ["cancel", endCall], + ["disconnect", endCall], + ] as const; + for (const [eventName, handler] of eventHandlers) { + // register call event handlers + // one event at a time to only update the handlers that changed + // without resetting the other handlers + + // eslint-disable-next-line react-hooks/rules-of-hooks + useEffect(() => { + if (!call) { + return; + } + + // if we already have this event handler registered, no need to re-register it + const listeners = call.listeners(eventName); + if (listeners.length > 0 && listeners.every((fn) => fn.toString() === handler.toString())) { + return; + } + + call.on(eventName, handler); + + return () => { + call.removeListener(eventName, handler); + }; + }, [call, setCall, eventName, handler]); + } + + return [call, setCall] as const; +} + +const callAtom = atom(null); diff --git a/app/features/phone-calls/hooks/use-device.ts b/app/features/phone-calls/hooks/use-device.ts index 8a34023..079fce2 100644 --- a/app/features/phone-calls/hooks/use-device.ts +++ b/app/features/phone-calls/hooks/use-device.ts @@ -1,18 +1,22 @@ -import { useEffect, useState } from "react"; +import { useCallback, useEffect } from "react"; import { useFetcher } from "@remix-run/react"; import { type TwilioError, Call, Device } from "@twilio/voice-sdk"; import { useAtom, atom } from "jotai"; import type { TwilioTokenLoaderData } from "~/features/phone-calls/loaders/twilio-token"; +import type { NotificationPayload } from "~/utils/web-push.server"; +import useCall from "./use-call"; export default function useDevice() { const jwt = useDeviceToken(); const [device, setDevice] = useAtom(deviceAtom); - const [isDeviceReady, setIsDeviceReady] = useState(device?.state === Device.State.Registered); + const [call, setCall] = useCall(); + const [isDeviceReady, setIsDeviceReady] = useAtom(isDeviceReadyAtom); useEffect(() => { // init token jwt.refresh(); + // eslint-disable-next-line react-hooks/exhaustive-deps }, []); useEffect(() => { @@ -31,8 +35,9 @@ export default function useDevice() { }, }); newDevice.register(); + (window as any).ddd = newDevice; setDevice(newDevice); - }, [device, jwt.token]); + }, [device, jwt.token, setDevice]); useEffect(() => { // refresh token @@ -41,79 +46,111 @@ export default function useDevice() { } }, [device, jwt.token]); - useEffect(() => { - if (!device) { - return; - } + const onTokenWillExpire = useCallback( + function onTokenWillExpire() { + jwt.refresh(); + }, + [jwt.refresh], + ); - device.on("registered", onDeviceRegistered); - device.on("unregistered", onDeviceUnregistered); - device.on("error", onDeviceError); - device.on("incoming", onDeviceIncoming); - device.on("tokenWillExpire", onTokenWillExpire); - - return () => { - if (typeof device.off !== "function") { + const onDeviceRegistered = useCallback( + function onDeviceRegistered() { + setIsDeviceReady(true); + }, + [setIsDeviceReady], + ); + const onDeviceUnregistered = useCallback( + function onDeviceUnregistered() { + setIsDeviceReady(false); + }, + [setIsDeviceReady], + ); + const onDeviceError = useCallback(function onDeviceError(error: TwilioError.TwilioError, call?: Call) { + console.log("error", error); + // we might have to change this if we instantiate the device on every page to receive calls + // setDevice(() => { + // hack to trigger the error boundary + throw error; + // }); + }, []); + const onDeviceIncoming = useCallback( + function onDeviceIncoming(incomingCall: Call) { + if (call) { + incomingCall.reject(); return; } - device.off("registered", onDeviceRegistered); - device.off("unregistered", onDeviceUnregistered); - device.off("error", onDeviceError); - device.off("incoming", onDeviceIncoming); - device.off("tokenWillExpire", onTokenWillExpire); - }; - }, [device]); + setCall(incomingCall); + console.log("incomingCall.parameters", incomingCall.parameters); + // TODO prevent making a new call when there is a pending incoming call + const channel = new BroadcastChannel("notifications"); + const recipient = incomingCall.parameters.From; + const message: NotificationPayload = { + title: recipient, // TODO: + body: "", + actions: [ + { + action: "answer", + title: "Answer", + }, + { + action: "decline", + title: "Decline", + }, + ], + data: { recipient, type: "call" }, + }; + channel.postMessage(JSON.stringify(message)); + }, + [call, setCall], + ); + const eventHandlers = [ + ["registered", onDeviceRegistered], + ["unregistered", onDeviceUnregistered], + ["error", onDeviceError], + ["incoming", onDeviceIncoming], + ["tokenWillExpire", onTokenWillExpire], + ] as const; + for (const [eventName, handler] of eventHandlers) { + // register device event handlers + // one event at a time to only update the handlers that changed + // without resetting the other handlers + + // eslint-disable-next-line react-hooks/rules-of-hooks + useEffect(() => { + if (!device) { + return; + } + + // if we already have this event handler registered, no need to re-register it + const listeners = device.listeners(eventName); + if (listeners.length > 0 && listeners.every((fn) => fn.toString() === handler.toString())) { + return; + } + + device.on(eventName, handler); + + return () => { + device.removeListener(eventName, handler); + }; + }, [device, eventName, handler]); + } return { device, isDeviceReady, }; - - function onTokenWillExpire() { - jwt.refresh(); - } - - function onDeviceRegistered() { - setIsDeviceReady(true); - } - - function onDeviceUnregistered() { - setIsDeviceReady(false); - } - - function onDeviceError(error: TwilioError.TwilioError, call?: Call) { - console.log("error", error); - // we might have to change this if we instantiate the device on every page to receive calls - setDevice(() => { - // hack to trigger the error boundary - throw error; - }); - } - - function onDeviceIncoming(call: Call) { - // TODO show alert to accept/reject the incoming call /!\ it should persist between screens /!\ prevent making a new call when there is a pending incoming call - console.log("call", call); - console.log("Incoming connection from " + call.parameters.From); - let archEnemyPhoneNumber = "+12093373517"; - - if (call.parameters.From === archEnemyPhoneNumber) { - call.reject(); - console.log("It's your nemesis. Rejected call."); - } else { - // accept the incoming connection and start two-way audio - call.accept(); - } - } } const deviceAtom = atom(null); +const isDeviceReadyAtom = atom(false); function useDeviceToken() { const fetcher = useFetcher(); + const refresh = useCallback(() => fetcher.load("/outgoing-call/twilio-token"), []); return { token: fetcher.data, - refresh: () => fetcher.load("/outgoing-call/twilio-token"), + refresh, }; } diff --git a/app/features/phone-calls/hooks/use-make-call.ts b/app/features/phone-calls/hooks/use-make-call.ts index 3ca81c4..1d0e679 100644 --- a/app/features/phone-calls/hooks/use-make-call.ts +++ b/app/features/phone-calls/hooks/use-make-call.ts @@ -3,6 +3,7 @@ import { useNavigate } from "@remix-run/react"; import type { Call } from "@twilio/voice-sdk"; import useDevice from "./use-device"; +import useCall from "./use-call"; type Params = { recipient: string; @@ -11,28 +12,23 @@ type Params = { export default function useMakeCall({ recipient, onHangUp }: Params) { const navigate = useNavigate(); - const [outgoingConnection, setOutgoingConnection] = useState(null); + const [call, setCall] = useCall(); const [state, setState] = useState("initial"); const { device, isDeviceReady } = useDevice(); const endCall = useCallback( function endCall() { - outgoingConnection?.off("cancel", endCall); - outgoingConnection?.off("disconnect", endCall); - outgoingConnection?.disconnect(); - setState("call_ending"); setTimeout(() => { setState("call_ended"); setTimeout(() => navigate("/keypad"), 100); }, 150); }, - [outgoingConnection, navigate], + [navigate], ); const makeCall = useCallback( async function makeCall() { - console.log({ device, isDeviceReady }); if (!device || !isDeviceReady) { console.warn("device is not ready yet, can't make the call"); return; @@ -42,7 +38,7 @@ export default function useMakeCall({ recipient, onHangUp }: Params) { return; } - if (device.isBusy) { + if (device.isBusy || Boolean(call)) { console.error("device is busy, this shouldn't happen"); return; } @@ -51,43 +47,30 @@ export default function useMakeCall({ recipient, onHangUp }: Params) { const params = { To: recipient }; const outgoingConnection = await device.connect({ params }); - setOutgoingConnection(outgoingConnection); + setCall(outgoingConnection); - outgoingConnection.on("error", (error) => { - outgoingConnection.off("cancel", endCall); - outgoingConnection.off("disconnect", endCall); - setState(() => { - // hack to trigger the error boundary - throw error; - }); - }); outgoingConnection.once("accept", (call: Call) => setState("call_in_progress")); - outgoingConnection.on("cancel", endCall); - outgoingConnection.on("disconnect", endCall); + outgoingConnection.once("cancel", endCall); + outgoingConnection.once("disconnect", endCall); }, - [device, isDeviceReady, recipient, state], + [call, device, endCall, isDeviceReady, recipient, setCall, state], ); const sendDigits = useCallback( function sendDigits(digits: string) { - return outgoingConnection?.sendDigits(digits); + return call?.sendDigits(digits); }, - [outgoingConnection], + [call], ); const hangUp = useCallback( function hangUp() { setState("call_ending"); - outgoingConnection?.disconnect(); - device?.disconnectAll(); - device?.destroy(); + call?.disconnect(); onHangUp?.(); navigate("/keypad"); - // TODO: outgoingConnection.off is not a function - outgoingConnection?.off("cancel", endCall); - outgoingConnection?.off("disconnect", endCall); }, - [device, endCall, onHangUp, outgoingConnection, navigate], + [call, onHangUp, navigate], ); return { diff --git a/app/features/phone-calls/hooks/use-receive-call.ts b/app/features/phone-calls/hooks/use-receive-call.ts new file mode 100644 index 0000000..d011c88 --- /dev/null +++ b/app/features/phone-calls/hooks/use-receive-call.ts @@ -0,0 +1,78 @@ +import { useCallback, useState } from "react"; +import { useNavigate } from "@remix-run/react"; + +import useDevice from "./use-device"; +import useCall from "./use-call"; + +type Params = { + onHangUp?: () => void; +}; + +export default function useMakeCall({ onHangUp }: Params) { + const navigate = useNavigate(); + const [call] = useCall(); + const [state, setState] = useState("initial"); + const { device, isDeviceReady } = useDevice(); + + const endCall = useCallback( + function endCall() { + setState("call_ending"); + setTimeout(() => { + setState("call_ended"); + setTimeout(() => navigate("/keypad"), 100); + }, 150); + }, + [navigate], + ); + + const acceptCall = useCallback( + async function acceptCall() { + if (!device || !isDeviceReady) { + console.warn("device is not ready yet, can't make the call"); + return; + } + + if (state !== "initial") { + return; + } + + if (device.isBusy || !call) { + console.error("device is busy, this shouldn't happen"); + return; + } + + call.accept(); + setState("call_in_progress"); + + call.once("cancel", endCall); + call.once("disconnect", endCall); + }, + [call, device, endCall, isDeviceReady, state], + ); + + const sendDigits = useCallback( + function sendDigits(digits: string) { + return call?.sendDigits(digits); + }, + [call], + ); + + const hangUp = useCallback( + function hangUp() { + setState("call_ending"); + call?.disconnect(); + onHangUp?.(); + navigate("/keypad"); + }, + [call, onHangUp, navigate], + ); + + return { + acceptCall, + sendDigits, + hangUp, + state, + }; +} + +type State = "initial" | "ready" | "calling" | "call_in_progress" | "call_ending" | "call_ended"; diff --git a/app/queues/notify-incoming-message.server.ts b/app/queues/notify-incoming-message.server.ts index 40997cf..4b4715b 100644 --- a/app/queues/notify-incoming-message.server.ts +++ b/app/queues/notify-incoming-message.server.ts @@ -39,5 +39,6 @@ export default Queue("notify incoming message", async ({ data }) => { const message = await twilioClient.messages.get(messageSid).fetch(); const payload = buildMessageNotificationPayload(message); + // TODO: implement WS/SSE to push new messages for users who haven't enabled push notifications await notify(subscriptions, payload); }); diff --git a/app/routes/__app.tsx b/app/routes/__app.tsx index a152bd8..5b07879 100644 --- a/app/routes/__app.tsx +++ b/app/routes/__app.tsx @@ -7,6 +7,7 @@ import Footer from "~/features/core/components/footer"; import ServiceWorkerUpdateNotifier from "~/features/core/components/service-worker-update-notifier"; import Notification from "~/features/core/components/notification"; import useServiceWorkerRevalidate from "~/features/core/hooks/use-service-worker-revalidate"; +import useDevice from "~/features/phone-calls/hooks/use-device"; import footerStyles from "~/features/core/components/footer.css"; import appStyles from "~/styles/app.css"; @@ -32,6 +33,7 @@ export const loader: LoaderFunction = async ({ request }) => { }; export default function __App() { + useDevice(); useServiceWorkerRevalidate(); const matches = useMatches(); const hideFooter = matches.some((match) => match.handle?.hideFooter === true); diff --git a/app/routes/__app/incoming-call.$recipient.tsx b/app/routes/__app/incoming-call.$recipient.tsx new file mode 100644 index 0000000..54c47dd --- /dev/null +++ b/app/routes/__app/incoming-call.$recipient.tsx @@ -0,0 +1,86 @@ +import { useCallback, useEffect } from "react"; +import type { MetaFunction } from "@remix-run/node"; +import { useParams } from "@remix-run/react"; +import { IoCall } from "react-icons/io5"; + +import { getSeoMeta } from "~/utils/seo"; +import { usePhoneNumber, usePressDigit } from "~/features/keypad/hooks/atoms"; +import useDevice from "~/features/phone-calls/hooks/use-device"; +import useReceiveCall from "~/features/phone-calls/hooks/use-receive-call"; +import Keypad from "~/features/keypad/components/keypad"; + +export const meta: MetaFunction = ({ params }) => { + const recipient = decodeURIComponent(params.recipient ?? ""); + + return { + ...getSeoMeta({ + title: `Calling ${recipient}`, + }), + }; +}; + +export default function IncomingCallPage() { + const params = useParams<{ recipient: string }>(); + const recipient = decodeURIComponent(params.recipient ?? ""); + const [phoneNumber, setPhoneNumber] = usePhoneNumber(); + const onHangUp = useCallback(() => setPhoneNumber(""), [setPhoneNumber]); + const call = useReceiveCall({ onHangUp }); + const { isDeviceReady } = useDevice(); + const pressDigit = usePressDigit(); + const onDigitPressProps = useCallback( + (digit: string) => ({ + onPress() { + pressDigit(digit); + + call.sendDigits(digit); + }, + }), + [call, pressDigit], + ); + + useEffect(() => { + if (isDeviceReady) { + call.acceptCall(); + } + }, [call, isDeviceReady]); + + return ( +
+
+ {recipient} +
+ +
+
{phoneNumber}
+
{translateState(call.state)}
+
+ + + + +
+ ); + + function translateState(state: typeof call.state) { + switch (state) { + case "initial": + case "ready": + return "Connecting..."; + case "calling": + return "Calling..."; + case "call_in_progress": + return "In call"; // TODO display time elapsed + case "call_ending": + return "Call ending..."; + case "call_ended": + return "Call ended"; + } + } +} + +export const handle = { hideFooter: true }; diff --git a/app/routes/webhook/call.ts b/app/routes/webhook/call.ts index af6922d..0222560 100644 --- a/app/routes/webhook/call.ts +++ b/app/routes/webhook/call.ts @@ -9,6 +9,7 @@ import twilio from "twilio"; import { voiceUrl, translateCallStatus } from "~/utils/twilio.server"; import { decrypt } from "~/utils/encryption"; import { validate } from "~/utils/validation.server"; +import { notify } from "~/utils/web-push.server"; export const action: ActionFunction = async ({ request }) => { const twilioSignature = request.headers.get("X-Twilio-Signature") || request.headers.get("x-twilio-signature"); @@ -18,96 +19,187 @@ export const action: ActionFunction = async ({ request }) => { const formData = Object.fromEntries(await request.formData()); const isOutgoingCall = formData.Caller?.toString().startsWith("client:"); + console.log("isOutgoingCall", isOutgoingCall); if (isOutgoingCall) { - const validation = validate(validations.outgoing, formData); - if (validation.errors) { - logger.error(validation.errors); - return badRequest(""); - } + return handleOutgoingCall(formData, twilioSignature); + } - const body = validation.data; - const recipient = body.To; - const accountSid = body.From.slice("client:".length).split("__")[0]; + return handleIncomingCall(formData, twilioSignature); +}; - try { - const twilioAccount = await db.twilioAccount.findUnique({ where: { accountSid } }); - if (!twilioAccount) { - // this shouldn't be happening - return new Response(null, { status: 402 }); - } +async function handleIncomingCall(formData: unknown, twilioSignature: string) { + console.log("formData", formData); + const validation = validate(validations.incoming, formData); + if (validation.errors) { + logger.error(validation.errors); + return badRequest(""); + } - const phoneNumber = await db.phoneNumber.findUnique({ - where: { twilioAccountSid_isCurrent: { twilioAccountSid: twilioAccount.accountSid, isCurrent: true } }, + const body = validation.data; + const phoneNumber = await db.phoneNumber.findFirst({ + where: { + number: body.To, + twilioAccountSid: body.AccountSid, + }, + include: { + twilioAccount: { include: { - twilioAccount: { - include: { - organization: { - select: { - subscriptions: { - where: { - OR: [ - { status: { not: SubscriptionStatus.deleted } }, - { - status: SubscriptionStatus.deleted, - cancellationEffectiveDate: { gt: new Date() }, - }, - ], + organization: { + select: { + subscriptions: { + where: { + OR: [ + { status: { not: SubscriptionStatus.deleted } }, + { + status: SubscriptionStatus.deleted, + cancellationEffectiveDate: { gt: new Date() }, }, - orderBy: { lastEventTime: Prisma.SortOrder.desc }, - }, + ], }, + orderBy: { lastEventTime: Prisma.SortOrder.desc }, + }, + memberships: { + select: { user: true }, }, }, }, }, - }); - - if (phoneNumber?.twilioAccount.organization.subscriptions.length === 0) { - // decline the outgoing call because - // the organization is on the free plan - console.log("no active subscription"); // TODO: uncomment the line below - // return new Response(null, { status: 402 }); - } - - const encryptedAuthToken = phoneNumber?.twilioAccount.authToken; - const authToken = encryptedAuthToken ? decrypt(encryptedAuthToken) : ""; - if ( - !phoneNumber || - !encryptedAuthToken || - !twilio.validateRequest(authToken, twilioSignature, voiceUrl, body) - ) { - return badRequest("Invalid webhook"); - } - - await db.phoneCall.create({ - data: { - id: body.CallSid, - recipient: body.To, - from: phoneNumber.number, - to: body.To, - status: translateCallStatus(body.CallStatus), - direction: Direction.Outbound, - duration: "0", - phoneNumberId: phoneNumber.id, - }, - }); - - const voiceResponse = new twilio.twiml.VoiceResponse(); - const dial = voiceResponse.dial({ - answerOnBridge: true, - callerId: phoneNumber!.number, - }); - dial.number(recipient); - console.log("twiml voiceResponse", voiceResponse.toString()); - - return new Response(voiceResponse.toString(), { headers: { "Content-Type": "text/xml" } }); - } catch (error: any) { - logger.error(error); - - return serverError(error.message); - } + }, + }, + }); + if (!phoneNumber) { + // this shouldn't be happening + return new Response(null, { status: 402 }); } -}; + + if (phoneNumber.twilioAccount.organization.subscriptions.length === 0) { + // decline the outgoing call because + // the organization is on the free plan + console.log("no active subscription"); // TODO: uncomment the line below + // return new Response(null, { status: 402 }); + } + + const encryptedAuthToken = phoneNumber.twilioAccount.authToken; + const authToken = encryptedAuthToken ? decrypt(encryptedAuthToken) : ""; + if (!phoneNumber || !encryptedAuthToken || !twilio.validateRequest(authToken, twilioSignature, voiceUrl, body)) { + return badRequest("Invalid webhook"); + } + + await db.phoneCall.create({ + data: { + id: body.CallSid, + recipient: body.From, + from: body.From, + to: body.To, + status: translateCallStatus(body.CallStatus), + direction: Direction.Outbound, + duration: "0", + phoneNumberId: phoneNumber.id, + }, + }); + + // await notify(); TODO + const user = phoneNumber.twilioAccount.organization.memberships[0].user!; + const identity = `${phoneNumber.twilioAccount.accountSid}__${user.id}`; + const voiceResponse = new twilio.twiml.VoiceResponse(); + const dial = voiceResponse.dial({ answerOnBridge: true }); + dial.client(identity); + console.log("twiml voiceResponse", voiceResponse.toString()); + + return new Response(voiceResponse.toString(), { headers: { "Content-Type": "text/xml" } }); +} + +async function handleOutgoingCall(formData: unknown, twilioSignature: string) { + const validation = validate(validations.outgoing, formData); + if (validation.errors) { + logger.error(validation.errors); + return badRequest(""); + } + + const body = validation.data; + const recipient = body.To; + const accountSid = body.From.slice("client:".length).split("__")[0]; + + try { + const twilioAccount = await db.twilioAccount.findUnique({ + where: { accountSid }, + include: { + organization: { + select: { + subscriptions: { + where: { + OR: [ + { status: { not: SubscriptionStatus.deleted } }, + { + status: SubscriptionStatus.deleted, + cancellationEffectiveDate: { gt: new Date() }, + }, + ], + }, + orderBy: { lastEventTime: Prisma.SortOrder.desc }, + }, + }, + }, + }, + }); + if (!twilioAccount) { + // this shouldn't be happening + return new Response(null, { status: 402 }); + } + + const phoneNumber = await db.phoneNumber.findUnique({ + where: { twilioAccountSid_isCurrent: { twilioAccountSid: twilioAccount.accountSid, isCurrent: true } }, + }); + if (!phoneNumber) { + // this shouldn't be happening + return new Response(null, { status: 402 }); + } + + if (twilioAccount.organization.subscriptions.length === 0) { + // decline the outgoing call because + // the organization is on the free plan + console.log("no active subscription"); // TODO: uncomment the line below + // return new Response(null, { status: 402 }); + } + + const encryptedAuthToken = twilioAccount.authToken; + const authToken = encryptedAuthToken ? decrypt(encryptedAuthToken) : ""; + if ( + !phoneNumber || + !encryptedAuthToken || + !twilio.validateRequest(authToken, twilioSignature, voiceUrl, body) + ) { + return badRequest("Invalid webhook"); + } + + await db.phoneCall.create({ + data: { + id: body.CallSid, + recipient: body.To, + from: phoneNumber.number, + to: body.To, + status: translateCallStatus(body.CallStatus), + direction: Direction.Outbound, + duration: "0", + phoneNumberId: phoneNumber.id, + }, + }); + + const voiceResponse = new twilio.twiml.VoiceResponse(); + const dial = voiceResponse.dial({ + answerOnBridge: true, + callerId: phoneNumber!.number, + }); + dial.number(recipient); + console.log("twiml voiceResponse", voiceResponse.toString()); + + return new Response(voiceResponse.toString(), { headers: { "Content-Type": "text/xml" } }); + } catch (error: any) { + logger.error(error); + + return serverError(error.message); + } +} const CallStatus = z.union([ z.literal("busy"), @@ -140,6 +232,7 @@ const validations = { ApplicationSid: z.string(), CallSid: z.string(), CallStatus, + CallToken: z.string(), Called: z.string(), CalledCity: z.string(), CalledCountry: z.string(),