From 860d173f0ad45d342b5dffced540ffac4db50bde Mon Sep 17 00:00:00 2001 From: Udit Takkar <53316345+Udit-takkar@users.noreply.github.com> Date: Wed, 11 Sep 2024 19:32:14 +0530 Subject: [PATCH] feat: instant meeting browser notifcations (#16480) * feat: instant meeting browser notifcations * chore: support multiple notifications * chore: save progress * fix: add test notification and move file * fix: type err * chore: feedback improvements * chore: undo DENY --- .../bookings/views/bookings-listing-view.tsx | 25 +----- apps/web/public/service-worker.js | 68 ++++++++++++++-- .../instant-meeting/handleInstantMeeting.ts | 77 +++++++++++++++++++ packages/features/instant-meeting/schema.ts | 10 +++ .../notifications/sendNotification.ts | 11 +++ packages/features/shell/Shell.tsx | 22 +++++- packages/lib/hooks/useNotifications.tsx | 1 + .../addNotificationsSubscription.handler.ts | 39 +++++++++- ...removeNotificationsSubscription.handler.ts | 4 +- 9 files changed, 220 insertions(+), 37 deletions(-) create mode 100644 packages/features/instant-meeting/schema.ts diff --git a/apps/web/modules/bookings/views/bookings-listing-view.tsx b/apps/web/modules/bookings/views/bookings-listing-view.tsx index 5ecef7c6de..952592f69b 100644 --- a/apps/web/modules/bookings/views/bookings-listing-view.tsx +++ b/apps/web/modules/bookings/views/bookings-listing-view.tsx @@ -12,7 +12,6 @@ import type { filterQuerySchema } from "@calcom/features/bookings/lib/useFilterQ import { useFilterQuery } from "@calcom/features/bookings/lib/useFilterQuery"; import Shell from "@calcom/features/shell/Shell"; import { useLocale } from "@calcom/lib/hooks/useLocale"; -import { useNotifications, ButtonState } from "@calcom/lib/hooks/useNotifications"; import { useParamsWithFallback } from "@calcom/lib/hooks/useParamsWithFallback"; import type { RouterOutputs } from "@calcom/trpc/react"; import { trpc } from "@calcom/trpc/react"; @@ -145,27 +144,6 @@ export default function Bookings() { )[0] || []; const [animationParentRef] = useAutoAnimate(); - const { buttonToShow, isLoading, enableNotifications, disableNotifications } = useNotifications(); - - const actions = ( -
- {buttonToShow && ( - - )} -
- ); return ( + description="Create events to share for people to book on your calendar.">
diff --git a/apps/web/public/service-worker.js b/apps/web/public/service-worker.js index 266ac7ea3b..2403d15ff7 100644 --- a/apps/web/public/service-worker.js +++ b/apps/web/public/service-worker.js @@ -1,16 +1,68 @@ -self.addEventListener("push", (event) => { +self.addEventListener("push", async (event) => { let notificationData = event.data.json(); - const title = notificationData.title || "You have new notification from Cal.com"; - const image ="/cal-com-icon.svg"; - const options = { - ...notificationData.options, + const allClients = await clients.matchAll({ + type: 'window', + includeUncontrolled: true + }); + + if(!allClients.length) { + console.log("No open tabs, skipping the push notification."); + return; + } + + + const title = notificationData.title || "You have a new notification from Cal.com"; + const image = "https://cal.com/api/logo?type=icon"; + const newNotificationOptions = { + requireInteraction: true, + ...notificationData, icon: image, + badge: image, + data: { + url: notificationData.data?.url || "https://app.cal.com", + }, + silent: false, + vibrate: [300, 100, 400], + tag: `notification-${Date.now()}-${Math.random()}`, }; - self.registration.showNotification(title, options); + + + const existingNotifications = await self.registration.getNotifications(); + + // Display each existing notification again to make sure old ones can still be clicked + existingNotifications.forEach((notification) => { + const options = { + body: notification.body, + icon: notification.icon, + badge: notification.badge, + data: notification.data, + silent: notification.silent, + vibrate: notification.vibrate, + requireInteraction: notification.requireInteraction, + tag: notification.tag, + }; + self.registration.showNotification(notification.title, options); + }); + + + // Show the new notification + self.registration.showNotification(title, newNotificationOptions); }); self.addEventListener("notificationclick", (event) => { - event.notification.close(); - event.waitUntil(self.clients.openWindow(event.notification.data.targetURL || "https://app.cal.com")); + if (!event.action) { + // Normal Notification Click + event.notification.close(); + const url = event.notification.data.url; + event.waitUntil(self.clients.openWindow(url)); + } + + switch (event.action) { + case 'connect-action': + event.notification.close(); + const url = event.notification.data.url; + event.waitUntil(self.clients.openWindow(url)); + break; + } }); diff --git a/packages/features/instant-meeting/handleInstantMeeting.ts b/packages/features/instant-meeting/handleInstantMeeting.ts index 9c3d73bfcb..bc3c4076c4 100644 --- a/packages/features/instant-meeting/handleInstantMeeting.ts +++ b/packages/features/instant-meeting/handleInstantMeeting.ts @@ -12,6 +12,7 @@ import { getCustomInputsResponses } from "@calcom/features/bookings/lib/handleNe import { getBookingData } from "@calcom/features/bookings/lib/handleNewBooking/getBookingData"; import { getEventTypesFromDB } from "@calcom/features/bookings/lib/handleNewBooking/getEventTypesFromDB"; import { getFullName } from "@calcom/features/form-builder/utils"; +import { sendNotification } from "@calcom/features/notifications/sendNotification"; import { sendGenericWebhookPayload } from "@calcom/features/webhooks/lib/sendPayload"; import { isPrismaObjOrUndefined } from "@calcom/lib"; import { WEBAPP_URL } from "@calcom/lib/constants"; @@ -21,6 +22,8 @@ import { getTranslation } from "@calcom/lib/server"; import prisma from "@calcom/prisma"; import { BookingStatus, WebhookTriggerEvents } from "@calcom/prisma/enums"; +import { subscriptionSchema } from "./schema"; + const handleInstantMeetingWebhookTrigger = async (args: { eventTypeId: number; webhookData: Record; @@ -86,6 +89,74 @@ const handleInstantMeetingWebhookTrigger = async (args: { } }; +const triggerBrowserNotifications = async (args: { + title: string; + connectAndJoinUrl: string; + teamId?: number | null; +}) => { + const { title, connectAndJoinUrl, teamId } = args; + + if (!teamId) { + logger.warn("No teamId provided, skipping browser notification trigger"); + return; + } + + const subscribers = await prisma.membership.findMany({ + where: { + teamId, + accepted: true, + }, + select: { + user: { + select: { + id: true, + NotificationsSubscriptions: { + select: { + id: true, + subscription: true, + }, + }, + }, + }, + }, + }); + + const promises = subscribers.map((sub) => { + const subscription = sub.user?.NotificationsSubscriptions?.[0]?.subscription; + if (!subscription) return Promise.resolve(); + + const parsedSubscription = subscriptionSchema.safeParse(JSON.parse(subscription)); + + if (!parsedSubscription.success) { + logger.error("Invalid subscription", parsedSubscription.error, JSON.stringify(sub.user)); + return Promise.resolve(); + } + + return sendNotification({ + subscription: { + endpoint: parsedSubscription.data.endpoint, + keys: { + auth: parsedSubscription.data.keys.auth, + p256dh: parsedSubscription.data.keys.p256dh, + }, + }, + title: title, + body: "User is waiting for you to join. Click to Connect", + url: connectAndJoinUrl, + actions: [ + { + action: "connect-action", + title: "Connect and join", + type: "button", + image: "https://cal.com/api/logo?type=icon", + }, + ], + }); + }); + + await Promise.allSettled(promises); +}; + export type HandleInstantMeetingResponse = { message: string; meetingTokenId: number; @@ -252,6 +323,12 @@ async function handler(req: NextApiRequest) { teamId: eventType.team?.id, }); + await triggerBrowserNotifications({ + title: newBooking.title, + connectAndJoinUrl: webhookData.connectAndJoinUrl, + teamId: eventType.team?.id, + }); + return { message: "Success", meetingTokenId: instantMeetingToken.id, diff --git a/packages/features/instant-meeting/schema.ts b/packages/features/instant-meeting/schema.ts new file mode 100644 index 0000000000..0cad150e25 --- /dev/null +++ b/packages/features/instant-meeting/schema.ts @@ -0,0 +1,10 @@ +import { z } from "zod"; + +export const subscriptionSchema = z.object({ + endpoint: z.string(), + expirationTime: z.any().optional(), + keys: z.object({ + auth: z.string(), + p256dh: z.string(), + }), +}); diff --git a/packages/features/notifications/sendNotification.ts b/packages/features/notifications/sendNotification.ts index 6e436cba05..3a603f09d5 100644 --- a/packages/features/notifications/sendNotification.ts +++ b/packages/features/notifications/sendNotification.ts @@ -21,17 +21,28 @@ export const sendNotification = async ({ title, body, icon, + url, + actions, + requireInteraction, }: { subscription: Subscription; title: string; body: string; icon?: string; + url?: string; + actions?: { action: string; title: string; type: string; image: string | null }[]; + requireInteraction?: boolean; }) => { try { const payload = JSON.stringify({ title, body, icon, + data: { + url, + }, + actions, + requireInteraction, }); await webpush.sendNotification(subscription, payload); } catch (error) { diff --git a/packages/features/shell/Shell.tsx b/packages/features/shell/Shell.tsx index 7a8a7fead3..a365da4fe5 100644 --- a/packages/features/shell/Shell.tsx +++ b/packages/features/shell/Shell.tsx @@ -53,6 +53,7 @@ import { useFormbricks } from "@calcom/lib/formbricks-client"; import getBrandColours from "@calcom/lib/getBrandColours"; import { useBookerUrl } from "@calcom/lib/hooks/useBookerUrl"; import { useLocale } from "@calcom/lib/hooks/useLocale"; +import { useNotifications, ButtonState } from "@calcom/lib/hooks/useNotifications"; import useTheme from "@calcom/lib/hooks/useTheme"; import { isKeyInObject } from "@calcom/lib/isKeyInObject"; import { localStorage } from "@calcom/lib/webstorage"; @@ -1084,7 +1085,9 @@ function SideBar({ bannersHeight, user }: SideBarProps) { export function ShellMain(props: LayoutProps) { const router = useRouter(); - const { isLocaleReady } = useLocale(); + const { isLocaleReady, t } = useLocale(); + + const { buttonToShow, isLoading, enableNotifications, disableNotifications } = useNotifications(); return ( <> @@ -1144,6 +1147,23 @@ export function ShellMain(props: LayoutProps) {
)} {props.actions && props.actions} + {props.heading === "Bookings" && buttonToShow && ( + + )} )}
diff --git a/packages/lib/hooks/useNotifications.tsx b/packages/lib/hooks/useNotifications.tsx index e8d5b3e44a..bc1f18ce6d 100644 --- a/packages/lib/hooks/useNotifications.tsx +++ b/packages/lib/hooks/useNotifications.tsx @@ -92,6 +92,7 @@ export const useNotifications = () => { } 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; diff --git a/packages/trpc/server/routers/loggedInViewer/addNotificationsSubscription.handler.ts b/packages/trpc/server/routers/loggedInViewer/addNotificationsSubscription.handler.ts index 19529ab2dd..1a197a1edd 100644 --- a/packages/trpc/server/routers/loggedInViewer/addNotificationsSubscription.handler.ts +++ b/packages/trpc/server/routers/loggedInViewer/addNotificationsSubscription.handler.ts @@ -1,6 +1,11 @@ +import { subscriptionSchema } from "@calcom/features/instant-meeting/schema"; +import { sendNotification } from "@calcom/features/notifications/sendNotification"; +import logger from "@calcom/lib/logger"; import prisma from "@calcom/prisma"; import type { TrpcSessionUser } from "@calcom/trpc/server/trpc"; +import { TRPCError } from "@trpc/server"; + import type { TAddNotificationsSubscriptionInputSchema } from "./addNotificationsSubscription.schema"; type AddSecondaryEmailOptions = { @@ -10,20 +15,52 @@ type AddSecondaryEmailOptions = { input: TAddNotificationsSubscriptionInputSchema; }; +const log = logger.getSubLogger({ prefix: ["[addNotificationsSubscriptionHandler]"] }); + export const addNotificationsSubscriptionHandler = async ({ ctx, input }: AddSecondaryEmailOptions) => { const { user } = ctx; const { subscription } = input; + const parsedSubscription = subscriptionSchema.safeParse(JSON.parse(subscription)); + + if (!parsedSubscription.success) { + log.error("Invalid subscription", parsedSubscription.error, JSON.stringify(subscription)); + throw new TRPCError({ + code: "BAD_REQUEST", + message: "Invalid subscription", + }); + } + const existingSubscription = await prisma.notificationsSubscriptions.findFirst({ - where: { userId: user.id, subscription }, + where: { userId: user.id }, }); if (!existingSubscription) { await prisma.notificationsSubscriptions.create({ data: { userId: user.id, subscription }, }); + } else { + await prisma.notificationsSubscriptions.update({ + where: { id: existingSubscription.id }, + data: { userId: user.id, subscription }, + }); } + // send test notification + sendNotification({ + subscription: { + endpoint: parsedSubscription.data.endpoint, + keys: { + auth: parsedSubscription.data.keys.auth, + p256dh: parsedSubscription.data.keys.p256dh, + }, + }, + title: "Test Notification", + body: "Push Notifications activated successfully", + url: "https://app.cal.com/", + requireInteraction: false, + }); + return { message: "Subscription added successfully", }; diff --git a/packages/trpc/server/routers/loggedInViewer/removeNotificationsSubscription.handler.ts b/packages/trpc/server/routers/loggedInViewer/removeNotificationsSubscription.handler.ts index 55e576939c..dd53d1284b 100644 --- a/packages/trpc/server/routers/loggedInViewer/removeNotificationsSubscription.handler.ts +++ b/packages/trpc/server/routers/loggedInViewer/removeNotificationsSubscription.handler.ts @@ -10,15 +10,13 @@ type AddSecondaryEmailOptions = { input: TRemoveNotificationsSubscriptionInputSchema; }; -export const removeNotificationsSubscriptionHandler = async ({ ctx, input }: AddSecondaryEmailOptions) => { +export const removeNotificationsSubscriptionHandler = async ({ ctx }: AddSecondaryEmailOptions) => { const { user } = ctx; - const { subscription } = input; // We just use findFirst because there will only be single unique subscription for a user const subscriptionToDelete = await prisma.notificationsSubscriptions.findFirst({ where: { userId: user.id, - subscription, }, });