fix: Unable to cancel a team event if any event host is an attendee (#22233)

* fix

* Update handleCancelBooking.ts

* Update handleCancelBooking.ts

* update comment

* fix type error

* added test

* Update packages/features/bookings/lib/handleCancelBooking.ts

Co-authored-by: cubic-dev-ai[bot] <191113872+cubic-dev-ai[bot]@users.noreply.github.com>

* Update handleCancelBooking.test.ts

* refactor

---------

Co-authored-by: cubic-dev-ai[bot] <191113872+cubic-dev-ai[bot]@users.noreply.github.com>
This commit is contained in:
Anik Dhabal Babu
2025-07-07 19:33:33 +00:00
committed by GitHub
co-authored by cubic-dev-ai[bot] <191113872+cubic-dev-ai[bot]@users.noreply.github.com> cubic-dev-ai[bot] <191113872+cubic-dev-ai[bot]@users.noreply.github.com>
parent 4404699fa6
commit 076afdc00e
2 changed files with 168 additions and 9 deletions
@@ -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);
}
}
@@ -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,
},
},
});
});
});