diff --git a/apps/web/components/booking/BookingListItem.tsx b/apps/web/components/booking/BookingListItem.tsx index 695f43f1995d20..6dd0054d988d9f 100644 --- a/apps/web/components/booking/BookingListItem.tsx +++ b/apps/web/components/booking/BookingListItem.tsx @@ -185,6 +185,12 @@ function BookingListItem(booking: BookingItemProps) { const isAttendee = !!userSeat; + // Checks if the logged in user is the host of the booking + const isHost = + booking.user?.id != null && + booking.loggedInUser.userId != null && + booking.loggedInUser.userId === booking.user.id; + const paymentAppData = getPaymentAppData(booking.eventType); const location = booking.location as ReturnType; @@ -234,6 +240,7 @@ function BookingListItem(booking: BookingItemProps) { showPendingPayment: paymentAppData.enabled && booking.payment.length && !booking.paid, isAttendee, cardCharged, + isHost, attendeeList, getSeatReferenceUid, t, diff --git a/apps/web/components/booking/actions/BookingActionsDropdown.tsx b/apps/web/components/booking/actions/BookingActionsDropdown.tsx index c0892c081541a9..66dcb7882e8730 100644 --- a/apps/web/components/booking/actions/BookingActionsDropdown.tsx +++ b/apps/web/components/booking/actions/BookingActionsDropdown.tsx @@ -254,6 +254,7 @@ export function BookingActionsDropdown({ showPendingPayment, isAttendee, cardCharged, + isHost, attendeeList, getSeatReferenceUid, t, diff --git a/apps/web/components/booking/actions/bookingActions.test.ts b/apps/web/components/booking/actions/bookingActions.test.ts index 1c8386d09194bc..24250ca97e6294 100644 --- a/apps/web/components/booking/actions/bookingActions.test.ts +++ b/apps/web/components/booking/actions/bookingActions.test.ts @@ -592,6 +592,38 @@ describe("Booking Actions", () => { expect(isActionDisabled("cancel", futureContext)).toBe(false); }); + it("should allow hosts to cancel bookings even when cancellation is disabled", () => { + const context = createMockContext({ + isHost: true, + isDisabledCancelling: true, + isBookingInPast: false, + }); + + expect(isActionDisabled("cancel", context)).toBe(false); + }); + + it("should not allow hosts to cancel already cancelled bookings", () => { + const context = createMockContext({ + isHost: true, + isDisabledCancelling: true, + isBookingInPast: false, + isCancelled: true, + }); + + expect(isActionDisabled("cancel", context)).toBe(true); + }); + + it("should not allow hosts to cancel rejected bookings", () => { + const context = createMockContext({ + isHost: true, + isDisabledCancelling: true, + isBookingInPast: false, + isRejected: true, + }); + + expect(isActionDisabled("cancel", context)).toBe(true); + }); + it("should disable video actions for non-past bookings", () => { const context = createMockContext({ isBookingInPast: false }); diff --git a/apps/web/components/booking/actions/bookingActions.ts b/apps/web/components/booking/actions/bookingActions.ts index 97544f63fdbff4..8f21304da4b5d8 100644 --- a/apps/web/components/booking/actions/bookingActions.ts +++ b/apps/web/components/booking/actions/bookingActions.ts @@ -24,6 +24,7 @@ export interface BookingActionContext { showPendingPayment: boolean; isAttendee: boolean; cardCharged: boolean; + isHost?: boolean; attendeeList: Array<{ name: string; email: string; @@ -237,7 +238,7 @@ export function shouldShowIndividualReportButton(context: BookingActionContext): } export function isActionDisabled(actionId: string, context: BookingActionContext): boolean { - const { booking, isBookingInPast, isDisabledRescheduling, isDisabledCancelling, isAttendee, isCancelled, isRejected } = context; + const { booking, isBookingInPast, isDisabledRescheduling, isDisabledCancelling, isAttendee, isHost, isCancelled, isRejected } = context; switch (actionId) { case "reschedule": @@ -263,6 +264,7 @@ export function isActionDisabled(actionId: string, context: BookingActionContext isWithinMinimumNotice ); case "cancel": + if (isHost && !isBookingInPast && !isCancelled && !isRejected) return false; return isDisabledCancelling || isBookingInPast || isCancelled || isRejected; case "view_recordings": return !(isBookingInPast && booking.status === BookingStatus.ACCEPTED && context.isCalVideoLocation); diff --git a/packages/features/bookings/lib/handleCancelBooking.ts b/packages/features/bookings/lib/handleCancelBooking.ts index 92f278c543f4d0..a8227774ba84b2 100644 --- a/packages/features/bookings/lib/handleCancelBooking.ts +++ b/packages/features/bookings/lib/handleCancelBooking.ts @@ -208,16 +208,35 @@ async function handler(input: CancelBookingInput, dependencies?: Dependencies) { throw new HttpError({ statusCode: 400, message: "User not found" }); } - if (bookingToDelete.eventType?.disableCancelling) { + let isCancellationUserHost = false; + if (userId) { + if (bookingToDelete.userId === userId) { + isCancellationUserHost = true; + } + else if (bookingToDelete.eventType?.hosts?.some((host) => host.user.id === userId)) { + isCancellationUserHost = true; + } + else if (bookingToDelete.eventType?.owner?.id === userId) { + isCancellationUserHost = true; + } + else if ( + await PrismaOrgMembershipRepository.isLoggedInUserOrgAdminOfBookingHost( + userId, + bookingToDelete.userId + ) + ) { + isCancellationUserHost = true; + } + } + + // Only the host can cancel the booking even when the cancellation is disabled for the event + if (!isCancellationUserHost && bookingToDelete.eventType?.disableCancelling) { throw new HttpError({ statusCode: 400, message: "This event type does not allow cancellations", }); } - const isCancellationUserHost = - bookingToDelete.userId === userId || bookingToDelete.user.email === cancelledBy; - if ( !platformClientId && !cancellationReason?.trim() && diff --git a/packages/features/bookings/lib/handleCancelBooking/test/handleCancelBooking.test.ts b/packages/features/bookings/lib/handleCancelBooking/test/handleCancelBooking.test.ts index 9bbe4784126ece..7ba9564030d328 100644 --- a/packages/features/bookings/lib/handleCancelBooking/test/handleCancelBooking.test.ts +++ b/packages/features/bookings/lib/handleCancelBooking/test/handleCancelBooking.test.ts @@ -700,7 +700,6 @@ describe("Cancel Booking", () => { }) ); - // This should throw an error with current implementation await expect( handleCancelBooking({ bookingData: { @@ -708,6 +707,7 @@ describe("Cancel Booking", () => { uid: uidOfBookingToBeCancelled, cancelledBy: organizer.email, }, + userId: organizer.id, }) ).rejects.toThrow("Cancellation reason is required when you are the host"); });