From db059d84c32cf3f90f3202caeaae30a578129373 Mon Sep 17 00:00:00 2001 From: Hariom Balhara Date: Thu, 12 Oct 2023 17:59:29 +0530 Subject: [PATCH] fix: Duplicate Calendar Invites on rescheduling an accepted booking that requires confirmation (#11827) --- .../utils/bookingScenario/bookingScenario.ts | 79 +- .../web/test/utils/bookingScenario/expects.ts | 48 +- packages/core/CalendarManager.ts | 34 +- packages/core/EventManager.ts | 189 ++++- .../handleNewBooking/test/reschedule.test.ts | 704 ++++++++++++++++++ 5 files changed, 1003 insertions(+), 51 deletions(-) diff --git a/apps/web/test/utils/bookingScenario/bookingScenario.ts b/apps/web/test/utils/bookingScenario/bookingScenario.ts index 1d65ff77ea..51d286df99 100644 --- a/apps/web/test/utils/bookingScenario/bookingScenario.ts +++ b/apps/web/test/utils/bookingScenario/bookingScenario.ts @@ -2,6 +2,7 @@ import appStoreMock from "../../../../../tests/libs/__mocks__/app-store"; import i18nMock from "../../../../../tests/libs/__mocks__/libServerI18n"; import prismock from "../../../../../tests/libs/__mocks__/prisma"; +import type { BookingReference, Attendee } from "@prisma/client"; import type { Prisma } from "@prisma/client"; import type { WebhookTriggerEvents } from "@prisma/client"; import type Stripe from "stripe"; @@ -111,17 +112,10 @@ type InputBooking = { title?: string; status: BookingStatus; attendees?: { email: string }[]; - references?: { - type: string; - uid: string; - meetingId?: string; - meetingPassword?: string; - meetingUrl?: string; - bookingId?: number; - externalCalendarId?: string; - deleted?: boolean; - credentialId?: number; - }[]; + references?: (Omit, "credentialId"> & { + // TODO: Make sure that all references start providing credentialId and then remove this intersection of optional credentialId + credentialId?: number | null; + })[]; }; export const Timezones = { @@ -267,15 +261,17 @@ async function addBookingsToDb( references: any[]; })[] ) { + log.silly("TestData: Creating Bookings", JSON.stringify(bookings)); await prismock.booking.createMany({ data: bookings, }); log.silly( - "TestData: Booking as in DB", + "TestData: Bookings as in DB", JSON.stringify({ bookings: await prismock.booking.findMany({ include: { references: true, + attendees: true, }, }), }) @@ -318,6 +314,15 @@ async function addBookings(bookings: InputBooking[]) { }, }; } + if (booking.attendees) { + bookingCreate.attendees = { + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + //@ts-ignore + createMany: { + data: booking.attendees, + }, + }; + } return bookingCreate; }) ); @@ -839,6 +844,8 @@ export function mockCalendar( const createEventCalls: any[] = []; // eslint-disable-next-line @typescript-eslint/no-explicit-any const updateEventCalls: any[] = []; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const deleteEventCalls: any[] = []; const app = appStoreMetadata[metadataLookupKey as keyof typeof appStoreMetadata]; appStoreMock.default[appStoreLookupKey as keyof typeof appStoreMock.default].mockResolvedValue({ lib: { @@ -888,6 +895,11 @@ export function mockCalendar( url: "https://UNUSED_URL", }); }, + deleteEvent: async (...rest: any[]) => { + log.silly("mockCalendar.deleteEvent", JSON.stringify({ rest })); + // eslint-disable-next-line prefer-rest-params + deleteEventCalls.push(rest); + }, getAvailability: async (): Promise => { if (calendarData?.getAvailabilityCrash) { throw new Error("MockCalendarService.getAvailability fake error"); @@ -902,6 +914,7 @@ export function mockCalendar( }); return { createEventCalls, + deleteEventCalls, updateEventCalls, }; } @@ -952,11 +965,13 @@ export function mockVideoApp({ password: "MOCK_PASS", url: `http://mock-${metadataLookupKey}.example.com`, }; - log.silly("mockSuccessfulVideoMeetingCreation", JSON.stringify({ metadataLookupKey, appStoreLookupKey })); + log.silly("mockVideoApp", JSON.stringify({ metadataLookupKey, appStoreLookupKey })); // eslint-disable-next-line @typescript-eslint/no-explicit-any const createMeetingCalls: any[] = []; // eslint-disable-next-line @typescript-eslint/no-explicit-any const updateMeetingCalls: any[] = []; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const deleteMeetingCalls: any[] = []; // eslint-disable-next-line @typescript-eslint/ban-ts-comment //@ts-ignore appStoreMock.default[appStoreLookupKey as keyof typeof appStoreMock.default].mockImplementation(() => { @@ -998,15 +1013,19 @@ export function mockVideoApp({ if (!calEvent.organizer) { throw new Error("calEvent.organizer is not defined"); } - log.silly( - "mockSuccessfulVideoMeetingCreation.updateMeeting", - JSON.stringify({ bookingRef, calEvent }) - ); + log.silly("MockVideoApiAdapter.updateMeeting", JSON.stringify({ bookingRef, calEvent })); return Promise.resolve({ type: appStoreMetadata[metadataLookupKey as keyof typeof appStoreMetadata].type, ...videoMeetingData, }); }, + deleteMeeting: async (...rest: any[]) => { + log.silly("MockVideoApiAdapter.deleteMeeting", JSON.stringify(rest)); + deleteMeetingCalls.push({ + credential, + args: rest, + }); + }, }; }, }, @@ -1016,6 +1035,7 @@ export function mockVideoApp({ return { createMeetingCalls, updateMeetingCalls, + deleteMeetingCalls, }; } @@ -1154,6 +1174,31 @@ export function getExpectedCalEventForBookingRequest({ }; } +export function getMockBookingReference( + bookingReference: Partial & Pick +) { + let credentialId = bookingReference.credentialId; + if (bookingReference.type === appStoreMetadata.dailyvideo.type) { + // Right now we seems to be storing credentialId for `dailyvideo` in BookingReference as null. Another possible value is 0 in there. + credentialId = null; + log.debug("Ensuring null credentialId for dailyvideo"); + } + return { + ...bookingReference, + credentialId, + }; +} + +export function getMockBookingAttendee(attendee: Omit) { + return { + id: attendee.id, + timeZone: attendee.timeZone, + name: attendee.name, + email: attendee.email, + locale: attendee.locale, + }; +} + export const enum BookingLocations { CalVideo = "integrations:daily", ZoomVideo = "integrations:zoom", diff --git a/apps/web/test/utils/bookingScenario/expects.ts b/apps/web/test/utils/bookingScenario/expects.ts index 3ad22136ca..18e2e92fc4 100644 --- a/apps/web/test/utils/bookingScenario/expects.ts +++ b/apps/web/test/utils/bookingScenario/expects.ts @@ -624,6 +624,31 @@ export function expectSuccessfulCalendarEventUpdationInCalendar( expect(externalId).toBe(expected.externalCalendarId); } +export function expectSuccessfulCalendarEventDeletionInCalendar( + calendarMock: { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + createEventCalls: any[]; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + updateEventCalls: any[]; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + deleteEventCalls: any[]; + }, + expected: { + externalCalendarId: string; + calEvent: Partial; + uid: string; + } +) { + expect(calendarMock.deleteEventCalls.length).toBe(1); + const call = calendarMock.deleteEventCalls[0]; + const uid = call[0]; + const calendarEvent = call[1]; + const externalId = call[2]; + expect(uid).toBe(expected.uid); + expect(calendarEvent).toEqual(expect.objectContaining(expected.calEvent)); + expect(externalId).toBe(expected.externalCalendarId); +} + export function expectSuccessfulVideoMeetingCreation( videoMock: { // eslint-disable-next-line @typescript-eslint/no-explicit-any @@ -669,6 +694,26 @@ export function expectSuccessfulVideoMeetingUpdationInCalendar( expect(calendarEvent).toEqual(expect.objectContaining(expected.calEvent)); } +export function expectSuccessfulVideoMeetingDeletionInCalendar( + videoMock: { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + createMeetingCalls: any[]; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + updateMeetingCalls: any[]; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + deleteMeetingCalls: any[]; + }, + expected: { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + bookingRef: any; + } +) { + expect(videoMock.deleteMeetingCalls.length).toBe(1); + const call = videoMock.deleteMeetingCalls[0]; + const bookingRefUid = call.args[0]; + expect(bookingRefUid).toEqual(expected.bookingRef.uid); +} + // eslint-disable-next-line @typescript-eslint/no-explicit-any export async function expectBookingInDBToBeRescheduledFromTo({ from, to }: { from: any; to: any }) { // Expect previous booking to be cancelled @@ -678,10 +723,9 @@ export async function expectBookingInDBToBeRescheduledFromTo({ from, to }: { fro status: BookingStatus.CANCELLED, }); - // Expect new booking to be created + // Expect new booking to be created but status would depend on whether the new booking requires confirmation or not. await expectBookingToBeInDatabase({ // eslint-disable-next-line @typescript-eslint/no-non-null-assertion ...to, - status: BookingStatus.ACCEPTED, }); } diff --git a/packages/core/CalendarManager.ts b/packages/core/CalendarManager.ts index 452ddd60d9..c6ce7eccb7 100644 --- a/packages/core/CalendarManager.ts +++ b/packages/core/CalendarManager.ts @@ -7,7 +7,7 @@ import getApps from "@calcom/app-store/utils"; import dayjs from "@calcom/dayjs"; import { getUid } from "@calcom/lib/CalEventParser"; import logger from "@calcom/lib/logger"; -import { getPiiFreeCalendarEvent } from "@calcom/lib/piiFreeData"; +import { getPiiFreeCalendarEvent, getPiiFreeCredential } from "@calcom/lib/piiFreeData"; import { safeStringify } from "@calcom/lib/safeStringify"; import { performance } from "@calcom/lib/server/perfObserver"; import type { @@ -366,14 +366,34 @@ export const updateEvent = async ( }; }; -export const deleteEvent = async ( - credential: CredentialPayload, - uid: string, - event: CalendarEvent -): Promise => { +export const deleteEvent = async ({ + credential, + bookingRefUid, + event, + externalCalendarId, +}: { + credential: CredentialPayload; + bookingRefUid: string; + event: CalendarEvent; + externalCalendarId?: string | null; +}): Promise => { const calendar = await getCalendar(credential); + log.debug( + "Deleting calendar event", + safeStringify({ + bookingRefUid, + event: getPiiFreeCalendarEvent(event), + }) + ); if (calendar) { - return calendar.deleteEvent(uid, event); + return calendar.deleteEvent(bookingRefUid, event, externalCalendarId); + } else { + log.warn( + "Could not do deleteEvent - No calendar adapter found", + safeStringify({ + credential: getPiiFreeCredential(credential), + }) + ); } return Promise.resolve({}); diff --git a/packages/core/EventManager.ts b/packages/core/EventManager.ts index 35be0141df..55f8abd6b0 100644 --- a/packages/core/EventManager.ts +++ b/packages/core/EventManager.ts @@ -10,7 +10,12 @@ import { appKeysSchema as calVideoKeysSchema } from "@calcom/app-store/dailyvide import { getEventLocationTypeFromApp, MeetLocationType } from "@calcom/app-store/locations"; import getApps from "@calcom/app-store/utils"; import logger from "@calcom/lib/logger"; -import { getPiiFreeDestinationCalendar, getPiiFreeUser, getPiiFreeCredential } from "@calcom/lib/piiFreeData"; +import { + getPiiFreeDestinationCalendar, + getPiiFreeUser, + getPiiFreeCredential, + getPiiFreeCalendarEvent, +} from "@calcom/lib/piiFreeData"; import { safeStringify } from "@calcom/lib/safeStringify"; import prisma from "@calcom/prisma"; import { credentialForCalendarServiceSelect } from "@calcom/prisma/selects/credential"; @@ -25,8 +30,8 @@ import type { PartialReference, } from "@calcom/types/EventManager"; -import { createEvent, updateEvent } from "./CalendarManager"; -import { createMeeting, updateMeeting } from "./videoClient"; +import { createEvent, updateEvent, deleteEvent } from "./CalendarManager"; +import { createMeeting, updateMeeting, deleteMeeting } from "./videoClient"; const log = logger.getChildLogger({ prefix: ["EventManager"] }); export const isDedicatedIntegration = (location: string): boolean => { @@ -91,7 +96,14 @@ export default class EventManager { // (type google_calendar) and non-traditional calendars such as CRMs like Close.com // (type closecom_other_calendar) this.calendarCredentials = appCredentials.filter((cred) => cred.type.endsWith("_calendar")); - this.videoCredentials = appCredentials.filter((cred) => cred.type.endsWith("_video")); + this.videoCredentials = appCredentials + .filter((cred) => cred.type.endsWith("_video")) + // Whenever a new video connection is added, latest credentials are added with the highest ID. + // Because you can't rely on having them in the highest first order here, ensure this by sorting in DESC order + // We also don't have updatedAt or createdAt dates on credentials so this is the best we can do + .sort((a, b) => { + return b.id - a.id; + }); } /** @@ -226,6 +238,82 @@ export default class EventManager { }; } + private async deleteCalendarEventForBookingReference({ + bookingCalendarReference, + event, + }: { + bookingCalendarReference: PartialReference; + event: CalendarEvent; + }) { + log.debug( + "deleteCalendarEventForBookingReference", + safeStringify({ bookingCalendarReference, event: getPiiFreeCalendarEvent(event) }) + ); + + const { + uid: bookingRefUid, + externalCalendarId: bookingExternalCalendarId, + credentialId, + type: credentialType, + } = bookingCalendarReference; + + const calendarCredential = await this.getCredentialAndWarnIfNotFound(credentialId, credentialType); + if (calendarCredential) { + await deleteEvent({ + credential: calendarCredential, + bookingRefUid, + event, + externalCalendarId: bookingExternalCalendarId, + }); + } + } + + private async deleteVideoEventForBookingReference({ + bookingVideoReference, + }: { + bookingVideoReference: PartialReference; + }) { + log.debug("deleteVideoEventForBookingReference", safeStringify({ bookingVideoReference })); + const { uid: bookingRefUid, credentialId } = bookingVideoReference; + + const videoCredential = await this.getCredentialAndWarnIfNotFound( + credentialId, + bookingVideoReference.type + ); + + if (videoCredential) { + await deleteMeeting(videoCredential, bookingRefUid); + } + } + + private async getCredentialAndWarnIfNotFound(credentialId: number | null | undefined, type: string) { + const credential = + typeof credentialId === "number" && credentialId > 0 + ? await prisma.credential.findUnique({ + where: { + id: credentialId, + }, + select: credentialForCalendarServiceSelect, + }) + : // Fallback for zero or nullish credentialId which could be the case of Global App e.g. dailyVideo + this.videoCredentials.find((cred) => cred.type === type) || + this.calendarCredentials.find((cred) => cred.type === type) || + null; + + if (!credential) { + log.error( + "getCredentialAndWarnIfNotFound: Could not find credential", + safeStringify({ + credentialId, + type, + videoCredentials: this.videoCredentials, + }) + ); + } + + return credential; + } + /** * Takes a calendarEvent and a rescheduleUid and updates the event that has the * given uid using the data delivered in the given CalendarEvent. @@ -281,24 +369,37 @@ export default class EventManager { throw new Error("booking not found"); } - const isDedicated = evt.location ? isDedicatedIntegration(evt.location) : null; const results: Array> = []; - // If and only if event type is a dedicated meeting, update the dedicated video meeting. - if (isDedicated) { - const result = await this.updateVideoEvent(evt, booking); - const [updatedEvent] = Array.isArray(result.updatedEvent) ? result.updatedEvent : [result.updatedEvent]; - if (updatedEvent) { - evt.videoCallData = updatedEvent; - evt.location = updatedEvent.url; + if (evt.requiresConfirmation) { + log.debug("RescheduleRequiresConfirmation: Deleting Event and Meeting for previous booking"); + // As the reschedule requires confirmation, we can't update the events and meetings to new time yet. So, just delete them and let it be handled when organizer confirms the booking. + await this.deleteEventsAndMeetings({ booking, event }); + } else { + // If the reschedule doesn't require confirmation, we can "update" the events and meetings to new time. + const isDedicated = evt.location ? isDedicatedIntegration(evt.location) : null; + // If and only if event type is a dedicated meeting, update the dedicated video meeting. + if (isDedicated) { + const result = await this.updateVideoEvent(evt, booking); + const [updatedEvent] = Array.isArray(result.updatedEvent) + ? result.updatedEvent + : [result.updatedEvent]; + + if (updatedEvent) { + evt.videoCallData = updatedEvent; + evt.location = updatedEvent.url; + } + results.push(result); } - results.push(result); - } - // There was a case that booking didn't had any reference and we don't want to throw error on function - if (booking.references.find((reference) => reference.type.includes("_calendar"))) { - // Update all calendar events. - results.push(...(await this.updateAllCalendarEvents(evt, booking, newBookingId))); + const bookingCalendarReference = booking.references.find((reference) => + reference.type.includes("_calendar") + ); + // There was a case that booking didn't had any reference and we don't want to throw error on function + if (bookingCalendarReference) { + // Update all calendar events. + results.push(...(await this.updateAllCalendarEvents(evt, booking, newBookingId))); + } } const bookingPayment = booking?.payment; @@ -317,12 +418,54 @@ export default class EventManager { }, }); } + return { results, referencesToCreate: [...booking.references], }; } + private async deleteEventsAndMeetings({ + event, + booking, + }: { + event: CalendarEvent; + booking: PartialBooking; + }) { + const calendarReferences = booking.references.filter((reference) => reference.type.includes("_calendar")); + const videoReferences = booking.references.filter((reference) => reference.type.includes("_video")); + log.debug("deleteEventsAndMeetings", safeStringify({ calendarReferences, videoReferences })); + const calendarPromises = calendarReferences.map(async (bookingCalendarReference) => { + return await this.deleteCalendarEventForBookingReference({ + bookingCalendarReference, + event, + }); + }); + + const videoPromises = videoReferences.map(async (bookingVideoReference) => { + return await this.deleteVideoEventForBookingReference({ + bookingVideoReference, + }); + }); + + const allPromises = [...calendarPromises, ...videoPromises]; + + // Using allSettled to ensure that if one of the promises rejects, the others will still be executed. + // Because we are just cleaning up the events and meetings, we don't want to throw an error if one of them fails. + (await Promise.allSettled(allPromises)).some((result) => { + if (result.status === "rejected") { + log.error( + "Error deleting calendar event or video meeting for booking", + safeStringify({ error: result.reason }) + ); + } + }); + + if (!allPromises.length) { + log.warn("No calendar or video references found for booking - Couldn't delete events or meetings"); + } + } + public async updateCalendarAttendees(event: CalendarEvent, booking: PartialBooking) { if (booking.references.length === 0) { console.error("Tried to update references but there wasn't any."); @@ -466,13 +609,9 @@ export default class EventManager { (credential) => credential.id === event.conferenceCredentialId ); } else { - videoCredential = this.videoCredentials - // Whenever a new video connection is added, latest credentials are added with the highest ID. - // Because you can't rely on having them in the highest first order here, ensure this by sorting in DESC order - .sort((a, b) => { - return b.id - a.id; - }) - .find((credential: CredentialPayload) => credential.type.includes(integrationName)); + videoCredential = this.videoCredentials.find((credential: CredentialPayload) => + credential.type.includes(integrationName) + ); log.warn( `Could not find conferenceCredentialId for event with location: ${event.location}, trying to use last added video credential` ); diff --git a/packages/features/bookings/lib/handleNewBooking/test/reschedule.test.ts b/packages/features/bookings/lib/handleNewBooking/test/reschedule.test.ts index 9a739b0385..015fdb0309 100644 --- a/packages/features/bookings/lib/handleNewBooking/test/reschedule.test.ts +++ b/packages/features/bookings/lib/handleNewBooking/test/reschedule.test.ts @@ -19,6 +19,8 @@ import { mockCalendarToHaveNoBusySlots, mockCalendarToCrashOnUpdateEvent, BookingLocations, + getMockBookingReference, + getMockBookingAttendee, } from "@calcom/web/test/utils/bookingScenario/bookingScenario"; import { expectWorkflowToBeTriggered, @@ -28,6 +30,10 @@ import { expectSuccessfulCalendarEventUpdationInCalendar, expectSuccessfulVideoMeetingUpdationInCalendar, expectBookingInDBToBeRescheduledFromTo, + expectBookingRequestedEmails, + expectBookingRequestedWebhookToHaveBeenFired, + expectSuccessfulCalendarEventDeletionInCalendar, + expectSuccessfulVideoMeetingDeletionInCalendar, } from "@calcom/web/test/utils/bookingScenario/expects"; import { createMockNextJsRequest } from "./lib/createMockNextJsRequest"; @@ -104,6 +110,7 @@ describe("handleNewBooking", () => { meetingId: "MOCK_ID", meetingPassword: "MOCK_PASS", meetingUrl: "http://mock-dailyvideo.example.com", + credentialId: null, }, { type: appStoreMetadata.googlecalendar.type, @@ -248,6 +255,7 @@ describe("handleNewBooking", () => { emails, iCalUID: "MOCKED_GOOGLE_CALENDAR_ICS_ID", }); + expectBookingRescheduledWebhookToHaveBeenFired({ booker, organizer, @@ -258,6 +266,7 @@ describe("handleNewBooking", () => { }, timeout ); + test( `should rechedule a booking successfully and update the event in the same externalCalendarId as was used in the booking earlier. 1. Should cancel the existing booking @@ -604,5 +613,700 @@ describe("handleNewBooking", () => { }, timeout ); + + describe("Event Type that requires confirmation", () => { + test( + `should reschedule a booking that requires confirmation in PENDING state - When a booker(who is not the organizer himself) is doing the schedule + 1. Should cancel the existing booking + 2. Should delete existing calendar invite and Video meeting + 2. Should create a new booking in the database in PENDING state + 3. Should send BOOKING Requested scenario emails to the booker as well as organizer + 4. Should trigger BOOKING_REQUESTED webhook instead of BOOKING_RESCHEDULED + `, + async ({ emails }) => { + const handleNewBooking = (await import("@calcom/features/bookings/lib/handleNewBooking")).default; + const subscriberUrl = "http://my-webhook.example.com"; + const booker = getBooker({ + email: "booker@example.com", + name: "Booker", + }); + + const organizer = getOrganizer({ + name: "Organizer", + email: "organizer@example.com", + id: 101, + schedules: [TestData.schedules.IstWorkHours], + credentials: [getGoogleCalendarCredential()], + selectedCalendars: [TestData.selectedCalendars.google], + }); + const { dateString: plus1DateString } = getDate({ dateIncrement: 1 }); + const uidOfBookingToBeRescheduled = "n5Wv3eHgconAED2j4gcVhP"; + + const scenarioData = getScenarioData({ + webhooks: [ + { + userId: organizer.id, + eventTriggers: ["BOOKING_CREATED"], + subscriberUrl, + active: true, + eventTypeId: 1, + appId: null, + }, + ], + eventTypes: [ + { + id: 1, + slotInterval: 45, + requiresConfirmation: true, + length: 45, + users: [ + { + id: 101, + }, + ], + }, + ], + bookings: [ + { + uid: uidOfBookingToBeRescheduled, + eventTypeId: 1, + status: BookingStatus.ACCEPTED, + startTime: `${plus1DateString}T05:00:00.000Z`, + endTime: `${plus1DateString}T05:15:00.000Z`, + references: [ + getMockBookingReference({ + type: appStoreMetadata.dailyvideo.type, + uid: "MOCK_ID", + meetingId: "MOCK_ID", + meetingPassword: "MOCK_PASS", + meetingUrl: "http://mock-dailyvideo.example.com", + credentialId: 0, + }), + getMockBookingReference({ + type: appStoreMetadata.googlecalendar.type, + uid: "MOCK_ID", + meetingId: "MOCK_ID", + meetingPassword: "MOCK_PASSWORD", + meetingUrl: "https://UNUSED_URL", + externalCalendarId: "MOCK_EXTERNAL_CALENDAR_ID", + credentialId: 1, + }), + ], + }, + ], + organizer, + apps: [TestData.apps["google-calendar"], TestData.apps["daily-video"]], + }); + await createBookingScenario(scenarioData); + + const videoMock = mockSuccessfulVideoMeetingCreation({ + metadataLookupKey: "dailyvideo", + }); + + const calendarMock = mockCalendarToHaveNoBusySlots("googlecalendar", { + create: { + uid: "MOCK_ID", + }, + update: { + uid: "UPDATED_MOCK_ID", + iCalUID: "MOCKED_GOOGLE_CALENDAR_ICS_ID", + }, + }); + + const mockBookingData = getMockRequestDataForBooking({ + data: { + eventTypeId: 1, + rescheduleUid: uidOfBookingToBeRescheduled, + start: `${plus1DateString}T04:00:00.000Z`, + end: `${plus1DateString}T04:15:00.000Z`, + responses: { + email: booker.email, + name: booker.name, + location: { optionValue: "", value: BookingLocations.CalVideo }, + }, + }, + }); + + const { req } = createMockNextJsRequest({ + method: "POST", + body: mockBookingData, + }); + + const createdBooking = await handleNewBooking(req); + expect(createdBooking.responses).toContain({ + email: booker.email, + name: booker.name, + }); + + await expectBookingInDBToBeRescheduledFromTo({ + from: { + uid: uidOfBookingToBeRescheduled, + }, + to: { + description: "", + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + uid: createdBooking.uid!, + eventTypeId: mockBookingData.eventTypeId, + // Rescheduled booking sill stays in pending state + status: BookingStatus.PENDING, + location: BookingLocations.CalVideo, + responses: expect.objectContaining({ + email: booker.email, + name: booker.name, + }), + references: [ + { + type: appStoreMetadata.dailyvideo.type, + uid: "MOCK_ID", + meetingId: "MOCK_ID", + meetingPassword: "MOCK_PASS", + meetingUrl: "http://mock-dailyvideo.example.com", + }, + { + type: appStoreMetadata.googlecalendar.type, + uid: "MOCK_ID", + meetingId: "MOCK_ID", + meetingPassword: "MOCK_PASSWORD", + meetingUrl: "https://UNUSED_URL", + externalCalendarId: "MOCK_EXTERNAL_CALENDAR_ID", + }, + ], + }, + }); + + expectWorkflowToBeTriggered(); + + expectBookingRequestedEmails({ + booker, + organizer, + emails, + }); + + expectBookingRequestedWebhookToHaveBeenFired({ + booker, + organizer, + location: BookingLocations.CalVideo, + subscriberUrl, + eventType: scenarioData.eventTypes[0], + }); + + expectSuccessfulVideoMeetingDeletionInCalendar(videoMock, { + bookingRef: { + type: appStoreMetadata.dailyvideo.type, + uid: "MOCK_ID", + meetingId: "MOCK_ID", + meetingPassword: "MOCK_PASS", + meetingUrl: "http://mock-dailyvideo.example.com", + }, + }); + + expectSuccessfulCalendarEventDeletionInCalendar(calendarMock, { + externalCalendarId: "MOCK_EXTERNAL_CALENDAR_ID", + calEvent: { + videoCallData: expect.objectContaining({ + url: "http://mock-dailyvideo.example.com", + }), + }, + uid: "MOCK_ID", + }); + }, + timeout + ); + + // eslint-disable-next-line playwright/no-skipped-test + test.skip( + `should rechedule a booking, that requires confirmation, without confirmation - When Organizer is doing the reschedule + 1. Should cancel the existing booking + 2. Should delete existing calendar invite and Video meeting + 2. Should create a new booking in the database in ACCEPTED state + 3. Should send rescheduled emails to the booker as well as organizer + 4. Should trigger BOOKING_RESCHEDULED webhook + `, + async ({ emails }) => { + const handleNewBooking = (await import("@calcom/features/bookings/lib/handleNewBooking")).default; + const booker = getBooker({ + email: "booker@example.com", + name: "Booker", + }); + + const organizer = getOrganizer({ + name: "Organizer", + email: "organizer@example.com", + id: 101, + schedules: [TestData.schedules.IstWorkHours], + credentials: [getGoogleCalendarCredential()], + selectedCalendars: [TestData.selectedCalendars.google], + }); + + const { dateString: plus1DateString } = getDate({ dateIncrement: 1 }); + const uidOfBookingToBeRescheduled = "n5Wv3eHgconAED2j4gcVhP"; + await createBookingScenario( + getScenarioData({ + webhooks: [ + { + userId: organizer.id, + eventTriggers: ["BOOKING_CREATED"], + subscriberUrl: "http://my-webhook.example.com", + active: true, + eventTypeId: 1, + appId: null, + }, + ], + eventTypes: [ + { + id: 1, + requiresConfirmation: true, + slotInterval: 45, + length: 45, + users: [ + { + id: 101, + }, + ], + destinationCalendar: { + integration: "google_calendar", + externalId: "event-type-1@example.com", + }, + }, + ], + bookings: [ + { + uid: uidOfBookingToBeRescheduled, + eventTypeId: 1, + status: BookingStatus.ACCEPTED, + startTime: `${plus1DateString}T05:00:00.000Z`, + endTime: `${plus1DateString}T05:15:00.000Z`, + references: [ + { + type: appStoreMetadata.dailyvideo.type, + uid: "MOCK_ID", + meetingId: "MOCK_ID", + meetingPassword: "MOCK_PASS", + meetingUrl: "http://mock-dailyvideo.example.com", + }, + { + type: appStoreMetadata.googlecalendar.type, + uid: "MOCK_ID", + meetingId: "MOCK_ID", + meetingPassword: "MOCK_PASSWORD", + meetingUrl: "https://UNUSED_URL", + externalCalendarId: "existing-event-type@example.com", + credentialId: undefined, + }, + ], + attendees: [ + getMockBookingAttendee({ + id: 1, + name: organizer.name, + email: organizer.email, + locale: "en", + timeZone: "Europe/London", + }), + getMockBookingAttendee({ + id: 2, + name: booker.name, + email: booker.email, + // Booker's locale when the fresh booking happened earlier + locale: "hi", + // Booker's timezone when the fresh booking happened earlier + timeZone: "Asia/Kolkata", + }), + ], + }, + ], + organizer, + apps: [TestData.apps["google-calendar"], TestData.apps["daily-video"]], + }) + ); + + const videoMock = mockSuccessfulVideoMeetingCreation({ + metadataLookupKey: "dailyvideo", + }); + + const calendarMock = mockCalendarToHaveNoBusySlots("googlecalendar", { + create: { + uid: "MOCK_ID", + }, + update: { + iCalUID: "MOCKED_GOOGLE_CALENDAR_ICS_ID", + uid: "UPDATED_MOCK_ID", + }, + }); + + const mockBookingData = getMockRequestDataForBooking({ + data: { + eventTypeId: 1, + rescheduleUid: uidOfBookingToBeRescheduled, + start: `${plus1DateString}T04:00:00.000Z`, + end: `${plus1DateString}T04:15:00.000Z`, + // Organizer is doing the rescheduling from his timezone which is different from Booker Timezone as per the booking being rescheduled + timeZone: "Europe/London", + responses: { + email: booker.email, + name: booker.name, + location: { optionValue: "", value: BookingLocations.CalVideo }, + }, + }, + }); + + const { req } = createMockNextJsRequest({ + method: "POST", + body: mockBookingData, + }); + + // Fake the request to be from organizer + req.userId = organizer.id; + + const createdBooking = await handleNewBooking(req); + + /** + * Booking Time should be new time + */ + expect(createdBooking.startTime?.toISOString()).toBe(`${plus1DateString}T04:00:00.000Z`); + expect(createdBooking.endTime?.toISOString()).toBe(`${plus1DateString}T04:15:00.000Z`); + + await expectBookingInDBToBeRescheduledFromTo({ + from: { + uid: uidOfBookingToBeRescheduled, + }, + to: { + description: "", + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + uid: createdBooking.uid!, + eventTypeId: mockBookingData.eventTypeId, + status: BookingStatus.ACCEPTED, + location: BookingLocations.CalVideo, + responses: expect.objectContaining({ + email: booker.email, + name: booker.name, + }), + references: [ + { + type: appStoreMetadata.dailyvideo.type, + uid: "MOCK_ID", + meetingId: "MOCK_ID", + meetingPassword: "MOCK_PASS", + meetingUrl: "http://mock-dailyvideo.example.com", + }, + { + type: appStoreMetadata.googlecalendar.type, + uid: "MOCK_ID", + meetingId: "MOCK_ID", + meetingPassword: "MOCK_PASSWORD", + meetingUrl: "https://UNUSED_URL", + externalCalendarId: "existing-event-type@example.com", + }, + ], + }, + }); + + expectWorkflowToBeTriggered(); + + expectSuccessfulVideoMeetingUpdationInCalendar(videoMock, { + calEvent: { + location: "http://mock-dailyvideo.example.com", + }, + bookingRef: { + type: appStoreMetadata.dailyvideo.type, + uid: "MOCK_ID", + meetingId: "MOCK_ID", + meetingPassword: "MOCK_PASS", + meetingUrl: "http://mock-dailyvideo.example.com", + }, + }); + + // updateEvent uses existing booking's externalCalendarId to update the event in calendar. + // and not the event-type's organizer's which is event-type-1@example.com + expectSuccessfulCalendarEventUpdationInCalendar(calendarMock, { + externalCalendarId: "existing-event-type@example.com", + calEvent: { + location: "http://mock-dailyvideo.example.com", + attendees: expect.arrayContaining([ + expect.objectContaining({ + email: booker.email, + name: booker.name, + // Expect that the booker timezone is his earlier timezone(from original booking), even though the rescheduling is done by organizer from his timezone + timeZone: "Asia/Kolkata", + language: expect.objectContaining({ + // Expect that the booker locale is his earlier locale(from original booking), even though the rescheduling is done by organizer with his locale + locale: "hi", + }), + }), + ]), + }, + uid: "MOCK_ID", + }); + + expectSuccessfulBookingRescheduledEmails({ + booker, + organizer, + emails, + iCalUID: "MOCKED_GOOGLE_CALENDAR_ICS_ID", + }); + expectBookingRescheduledWebhookToHaveBeenFired({ + booker, + organizer, + location: BookingLocations.CalVideo, + subscriberUrl: "http://my-webhook.example.com", + videoCallUrl: `${WEBAPP_URL}/video/DYNAMIC_UID`, + }); + }, + timeout + ); + + // eslint-disable-next-line playwright/no-skipped-test + test.skip( + `should rechedule a booking, that requires confirmation, without confirmation - When the owner of the previous booking is doing the reschedule + 1. Should cancel the existing booking + 2. Should delete existing calendar invite and Video meeting + 2. Should create a new booking in the database in ACCEPTED state + 3. Should send rescheduled emails to the booker as well as organizer + 4. Should trigger BOOKING_RESCHEDULED webhook + `, + async ({ emails }) => { + const handleNewBooking = (await import("@calcom/features/bookings/lib/handleNewBooking")).default; + const booker = getBooker({ + email: "booker@example.com", + name: "Booker", + }); + + const organizer = getOrganizer({ + name: "Organizer", + email: "organizer@example.com", + id: 101, + schedules: [TestData.schedules.IstWorkHours], + credentials: [getGoogleCalendarCredential()], + selectedCalendars: [TestData.selectedCalendars.google], + }); + + const { dateString: plus1DateString } = getDate({ dateIncrement: 1 }); + const uidOfBookingToBeRescheduled = "n5Wv3eHgconAED2j4gcVhP"; + const previousOrganizerIdForTheBooking = 1001; + await createBookingScenario( + getScenarioData({ + webhooks: [ + { + userId: organizer.id, + eventTriggers: ["BOOKING_CREATED"], + subscriberUrl: "http://my-webhook.example.com", + active: true, + eventTypeId: 1, + appId: null, + }, + ], + eventTypes: [ + { + id: 1, + requiresConfirmation: true, + slotInterval: 45, + length: 45, + users: [ + { + id: 101, + }, + ], + destinationCalendar: { + integration: "google_calendar", + externalId: "event-type-1@example.com", + }, + }, + ], + bookings: [ + { + uid: uidOfBookingToBeRescheduled, + eventTypeId: 1, + // Make sure that the earlier booking owner is some user with ID 10001 + userId: previousOrganizerIdForTheBooking, + status: BookingStatus.ACCEPTED, + startTime: `${plus1DateString}T05:00:00.000Z`, + endTime: `${plus1DateString}T05:15:00.000Z`, + references: [ + { + type: appStoreMetadata.dailyvideo.type, + uid: "MOCK_ID", + meetingId: "MOCK_ID", + meetingPassword: "MOCK_PASS", + meetingUrl: "http://mock-dailyvideo.example.com", + }, + { + type: appStoreMetadata.googlecalendar.type, + uid: "MOCK_ID", + meetingId: "MOCK_ID", + meetingPassword: "MOCK_PASSWORD", + meetingUrl: "https://UNUSED_URL", + externalCalendarId: "existing-event-type@example.com", + credentialId: undefined, + }, + ], + attendees: [ + getMockBookingAttendee({ + id: 1, + name: organizer.name, + email: organizer.email, + locale: "en", + timeZone: "Europe/London", + }), + getMockBookingAttendee({ + id: 2, + name: booker.name, + email: booker.email, + // Booker's locale when the fresh booking happened earlier + locale: "hi", + // Booker's timezone when the fresh booking happened earlier + timeZone: "Asia/Kolkata", + }), + ], + }, + ], + organizer, + usersApartFromOrganizer: [ + { + id: previousOrganizerIdForTheBooking, + name: "Previous Organizer", + username: "prev-organizer", + email: "", + schedules: [TestData.schedules.IstWorkHours], + timeZone: "Europe/London", + }, + ], + apps: [TestData.apps["google-calendar"], TestData.apps["daily-video"]], + }) + ); + + const videoMock = mockSuccessfulVideoMeetingCreation({ + metadataLookupKey: "dailyvideo", + }); + + const calendarMock = mockCalendarToHaveNoBusySlots("googlecalendar", { + create: { + uid: "MOCK_ID", + }, + update: { + iCalUID: "MOCKED_GOOGLE_CALENDAR_ICS_ID", + uid: "UPDATED_MOCK_ID", + }, + }); + + const mockBookingData = getMockRequestDataForBooking({ + data: { + eventTypeId: 1, + rescheduleUid: uidOfBookingToBeRescheduled, + start: `${plus1DateString}T04:00:00.000Z`, + end: `${plus1DateString}T04:15:00.000Z`, + // Organizer is doing the rescheduling from his timezone which is different from Booker Timezone as per the booking being rescheduled + timeZone: "Europe/London", + responses: { + email: booker.email, + name: booker.name, + location: { optionValue: "", value: BookingLocations.CalVideo }, + }, + }, + }); + + const { req } = createMockNextJsRequest({ + method: "POST", + body: mockBookingData, + }); + + // Fake the request to be from organizer + req.userId = previousOrganizerIdForTheBooking; + + const createdBooking = await handleNewBooking(req); + + /** + * Booking Time should be new time + */ + expect(createdBooking.startTime?.toISOString()).toBe(`${plus1DateString}T04:00:00.000Z`); + expect(createdBooking.endTime?.toISOString()).toBe(`${plus1DateString}T04:15:00.000Z`); + + await expectBookingInDBToBeRescheduledFromTo({ + from: { + uid: uidOfBookingToBeRescheduled, + }, + to: { + description: "", + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + uid: createdBooking.uid!, + eventTypeId: mockBookingData.eventTypeId, + status: BookingStatus.ACCEPTED, + location: BookingLocations.CalVideo, + responses: expect.objectContaining({ + email: booker.email, + name: booker.name, + }), + references: [ + { + type: appStoreMetadata.dailyvideo.type, + uid: "MOCK_ID", + meetingId: "MOCK_ID", + meetingPassword: "MOCK_PASS", + meetingUrl: "http://mock-dailyvideo.example.com", + }, + { + type: appStoreMetadata.googlecalendar.type, + uid: "MOCK_ID", + meetingId: "MOCK_ID", + meetingPassword: "MOCK_PASSWORD", + meetingUrl: "https://UNUSED_URL", + externalCalendarId: "existing-event-type@example.com", + }, + ], + }, + }); + + expectWorkflowToBeTriggered(); + + expectSuccessfulVideoMeetingUpdationInCalendar(videoMock, { + calEvent: { + location: "http://mock-dailyvideo.example.com", + }, + bookingRef: { + type: appStoreMetadata.dailyvideo.type, + uid: "MOCK_ID", + meetingId: "MOCK_ID", + meetingPassword: "MOCK_PASS", + meetingUrl: "http://mock-dailyvideo.example.com", + }, + }); + + // updateEvent uses existing booking's externalCalendarId to update the event in calendar. + // and not the event-type's organizer's which is event-type-1@example.com + expectSuccessfulCalendarEventUpdationInCalendar(calendarMock, { + externalCalendarId: "existing-event-type@example.com", + calEvent: { + location: "http://mock-dailyvideo.example.com", + attendees: expect.arrayContaining([ + expect.objectContaining({ + email: booker.email, + name: booker.name, + // Expect that the booker timezone is his earlier timezone(from original booking), even though the rescheduling is done by organizer from his timezone + timeZone: "Asia/Kolkata", + language: expect.objectContaining({ + // Expect that the booker locale is his earlier locale(from original booking), even though the rescheduling is done by organizer with his locale + locale: "hi", + }), + }), + ]), + }, + uid: "MOCK_ID", + }); + + expectSuccessfulBookingRescheduledEmails({ + booker, + organizer, + emails, + iCalUID: "MOCKED_GOOGLE_CALENDAR_ICS_ID", + }); + expectBookingRescheduledWebhookToHaveBeenFired({ + booker, + organizer, + location: BookingLocations.CalVideo, + subscriberUrl: "http://my-webhook.example.com", + videoCallUrl: `${WEBAPP_URL}/video/DYNAMIC_UID`, + }); + }, + timeout + ); + }); }); });