From 11bddbb71dc4db7f53c4e3bbdb28d67dac6ee54e Mon Sep 17 00:00:00 2001 From: "devin-ai-integration[bot]" <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Sat, 7 Jun 2025 22:30:54 +0200 Subject: [PATCH] feat: add setting to allow booking through a reschedule link (#21652) * feat: add setting to disable rescheduling cancelled bookings Co-Authored-By: hariom@cal.com * fix: resolve type errors for disableReschedulingCancelledBookings field Co-Authored-By: hariom@cal.com * fix: update test expectations and builder to include disableReschedulingCancelledBookings field Co-Authored-By: hariom@cal.com * fix: add disableReschedulingCancelledBookings field to managed event types Co-Authored-By: hariom@cal.com * fix: remove duplicate disableReschedulingCancelledBookings property in test Co-Authored-By: hariom@cal.com * fix: change default value to true for disableReschedulingCancelledBookings Co-Authored-By: hariom@cal.com * fix: update managed event types to use true as default for disableReschedulingCancelledBookings Co-Authored-By: hariom@cal.com * test: add comprehensive tests for disableReschedulingCancelledBookings feature Co-Authored-By: hariom@cal.com * update and remove unnecesarry test * update e2e test * Update reschedule.e2e.ts * update * fix * Reverse the meaning of column * Simpify logic of rescheduling redirects * fix test * revert --------- Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Co-authored-by: hariom@cal.com Co-authored-by: Anik Dhabal Babu <81948346+anikdhabal@users.noreply.github.com> Co-authored-by: unknown Co-authored-by: Hariom Balhara --- .../reschedule/[uid]/getServerSideProps.ts | 26 +++++++-- .../team/[slug]/[type]/getServerSideProps.tsx | 7 ++- apps/web/playwright/booking-pages.e2e.ts | 4 +- apps/web/playwright/reschedule.e2e.ts | 56 +++++++++++++++++++ apps/web/public/static/locales/en/common.json | 2 + .../lib/[user]/[type]/getServerSideProps.ts | 4 +- .../test/lib/handleChildrenEventTypes.test.ts | 5 ++ .../lib/handleChildrenEventTypes.ts | 2 + .../tabs/advanced/EventAdvancedTab.tsx | 27 +++++++++ .../features/eventtypes/lib/getPublicEvent.ts | 2 + packages/lib/server/eventTypeSelect.ts | 1 + packages/lib/server/repository/eventType.ts | 1 + packages/lib/test/builder.ts | 1 + .../migration.sql | 2 + packages/prisma/schema.prisma | 1 + packages/prisma/zod-utils.ts | 1 + 16 files changed, 132 insertions(+), 10 deletions(-) create mode 100644 packages/prisma/migrations/20250605102526_add_allow_reschedule_of_cancelled_column/migration.sql diff --git a/apps/web/lib/reschedule/[uid]/getServerSideProps.ts b/apps/web/lib/reschedule/[uid]/getServerSideProps.ts index 0f9ae333b2..c6036c2050 100644 --- a/apps/web/lib/reschedule/[uid]/getServerSideProps.ts +++ b/apps/web/lib/reschedule/[uid]/getServerSideProps.ts @@ -60,6 +60,7 @@ export async function getServerSideProps(context: GetServerSidePropsContext) { slug: true, allowReschedulingPastBookings: true, disableRescheduling: true, + allowReschedulingCancelledBookings: true, team: { select: { parentId: true, @@ -110,18 +111,31 @@ export async function getServerSideProps(context: GetServerSidePropsContext) { profileEnrichedBookingUser: enrichedBookingUser, }); + const isForcedRescheduleForCancelledBooking = allowRescheduleForCancelledBooking; // If booking is already REJECTED, we can't reschedule this booking. Take the user to the booking page which would show it's correct status and other details. // If the booking is CANCELLED and allowRescheduleForCancelledBooking is false, we redirect the user to the original event link. // A booking that has been rescheduled to a new booking will also have a status of CANCELLED const isDisabledRescheduling = booking.eventType?.disableRescheduling; - if ( - isDisabledRescheduling || - (!allowRescheduleForCancelledBooking && - (booking.status === BookingStatus.CANCELLED || booking.status === BookingStatus.REJECTED)) - ) { + // This comes from query param and thus is considered forced + const canRescheduleCancelledBooking = + isForcedRescheduleForCancelledBooking || booking.eventType?.allowReschedulingCancelledBookings; + const isNonRescheduleableBooking = + booking.status === BookingStatus.CANCELLED || booking.status === BookingStatus.REJECTED; + + if (isDisabledRescheduling) { return { redirect: { - destination: booking.status === BookingStatus.CANCELLED ? eventUrl : `/booking/${uid}`, + destination: `/booking/${uid}`, + permanent: false, + }, + }; + } + + if (isNonRescheduleableBooking) { + const canReschedule = booking.status === BookingStatus.CANCELLED && canRescheduleCancelledBooking; + return { + redirect: { + destination: canReschedule ? eventUrl : `/booking/${uid}`, permanent: false, }, }; diff --git a/apps/web/lib/team/[slug]/[type]/getServerSideProps.tsx b/apps/web/lib/team/[slug]/[type]/getServerSideProps.tsx index 8564a4efce..08e09281cd 100644 --- a/apps/web/lib/team/[slug]/[type]/getServerSideProps.tsx +++ b/apps/web/lib/team/[slug]/[type]/getServerSideProps.tsx @@ -71,7 +71,11 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) => let booking: GetBookingType | null = null; if (rescheduleUid) { booking = await getBookingForReschedule(`${rescheduleUid}`, session?.user?.id); - if (booking?.status === BookingStatus.CANCELLED && !allowRescheduleForCancelledBooking) { + if ( + booking?.status === BookingStatus.CANCELLED && + !allowRescheduleForCancelledBooking && + !eventData.allowReschedulingCancelledBookings + ) { return { redirect: { permanent: false, @@ -212,6 +216,7 @@ const getTeamWithEventsData = async ( hidden: true, disableCancelling: true, disableRescheduling: true, + allowReschedulingCancelledBookings: true, interfaceLanguage: true, hosts: { take: 3, diff --git a/apps/web/playwright/booking-pages.e2e.ts b/apps/web/playwright/booking-pages.e2e.ts index 802045cb75..8da6410a34 100644 --- a/apps/web/playwright/booking-pages.e2e.ts +++ b/apps/web/playwright/booking-pages.e2e.ts @@ -252,8 +252,8 @@ test.describe("pro user", () => { await page.goto(`/reschedule/${bookingCancelledId}`); - // Should be redirected to the original event link - await expect(page).toHaveURL(new RegExp(`/${pro.username}/${eventSlug}`)); + expect(page.url()).not.toContain("rescheduleUid"); + await expect(cancelledHeadline).toBeVisible(); }); test("can book an event that requires confirmation and then that booking can be accepted by organizer", async ({ diff --git a/apps/web/playwright/reschedule.e2e.ts b/apps/web/playwright/reschedule.e2e.ts index ead6f8de70..0ec9ccb16c 100644 --- a/apps/web/playwright/reschedule.e2e.ts +++ b/apps/web/playwright/reschedule.e2e.ts @@ -389,6 +389,62 @@ test.describe("Reschedule Tests", async () => { // It is tested in teams.e2e.ts }); + test("Should redirect to cancelled page when allowReschedulingCancelledBookings is false (default)", async ({ + page, + users, + bookings, + }) => { + const user = await users.create(); + const eventType = user.eventTypes[0]; + + await prisma.eventType.update({ + where: { + id: eventType.id, + }, + data: { + allowReschedulingCancelledBookings: false, + }, + }); + + const booking = await bookings.create(user.id, user.username, eventType.id, { + status: BookingStatus.CANCELLED, + }); + + await page.goto(`/reschedule/${booking.uid}`); + + expect(page.url()).not.toContain("rescheduleUid"); + await expect(page.locator('[data-testid="cancelled-headline"]')).toBeVisible(); + }); + + test("Should allow rescheduling when allowReschedulingCancelledBookings is true", async ({ + page, + users, + bookings, + }) => { + const user = await users.create(); + const eventType = user.eventTypes[0]; + + await prisma.eventType.update({ + where: { + id: eventType.id, + }, + data: { + allowReschedulingCancelledBookings: true, + }, + }); + + const booking = await bookings.create(user.id, user.username, eventType.id, { + status: BookingStatus.CANCELLED, + }); + + await page.goto(`/reschedule/${booking.uid}`); + + await selectFirstAvailableTimeSlotNextMonth(page); + await bookTimeSlot(page); + + await expect(page.locator("[data-testid=success-page]")).toBeVisible(); + }); + test.describe("Organization", () => { test("Booking should be rescheduleable for a user that was moved to an organization through org domain", async ({ users, diff --git a/apps/web/public/static/locales/en/common.json b/apps/web/public/static/locales/en/common.json index d9c7563da6..ffa0c3a834 100644 --- a/apps/web/public/static/locales/en/common.json +++ b/apps/web/public/static/locales/en/common.json @@ -1171,6 +1171,8 @@ "confirm_delete_api_key": "Revoke this API key", "disable_rescheduling": "Disable Rescheduling", "description_disable_rescheduling": "Guests can no longer reschedule the event with calendar invite or email", + "allow_rescheduling_cancelled_bookings": "Allow booking through reschedule link", + "description_allow_rescheduling_cancelled_bookings": "When enabled, users will be able to create a new booking when trying to reschedule a cancelled booking", "disable_cancelling": "Disable Cancelling", "description_disable_cancelling": "Guests can no longer cancel the event with calendar invite or email", "revoke_api_key": "Revoke API key", diff --git a/apps/web/server/lib/[user]/[type]/getServerSideProps.ts b/apps/web/server/lib/[user]/[type]/getServerSideProps.ts index 9cec8a58e6..86c2c99cb1 100644 --- a/apps/web/server/lib/[user]/[type]/getServerSideProps.ts +++ b/apps/web/server/lib/[user]/[type]/getServerSideProps.ts @@ -60,7 +60,9 @@ async function processReschedule({ booking === null || !booking.eventTypeId || (booking?.eventTypeId === props.eventData?.id && - (booking.status !== BookingStatus.CANCELLED || allowRescheduleForCancelledBooking)) + (booking.status !== BookingStatus.CANCELLED || + allowRescheduleForCancelledBooking || + !!(props.eventData as any)?.allowReschedulingCancelledBookings)) ) { props.booking = booking; props.rescheduleUid = Array.isArray(rescheduleUid) ? rescheduleUid[0] : rescheduleUid; diff --git a/apps/web/test/lib/handleChildrenEventTypes.test.ts b/apps/web/test/lib/handleChildrenEventTypes.test.ts index 54f7499f03..9c9b4a4c6b 100644 --- a/apps/web/test/lib/handleChildrenEventTypes.test.ts +++ b/apps/web/test/lib/handleChildrenEventTypes.test.ts @@ -147,6 +147,7 @@ describe("handleChildrenEventTypes", () => { userId: 4, rrSegmentQueryValue: undefined, assignRRMembersUsingSegment: false, + allowReschedulingCancelledBookings: false, }, }); expect(result.newUserIds).toEqual([4]); @@ -206,6 +207,7 @@ describe("handleChildrenEventTypes", () => { deleteMany: {}, }, instantMeetingScheduleId: undefined, + allowReschedulingCancelledBookings: false, }, where: { userId_parentId: { @@ -315,6 +317,7 @@ describe("handleChildrenEventTypes", () => { workflows: undefined, rrSegmentQueryValue: undefined, assignRRMembersUsingSegment: false, + allowReschedulingCancelledBookings: false, }, }); expect(result.newUserIds).toEqual([4]); @@ -371,6 +374,7 @@ describe("handleChildrenEventTypes", () => { }, lockTimeZoneToggleOnBookingPage: false, requiresBookerEmailVerification: false, + allowReschedulingCancelledBookings: false, }, where: { userId_parentId: { @@ -476,6 +480,7 @@ describe("handleChildrenEventTypes", () => { rrSegmentQueryValue: undefined, assignRRMembersUsingSegment: false, useEventLevelSelectedCalendars: false, + allowReschedulingCancelledBookings: false, }, }); const { profileId, rrSegmentQueryValue, ...rest } = evType; diff --git a/packages/features/ee/managed-event-types/lib/handleChildrenEventTypes.ts b/packages/features/ee/managed-event-types/lib/handleChildrenEventTypes.ts index 7875c0d478..6fd6cb5674 100644 --- a/packages/features/ee/managed-event-types/lib/handleChildrenEventTypes.ts +++ b/packages/features/ee/managed-event-types/lib/handleChildrenEventTypes.ts @@ -187,6 +187,7 @@ export default async function handleChildrenEventTypes({ rrSegmentQueryValue: undefined, assignRRMembersUsingSegment: false, useEventLevelSelectedCalendars: false, + allowReschedulingCancelledBookings: managedEventTypeValues.allowReschedulingCancelledBookings ?? false, }, }); }) @@ -259,6 +260,7 @@ export default async function handleChildrenEventTypes({ : { deleteMany: {}, }, + allowReschedulingCancelledBookings: managedEventTypeValues.allowReschedulingCancelledBookings ?? false, metadata: { ...(eventType.metadata as Prisma.JsonObject), ...(metadata?.multipleDuration && "length" in unlockedFieldProps diff --git a/packages/features/eventtypes/components/tabs/advanced/EventAdvancedTab.tsx b/packages/features/eventtypes/components/tabs/advanced/EventAdvancedTab.tsx index be706ca4a0..cfab715163 100644 --- a/packages/features/eventtypes/components/tabs/advanced/EventAdvancedTab.tsx +++ b/packages/features/eventtypes/components/tabs/advanced/EventAdvancedTab.tsx @@ -497,6 +497,9 @@ export const EventAdvancedTab = ({ const disableCancellingLocked = shouldLockDisableProps("disableCancelling"); const disableReschedulingLocked = shouldLockDisableProps("disableRescheduling"); + const allowReschedulingCancelledBookingsLocked = shouldLockDisableProps( + "allowReschedulingCancelledBookings" + ); const { isLocked, ...eventNameLocked } = shouldLockDisableProps("eventName"); @@ -508,6 +511,10 @@ export const EventAdvancedTab = ({ const [disableRescheduling, setDisableRescheduling] = useState(eventType.disableRescheduling || false); + const [allowReschedulingCancelledBookings, setallowReschedulingCancelledBookings] = useState( + eventType.allowReschedulingCancelledBookings ?? false + ); + const closeEventNameTip = () => setShowEventNameTip(false); const [isEventTypeColorChecked, setIsEventTypeColorChecked] = useState(!!eventType.eventTypeColor); @@ -1028,6 +1035,26 @@ export const EventAdvancedTab = ({ /> )} /> + + ( + { + setallowReschedulingCancelledBookings(val); + onChange(val); + }} + /> + )} + /> {!isPlatform && ( <> { seatsPerTimeSlot: true, disableCancelling: true, disableRescheduling: true, + allowReschedulingCancelledBookings: true, seatsShowAvailabilityCount: true, bookingFields: true, teamId: true, @@ -528,6 +529,7 @@ export const getPublicEvent = async ( assignAllTeamMembers: event.assignAllTeamMembers, disableCancelling: event.disableCancelling, disableRescheduling: event.disableRescheduling, + allowReschedulingCancelledBookings: event.allowReschedulingCancelledBookings, interfaceLanguage: event.interfaceLanguage, }; }; diff --git a/packages/lib/server/eventTypeSelect.ts b/packages/lib/server/eventTypeSelect.ts index a94ad4d256..68c62e5fe7 100644 --- a/packages/lib/server/eventTypeSelect.ts +++ b/packages/lib/server/eventTypeSelect.ts @@ -33,6 +33,7 @@ export const eventTypeSelect = Prisma.validator()({ disableGuests: true, disableCancelling: true, disableRescheduling: true, + allowReschedulingCancelledBookings: true, hideCalendarNotes: true, minimumBookingNotice: true, beforeEventBuffer: true, diff --git a/packages/lib/server/repository/eventType.ts b/packages/lib/server/repository/eventType.ts index f09971dc19..436712d5f4 100644 --- a/packages/lib/server/repository/eventType.ts +++ b/packages/lib/server/repository/eventType.ts @@ -517,6 +517,7 @@ export class EventTypeRepository { disableGuests: true, disableCancelling: true, disableRescheduling: true, + allowReschedulingCancelledBookings: true, minimumBookingNotice: true, beforeEventBuffer: true, afterEventBuffer: true, diff --git a/packages/lib/test/builder.ts b/packages/lib/test/builder.ts index 34af319093..6d55339777 100644 --- a/packages/lib/test/builder.ts +++ b/packages/lib/test/builder.ts @@ -126,6 +126,7 @@ export const buildEventType = (eventType?: Partial): EventType => { seatsShowAttendees: null, disableCancelling: false, disableRescheduling: false, + allowReschedulingCancelledBookings: false, seatsShowAvailabilityCount: null, maxLeadThreshold: null, includeNoShowInRRCalculation: false, diff --git a/packages/prisma/migrations/20250605102526_add_allow_reschedule_of_cancelled_column/migration.sql b/packages/prisma/migrations/20250605102526_add_allow_reschedule_of_cancelled_column/migration.sql new file mode 100644 index 0000000000..35da015e61 --- /dev/null +++ b/packages/prisma/migrations/20250605102526_add_allow_reschedule_of_cancelled_column/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE "EventType" ADD COLUMN "allowReschedulingCancelledBookings" BOOLEAN DEFAULT false; diff --git a/packages/prisma/schema.prisma b/packages/prisma/schema.prisma index 56aa60c42f..d0d3738cd7 100644 --- a/packages/prisma/schema.prisma +++ b/packages/prisma/schema.prisma @@ -152,6 +152,7 @@ model EventType { schedulingType SchedulingType? schedule Schedule? @relation(fields: [scheduleId], references: [id]) scheduleId Int? + allowReschedulingCancelledBookings Boolean? @default(false) // price is deprecated. It has now moved to metadata.apps.stripe.price. Plan to drop this column. price Int @default(0) // currency is deprecated. It has now moved to metadata.apps.stripe.currency. Plan to drop this column. diff --git a/packages/prisma/zod-utils.ts b/packages/prisma/zod-utils.ts index 60930fe835..164cffa678 100644 --- a/packages/prisma/zod-utils.ts +++ b/packages/prisma/zod-utils.ts @@ -629,6 +629,7 @@ export const allManagedEventTypeProps: { [k in keyof Omit