Skip to content

Commit b46fb82

Browse files
authored
feat: minium reschedule notice on events (calcom#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
1 parent 71c9567 commit b46fb82

26 files changed

Lines changed: 734 additions & 122 deletions

File tree

apps/web/components/booking/actions/bookingActions.ts

Lines changed: 32 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import { isWithinMinimumRescheduleNotice } from "@calcom/features/bookings/lib/reschedule/isWithinMinimumRescheduleNotice";
12
import { BookingStatus, SchedulingType } from "@calcom/prisma/enums";
23
import type { ActionType } from "@calcom/ui/components/table";
34

@@ -122,18 +123,25 @@ export function getEditEventActions(context: BookingActionContext): ActionType[]
122123
? `?seatReferenceUid=${seatReferenceUid}`
123124
: ""
124125
}`,
125-
disabled:
126-
(isBookingInPast && !booking.eventType.allowReschedulingPastBookings) || isDisabledRescheduling,
126+
disabled: isActionDisabled("reschedule", {
127+
...context,
128+
booking,
129+
isBookingInPast,
130+
isDisabledRescheduling,
131+
}),
127132
},
128133
{
129134
id: "reschedule_request",
130135
icon: "send",
131136
iconClassName: "rotate-45 w-[16px] -translate-x-0.5 ",
132137
label: t("send_reschedule_request"),
133138
disabled:
134-
(isBookingInPast && !booking.eventType.allowReschedulingPastBookings) ||
135-
isDisabledRescheduling ||
136-
booking.seatsReferences.length > 0,
139+
isActionDisabled("reschedule_request", {
140+
...context,
141+
booking,
142+
isBookingInPast,
143+
isDisabledRescheduling,
144+
}) || booking.seatsReferences.length > 0,
137145
},
138146
isBookingFromRoutingForm
139147
? {
@@ -229,12 +237,29 @@ export function shouldShowIndividualReportButton(context: BookingActionContext):
229237
}
230238

231239
export function isActionDisabled(actionId: string, context: BookingActionContext): boolean {
232-
const { booking, isBookingInPast, isDisabledRescheduling, isDisabledCancelling } = context;
240+
const { booking, isBookingInPast, isDisabledRescheduling, isDisabledCancelling, isAttendee } = context;
233241

234242
switch (actionId) {
235243
case "reschedule":
236244
case "reschedule_request":
237-
return (isBookingInPast && !booking.eventType.allowReschedulingPastBookings) || isDisabledRescheduling;
245+
// Only apply minimum reschedule notice restriction if user is NOT the organizer
246+
// If user is an attendee (or not authenticated), apply the restriction
247+
const isUserOrganizer =
248+
!isAttendee &&
249+
booking.loggedInUser?.userId &&
250+
booking.user?.id &&
251+
booking.loggedInUser.userId === booking.user.id;
252+
const isWithinMinimumNotice =
253+
!isUserOrganizer &&
254+
isWithinMinimumRescheduleNotice(
255+
new Date(booking.startTime),
256+
booking.eventType.minimumRescheduleNotice ?? null
257+
);
258+
return (
259+
(isBookingInPast && !booking.eventType.allowReschedulingPastBookings) ||
260+
isDisabledRescheduling ||
261+
isWithinMinimumNotice
262+
);
238263
case "cancel":
239264
return isDisabledCancelling || isBookingInPast;
240265
case "view_recordings":

apps/web/lib/booking.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,7 @@ export const getEventTypesFromDB = async (id: number) => {
4444
hideOrganizerEmail: true,
4545
disableCancelling: true,
4646
disableRescheduling: true,
47+
minimumRescheduleNotice: true,
4748
disableGuests: true,
4849
timeZone: true,
4950
profile: {

apps/web/lib/reschedule/[uid]/getServerSideProps.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,7 @@ export async function getServerSideProps(context: GetServerSidePropsContext) {
4747
},
4848
select: {
4949
...bookingMinimalSelect,
50+
userId: true,
5051
responses: true,
5152
eventType: {
5253
select: {
@@ -59,6 +60,7 @@ export async function getServerSideProps(context: GetServerSidePropsContext) {
5960
allowReschedulingPastBookings: true,
6061
disableRescheduling: true,
6162
allowReschedulingCancelledBookings: true,
63+
minimumRescheduleNotice: true,
6264
team: {
6365
select: {
6466
id: true,
@@ -122,17 +124,21 @@ export async function getServerSideProps(context: GetServerSidePropsContext) {
122124
booking: {
123125
uid,
124126
status: booking.status,
127+
startTime: booking.startTime,
125128
endTime: booking.endTime,
126129
responses: booking.responses,
130+
userId: booking.userId,
127131
eventType: {
128132
disableRescheduling: !!eventType?.disableRescheduling,
129133
allowReschedulingPastBookings: eventType.allowReschedulingPastBookings,
130134
allowBookingFromCancelledBookingReschedule: !!eventType.allowReschedulingCancelledBookings,
135+
minimumRescheduleNotice: eventType.minimumRescheduleNotice,
131136
teamId: eventType.team?.id ?? null,
132137
},
133138
},
134139
eventUrl,
135140
forceRescheduleForCancelledBooking: allowRescheduleForCancelledBooking,
141+
currentUserId: session?.user?.id ?? null,
136142
bookingSeat,
137143
});
138144

apps/web/modules/bookings/views/bookings-single-view.tsx

Lines changed: 60 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ import {
2424
import { Price } from "@calcom/features/bookings/components/event-meta/Price";
2525
import { getCalendarLinks, CalendarLinkType } from "@calcom/features/bookings/lib/getCalendarLinks";
2626
import { RATING_OPTIONS, validateRating } from "@calcom/features/bookings/lib/rating";
27+
import { isWithinMinimumRescheduleNotice as isWithinMinimumRescheduleNoticeUtil } from "@calcom/features/bookings/lib/reschedule/isWithinMinimumRescheduleNotice";
2728
import type { nameObjectSchema } from "@calcom/features/eventtypes/lib/eventNaming";
2829
import { getEventName } from "@calcom/features/eventtypes/lib/eventNaming";
2930
import { shouldShowFieldInCustomResponses } from "@calcom/lib/bookings/SystemField";
@@ -52,6 +53,7 @@ import { EmptyScreen } from "@calcom/ui/components/empty-screen";
5253
import { EmailInput, TextArea } from "@calcom/ui/components/form";
5354
import { Icon } from "@calcom/ui/components/icon";
5455
import { showToast } from "@calcom/ui/components/toast";
56+
import { Tooltip } from "@calcom/ui/components/tooltip";
5557
import { useCalcomTheme } from "@calcom/ui/styles";
5658
import CancelBooking from "@calcom/web/components/booking/CancelBooking";
5759
import EventReservationSchema from "@calcom/web/components/schemas/EventReservationSchema";
@@ -385,6 +387,15 @@ export default function Success(props: PageProps) {
385387
const canCancel = !eventType?.disableCancelling;
386388
const canReschedule = !eventType?.disableRescheduling;
387389

390+
// Check if reschedule should be disabled due to minimum reschedule notice
391+
// Use server-side computed isHost prop instead of client-side computation
392+
const isWithinMinimumRescheduleNotice = isHost
393+
? false // Organizers can always reschedule
394+
: isWithinMinimumRescheduleNoticeUtil(
395+
bookingInfo?.startTime ?? null,
396+
eventType?.minimumRescheduleNotice ?? null
397+
);
398+
const isRescheduleDisabled = !canReschedule || isWithinMinimumRescheduleNotice;
388399
const paymentStatusMessage = usePaymentStatus({
389400
bookingStatus: bookingInfo.status,
390401
startTime: bookingInfo.startTime,
@@ -489,7 +500,7 @@ export default function Success(props: PageProps) {
489500
{!isFeedbackMode && (
490501
<>
491502
<div
492-
className={classNames(isRoundRobin && "min-w-32 min-h-24 relative mx-auto h-24 w-32")}>
503+
className={classNames(isRoundRobin && "relative mx-auto h-24 min-h-24 w-32 min-w-32")}>
493504
{isRoundRobin && bookingInfo.user && (
494505
<Avatar
495506
className="mx-auto flex items-center justify-center"
@@ -539,7 +550,7 @@ export default function Success(props: PageProps) {
539550
(bookingInfo.status === BookingStatus.CANCELLED ||
540551
bookingInfo.status === BookingStatus.REJECTED) && <h4>{paymentStatusMessage}</h4>}
541552

542-
<div className="border-subtle text-default mt-8 grid grid-cols-3 gap-x-4 border-t pt-8 text-left rtl:text-right sm:gap-x-0">
553+
<div className="border-subtle text-default mt-8 grid grid-cols-3 gap-x-4 border-t pt-8 text-left sm:gap-x-0 rtl:text-right">
543554
{(isCancelled || reschedule) && cancellationReason && (
544555
<>
545556
<div className="font-medium">
@@ -810,47 +821,56 @@ export default function Success(props: PageProps) {
810821
canCancelOrReschedule &&
811822
(!isCancellationMode ? (
812823
<>
813-
<hr className="border-subtle mb-8" />
814-
<div className="text-center last:pb-0">
815-
<span className="text-emphasis ltr:mr-2 rtl:ml-2">
816-
{t("need_to_make_a_change")}
817-
</span>
818-
824+
{/* Only show section if there's at least one actionable option */}
825+
{((!props.recurringBookings &&
826+
(!isBookingInPast || eventType.allowReschedulingPastBookings) &&
827+
canReschedule) ||
828+
(!isBookingInPast && canCancel)) && (
819829
<>
820-
{!props.recurringBookings &&
821-
(!isBookingInPast || eventType.allowReschedulingPastBookings) &&
822-
canReschedule && (
823-
<span className="text-default inline">
824-
<span className="underline" data-testid="reschedule-link">
825-
<Link
826-
href={`/reschedule/${seatReferenceUid || bookingInfo?.uid}${
827-
currentUserEmail
828-
? `?rescheduledBy=${encodeURIComponent(currentUserEmail)}`
829-
: ""
830-
}`}
831-
legacyBehavior>
832-
{t("reschedule")}
833-
</Link>
834-
</span>
835-
{!isBookingInPast && canCancel && (
836-
<span className="mx-2">{t("or_lowercase")}</span>
830+
<hr className="border-subtle mb-8" />
831+
<div className="text-center last:pb-0">
832+
<span className="text-emphasis ltr:mr-2 rtl:ml-2">
833+
{t("need_to_make_a_change")}
834+
</span>
835+
836+
<>
837+
{!props.recurringBookings &&
838+
(!isBookingInPast || eventType.allowReschedulingPastBookings) &&
839+
canReschedule &&
840+
!isRescheduleDisabled && (
841+
<span className="text-default inline">
842+
<span className="underline" data-testid="reschedule-link">
843+
<Link
844+
href={`/reschedule/${seatReferenceUid || bookingInfo?.uid}${
845+
currentUserEmail
846+
? `?rescheduledBy=${encodeURIComponent(currentUserEmail)}`
847+
: ""
848+
}`}
849+
legacyBehavior>
850+
{t("reschedule")}
851+
</Link>
852+
</span>
853+
{!isBookingInPast && canCancel && (
854+
<span className="mx-2">{t("or_lowercase")}</span>
855+
)}
856+
</span>
837857
)}
838-
</span>
839-
)}
840-
841-
{!isBookingInPast && canCancel && (
842-
<button
843-
data-testid="cancel"
844-
className={classNames(
845-
"text-default underline",
846-
props.recurringBookings && "ltr:mr-2 rtl:ml-2"
858+
859+
{!isBookingInPast && canCancel && (
860+
<button
861+
data-testid="cancel"
862+
className={classNames(
863+
"text-default underline",
864+
props.recurringBookings && "ltr:mr-2 rtl:ml-2"
865+
)}
866+
onClick={() => setIsCancellationMode(true)}>
867+
{t("cancel")}
868+
</button>
847869
)}
848-
onClick={() => setIsCancellationMode(true)}>
849-
{t("cancel")}
850-
</button>
851-
)}
870+
</>
871+
</div>
852872
</>
853-
</div>
873+
)}
854874
</>
855875
) : (
856876
<>
@@ -1067,7 +1087,7 @@ export default function Success(props: PageProps) {
10671087
</div>
10681088
{isGmail && !isFeedbackMode && (
10691089
<Alert
1070-
className="main -mb-20 mt-4 inline-block ltr:text-left rtl:text-right sm:-mt-4 sm:mb-4 sm:w-full sm:max-w-xl sm:align-middle"
1090+
className="main -mb-20 mt-4 inline-block sm:-mt-4 sm:mb-4 sm:w-full sm:max-w-xl sm:align-middle ltr:text-left rtl:text-right"
10711091
severity="warning"
10721092
message={
10731093
<div>

apps/web/public/static/locales/en/common.json

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1346,6 +1346,11 @@
13461346
"confirm_delete_api_key": "Revoke this API key",
13471347
"disable_rescheduling": "Disable Rescheduling",
13481348
"description_disable_rescheduling": "Guests and Organizer can no longer reschedule the event with calendar invite or email. <0>Learn more</0>",
1349+
"minimum_reschedule_notice": "Minimum Reschedule Notice",
1350+
"minimum_reschedule_notice_description": "Prevent rescheduling within this many minutes before the event starts. Leave empty to allow rescheduling at any time.",
1351+
"when_less_than_minutes_before_meeting": "When less than <0></0> minutes before meeting",
1352+
"reschedule_disabled_minimum_notice": "Rescheduling is disabled within the minimum notice period before the event",
1353+
"rescheduling_is_disabled": "Rescheduling is disabled for this event",
13491354
"allow_rescheduling_cancelled_bookings": "Allow booking through reschedule link",
13501355
"description_allow_rescheduling_cancelled_bookings": "When enabled, users will be able to create a new booking when trying to reschedule a cancelled booking",
13511356
"disable_cancelling": "Disable Cancelling",

packages/features/bookings/lib/get-booking.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,9 +60,11 @@ async function getBooking(prisma: PrismaClient, uid: string, isSeatedEvent?: boo
6060
location: true,
6161
eventTypeId: true,
6262
status: true,
63+
userId: true,
6364
eventType: {
6465
select: {
6566
disableRescheduling: true,
67+
minimumRescheduleNotice: true,
6668
},
6769
},
6870
attendees: {
@@ -232,6 +234,7 @@ export const getBookingForSeatedEvent = async (uid: string) => {
232234
startTime: true,
233235
endTime: true,
234236
status: true,
237+
userId: true,
235238
attendees: {
236239
select: {
237240
id: true,
@@ -271,6 +274,7 @@ export const getBookingForSeatedEvent = async (uid: string) => {
271274
location: null,
272275
eventType: {
273276
disableRescheduling: false,
277+
minimumRescheduleNotice: null,
274278
},
275279
// mask attendee emails for seated events
276280
attendees: booking.attendees.map((attendee) => ({

packages/features/bookings/lib/handleNewBooking/getEventTypesFromDB.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ const getEventTypesFromDBSelect = {
2222
restrictionScheduleId: true,
2323
useBookerTimezone: true,
2424
disableRescheduling: true,
25+
minimumRescheduleNotice: true,
2526
disableCancelling: true,
2627
users: {
2728
select: {

0 commit comments

Comments
 (0)