From 18a0e2d75c05488cda8acfcea9c098a4c2200d2c Mon Sep 17 00:00:00 2001 From: Anik Dhabal Babu <81948346+anikdhabal@users.noreply.github.com> Date: Fri, 15 Aug 2025 02:12:36 +0530 Subject: [PATCH] fix: event not removed from calendar when reassigned (#23099) * fix: event not removed from calendar when reassign * fix test * tweak * add test * test: add comprehensive test for calendar event deletion during round robin reassignment - Remove existing tests and replace with focused test for organizer change scenario - Verify deleteEventsAndMeetings is called when organizer changes - Mock reschedule method to simulate real behavior while tracking deletion calls - Test verifies original host's calendar events are properly deleted - Ensures booking reassignment and email notifications work correctly Co-Authored-By: anik@cal.com * update * revert roundRobinReassignment.test.ts * revert --------- Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- .../features/bookings/lib/handleNewBooking.ts | 5 +- .../roundRobinDeleteEvents.test.ts | 145 ++++++++++++++++++ packages/lib/EventManager.ts | 13 +- 3 files changed, 160 insertions(+), 3 deletions(-) create mode 100644 packages/features/ee/round-robin/roundRobinDeleteEvents.test.ts 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);