diff --git a/packages/features/bookings/lib/handleNewBooking.ts b/packages/features/bookings/lib/handleNewBooking.ts index 040d1a2c33..9854474d4a 100644 --- a/packages/features/bookings/lib/handleNewBooking.ts +++ b/packages/features/bookings/lib/handleNewBooking.ts @@ -1430,6 +1430,8 @@ async function handler( eventType.schedulingType === SchedulingType.ROUND_ROBIN && originalRescheduledBooking.userId !== evt.organizer.id; + const skipDeleteEventsAndMeetings = changedOrganizer; + const isBookingRequestedReschedule = !!originalRescheduledBooking && !!originalRescheduledBooking.rescheduled && @@ -1702,7 +1704,8 @@ async function handler( undefined, changedOrganizer, previousHostDestinationCalendar, - isBookingRequestedReschedule + isBookingRequestedReschedule, + skipDeleteEventsAndMeetings ); // 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. diff --git a/packages/features/ee/round-robin/roundRobinDeleteEvents.test.ts b/packages/features/ee/round-robin/roundRobinDeleteEvents.test.ts new file mode 100644 index 0000000000..807ffbfa81 --- /dev/null +++ b/packages/features/ee/round-robin/roundRobinDeleteEvents.test.ts @@ -0,0 +1,145 @@ +/** + * Tests for calendar event deletion during round robin organizer change operations. + * Covers scenarios where events need to be deleted from original hosts and + * credential handling during organizer changes. + */ +import { + getDate, + createBookingScenario, + getScenarioData, + getMockBookingAttendee, + TestData, + getOrganizer, + getGoogleCalendarCredential, + mockCalendarToHaveNoBusySlots, +} from "@calcom/web/test/utils/bookingScenario/bookingScenario"; +import { + expectBookingToBeInDatabase, + expectSuccessfulRoundRobinReschedulingEmails, +} from "@calcom/web/test/utils/bookingScenario/expects"; +import { setupAndTeardown } from "@calcom/web/test/utils/bookingScenario/setupAndTeardown"; + +import { describe, expect } from "vitest"; + +import { appStoreMetadata } from "@calcom/app-store/appStoreMetaData"; +import { SchedulingType, BookingStatus } from "@calcom/prisma/enums"; +import { test } from "@calcom/web/test/fixtures/fixtures"; + +describe("roundRobinReassignment test", () => { + setupAndTeardown(); + + test("should delete calendar events from original host when round robin reassignment changes organizer", async ({ + emails, + }) => { + const roundRobinReassignment = (await import("./roundRobinReassignment")).default; + + const originalHost = getOrganizer({ + name: "Original Host", + email: "originalhost@example.com", + id: 101, + schedules: [TestData.schedules.IstWorkHours], + credentials: [getGoogleCalendarCredential()], + selectedCalendars: [TestData.selectedCalendars.google], + }); + + const newHost = getOrganizer({ + name: "New Host", + email: "newhost@example.com", + id: 102, + schedules: [TestData.schedules.IstWorkHours], + credentials: [getGoogleCalendarCredential()], + selectedCalendars: [TestData.selectedCalendars.google], + }); + + const { dateString: dateStringPlusOne } = getDate({ dateIncrement: 1 }); + const bookingToReassignUid = "booking-to-reassign-with-deletion"; + + await createBookingScenario( + getScenarioData({ + eventTypes: [ + { + id: 1, + slug: "round-robin-event", + schedulingType: SchedulingType.ROUND_ROBIN, + length: 45, + users: [{ id: 101 }, { id: 102 }], + hosts: [ + { userId: 101, isFixed: false }, + { userId: 102, isFixed: false }, + ], + }, + ], + bookings: [ + { + id: 123, + eventTypeId: 1, + userId: originalHost.id, + uid: bookingToReassignUid, + status: BookingStatus.ACCEPTED, + startTime: `${dateStringPlusOne}T05:00:00.000Z`, + endTime: `${dateStringPlusOne}T05:45:00.000Z`, + attendees: [ + getMockBookingAttendee({ + id: 2, + name: "attendee", + email: "attendee@test.com", + locale: "en", + timeZone: "Asia/Kolkata", + }), + ], + references: [ + { + id: 1, + type: appStoreMetadata.googlecalendar.type, + uid: "ORIGINAL_EVENT_ID", + meetingId: "ORIGINAL_EVENT_ID", + meetingPassword: null, + meetingUrl: null, + externalCalendarId: "MOCK_EXTERNAL_CALENDAR_ID", + credentialId: 1, + deleted: null, + }, + ], + }, + ], + organizer: originalHost, + usersApartFromOrganizer: [newHost], + apps: [TestData.apps["google-calendar"]], + }) + ); + + const calendarMock = mockCalendarToHaveNoBusySlots("googlecalendar", { + create: { uid: "NEW_EVENT_ID" }, + update: { uid: "UPDATED_EVENT_ID" }, + }); + + await roundRobinReassignment({ + bookingId: 123, + }); + + // Verify that calendar deletion occurred (may be called multiple times due to duplicate references) + expect(calendarMock.deleteEventCalls.length).toBeGreaterThanOrEqual(1); + const deleteCall = calendarMock.deleteEventCalls[0]; + expect(deleteCall.args.uid).toBe("ORIGINAL_EVENT_ID"); + expect(deleteCall.args.externalCalendarId).toBe("MOCK_EXTERNAL_CALENDAR_ID"); + expect(deleteCall.args.event.organizer.email).toBe(newHost.email); // Current implementation uses new host credentials + expect(deleteCall.args.event.uid).toBe(bookingToReassignUid); + + // Verify that creation occurred with new host credentials + expect(calendarMock.createEventCalls.length).toBe(1); + const createCall = calendarMock.createEventCalls[0]; + expect(createCall.args.calEvent.organizer.email).toBe(newHost.email); + + // Verify the booking was reassigned to the new host + expectBookingToBeInDatabase({ + uid: bookingToReassignUid, + userId: newHost.id, + }); + + expectSuccessfulRoundRobinReschedulingEmails({ + prevOrganizer: originalHost, + newOrganizer: newHost, + emails, + }); + }); +}); diff --git a/packages/lib/EventManager.ts b/packages/lib/EventManager.ts index 1faf98f3c8..a244eee2dc 100644 --- a/packages/lib/EventManager.ts +++ b/packages/lib/EventManager.ts @@ -570,7 +570,8 @@ export default class EventManager { newBookingId?: number, changedOrganizer?: boolean, previousHostDestinationCalendar?: DestinationCalendar[] | null, - isBookingRequestedReschedule?: boolean + isBookingRequestedReschedule?: boolean, + skipDeleteEventsAndMeetings?: boolean ): Promise { const originalEvt = processLocation(event); const evt = cloneDeep(originalEvt); @@ -637,7 +638,7 @@ export default class EventManager { const shouldUpdateBookingReferences = !!changedOrganizer || isLocationChanged || !!isBookingRequestedReschedule || isDailyVideoRoomExpired; - if (evt.requiresConfirmation && !changedOrganizer) { + if (evt.requiresConfirmation && !skipDeleteEventsAndMeetings) { log.debug("RescheduleRequiresConfirmation: Deleting Event and Meeting for previous booking"); // As the reschedule requires confirmation, we can't update the events and meetings to new time yet. So, just delete them and let it be handled when organizer confirms the booking. await this.deleteEventsAndMeetings({ @@ -646,6 +647,14 @@ export default class EventManager { }); } else { if (changedOrganizer) { + if (!skipDeleteEventsAndMeetings) { + log.debug("RescheduleOrganizerChanged: Deleting Event and Meeting for previous booking"); + await this.deleteEventsAndMeetings({ + event: { ...event, destinationCalendar: previousHostDestinationCalendar }, + bookingReferences: booking.references, + }); + } + log.debug("RescheduleOrganizerChanged: Creating Event and Meeting for for new booking"); const createdEvent = await this.create(originalEvt); results.push(...createdEvent.results);