Skip to content

Commit

Permalink
feat: notifications feature for unconfirmed events
Browse files Browse the repository at this point in the history
  • Loading branch information
thepradipvc committed May 6, 2024
1 parent 5c2b561 commit d085c1d
Show file tree
Hide file tree
Showing 16 changed files with 572 additions and 5,497 deletions.
144 changes: 144 additions & 0 deletions apps/web/lib/hooks/useNotifications.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,144 @@
import { useState, useEffect } from "react";

import { trpc } from "@calcom/trpc/react";
import { showToast } from "@calcom/ui";

export const useNotifications = () => {
const [buttonToShow, setButtonToShow] = useState<"none" | "allow" | "disable" | "denied">("none");
const [isLoading, setIsLoading] = useState(false);

const { mutate: addSubscription } = trpc.viewer.addNotificationsSubscription.useMutation({
onSuccess: () => {
setButtonToShow("disable");
showToast("Notifications turned on", "success");
},
onError: (error) => {
showToast(`Error: ${error.message}`, "error");
},
onSettled: () => {
setIsLoading(false);
},
});
const { mutate: removeSubscription } = trpc.viewer.removeNotificationsSubscription.useMutation({
onSuccess: () => {
setButtonToShow("allow");
showToast("Notifications turned off", "success");
},
onError: (error) => {
showToast(`Error: ${error.message}`, "error");
},
onSettled: () => {
setIsLoading(false);
},
});

useEffect(() => {
const decideButtonToShow = async () => {
if (!("Notification" in window)) {
console.log("Notifications not supported");
}

const registration = await navigator.serviceWorker.getRegistration();
if (!registration) return;
const subscription = await registration.pushManager.getSubscription();

const permission = Notification.permission;

if (permission === "denied") {
setButtonToShow("denied");
return;
}

if (permission === "default") {
setButtonToShow("allow");
return;
}

if (!subscription) {
setButtonToShow("allow");
return;
}

setButtonToShow("disable");
};

decideButtonToShow();
}, []);

const enableNotifications = async () => {
setIsLoading(true);
const permissionResponse = await Notification.requestPermission();

if (permissionResponse === "denied") {
setButtonToShow("denied");
setIsLoading(false);
showToast("You denied the notifications", "warning");
return;
}

if (permissionResponse === "default") {
setIsLoading(false);
showToast("Please allow notifications from the prompt", "warning");
return;
}

const registration = await navigator.serviceWorker.getRegistration();
if (!registration) {
// This will not happen ideally as the button will not be shown if the service worker is not registered
return;
}

const subscription = await registration.pushManager.subscribe({
userVisibleOnly: true,
applicationServerKey: urlB64ToUint8Array(process.env.NEXT_PUBLIC_VAPID_PUBLIC_KEY || ""),
});
addSubscription(
{ subscription: JSON.stringify(subscription) },
{
onError: async () => {
await subscription.unsubscribe();
},
}
);
};

const disableNotifications = async () => {
setIsLoading(true);
const registration = await navigator.serviceWorker.getRegistration();
if (!registration) {
// This will not happen ideally as the button will not be shown if the service worker is not registered
return;
}
const subscription = await registration.pushManager.getSubscription();
if (!subscription) {
// This will not happen ideally as the button will not be shown if the subscription is not present
return;
}
removeSubscription(
{ subscription: JSON.stringify(subscription) },
{
onSuccess: async () => {
await subscription.unsubscribe();
},
}
);
};

return {
buttonToShow,
isLoading,
enableNotifications,
disableNotifications,
};
};

const urlB64ToUint8Array = (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;
};
10 changes: 9 additions & 1 deletion apps/web/pages/_app.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import type { IncomingMessage } from "http";
import type { AppContextType } from "next/dist/shared/lib/utils";
import React from "react";
import React, { useEffect } from "react";

import { trpc } from "@calcom/trpc/react";

Expand All @@ -11,6 +11,14 @@ import "../styles/globals.css";
function MyApp(props: AppProps) {
const { Component, pageProps } = props;

useEffect(() => {
if ("serviceWorker" in navigator) {
navigator.serviceWorker.register("/service-worker.js");
} else {
console.log("Service workers not supported");
}
}, []);

if (Component.PageWrapper !== undefined) return Component.PageWrapper(props);
return <Component {...pageProps} />;
}
Expand Down
16 changes: 16 additions & 0 deletions apps/web/public/service-worker.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
self.addEventListener("push", (event) => {
let notificationData = event.data.json();

const title = notificationData.title || "You have new notification from Cal.com";
const image ="/cal-com-icon.svg";

Check failure on line 5 in apps/web/public/service-worker.js

View workflow job for this annotation

GitHub Actions / ESLint Report Analysis

apps/web/public/service-worker.js#L5

[prettier/prettier] Insert `·`
const options = {
...notificationData.options,
icon: image,
};
self.registration.showNotification(title, options);
});

self.addEventListener("notificationclick", (event) => {
event.notification.close();
event.waitUntil(self.clients.openWindow(event.notification.data.targetURL || "https://app.cal.com"));
});
29 changes: 29 additions & 0 deletions packages/features/bookings/lib/handleNewBooking.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@ import {
scheduleWorkflowReminders,
} from "@calcom/features/ee/workflows/lib/reminders/reminderScheduler";
import { getFullName } from "@calcom/features/form-builder/utils";
import { sendNotification } from "@calcom/features/notifications/sendNotification";
import type { GetSubscriberOptions } from "@calcom/features/webhooks/lib/getWebhooks";
import getWebhooks from "@calcom/features/webhooks/lib/getWebhooks";
import { cancelScheduledJobs, scheduleTrigger } from "@calcom/features/webhooks/lib/scheduleTrigger";
Expand Down Expand Up @@ -744,6 +745,34 @@ async function createBooking({
});
}

if (!isConfirmedByDefault) {
// If the booking is pending, we need to send notification
const notification = {
title: "You have a new event request",
options: {
body: evt.title,
data: {
targetURL: "https://app.cal.com/bookings/unconfirmed",
},
},
};

const subscriptions = await prisma.notificationsSubscriptions.findMany({
where: {
userId: organizerUser.id,
},
select: {
subscription: true,
},
});

await Promise.all(
subscriptions.map((subscription) => {
return sendNotification(JSON.parse(subscription.subscription), JSON.stringify(notification));
})
);
}

return prisma.booking.create(createBookingObj);
}

Expand Down
25 changes: 25 additions & 0 deletions packages/features/notifications/sendNotification.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import webpush from "web-push";

const vapidKeys = {
publicKey: process.env.NEXT_PUBLIC_VAPID_PUBLIC_KEY || "",
privateKey: process.env.VAPID_PRIVATE_KEY || "",
};

// The mail to email address should be the one at which push service providers can reach you. It can also be a URL.
webpush.setVapidDetails("mailto:web-push-book@gauntface.com", vapidKeys.publicKey, vapidKeys.privateKey);

type Subscription = {
endpoint: string;
keys: {
auth: string;
p256dh: string;
};
};

export const sendNotification = async (subscription: Subscription, payload: string) => {
try {
await webpush.sendNotification(subscription, payload);
} catch (error) {
console.error("Error sending notification", error);
}
};
1 change: 1 addition & 0 deletions packages/features/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@
"react-select": "^5.7.0",
"react-sticky-box": "^2.0.4",
"react-virtual": "^2.10.4",
"web-push": "^3.6.7",
"zustand": "^4.3.2"
},
"devDependencies": {
Expand Down
19 changes: 19 additions & 0 deletions packages/features/shell/Shell.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,8 @@ import {
} from "@calcom/ui";
import { Discord } from "@calcom/ui/components/icon/Discord";

import { useNotifications } from "@lib/hooks/useNotifications";

import { useOrgBranding } from "../ee/organizations/context/provider";
import FreshChatProvider from "../ee/support/lib/freshchat/FreshChatProvider";
import { TeamInviteBadge } from "./TeamInviteBadge";
Expand Down Expand Up @@ -1004,6 +1006,7 @@ function SideBar({ bannersHeight, user }: SideBarProps) {
export function ShellMain(props: LayoutProps) {
const router = useRouter();
const { isLocaleReady } = useLocale();
const { buttonToShow, isLoading, enableNotifications, disableNotifications } = useNotifications();

return (
<>
Expand Down Expand Up @@ -1062,6 +1065,22 @@ export function ShellMain(props: LayoutProps) {
</div>
)}
{props.actions && props.actions}
{props.heading === "Bookings" &&
(buttonToShow === "allow" ? (
<Button color="primary" onClick={enableNotifications} loading={isLoading}>
Allow Notifications
</Button>
) : buttonToShow === "disable" ? (
<Button color="primary" onClick={disableNotifications} loading={isLoading}>
Disable Notifications
</Button>
) : buttonToShow === "denied" ? (
<Tooltip content="You have denied the notifications. You will have to reset the permission from browser settings to enable them.">
<Button color="primary" disabled>
Allow Notifications
</Button>
</Tooltip>
) : null)}
</header>
)}
</div>
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
-- CreateTable
CREATE TABLE "NotificationsSubscriptions" (
"id" SERIAL NOT NULL,
"userId" INTEGER NOT NULL,
"subscription" TEXT NOT NULL,

CONSTRAINT "NotificationsSubscriptions_pkey" PRIMARY KEY ("id")
);

-- CreateIndex
CREATE INDEX "NotificationsSubscriptions_userId_subscription_idx" ON "NotificationsSubscriptions"("userId", "subscription");

-- AddForeignKey
ALTER TABLE "NotificationsSubscriptions" ADD CONSTRAINT "NotificationsSubscriptions_userId_fkey" FOREIGN KEY ("userId") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;
14 changes: 12 additions & 2 deletions packages/prisma/schema.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -317,8 +317,9 @@ model User {
secondaryEmails SecondaryEmail[]
isPlatformManaged Boolean @default(false)
OutOfOfficeReasons OutOfOfficeReason[]
smsLockState SMSLockState @default(UNLOCKED)
OutOfOfficeReasons OutOfOfficeReason[]
smsLockState SMSLockState @default(UNLOCKED)
NotificationsSubscriptions NotificationsSubscriptions[]
@@unique([email])
@@unique([email, username])
Expand All @@ -331,6 +332,15 @@ model User {
@@map(name: "users")
}

model NotificationsSubscriptions {
id Int @id @default(autoincrement())
userId Int
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
subscription String
@@index([userId, subscription])
}

// It holds Organization Profiles as well as User Profiles for users that have been added to an organization
model Profile {
id Int @id @default(autoincrement())
Expand Down
Loading

0 comments on commit d085c1d

Please sign in to comment.