From 1df2bbed477091bbea1a9512c25e53eba54da531 Mon Sep 17 00:00:00 2001 From: Udit Takkar Date: Wed, 23 Oct 2024 17:45:15 +0530 Subject: [PATCH 1/9] feat: google meet automatic no show --- .../[clientId]/edit/edit-webhooks-view.tsx | 8 + apps/web/package.json | 3 + apps/web/public/static/locales/en/common.json | 6 +- .../app-store/googlecalendar/lib/constants.ts | 3 +- .../features/bookings/lib/handleNewBooking.ts | 8 +- .../scheduleNoShowTriggers.ts | 220 +++++++++++++----- .../tasker/tasks/triggerNoShow/common.ts | 106 +++++++-- .../tasker/tasks/triggerNoShow/schema.ts | 15 ++ .../webhooks/components/WebhookForm.tsx | 10 +- packages/features/webhooks/lib/constants.ts | 2 + .../features/webhooks/lib/scheduleTrigger.ts | 4 +- .../migration.sql | 10 + packages/prisma/schema.prisma | 2 + 13 files changed, 307 insertions(+), 90 deletions(-) create mode 100644 packages/prisma/migrations/20241023095517_add_webhook_google_meet_no_show/migration.sql diff --git a/apps/web/modules/settings/platform/oauth-clients/[clientId]/edit/edit-webhooks-view.tsx b/apps/web/modules/settings/platform/oauth-clients/[clientId]/edit/edit-webhooks-view.tsx index 35583707afdec4..82b2c60b79c4d2 100644 --- a/apps/web/modules/settings/platform/oauth-clients/[clientId]/edit/edit-webhooks-view.tsx +++ b/apps/web/modules/settings/platform/oauth-clients/[clientId]/edit/edit-webhooks-view.tsx @@ -77,6 +77,14 @@ export default function EditOAuthClientWebhooks() { value: WebhookTriggerEvents.AFTER_GUESTS_CAL_VIDEO_NO_SHOW, label: "after_guests_cal_video_no_show", }, + { + value: WebhookTriggerEvents.AFTER_HOSTS_GOOGLE_MEET_NO_SHOW, + label: "after_hosts_google_meet_no_show", + }, + { + value: WebhookTriggerEvents.AFTER_GUESTS_GOOGLE_MEET_NO_SHOW, + label: "after_guests_google_meet_no_show", + }, ]} onSubmit={async (data) => { try { diff --git a/apps/web/package.json b/apps/web/package.json index f984694d2da025..3cba2c9bb7c6de 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -146,6 +146,8 @@ "@babel/core": "^7.19.6", "@calcom/config": "*", "@calcom/types": "*", + "@google-apps/meet": "^0.3.0", + "@google-cloud/local-auth": "2.1.0", "@microsoft/microsoft-graph-types-beta": "0.15.0-preview", "@playwright/test": "^1.45.3", "@testing-library/react": "^13.3.0", @@ -177,6 +179,7 @@ "deasync": "^0.1.30", "detect-port": "^1.3.0", "env-cmd": "^10.1.0", + "google-auth-library": "^9.14.2", "module-alias": "^2.2.2", "msw": "^0.42.3", "node-html-parser": "^6.1.10", diff --git a/apps/web/public/static/locales/en/common.json b/apps/web/public/static/locales/en/common.json index 8ffa5487bbed1e..57679da6e7e8b1 100644 --- a/apps/web/public/static/locales/en/common.json +++ b/apps/web/public/static/locales/en/common.json @@ -1229,6 +1229,8 @@ "event_cancelled_trigger": "when event is canceled", "after_hosts_cal_video_no_show": "After hosts don't join cal video", "after_guests_cal_video_no_show": "After guests don't join cal video", + "after_hosts_google_meet_no_show": "After hosts don't join google meet", + "after_guests_google_meet_no_show": "After guests don't join google meet", "new_event_trigger": "when new event is booked", "email_host_action": "send email to host", "email_attendee_action": "send email to attendees", @@ -2651,10 +2653,10 @@ "no_columns_found": "No columns found", "salesforce_create_record_as": "On booking, add events on and new attendees as:", "salesforce_lead": "Lead", - "salesforce_contact_under_account": "Contact under an account", + "salesforce_contact_under_account": "Contact under an account", "salesforce_skip_entry_creation": "Skip creating {{entry}} record if they do not exist in Salesforce", "salesforce_if_account_does_not_exist": "If the contact does not exist under an account, create new lead from attendee", - "salesforce_create_new_contact_under_account": "Create a new contact under an account based on email domain of attendee and existing contacts", + "salesforce_create_new_contact_under_account": "Create a new contact under an account based on email domain of attendee and existing contacts", "mass_assign_attributes": "Mass assign attributes", "reroute_preview_custom_message": "It results in showing custom message. Try changing the response to route to an event", "reroute_preview_external_redirect": "It results in redirecting to {{externalUrl}}. Try changing the response to route to an event", diff --git a/packages/app-store/googlecalendar/lib/constants.ts b/packages/app-store/googlecalendar/lib/constants.ts index 5c285ce803bd88..332458ca4c1a62 100644 --- a/packages/app-store/googlecalendar/lib/constants.ts +++ b/packages/app-store/googlecalendar/lib/constants.ts @@ -1,7 +1,8 @@ export const SCOPE_USERINFO_PROFILE = "https://www.googleapis.com/auth/userinfo.profile"; export const SCOPE_CALENDAR_READONLY = "https://www.googleapis.com/auth/calendar.readonly"; export const SCOPE_CALENDAR_EVENT = "https://www.googleapis.com/auth/calendar.events"; +export const GOOGLE_MEET_API = "https://www.googleapis.com/auth/meetings.space.readonly"; -export const REQUIRED_SCOPES = [SCOPE_CALENDAR_READONLY, SCOPE_CALENDAR_EVENT]; +export const REQUIRED_SCOPES = [SCOPE_CALENDAR_READONLY, SCOPE_CALENDAR_EVENT, GOOGLE_MEET_API]; export const SCOPES = [...REQUIRED_SCOPES, SCOPE_USERINFO_PROFILE]; diff --git a/packages/features/bookings/lib/handleNewBooking.ts b/packages/features/bookings/lib/handleNewBooking.ts index 22af828b4e173a..c7d619e267b1eb 100644 --- a/packages/features/bookings/lib/handleNewBooking.ts +++ b/packages/features/bookings/lib/handleNewBooking.ts @@ -12,7 +12,6 @@ import { OrganizerDefaultConferencingAppType, getLocationValueForDB, } from "@calcom/app-store/locations"; -import { DailyLocationType } from "@calcom/app-store/locations"; import { getAppFromSlug } from "@calcom/app-store/utils"; import EventManager from "@calcom/core/EventManager"; import { getEventName } from "@calcom/core/event"; @@ -1249,6 +1248,7 @@ async function handler( } else if (isConfirmedByDefault) { // Use EventManager to conditionally use all needed integrations. const createManager = await eventManager.create(evt); + console.log("createManager", JSON.stringify(createManager)); if (evt.location) { booking.location = evt.location; } @@ -1647,17 +1647,19 @@ async function handler( } try { - if (isConfirmedByDefault && (booking.location === DailyLocationType || booking.location?.trim() === "")) { + if (isConfirmedByDefault) { await scheduleNoShowTriggers({ - booking: { startTime: booking.startTime, id: booking.id }, + booking: { startTime: booking.startTime, id: booking.id, location: booking.location }, triggerForUser, organizerUser: { id: organizerUser.id }, eventTypeId, teamId, orgId, + destinationCalendars: evt.destinationCalendar, }); } } catch (error) { + console.log("error", error); loggerWithEventDetails.error("Error while scheduling no show triggers", JSON.stringify({ error })); } diff --git a/packages/features/bookings/lib/handleNewBooking/scheduleNoShowTriggers.ts b/packages/features/bookings/lib/handleNewBooking/scheduleNoShowTriggers.ts index 49906aef079279..4ff274a0a6c195 100644 --- a/packages/features/bookings/lib/handleNewBooking/scheduleNoShowTriggers.ts +++ b/packages/features/bookings/lib/handleNewBooking/scheduleNoShowTriggers.ts @@ -1,5 +1,9 @@ +import type { DestinationCalendar } from "@prisma/client"; + +import { DailyLocationType } from "@calcom/app-store/locations"; import dayjs from "@calcom/dayjs"; import tasker from "@calcom/features/tasker"; +import { triggerHostNoShow } from "@calcom/features/tasker/tasks/triggerNoShow/triggerHostNoShow"; import getWebhooks from "@calcom/features/webhooks/lib/getWebhooks"; import { WebhookTriggerEvents } from "@calcom/prisma/enums"; @@ -7,79 +11,173 @@ type ScheduleNoShowTriggersArgs = { booking: { startTime: Date; id: number; + location: string | null; }; triggerForUser?: number | true | null; organizerUser: { id: number | null }; eventTypeId: number | null; teamId?: number | null; orgId?: number | null; + destinationCalendars?: DestinationCalendar[]; }; export const scheduleNoShowTriggers = async (args: ScheduleNoShowTriggersArgs) => { - const { booking, triggerForUser, organizerUser, eventTypeId, teamId, orgId } = args; + const { booking, triggerForUser, organizerUser, eventTypeId, teamId, orgId, destinationCalendars } = args; + + const isDailyVideoLocation = booking.location === DailyLocationType || booking.location?.trim() === ""; - // Add task for automatic no show in cal video + const isGoogleMeetLocation = booking.location === "integrations:google:meet"; const noShowPromises: Promise[] = []; - const subscribersHostsNoShowStarted = await getWebhooks({ - userId: triggerForUser ? organizerUser.id : null, - eventTypeId, - triggerEvent: WebhookTriggerEvents.AFTER_HOSTS_CAL_VIDEO_NO_SHOW, - teamId, - orgId, - }); - - noShowPromises.push( - ...subscribersHostsNoShowStarted.map((webhook) => { - if (booking?.startTime && webhook.time && webhook.timeUnit) { - const scheduledAt = dayjs(booking.startTime) - .add(webhook.time, webhook.timeUnit.toLowerCase() as dayjs.ManipulateType) - .toDate(); - return tasker.create( - "triggerHostNoShowWebhook", - { - triggerEvent: WebhookTriggerEvents.AFTER_HOSTS_CAL_VIDEO_NO_SHOW, - bookingId: booking.id, - // Prevents null values from being serialized - webhook: { ...webhook, time: webhook.time, timeUnit: webhook.timeUnit }, - }, - { scheduledAt } - ); - } - return Promise.resolve(); - }) - ); - - const subscribersGuestsNoShowStarted = await getWebhooks({ - userId: triggerForUser ? organizerUser.id : null, - eventTypeId, - triggerEvent: WebhookTriggerEvents.AFTER_GUESTS_CAL_VIDEO_NO_SHOW, - teamId, - orgId, - }); - - noShowPromises.push( - ...subscribersGuestsNoShowStarted.map((webhook) => { - if (booking?.startTime && webhook.time && webhook.timeUnit) { - const scheduledAt = dayjs(booking.startTime) - .add(webhook.time, webhook.timeUnit.toLowerCase() as dayjs.ManipulateType) - .toDate(); - - return tasker.create( - "triggerGuestNoShowWebhook", - { - triggerEvent: WebhookTriggerEvents.AFTER_GUESTS_CAL_VIDEO_NO_SHOW, - bookingId: booking.id, - // Prevents null values from being serialized - webhook: { ...webhook, time: webhook.time, timeUnit: webhook.timeUnit }, - }, - { scheduledAt } - ); - } - - return Promise.resolve(); - }) - ); + console.log("destinationCalendar.scheduleNoShowTriggers", args, isGoogleMeetLocation, destinationCalendars); + + if (isDailyVideoLocation) { + // Add task for automatic no show in cal video + + const subscribersHostsNoShowStarted = await getWebhooks({ + userId: triggerForUser ? organizerUser.id : null, + eventTypeId, + triggerEvent: WebhookTriggerEvents.AFTER_HOSTS_CAL_VIDEO_NO_SHOW, + teamId, + orgId, + }); + + noShowPromises.push( + ...subscribersHostsNoShowStarted.map((webhook) => { + if (booking?.startTime && webhook.time && webhook.timeUnit) { + const scheduledAt = dayjs(booking.startTime) + .add(webhook.time, webhook.timeUnit.toLowerCase() as dayjs.ManipulateType) + .toDate(); + return tasker.create( + "triggerHostNoShowWebhook", + { + triggerEvent: WebhookTriggerEvents.AFTER_HOSTS_CAL_VIDEO_NO_SHOW, + bookingId: booking.id, + // Prevents null values from being serialized + webhook: { ...webhook, time: webhook.time, timeUnit: webhook.timeUnit }, + }, + { scheduledAt } + ); + } + return Promise.resolve(); + }) + ); + + const subscribersGuestsNoShowStarted = await getWebhooks({ + userId: triggerForUser ? organizerUser.id : null, + eventTypeId, + triggerEvent: WebhookTriggerEvents.AFTER_GUESTS_CAL_VIDEO_NO_SHOW, + teamId, + orgId, + }); + + noShowPromises.push( + ...subscribersGuestsNoShowStarted.map((webhook) => { + if (booking?.startTime && webhook.time && webhook.timeUnit) { + const scheduledAt = dayjs(booking.startTime) + .add(webhook.time, webhook.timeUnit.toLowerCase() as dayjs.ManipulateType) + .toDate(); + + return tasker.create( + "triggerGuestNoShowWebhook", + { + triggerEvent: WebhookTriggerEvents.AFTER_GUESTS_CAL_VIDEO_NO_SHOW, + bookingId: booking.id, + // Prevents null values from being serialized + webhook: { ...webhook, time: webhook.time, timeUnit: webhook.timeUnit }, + }, + { scheduledAt } + ); + } + + return Promise.resolve(); + }) + ); + } else if (isGoogleMeetLocation) { + // Add task for automatic no show in google meet + + const destinationCalendar = destinationCalendars?.find((cal) => cal.userId === organizerUser.id); + + const subscribersHostsNoShowStartedPromises = await getWebhooks({ + userId: triggerForUser ? organizerUser.id : null, + eventTypeId, + triggerEvent: WebhookTriggerEvents.AFTER_HOSTS_GOOGLE_MEET_NO_SHOW, + teamId, + orgId, + }); + + const subscribersGuestsNoShowStartedPromises = await getWebhooks({ + userId: triggerForUser ? organizerUser.id : null, + eventTypeId, + triggerEvent: WebhookTriggerEvents.AFTER_GUESTS_GOOGLE_MEET_NO_SHOW, + teamId, + orgId, + }); + + const [subscribersHostsNoShowStarted, subscribersGuestsNoShowStarted] = await Promise.all([ + subscribersHostsNoShowStartedPromises, + subscribersGuestsNoShowStartedPromises, + ]); + + await triggerHostNoShow( + JSON.stringify({ + triggerEvent: WebhookTriggerEvents.AFTER_HOSTS_GOOGLE_MEET_NO_SHOW, + bookingId: booking.id, + webhook: { + ...subscribersHostsNoShowStarted[0], + time: subscribersHostsNoShowStarted[0].time, + timeUnit: subscribersHostsNoShowStarted[0].timeUnit, + }, + destinationCalendar, + }) + ); + + noShowPromises.push( + ...subscribersHostsNoShowStarted.map((webhook) => { + if (booking?.startTime && webhook.time && webhook.timeUnit) { + const scheduledAt = dayjs(booking.startTime) + .add(webhook.time, webhook.timeUnit.toLowerCase() as dayjs.ManipulateType) + .toDate(); + return tasker.create( + "triggerHostNoShowWebhook", + { + triggerEvent: WebhookTriggerEvents.AFTER_HOSTS_GOOGLE_MEET_NO_SHOW, + bookingId: booking.id, + // Prevents null values from being serialized + webhook: { ...webhook, time: webhook.time, timeUnit: webhook.timeUnit }, + destinationCalendar, + }, + { scheduledAt } + ); + } + return Promise.resolve(); + }) + ); + + noShowPromises.push( + ...subscribersGuestsNoShowStarted.map((webhook) => { + if (booking?.startTime && webhook.time && webhook.timeUnit) { + const scheduledAt = dayjs(booking.startTime) + .add(webhook.time, webhook.timeUnit.toLowerCase() as dayjs.ManipulateType) + .toDate(); + + return tasker.create( + "triggerGuestNoShowWebhook", + { + triggerEvent: WebhookTriggerEvents.AFTER_GUESTS_GOOGLE_MEET_NO_SHOW, + bookingId: booking.id, + // Prevents null values from being serialized + webhook: { ...webhook, time: webhook.time, timeUnit: webhook.timeUnit }, + destinationCalendar, + }, + { scheduledAt } + ); + } + + return Promise.resolve(); + }) + ); + } await Promise.all(noShowPromises); diff --git a/packages/features/tasker/tasks/triggerNoShow/common.ts b/packages/features/tasker/tasks/triggerNoShow/common.ts index 1bf8fc0c372b34..9007d77fccaf6f 100644 --- a/packages/features/tasker/tasks/triggerNoShow/common.ts +++ b/packages/features/tasker/tasks/triggerNoShow/common.ts @@ -1,13 +1,16 @@ +import { getCalendar } from "@calcom/app-store/_utils/getCalendar"; +import { DailyLocationType } from "@calcom/app-store/locations"; import dayjs from "@calcom/dayjs"; import { sendGenericWebhookPayload } from "@calcom/features/webhooks/lib/sendPayload"; import logger from "@calcom/lib/logger"; import { safeStringify } from "@calcom/lib/safeStringify"; +import { prisma } from "@calcom/prisma"; import type { TimeUnit } from "@calcom/prisma/enums"; import { BookingStatus, WebhookTriggerEvents } from "@calcom/prisma/enums"; import { getBooking } from "./getBooking"; import { getMeetingSessionsFromRoomName } from "./getMeetingSessionsFromRoomName"; -import type { TWebhook, TTriggerNoShowPayloadSchema } from "./schema"; +import type { TWebhook, TTriggerNoShowPayloadSchema, TSendNoShowWebhookPayloadSchema } from "./schema"; import { ZSendNoShowWebhookPayloadSchema } from "./schema"; export type Host = { @@ -18,6 +21,39 @@ export type Host = { export type Booking = Awaited>; type Webhook = TWebhook; export type Participants = TTriggerNoShowPayloadSchema["data"][number]["participants"]; +export type DestinationCalendar = TSendNoShowWebhookPayloadSchema["destinationCalendar"]; + +const getGoogleCalendarCredential = async (destinationCalendar: DestinationCalendar) => { + const credential = await prisma.credential.findUnique({ + where: { + id: destinationCalendar?.credentialId, + }, + }); + + if (credential) { + return credential; + } + + const destinationCalendars = await prisma.destinationCalendar.findMany({ + where: { + integration: "google_calendar", + userId: destinationCalendar?.userId, + externalId: destinationCalendar?.externalId, + }, + }); + + if (!destinationCalendars.length) { + return null; + } + + const newCredential = await prisma.credential.findUnique({ + where: { + id: destinationCalendars[0].credentialId, + }, + }); + + return newCredential; +}; export function getHosts(booking: Booking): Host[] { const hostMap = new Map(); @@ -106,7 +142,9 @@ export const prepareNoShowTrigger = async ( numberOfHostsThatJoined: number; didGuestJoinTheCall: boolean; } | void> => { - const { bookingId, webhook } = ZSendNoShowWebhookPayloadSchema.parse(JSON.parse(payload)); + const { bookingId, webhook, destinationCalendar, triggerEvent } = ZSendNoShowWebhookPayloadSchema.parse( + JSON.parse(payload) + ); const booking = await getBooking(bookingId); @@ -122,32 +160,58 @@ export const prepareNoShowTrigger = async ( return; } + const hosts = getHosts(booking); + const dailyVideoReference = booking.references.find((reference) => reference.type === "daily_video"); - if (!dailyVideoReference) { + if (!!dailyVideoReference || booking.location === DailyLocationType || booking.location?.trim() === "") { + const meetingDetails = await getMeetingSessionsFromRoomName(dailyVideoReference.uid); + + const allParticipants = meetingDetails.data.flatMap((meeting) => meeting.participants); + + const hostsThatDidntJoinTheCall = hosts.filter( + (host) => !checkIfUserJoinedTheCall(host.id, allParticipants) + ); + + const numberOfHostsThatJoined = hosts.length - hostsThatDidntJoinTheCall.length; + + const didGuestJoinTheCall = meetingDetails.data.some( + (meeting) => meeting.max_participants < numberOfHostsThatJoined + ); + + return { + hostsThatDidntJoinTheCall, + booking, + numberOfHostsThatJoined, + webhook, + didGuestJoinTheCall, + triggerEvent, + }; + } else if (booking.location === "integrations:google:meet") { + const googleCalendarCredentials = await getGoogleCalendarCredential(destinationCalendar); + if (!googleCalendarCredentials) { + log.error( + "No google calendar credentials found", + safeStringify({ + bookingId, + webhook: { id: webhook.id }, + destinationCalendar, + }) + ); + return; + } + + const calendar = await getCalendar(googleCalendarCredentials); + const participants = await calendar?.getParticipants(booking.metadata?.videoCallUrl); + console.log("participants", participants); + } else { log.error( - "Daily video reference not found", + "No valid location found in triggerNoShowWebhook", safeStringify({ bookingId, webhook: { id: webhook.id }, }) ); - throw new Error(`Daily video reference not found in triggerHostNoShow with bookingId ${bookingId}`); + throw new Error(`No valid location found in triggerNoShowWebhook with bookingId ${bookingId}`); } - const meetingDetails = await getMeetingSessionsFromRoomName(dailyVideoReference.uid); - - const hosts = getHosts(booking); - const allParticipants = meetingDetails.data.flatMap((meeting) => meeting.participants); - - const hostsThatDidntJoinTheCall = hosts.filter( - (host) => !checkIfUserJoinedTheCall(host.id, allParticipants) - ); - - const numberOfHostsThatJoined = hosts.length - hostsThatDidntJoinTheCall.length; - - const didGuestJoinTheCall = meetingDetails.data.some( - (meeting) => meeting.max_participants < numberOfHostsThatJoined - ); - - return { hostsThatDidntJoinTheCall, booking, numberOfHostsThatJoined, webhook, didGuestJoinTheCall }; }; diff --git a/packages/features/tasker/tasks/triggerNoShow/schema.ts b/packages/features/tasker/tasks/triggerNoShow/schema.ts index 355b6cbd0c02b7..a998330153e2e8 100644 --- a/packages/features/tasker/tasks/triggerNoShow/schema.ts +++ b/packages/features/tasker/tasks/triggerNoShow/schema.ts @@ -7,6 +7,8 @@ const commonSchema = z.object({ triggerEvent: z.enum([ WebhookTriggerEvents.AFTER_HOSTS_CAL_VIDEO_NO_SHOW, WebhookTriggerEvents.AFTER_GUESTS_CAL_VIDEO_NO_SHOW, + WebhookTriggerEvents.AFTER_HOSTS_GOOGLE_MEET_NO_SHOW, + WebhookTriggerEvents.AFTER_GUESTS_GOOGLE_MEET_NO_SHOW, ]), bookingId: z.number(), }); @@ -48,10 +50,23 @@ export const triggerNoShowPayloadSchema = z.object({ ), }); +const ZDestinationCalendar = z + .object({ + id: z.number(), + integration: z.string(), + externalId: z.string(), + primaryEmail: z.string().nullable(), + credentialId: z.number().nullable(), + userId: z.number().nullable(), + }) + .passthrough() + .optional(); + export type TTriggerNoShowPayloadSchema = z.infer; export const ZSendNoShowWebhookPayloadSchema = commonSchema.extend({ webhook: ZWebhook, + destinationCalendar: ZDestinationCalendar, }); export type TSendNoShowWebhookPayloadSchema = z.infer; diff --git a/packages/features/webhooks/components/WebhookForm.tsx b/packages/features/webhooks/components/WebhookForm.tsx index 92433a213bc21c..1f8c2e38e8e2c9 100644 --- a/packages/features/webhooks/components/WebhookForm.tsx +++ b/packages/features/webhooks/components/WebhookForm.tsx @@ -57,6 +57,11 @@ const WEBHOOK_TRIGGER_EVENTS_GROUPED_BY_APP_V2: Record { + console.log("event", event); onChange(event.map((selection) => selection.value)); const noShowWebhookTriggerExists = !!event.find( (trigger) => trigger.value === WebhookTriggerEvents.AFTER_HOSTS_CAL_VIDEO_NO_SHOW || - trigger.value === WebhookTriggerEvents.AFTER_GUESTS_CAL_VIDEO_NO_SHOW + trigger.value === WebhookTriggerEvents.AFTER_GUESTS_CAL_VIDEO_NO_SHOW || + trigger.value === WebhookTriggerEvents.AFTER_HOSTS_GOOGLE_MEET_NO_SHOW || + trigger.value === WebhookTriggerEvents.AFTER_GUESTS_GOOGLE_MEET_NO_SHOW ); if (noShowWebhookTriggerExists) { diff --git a/packages/features/webhooks/lib/constants.ts b/packages/features/webhooks/lib/constants.ts index c9ceab76cdcfd3..26f6ecf9b06786 100644 --- a/packages/features/webhooks/lib/constants.ts +++ b/packages/features/webhooks/lib/constants.ts @@ -20,6 +20,8 @@ export const WEBHOOK_TRIGGER_EVENTS_GROUPED_BY_APP = { WebhookTriggerEvents.OOO_CREATED, WebhookTriggerEvents.AFTER_HOSTS_CAL_VIDEO_NO_SHOW, WebhookTriggerEvents.AFTER_GUESTS_CAL_VIDEO_NO_SHOW, + WebhookTriggerEvents.AFTER_HOSTS_GOOGLE_MEET_NO_SHOW, + WebhookTriggerEvents.AFTER_GUESTS_GOOGLE_MEET_NO_SHOW, ] as const, "routing-forms": [WebhookTriggerEvents.FORM_SUBMITTED] as const, }; diff --git a/packages/features/webhooks/lib/scheduleTrigger.ts b/packages/features/webhooks/lib/scheduleTrigger.ts index 6529e8e7994e90..35bc47a63d541d 100644 --- a/packages/features/webhooks/lib/scheduleTrigger.ts +++ b/packages/features/webhooks/lib/scheduleTrigger.ts @@ -442,7 +442,9 @@ export async function updateTriggerForExistingBookings( return addedEventTriggers.map((triggerEvent) => { if ( triggerEvent === WebhookTriggerEvents.AFTER_GUESTS_CAL_VIDEO_NO_SHOW || - triggerEvent === WebhookTriggerEvents.AFTER_HOSTS_CAL_VIDEO_NO_SHOW + triggerEvent === WebhookTriggerEvents.AFTER_HOSTS_CAL_VIDEO_NO_SHOW || + triggerEvent === WebhookTriggerEvents.AFTER_HOSTS_GOOGLE_MEET_NO_SHOW || + triggerEvent === WebhookTriggerEvents.AFTER_GUESTS_GOOGLE_MEET_NO_SHOW ) return Promise.resolve(); diff --git a/packages/prisma/migrations/20241023095517_add_webhook_google_meet_no_show/migration.sql b/packages/prisma/migrations/20241023095517_add_webhook_google_meet_no_show/migration.sql new file mode 100644 index 00000000000000..992825ca9485bc --- /dev/null +++ b/packages/prisma/migrations/20241023095517_add_webhook_google_meet_no_show/migration.sql @@ -0,0 +1,10 @@ +-- AlterEnum +-- This migration adds more than one value to an enum. +-- With PostgreSQL versions 11 and earlier, this is not possible +-- in a single migration. This can be worked around by creating +-- multiple migrations, each migration adding only one value to +-- the enum. + + +ALTER TYPE "WebhookTriggerEvents" ADD VALUE 'AFTER_HOSTS_GOOGLE_MEET_NO_SHOW'; +ALTER TYPE "WebhookTriggerEvents" ADD VALUE 'AFTER_GUESTS_GOOGLE_MEET_NO_SHOW'; diff --git a/packages/prisma/schema.prisma b/packages/prisma/schema.prisma index af63d6d9811dc7..6ab2fb4a616267 100644 --- a/packages/prisma/schema.prisma +++ b/packages/prisma/schema.prisma @@ -778,6 +778,8 @@ enum WebhookTriggerEvents { OOO_CREATED AFTER_HOSTS_CAL_VIDEO_NO_SHOW AFTER_GUESTS_CAL_VIDEO_NO_SHOW + AFTER_HOSTS_GOOGLE_MEET_NO_SHOW + AFTER_GUESTS_GOOGLE_MEET_NO_SHOW } model Webhook { From 744a04c6da17e3cc0b2be6d71ea2f29207b9d25e Mon Sep 17 00:00:00 2001 From: Udit Takkar Date: Wed, 23 Oct 2024 17:45:56 +0530 Subject: [PATCH 2/9] chore: add Calendar service --- .../googlecalendar/lib/CalendarService.ts | 98 ++++++++++++++++++- 1 file changed, 97 insertions(+), 1 deletion(-) diff --git a/packages/app-store/googlecalendar/lib/CalendarService.ts b/packages/app-store/googlecalendar/lib/CalendarService.ts index 20ed7ce00f7141..7b102f32f79cee 100644 --- a/packages/app-store/googlecalendar/lib/CalendarService.ts +++ b/packages/app-store/googlecalendar/lib/CalendarService.ts @@ -1,5 +1,7 @@ /* eslint-disable @typescript-eslint/no-explicit-any */ +import { ConferenceRecordsServiceClient, SpacesServiceClient } from "@google-apps/meet"; import type { Prisma } from "@prisma/client"; +import { GoogleAuth, OAuth2Client } from "google-auth-library"; import type { calendar_v3 } from "googleapis"; import { google } from "googleapis"; import { RRule } from "rrule"; @@ -334,6 +336,7 @@ export default class GoogleCalendarService implements Calendar { conferenceDataVersion: 1, sendUpdates: "none", }); + event = eventResponse.data; if (event.recurrence) { if (event.recurrence.length > 0) { @@ -663,6 +666,99 @@ export default class GoogleCalendarService implements Calendar { } } + async getParticipants(videoCallUrl: string | null): Promise { + const { token } = await this.oAuthManagerInstance.getTokenObjectOrFetch(); + if (!token) { + throw new Error("Invalid grant for Google Calendar app"); + } + + console.log("getParticipants.token", token); + + const googleAuth = new GoogleAuth({ + authClient: new OAuth2Client({ + credentials: { + access_token: token.access_token, + }, + }), + }); + + const meetClient = new ConferenceRecordsServiceClient({ + auth: googleAuth, + }); + + const spacesClient = new SpacesServiceClient({ + auth: googleAuth, + }); + + const meetingCode = videoCallUrl ? new URL(videoCallUrl).pathname.split("/").pop() : null; + + const spaceInfo = await spacesClient.getSpace({ name: `spaces/${meetingCode}` }); + console.log("spaceInfot", spaceInfo); + // const spaceName = spaceInfo[0].name; + const spaceName = "spaces/iwjcGmsr0Z4B"; + + const allConferenceRecords = meetClient.listConferenceRecordsAsync(); + + type Time = { seconds: number; nanos: number }; + + const conferenceRecords: Array<{ + space: string; + name: string; + startTime: Time; + endTime: Time; + expireTime: Time; + }> = []; + for await (const response of allConferenceRecords) { + if (response.space === spaceName) { + conferenceRecords.push(response); + } + } + console.log("conferenceRecords", conferenceRecords); + + type Participant = { + name: string; + earliestStartTime: Time; + latestEndTime: Time; + user: string; + singedInUser: { user: string; displayName: string }; + }; + + const participantsByConferenceRecord: Array> = []; + + for (const conferenceRecord of conferenceRecords) { + const participants: Array = []; + + const iterable = meetClient.listParticipantsAsync({ parent: conferenceRecord.name }); + for await (const participant of iterable) { + try { + const response = await fetch( + `https://people.googleapis.com/v1/people/${ + participant.signedinUser.user.split("/")[1] + }?personFields=emailAddresses&sources=READ_SOURCE_TYPE_OTHER_CONTACT&sources=READ_SOURCE_TYPE_PROFILE&sources=READ_SOURCE_TYPE_CONTACT`, + { + headers: { + Authorization: `Bearer ${token.access_token}`, + Accept: "application/json", + }, + } + ); + + const data = await response.json(); + + console.log("data", data); + } catch (err) { + console.log("err", err); + } + } + + participantsByConferenceRecord.push(participants); + } + + console.log("participantsByConferenceRecord", participantsByConferenceRecord); + + return conferenceRecords; + } + async listCalendars(): Promise { this.log.debug("Listing calendars"); const calendar = await this.authedCalendar(); @@ -693,7 +789,7 @@ export default class GoogleCalendarService implements Calendar { } } -class MyGoogleAuth extends google.auth.OAuth2 { +export class MyGoogleAuth extends google.auth.OAuth2 { constructor(client_id: string, client_secret: string, redirect_uri: string) { super(client_id, client_secret, redirect_uri); } From 9e2890b3c2f7598fa4265e687d029d1b443b35bd Mon Sep 17 00:00:00 2001 From: Udit Takkar Date: Wed, 23 Oct 2024 18:44:21 +0530 Subject: [PATCH 3/9] feat: send no show events --- .../googlecalendar/lib/CalendarService.ts | 98 ++++++++++--------- .../app-store/googlecalendar/lib/constants.ts | 12 ++- .../tasker/tasks/triggerNoShow/common.ts | 28 +++++- .../tasks/triggerNoShow/triggerGuestNoShow.ts | 11 +-- .../tasks/triggerNoShow/triggerHostNoShow.ts | 11 +-- 5 files changed, 93 insertions(+), 67 deletions(-) diff --git a/packages/app-store/googlecalendar/lib/CalendarService.ts b/packages/app-store/googlecalendar/lib/CalendarService.ts index 7b102f32f79cee..13ac9e6833017c 100644 --- a/packages/app-store/googlecalendar/lib/CalendarService.ts +++ b/packages/app-store/googlecalendar/lib/CalendarService.ts @@ -39,6 +39,16 @@ import { OAuth2UniversalSchema } from "../../_utils/oauth/universalSchema"; import { metadata } from "../_metadata"; import { getGoogleAppKeys } from "./getGoogleAppKeys"; +type Time = { seconds: number; nanos: number }; + +type Participant = { + name: string; + earliestStartTime: Time; + latestEndTime: Time; + user: string; + singedInUser: { user: string; displayName: string }; +}; + const log = logger.getSubLogger({ prefix: ["app-store/googlecalendar/lib/CalendarService"] }); interface GoogleCalError extends Error { code?: number; @@ -693,14 +703,9 @@ export default class GoogleCalendarService implements Calendar { const meetingCode = videoCallUrl ? new URL(videoCallUrl).pathname.split("/").pop() : null; const spaceInfo = await spacesClient.getSpace({ name: `spaces/${meetingCode}` }); - console.log("spaceInfot", spaceInfo); // const spaceName = spaceInfo[0].name; const spaceName = "spaces/iwjcGmsr0Z4B"; - const allConferenceRecords = meetClient.listConferenceRecordsAsync(); - - type Time = { seconds: number; nanos: number }; - const conferenceRecords: Array<{ space: string; name: string; @@ -708,55 +713,58 @@ export default class GoogleCalendarService implements Calendar { endTime: Time; expireTime: Time; }> = []; - for await (const response of allConferenceRecords) { + for await (const response of meetClient.listConferenceRecordsAsync()) { if (response.space === spaceName) { conferenceRecords.push(response); } } - console.log("conferenceRecords", conferenceRecords); - - type Participant = { - name: string; - earliestStartTime: Time; - latestEndTime: Time; - user: string; - singedInUser: { user: string; displayName: string }; - }; - - const participantsByConferenceRecord: Array> = []; - - for (const conferenceRecord of conferenceRecords) { - const participants: Array = []; - - const iterable = meetClient.listParticipantsAsync({ parent: conferenceRecord.name }); - for await (const participant of iterable) { - try { - const response = await fetch( - `https://people.googleapis.com/v1/people/${ - participant.signedinUser.user.split("/")[1] - }?personFields=emailAddresses&sources=READ_SOURCE_TYPE_OTHER_CONTACT&sources=READ_SOURCE_TYPE_PROFILE&sources=READ_SOURCE_TYPE_CONTACT`, - { - headers: { - Authorization: `Bearer ${token.access_token}`, - Accept: "application/json", - }, - } - ); - - const data = await response.json(); - console.log("data", data); - } catch (err) { - console.log("err", err); + const participantsByConferenceRecord: Array> = await Promise.all( + conferenceRecords.map(async (conferenceRecord) => { + const participants = []; + for await (const participant of meetClient.listParticipantsAsync({ parent: conferenceRecord.name })) { + participants.push(participant); } - } - - participantsByConferenceRecord.push(participants); - } + return participants; + }) + ); console.log("participantsByConferenceRecord", participantsByConferenceRecord); - return conferenceRecords; + const participantsWithEmails = await Promise.all( + participantsByConferenceRecord.map(async (participants) => { + return Promise.all( + participants.map(async (participant) => { + try { + const response = await fetch( + `https://people.googleapis.com/v1/people/${ + participant.signedinUser.user.split("/")[1] + }?personFields=emailAddresses`, + { + headers: { + Authorization: `Bearer ${token.access_token}`, + Accept: "application/json", + }, + } + ); + + const data = await response.json(); + const emailAddresses = data.emailAddresses; + + return { + ...participant, + email: emailAddresses ? emailAddresses[0].value : undefined, + }; + } catch (err) { + console.error("Error fetching email for participant:", err); + return participant; + } + }) + ); + }) + ); + + return participantsWithEmails; } async listCalendars(): Promise { diff --git a/packages/app-store/googlecalendar/lib/constants.ts b/packages/app-store/googlecalendar/lib/constants.ts index 332458ca4c1a62..5fc6bce0dd57d4 100644 --- a/packages/app-store/googlecalendar/lib/constants.ts +++ b/packages/app-store/googlecalendar/lib/constants.ts @@ -2,7 +2,17 @@ export const SCOPE_USERINFO_PROFILE = "https://www.googleapis.com/auth/userinfo. export const SCOPE_CALENDAR_READONLY = "https://www.googleapis.com/auth/calendar.readonly"; export const SCOPE_CALENDAR_EVENT = "https://www.googleapis.com/auth/calendar.events"; export const GOOGLE_MEET_API = "https://www.googleapis.com/auth/meetings.space.readonly"; +export const SCOPE_USERINFO_EMAIL = "https://www.googleapis.com/auth/userinfo.email"; + +export const SCOPE_USER_CONTACTS = "https://www.googleapis.com/auth/contacts.readonly"; +export const SCOPE_OTHER_USER_CONTACTS = "https://www.googleapis.com/auth/contacts.other.readonly"; export const REQUIRED_SCOPES = [SCOPE_CALENDAR_READONLY, SCOPE_CALENDAR_EVENT, GOOGLE_MEET_API]; -export const SCOPES = [...REQUIRED_SCOPES, SCOPE_USERINFO_PROFILE]; +export const SCOPES = [ + ...REQUIRED_SCOPES, + SCOPE_USERINFO_EMAIL, + SCOPE_USERINFO_PROFILE, + SCOPE_USER_CONTACTS, + SCOPE_OTHER_USER_CONTACTS, +]; diff --git a/packages/features/tasker/tasks/triggerNoShow/common.ts b/packages/features/tasker/tasks/triggerNoShow/common.ts index 9007d77fccaf6f..43efd1e7b086fb 100644 --- a/packages/features/tasker/tasks/triggerNoShow/common.ts +++ b/packages/features/tasker/tasks/triggerNoShow/common.ts @@ -149,7 +149,7 @@ export const prepareNoShowTrigger = async ( const booking = await getBooking(bookingId); if (booking.status !== BookingStatus.ACCEPTED) { - log.debug( + log.info( "Booking is not accepted", safeStringify({ bookingId, @@ -202,8 +202,30 @@ export const prepareNoShowTrigger = async ( } const calendar = await getCalendar(googleCalendarCredentials); - const participants = await calendar?.getParticipants(booking.metadata?.videoCallUrl); - console.log("participants", participants); + const allParticipantGroups = await calendar?.getParticipants(booking.metadata?.videoCallUrl); + + const allParticipants = allParticipantGroups.flat(); + + const hostsThatDidntJoinTheCall = hosts.filter( + (host) => !allParticipants?.some((participant) => participant.email === host.email) + ); + + const numberOfHostsThatJoined = hosts.length - hostsThatDidntJoinTheCall.length; + + const maxParticipants = allParticipantGroups.reduce((max, participantGroup) => { + return Math.max(max, participantGroup.length); + }, 0); + + const didGuestJoinTheCall = maxParticipants < numberOfHostsThatJoined; + + return { + hostsThatDidntJoinTheCall, + booking, + numberOfHostsThatJoined, + webhook, + didGuestJoinTheCall, + triggerEvent, + }; } else { log.error( "No valid location found in triggerNoShowWebhook", diff --git a/packages/features/tasker/tasks/triggerNoShow/triggerGuestNoShow.ts b/packages/features/tasker/tasks/triggerNoShow/triggerGuestNoShow.ts index 3c9f82e4d1a4ee..ce0a450aa023f8 100644 --- a/packages/features/tasker/tasks/triggerNoShow/triggerGuestNoShow.ts +++ b/packages/features/tasker/tasks/triggerNoShow/triggerGuestNoShow.ts @@ -1,21 +1,14 @@ -import { WebhookTriggerEvents } from "@calcom/prisma/enums"; - import { calculateMaxStartTime, sendWebhookPayload, prepareNoShowTrigger } from "./common"; export async function triggerGuestNoShow(payload: string): Promise { const result = await prepareNoShowTrigger(payload); if (!result) return; - const { webhook, booking, didGuestJoinTheCall } = result; + const { webhook, booking, didGuestJoinTheCall, triggerEvent } = result; const maxStartTime = calculateMaxStartTime(booking.startTime, webhook.time, webhook.timeUnit); if (!didGuestJoinTheCall) { - await sendWebhookPayload( - webhook, - WebhookTriggerEvents.AFTER_GUESTS_CAL_VIDEO_NO_SHOW, - booking, - maxStartTime - ); + await sendWebhookPayload(webhook, triggerEvent, booking, maxStartTime); } } diff --git a/packages/features/tasker/tasks/triggerNoShow/triggerHostNoShow.ts b/packages/features/tasker/tasks/triggerNoShow/triggerHostNoShow.ts index dee91eb04bfa50..f268a0594a21d1 100644 --- a/packages/features/tasker/tasks/triggerNoShow/triggerHostNoShow.ts +++ b/packages/features/tasker/tasks/triggerNoShow/triggerHostNoShow.ts @@ -1,5 +1,4 @@ import { prisma } from "@calcom/prisma"; -import { WebhookTriggerEvents } from "@calcom/prisma/enums"; import type { Booking, Host } from "./common"; import { calculateMaxStartTime, sendWebhookPayload, prepareNoShowTrigger, log } from "./common"; @@ -37,18 +36,12 @@ export async function triggerHostNoShow(payload: string): Promise { const result = await prepareNoShowTrigger(payload); if (!result) return; - const { booking, webhook, hostsThatDidntJoinTheCall } = result; + const { booking, webhook, hostsThatDidntJoinTheCall, triggerEvent } = result; const maxStartTime = calculateMaxStartTime(booking.startTime, webhook.time, webhook.timeUnit); const hostsNoShowPromises = hostsThatDidntJoinTheCall.map((host) => { - return sendWebhookPayload( - webhook, - WebhookTriggerEvents.AFTER_HOSTS_CAL_VIDEO_NO_SHOW, - booking, - maxStartTime, - host.email - ); + return sendWebhookPayload(webhook, triggerEvent, booking, maxStartTime, host.email); }); await Promise.all(hostsNoShowPromises); From 47017b655b53c561cda42c0ef8fc960b98a3b4a2 Mon Sep 17 00:00:00 2001 From: Udit Takkar Date: Wed, 23 Oct 2024 18:52:06 +0530 Subject: [PATCH 4/9] refactor: simplify code --- .../scheduleNoShowTriggers.ts | 230 ++++++------------ 1 file changed, 79 insertions(+), 151 deletions(-) diff --git a/packages/features/bookings/lib/handleNewBooking/scheduleNoShowTriggers.ts b/packages/features/bookings/lib/handleNewBooking/scheduleNoShowTriggers.ts index 4ff274a0a6c195..fedba563dcb8e9 100644 --- a/packages/features/bookings/lib/handleNewBooking/scheduleNoShowTriggers.ts +++ b/packages/features/bookings/lib/handleNewBooking/scheduleNoShowTriggers.ts @@ -3,7 +3,6 @@ import type { DestinationCalendar } from "@prisma/client"; import { DailyLocationType } from "@calcom/app-store/locations"; import dayjs from "@calcom/dayjs"; import tasker from "@calcom/features/tasker"; -import { triggerHostNoShow } from "@calcom/features/tasker/tasks/triggerNoShow/triggerHostNoShow"; import getWebhooks from "@calcom/features/webhooks/lib/getWebhooks"; import { WebhookTriggerEvents } from "@calcom/prisma/enums"; @@ -25,159 +24,88 @@ export const scheduleNoShowTriggers = async (args: ScheduleNoShowTriggersArgs) = const { booking, triggerForUser, organizerUser, eventTypeId, teamId, orgId, destinationCalendars } = args; const isDailyVideoLocation = booking.location === DailyLocationType || booking.location?.trim() === ""; - const isGoogleMeetLocation = booking.location === "integrations:google:meet"; + + if (!isGoogleMeetLocation && !isDailyVideoLocation) return; + const noShowPromises: Promise[] = []; - console.log("destinationCalendar.scheduleNoShowTriggers", args, isGoogleMeetLocation, destinationCalendars); - - if (isDailyVideoLocation) { - // Add task for automatic no show in cal video - - const subscribersHostsNoShowStarted = await getWebhooks({ - userId: triggerForUser ? organizerUser.id : null, - eventTypeId, - triggerEvent: WebhookTriggerEvents.AFTER_HOSTS_CAL_VIDEO_NO_SHOW, - teamId, - orgId, - }); - - noShowPromises.push( - ...subscribersHostsNoShowStarted.map((webhook) => { - if (booking?.startTime && webhook.time && webhook.timeUnit) { - const scheduledAt = dayjs(booking.startTime) - .add(webhook.time, webhook.timeUnit.toLowerCase() as dayjs.ManipulateType) - .toDate(); - return tasker.create( - "triggerHostNoShowWebhook", - { - triggerEvent: WebhookTriggerEvents.AFTER_HOSTS_CAL_VIDEO_NO_SHOW, - bookingId: booking.id, - // Prevents null values from being serialized - webhook: { ...webhook, time: webhook.time, timeUnit: webhook.timeUnit }, - }, - { scheduledAt } - ); - } - return Promise.resolve(); - }) - ); - - const subscribersGuestsNoShowStarted = await getWebhooks({ - userId: triggerForUser ? organizerUser.id : null, - eventTypeId, - triggerEvent: WebhookTriggerEvents.AFTER_GUESTS_CAL_VIDEO_NO_SHOW, - teamId, - orgId, - }); - - noShowPromises.push( - ...subscribersGuestsNoShowStarted.map((webhook) => { - if (booking?.startTime && webhook.time && webhook.timeUnit) { - const scheduledAt = dayjs(booking.startTime) - .add(webhook.time, webhook.timeUnit.toLowerCase() as dayjs.ManipulateType) - .toDate(); - - return tasker.create( - "triggerGuestNoShowWebhook", - { - triggerEvent: WebhookTriggerEvents.AFTER_GUESTS_CAL_VIDEO_NO_SHOW, - bookingId: booking.id, - // Prevents null values from being serialized - webhook: { ...webhook, time: webhook.time, timeUnit: webhook.timeUnit }, - }, - { scheduledAt } - ); - } - - return Promise.resolve(); - }) - ); - } else if (isGoogleMeetLocation) { - // Add task for automatic no show in google meet - - const destinationCalendar = destinationCalendars?.find((cal) => cal.userId === organizerUser.id); - - const subscribersHostsNoShowStartedPromises = await getWebhooks({ - userId: triggerForUser ? organizerUser.id : null, - eventTypeId, - triggerEvent: WebhookTriggerEvents.AFTER_HOSTS_GOOGLE_MEET_NO_SHOW, - teamId, - orgId, - }); - - const subscribersGuestsNoShowStartedPromises = await getWebhooks({ - userId: triggerForUser ? organizerUser.id : null, - eventTypeId, - triggerEvent: WebhookTriggerEvents.AFTER_GUESTS_GOOGLE_MEET_NO_SHOW, - teamId, - orgId, - }); - - const [subscribersHostsNoShowStarted, subscribersGuestsNoShowStarted] = await Promise.all([ - subscribersHostsNoShowStartedPromises, - subscribersGuestsNoShowStartedPromises, - ]); - - await triggerHostNoShow( - JSON.stringify({ - triggerEvent: WebhookTriggerEvents.AFTER_HOSTS_GOOGLE_MEET_NO_SHOW, - bookingId: booking.id, - webhook: { - ...subscribersHostsNoShowStarted[0], - time: subscribersHostsNoShowStarted[0].time, - timeUnit: subscribersHostsNoShowStarted[0].timeUnit, - }, - destinationCalendar, - }) - ); - - noShowPromises.push( - ...subscribersHostsNoShowStarted.map((webhook) => { - if (booking?.startTime && webhook.time && webhook.timeUnit) { - const scheduledAt = dayjs(booking.startTime) - .add(webhook.time, webhook.timeUnit.toLowerCase() as dayjs.ManipulateType) - .toDate(); - return tasker.create( - "triggerHostNoShowWebhook", - { - triggerEvent: WebhookTriggerEvents.AFTER_HOSTS_GOOGLE_MEET_NO_SHOW, - bookingId: booking.id, - // Prevents null values from being serialized - webhook: { ...webhook, time: webhook.time, timeUnit: webhook.timeUnit }, - destinationCalendar, - }, - { scheduledAt } - ); - } - return Promise.resolve(); - }) - ); - - noShowPromises.push( - ...subscribersGuestsNoShowStarted.map((webhook) => { - if (booking?.startTime && webhook.time && webhook.timeUnit) { - const scheduledAt = dayjs(booking.startTime) - .add(webhook.time, webhook.timeUnit.toLowerCase() as dayjs.ManipulateType) - .toDate(); - - return tasker.create( - "triggerGuestNoShowWebhook", - { - triggerEvent: WebhookTriggerEvents.AFTER_GUESTS_GOOGLE_MEET_NO_SHOW, - bookingId: booking.id, - // Prevents null values from being serialized - webhook: { ...webhook, time: webhook.time, timeUnit: webhook.timeUnit }, - destinationCalendar, - }, - { scheduledAt } - ); - } - - return Promise.resolve(); - }) - ); - } + const hostNoShowTriggerEvent = isDailyVideoLocation + ? WebhookTriggerEvents.AFTER_HOSTS_CAL_VIDEO_NO_SHOW + : WebhookTriggerEvents.AFTER_HOSTS_GOOGLE_MEET_NO_SHOW; + + const guestNoShowTriggerEvent = isDailyVideoLocation + ? WebhookTriggerEvents.AFTER_GUESTS_CAL_VIDEO_NO_SHOW + : WebhookTriggerEvents.AFTER_GUESTS_GOOGLE_MEET_NO_SHOW; + + const subscribersHostsNoShowStartedPromises = getWebhooks({ + userId: triggerForUser ? organizerUser.id : null, + eventTypeId, + triggerEvent: hostNoShowTriggerEvent, + teamId, + orgId, + }); + const subscribersGuestsNoShowStartedPromises = getWebhooks({ + userId: triggerForUser ? organizerUser.id : null, + eventTypeId, + triggerEvent: guestNoShowTriggerEvent, + teamId, + orgId, + }); + + const [subscribersHostsNoShowStarted, subscribersGuestsNoShowStarted] = await Promise.all([ + subscribersHostsNoShowStartedPromises, + subscribersGuestsNoShowStartedPromises, + ]); + + // TODO: is this correct? + const destinationCalendar = destinationCalendars?.find((cal) => cal.userId === organizerUser.id); + + noShowPromises.push( + ...subscribersHostsNoShowStarted.map((webhook) => { + if (booking?.startTime && webhook.time && webhook.timeUnit) { + const scheduledAt = dayjs(booking.startTime) + .add(webhook.time, webhook.timeUnit.toLowerCase() as dayjs.ManipulateType) + .toDate(); + return tasker.create( + "triggerHostNoShowWebhook", + { + triggerEvent: hostNoShowTriggerEvent, + bookingId: booking.id, + // Prevents null values from being serialized + webhook: { ...webhook, time: webhook.time, timeUnit: webhook.timeUnit }, + destinationCalendar, + }, + { scheduledAt } + ); + } + return Promise.resolve(); + }) + ); + + noShowPromises.push( + ...subscribersGuestsNoShowStarted.map((webhook) => { + if (booking?.startTime && webhook.time && webhook.timeUnit) { + const scheduledAt = dayjs(booking.startTime) + .add(webhook.time, webhook.timeUnit.toLowerCase() as dayjs.ManipulateType) + .toDate(); + + return tasker.create( + "triggerGuestNoShowWebhook", + { + triggerEvent: guestNoShowTriggerEvent, + bookingId: booking.id, + // Prevents null values from being serialized + webhook: { ...webhook, time: webhook.time, timeUnit: webhook.timeUnit }, + destinationCalendar, + }, + { scheduledAt } + ); + } + + return Promise.resolve(); + }) + ); await Promise.all(noShowPromises); From 223cf4db4c9c962f7bb15b6df1b562dad4232b69 Mon Sep 17 00:00:00 2001 From: Udit Takkar Date: Thu, 24 Oct 2024 00:56:43 +0530 Subject: [PATCH 5/9] fix: type error --- .../googlecalendar/lib/CalendarService.ts | 31 +++++-------------- 1 file changed, 7 insertions(+), 24 deletions(-) diff --git a/packages/app-store/googlecalendar/lib/CalendarService.ts b/packages/app-store/googlecalendar/lib/CalendarService.ts index 13ac9e6833017c..5a4fa810a81f82 100644 --- a/packages/app-store/googlecalendar/lib/CalendarService.ts +++ b/packages/app-store/googlecalendar/lib/CalendarService.ts @@ -39,16 +39,6 @@ import { OAuth2UniversalSchema } from "../../_utils/oauth/universalSchema"; import { metadata } from "../_metadata"; import { getGoogleAppKeys } from "./getGoogleAppKeys"; -type Time = { seconds: number; nanos: number }; - -type Participant = { - name: string; - earliestStartTime: Time; - latestEndTime: Time; - user: string; - singedInUser: { user: string; displayName: string }; -}; - const log = logger.getSubLogger({ prefix: ["app-store/googlecalendar/lib/CalendarService"] }); interface GoogleCalError extends Error { code?: number; @@ -693,33 +683,26 @@ export default class GoogleCalendarService implements Calendar { }); const meetClient = new ConferenceRecordsServiceClient({ - auth: googleAuth, + auth: googleAuth as ConferenceRecordsServiceClient["auth"], }); const spacesClient = new SpacesServiceClient({ - auth: googleAuth, + auth: googleAuth as SpacesServiceClient["auth"], }); const meetingCode = videoCallUrl ? new URL(videoCallUrl).pathname.split("/").pop() : null; const spaceInfo = await spacesClient.getSpace({ name: `spaces/${meetingCode}` }); - // const spaceName = spaceInfo[0].name; - const spaceName = "spaces/iwjcGmsr0Z4B"; - - const conferenceRecords: Array<{ - space: string; - name: string; - startTime: Time; - endTime: Time; - expireTime: Time; - }> = []; + const spaceName = spaceInfo[0].name; + + const conferenceRecords = []; for await (const response of meetClient.listConferenceRecordsAsync()) { if (response.space === spaceName) { conferenceRecords.push(response); } } - const participantsByConferenceRecord: Array> = await Promise.all( + const participantsByConferenceRecord = await Promise.all( conferenceRecords.map(async (conferenceRecord) => { const participants = []; for await (const participant of meetClient.listParticipantsAsync({ parent: conferenceRecord.name })) { @@ -738,7 +721,7 @@ export default class GoogleCalendarService implements Calendar { try { const response = await fetch( `https://people.googleapis.com/v1/people/${ - participant.signedinUser.user.split("/")[1] + participant.signedinUser?.user?.split("/")[1] }?personFields=emailAddresses`, { headers: { From c7ac4a985326ac08abc2a154486ede08dbc63f38 Mon Sep 17 00:00:00 2001 From: Udit Takkar Date: Thu, 24 Oct 2024 01:48:14 +0530 Subject: [PATCH 6/9] fix: more type error --- apps/web/package.json | 1 - .../googlecalendar/lib/CalendarService.ts | 7 ++- .../bookings/lib/handleConfirmation.ts | 3 ++ .../scheduleNoShowTriggers.ts | 2 +- .../tasker/tasks/triggerNoShow/common.ts | 50 +++++++++++++++---- packages/types/Calendar.d.ts | 7 +++ 6 files changed, 57 insertions(+), 13 deletions(-) diff --git a/apps/web/package.json b/apps/web/package.json index 3e5d5c8a574ff4..2fbbe9e03da215 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -147,7 +147,6 @@ "@calcom/config": "*", "@calcom/types": "*", "@google-apps/meet": "^0.3.0", - "@google-cloud/local-auth": "2.1.0", "@microsoft/microsoft-graph-types-beta": "0.15.0-preview", "@playwright/test": "^1.45.3", "@testing-library/react": "^13.3.0", diff --git a/packages/app-store/googlecalendar/lib/CalendarService.ts b/packages/app-store/googlecalendar/lib/CalendarService.ts index 5a4fa810a81f82..51128c8abd7a81 100644 --- a/packages/app-store/googlecalendar/lib/CalendarService.ts +++ b/packages/app-store/googlecalendar/lib/CalendarService.ts @@ -1,4 +1,5 @@ /* eslint-disable @typescript-eslint/no-explicit-any */ +import type { protos } from "@google-apps/meet"; import { ConferenceRecordsServiceClient, SpacesServiceClient } from "@google-apps/meet"; import type { Prisma } from "@prisma/client"; import { GoogleAuth, OAuth2Client } from "google-auth-library"; @@ -44,6 +45,10 @@ interface GoogleCalError extends Error { code?: number; } +export interface ParticipantWithEmail extends protos.google.apps.meet.v2.IParticipant { + email?: string; +} + const ONE_MINUTE_MS = 60 * 1000; const CACHING_TIME = ONE_MINUTE_MS; @@ -666,7 +671,7 @@ export default class GoogleCalendarService implements Calendar { } } - async getParticipants(videoCallUrl: string | null): Promise { + async getMeetParticipants(videoCallUrl: string | null): Promise { const { token } = await this.oAuthManagerInstance.getTokenObjectOrFetch(); if (!token) { throw new Error("Invalid grant for Google Calendar app"); diff --git a/packages/features/bookings/lib/handleConfirmation.ts b/packages/features/bookings/lib/handleConfirmation.ts index eb949e11a8131d..ec725b734c4764 100644 --- a/packages/features/bookings/lib/handleConfirmation.ts +++ b/packages/features/bookings/lib/handleConfirmation.ts @@ -40,6 +40,7 @@ export async function handleConfirmation(args: { booking: { startTime: Date; id: number; + location: string | null; eventType: { currency: string; description: string | null; @@ -401,12 +402,14 @@ export async function handleConfirmation(args: { booking: { startTime: booking.startTime, id: booking.id, + location: booking.location, }, triggerForUser, organizerUser: { id: booking.userId }, eventTypeId: booking.eventTypeId, teamId, orgId, + destinationCalendars: evt.destinationCalendar, }); const eventTypeInfo: EventTypeInfo = { diff --git a/packages/features/bookings/lib/handleNewBooking/scheduleNoShowTriggers.ts b/packages/features/bookings/lib/handleNewBooking/scheduleNoShowTriggers.ts index fedba563dcb8e9..7555b8aceb1a7a 100644 --- a/packages/features/bookings/lib/handleNewBooking/scheduleNoShowTriggers.ts +++ b/packages/features/bookings/lib/handleNewBooking/scheduleNoShowTriggers.ts @@ -17,7 +17,7 @@ type ScheduleNoShowTriggersArgs = { eventTypeId: number | null; teamId?: number | null; orgId?: number | null; - destinationCalendars?: DestinationCalendar[]; + destinationCalendars?: DestinationCalendar[] | null; }; export const scheduleNoShowTriggers = async (args: ScheduleNoShowTriggersArgs) => { diff --git a/packages/features/tasker/tasks/triggerNoShow/common.ts b/packages/features/tasker/tasks/triggerNoShow/common.ts index 43efd1e7b086fb..4f8d108caacd6c 100644 --- a/packages/features/tasker/tasks/triggerNoShow/common.ts +++ b/packages/features/tasker/tasks/triggerNoShow/common.ts @@ -1,3 +1,5 @@ +import type { protos } from "@google-apps/meet"; + import { getCalendar } from "@calcom/app-store/_utils/getCalendar"; import { DailyLocationType } from "@calcom/app-store/locations"; import dayjs from "@calcom/dayjs"; @@ -7,6 +9,7 @@ import { safeStringify } from "@calcom/lib/safeStringify"; import { prisma } from "@calcom/prisma"; import type { TimeUnit } from "@calcom/prisma/enums"; import { BookingStatus, WebhookTriggerEvents } from "@calcom/prisma/enums"; +import { bookingMetadataSchema } from "@calcom/prisma/zod-utils"; import { getBooking } from "./getBooking"; import { getMeetingSessionsFromRoomName } from "./getMeetingSessionsFromRoomName"; @@ -18,34 +21,59 @@ export type Host = { email: string; }; +interface ParticipantWithEmail extends protos.google.apps.meet.v2.IParticipant { + email?: string; +} + export type Booking = Awaited>; type Webhook = TWebhook; export type Participants = TTriggerNoShowPayloadSchema["data"][number]["participants"]; export type DestinationCalendar = TSendNoShowWebhookPayloadSchema["destinationCalendar"]; const getGoogleCalendarCredential = async (destinationCalendar: DestinationCalendar) => { - const credential = await prisma.credential.findUnique({ - where: { - id: destinationCalendar?.credentialId, - }, - }); + if (destinationCalendar?.credentialId) { + const credential = await prisma.credential.findUnique({ + where: { + id: destinationCalendar.credentialId, + }, + include: { + user: { + select: { + email: true, + }, + }, + }, + }); - if (credential) { - return credential; + if (credential) { + return credential; + } } + // If for some reason credentialId is deleted, we find the destinationCalendar by userId and externalId const destinationCalendars = await prisma.destinationCalendar.findMany({ where: { integration: "google_calendar", userId: destinationCalendar?.userId, externalId: destinationCalendar?.externalId, }, + include: { + user: { + select: { + email: true, + }, + }, + }, }); if (!destinationCalendars.length) { return null; } + if (!destinationCalendars?.[0]?.credentialId) { + return null; + } + const newCredential = await prisma.credential.findUnique({ where: { id: destinationCalendars[0].credentialId, @@ -141,6 +169,7 @@ export const prepareNoShowTrigger = async ( hostsThatDidntJoinTheCall: Host[]; numberOfHostsThatJoined: number; didGuestJoinTheCall: boolean; + triggerEvent: WebhookTriggerEvents; } | void> => { const { bookingId, webhook, destinationCalendar, triggerEvent } = ZSendNoShowWebhookPayloadSchema.parse( JSON.parse(payload) @@ -164,7 +193,7 @@ export const prepareNoShowTrigger = async ( const dailyVideoReference = booking.references.find((reference) => reference.type === "daily_video"); - if (!!dailyVideoReference || booking.location === DailyLocationType || booking.location?.trim() === "") { + if (!!dailyVideoReference && (booking.location === DailyLocationType || booking.location?.trim() === "")) { const meetingDetails = await getMeetingSessionsFromRoomName(dailyVideoReference.uid); const allParticipants = meetingDetails.data.flatMap((meeting) => meeting.participants); @@ -202,9 +231,10 @@ export const prepareNoShowTrigger = async ( } const calendar = await getCalendar(googleCalendarCredentials); - const allParticipantGroups = await calendar?.getParticipants(booking.metadata?.videoCallUrl); + const bookingMetadata = bookingMetadataSchema.parse(booking.metadata ?? null); + const allParticipantGroups = (await calendar?.getMeetParticipants?.(bookingMetadata?.videoCallUrl)) ?? []; - const allParticipants = allParticipantGroups.flat(); + const allParticipants: ParticipantWithEmail[] = allParticipantGroups.flat(); const hostsThatDidntJoinTheCall = hosts.filter( (host) => !allParticipants?.some((participant) => participant.email === host.email) diff --git a/packages/types/Calendar.d.ts b/packages/types/Calendar.d.ts index 0f904b9cdf92e1..3960e9cf0f45d7 100644 --- a/packages/types/Calendar.d.ts +++ b/packages/types/Calendar.d.ts @@ -1,3 +1,4 @@ +import type { protos } from "@google-apps/meet"; import type { BookingSeat, DestinationCalendar, Prisma, SelectedCalendar } from "@prisma/client"; import type { Dayjs } from "dayjs"; import type { calendar_v3 } from "googleapis"; @@ -14,6 +15,10 @@ import type { CredentialPayload } from "@calcom/types/Credential"; import type { Ensure } from "./utils"; +interface ParticipantWithEmail extends protos.google.apps.meet.v2.IParticipant { + email?: string; +} + export type { VideoCallData } from "./VideoApiAdapter"; type PaymentInfo = { @@ -260,6 +265,8 @@ export interface Calendar { ): Promise; listCalendars(event?: CalendarEvent): Promise; + + getMeetParticipants?(videoCallUrl: string | null): Promise; } /** From 99c670ba117d28f091b8eb7d382da38c3ed80647 Mon Sep 17 00:00:00 2001 From: Udit Takkar Date: Thu, 24 Oct 2024 01:49:58 +0530 Subject: [PATCH 7/9] chore: few more --- packages/features/tasker/tasks/triggerNoShow/common.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/features/tasker/tasks/triggerNoShow/common.ts b/packages/features/tasker/tasks/triggerNoShow/common.ts index 4f8d108caacd6c..9b02d0791f9e59 100644 --- a/packages/features/tasker/tasks/triggerNoShow/common.ts +++ b/packages/features/tasker/tasks/triggerNoShow/common.ts @@ -231,8 +231,8 @@ export const prepareNoShowTrigger = async ( } const calendar = await getCalendar(googleCalendarCredentials); - const bookingMetadata = bookingMetadataSchema.parse(booking.metadata ?? null); - const allParticipantGroups = (await calendar?.getMeetParticipants?.(bookingMetadata?.videoCallUrl)) ?? []; + const videoCallUrl = bookingMetadataSchema.parse(booking.metadata ?? null)?.videoCallUrl ?? null; + const allParticipantGroups = (await calendar?.getMeetParticipants?.(videoCallUrl)) ?? []; const allParticipants: ParticipantWithEmail[] = allParticipantGroups.flat(); From e1f95a57adfb2743ef276bfabce4e4be10323416 Mon Sep 17 00:00:00 2001 From: Udit Takkar Date: Thu, 24 Oct 2024 18:19:26 +0530 Subject: [PATCH 8/9] fix: test and type error --- .../tasker/tasks/triggerNoShow/common.ts | 17 +++++++++++++++-- 1 file changed, 15 insertions(+), 2 deletions(-) diff --git a/packages/features/tasker/tasks/triggerNoShow/common.ts b/packages/features/tasker/tasks/triggerNoShow/common.ts index 9b02d0791f9e59..ec265e2259a0b3 100644 --- a/packages/features/tasker/tasks/triggerNoShow/common.ts +++ b/packages/features/tasker/tasks/triggerNoShow/common.ts @@ -36,7 +36,17 @@ const getGoogleCalendarCredential = async (destinationCalendar: DestinationCalen where: { id: destinationCalendar.credentialId, }, - include: { + select: { + id: true, + type: true, + key: true, + userId: true, + teamId: true, + subscriptionId: true, + billingCycleStart: true, + appId: true, + paymentStatus: true, + invalid: true, user: { select: { email: true, @@ -193,7 +203,10 @@ export const prepareNoShowTrigger = async ( const dailyVideoReference = booking.references.find((reference) => reference.type === "daily_video"); - if (!!dailyVideoReference && (booking.location === DailyLocationType || booking.location?.trim() === "")) { + if ( + !!dailyVideoReference && + (booking.location === DailyLocationType || booking.location?.trim() === "" || !booking.location) + ) { const meetingDetails = await getMeetingSessionsFromRoomName(dailyVideoReference.uid); const allParticipants = meetingDetails.data.flatMap((meeting) => meeting.participants); From 92f12f0f63c1d7dcf8d920cd5775c1f918a4607d Mon Sep 17 00:00:00 2001 From: Udit Takkar Date: Thu, 24 Oct 2024 18:41:00 +0530 Subject: [PATCH 9/9] fix: type error --- .../googlecalendar/lib/CalendarService.ts | 4 ---- .../tasker/tasks/triggerNoShow/common.ts | 22 ++++++++++++++----- 2 files changed, 16 insertions(+), 10 deletions(-) diff --git a/packages/app-store/googlecalendar/lib/CalendarService.ts b/packages/app-store/googlecalendar/lib/CalendarService.ts index 51128c8abd7a81..cf86c18580af5e 100644 --- a/packages/app-store/googlecalendar/lib/CalendarService.ts +++ b/packages/app-store/googlecalendar/lib/CalendarService.ts @@ -677,8 +677,6 @@ export default class GoogleCalendarService implements Calendar { throw new Error("Invalid grant for Google Calendar app"); } - console.log("getParticipants.token", token); - const googleAuth = new GoogleAuth({ authClient: new OAuth2Client({ credentials: { @@ -717,8 +715,6 @@ export default class GoogleCalendarService implements Calendar { }) ); - console.log("participantsByConferenceRecord", participantsByConferenceRecord); - const participantsWithEmails = await Promise.all( participantsByConferenceRecord.map(async (participants) => { return Promise.all( diff --git a/packages/features/tasker/tasks/triggerNoShow/common.ts b/packages/features/tasker/tasks/triggerNoShow/common.ts index ec265e2259a0b3..f07d4dfb5bf29f 100644 --- a/packages/features/tasker/tasks/triggerNoShow/common.ts +++ b/packages/features/tasker/tasks/triggerNoShow/common.ts @@ -67,12 +67,8 @@ const getGoogleCalendarCredential = async (destinationCalendar: DestinationCalen userId: destinationCalendar?.userId, externalId: destinationCalendar?.externalId, }, - include: { - user: { - select: { - email: true, - }, - }, + select: { + credentialId: true, }, }); @@ -88,6 +84,20 @@ const getGoogleCalendarCredential = async (destinationCalendar: DestinationCalen where: { id: destinationCalendars[0].credentialId, }, + select: { + id: true, + type: true, + key: true, + userId: true, + teamId: true, + appId: true, + invalid: true, + user: { + select: { + email: true, + }, + }, + }, }); return newCredential;