diff --git a/packages/features/bookings/lib/handleCancelBooking.ts b/packages/features/bookings/lib/handleCancelBooking.ts index 6ed8d3de75..7b5518262e 100644 --- a/packages/features/bookings/lib/handleCancelBooking.ts +++ b/packages/features/bookings/lib/handleCancelBooking.ts @@ -185,8 +185,10 @@ async function handler(input: CancelBookingInput) { const teamMembersPromises = []; const attendeesListPromises = []; const hostsPresent = !!bookingToDelete.eventType?.hosts; + const hostEmails = new Set(bookingToDelete.eventType?.hosts?.map((host) => host.user.email) ?? []); - for (const attendee of bookingToDelete.attendees) { + for (let index = 0; index < bookingToDelete.attendees.length; index++) { + const attendee = bookingToDelete.attendees[index]; const attendeeObject = { name: attendee.name, email: attendee.email, @@ -198,18 +200,18 @@ async function handler(input: CancelBookingInput) { }, }; - // Check for the presence of hosts to determine if it is a team event type - if (hostsPresent) { - // If the attendee is a host then they are a team member - const teamMember = bookingToDelete.eventType?.hosts.some((host) => host.user.email === attendee.email); - if (teamMember) { + // The first attendee is the booker in all cases, so always consider them as an attendee. + if (index === 0) { + attendeesListPromises.push(attendeeObject); + } else { + const isTeamEvent = hostEmails.size > 0; + const isTeamMember = isTeamEvent && hostEmails.has(attendee.email); + + if (isTeamMember) { teamMembersPromises.push(attendeeObject); - // If not then they are an attendee } else { attendeesListPromises.push(attendeeObject); } - } else { - attendeesListPromises.push(attendeeObject); } } diff --git a/packages/features/bookings/lib/handleCancelBooking/test/handleCancelBooking.test.ts b/packages/features/bookings/lib/handleCancelBooking/test/handleCancelBooking.test.ts index bf304fcb98..42214b92a3 100644 --- a/packages/features/bookings/lib/handleCancelBooking/test/handleCancelBooking.test.ts +++ b/packages/features/bookings/lib/handleCancelBooking/test/handleCancelBooking.test.ts @@ -263,4 +263,161 @@ describe("Cancel Booking", () => { expect(processPaymentRefund).toHaveBeenCalled(); }); + + test("Should successfully cancel round robin team event when host is also an attendee with workflow emails", async () => { + const handleCancelBooking = (await import("@calcom/features/bookings/lib/handleCancelBooking")).default; + + const organizer = getOrganizer({ + name: "Host Organizer", + email: "host-organizer@example.com", + id: 101, + schedules: [TestData.schedules.IstWorkHours], + credentials: [getGoogleCalendarCredential()], + selectedCalendars: [TestData.selectedCalendars.google], + }); + + const hostAttendee = getOrganizer({ + name: "Host Attendee", + email: "host-attendee@example.com", + id: 102, + schedules: [TestData.schedules.IstWorkHours], + credentials: [getGoogleCalendarCredential()], + selectedCalendars: [TestData.selectedCalendars.google], + }); + + const uidOfBookingToBeCancelled = "round-robin-booking-uid"; + const idOfBookingToBeCancelled = 2030; + const { dateString: plus1DateString } = getDate({ dateIncrement: 1 }); + + await createBookingScenario( + getScenarioData({ + webhooks: [ + { + userId: organizer.id, + eventTriggers: ["BOOKING_CANCELLED"], + subscriberUrl: "http://my-webhook.example.com", + active: true, + eventTypeId: 2, + appId: null, + }, + ], + eventTypes: [ + { + id: 2, + slotInterval: 30, + length: 30, + schedulingType: "ROUND_ROBIN", + teamId: 1, + users: [ + { + id: 101, + }, + { + id: 102, + }, + ], + hosts: [ + { + userId: 101, + isFixed: false, + }, + { + userId: 102, + isFixed: false, + }, + ], + }, + ], + workflows: [ + { + id: 1, + name: "Cancellation Email Workflow", + teamId: 1, + trigger: "EVENT_CANCELLED", + action: "EMAIL_ATTENDEE", + template: "REMINDER", + activeOn: [2], + }, + ], + bookings: [ + { + id: idOfBookingToBeCancelled, + uid: uidOfBookingToBeCancelled, + eventTypeId: 2, + userId: 101, + responses: { + email: hostAttendee.email, + name: hostAttendee.name, + location: { optionValue: "", value: BookingLocations.CalVideo }, + }, + status: BookingStatus.ACCEPTED, + startTime: `${plus1DateString}T05:00:00.000Z`, + endTime: `${plus1DateString}T05:30:00.000Z`, + attendees: [ + { + email: hostAttendee.email, + timeZone: "Asia/Kolkata", + locale: "en", + }, + ], + }, + ], + teams: [ + { + id: 1, + name: "Test Team", + slug: "test-team", + }, + ], + users: [organizer, hostAttendee], + apps: [TestData.apps["daily-video"]], + }) + ); + + mockSuccessfulVideoMeetingCreation({ + metadataLookupKey: "dailyvideo", + videoMeetingData: { + id: "MOCK_ID", + password: "MOCK_PASS", + url: `http://mock-dailyvideo.example.com/meeting-2`, + }, + }); + + mockCalendarToHaveNoBusySlots("googlecalendar", { + create: { + id: "MOCKED_GOOGLE_CALENDAR_EVENT_ID_2", + }, + }); + + const result = await handleCancelBooking({ + bookingData: { + id: idOfBookingToBeCancelled, + uid: uidOfBookingToBeCancelled, + cancelledBy: organizer.email, + cancellationReason: "Testing round robin cancellation with host as attendee", + }, + }); + + expect(result.success).toBe(true); + expect(result.bookingId).toBe(idOfBookingToBeCancelled); + expect(result.bookingUid).toBe(uidOfBookingToBeCancelled); + expect(result.onlyRemovedAttendee).toBe(false); + + expectBookingCancelledWebhookToHaveBeenFired({ + booker: hostAttendee, + organizer, + location: BookingLocations.CalVideo, + subscriberUrl: "http://my-webhook.example.com", + payload: { + cancelledBy: organizer.email, + organizer: { + id: organizer.id, + username: organizer.username, + email: organizer.email, + name: organizer.name, + timeZone: organizer.timeZone, + }, + }, + }); + }); });