Skip to content
Merged
28 changes: 28 additions & 0 deletions apps/web/components/booking/bookingActions.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -475,6 +475,34 @@ describe("Booking Actions", () => {
expect(isActionDisabled("cancel", context)).toBe(true);
});

it("should disable cancelling all past bookings", () => {
const pastConfirmedContext = createMockContext({
isBookingInPast: true,
isPending: false,
isConfirmed: true,
});

const pastPendingContext = createMockContext({
isBookingInPast: true,
isPending: true,
isConfirmed: false,
});

// Current implementation blocks ALL past bookings
expect(isActionDisabled("cancel", pastConfirmedContext)).toBe(true);
expect(isActionDisabled("cancel", pastPendingContext)).toBe(true);
});

it("should allow cancelling future bookings when cancelling is not disabled", () => {
const futureContext = createMockContext({
isBookingInPast: false,
isPending: true,
isConfirmed: false,
});

expect(isActionDisabled("cancel", futureContext)).toBe(false);
});

it("should disable video actions for non-past bookings", () => {
const context = createMockContext({ isBookingInPast: false });

Expand Down
2 changes: 1 addition & 1 deletion apps/web/components/booking/bookingActions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -210,7 +210,7 @@ export function isActionDisabled(actionId: string, context: BookingActionContext
case "reschedule_request":
return (isBookingInPast && !booking.eventType.allowReschedulingPastBookings) || isDisabledRescheduling;
case "cancel":
return isDisabledCancelling || (isBookingInPast && isPending && !isConfirmed);
return isDisabledCancelling || isBookingInPast;
case "view_recordings":
return !(isBookingInPast && booking.status === BookingStatus.ACCEPTED && context.isCalVideoLocation);
case "meeting_session_details":
Expand Down
4 changes: 2 additions & 2 deletions apps/web/modules/bookings/views/bookings-single-view.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -839,13 +839,13 @@ export default function Success(props: PageProps) {
{t("reschedule")}
</Link>
</span>
{canCancelAndReschedule && (
{!isBookingInPast && canCancel && (
<span className="mx-2">{t("or_lowercase")}</span>
)}
</span>
)}

{canCancel && (
{!isBookingInPast && canCancel && (
<button
data-testid="cancel"
className={classNames(
Expand Down
28 changes: 28 additions & 0 deletions apps/web/playwright/booking-pages.e2e.ts
Original file line number Diff line number Diff line change
Expand Up @@ -766,3 +766,31 @@ test.describe("GTM container", () => {
expect(scriptContent).toContain("googletagmanager");
});
});

test.describe("Past booking cancellation", () => {
test("Cancel button should be hidden for past bookings", async ({ page, users, bookings }) => {
const user = await users.create({
name: "Test User",
});

await user.apiLogin();

const pastDate = new Date();
pastDate.setDate(pastDate.getDate() - 1);
const endDate = new Date(pastDate.getTime() + 30 * 60 * 1000);

const booking = await bookings.create(user.id, user.username, user.eventTypes[0].id, {
title: "Past Meeting",
startTime: pastDate,
endTime: endDate,
status: "ACCEPTED",
});

await page.goto("/bookings/past");
await page.locator('[data-testid="booking-actions-dropdown"]').nth(0).click();
await expect(page.locator('[data-testid="cancel"]')).toBeDisabled();

await page.goto(`/booking/${booking.uid}`);
await expect(page.locator('[data-testid="cancel"]')).toBeHidden();
});
});
7 changes: 7 additions & 0 deletions packages/features/bookings/lib/handleCancelBooking.ts
Original file line number Diff line number Diff line change
Expand Up @@ -120,6 +120,13 @@ async function handler(input: CancelBookingInput) {
});
}

if (bookingToDelete.endTime && new Date() > new Date(bookingToDelete.endTime)) {
throw new HttpError({
statusCode: 400,
message: "Cannot cancel a booking that has already ended",
});
}

// If the booking is a seated event and there is no seatReferenceUid we should validate that logged in user is host
if (bookingToDelete.eventType?.seatsPerTimeSlot && !seatReferenceUid) {
const userIsHost = bookingToDelete.eventType.hosts.find((host) => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -420,4 +420,73 @@ describe("Cancel Booking", () => {
},
});
});

test("Should block cancelling past bookings", async () => {
const handleCancelBooking = (await import("@calcom/features/bookings/lib/handleCancelBooking")).default;

const booker = getBooker({
email: "booker@example.com",
name: "Booker",
});

const organizer = getOrganizer({
name: "Organizer",
email: "organizer@example.com",
id: 101,
schedules: [TestData.schedules.IstWorkHours],
credentials: [getGoogleCalendarCredential()],
selectedCalendars: [TestData.selectedCalendars.google],
});

const uidOfBookingToBeCancelled = "past-booking";
const idOfBookingToBeCancelled = 3040;
const { dateString: minus1DateString } = getDate({ dateIncrement: -1 });

await createBookingScenario(
getScenarioData({
eventTypes: [
{
id: 1,
slotInterval: 30,
length: 30,
users: [
{
id: 101,
},
],
},
],
bookings: [
{
id: idOfBookingToBeCancelled,
uid: uidOfBookingToBeCancelled,
eventTypeId: 1,
userId: 101,
responses: {
email: booker.email,
name: booker.name,
location: { optionValue: "", value: BookingLocations.CalVideo },
},
status: BookingStatus.ACCEPTED,
startTime: `${minus1DateString}T05:00:00.000Z`,
endTime: `${minus1DateString}T05:30:00.000Z`,
},
],
organizer,
apps: [TestData.apps["daily-video"]],
})
);

// This should throw an error with current implementation
await expect(
handleCancelBooking({
bookingData: {
id: idOfBookingToBeCancelled,
uid: uidOfBookingToBeCancelled,
cancelledBy: organizer.email,
cancellationReason: "Testing past booking cancellation",
},
})
).rejects.toThrow("Cannot cancel a booking that has already ended");
});
});
Loading