send notification when sms arrives

This commit is contained in:
m5r 2021-08-02 00:28:47 +08:00
parent 1489f97c14
commit fef4c03458
14 changed files with 1834 additions and 25 deletions

View File

@ -33,7 +33,7 @@ export default async function ddd(req: BlitzApiRequest, res: BlitzApiResponse) {
// console.log(JSON.stringify(Object.keys(error))); // console.log(JSON.stringify(Object.keys(error)));
}*/ }*/
/*const ddd = await twilio(accountSid, authToken).messages.create({ /*const ddd = await twilio(accountSid, authToken).messages.create({
body: "content", body: "cccccasdasd",
to: "+33757592025", to: "+33757592025",
from: "+33757592722", from: "+33757592722",
});*/ });*/

View File

@ -0,0 +1,66 @@
import { getConfig, useMutation } from "blitz";
import { useEffect, useState } from "react";
import setNotificationSubscription from "../mutations/set-notification-subscription";
const { publicRuntimeConfig } = getConfig();
export default function useNotifications() {
const isServiceWorkerSupported = "serviceWorker" in navigator;
const [subscription, setSubscription] = useState<PushSubscription | null>(null);
const [setNotificationSubscriptionMutation] = useMutation(setNotificationSubscription);
useEffect(() => {
(async () => {
if (!isServiceWorkerSupported) {
return;
}
const registration = await navigator.serviceWorker.ready;
const subscription = await registration.pushManager.getSubscription();
setSubscription(subscription);
})();
}, [isServiceWorkerSupported]);
async function subscribe() {
if (!isServiceWorkerSupported) {
return;
}
const registration = await navigator.serviceWorker.ready;
const subscription = await registration.pushManager.subscribe({
userVisibleOnly: true,
applicationServerKey: urlBase64ToUint8Array(publicRuntimeConfig.webPush.publicKey),
});
setSubscription(subscription);
await setNotificationSubscriptionMutation({ subscription: subscription.toJSON() as any }); // TODO remove as any
}
async function unsubscribe() {
if (!isServiceWorkerSupported) {
return;
}
return subscription?.unsubscribe();
}
return {
isServiceWorkerSupported,
subscription,
subscribe,
unsubscribe,
};
}
function urlBase64ToUint8Array(base64String: string) {
const padding = "=".repeat((4 - (base64String.length % 4)) % 4);
const base64 = (base64String + padding).replace(/\-/g, "+").replace(/_/g, "/");
const rawData = window.atob(base64);
const outputArray = new Uint8Array(rawData.length);
for (let i = 0; i < rawData.length; ++i) {
outputArray[i] = rawData.charCodeAt(i);
}
return outputArray;
}

View File

@ -0,0 +1,38 @@
import { resolver } from "blitz";
import { z } from "zod";
import db from "../../../db";
import appLogger from "../../../integrations/logger";
const logger = appLogger.child({ mutation: "set-notification-subscription" });
const Body = z.object({
subscription: z.object({
endpoint: z.string(),
expirationTime: z.number().nullable(),
keys: z.object({
p256dh: z.string(),
auth: z.string(),
}),
}),
});
export default resolver.pipe(resolver.zod(Body), resolver.authorize(), async ({ subscription }, context) => {
const customerId = context.session.userId;
try {
await db.notificationSubscription.create({
data: {
customerId,
endpoint: subscription.endpoint,
expirationTime: subscription.expirationTime,
keys_p256dh: subscription.keys.p256dh,
keys_auth: subscription.keys.auth,
},
});
} catch (error) {
if (error.code !== "P2002") {
logger.error(error);
// we might want to `throw error`;
}
}
});

View File

@ -0,0 +1,59 @@
import { getConfig } from "blitz";
import { Queue } from "quirrel/blitz";
import type { MessageInstance } from "twilio/lib/rest/api/v2010/account/message";
import twilio from "twilio";
import webpush, { PushSubscription, WebPushError } from "web-push";
import db from "../../../../db";
import appLogger from "../../../../integrations/logger";
const { serverRuntimeConfig, publicRuntimeConfig } = getConfig();
const logger = appLogger.child({ queue: "notify-incoming-message" });
type Payload = {
customerId: string;
messageSid: MessageInstance["sid"];
};
const notifyIncomingMessageQueue = Queue<Payload>(
"api/queue/notify-incoming-message",
async ({ messageSid, customerId }) => {
webpush.setVapidDetails(
"mailto:mokht@rmi.al",
publicRuntimeConfig.webPush.publicKey,
serverRuntimeConfig.webPush.privateKey,
);
const customer = await db.customer.findFirst({ where: { id: customerId } });
if (!customer || !customer.accountSid || !customer.authToken) {
return;
}
const message = await twilio(customer.accountSid, customer.authToken).messages.get(messageSid).fetch();
const notification = { message: `${message.from} - ${message.body}` };
const subscriptions = await db.notificationSubscription.findMany({ where: { customerId: customer.id } });
await Promise.all(
subscriptions.map(async (subscription) => {
const webPushSubscription: PushSubscription = {
endpoint: subscription.endpoint,
keys: {
p256dh: subscription.keys_p256dh,
auth: subscription.keys_auth,
},
};
try {
await webpush.sendNotification(webPushSubscription, JSON.stringify(notification));
} catch (error) {
logger.error(error);
if (error instanceof WebPushError) {
// subscription most likely expired
await db.notificationSubscription.delete({ where: { id: subscription.id } });
}
}
}),
);
},
);
export default notifyIncomingMessageQueue;

View File

@ -6,6 +6,7 @@ import type { ApiError } from "../../../api/_types";
import appLogger from "../../../../integrations/logger"; import appLogger from "../../../../integrations/logger";
import db from "../../../../db"; import db from "../../../../db";
import insertIncomingMessageQueue from "../queue/insert-incoming-message"; import insertIncomingMessageQueue from "../queue/insert-incoming-message";
import notifyIncomingMessageQueue from "../queue/notify-incoming-message";
const logger = appLogger.child({ route: "/api/webhook/incoming-message" }); const logger = appLogger.child({ route: "/api/webhook/incoming-message" });
const { serverRuntimeConfig } = getConfig(); const { serverRuntimeConfig } = getConfig();
@ -70,16 +71,24 @@ export default async function incomingMessageHandler(req: BlitzApiRequest, res:
return; return;
} }
// TODO: send notification
const messageSid = body.MessageSid; const messageSid = body.MessageSid;
await insertIncomingMessageQueue.enqueue( const customerId = customer.id;
await Promise.all([
notifyIncomingMessageQueue.enqueue(
{ {
messageSid, messageSid,
customerId: customer.id, customerId,
}, },
{ id: messageSid }, { id: `notify-${messageSid}` },
); ),
insertIncomingMessageQueue.enqueue(
{
messageSid,
customerId,
},
{ id: `insert-${messageSid}` },
),
]);
res.status(200).end(); res.status(200).end();
} catch (error) { } catch (error) {

View File

@ -48,7 +48,7 @@ export default resolver.pipe(resolver.zod(Body), resolver.authorize(), async ({
content, content,
}, },
{ {
id: message.id, id: `insert-${message.id}`,
}, },
); );
}); });

View File

@ -1,4 +1,4 @@
import { Suspense } from "react"; import { Suspense, useEffect } from "react";
import type { BlitzPage } from "blitz"; import type { BlitzPage } from "blitz";
import { Routes } from "blitz"; import { Routes } from "blitz";
import { useAtom } from "jotai"; import { useAtom } from "jotai";
@ -8,11 +8,19 @@ import ConversationsList from "../components/conversations-list";
import NewMessageButton from "../components/new-message-button"; import NewMessageButton from "../components/new-message-button";
import NewMessageBottomSheet, { bottomSheetOpenAtom } from "../components/new-message-bottom-sheet"; import NewMessageBottomSheet, { bottomSheetOpenAtom } from "../components/new-message-bottom-sheet";
import useRequireOnboarding from "../../core/hooks/use-require-onboarding"; import useRequireOnboarding from "../../core/hooks/use-require-onboarding";
import useNotifications from "../../core/hooks/use-notifications";
const Messages: BlitzPage = () => { const Messages: BlitzPage = () => {
useRequireOnboarding(); useRequireOnboarding();
const { subscription, subscribe } = useNotifications();
const setIsOpen = useAtom(bottomSheetOpenAtom)[1]; const setIsOpen = useAtom(bottomSheetOpenAtom)[1];
useEffect(() => {
if (!subscription) {
subscribe();
}
}, [subscription?.endpoint]);
return ( return (
<> <>
<div className="flex flex-col space-y-6 p-6"> <div className="flex flex-col space-y-6 p-6">

View File

@ -1,5 +1,7 @@
import { BlitzConfig, sessionMiddleware, simpleRolesIsAuthorized } from "blitz"; import { BlitzConfig, sessionMiddleware, simpleRolesIsAuthorized } from "blitz";
const withPWA = require("next-pwa");
const config: BlitzConfig = { const config: BlitzConfig = {
middleware: [ middleware: [
sessionMiddleware({ sessionMiddleware({
@ -24,7 +26,15 @@ const config: BlitzConfig = {
}, },
masterEncryptionKey: process.env.MASTER_ENCRYPTION_KEY, masterEncryptionKey: process.env.MASTER_ENCRYPTION_KEY,
app: { app: {
baseUrl: process.env.QUIRREL_BASE_URL, baseUrl: process.env.APP_BASE_URL,
},
webPush: {
privateKey: process.env.WEB_PUSH_VAPID_PRIVATE_KEY,
},
},
publicRuntimeConfig: {
webPush: {
publicKey: process.env.WEB_PUSH_VAPID_PUBLIC_KEY,
}, },
}, },
/* Uncomment this to customize the webpack config /* Uncomment this to customize the webpack config
@ -36,4 +46,11 @@ const config: BlitzConfig = {
}, },
*/ */
}; };
module.exports = config;
module.exports = withPWA({
...config,
pwa: {
dest: "public",
disable: process.env.NODE_ENV !== "production",
},
});

View File

@ -0,0 +1,16 @@
-- CreateTable
CREATE TABLE "NotificationSubscription" (
"id" TEXT NOT NULL,
"createdAt" TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMPTZ NOT NULL,
"endpoint" TEXT NOT NULL,
"expirationTime" INTEGER,
"keys_p256dh" TEXT NOT NULL,
"keys_auth" TEXT NOT NULL,
"customerId" TEXT NOT NULL,
PRIMARY KEY ("id")
);
-- AddForeignKey
ALTER TABLE "NotificationSubscription" ADD FOREIGN KEY ("customerId") REFERENCES "Customer"("id") ON DELETE CASCADE ON UPDATE CASCADE;

View File

@ -0,0 +1,8 @@
/*
Warnings:
- A unique constraint covering the columns `[endpoint]` on the table `NotificationSubscription` will be added. If there are existing duplicate values, this will fail.
*/
-- CreateIndex
CREATE UNIQUE INDEX "NotificationSubscription.endpoint_unique" ON "NotificationSubscription"("endpoint");

View File

@ -81,6 +81,7 @@ model Customer {
messages Message[] messages Message[]
phoneCalls PhoneCall[] phoneCalls PhoneCall[]
phoneNumbers PhoneNumber[] phoneNumbers PhoneNumber[]
notificationSubscriptions NotificationSubscription[]
} }
model Message { model Message {
@ -152,3 +153,16 @@ model PhoneNumber {
customer Customer @relation(fields: [customerId], references: [id]) customer Customer @relation(fields: [customerId], references: [id])
customerId String customerId String
} }
model NotificationSubscription {
id String @id @default(uuid())
createdAt DateTime @default(now()) @db.Timestamptz
updatedAt DateTime @updatedAt @db.Timestamptz
endpoint String @unique
expirationTime Int?
keys_p256dh String
keys_auth String
customer Customer @relation(fields: [customerId], references: [id])
customerId String
}

1545
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -56,6 +56,7 @@
"concurrently": "6.2.0", "concurrently": "6.2.0",
"got": "11.8.2", "got": "11.8.2",
"jotai": "1.2.2", "jotai": "1.2.2",
"next-pwa": "5.2.24",
"pino": "6.13.0", "pino": "6.13.0",
"pino-pretty": "5.1.2", "pino-pretty": "5.1.2",
"postcss": "8.3.6", "postcss": "8.3.6",
@ -68,12 +69,14 @@
"react-use-gesture": "9.1.3", "react-use-gesture": "9.1.3",
"tailwindcss": "2.2.7", "tailwindcss": "2.2.7",
"twilio": "3.66.1", "twilio": "3.66.1",
"web-push": "3.4.5",
"zod": "3.5.1" "zod": "3.5.1"
}, },
"devDependencies": { "devDependencies": {
"@types/pino": "6.3.11", "@types/pino": "6.3.11",
"@types/preview-email": "2.0.1", "@types/preview-email": "2.0.1",
"@types/react": "17.0.15", "@types/react": "17.0.15",
"@types/web-push": "3.3.2",
"eslint": "7.32.0", "eslint": "7.32.0",
"husky": "6.0.0", "husky": "6.0.0",
"lint-staged": "10.5.4", "lint-staged": "10.5.4",

40
worker/index.js Normal file
View File

@ -0,0 +1,40 @@
"use strict";
self.addEventListener("push", function (event) {
console.log("event.data.text()", event.data.text());
const data = JSON.parse(event.data.text());
event.waitUntil(
registration.showNotification(data.title, {
body: data.message,
icon: "/icons/android-chrome-192x192.png",
}),
);
});
self.addEventListener("notificationclick", function (event) {
event.notification.close();
event.waitUntil(
clients.matchAll({ type: "window", includeUncontrolled: true }).then(function (clientList) {
if (clientList.length > 0) {
let client = clientList[0];
for (let i = 0; i < clientList.length; i++) {
if (clientList[i].focused) {
client = clientList[i];
}
}
return client.focus();
}
return clients.openWindow("/");
}),
);
});
// self.addEventListener('pushsubscriptionchange', function(event) {
// event.waitUntil(
// Promise.all([
// Promise.resolve(event.oldSubscription ? deleteSubscription(event.oldSubscription) : true),
// Promise.resolve(event.newSubscription ? event.newSubscription : subscribePush(registration))
// .then(function(sub) { return saveSubscription(sub) })
// ])
// )
// })