send notification when sms arrives
This commit is contained in:
parent
1489f97c14
commit
fef4c03458
@ -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",
|
||||||
});*/
|
});*/
|
||||||
|
66
app/core/hooks/use-notifications.ts
Normal file
66
app/core/hooks/use-notifications.ts
Normal 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;
|
||||||
|
}
|
38
app/core/mutations/set-notification-subscription.ts
Normal file
38
app/core/mutations/set-notification-subscription.ts
Normal 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`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
59
app/messages/api/queue/notify-incoming-message.ts
Normal file
59
app/messages/api/queue/notify-incoming-message.ts
Normal 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;
|
@ -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([
|
||||||
messageSid,
|
notifyIncomingMessageQueue.enqueue(
|
||||||
customerId: customer.id,
|
{
|
||||||
},
|
messageSid,
|
||||||
{ id: messageSid },
|
customerId,
|
||||||
);
|
},
|
||||||
|
{ id: `notify-${messageSid}` },
|
||||||
|
),
|
||||||
|
insertIncomingMessageQueue.enqueue(
|
||||||
|
{
|
||||||
|
messageSid,
|
||||||
|
customerId,
|
||||||
|
},
|
||||||
|
{ id: `insert-${messageSid}` },
|
||||||
|
),
|
||||||
|
]);
|
||||||
|
|
||||||
res.status(200).end();
|
res.status(200).end();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
@ -48,7 +48,7 @@ export default resolver.pipe(resolver.zod(Body), resolver.authorize(), async ({
|
|||||||
content,
|
content,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: message.id,
|
id: `insert-${message.id}`,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
@ -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">
|
||||||
|
@ -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",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
@ -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;
|
@ -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");
|
@ -77,10 +77,11 @@ model Customer {
|
|||||||
paddleCustomerId String?
|
paddleCustomerId String?
|
||||||
paddleSubscriptionId String?
|
paddleSubscriptionId String?
|
||||||
|
|
||||||
user User @relation(fields: [id], references: [id])
|
user User @relation(fields: [id], references: [id])
|
||||||
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
1545
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@ -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
40
worker/index.js
Normal 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) })
|
||||||
|
// ])
|
||||||
|
// )
|
||||||
|
// })
|
Loading…
Reference in New Issue
Block a user