push notifications but installable to home screen yet

This commit is contained in:
m5r
2022-05-30 02:21:42 +02:00
parent b5bb8e1822
commit 4c22ee83e1
40 changed files with 1104 additions and 83 deletions

View File

@ -28,6 +28,14 @@ invariant(
typeof process.env.MASTER_ENCRYPTION_KEY === "string",
`Please define the "MASTER_ENCRYPTION_KEY" environment variable`,
);
invariant(
typeof process.env.WEB_PUSH_VAPID_PRIVATE_KEY === "string",
`Please define the "WEB_PUSH_VAPID_PRIVATE_KEY" environment variable`,
);
invariant(
typeof process.env.WEB_PUSH_VAPID_PUBLIC_KEY === "string",
`Please define the "WEB_PUSH_VAPID_PUBLIC_KEY" environment variable`,
);
export default {
app: {
@ -49,4 +57,8 @@ export default {
twilio: {
authToken: process.env.TWILIO_AUTH_TOKEN,
},
webPush: {
privateKey: process.env.WEB_PUSH_VAPID_PRIVATE_KEY,
publicKey: process.env.WEB_PUSH_VAPID_PUBLIC_KEY,
},
};

View File

@ -2,3 +2,14 @@ import { hydrate } from "react-dom";
import { RemixBrowser } from "@remix-run/react";
hydrate(<RemixBrowser />, document);
if ("serviceWorker" in navigator) {
window.addEventListener("load", async () => {
try {
await navigator.serviceWorker.register("/entry.worker.js");
await navigator.serviceWorker.ready;
} catch (error) {
console.error("Service worker registration failed", error, (error as Error).name);
}
});
}

47
app/entry.worker.ts Normal file
View File

@ -0,0 +1,47 @@
/// <reference lib="WebWorker" />
import type { NotificationPayload } from "~/utils/web-push.server";
import { addBadge, removeBadge } from "~/utils/pwa.client";
declare let self: ServiceWorkerGlobalScope;
const defaultOptions: NotificationOptions = {
icon: "/icons/android-chrome-192x192.png",
badge: "/icons/android-chrome-48x48.png",
dir: "auto",
image: undefined,
silent: false,
};
self.addEventListener("push", (event) => {
const { title, ...payload }: NotificationPayload = JSON.parse(event?.data!.text());
const options = Object.assign({}, defaultOptions, payload);
event.waitUntil(async () => {
await self.registration.showNotification(title, options);
await addBadge(1);
});
});
self.addEventListener("notificationclick", (event) => {
event.waitUntil(
(async () => {
console.log("On notification click: ", event.notification.tag);
// Android doesnt close the notification when you click on it
// See: http://crbug.com/463146
event.notification.close();
await removeBadge();
if (event.action === "reply") {
const recipient = encodeURIComponent(event.notification.data.recipient);
return self.clients.openWindow?.(`/messages/${recipient}`);
}
if (event.action === "answer") {
const recipient = encodeURIComponent(event.notification.data.recipient);
return self.clients.openWindow?.(`/incoming-call/${recipient}`);
}
return self.clients.openWindow?.("/");
})(),
);
});

View File

@ -0,0 +1,104 @@
import { type ActionFunction } from "@remix-run/node";
import { badRequest, notFound } from "remix-utils";
import { z } from "zod";
import db from "~/utils/db.server";
import logger from "~/utils/logger.server";
import { validate } from "~/utils/validation.server";
import { requireLoggedIn } from "~/utils/auth.server";
const action: ActionFunction = async ({ request }) => {
const formData = await request.clone().formData();
const action = formData.get("_action");
if (!action) {
const errorMessage = "POST /notifications-subscription without any _action";
logger.error(errorMessage);
return badRequest({ errorMessage });
}
switch (action as Action) {
case "subscribe":
return subscribe(request);
case "unsubscribe":
return unsubscribe(request);
default:
const errorMessage = `POST /notifications-subscription with an invalid _action=${action}`;
logger.error(errorMessage);
return badRequest({ errorMessage });
}
};
export default action;
async function subscribe(request: Request) {
const { organization } = await requireLoggedIn(request);
const formData = await request.formData();
const body = {
subscription: JSON.parse(formData.get("subscription")?.toString() ?? "{}"),
};
const validation = validate(validations.subscribe, body);
if (validation.errors) {
return badRequest(validation.errors);
}
const { subscription } = validation.data;
const membership = await db.membership.findFirst({
where: { id: organization.membershipId },
});
if (!membership) {
return notFound("Phone number not found");
}
try {
await db.notificationSubscription.create({
data: {
membershipId: membership.id,
endpoint: subscription.endpoint,
expirationTime: subscription.expirationTime,
keys_p256dh: subscription.keys.p256dh,
keys_auth: subscription.keys.auth,
},
});
} catch (error: any) {
if (error.code !== "P2002") {
logger.error(error);
throw error;
}
}
return null;
}
async function unsubscribe(request: Request) {
const formData = await request.formData();
const body = {
subscriptionEndpoint: formData.get("subscriptionEndpoint"),
};
const validation = validate(validations.unsubscribe, body);
if (validation.errors) {
return badRequest(validation.errors);
}
const endpoint = validation.data.subscriptionEndpoint;
await db.notificationSubscription.delete({ where: { endpoint } });
return null;
}
type Action = "subscribe" | "unsubscribe";
const validations = {
subscribe: z.object({
subscription: z.object({
endpoint: z.string(),
expirationTime: z.number().nullable(),
keys: z.object({
p256dh: z.string(),
auth: z.string(),
}),
}),
}),
unsubscribe: z.object({
subscriptionEndpoint: z.string(),
}),
} as const;

View File

@ -0,0 +1,4 @@
.footer-ios {
margin-bottom: var(--safe-area-bottom);
padding-bottom: var(--safe-area-bottom);
}

View File

@ -6,7 +6,7 @@ import clsx from "clsx";
export default function Footer() {
return (
<footer
className="grid grid-cols-4 bg-[#F7F7F7] h-16 border-t border-gray-400 border-opacity-25 py-2 z-10"
className="footer-ios grid grid-cols-4 bg-[#F7F7F7] h-16 border-t border-gray-400 border-opacity-25 py-2 z-10"
// className="grid grid-cols-4 border-t border-gray-400 border-opacity-25 py-3 z-10 backdrop-blur"
>
<FooterLink label="Calls" path="/calls" icon={<IoCall className="w-6 h-6" />} />

View File

@ -0,0 +1,13 @@
import { useMatches } from "@remix-run/react";
import type { AppLoaderData } from "~/routes/__app";
export default function useAppLoaderData() {
const matches = useMatches();
const __appRoute = matches.find((match) => match.id === "routes/__app");
if (!__appRoute) {
throw new Error("useSession hook called outside _app route");
}
return __appRoute.data as AppLoaderData;
}

View File

@ -0,0 +1,90 @@
import { useEffect, useMemo, useState } from "react";
import { useFetcher } from "@remix-run/react";
import useAppLoaderData from "~/features/core/hooks/use-app-loader-data";
export default function useNotifications() {
const isServiceWorkerSupported = useMemo(() => "serviceWorker" in navigator, []);
const [subscription, setSubscription] = useState<PushSubscription | null>(null);
const { webPushPublicKey } = useAppLoaderData().config;
const fetcher = useFetcher();
const subscribeToNotifications = (subscription: PushSubscriptionJSON) => {
fetcher.submit(
{
_action: "subscribe",
subscription: JSON.stringify(subscription),
},
{ method: "post", action: "/notifications-subscription" },
);
};
const unsubscribeFromNotifications = (subscriptionEndpoint: PushSubscription["endpoint"]) => {
fetcher.submit(
{
_action: "unsubscribe",
subscriptionEndpoint,
},
{ method: "post", action: "/notifications-subscription" },
);
};
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 || subscription !== null || fetcher.state !== "idle") {
return;
}
const registration = await navigator.serviceWorker.ready;
const newSubscription = await registration.pushManager.subscribe({
userVisibleOnly: true,
applicationServerKey: urlBase64ToUint8Array(webPushPublicKey),
});
setSubscription(newSubscription);
subscribeToNotifications(newSubscription.toJSON());
}
async function unsubscribe() {
if (!isServiceWorkerSupported || !subscription || fetcher.state !== "idle") {
return;
}
subscription
.unsubscribe()
.then(() => {
console.log("Unsubscribed from notifications");
setSubscription(null);
})
.catch((error) => console.error("Failed to unsubscribe from notifications", error));
unsubscribeFromNotifications(subscription.endpoint);
}
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

@ -1,13 +1,5 @@
import { useMatches } from "@remix-run/react";
import type { SessionData } from "~/utils/auth.server";
import useAppLoaderData from "~/features/core/hooks/use-app-loader-data";
export default function useSession() {
const matches = useMatches();
const __appRoute = matches.find((match) => match.id === "routes/__app");
if (!__appRoute) {
throw new Error("useSession hook called outside _app route");
}
return __appRoute.data as SessionData;
return useAppLoaderData().sessionData;
}

View File

@ -31,6 +31,7 @@ const action: ActionFunction = async ({ params, request }) => {
phoneNumberId: phoneNumber!.id,
id: message.sid,
to: message.to,
recipient: message.to,
from: message.from,
status: translateMessageStatus(message.status),
direction: translateMessageDirection(message.direction),

View File

@ -1,4 +1,4 @@
import type { FunctionComponent, ReactNode } from "react";
import { type FunctionComponent, type PropsWithChildren } from "react";
import type { LinksFunction } from "@remix-run/node";
import { Link, Links, LiveReload, Meta, Outlet, Scripts, ScrollRestoration, useCatch } from "@remix-run/react";
@ -6,35 +6,7 @@ import Logo from "~/features/core/components/logo";
import styles from "./styles/tailwind.css";
export const links: LinksFunction = () => [
{ rel: "stylesheet", href: styles },
{
rel: "icon",
href: "/favicon.ico",
},
{
rel: "apple-touch-icon",
sizes: "180x180",
href: "/apple-touch-icon.png",
},
{
rel: "icon",
type: "image/png",
sizes: "32x32",
href: "/favicon-32x32.png",
},
{
rel: "icon",
type: "image/png",
sizes: "16x16",
href: "/favicon-16x16.png",
},
{
rel: "mask-icon",
href: "/safari-pinned-tab.svg",
color: "#663399",
},
];
export const links: LinksFunction = () => [{ rel: "stylesheet", href: styles }];
export default function App() {
return (
@ -92,11 +64,29 @@ export function CatchBoundary() {
);
}
const Document: FunctionComponent<{ children: ReactNode }> = ({ children }) => (
const Document: FunctionComponent<PropsWithChildren<{}>> = ({ children }) => (
<html lang="en" className="h-full">
<head>
<meta charSet="utf-8" />
<meta name="viewport" content="width=device-width,initial-scale=1" />
<meta name="viewport" content="width=device-width,initial-scale=1,viewport-fit=cover" />
<meta name="apple-mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-title" content="Shellphone" />
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
<meta name="mobile-web-app-capable" content="yes" />
<meta name="application-name" content="Shellphone" />
<meta name="theme-color" content="#F4F4F5" />
<meta name="msapplication-navbutton-color" content="#F4F4F5" />
<meta name="msapplication-starturl" content="/messages" />
<meta name="msapplication-TileColor" content="#F4F4F5" />
<link rel="apple-touch-icon" sizes="180x180" href="/icons/apple-touch-icon.png" />
<link rel="icon" type="image/png" sizes="32x32" href="/icons/favicon-32x32.png" />
<link rel="icon" type="image/png" sizes="16x16" href="/icons/favicon-16x16.png" />
<link rel="mask-icon" href="/icons/safari-pinned-tab.svg" color="#F4F4F5" />
<link rel="manifest" href="/manifest.webmanifest" />
<Meta />
<Links />
</head>

View File

@ -1,15 +1,31 @@
import { type LoaderFunction, json } from "@remix-run/node";
import { type LinksFunction, type LoaderFunction, json } from "@remix-run/node";
import { Outlet, useCatch, useMatches } from "@remix-run/react";
import serverConfig from "~/config/config.server";
import { type SessionData, requireLoggedIn } from "~/utils/auth.server";
import Footer from "~/features/core/components/footer";
import footerStyles from "~/features/core/components/footer.css";
import appStyles from "~/styles/app.css";
export type AppLoaderData = SessionData;
export const links: LinksFunction = () => [
{ rel: "stylesheet", href: appStyles },
{ rel: "stylesheet", href: footerStyles },
];
export type AppLoaderData = {
sessionData: SessionData;
config: { webPushPublicKey: string };
};
export const loader: LoaderFunction = async ({ request }) => {
const sessionData = await requireLoggedIn(request);
return json<AppLoaderData>(sessionData);
return json<AppLoaderData>({
sessionData,
config: {
webPushPublicKey: serverConfig.webPush.publicKey,
},
});
};
export default function __App() {
@ -24,7 +40,7 @@ export default function __App() {
<Outlet />
</main>
</div>
{!hideFooter ? <Footer /> : null}
{hideFooter ? null : <Footer />}
</div>
</div>
);

View File

@ -0,0 +1,3 @@
import notificationsSubscriptionAction from "~/features/core/actions/notifications-subscription";
export const action = notificationsSubscriptionAction;

View File

@ -8,6 +8,7 @@ import {
IoCardOutline,
IoCallOutline,
IoPersonCircleOutline,
IoHelpBuoyOutline,
} from "react-icons/io5";
import Divider from "~/features/settings/components/divider";
@ -18,6 +19,7 @@ const subNavigation = [
{ name: "Phone", to: "/settings/phone", icon: IoCallOutline },
{ name: "Billing", to: "/settings/billing", icon: IoCardOutline },
{ name: "Notifications", to: "/settings/notifications", icon: IoNotificationsOutline },
{ name: "Support", to: "/settings/support", icon: IoHelpBuoyOutline },
];
export const meta: MetaFunction = () => ({
@ -90,4 +92,4 @@ export default function SettingsLayout() {
</main>
</section>
);
};
}

View File

@ -15,7 +15,7 @@ function useSubscription() {
};
}
function Billing() {
export default function Billing() {
const { count: paymentsCount } = usePaymentsHistory();
const { subscription, cancelSubscription, updatePaymentMethod } = useSubscription();
@ -64,5 +64,3 @@ const plansName: Record<number, string> = {
727544: "Yearly",
727540: "Monthly",
};
export default Billing;

View File

@ -1,7 +1,158 @@
import { type ElementType, useEffect, useState } from "react";
import type { ActionFunction } from "@remix-run/node";
import { ClientOnly } from "remix-utils";
import { Switch } from "@headlessui/react";
import clsx from "clsx";
import useNotifications from "~/features/core/hooks/use-notifications.client";
import Alert from "~/features/core/components/alert";
import { Form } from "@remix-run/react";
import { notify } from "~/utils/web-push.server";
import Button from "~/features/settings/components/button";
export const action: ActionFunction = async () => {
await notify("PN4f11f0c4155dfb5d5ac8bbab2cc23cbc", {
title: "+33 6 13 37 07 87",
body: "wesh le zin, wesh la zine, copain copine mais si y'a moyen on pine",
actions: [
{
action: "reply",
title: "Reply",
},
],
data: { recipient: "+33613370787" },
});
return null;
};
export default function NotificationsPage() {
return <ClientOnly fallback={<Loader />}>{() => <Notifications />}</ClientOnly>;
}
function Notifications() {
const { subscription, subscribe, unsubscribe } = useNotifications();
const [notificationsEnabled, setNotificationsEnabled] = useState(!!subscription);
const [errorMessage, setErrorMessage] = useState("");
const [isChanging, setIsChanging] = useState(false);
const onChange = async (checked: boolean) => {
if (isChanging) {
return;
}
setIsChanging(true);
setNotificationsEnabled(checked);
setErrorMessage("");
try {
if (checked) {
await subscribe();
} else {
await unsubscribe();
}
} catch (error: any) {
console.error(error);
setNotificationsEnabled(!checked);
switch (error.name) {
case "NotAllowedError":
setErrorMessage(
"Your browser is not allowing Shellphone to register push notifications for you. Please allow Shellphone's notifications in your browser's settings if you wish to receive them.",
);
break;
case "TypeError":
setErrorMessage("Your browser does not support push notifications yet.");
break;
}
} finally {
setIsChanging(false);
}
};
useEffect(() => {
setNotificationsEnabled(!!subscription);
}, [subscription]);
return (
<div>Coming soon</div>
<section className="pt-6 divide-y divide-gray-200">
<section>
<Form method="post">
<Button variant="default" type="submit">
send it!!!
</Button>
</Form>
</section>
<div className="px-4 sm:px-6">
<div>
<h2 className="text-lg leading-6 font-medium text-gray-900">Notifications</h2>
</div>
<ul className="mt-2 divide-y divide-gray-200">
<Toggle
as="li"
checked={notificationsEnabled}
description="Get notified on this device when you receive a message or a phone call"
onChange={onChange}
title="Enable notifications"
/>
</ul>
{errorMessage !== "" && <Alert title="Browser error" message={errorMessage} variant="error" />}
</div>
</section>
);
}
export default Notifications;
type ToggleProps = {
as?: ElementType;
checked: boolean;
description?: string;
onChange(checked: boolean): void;
title: string;
};
function Toggle({ as, checked, description, onChange, title }: ToggleProps) {
return (
<Switch.Group as={as} className="py-4 flex items-center justify-between">
<div className="flex flex-col">
<Switch.Label as="p" className="text-sm font-medium text-gray-900" passive>
{title}
</Switch.Label>
{description && (
<Switch.Description as="span" className="text-sm text-gray-500">
{description}
</Switch.Description>
)}
</div>
<Switch
checked={checked}
onChange={onChange}
className={clsx(
checked ? "bg-primary-500" : "bg-gray-200",
"ml-4 relative inline-flex flex-shrink-0 h-6 w-11 border-2 border-transparent rounded-full cursor-pointer transition-colors ease-in-out duration-200 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-sky-500",
)}
>
<span
aria-hidden="true"
className={clsx(
checked ? "translate-x-5" : "translate-x-0",
"inline-block h-5 w-5 rounded-full bg-white shadow transform ring-0 transition ease-in-out duration-200",
)}
/>
</Switch>
</Switch.Group>
);
}
function Loader() {
return (
<svg
className="animate-spin mx-auto h-5 w-5 text-primary-700"
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
>
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
<path
className="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
/>
</svg>
);
}

View File

@ -7,7 +7,7 @@ export const loader = settingsPhoneLoader;
export const action = settingsPhoneAction;
function PhoneSettings() {
export default function PhoneSettings() {
return (
<div className="flex flex-col space-y-6">
<TwilioConnect />
@ -15,5 +15,3 @@ function PhoneSettings() {
</div>
);
}
export default PhoneSettings;

View File

@ -0,0 +1,9 @@
export default function SupportPage() {
return (
<div>
<a className="underline" href="mailto:support@shellphone.app">
Email us
</a>
</div>
);
}

View File

@ -0,0 +1,20 @@
import type { LoaderFunction } from "@remix-run/node";
const manifest = `CACHE MANIFEST
# Version 1.0000
NETWORK:
*
`;
export const loader: LoaderFunction = async () => {
const headers = new Headers({
"Content-Type": "text/cache-manifest",
"Cache-Control": "max-age=0, no-cache, no-store, must-revalidate",
Pragma: "no-cache",
Expires: "Thu, 01 Jan 1970 00:00:01 GMT",
});
return new Response(manifest, { headers });
};

10
app/styles/app.css Normal file
View File

@ -0,0 +1,10 @@
:root {
--safe-area-top: env(safe-area-inset-top);
--safe-area-right: env(safe-area-inset-right);
--safe-area-bottom: env(safe-area-inset-bottom); /* THIS ONE GETS US THE HOME BAR HEIGHT */
--safe-area-left: env(safe-area-inset-left);
}
body {
padding: env(safe-area-inset-top) env(safe-area-inset-right) env(safe-area-inset-bottom) env(safe-area-inset-left);
}

View File

@ -13,7 +13,7 @@ type SessionTwilioAccount = Pick<
TwilioAccount,
"accountSid" | "subAccountSid" | "subAccountAuthToken" | "apiKeySid" | "apiKeySecret" | "twimlAppSid"
>;
type SessionOrganization = Pick<Organization, "id"> & { role: MembershipRole };
type SessionOrganization = Pick<Organization, "id"> & { role: MembershipRole; membershipId: string };
type SessionPhoneNumber = Pick<PhoneNumber, "id" | "number">;
export type SessionUser = Pick<User, "id" | "role" | "email" | "fullName">;
export type SessionData = {
@ -190,6 +190,7 @@ async function buildSessionData(id: string): Promise<SessionData> {
},
},
role: true,
id: true,
},
},
},
@ -203,6 +204,7 @@ async function buildSessionData(id: string): Promise<SessionData> {
const organizations = memberships.map((membership) => ({
...membership.organization,
role: membership.role,
membershipId: membership.id,
}));
const { twilioAccount, ...organization } = organizations[0];
const phoneNumber = await db.phoneNumber.findUnique({

70
app/utils/pwa.client.ts Normal file
View File

@ -0,0 +1,70 @@
type ResponseObject = {
status: "success" | "bad";
message: string;
};
// use case: prevent making phone calls / queue messages when offline
export async function checkConnectivity(online: () => void, offline: () => void): Promise<ResponseObject> {
try {
if (navigator.onLine) {
online();
return {
status: "success",
message: "Connected to the internet",
};
} else {
offline();
return {
status: "bad",
message: "No internet connection available",
};
}
} catch (err) {
console.debug(err);
throw new Error("Unable to check network connectivity!");
}
}
// use case: display unread messages + missed phone calls count
export async function addBadge(numberCount: number): Promise<ResponseObject> {
try {
//@ts-ignore
if (navigator.setAppBadge) {
//@ts-ignore
await navigator.setAppBadge(numberCount);
return {
status: "success",
message: "Badge successfully added",
};
} else {
return {
status: "bad",
message: "Badging API not supported",
};
}
} catch (err) {
console.debug(err);
throw new Error("Error adding badge!");
}
}
export async function removeBadge(): Promise<ResponseObject> {
try {
//@ts-ignore
if (navigator.clearAppBadge) {
//@ts-ignore
await navigator.clearAppBadge();
return {
status: "success",
message: "Cleared badges",
};
} else {
return {
status: "bad",
message: "Badging API not supported in this browser!",
};
}
} catch (error) {
console.debug(error);
throw new Error("Error removing badge!");
}
}

View File

@ -4,5 +4,5 @@ export const { getSeo, getSeoMeta, getSeoLinks } = initSeo({
title: "",
titleTemplate: "%s | Shellphone",
description: "",
defaultTitle: "Shellphone",
defaultTitle: "Shellphone: Your Personal Cloud Phone",
});

View File

@ -15,7 +15,7 @@ export default function getTwilioClient({
throw new Error("unreachable");
}
return twilio(subAccountSid, subAccountAuthToken ?? serverConfig.twilio.authToken, {
return twilio(subAccountSid, serverConfig.twilio.authToken, {
accountSid,
});
}

View File

@ -0,0 +1,57 @@
import webpush, { type PushSubscription, WebPushError } from "web-push";
import serverConfig from "~/config/config.server";
import db from "~/utils/db.server";
import logger from "~/utils/logger.server";
export type NotificationPayload = NotificationOptions & {
title: string;
body: string;
};
export async function notify(phoneNumberId: string, payload: NotificationPayload) {
webpush.setVapidDetails("mailto:mokht@rmi.al", serverConfig.webPush.publicKey, serverConfig.webPush.privateKey);
const phoneNumber = await db.phoneNumber.findUnique({
where: { id: phoneNumberId },
select: {
organization: {
select: {
memberships: {
select: { notificationSubscription: true },
},
},
},
},
});
if (!phoneNumber) {
// TODO
return;
}
const subscriptions = phoneNumber.organization.memberships.flatMap(
(membership) => membership.notificationSubscription,
);
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(payload));
} catch (error: any) {
logger.error(error);
if (error instanceof WebPushError) {
// subscription most likely expired or has been revoked
await db.notificationSubscription.delete({ where: { id: subscription.id } });
}
}
}),
);
}