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

@ -21,3 +21,7 @@ PADDLE_VENDOR_ID=TODO
PADDLE_API_KEY=TODO
TWILIO_AUTH_TOKEN=TODO
# npx web-push generate-vapid-keys
WEB_PUSH_VAPID_PUBLIC_KEY=TODO
WEB_PUSH_VAPID_PRIVATE_KEY=TODO

1
.gitignore vendored
View File

@ -3,6 +3,7 @@ node_modules
/.cache
/server/build
/public/build
/public/entry.worker.js
/build
server.js
/app/styles/tailwind.css

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 } });
}
}
}),
);
}

387
package-lock.json generated
View File

@ -47,6 +47,7 @@
"tiny-invariant": "1.2.0",
"tslog": "3.3.3",
"twilio": "3.77.0",
"web-push": "3.5.0",
"zod": "3.16.0"
},
"devDependencies": {
@ -66,6 +67,7 @@
"@types/react": "18.0.9",
"@types/react-dom": "18.0.4",
"@types/secure-password": "3.1.1",
"@types/web-push": "3.3.2",
"@vitejs/plugin-react": "1.3.2",
"c8": "7.11.2",
"cypress": "9.6.1",
@ -4459,6 +4461,15 @@
"integrity": "sha512-PBjIUxZHOuj0R15/xuwJYjFi+KZdNFrehocChv4g5hu6aFroHue8m0lBP0POdK2nKzbw0cgV1mws8+V/JAcEkQ==",
"dev": true
},
"node_modules/@types/web-push": {
"version": "3.3.2",
"resolved": "https://registry.npmjs.org/@types/web-push/-/web-push-3.3.2.tgz",
"integrity": "sha512-JxWGVL/m7mWTIg4mRYO+A6s0jPmBkr4iJr39DqJpRJAc+jrPiEe1/asmkwerzRon8ZZDxaZJpsxpv0Z18Wo9gw==",
"dev": true,
"dependencies": {
"@types/node": "*"
}
},
"node_modules/@types/yauzl": {
"version": "2.9.2",
"resolved": "https://registry.npmjs.org/@types/yauzl/-/yauzl-2.9.2.tgz",
@ -5108,6 +5119,17 @@
"safer-buffer": "~2.1.0"
}
},
"node_modules/asn1.js": {
"version": "5.4.1",
"resolved": "https://registry.npmjs.org/asn1.js/-/asn1.js-5.4.1.tgz",
"integrity": "sha512-+I//4cYPccV8LdmBLiX8CYvf9Sp3vQsrqu2QNXRcrbiWvcx/UdlFiqUJJzxRQxgsZmvhXhn4cSKeSmoFjVdupA==",
"dependencies": {
"bn.js": "^4.0.0",
"inherits": "^2.0.1",
"minimalistic-assert": "^1.0.0",
"safer-buffer": "^2.1.0"
}
},
"node_modules/assert-never": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/assert-never/-/assert-never-1.2.1.tgz",
@ -5533,6 +5555,16 @@
"tweetnacl": "^0.14.3"
}
},
"node_modules/big-integer": {
"version": "1.6.51",
"resolved": "https://registry.npmjs.org/big-integer/-/big-integer-1.6.51.tgz",
"integrity": "sha512-GPEid2Y9QU1Exl1rpO9B2IPJGHPSupF5GnVIP0blYvNOMer2bTvSWs1jGOUg04hTmu67nmLsQ9TBo1puaotBHg==",
"optional": true,
"peer": true,
"engines": {
"node": ">=0.6"
}
},
"node_modules/big.js": {
"version": "5.2.2",
"resolved": "https://registry.npmjs.org/big.js/-/big.js-5.2.2.tgz",
@ -5618,6 +5650,11 @@
"integrity": "sha512-XpNj6GDQzdfW+r2Wnn7xiSAd7TM3jzkxGXBGTtWKuSXv1xUV+azxAm8jdWZN06QTQk+2N2XB9jRDkvbmQmcRtg==",
"dev": true
},
"node_modules/bn.js": {
"version": "4.12.0",
"resolved": "https://registry.npmjs.org/bn.js/-/bn.js-4.12.0.tgz",
"integrity": "sha512-c98Bf3tPniI+scsdk237ku1Dc3ujXQTSgyiPUDEOe7tRkhrqridvh8klBv0HCEso1OLOYcHuCv/cS6DNxKH+ZA=="
},
"node_modules/body-parser": {
"version": "1.20.0",
"resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.0.tgz",
@ -5729,6 +5766,23 @@
"node": ">=8"
}
},
"node_modules/broadcast-channel": {
"version": "3.7.0",
"resolved": "https://registry.npmjs.org/broadcast-channel/-/broadcast-channel-3.7.0.tgz",
"integrity": "sha512-cIAKJXAxGJceNZGTZSBzMxzyOn72cVgPnKx4dc6LRjQgbaJUQqhy5rzL3zbMxkMWsGKkv2hSFkPRMEXfoMZ2Mg==",
"optional": true,
"peer": true,
"dependencies": {
"@babel/runtime": "^7.7.2",
"detect-node": "^2.1.0",
"js-sha3": "0.8.0",
"microseconds": "0.2.0",
"nano-time": "1.0.0",
"oblivious-set": "1.0.0",
"rimraf": "3.0.2",
"unload": "2.2.0"
}
},
"node_modules/browser-sync": {
"version": "2.27.10",
"resolved": "https://registry.npmjs.org/browser-sync/-/browser-sync-2.27.10.tgz",
@ -7764,6 +7818,13 @@
"node": ">=8"
}
},
"node_modules/detect-node": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/detect-node/-/detect-node-2.1.0.tgz",
"integrity": "sha512-T0NIuQpnTvFDATNuHN5roPwSBG83rFsuO+MXXH9/3N1eFbn4wcPjttvjMLEPWJ0RGUYgQE7cGgS3tNxbqCGM7g==",
"optional": true,
"peer": true
},
"node_modules/detective": {
"version": "5.2.0",
"resolved": "https://registry.npmjs.org/detective/-/detective-5.2.0.tgz",
@ -11217,6 +11278,17 @@
"entities": "^2.0.0"
}
},
"node_modules/http_ece": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/http_ece/-/http_ece-1.1.0.tgz",
"integrity": "sha512-bptAfCDdPJxOs5zYSe7Y3lpr772s1G346R4Td5LgRUeCwIGpCGDUTJxRrhTNcAXbx37spge0kWEIH7QAYWNTlA==",
"dependencies": {
"urlsafe-base64": "~1.0.0"
},
"engines": {
"node": ">=4"
}
},
"node_modules/http-basic": {
"version": "8.1.3",
"resolved": "https://registry.npmjs.org/http-basic/-/http-basic-8.1.3.tgz",
@ -12411,6 +12483,13 @@
"node": ">=10"
}
},
"node_modules/js-sha3": {
"version": "0.8.0",
"resolved": "https://registry.npmjs.org/js-sha3/-/js-sha3-0.8.0.tgz",
"integrity": "sha512-gF1cRrHhIzNfToc802P800N8PpXS+evLLXfsVpowqmAFR9uwbi89WvXg2QspOmXL8QL86J4T1EpFu+yUkwJY3Q==",
"optional": true,
"peer": true
},
"node_modules/js-stringify": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/js-stringify/-/js-stringify-1.0.2.tgz",
@ -13669,6 +13748,17 @@
"url": "https://github.com/fb55/entities?sponsor=1"
}
},
"node_modules/match-sorter": {
"version": "6.3.1",
"resolved": "https://registry.npmjs.org/match-sorter/-/match-sorter-6.3.1.tgz",
"integrity": "sha512-mxybbo3pPNuA+ZuCUhm5bwNkXrJTbsk5VWbR5wiwz/GC6LIiegBGn2w3O08UG/jdbYLinw51fSQ5xNU1U3MgBw==",
"optional": true,
"peer": true,
"dependencies": {
"@babel/runtime": "^7.12.5",
"remove-accents": "0.4.2"
}
},
"node_modules/matcher": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/matcher/-/matcher-4.0.0.tgz",
@ -14621,6 +14711,13 @@
"node": ">=8.6"
}
},
"node_modules/microseconds": {
"version": "0.2.0",
"resolved": "https://registry.npmjs.org/microseconds/-/microseconds-0.2.0.tgz",
"integrity": "sha512-n7DHHMjR1avBbSpsTBj6fmMGh2AGrifVV4e+WYc3Q9lO+xnSZ3NyhcBND3vzzatt05LFhoKFRxrIyklmLlUtyA==",
"optional": true,
"peer": true
},
"node_modules/mime": {
"version": "1.4.1",
"resolved": "https://registry.npmjs.org/mime/-/mime-1.4.1.tgz",
@ -14680,6 +14777,11 @@
"mini-svg-data-uri": "cli.js"
}
},
"node_modules/minimalistic-assert": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/minimalistic-assert/-/minimalistic-assert-1.0.1.tgz",
"integrity": "sha512-UtJcAD4yEaGtjPezWuO9wC4nwUnVH/8/Im3yEHQP4b67cXlD/Qr9hdITCU1xDbSEXg2XKNaP8jsReV7vQd00/A=="
},
"node_modules/minimatch": {
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz",
@ -14989,6 +15091,16 @@
"integrity": "sha512-nnbWWOkoWyUsTjKrhgD0dcz22mdkSnpYqbEjIm2nhwhuxlSkpywJmBo8h0ZqJdkp73mb90SssHkN4rsRaBAfAA==",
"dev": true
},
"node_modules/nano-time": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/nano-time/-/nano-time-1.0.0.tgz",
"integrity": "sha1-sFVPaa2J4i0JB/ehKwmTpdlhN+8=",
"optional": true,
"peer": true,
"dependencies": {
"big-integer": "^1.6.16"
}
},
"node_modules/nanoassert": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/nanoassert/-/nanoassert-1.1.0.tgz",
@ -15664,6 +15776,13 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/oblivious-set": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/oblivious-set/-/oblivious-set-1.0.0.tgz",
"integrity": "sha512-z+pI07qxo4c2CulUHCDf9lcqDlMSo72N/4rLUpRXf6fu+q8vjt8y0xS+Tlf8NTJDdTXHbdeO1n3MlbctwEoXZw==",
"optional": true,
"peer": true
},
"node_modules/on-finished": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.3.0.tgz",
@ -17655,6 +17774,33 @@
"react": ">=16"
}
},
"node_modules/react-query": {
"version": "3.39.0",
"resolved": "https://registry.npmjs.org/react-query/-/react-query-3.39.0.tgz",
"integrity": "sha512-Od0IkSuS79WJOhzWBx/ys0x13+7wFqgnn64vBqqAAnZ9whocVhl/y1padD5uuZ6EIkXbFbInax0qvY7zGM0thA==",
"optional": true,
"peer": true,
"dependencies": {
"@babel/runtime": "^7.5.5",
"broadcast-channel": "^3.4.1",
"match-sorter": "^6.0.2"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/tannerlinsley"
},
"peerDependencies": {
"react": "^16.8.0 || ^17.0.0 || ^18.0.0"
},
"peerDependenciesMeta": {
"react-dom": {
"optional": true
},
"react-native": {
"optional": true
}
}
},
"node_modules/react-refresh": {
"version": "0.13.0",
"resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.13.0.tgz",
@ -18289,6 +18435,13 @@
"uuid": "dist/bin/uuid"
}
},
"node_modules/remove-accents": {
"version": "0.4.2",
"resolved": "https://registry.npmjs.org/remove-accents/-/remove-accents-0.4.2.tgz",
"integrity": "sha1-CkPTqq4egNuRngeuJUsoXZ4ce7U=",
"optional": true,
"peer": true
},
"node_modules/repeat-element": {
"version": "1.1.4",
"resolved": "https://registry.npmjs.org/repeat-element/-/repeat-element-1.1.4.tgz",
@ -21169,6 +21322,17 @@
"node": ">= 10.0.0"
}
},
"node_modules/unload": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/unload/-/unload-2.2.0.tgz",
"integrity": "sha512-B60uB5TNBLtN6/LsgAf3udH9saB5p7gqJwcFfbOEZ8BcBHnGwCf6G/TGiEqkRAxX7zAFIUtzdrXQSdL3Q/wqNA==",
"optional": true,
"peer": true,
"dependencies": {
"@babel/runtime": "^7.6.2",
"detect-node": "^2.0.4"
}
},
"node_modules/unpipe": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz",
@ -21273,6 +21437,11 @@
"resolved": "https://registry.npmjs.org/punycode/-/punycode-1.3.2.tgz",
"integrity": "sha1-llOgNvt8HuQjQvIyXM7v6jkmxI0="
},
"node_modules/urlsafe-base64": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/urlsafe-base64/-/urlsafe-base64-1.0.0.tgz",
"integrity": "sha1-I/iQaabGL0bPOh07ABac77kL4MY="
},
"node_modules/use": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/use/-/use-3.1.1.tgz",
@ -21586,6 +21755,44 @@
"@zxing/text-encoding": "0.9.0"
}
},
"node_modules/web-push": {
"version": "3.5.0",
"resolved": "https://registry.npmjs.org/web-push/-/web-push-3.5.0.tgz",
"integrity": "sha512-JC0V9hzKTqlDYJ+LTZUXtW7B175qwwaqzbbMSWDxHWxZvd3xY0C2rcotMGDavub2nAAFw+sXTsqR65/KY2A5AQ==",
"dependencies": {
"asn1.js": "^5.3.0",
"http_ece": "1.1.0",
"https-proxy-agent": "^5.0.0",
"jws": "^4.0.0",
"minimist": "^1.2.5",
"urlsafe-base64": "^1.0.0"
},
"bin": {
"web-push": "src/cli.js"
},
"engines": {
"node": ">= 6"
}
},
"node_modules/web-push/node_modules/jwa": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/jwa/-/jwa-2.0.0.tgz",
"integrity": "sha512-jrZ2Qx916EA+fq9cEAeCROWPTfCwi1IVHqT2tapuqLEVVDKFDENFw1oL+MwrTvH6msKxsd1YTDVw6uKEcsrLEA==",
"dependencies": {
"buffer-equal-constant-time": "1.0.1",
"ecdsa-sig-formatter": "1.0.11",
"safe-buffer": "^5.0.1"
}
},
"node_modules/web-push/node_modules/jws": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/jws/-/jws-4.0.0.tgz",
"integrity": "sha512-KDncfTmOZoOMTFG4mBlG0qUIOlc03fmzH+ru6RgYVZhPkyiy/92Owlt/8UEN+a4TXR1FQetfIpJE8ApdvdVxTg==",
"dependencies": {
"jwa": "^2.0.0",
"safe-buffer": "^5.0.1"
}
},
"node_modules/web-resource-inliner": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/web-resource-inliner/-/web-resource-inliner-5.0.0.tgz",
@ -25252,6 +25459,15 @@
"integrity": "sha512-PBjIUxZHOuj0R15/xuwJYjFi+KZdNFrehocChv4g5hu6aFroHue8m0lBP0POdK2nKzbw0cgV1mws8+V/JAcEkQ==",
"dev": true
},
"@types/web-push": {
"version": "3.3.2",
"resolved": "https://registry.npmjs.org/@types/web-push/-/web-push-3.3.2.tgz",
"integrity": "sha512-JxWGVL/m7mWTIg4mRYO+A6s0jPmBkr4iJr39DqJpRJAc+jrPiEe1/asmkwerzRon8ZZDxaZJpsxpv0Z18Wo9gw==",
"dev": true,
"requires": {
"@types/node": "*"
}
},
"@types/yauzl": {
"version": "2.9.2",
"resolved": "https://registry.npmjs.org/@types/yauzl/-/yauzl-2.9.2.tgz",
@ -25695,6 +25911,17 @@
"safer-buffer": "~2.1.0"
}
},
"asn1.js": {
"version": "5.4.1",
"resolved": "https://registry.npmjs.org/asn1.js/-/asn1.js-5.4.1.tgz",
"integrity": "sha512-+I//4cYPccV8LdmBLiX8CYvf9Sp3vQsrqu2QNXRcrbiWvcx/UdlFiqUJJzxRQxgsZmvhXhn4cSKeSmoFjVdupA==",
"requires": {
"bn.js": "^4.0.0",
"inherits": "^2.0.1",
"minimalistic-assert": "^1.0.0",
"safer-buffer": "^2.1.0"
}
},
"assert-never": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/assert-never/-/assert-never-1.2.1.tgz",
@ -26011,6 +26238,13 @@
"tweetnacl": "^0.14.3"
}
},
"big-integer": {
"version": "1.6.51",
"resolved": "https://registry.npmjs.org/big-integer/-/big-integer-1.6.51.tgz",
"integrity": "sha512-GPEid2Y9QU1Exl1rpO9B2IPJGHPSupF5GnVIP0blYvNOMer2bTvSWs1jGOUg04hTmu67nmLsQ9TBo1puaotBHg==",
"optional": true,
"peer": true
},
"big.js": {
"version": "5.2.2",
"resolved": "https://registry.npmjs.org/big.js/-/big.js-5.2.2.tgz",
@ -26078,6 +26312,11 @@
"integrity": "sha512-XpNj6GDQzdfW+r2Wnn7xiSAd7TM3jzkxGXBGTtWKuSXv1xUV+azxAm8jdWZN06QTQk+2N2XB9jRDkvbmQmcRtg==",
"dev": true
},
"bn.js": {
"version": "4.12.0",
"resolved": "https://registry.npmjs.org/bn.js/-/bn.js-4.12.0.tgz",
"integrity": "sha512-c98Bf3tPniI+scsdk237ku1Dc3ujXQTSgyiPUDEOe7tRkhrqridvh8klBv0HCEso1OLOYcHuCv/cS6DNxKH+ZA=="
},
"body-parser": {
"version": "1.20.0",
"resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.0.tgz",
@ -26165,6 +26404,23 @@
"fill-range": "^7.0.1"
}
},
"broadcast-channel": {
"version": "3.7.0",
"resolved": "https://registry.npmjs.org/broadcast-channel/-/broadcast-channel-3.7.0.tgz",
"integrity": "sha512-cIAKJXAxGJceNZGTZSBzMxzyOn72cVgPnKx4dc6LRjQgbaJUQqhy5rzL3zbMxkMWsGKkv2hSFkPRMEXfoMZ2Mg==",
"optional": true,
"peer": true,
"requires": {
"@babel/runtime": "^7.7.2",
"detect-node": "^2.1.0",
"js-sha3": "0.8.0",
"microseconds": "0.2.0",
"nano-time": "1.0.0",
"oblivious-set": "1.0.0",
"rimraf": "3.0.2",
"unload": "2.2.0"
}
},
"browser-sync": {
"version": "2.27.10",
"resolved": "https://registry.npmjs.org/browser-sync/-/browser-sync-2.27.10.tgz",
@ -27730,6 +27986,13 @@
"integrity": "sha512-TLz+x/vEXm/Y7P7wn1EJFNLxYpUD4TgMosxY6fAVJUnJMbupHBOncxyWUG9OpTaH9EBD7uFI5LfEgmMOc54DsA==",
"dev": true
},
"detect-node": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/detect-node/-/detect-node-2.1.0.tgz",
"integrity": "sha512-T0NIuQpnTvFDATNuHN5roPwSBG83rFsuO+MXXH9/3N1eFbn4wcPjttvjMLEPWJ0RGUYgQE7cGgS3tNxbqCGM7g==",
"optional": true,
"peer": true
},
"detective": {
"version": "5.2.0",
"resolved": "https://registry.npmjs.org/detective/-/detective-5.2.0.tgz",
@ -30271,6 +30534,14 @@
"entities": "^2.0.0"
}
},
"http_ece": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/http_ece/-/http_ece-1.1.0.tgz",
"integrity": "sha512-bptAfCDdPJxOs5zYSe7Y3lpr772s1G346R4Td5LgRUeCwIGpCGDUTJxRrhTNcAXbx37spge0kWEIH7QAYWNTlA==",
"requires": {
"urlsafe-base64": "~1.0.0"
}
},
"http-basic": {
"version": "8.1.3",
"resolved": "https://registry.npmjs.org/http-basic/-/http-basic-8.1.3.tgz",
@ -31084,6 +31355,13 @@
"nopt": "^5.0.0"
}
},
"js-sha3": {
"version": "0.8.0",
"resolved": "https://registry.npmjs.org/js-sha3/-/js-sha3-0.8.0.tgz",
"integrity": "sha512-gF1cRrHhIzNfToc802P800N8PpXS+evLLXfsVpowqmAFR9uwbi89WvXg2QspOmXL8QL86J4T1EpFu+yUkwJY3Q==",
"optional": true,
"peer": true
},
"js-stringify": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/js-stringify/-/js-stringify-1.0.2.tgz",
@ -32094,6 +32372,17 @@
}
}
},
"match-sorter": {
"version": "6.3.1",
"resolved": "https://registry.npmjs.org/match-sorter/-/match-sorter-6.3.1.tgz",
"integrity": "sha512-mxybbo3pPNuA+ZuCUhm5bwNkXrJTbsk5VWbR5wiwz/GC6LIiegBGn2w3O08UG/jdbYLinw51fSQ5xNU1U3MgBw==",
"optional": true,
"peer": true,
"requires": {
"@babel/runtime": "^7.12.5",
"remove-accents": "0.4.2"
}
},
"matcher": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/matcher/-/matcher-4.0.0.tgz",
@ -32706,6 +32995,13 @@
"picomatch": "^2.3.1"
}
},
"microseconds": {
"version": "0.2.0",
"resolved": "https://registry.npmjs.org/microseconds/-/microseconds-0.2.0.tgz",
"integrity": "sha512-n7DHHMjR1avBbSpsTBj6fmMGh2AGrifVV4e+WYc3Q9lO+xnSZ3NyhcBND3vzzatt05LFhoKFRxrIyklmLlUtyA==",
"optional": true,
"peer": true
},
"mime": {
"version": "1.4.1",
"resolved": "https://registry.npmjs.org/mime/-/mime-1.4.1.tgz",
@ -32744,6 +33040,11 @@
"resolved": "https://registry.npmjs.org/mini-svg-data-uri/-/mini-svg-data-uri-1.4.4.tgz",
"integrity": "sha512-r9deDe9p5FJUPZAk3A59wGH7Ii9YrjjWw0jmw/liSbHl2CHiyXj6FcDXDu2K3TjVAXqiJdaw3xxwlZZr9E6nHg=="
},
"minimalistic-assert": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/minimalistic-assert/-/minimalistic-assert-1.0.1.tgz",
"integrity": "sha512-UtJcAD4yEaGtjPezWuO9wC4nwUnVH/8/Im3yEHQP4b67cXlD/Qr9hdITCU1xDbSEXg2XKNaP8jsReV7vQd00/A=="
},
"minimatch": {
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz",
@ -32970,6 +33271,16 @@
"integrity": "sha512-nnbWWOkoWyUsTjKrhgD0dcz22mdkSnpYqbEjIm2nhwhuxlSkpywJmBo8h0ZqJdkp73mb90SssHkN4rsRaBAfAA==",
"dev": true
},
"nano-time": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/nano-time/-/nano-time-1.0.0.tgz",
"integrity": "sha1-sFVPaa2J4i0JB/ehKwmTpdlhN+8=",
"optional": true,
"peer": true,
"requires": {
"big-integer": "^1.6.16"
}
},
"nanoassert": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/nanoassert/-/nanoassert-1.1.0.tgz",
@ -33465,6 +33776,13 @@
"es-abstract": "^1.19.1"
}
},
"oblivious-set": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/oblivious-set/-/oblivious-set-1.0.0.tgz",
"integrity": "sha512-z+pI07qxo4c2CulUHCDf9lcqDlMSo72N/4rLUpRXf6fu+q8vjt8y0xS+Tlf8NTJDdTXHbdeO1n3MlbctwEoXZw==",
"optional": true,
"peer": true
},
"on-finished": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.3.0.tgz",
@ -34955,6 +35273,18 @@
"react-merge-refs": "1.1.0"
}
},
"react-query": {
"version": "3.39.0",
"resolved": "https://registry.npmjs.org/react-query/-/react-query-3.39.0.tgz",
"integrity": "sha512-Od0IkSuS79WJOhzWBx/ys0x13+7wFqgnn64vBqqAAnZ9whocVhl/y1padD5uuZ6EIkXbFbInax0qvY7zGM0thA==",
"optional": true,
"peer": true,
"requires": {
"@babel/runtime": "^7.5.5",
"broadcast-channel": "^3.4.1",
"match-sorter": "^6.0.2"
}
},
"react-refresh": {
"version": "0.13.0",
"resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.13.0.tgz",
@ -35449,6 +35779,13 @@
}
}
},
"remove-accents": {
"version": "0.4.2",
"resolved": "https://registry.npmjs.org/remove-accents/-/remove-accents-0.4.2.tgz",
"integrity": "sha1-CkPTqq4egNuRngeuJUsoXZ4ce7U=",
"optional": true,
"peer": true
},
"repeat-element": {
"version": "1.1.4",
"resolved": "https://registry.npmjs.org/repeat-element/-/repeat-element-1.1.4.tgz",
@ -37748,6 +38085,17 @@
"resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.0.tgz",
"integrity": "sha512-hAZsKq7Yy11Zu1DE0OzWjw7nnLZmJZYTDZZyEFHZdUhV8FkH5MCfoU1XMaxXovpyW5nq5scPqq0ZDP9Zyl04oQ=="
},
"unload": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/unload/-/unload-2.2.0.tgz",
"integrity": "sha512-B60uB5TNBLtN6/LsgAf3udH9saB5p7gqJwcFfbOEZ8BcBHnGwCf6G/TGiEqkRAxX7zAFIUtzdrXQSdL3Q/wqNA==",
"optional": true,
"peer": true,
"requires": {
"@babel/runtime": "^7.6.2",
"detect-node": "^2.0.4"
}
},
"unpipe": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz",
@ -37839,6 +38187,11 @@
"requires-port": "^1.0.0"
}
},
"urlsafe-base64": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/urlsafe-base64/-/urlsafe-base64-1.0.0.tgz",
"integrity": "sha1-I/iQaabGL0bPOh07ABac77kL4MY="
},
"use": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/use/-/use-3.1.1.tgz",
@ -38051,6 +38404,40 @@
"util": "^0.12.3"
}
},
"web-push": {
"version": "3.5.0",
"resolved": "https://registry.npmjs.org/web-push/-/web-push-3.5.0.tgz",
"integrity": "sha512-JC0V9hzKTqlDYJ+LTZUXtW7B175qwwaqzbbMSWDxHWxZvd3xY0C2rcotMGDavub2nAAFw+sXTsqR65/KY2A5AQ==",
"requires": {
"asn1.js": "^5.3.0",
"http_ece": "1.1.0",
"https-proxy-agent": "^5.0.0",
"jws": "^4.0.0",
"minimist": "^1.2.5",
"urlsafe-base64": "^1.0.0"
},
"dependencies": {
"jwa": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/jwa/-/jwa-2.0.0.tgz",
"integrity": "sha512-jrZ2Qx916EA+fq9cEAeCROWPTfCwi1IVHqT2tapuqLEVVDKFDENFw1oL+MwrTvH6msKxsd1YTDVw6uKEcsrLEA==",
"requires": {
"buffer-equal-constant-time": "1.0.1",
"ecdsa-sig-formatter": "1.0.11",
"safe-buffer": "^5.0.1"
}
},
"jws": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/jws/-/jws-4.0.0.tgz",
"integrity": "sha512-KDncfTmOZoOMTFG4mBlG0qUIOlc03fmzH+ru6RgYVZhPkyiy/92Owlt/8UEN+a4TXR1FQetfIpJE8ApdvdVxTg==",
"requires": {
"jwa": "^2.0.0",
"safe-buffer": "^5.0.1"
}
}
}
},
"web-resource-inliner": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/web-resource-inliner/-/web-resource-inliner-5.0.0.tgz",

View File

@ -7,12 +7,14 @@
"dev:css": "cross-env NODE_ENV=development tailwindcss -i ./styles/tailwind.css -o ./app/styles/tailwind.css --watch",
"dev:remix": "cross-env NODE_ENV=development remix watch",
"dev:server": "cross-env NODE_ENV=development dotenv node ./server.js",
"dev:worker": "esbuild ./app/entry.worker.ts --outfile=./public/entry.worker.js --bundle --format=esm --watch",
"dev:init": "cross-env NODE_ENV=development dotenv run-s build:remix build:server",
"dev": "npm run dev:init && run-p dev:build dev:css dev:remix dev:server",
"dev": "npm run dev:init && run-p dev:build dev:worker dev:css dev:remix dev:server",
"build:server": "node ./scripts/build-server.js",
"build:css": "tailwindcss -i ./styles/tailwind.css -o ./app/styles/tailwind.css",
"build:remix": "remix build",
"build": "cross-env NODE_ENV=production run-s build:css build:remix build:server",
"build:worker": "esbuild ./app/entry.worker.ts --outfile=./public/entry.worker.js --minify --bundle --format=esm",
"build": "cross-env NODE_ENV=production run-s build:css build:worker build:remix build:server",
"start": "cross-env NODE_ENV=production node ./server.js",
"test": "vitest",
"test:coverage": "vitest run --coverage",
@ -88,6 +90,7 @@
"tiny-invariant": "1.2.0",
"tslog": "3.3.3",
"twilio": "3.77.0",
"web-push": "3.5.0",
"zod": "3.16.0"
},
"devDependencies": {
@ -107,6 +110,7 @@
"@types/react": "18.0.9",
"@types/react-dom": "18.0.4",
"@types/secure-password": "3.1.1",
"@types/web-push": "3.3.2",
"@vitejs/plugin-react": "1.3.2",
"c8": "7.11.2",
"cypress": "9.6.1",

View File

@ -157,6 +157,20 @@ CREATE TABLE "PhoneNumber" (
CONSTRAINT "PhoneNumber_pkey" PRIMARY KEY ("id")
);
-- 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,
"membershipId" TEXT NOT NULL,
CONSTRAINT "NotificationSubscription_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "TwilioAccount_organizationId_key" ON "TwilioAccount"("organizationId");
@ -184,10 +198,12 @@ CREATE INDEX "PhoneCall_phoneNumberId_recipient_idx" ON "PhoneCall"("phoneNumber
-- CreateIndex
CREATE UNIQUE INDEX "PhoneNumber_organizationId_isCurrent_key" ON "PhoneNumber"("organizationId", "isCurrent") WHERE ("isCurrent" = true);
-- CreateIndex
CREATE UNIQUE INDEX "NotificationSubscription_endpoint_key" ON "NotificationSubscription"("endpoint");
-- AddForeignKey
ALTER TABLE "TwilioAccount" ADD CONSTRAINT "TwilioAccount_organizationId_fkey" FOREIGN KEY ("organizationId") REFERENCES "Organization"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Subscription" ADD CONSTRAINT "Subscription_organizationId_fkey" FOREIGN KEY ("organizationId") REFERENCES "Organization"("id") ON DELETE CASCADE ON UPDATE CASCADE;
@ -214,3 +230,6 @@ ALTER TABLE "PhoneCall" ADD CONSTRAINT "PhoneCall_phoneNumberId_fkey" FOREIGN KE
-- AddForeignKey
ALTER TABLE "PhoneNumber" ADD CONSTRAINT "PhoneNumber_organizationId_fkey" FOREIGN KEY ("organizationId") REFERENCES "Organization"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "NotificationSubscription" ADD CONSTRAINT "NotificationSubscription_membershipId_fkey" FOREIGN KEY ("membershipId") REFERENCES "Membership"("id") ON DELETE CASCADE ON UPDATE CASCADE;

View File

@ -51,14 +51,15 @@ model Subscription {
}
model Membership {
id String @id @default(cuid())
role MembershipRole
organizationId String
userId String?
invitedEmail String?
organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade)
user User? @relation(fields: [userId], references: [id], onDelete: Cascade)
invitationToken Token?
id String @id @default(cuid())
role MembershipRole
organizationId String
userId String?
invitedEmail String?
organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade)
user User? @relation(fields: [userId], references: [id], onDelete: Cascade)
invitationToken Token?
notificationSubscription NotificationSubscription[]
@@unique([organizationId, invitedEmail])
}
@ -147,6 +148,19 @@ model PhoneNumber {
@@unique([organizationId, isCurrent])
}
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
membership Membership? @relation(fields: [membershipId], references: [id], onDelete: Cascade)
membershipId String?
}
enum SubscriptionStatus {
active
trialing

View File

@ -1,8 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<browserconfig>
<msapplication>
<tile>
<TileColor>#663399</TileColor>
</tile>
</msapplication>
</browserconfig>

View File

Before

Width:  |  Height:  |  Size: 32 KiB

After

Width:  |  Height:  |  Size: 32 KiB

View File

Before

Width:  |  Height:  |  Size: 99 KiB

After

Width:  |  Height:  |  Size: 99 KiB

View File

Before

Width:  |  Height:  |  Size: 19 KiB

After

Width:  |  Height:  |  Size: 19 KiB

View File

Before

Width:  |  Height:  |  Size: 1.1 KiB

After

Width:  |  Height:  |  Size: 1.1 KiB

View File

Before

Width:  |  Height:  |  Size: 2.2 KiB

After

Width:  |  Height:  |  Size: 2.2 KiB

View File

Before

Width:  |  Height:  |  Size: 15 KiB

After

Width:  |  Height:  |  Size: 15 KiB

View File

Before

Width:  |  Height:  |  Size: 3.6 KiB

After

Width:  |  Height:  |  Size: 3.6 KiB

View File

@ -2,7 +2,7 @@
"name": "Shellphone: Your Personal Cloud Phone",
"short_name": "Shellphone",
"lang": "en-US",
"start_url": "/",
"start_url": "/messages",
"scope": "/",
"shortcuts": [
{
@ -18,18 +18,18 @@
],
"icons": [
{
"src": "/android-chrome-192x192.png",
"src": "/icons/android-chrome-192x192.png",
"sizes": "192x192",
"type": "image/png"
},
{
"src": "/android-chrome-384x384.png",
"src": "/icons/android-chrome-384x384.png",
"sizes": "384x384",
"type": "image/png"
}
],
"display": "standalone",
"orientation": "portrait",
"theme_color": "#663399",
"background_color": "#F9FAFB"
"theme_color": "#0062CC",
"background_color": "#F4F4F5"
}