From b46fb82695ae58ec83985319de133b81cf062596 Mon Sep 17 00:00:00 2001 From: sean-brydon <55134778+sean-brydon@users.noreply.github.com> Date: Fri, 12 Dec 2025 13:53:01 +0000 Subject: [PATCH] feat: minium reschedule notice on events (#25575) * wip * add i18n and essential tests * fix UI and add migration * fixes * add logic on book event * restore comment * remove dev debug box * extract min reschedule notice to util * one more replace * add to disable reschedule component * tidy up state * restore lock * restore file * fix zod utils * respond to cubic feedback * fix type error * unit tests * add zod util * build tests with nul minReshceuldeNotice * fix stuff --- .../booking/actions/bookingActions.ts | 39 ++- apps/web/lib/booking.ts | 1 + .../reschedule/[uid]/getServerSideProps.ts | 6 + .../bookings/views/bookings-single-view.tsx | 100 ++++--- apps/web/public/static/locales/en/common.json | 5 + packages/features/bookings/lib/get-booking.ts | 4 + .../handleNewBooking/getEventTypesFromDB.ts | 1 + ...ermineReschedulePreventionRedirect.test.ts | 252 +++++++++++++++++- .../determineReschedulePreventionRedirect.ts | 18 ++ .../isWithinMinimumRescheduleNotice.ts | 15 ++ .../lib/service/RegularBookingService.ts | 65 +++++ .../repositories/BookingRepository.ts | 8 + .../DisableReschedulingController.tsx | 200 ++++++++++++++ .../tabs/advanced/EventAdvancedTab.tsx | 42 +-- .../components/tabs/limits/EventLimitsTab.tsx | 10 +- .../features/eventtypes/lib/defaultEvents.ts | 1 + .../features/eventtypes/lib/getPublicEvent.ts | 1 + packages/features/eventtypes/lib/types.ts | 1 + .../repositories/eventTypeRepository.ts | 2 + packages/lib/server/eventTypeSelect.ts | 1 + packages/lib/test/builder.ts | 1 + .../event-types/hooks/useEventTypeForm.ts | 7 +- .../migration.sql | 2 + packages/prisma/schema.prisma | 2 + packages/prisma/zod-utils.ts | 69 +++-- .../routers/viewer/bookings/get.handler.ts | 1 + 26 files changed, 733 insertions(+), 121 deletions(-) create mode 100644 packages/features/bookings/lib/reschedule/isWithinMinimumRescheduleNotice.ts create mode 100644 packages/features/eventtypes/components/tabs/advanced/DisableReschedulingController.tsx create mode 100644 packages/prisma/migrations/20251203102204_add_min_reschedule_period/migration.sql diff --git a/apps/web/components/booking/actions/bookingActions.ts b/apps/web/components/booking/actions/bookingActions.ts index bb7af61757..bd76e4c7e4 100644 --- a/apps/web/components/booking/actions/bookingActions.ts +++ b/apps/web/components/booking/actions/bookingActions.ts @@ -1,3 +1,4 @@ +import { isWithinMinimumRescheduleNotice } from "@calcom/features/bookings/lib/reschedule/isWithinMinimumRescheduleNotice"; import { BookingStatus, SchedulingType } from "@calcom/prisma/enums"; import type { ActionType } from "@calcom/ui/components/table"; @@ -122,8 +123,12 @@ export function getEditEventActions(context: BookingActionContext): ActionType[] ? `?seatReferenceUid=${seatReferenceUid}` : "" }`, - disabled: - (isBookingInPast && !booking.eventType.allowReschedulingPastBookings) || isDisabledRescheduling, + disabled: isActionDisabled("reschedule", { + ...context, + booking, + isBookingInPast, + isDisabledRescheduling, + }), }, { id: "reschedule_request", @@ -131,9 +136,12 @@ export function getEditEventActions(context: BookingActionContext): ActionType[] iconClassName: "rotate-45 w-[16px] -translate-x-0.5 ", label: t("send_reschedule_request"), disabled: - (isBookingInPast && !booking.eventType.allowReschedulingPastBookings) || - isDisabledRescheduling || - booking.seatsReferences.length > 0, + isActionDisabled("reschedule_request", { + ...context, + booking, + isBookingInPast, + isDisabledRescheduling, + }) || booking.seatsReferences.length > 0, }, isBookingFromRoutingForm ? { @@ -229,12 +237,29 @@ export function shouldShowIndividualReportButton(context: BookingActionContext): } export function isActionDisabled(actionId: string, context: BookingActionContext): boolean { - const { booking, isBookingInPast, isDisabledRescheduling, isDisabledCancelling } = context; + const { booking, isBookingInPast, isDisabledRescheduling, isDisabledCancelling, isAttendee } = context; switch (actionId) { case "reschedule": case "reschedule_request": - return (isBookingInPast && !booking.eventType.allowReschedulingPastBookings) || isDisabledRescheduling; + // Only apply minimum reschedule notice restriction if user is NOT the organizer + // If user is an attendee (or not authenticated), apply the restriction + const isUserOrganizer = + !isAttendee && + booking.loggedInUser?.userId && + booking.user?.id && + booking.loggedInUser.userId === booking.user.id; + const isWithinMinimumNotice = + !isUserOrganizer && + isWithinMinimumRescheduleNotice( + new Date(booking.startTime), + booking.eventType.minimumRescheduleNotice ?? null + ); + return ( + (isBookingInPast && !booking.eventType.allowReschedulingPastBookings) || + isDisabledRescheduling || + isWithinMinimumNotice + ); case "cancel": return isDisabledCancelling || isBookingInPast; case "view_recordings": diff --git a/apps/web/lib/booking.ts b/apps/web/lib/booking.ts index d209dfafbb..87e96d0e56 100644 --- a/apps/web/lib/booking.ts +++ b/apps/web/lib/booking.ts @@ -44,6 +44,7 @@ export const getEventTypesFromDB = async (id: number) => { hideOrganizerEmail: true, disableCancelling: true, disableRescheduling: true, + minimumRescheduleNotice: true, disableGuests: true, timeZone: true, profile: { diff --git a/apps/web/lib/reschedule/[uid]/getServerSideProps.ts b/apps/web/lib/reschedule/[uid]/getServerSideProps.ts index 5c69b8a7c7..e3c009d649 100644 --- a/apps/web/lib/reschedule/[uid]/getServerSideProps.ts +++ b/apps/web/lib/reschedule/[uid]/getServerSideProps.ts @@ -47,6 +47,7 @@ export async function getServerSideProps(context: GetServerSidePropsContext) { }, select: { ...bookingMinimalSelect, + userId: true, responses: true, eventType: { select: { @@ -59,6 +60,7 @@ export async function getServerSideProps(context: GetServerSidePropsContext) { allowReschedulingPastBookings: true, disableRescheduling: true, allowReschedulingCancelledBookings: true, + minimumRescheduleNotice: true, team: { select: { id: true, @@ -122,17 +124,21 @@ export async function getServerSideProps(context: GetServerSidePropsContext) { booking: { uid, status: booking.status, + startTime: booking.startTime, endTime: booking.endTime, responses: booking.responses, + userId: booking.userId, eventType: { disableRescheduling: !!eventType?.disableRescheduling, allowReschedulingPastBookings: eventType.allowReschedulingPastBookings, allowBookingFromCancelledBookingReschedule: !!eventType.allowReschedulingCancelledBookings, + minimumRescheduleNotice: eventType.minimumRescheduleNotice, teamId: eventType.team?.id ?? null, }, }, eventUrl, forceRescheduleForCancelledBooking: allowRescheduleForCancelledBooking, + currentUserId: session?.user?.id ?? null, bookingSeat, }); diff --git a/apps/web/modules/bookings/views/bookings-single-view.tsx b/apps/web/modules/bookings/views/bookings-single-view.tsx index 04505d7469..4f45739c53 100644 --- a/apps/web/modules/bookings/views/bookings-single-view.tsx +++ b/apps/web/modules/bookings/views/bookings-single-view.tsx @@ -24,6 +24,7 @@ import { import { Price } from "@calcom/features/bookings/components/event-meta/Price"; import { getCalendarLinks, CalendarLinkType } from "@calcom/features/bookings/lib/getCalendarLinks"; import { RATING_OPTIONS, validateRating } from "@calcom/features/bookings/lib/rating"; +import { isWithinMinimumRescheduleNotice as isWithinMinimumRescheduleNoticeUtil } from "@calcom/features/bookings/lib/reschedule/isWithinMinimumRescheduleNotice"; import type { nameObjectSchema } from "@calcom/features/eventtypes/lib/eventNaming"; import { getEventName } from "@calcom/features/eventtypes/lib/eventNaming"; import { shouldShowFieldInCustomResponses } from "@calcom/lib/bookings/SystemField"; @@ -52,6 +53,7 @@ import { EmptyScreen } from "@calcom/ui/components/empty-screen"; import { EmailInput, TextArea } from "@calcom/ui/components/form"; import { Icon } from "@calcom/ui/components/icon"; import { showToast } from "@calcom/ui/components/toast"; +import { Tooltip } from "@calcom/ui/components/tooltip"; import { useCalcomTheme } from "@calcom/ui/styles"; import CancelBooking from "@calcom/web/components/booking/CancelBooking"; import EventReservationSchema from "@calcom/web/components/schemas/EventReservationSchema"; @@ -385,6 +387,15 @@ export default function Success(props: PageProps) { const canCancel = !eventType?.disableCancelling; const canReschedule = !eventType?.disableRescheduling; + // Check if reschedule should be disabled due to minimum reschedule notice + // Use server-side computed isHost prop instead of client-side computation + const isWithinMinimumRescheduleNotice = isHost + ? false // Organizers can always reschedule + : isWithinMinimumRescheduleNoticeUtil( + bookingInfo?.startTime ?? null, + eventType?.minimumRescheduleNotice ?? null + ); + const isRescheduleDisabled = !canReschedule || isWithinMinimumRescheduleNotice; const paymentStatusMessage = usePaymentStatus({ bookingStatus: bookingInfo.status, startTime: bookingInfo.startTime, @@ -489,7 +500,7 @@ export default function Success(props: PageProps) { {!isFeedbackMode && ( <>
+