notify of incoming messages and show in app notifications

This commit is contained in:
m5r 2022-06-12 23:17:22 +02:00
parent a77f02189e
commit ceaadc4f99
7 changed files with 268 additions and 44 deletions

View File

@ -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 (
<div aria-live="assertive" className="fixed inset-0 flex items-start px-4 py-6 pointer-events-none sm:p-6">
<div className="w-full flex flex-col items-center space-y-4 sm:items-end">
{/* Notification panel, dynamically insert this into the live region when it needs to be displayed */}
<Transition
show={show}
as={Fragment}
enter="transform ease-out duration-300 transition"
enterFrom="translate-y-2 opacity-0 sm:translate-y-0 sm:translate-x-2"
enterTo="translate-y-0 opacity-100 sm:translate-x-0"
leave="transition ease-in duration-100"
leaveFrom="opacity-100"
leaveTo="opacity-0"
>
<div className="max-w-md w-full bg-white shadow-lg rounded-lg pointer-events-auto flex ring-1 ring-black ring-opacity-5 divide-x divide-gray-200">
<div className="w-0 flex-1 flex items-center p-4">
<div className="w-full">
<p className="text-sm font-medium text-gray-900">{notificationData?.data.recipient}</p>
<p className="mt-1 text-sm text-gray-500">{notificationData?.body}</p>
</div>
</div>
<div className="flex">
<div className="flex flex-col divide-y divide-gray-200">
<div className="h-0 flex-1 flex">
<button
type="button"
className="w-full border border-transparent rounded-none rounded-tr-lg px-4 py-3 flex items-center justify-center text-sm font-medium text-indigo-600 hover:text-indigo-500 focus:outline-none focus:z-10 focus:ring-2 focus:ring-indigo-500"
onClick={actions[0].onClick}
>
{actions[0].title}
</button>
</div>
<div className="h-0 flex-1 flex">
<button
type="button"
className="w-full border border-transparent rounded-none rounded-br-lg px-4 py-3 flex items-center justify-center text-sm font-medium text-gray-700 hover:text-gray-500 focus:outline-none focus:ring-2 focus:ring-indigo-500"
onClick={actions[1].onClick}
>
{actions[1].title}
</button>
</div>
</div>
</div>
</div>
</Transition>
</div>
</div>
);
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];
}
}

View File

@ -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<NotificationPayload | null>(null);

View File

@ -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<Payload>("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);
});

View File

@ -5,6 +5,7 @@ import serverConfig from "~/config/config.server";
import { type SessionData, requireLoggedIn } from "~/utils/auth.server"; import { type SessionData, requireLoggedIn } from "~/utils/auth.server";
import Footer from "~/features/core/components/footer"; import Footer from "~/features/core/components/footer";
import ServiceWorkerUpdateNotifier from "~/features/core/components/service-worker-update-notifier"; 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 useServiceWorkerRevalidate from "~/features/core/hooks/use-service-worker-revalidate";
import footerStyles from "~/features/core/components/footer.css"; import footerStyles from "~/features/core/components/footer.css";
import appStyles from "~/styles/app.css"; import appStyles from "~/styles/app.css";
@ -36,17 +37,20 @@ export default function __App() {
const hideFooter = matches.some((match) => match.handle?.hideFooter === true); const hideFooter = matches.some((match) => match.handle?.hideFooter === true);
return ( return (
<div className="h-full w-full overflow-hidden fixed bg-gray-100"> <>
<div className="flex flex-col w-full h-full"> <div className="h-full w-full overflow-hidden fixed bg-gray-100">
<ServiceWorkerUpdateNotifier /> <div className="flex flex-col w-full h-full">
<div className="flex flex-col flex-1 w-full overflow-y-auto"> <ServiceWorkerUpdateNotifier />
<main className="flex flex-col flex-1 my-0 h-full"> <div className="flex flex-col flex-1 w-full overflow-y-auto">
<Outlet /> <main className="flex flex-col flex-1 my-0 h-full">
</main> <Outlet />
</main>
</div>
{hideFooter ? null : <Footer />}
</div> </div>
{hideFooter ? null : <Footer />}
</div> </div>
</div> <Notification />
</>
); );
} }

View File

@ -1,13 +1,16 @@
import { type ActionFunction } from "@remix-run/node"; import { type ActionFunction } from "@remix-run/node";
import { badRequest, html, notFound, serverError } from "remix-utils"; import { badRequest, html, notFound, serverError } from "remix-utils";
import twilio from "twilio";
import { z } from "zod";
import { Prisma, SubscriptionStatus } from "@prisma/client"; import { Prisma, SubscriptionStatus } from "@prisma/client";
import insertIncomingMessageQueue from "~/queues/insert-incoming-message.server"; import insertIncomingMessageQueue from "~/queues/insert-incoming-message.server";
import notifyIncomingMessageQueue from "~/queues/notify-incoming-message.server";
import logger from "~/utils/logger.server"; import logger from "~/utils/logger.server";
import db from "~/utils/db.server"; import db from "~/utils/db.server";
import twilio from "twilio";
import { smsUrl } from "~/utils/twilio.server"; import { smsUrl } from "~/utils/twilio.server";
import { decrypt } from "~/utils/encryption"; import { decrypt } from "~/utils/encryption";
import { validate } from "~/utils/validation.server";
export const action: ActionFunction = async ({ request }) => { export const action: ActionFunction = async ({ request }) => {
const twilioSignature = request.headers.get("X-Twilio-Signature") || request.headers.get("x-twilio-signature"); 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"); 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 { try {
const phoneNumbers = await db.phoneNumber.findMany({ const phoneNumbers = await db.phoneNumber.findMany({
where: { number: body.To }, where: { number: body.To },
@ -47,7 +57,7 @@ export const action: ActionFunction = async ({ request }) => {
return notFound("Phone number not found"); return notFound("Phone number not found");
} }
const phoneNumbersWithActiveSub = phoneNumbers.filter( /*const phoneNumbersWithActiveSub = phoneNumbers.filter(
(phoneNumber) => phoneNumber.twilioAccount.organization.subscriptions.length > 0, (phoneNumber) => phoneNumber.twilioAccount.organization.subscriptions.length > 0,
); );
if (phoneNumbersWithActiveSub.length === 0) { if (phoneNumbersWithActiveSub.length === 0) {
@ -55,7 +65,7 @@ export const action: ActionFunction = async ({ request }) => {
// because the organization is on the free plan // 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 console.log("no active subscription"); // TODO: uncomment the line below -- beware: refresh phone numbers refetch those missed messages lol
// return html("<Response></Response>"); // return html("<Response></Response>");
} }*/
const phoneNumber = phoneNumbers.find((phoneNumber) => { const phoneNumber = phoneNumbers.find((phoneNumber) => {
// TODO: uncomment the line below // 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? // maybe we shouldn't let that happen by restricting a phone number to one org?
const encryptedAuthToken = phoneNumber.twilioAccount.authToken; const encryptedAuthToken = phoneNumber.twilioAccount.authToken;
const authToken = encryptedAuthToken ? decrypt(encryptedAuthToken) : ""; const authToken = encryptedAuthToken ? decrypt(encryptedAuthToken) : "";
return twilio.validateRequest(authToken, twilioSignature, smsUrl, body); return twilio.validateRequest(authToken, twilioSignature, smsUrl, formData);
}); });
if (!phoneNumber) { if (!phoneNumber) {
return badRequest("Invalid webhook"); return badRequest("Invalid webhook");
@ -73,10 +83,16 @@ export const action: ActionFunction = async ({ request }) => {
const messageSid = body.MessageSid; const messageSid = body.MessageSid;
const phoneNumberId = phoneNumber.id; const phoneNumberId = phoneNumber.id;
await insertIncomingMessageQueue.add(`insert message ${messageSid} for ${phoneNumberId}`, { await Promise.all([
messageSid, insertIncomingMessageQueue.add(`insert message ${messageSid} for ${phoneNumberId}`, {
phoneNumberId, messageSid,
}); phoneNumberId,
}),
notifyIncomingMessageQueue.add(`notify incoming message ${messageSid} for ${phoneNumberId}`, {
messageSid,
phoneNumberId,
}),
]);
return html("<Response></Response>"); return html("<Response></Response>");
} catch (error: any) { } catch (error: any) {
@ -86,24 +102,25 @@ export const action: ActionFunction = async ({ request }) => {
} }
}; };
type Body = { const bodySchema = z.object({
ToCountry: string; MessageSid: z.string(),
ToState: string; To: z.string(),
SmsMessageSid: string; ToCountry: z.string().optional(),
NumMedia: string; ToState: z.string().optional(),
ToCity: string; SmsMessageSid: z.string().optional(),
FromZip: string; NumMedia: z.string().optional(),
SmsSid: string; ToCity: z.string().optional(),
FromState: string; FromZip: z.string().optional(),
SmsStatus: string; SmsSid: z.string().optional(),
FromCity: string; FromState: z.string().optional(),
Body: string; SmsStatus: z.string().optional(),
FromCountry: string; FromCity: z.string().optional(),
To: string; Body: z.string().optional(),
ToZip: string; FromCountry: z.string().optional(),
NumSegments: string; ToZip: z.string().optional(),
MessageSid: string; NumSegments: z.string().optional(),
AccountSid: string; AccountSid: z.string().optional(),
From: string; From: z.string().optional(),
ApiVersion: string; ApiVersion: z.string().optional(),
}; ReferralNumMedia: z.string().optional(),
});

View File

@ -12,8 +12,17 @@ const defaultOptions: NotificationOptions = {
}; };
export default async function handlePush(event: PushEvent) { 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); 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);
}
} }

View File

@ -1,13 +1,20 @@
import webpush, { type PushSubscription, WebPushError } from "web-push"; 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 serverConfig from "~/config/config.server";
import db from "~/utils/db.server"; import db from "~/utils/db.server";
import logger from "~/utils/logger.server"; import logger from "~/utils/logger.server";
import { translateMessageDirection } from "~/utils/twilio.server";
export type NotificationPayload = NotificationOptions & { export type NotificationPayload = Omit<NotificationOptions, "data"> & {
title: string; // max 50 characters title: string; // max 50 characters
body: string; // max 150 characters body: string; // max 150 characters
data: {
recipient: string;
type: "message" | "call";
};
}; };
export async function notify(subscriptions: NotificationSubscription[], payload: NotificationPayload) { 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"; 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" },
};
}