diff --git a/apps/web/test/utils/bookingScenario/expects.ts b/apps/web/test/utils/bookingScenario/expects.ts index 6f234e844e..5db6e68c4c 100644 --- a/apps/web/test/utils/bookingScenario/expects.ts +++ b/apps/web/test/utils/bookingScenario/expects.ts @@ -547,6 +547,45 @@ export function expectCalendarEventCreationFailureEmails({ ); } +export function expectSuccessfulRoudRobinReschedulingEmails({ + emails, + newOrganizer, + prevOrganizer, +}: { + emails: Fixtures["emails"]; + newOrganizer: { email: string; name: string }; + prevOrganizer: { email: string; name: string }; +}) { + if (newOrganizer !== prevOrganizer) { + // new organizer should recieve scheduling emails + expect(emails).toHaveEmail( + { + heading: "new_event_scheduled", + to: `${newOrganizer.email}`, + }, + `${newOrganizer.email}` + ); + + // old organizer should recieve cancelled emails + expect(emails).toHaveEmail( + { + heading: "event_request_cancelled", + to: `${prevOrganizer.email}`, + }, + `${prevOrganizer.email}` + ); + } else { + // organizer should recieve rescheduled emails + expect(emails).toHaveEmail( + { + heading: "event_has_been_rescheduled", + to: `${newOrganizer.email}`, + }, + `${newOrganizer.email}` + ); + } +} + export function expectSuccessfulBookingRescheduledEmails({ emails, organizer, diff --git a/packages/core/EventManager.ts b/packages/core/EventManager.ts index 6c41d76ca2..cfc679e4b1 100644 --- a/packages/core/EventManager.ts +++ b/packages/core/EventManager.ts @@ -323,7 +323,8 @@ export default class EventManager { public async reschedule( event: CalendarEvent, rescheduleUid: string, - newBookingId?: number + newBookingId?: number, + changedOrganizer?: boolean ): Promise { const originalEvt = processLocation(event); const evt = cloneDeep(originalEvt); @@ -376,32 +377,37 @@ export default class EventManager { // 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 (changedOrganizer) { + log.debug("RescheduleOrganizerChanged: Deleting Event and Meeting for previous booking"); + await this.deleteEventsAndMeetings({ booking, event }); + // New event is created in handleNewBooking + } 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; + if (updatedEvent) { + evt.videoCallData = updatedEvent; + evt.location = updatedEvent.url; + } + results.push(result); } - results.push(result); - } - 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 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; // Updating all payment to new diff --git a/packages/emails/email-manager.ts b/packages/emails/email-manager.ts index 98f6ed7b39..8774278fa1 100644 --- a/packages/emails/email-manager.ts +++ b/packages/emails/email-manager.ts @@ -102,6 +102,37 @@ export const sendScheduledEmails = async ( await Promise.all(emailsToSend); }; +// for rescheduled round robin booking that assigned new members +export const sendRoundRobinScheduledEmails = async (calEvent: CalendarEvent, members: Person[]) => { + const emailsToSend: Promise[] = []; + + for (const teamMember of members) { + emailsToSend.push(sendEmail(() => new OrganizerScheduledEmail({ calEvent, teamMember }))); + } + + await Promise.all(emailsToSend); +}; + +export const sendRoundRobinRescheduledEmails = async (calEvent: CalendarEvent, members: Person[]) => { + const emailsToSend: Promise[] = []; + + for (const teamMember of members) { + emailsToSend.push(sendEmail(() => new OrganizerRescheduledEmail({ calEvent, teamMember }))); + } + + await Promise.all(emailsToSend); +}; + +export const sendRoundRobinCancelledEmails = async (calEvent: CalendarEvent, members: Person[]) => { + const emailsToSend: Promise[] = []; + + for (const teamMember of members) { + emailsToSend.push(sendEmail(() => new OrganizerCancelledEmail({ calEvent, teamMember }))); + } + + await Promise.all(emailsToSend); +}; + export const sendRescheduledEmails = async (calEvent: CalendarEvent) => { const emailsToSend: Promise[] = []; diff --git a/packages/features/bookings/lib/handleNewBooking.ts b/packages/features/bookings/lib/handleNewBooking.ts index dd00afc82c..af2f70ee70 100644 --- a/packages/features/bookings/lib/handleNewBooking.ts +++ b/packages/features/bookings/lib/handleNewBooking.ts @@ -30,6 +30,9 @@ import { sendOrganizerRequestEmail, sendRescheduledEmails, sendRescheduledSeatEmail, + sendRoundRobinCancelledEmails, + sendRoundRobinRescheduledEmails, + sendRoundRobinScheduledEmails, sendScheduledEmails, sendScheduledSeatsEmails, } from "@calcom/emails"; @@ -467,8 +470,26 @@ async function getOriginalRescheduledBooking(uid: string, seatsEventType?: boole email: true, locale: true, timeZone: true, + destinationCalendar: true, + credentials: { + select: { + id: true, + userId: true, + key: true, + type: true, + teamId: true, + appId: true, + invalid: true, + user: { + select: { + email: true, + }, + }, + }, + }, }, }, + destinationCalendar: true, payment: true, references: true, workflowReminders: true, @@ -663,8 +684,6 @@ async function handler( }; const { recurringCount, - allRecurringDates, - currentRecurringIndex, noEmail, eventTypeId, eventTypeSlug, @@ -900,6 +919,7 @@ async function handler( let originalRescheduledBooking: BookingType = null; + //this gets the orginal rescheduled booking if (rescheduleUid) { // rescheduleUid can be bookingUid and bookingSeatUid bookingSeat = await prisma.bookingSeat.findUnique({ @@ -923,6 +943,7 @@ async function handler( } } + //checks what users are available if (!eventType.seatsPerTimeSlot && !skipAvailabilityCheck) { const availableUsers = await ensureAvailableUsers( { @@ -1897,11 +1918,16 @@ async function handler( evt.recurringEvent = eventType.recurringEvent; } + const changedOrganizer = + !!originalRescheduledBooking && + eventType.schedulingType === SchedulingType.ROUND_ROBIN && + originalRescheduledBooking.userId !== evt.organizer.id; + async function createBooking() { if (originalRescheduledBooking) { evt.title = originalRescheduledBooking?.title || evt.title; evt.description = originalRescheduledBooking?.description || evt.description; - evt.location = originalRescheduledBooking?.location || evt.location; + evt.location = changedOrganizer ? evt.location : originalRescheduledBooking?.location || evt.location; } const eventTypeRel = !eventTypeId @@ -2146,6 +2172,7 @@ async function handler( let videoCallUrl; + //this is the actual rescheduling logic if (originalRescheduledBooking?.uid) { log.silly("Rescheduling booking", originalRescheduledBooking.uid); try { @@ -2158,9 +2185,7 @@ async function handler( ); } - // Use EventManager to conditionally use all needed integrations. addVideoCallDataToEvt(originalRescheduledBooking.references); - const updateManager = await eventManager.reschedule(evt, originalRescheduledBooking.uid); //update original rescheduled booking (no seats event) if (!eventType.seatsPerTimeSlot) { @@ -2175,6 +2200,21 @@ async function handler( }); } + const newDesinationCalendar = evt.destinationCalendar; + + evt.destinationCalendar = originalRescheduledBooking?.destinationCalendar + ? [originalRescheduledBooking?.destinationCalendar] + : originalRescheduledBooking?.user?.destinationCalendar + ? [originalRescheduledBooking?.user.destinationCalendar] + : evt.destinationCalendar; + + const updateManager = await eventManager.reschedule( + evt, + originalRescheduledBooking.uid, + undefined, + changedOrganizer + ); + // This gets overridden when updating the event - to check if notes have been hidden or not. We just reset this back // to the default description when we are sending the emails. evt.description = eventType.description; @@ -2200,23 +2240,179 @@ async function handler( : calendarResult?.updatedEvent?.iCalUID || undefined; } - const { metadata, videoCallUrl: _videoCallUrl } = getVideoCallDetails({ + const { metadata: videoMetadata, videoCallUrl: _videoCallUrl } = getVideoCallDetails({ results, }); + let metadata: AdditionalInformation = {}; + metadata = videoMetadata; videoCallUrl = _videoCallUrl; + + //if organizer changed we need to create a new booking (reschedule only cancels the old one) + if (changedOrganizer) { + evt.destinationCalendar = newDesinationCalendar; + evt.title = getEventName(eventNameObject); + + // location might changed and will be new created in eventManager.create (organizer default location) + evt.videoCallData = undefined; + const createManager = await eventManager.create(evt); + + // This gets overridden when creating the event - to check if notes have been hidden or not. We just reset this back + // to the default description when we are sending the emails. + evt.description = eventType.description; + + results = createManager.results; + referencesToCreate = createManager.referencesToCreate; + videoCallUrl = evt.videoCallData && evt.videoCallData.url ? evt.videoCallData.url : null; + + if (results.length > 0 && results.every((res) => !res.success)) { + const error = { + errorCode: "BookingCreatingMeetingFailed", + message: "Booking rescheduling failed", + }; + + loggerWithEventDetails.error( + `EventManager.create failure in some of the integrations ${organizerUser.username}`, + safeStringify({ error, results }) + ); + } else { + if (results.length) { + // Handle Google Meet results + // We use the original booking location since the evt location changes to daily + if (bookingLocation === MeetLocationType) { + const googleMeetResult = { + appName: GoogleMeetMetadata.name, + type: "conferencing", + uid: results[0].uid, + originalEvent: results[0].originalEvent, + }; + + // Find index of google_calendar inside createManager.referencesToCreate + const googleCalIndex = createManager.referencesToCreate.findIndex( + (ref) => ref.type === "google_calendar" + ); + const googleCalResult = results[googleCalIndex]; + + if (!googleCalResult) { + loggerWithEventDetails.warn("Google Calendar not installed but using Google Meet as location"); + results.push({ + ...googleMeetResult, + success: false, + calWarnings: [tOrganizer("google_meet_warning")], + }); + } + + if (googleCalResult?.createdEvent?.hangoutLink) { + results.push({ + ...googleMeetResult, + success: true, + }); + + // Add google_meet to referencesToCreate in the same index as google_calendar + createManager.referencesToCreate[googleCalIndex] = { + ...createManager.referencesToCreate[googleCalIndex], + meetingUrl: googleCalResult.createdEvent.hangoutLink, + }; + + // Also create a new referenceToCreate with type video for google_meet + createManager.referencesToCreate.push({ + type: "google_meet_video", + meetingUrl: googleCalResult.createdEvent.hangoutLink, + uid: googleCalResult.uid, + credentialId: createManager.referencesToCreate[googleCalIndex].credentialId, + }); + } else if (googleCalResult && !googleCalResult.createdEvent?.hangoutLink) { + results.push({ + ...googleMeetResult, + success: false, + }); + } + } + + metadata.hangoutLink = results[0].createdEvent?.hangoutLink; + metadata.conferenceData = results[0].createdEvent?.conferenceData; + metadata.entryPoints = results[0].createdEvent?.entryPoints; + evt.appsStatus = handleAppsStatus(results, booking); + videoCallUrl = + metadata.hangoutLink || + results[0].createdEvent?.url || + organizerOrFirstDynamicGroupMemberDefaultLocationUrl || + videoCallUrl; + } + } + } + evt.appsStatus = handleAppsStatus(results, booking); // If there is an integration error, we don't send successful rescheduling email, instead broken integration email should be sent that are handled by either CalendarManager or videoClient if (noEmail !== true && isConfirmedByDefault && !isThereAnIntegrationError) { const copyEvent = cloneDeep(evt); - loggerWithEventDetails.debug("Emails: Sending rescheduled emails for booking confirmation"); - await sendRescheduledEmails({ + const copyEventAdditionalInfo = { ...copyEvent, additionalInformation: metadata, additionalNotes, // Resets back to the additionalNote input and not the override value cancellationReason: `$RCH$${rescheduleReason ? rescheduleReason : ""}`, // Removable code prefix to differentiate cancellation from rescheduling for email - }); + }; + loggerWithEventDetails.debug("Emails: Sending rescheduled emails for booking confirmation"); + + /* + handle emails for round robin + - if booked rr host is the same, then rescheduling email + - if new rr host is booked, then cancellation email to old host and confirmation email to new host + */ + if (eventType.schedulingType === SchedulingType.ROUND_ROBIN) { + const originalBookingMemberEmails: Person[] = []; + + for (const user of originalRescheduledBooking.attendees) { + const translate = await getTranslation(user.locale ?? "en", "common"); + originalBookingMemberEmails.push({ + name: user.name, + email: user.email, + timeZone: user.timeZone, + language: { translate, locale: user.locale ?? "en" }, + }); + } + if (originalRescheduledBooking.user) { + const translate = await getTranslation(originalRescheduledBooking.user.locale ?? "en", "common"); + originalBookingMemberEmails.push({ + ...originalRescheduledBooking.user, + name: originalRescheduledBooking.user.name || "", + language: { translate, locale: originalRescheduledBooking.user.locale ?? "en" }, + }); + } + + const newBookingMemberEmails: Person[] = + copyEvent.team?.members + .map((member) => member) + .concat(copyEvent.organizer) + .concat(copyEvent.attendees) || []; + + // scheduled Emails + const newBookedMembers = newBookingMemberEmails.filter( + (member) => + !originalBookingMemberEmails.find((originalMember) => originalMember.email === member.email) + ); + // cancelled Emails + const cancelledMembers = originalBookingMemberEmails.filter( + (member) => !newBookingMemberEmails.find((newMember) => newMember.email === member.email) + ); + // rescheduled Emails + const rescheduledMembers = newBookingMemberEmails.filter((member) => + originalBookingMemberEmails.find((orignalMember) => orignalMember.email === member.email) + ); + + sendRoundRobinRescheduledEmails(copyEventAdditionalInfo, rescheduledMembers); + sendRoundRobinScheduledEmails(copyEventAdditionalInfo, newBookedMembers); + sendRoundRobinCancelledEmails(copyEventAdditionalInfo, cancelledMembers); + } else { + // send normal rescheduled emails (non round robin event, where organizers stay the same) + await sendRescheduledEmails({ + ...copyEvent, + additionalInformation: metadata, + additionalNotes, // Resets back to the additionalNote input and not the override value + cancellationReason: `$RCH$${rescheduleReason ? rescheduleReason : ""}`, // Removable code prefix to differentiate cancellation from rescheduling for email + }); + } } // If it's not a reschedule, doesn't require confirmation and there's no price, // Create a booking @@ -2376,7 +2572,7 @@ async function handler( const metadata = videoCallUrl ? { - videoCallUrl: getVideoCallUrlFromCalEvent(evt), + videoCallUrl: getVideoCallUrlFromCalEvent(evt) || videoCallUrl, } : undefined; diff --git a/packages/features/bookings/lib/handleNewBooking/test/reschedule.test.ts b/packages/features/bookings/lib/handleNewBooking/test/reschedule.test.ts index 50c1fe2baa..622b88e950 100644 --- a/packages/features/bookings/lib/handleNewBooking/test/reschedule.test.ts +++ b/packages/features/bookings/lib/handleNewBooking/test/reschedule.test.ts @@ -5,7 +5,7 @@ import { describe, expect } from "vitest"; import { appStoreMetadata } from "@calcom/app-store/apps.metadata.generated"; import { WEBAPP_URL } from "@calcom/lib/constants"; import logger from "@calcom/lib/logger"; -import { BookingStatus } from "@calcom/prisma/enums"; +import { BookingStatus, SchedulingType } from "@calcom/prisma/enums"; import { test } from "@calcom/web/test/fixtures/fixtures"; import { createBookingScenario, @@ -37,6 +37,7 @@ import { expectBookingRequestedWebhookToHaveBeenFired, expectSuccessfulCalendarEventDeletionInCalendar, expectSuccessfulVideoMeetingDeletionInCalendar, + expectSuccessfulRoudRobinReschedulingEmails, } from "@calcom/web/test/utils/bookingScenario/expects"; import { getMockRequestDataForBooking } from "@calcom/web/test/utils/bookingScenario/getMockRequestDataForBooking"; import { setupAndTeardown } from "@calcom/web/test/utils/bookingScenario/setupAndTeardown"; @@ -48,839 +49,642 @@ describe("handleNewBooking", () => { setupAndTeardown(); describe("Reschedule", () => { - test( - `should rechedule an existing booking successfully with Cal Video(Daily Video) - 1. Should cancel the existing booking - 2. Should create a new booking in the database - 3. Should send 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, - slotInterval: 45, - 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`, - metadata: { - videoCallUrl: "https://existing-daily-video-call-url.example.com", - }, - references: [ - { - type: appStoreMetadata.dailyvideo.type, - uid: "MOCK_ID", - meetingId: "MOCK_ID", - meetingPassword: "MOCK_PASS", - meetingUrl: "http://mock-dailyvideo.example.com", - credentialId: null, - }, - { - type: appStoreMetadata.googlecalendar.type, - uid: "MOCK_ID", - meetingId: "MOCK_ID", - meetingPassword: "MOCK_PASSWORD", - meetingUrl: "https://UNUSED_URL", - externalCalendarId: "MOCK_EXTERNAL_CALENDAR_ID", - credentialId: undefined, - }, - ], - }, - ], - organizer, - apps: [TestData.apps["google-calendar"], TestData.apps["daily-video"]], - }) - ); - - 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); - - const previousBooking = await prismaMock.booking.findUnique({ - where: { - uid: uidOfBookingToBeRescheduled, - }, - }); - - logger.silly({ - previousBooking, - allBookings: await prismaMock.booking.findMany(), - }); - - // Expect previous booking to be cancelled - await expectBookingToBeInDatabase({ - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - uid: uidOfBookingToBeRescheduled, - status: BookingStatus.CANCELLED, - }); - - expect(previousBooking?.status).toBe(BookingStatus.CANCELLED); - /** - * 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: "MOCK_EXTERNAL_CALENDAR_ID", - }, - ], - }, - }); - - 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", - }, - }); - - expectSuccessfulCalendarEventUpdationInCalendar(calendarMock, { - externalCalendarId: "MOCK_EXTERNAL_CALENDAR_ID", - calEvent: { - videoCallData: expect.objectContaining({ - url: "http://mock-dailyvideo.example.com", - }), - }, - uid: "MOCK_ID", - }); - - expectSuccessfulBookingRescheduledEmails({ - booker, - organizer, - emails, - iCalUID: "MOCKED_GOOGLE_CALENDAR_ICS_ID", - appsStatus: [ - getMockPassingAppStatus({ slug: appStoreMetadata.dailyvideo.slug }), - getMockPassingAppStatus({ slug: appStoreMetadata.googlecalendar.slug }), - ], - }); - - expectBookingRescheduledWebhookToHaveBeenFired({ - booker, - organizer, - location: BookingLocations.CalVideo, - subscriberUrl: "http://my-webhook.example.com", - videoCallUrl: `${WEBAPP_URL}/video/${createdBooking.uid}`, - }); - }, - 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 - 2. Should create a new booking in the database - 3. Should send 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, - 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, - }, - ], - }, - ], - 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`, - responses: { - email: booker.email, - name: booker.name, - location: { optionValue: "", value: BookingLocations.CalVideo }, - }, - }, - }); - - const { req } = createMockNextJsRequest({ - method: "POST", - body: mockBookingData, - }); - - 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", - }, - 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/${createdBooking.uid}`, - }); - }, - timeout - ); - - test( - `an error in updating a calendar event should not stop the rescheduling - Current behaviour is wrong as the booking is resheduled but no-one is notified of it`, - async ({}) => { - 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], - destinationCalendar: { - integration: "google_calendar", - externalId: "organizer@google-calendar.com", - }, - }); - const uidOfBookingToBeRescheduled = "n5Wv3eHgconAED2j4gcVhP"; - const { dateString: plus1DateString } = getDate({ dateIncrement: 1 }); - - 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, - slotInterval: 45, - 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`, - metadata: { - videoCallUrl: "https://existing-daily-video-call-url.example.com", - }, - 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: "ORIGINAL_BOOKING_UID", - meetingId: "ORIGINAL_MEETING_ID", - meetingPassword: "ORIGINAL_MEETING_PASSWORD", - meetingUrl: "https://ORIGINAL_MEETING_URL", - externalCalendarId: "existing-event-type@example.com", - credentialId: undefined, - }, - ], - }, - ], - organizer, - apps: [TestData.apps["google-calendar"], TestData.apps["daily-video"]], - }) - ); - - const _calendarMock = mockCalendarToCrashOnUpdateEvent("googlecalendar"); - const _videoMock = mockSuccessfulVideoMeetingCreation({ - metadataLookupKey: "dailyvideo", - }); - - const mockBookingData = getMockRequestDataForBooking({ - data: { - eventTypeId: 1, - rescheduleUid: uidOfBookingToBeRescheduled, - responses: { - email: booker.email, - name: booker.name, - location: { optionValue: "", value: BookingLocations.CalVideo }, - }, - }, - }); - - const { req } = createMockNextJsRequest({ - method: "POST", - body: mockBookingData, - }); - - const createdBooking = await handleNewBooking(req); - - await expectBookingInDBToBeRescheduledFromTo({ - from: { - uid: uidOfBookingToBeRescheduled, - }, - to: { - description: "", - location: "integrations:daily", - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - uid: createdBooking.uid!, - eventTypeId: mockBookingData.eventTypeId, - status: BookingStatus.ACCEPTED, - metadata: { - videoCallUrl: `${WEBAPP_URL}/video/${createdBooking?.uid}`, - }, - responses: expect.objectContaining({ - email: booker.email, - name: booker.name, - }), - // Booking References still use the original booking's references - Not sure how intentional it is. - references: [ - { - type: appStoreMetadata.dailyvideo.type, - uid: "MOCK_ID", - meetingId: "MOCK_ID", - meetingPassword: "MOCK_PASS", - meetingUrl: "http://mock-dailyvideo.example.com", - }, - { - type: appStoreMetadata.googlecalendar.type, - // A reference is still created in case of event creation failure, with nullish values. Not sure what's the purpose for this. - uid: "ORIGINAL_BOOKING_UID", - meetingId: "ORIGINAL_MEETING_ID", - meetingPassword: "ORIGINAL_MEETING_PASSWORD", - meetingUrl: "https://ORIGINAL_MEETING_URL", - }, - ], - }, - }); - - expectWorkflowToBeTriggered(); - - // FIXME: We should send Broken Integration emails on calendar event updation failure - // expectBrokenIntegrationEmails({ booker, organizer, emails }); - - expectBookingRescheduledWebhookToHaveBeenFired({ - booker, - organizer, - location: "integrations:daily", - subscriberUrl: "http://my-webhook.example.com", - payload: { - uid: createdBooking.uid, - appsStatus: [ - expect.objectContaining(getMockPassingAppStatus({ slug: appStoreMetadata.dailyvideo.slug })), - expect.objectContaining( - getMockFailingAppStatus({ slug: appStoreMetadata.googlecalendar.slug }) - ), - ], - }, - videoCallUrl: `${WEBAPP_URL}/video/${createdBooking?.uid}`, - }); - }, - timeout - ); - - describe("Event Type that requires confirmation", () => { + describe("User event-type", () => { test( - `should reschedule a booking that requires confirmation in PENDING state - When a booker(who is not the organizer himself) is doing the reschedule + `should rechedule an existing booking successfully with Cal Video(Daily Video) + 1. Should cancel the existing booking + 2. Should create a new booking in the database + 3. Should send 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, + slotInterval: 45, + 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`, + metadata: { + videoCallUrl: "https://existing-daily-video-call-url.example.com", + }, + references: [ + { + type: appStoreMetadata.dailyvideo.type, + uid: "MOCK_ID", + meetingId: "MOCK_ID", + meetingPassword: "MOCK_PASS", + meetingUrl: "http://mock-dailyvideo.example.com", + credentialId: null, + }, + { + type: appStoreMetadata.googlecalendar.type, + uid: "MOCK_ID", + meetingId: "MOCK_ID", + meetingPassword: "MOCK_PASSWORD", + meetingUrl: "https://UNUSED_URL", + externalCalendarId: "MOCK_EXTERNAL_CALENDAR_ID", + credentialId: undefined, + }, + ], + }, + ], + organizer, + apps: [TestData.apps["google-calendar"], TestData.apps["daily-video"]], + }) + ); + + 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); + + const previousBooking = await prismaMock.booking.findUnique({ + where: { + uid: uidOfBookingToBeRescheduled, + }, + }); + + logger.silly({ + previousBooking, + allBookings: await prismaMock.booking.findMany(), + }); + + // Expect previous booking to be cancelled + await expectBookingToBeInDatabase({ + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + uid: uidOfBookingToBeRescheduled, + status: BookingStatus.CANCELLED, + }); + + expect(previousBooking?.status).toBe(BookingStatus.CANCELLED); + /** + * 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: "MOCK_EXTERNAL_CALENDAR_ID", + }, + ], + }, + }); + + 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", + }, + }); + + expectSuccessfulCalendarEventUpdationInCalendar(calendarMock, { + externalCalendarId: "MOCK_EXTERNAL_CALENDAR_ID", + calEvent: { + videoCallData: expect.objectContaining({ + url: "http://mock-dailyvideo.example.com", + }), + }, + uid: "MOCK_ID", + }); + + expectSuccessfulBookingRescheduledEmails({ + booker, + organizer, + emails, + iCalUID: "MOCKED_GOOGLE_CALENDAR_ICS_ID", + appsStatus: [ + getMockPassingAppStatus({ slug: appStoreMetadata.dailyvideo.slug }), + getMockPassingAppStatus({ slug: appStoreMetadata.googlecalendar.slug }), + ], + }); + + expectBookingRescheduledWebhookToHaveBeenFired({ + booker, + organizer, + location: BookingLocations.CalVideo, + subscriberUrl: "http://my-webhook.example.com", + videoCallUrl: `${WEBAPP_URL}/video/${createdBooking.uid}`, + }); + }, + 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 + 2. Should create a new booking in the database + 3. Should send 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, + 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, + }, + ], + }, + ], + 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`, + responses: { + email: booker.email, + name: booker.name, + location: { optionValue: "", value: BookingLocations.CalVideo }, + }, + }, + }); + + const { req } = createMockNextJsRequest({ + method: "POST", + body: mockBookingData, + }); + + 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", + }, + 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/${createdBooking.uid}`, + }); + }, + timeout + ); + + test( + `an error in updating a calendar event should not stop the rescheduling - Current behaviour is wrong as the booking is resheduled but no-one is notified of it`, + async ({}) => { + 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], + destinationCalendar: { + integration: "google_calendar", + externalId: "organizer@google-calendar.com", + }, + }); + const uidOfBookingToBeRescheduled = "n5Wv3eHgconAED2j4gcVhP"; + const { dateString: plus1DateString } = getDate({ dateIncrement: 1 }); + + 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, + slotInterval: 45, + 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`, + metadata: { + videoCallUrl: "https://existing-daily-video-call-url.example.com", + }, + 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: "ORIGINAL_BOOKING_UID", + meetingId: "ORIGINAL_MEETING_ID", + meetingPassword: "ORIGINAL_MEETING_PASSWORD", + meetingUrl: "https://ORIGINAL_MEETING_URL", + externalCalendarId: "existing-event-type@example.com", + credentialId: undefined, + }, + ], + }, + ], + organizer, + apps: [TestData.apps["google-calendar"], TestData.apps["daily-video"]], + }) + ); + + const _calendarMock = mockCalendarToCrashOnUpdateEvent("googlecalendar"); + const _videoMock = mockSuccessfulVideoMeetingCreation({ + metadataLookupKey: "dailyvideo", + }); + + const mockBookingData = getMockRequestDataForBooking({ + data: { + eventTypeId: 1, + rescheduleUid: uidOfBookingToBeRescheduled, + responses: { + email: booker.email, + name: booker.name, + location: { optionValue: "", value: BookingLocations.CalVideo }, + }, + }, + }); + + const { req } = createMockNextJsRequest({ + method: "POST", + body: mockBookingData, + }); + + const createdBooking = await handleNewBooking(req); + + await expectBookingInDBToBeRescheduledFromTo({ + from: { + uid: uidOfBookingToBeRescheduled, + }, + to: { + description: "", + location: "integrations:daily", + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + uid: createdBooking.uid!, + eventTypeId: mockBookingData.eventTypeId, + status: BookingStatus.ACCEPTED, + metadata: { + videoCallUrl: `${WEBAPP_URL}/video/${createdBooking?.uid}`, + }, + responses: expect.objectContaining({ + email: booker.email, + name: booker.name, + }), + // Booking References still use the original booking's references - Not sure how intentional it is. + references: [ + { + type: appStoreMetadata.dailyvideo.type, + uid: "MOCK_ID", + meetingId: "MOCK_ID", + meetingPassword: "MOCK_PASS", + meetingUrl: "http://mock-dailyvideo.example.com", + }, + { + type: appStoreMetadata.googlecalendar.type, + // A reference is still created in case of event creation failure, with nullish values. Not sure what's the purpose for this. + uid: "ORIGINAL_BOOKING_UID", + meetingId: "ORIGINAL_MEETING_ID", + meetingPassword: "ORIGINAL_MEETING_PASSWORD", + meetingUrl: "https://ORIGINAL_MEETING_URL", + }, + ], + }, + }); + + expectWorkflowToBeTriggered(); + + // FIXME: We should send Broken Integration emails on calendar event updation failure + // expectBrokenIntegrationEmails({ booker, organizer, emails }); + + expectBookingRescheduledWebhookToHaveBeenFired({ + booker, + organizer, + location: "integrations:daily", + subscriberUrl: "http://my-webhook.example.com", + payload: { + uid: createdBooking.uid, + appsStatus: [ + expect.objectContaining(getMockPassingAppStatus({ slug: appStoreMetadata.dailyvideo.slug })), + expect.objectContaining( + getMockFailingAppStatus({ slug: appStoreMetadata.googlecalendar.slug }) + ), + ], + }, + videoCallUrl: `${WEBAPP_URL}/video/${createdBooking?.uid}`, + }); + }, + 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 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 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", - }); + 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 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 - ); - - test( - `should rechedule a booking, that requires confirmation, without confirmation - When booker is the organizer of the existing booking as well as the event-type - 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({ + const scenarioData = getScenarioData({ webhooks: [ { userId: organizer.id, eventTriggers: ["BOOKING_CREATED"], - subscriberUrl: "http://my-webhook.example.com", + subscriberUrl, active: true, eventTypeId: 1, appId: null, @@ -889,441 +693,437 @@ describe("handleNewBooking", () => { eventTypes: [ { id: 1, - requiresConfirmation: true, slotInterval: 45, + requiresConfirmation: true, length: 45, users: [ { id: 101, }, ], - destinationCalendar: { - integration: "google_calendar", - externalId: "event-type-1@example.com", - }, }, ], bookings: [ { uid: uidOfBookingToBeRescheduled, eventTypeId: 1, - userId: organizer.id, 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: "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", + 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 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 calendarMock = mockCalendarToHaveNoBusySlots("googlecalendar", { + create: { + uid: "MOCK_ID", }, - }, - }); + update: { + uid: "UPDATED_MOCK_ID", + iCalUID: "MOCKED_GOOGLE_CALENDAR_ICS_ID", + }, + }); - 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", + 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 }, }, - ], - }, - }); + }, + }); - expectWorkflowToBeTriggered(); + const { req } = createMockNextJsRequest({ + method: "POST", + body: mockBookingData, + }); - 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", - }, - }); + const createdBooking = await handleNewBooking(req); + expect(createdBooking.responses).toContain({ + email: booker.email, + name: booker.name, + }); - // 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({ + 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, - // 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", - }); + 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", + }, + ], + }, + }); - 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/${createdBooking.uid}`, - }); - }, - timeout - ); + expectWorkflowToBeTriggered(); - test( - `should rechedule a booking, that requires confirmation, in PENDING state - Even when the rescheduler is the organizer of the event-type but not the organizer of the existing booking + 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 + ); + + test( + `should rechedule a booking, that requires confirmation, without confirmation - When booker is the organizer of the existing booking as well as the event-type + 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, + userId: organizer.id, + 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/${createdBooking.uid}`, + }); + }, + timeout + ); + + test( + `should rechedule a booking, that requires confirmation, in PENDING state - Even when the rescheduler is the organizer of the event-type but not the organizer of the existing booking 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 emails to the booker as well as organizer 4. Should trigger BOOKING_REQUESTED webhook `, - 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", - }); + 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 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, - }); - - // Fake the request to be from organizer - req.userId = organizer.id; - - 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 - ); - - test( - `should rechedule a booking, that requires confirmation, without confirmation - When the owner of the previous booking is doing the reschedule(but he isn't the organizer of the event-type now) - 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({ + const scenarioData = getScenarioData({ webhooks: [ { userId: organizer.id, eventTriggers: ["BOOKING_CREATED"], - subscriberUrl: "http://my-webhook.example.com", + subscriberUrl, active: true, eventTypeId: 1, appId: null, @@ -1332,104 +1132,509 @@ describe("handleNewBooking", () => { eventTypes: [ { id: 1, - requiresConfirmation: true, slotInterval: 45, + requiresConfirmation: true, 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: [ - { + 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: "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", + externalCalendarId: "MOCK_EXTERNAL_CALENDAR_ID", + credentialId: 1, }), ], }, ], organizer, - usersApartFromOrganizer: [ + 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, + }); + + // Fake the request to be from organizer + req.userId = organizer.id; + + 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 + ); + + test( + `should rechedule a booking, that requires confirmation, without confirmation - When the owner of the previous booking is doing the reschedule(but he isn't the organizer of the event-type now) + 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", + email: "", + schedules: [TestData.schedules.IstWorkHours], + username: "prev-organizer", + 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/${createdBooking.uid}`, + }); + }, + timeout + ); + }); + }); + describe("Team event-type", () => { + test( + "should send correct schedule/cancellation emails to hosts when round robin is rescheduled to different host", + async ({ emails }) => { + const handleNewBooking = (await import("@calcom/features/bookings/lib/handleNewBooking")).default; + const booker = getBooker({ + email: "booker@example.com", + name: "Booker", + }); + + const roundRobinHost1 = getOrganizer({ + name: "RR Host 1", + email: "rrhost1@example.com", + id: 101, + schedules: [TestData.schedules.IstWorkHours], + credentials: [getGoogleCalendarCredential()], + selectedCalendars: [TestData.selectedCalendars.google], + }); + + const roundRobinHost2 = getOrganizer({ + name: "RR Host 2", + email: "rrhost2@example.com", + id: 102, + schedules: [TestData.schedules.IstWorkHours], + credentials: [getGoogleCalendarCredential()], + selectedCalendars: [TestData.selectedCalendars.google], + }); + + const { dateString: plus1DateString } = getDate({ dateIncrement: 1 }); + const uidOfBookingToBeRescheduled = "n5Wv3eHgconAED2j4gcVhP"; + await createBookingScenario( + getScenarioData({ + eventTypes: [ { - id: previousOrganizerIdForTheBooking, - name: "Previous Organizer", - email: "", - schedules: [TestData.schedules.IstWorkHours], - username: "prev-organizer", - timeZone: "Europe/London", + id: 1, + slotInterval: 45, + length: 45, + users: [ + { + id: 101, + }, + { + id: 102, + }, + ], + schedulingType: SchedulingType.ROUND_ROBIN, }, ], + bookings: [ + { + uid: uidOfBookingToBeRescheduled, + eventTypeId: 1, + userId: 101, + status: BookingStatus.ACCEPTED, + startTime: `${plus1DateString}T05:00:00.000Z`, + endTime: `${plus1DateString}T05:15:00.000Z`, + metadata: { + videoCallUrl: "https://existing-daily-video-call-url.example.com", + }, + }, + ], + organizer: roundRobinHost1, + usersApartFromOrganizer: [roundRobinHost2], apps: [TestData.apps["google-calendar"], TestData.apps["daily-video"]], }) ); - const videoMock = mockSuccessfulVideoMeetingCreation({ + mockSuccessfulVideoMeetingCreation({ metadataLookupKey: "dailyvideo", }); - const calendarMock = mockCalendarToHaveNoBusySlots("googlecalendar", { + mockCalendarToHaveNoBusySlots("googlecalendar", { create: { uid: "MOCK_ID", }, update: { - iCalUID: "MOCKED_GOOGLE_CALENDAR_ICS_ID", uid: "UPDATED_MOCK_ID", + iCalUID: "MOCKED_GOOGLE_CALENDAR_ICS_ID", }, }); const mockBookingData = getMockRequestDataForBooking({ data: { eventTypeId: 1, + user: roundRobinHost1.name, 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, @@ -1437,17 +1642,32 @@ describe("handleNewBooking", () => { }, }, }); - const { req } = createMockNextJsRequest({ method: "POST", body: mockBookingData, }); - // Fake the request to be from organizer - req.userId = previousOrganizerIdForTheBooking; - const createdBooking = await handleNewBooking(req); + const previousBooking = await prismaMock.booking.findUnique({ + where: { + uid: uidOfBookingToBeRescheduled, + }, + }); + + logger.silly({ + previousBooking, + allBookings: await prismaMock.booking.findMany(), + }); + + // Expect previous booking to be cancelled + await expectBookingToBeInDatabase({ + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + uid: uidOfBookingToBeRescheduled, + status: BookingStatus.CANCELLED, + }); + + expect(previousBooking?.status).toBe(BookingStatus.CANCELLED); /** * Booking Time should be new time */ @@ -1469,76 +1689,163 @@ describe("handleNewBooking", () => { email: booker.email, name: booker.name, }), - references: [ + }, + }); + + expectSuccessfulRoudRobinReschedulingEmails({ + prevOrganizer: roundRobinHost1, + newOrganizer: roundRobinHost2, + emails, + }); + }, + timeout + ); + + test( + "should send rescheduling emails when round robin is rescheduled to same host", + async ({ emails }) => { + const handleNewBooking = (await import("@calcom/features/bookings/lib/handleNewBooking")).default; + const booker = getBooker({ + email: "booker@example.com", + name: "Booker", + }); + + const roundRobinHost1 = getOrganizer({ + name: "RR Host 1", + email: "rrhost1@example.com", + id: 101, + schedules: [TestData.schedules.IstMorningShift], + credentials: [getGoogleCalendarCredential()], + selectedCalendars: [TestData.selectedCalendars.google], + }); + + const roundRobinHost2 = getOrganizer({ + name: "RR Host 2", + email: "rrhost2@example.com", + id: 102, + schedules: [TestData.schedules.IstEveningShift], + credentials: [getGoogleCalendarCredential()], + selectedCalendars: [TestData.selectedCalendars.google], + }); + + const { dateString: plus1DateString } = getDate({ dateIncrement: 1 }); + const uidOfBookingToBeRescheduled = "n5Wv3eHgconAED2j4gcVhP"; + await createBookingScenario( + getScenarioData({ + eventTypes: [ { - 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", + id: 1, + slotInterval: 45, + length: 45, + users: [ + { + id: 101, + }, + { + id: 102, + }, + ], + schedulingType: SchedulingType.ROUND_ROBIN, }, ], - }, + bookings: [ + { + uid: uidOfBookingToBeRescheduled, + eventTypeId: 1, + userId: 101, + status: BookingStatus.ACCEPTED, + startTime: `${plus1DateString}T05:00:00.000Z`, + endTime: `${plus1DateString}T05:15:00.000Z`, + }, + ], + organizer: roundRobinHost1, + usersApartFromOrganizer: [roundRobinHost2], + apps: [TestData.apps["google-calendar"], TestData.apps["daily-video"]], + }) + ); + + mockSuccessfulVideoMeetingCreation({ + metadataLookupKey: "dailyvideo", }); - expectWorkflowToBeTriggered(); - - expectSuccessfulVideoMeetingUpdationInCalendar(videoMock, { - calEvent: { - location: "http://mock-dailyvideo.example.com", - }, - bookingRef: { - type: appStoreMetadata.dailyvideo.type, + mockCalendarToHaveNoBusySlots("googlecalendar", { + create: { uid: "MOCK_ID", - meetingId: "MOCK_ID", - meetingPassword: "MOCK_PASS", - meetingUrl: "http://mock-dailyvideo.example.com", + }, + update: { + uid: "UPDATED_MOCK_ID", + iCalUID: "MOCKED_GOOGLE_CALENDAR_ICS_ID", }, }); - // 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", - }), - }), - ]), + const mockBookingData = getMockRequestDataForBooking({ + data: { + eventTypeId: 1, + user: roundRobinHost1.name, + 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 }, + }, }, - uid: "MOCK_ID", + }); + const { req } = createMockNextJsRequest({ + method: "POST", + body: mockBookingData, }); - expectSuccessfulBookingRescheduledEmails({ - booker, - organizer, + const createdBooking = await handleNewBooking(req); + + const previousBooking = await prismaMock.booking.findUnique({ + where: { + uid: uidOfBookingToBeRescheduled, + }, + }); + + logger.silly({ + previousBooking, + allBookings: await prismaMock.booking.findMany(), + }); + + // Expect previous booking to be cancelled + await expectBookingToBeInDatabase({ + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + uid: uidOfBookingToBeRescheduled, + status: BookingStatus.CANCELLED, + }); + + expect(previousBooking?.status).toBe(BookingStatus.CANCELLED); + /** + * 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, + }), + }, + }); + + expectSuccessfulRoudRobinReschedulingEmails({ + prevOrganizer: roundRobinHost1, + newOrganizer: roundRobinHost1, // Round robin host 2 is not available and it will be rescheduled to same user emails, - iCalUID: "MOCKED_GOOGLE_CALENDAR_ICS_ID", - }); - - expectBookingRescheduledWebhookToHaveBeenFired({ - booker, - organizer, - location: BookingLocations.CalVideo, - subscriberUrl: "http://my-webhook.example.com", - videoCallUrl: `${WEBAPP_URL}/video/${createdBooking.uid}`, }); }, timeout