diff --git a/app/features/core/components/notification.tsx b/app/features/core/components/notification.tsx new file mode 100644 index 0000000..db8c0d3 --- /dev/null +++ b/app/features/core/components/notification.tsx @@ -0,0 +1,94 @@ +import { Fragment, useEffect, useState } from "react"; +import { useNavigate } from "@remix-run/react"; +import { Transition } from "@headlessui/react"; +import { useAtom } from "jotai"; + +import useNotifications, { notificationDataAtom } from "~/features/core/hooks/use-notifications"; + +export default function Notification() { + useNotifications(); + const navigate = useNavigate(); + const [notificationData] = useAtom(notificationDataAtom); + const [show, setShow] = useState(notificationData !== null); + const close = () => setShow(false); + const actions = buildActions(); + + useEffect(() => { + setShow(notificationData !== null); + }, [notificationData]); + + return ( +
+
+ {/* Notification panel, dynamically insert this into the live region when it needs to be displayed */} + +
+
+
+

{notificationData?.data.recipient}

+

{notificationData?.body}

+
+
+
+
+
+ +
+
+ +
+
+
+
+
+
+
+ ); + + function buildActions() { + if (!notificationData) { + return [ + { title: "", onClick: close }, + { title: "", onClick: close }, + ]; + } + + return { + message: [ + { + title: "Reply", + onClick: () => { + navigate(`/messages/${encodeURIComponent(notificationData.data.recipient)}`); + close(); + }, + }, + { title: "Close", onClick: close }, + ], + call: [ + { title: "Answer", onClick: close }, + { title: "Decline", onClick: close }, + ], + }[notificationData.data.type]; + } +} diff --git a/app/features/core/hooks/use-notifications.ts b/app/features/core/hooks/use-notifications.ts new file mode 100644 index 0000000..9c9d447 --- /dev/null +++ b/app/features/core/hooks/use-notifications.ts @@ -0,0 +1,34 @@ +import { useEffect } from "react"; +import { atom, useAtom } from "jotai"; + +import type { NotificationPayload } from "~/utils/web-push.server"; + +export default function useNotifications() { + const [notificationData, setNotificationData] = useAtom(notificationDataAtom); + + useEffect(() => { + const channel = new BroadcastChannel("notifications"); + async function eventHandler(event: MessageEvent) { + const payload: NotificationPayload = JSON.parse(event.data); + setNotificationData(payload); + } + + channel.addEventListener("message", eventHandler); + + return () => { + channel.removeEventListener("message", eventHandler); + channel.close(); + }; + }, []); + + useEffect(() => { + if (!notificationData) { + return; + } + + const timeout = setTimeout(() => setNotificationData(null), 5000); + return () => clearTimeout(timeout); + }, [notificationData]); +} + +export const notificationDataAtom = atom(null); diff --git a/app/queues/notify-incoming-message.server.ts b/app/queues/notify-incoming-message.server.ts new file mode 100644 index 0000000..40997cf --- /dev/null +++ b/app/queues/notify-incoming-message.server.ts @@ -0,0 +1,43 @@ +import { Queue } from "~/utils/queue.server"; +import db from "~/utils/db.server"; +import logger from "~/utils/logger.server"; +import { buildMessageNotificationPayload, notify } from "~/utils/web-push.server"; +import getTwilioClient from "~/utils/twilio.server"; + +type Payload = { + messageSid: string; + phoneNumberId: string; +}; + +export default Queue("notify incoming message", async ({ data }) => { + const { messageSid, phoneNumberId } = data; + const phoneNumber = await db.phoneNumber.findUnique({ + where: { id: phoneNumberId }, + select: { + twilioAccount: { + include: { + organization: { + select: { + memberships: { + select: { notificationSubscription: true }, + }, + }, + }, + }, + }, + }, + }); + if (!phoneNumber) { + logger.warn(`No phone number found with id=${phoneNumberId}`); + return; + } + const subscriptions = phoneNumber.twilioAccount.organization.memberships.flatMap( + (membership) => membership.notificationSubscription, + ); + + const twilioClient = getTwilioClient(phoneNumber.twilioAccount); + const message = await twilioClient.messages.get(messageSid).fetch(); + const payload = buildMessageNotificationPayload(message); + + await notify(subscriptions, payload); +}); diff --git a/app/routes/__app.tsx b/app/routes/__app.tsx index 6163304..a152bd8 100644 --- a/app/routes/__app.tsx +++ b/app/routes/__app.tsx @@ -5,6 +5,7 @@ import serverConfig from "~/config/config.server"; import { type SessionData, requireLoggedIn } from "~/utils/auth.server"; 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 footerStyles from "~/features/core/components/footer.css"; import appStyles from "~/styles/app.css"; @@ -36,17 +37,20 @@ export default function __App() { const hideFooter = matches.some((match) => match.handle?.hideFooter === true); return ( -
-
- -
-
- -
+ <> +
+
+ +
+
+ +
+
+ {hideFooter ? null :
}
- {hideFooter ? null :
}
-
+ + ); } diff --git a/app/routes/webhook/message.ts b/app/routes/webhook/message.ts index 69841e3..cffa51b 100644 --- a/app/routes/webhook/message.ts +++ b/app/routes/webhook/message.ts @@ -1,13 +1,16 @@ import { type ActionFunction } from "@remix-run/node"; import { badRequest, html, notFound, serverError } from "remix-utils"; +import twilio from "twilio"; +import { z } from "zod"; import { Prisma, SubscriptionStatus } from "@prisma/client"; import insertIncomingMessageQueue from "~/queues/insert-incoming-message.server"; +import notifyIncomingMessageQueue from "~/queues/notify-incoming-message.server"; import logger from "~/utils/logger.server"; import db from "~/utils/db.server"; -import twilio from "twilio"; import { smsUrl } from "~/utils/twilio.server"; import { decrypt } from "~/utils/encryption"; +import { validate } from "~/utils/validation.server"; export const action: ActionFunction = async ({ request }) => { const twilioSignature = request.headers.get("X-Twilio-Signature") || request.headers.get("x-twilio-signature"); @@ -15,7 +18,14 @@ export const action: ActionFunction = async ({ request }) => { return badRequest("Invalid header X-Twilio-Signature"); } - const body: Body = Object.fromEntries(await request.formData()) as any; + const formData = Object.fromEntries(await request.formData()); + const validation = validate(bodySchema, formData); + if (validation.errors) { + logger.error(validation.errors); + return badRequest(""); + } + + const body = validation.data; try { const phoneNumbers = await db.phoneNumber.findMany({ where: { number: body.To }, @@ -47,7 +57,7 @@ export const action: ActionFunction = async ({ request }) => { return notFound("Phone number not found"); } - const phoneNumbersWithActiveSub = phoneNumbers.filter( + /*const phoneNumbersWithActiveSub = phoneNumbers.filter( (phoneNumber) => phoneNumber.twilioAccount.organization.subscriptions.length > 0, ); if (phoneNumbersWithActiveSub.length === 0) { @@ -55,7 +65,7 @@ export const action: ActionFunction = async ({ request }) => { // because the organization is on the free plan console.log("no active subscription"); // TODO: uncomment the line below -- beware: refresh phone numbers refetch those missed messages lol // return html(""); - } + }*/ const phoneNumber = phoneNumbers.find((phoneNumber) => { // TODO: uncomment the line below @@ -65,7 +75,7 @@ export const action: ActionFunction = async ({ request }) => { // maybe we shouldn't let that happen by restricting a phone number to one org? const encryptedAuthToken = phoneNumber.twilioAccount.authToken; const authToken = encryptedAuthToken ? decrypt(encryptedAuthToken) : ""; - return twilio.validateRequest(authToken, twilioSignature, smsUrl, body); + return twilio.validateRequest(authToken, twilioSignature, smsUrl, formData); }); if (!phoneNumber) { return badRequest("Invalid webhook"); @@ -73,10 +83,16 @@ export const action: ActionFunction = async ({ request }) => { const messageSid = body.MessageSid; const phoneNumberId = phoneNumber.id; - await insertIncomingMessageQueue.add(`insert message ${messageSid} for ${phoneNumberId}`, { - messageSid, - phoneNumberId, - }); + await Promise.all([ + insertIncomingMessageQueue.add(`insert message ${messageSid} for ${phoneNumberId}`, { + messageSid, + phoneNumberId, + }), + notifyIncomingMessageQueue.add(`notify incoming message ${messageSid} for ${phoneNumberId}`, { + messageSid, + phoneNumberId, + }), + ]); return html(""); } catch (error: any) { @@ -86,24 +102,25 @@ export const action: ActionFunction = async ({ request }) => { } }; -type Body = { - ToCountry: string; - ToState: string; - SmsMessageSid: string; - NumMedia: string; - ToCity: string; - FromZip: string; - SmsSid: string; - FromState: string; - SmsStatus: string; - FromCity: string; - Body: string; - FromCountry: string; - To: string; - ToZip: string; - NumSegments: string; - MessageSid: string; - AccountSid: string; - From: string; - ApiVersion: string; -}; +const bodySchema = z.object({ + MessageSid: z.string(), + To: z.string(), + ToCountry: z.string().optional(), + ToState: z.string().optional(), + SmsMessageSid: z.string().optional(), + NumMedia: z.string().optional(), + ToCity: z.string().optional(), + FromZip: z.string().optional(), + SmsSid: z.string().optional(), + FromState: z.string().optional(), + SmsStatus: z.string().optional(), + FromCity: z.string().optional(), + Body: z.string().optional(), + FromCountry: z.string().optional(), + ToZip: z.string().optional(), + NumSegments: z.string().optional(), + AccountSid: z.string().optional(), + From: z.string().optional(), + ApiVersion: z.string().optional(), + ReferralNumMedia: z.string().optional(), +}); diff --git a/app/service-worker/push.ts b/app/service-worker/push.ts index 24d3c8f..363f0ad 100644 --- a/app/service-worker/push.ts +++ b/app/service-worker/push.ts @@ -12,8 +12,17 @@ const defaultOptions: NotificationOptions = { }; export default async function handlePush(event: PushEvent) { - const { title, ...payload }: NotificationPayload = event.data!.json(); + const payload: NotificationPayload = event.data!.json(); const options = Object.assign({}, defaultOptions, payload); - await self.registration.showNotification(title, options); - await addBadge(1); + + const clients = await self.clients.matchAll({ type: "window" }); + const hasOpenTab = clients.some((client) => client.focused === true); + if (hasOpenTab) { + const channel = new BroadcastChannel("notifications"); + channel.postMessage(JSON.stringify(payload)); + channel.close(); + } else { + await self.registration.showNotification(payload.title, options); + await addBadge(1); + } } diff --git a/app/utils/web-push.server.ts b/app/utils/web-push.server.ts index 98cffd7..95b8799 100644 --- a/app/utils/web-push.server.ts +++ b/app/utils/web-push.server.ts @@ -1,13 +1,20 @@ import webpush, { type PushSubscription, WebPushError } from "web-push"; -import type { NotificationSubscription } from "@prisma/client"; +import type { MessageInstance } from "twilio/lib/rest/api/v2010/account/message"; +import { parsePhoneNumber } from "awesome-phonenumber"; +import { type NotificationSubscription, Direction } from "@prisma/client"; import serverConfig from "~/config/config.server"; import db from "~/utils/db.server"; import logger from "~/utils/logger.server"; +import { translateMessageDirection } from "~/utils/twilio.server"; -export type NotificationPayload = NotificationOptions & { +export type NotificationPayload = Omit & { title: string; // max 50 characters body: string; // max 150 characters + data: { + recipient: string; + type: "message" | "call"; + }; }; export async function notify(subscriptions: NotificationSubscription[], payload: NotificationPayload) { @@ -50,3 +57,19 @@ function truncate(str: string, maxLength: number) { return str.substring(0, maxLength - 1) + "\u2026"; } + +export function buildMessageNotificationPayload(message: MessageInstance): NotificationPayload { + const direction = translateMessageDirection(message.direction); + const recipient = direction === Direction.Outbound ? message.to : message.from; + return { + title: parsePhoneNumber(recipient).getNumber("international"), + body: message.body, + actions: [ + { + action: "reply", + title: "Reply", + }, + ], + data: { recipient, type: "message" }, + }; +}