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 && ( <>
+ className={classNames(isRoundRobin && "relative mx-auto h-24 min-h-24 w-32 min-w-32")}> {isRoundRobin && bookingInfo.user && ( {paymentStatusMessage}} -
+
{(isCancelled || reschedule) && cancellationReason && ( <>
@@ -810,47 +821,56 @@ export default function Success(props: PageProps) { canCancelOrReschedule && (!isCancellationMode ? ( <> -
-
- - {t("need_to_make_a_change")} - - + {/* Only show section if there's at least one actionable option */} + {((!props.recurringBookings && + (!isBookingInPast || eventType.allowReschedulingPastBookings) && + canReschedule) || + (!isBookingInPast && canCancel)) && ( <> - {!props.recurringBookings && - (!isBookingInPast || eventType.allowReschedulingPastBookings) && - canReschedule && ( - - - - {t("reschedule")} - - - {!isBookingInPast && canCancel && ( - {t("or_lowercase")} - )} - - )} +
+
+ + {t("need_to_make_a_change")} + - {!isBookingInPast && canCancel && ( - )} - onClick={() => setIsCancellationMode(true)}> - {t("cancel")} - - )} + +
-
+ )} ) : ( <> @@ -1067,7 +1087,7 @@ export default function Success(props: PageProps) {
{isGmail && !isFeedbackMode && ( diff --git a/apps/web/public/static/locales/en/common.json b/apps/web/public/static/locales/en/common.json index 558fa5c98b..3fd2cd3197 100644 --- a/apps/web/public/static/locales/en/common.json +++ b/apps/web/public/static/locales/en/common.json @@ -1346,6 +1346,11 @@ "confirm_delete_api_key": "Revoke this API key", "disable_rescheduling": "Disable Rescheduling", "description_disable_rescheduling": "Guests and Organizer can no longer reschedule the event with calendar invite or email. <0>Learn more", + "minimum_reschedule_notice": "Minimum Reschedule Notice", + "minimum_reschedule_notice_description": "Prevent rescheduling within this many minutes before the event starts. Leave empty to allow rescheduling at any time.", + "when_less_than_minutes_before_meeting": "When less than <0> minutes before meeting", + "reschedule_disabled_minimum_notice": "Rescheduling is disabled within the minimum notice period before the event", + "rescheduling_is_disabled": "Rescheduling is disabled for this event", "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", diff --git a/packages/features/bookings/lib/get-booking.ts b/packages/features/bookings/lib/get-booking.ts index d14f682be6..21f089c6bd 100644 --- a/packages/features/bookings/lib/get-booking.ts +++ b/packages/features/bookings/lib/get-booking.ts @@ -60,9 +60,11 @@ async function getBooking(prisma: PrismaClient, uid: string, isSeatedEvent?: boo location: true, eventTypeId: true, status: true, + userId: true, eventType: { select: { disableRescheduling: true, + minimumRescheduleNotice: true, }, }, attendees: { @@ -232,6 +234,7 @@ export const getBookingForSeatedEvent = async (uid: string) => { startTime: true, endTime: true, status: true, + userId: true, attendees: { select: { id: true, @@ -271,6 +274,7 @@ export const getBookingForSeatedEvent = async (uid: string) => { location: null, eventType: { disableRescheduling: false, + minimumRescheduleNotice: null, }, // mask attendee emails for seated events attendees: booking.attendees.map((attendee) => ({ diff --git a/packages/features/bookings/lib/handleNewBooking/getEventTypesFromDB.ts b/packages/features/bookings/lib/handleNewBooking/getEventTypesFromDB.ts index 44db01efdf..b94c02add2 100644 --- a/packages/features/bookings/lib/handleNewBooking/getEventTypesFromDB.ts +++ b/packages/features/bookings/lib/handleNewBooking/getEventTypesFromDB.ts @@ -22,6 +22,7 @@ const getEventTypesFromDBSelect = { restrictionScheduleId: true, useBookerTimezone: true, disableRescheduling: true, + minimumRescheduleNotice: true, disableCancelling: true, users: { select: { diff --git a/packages/features/bookings/lib/reschedule/determineReschedulePreventionRedirect.test.ts b/packages/features/bookings/lib/reschedule/determineReschedulePreventionRedirect.test.ts index 933e80cb2b..7a7c804b19 100644 --- a/packages/features/bookings/lib/reschedule/determineReschedulePreventionRedirect.test.ts +++ b/packages/features/bookings/lib/reschedule/determineReschedulePreventionRedirect.test.ts @@ -22,23 +22,31 @@ vi.mock("@calcom/lib/constants", async () => { const createTestBooking = (overrides?: { uid?: string; status?: BookingStatus; + startTime?: Date | null; endTime?: Date | null; responses?: JsonValue; + userId?: number | null; eventType?: { disableRescheduling?: boolean | null; allowReschedulingPastBookings?: boolean | null; allowBookingFromCancelledBookingReschedule?: boolean | null; + minimumRescheduleNotice?: number | null; teamId?: number | null; } | null; dynamicEventSlugRef?: string | null; -}) => ({ +}) => { + const defaultEndTime = futureDate(5); + const defaultStartTime = new Date(defaultEndTime.getTime() - 30 * 60 * 1000); // 30 minutes before endTime + return { uid: overrides?.uid || "test-booking-uid", status: overrides?.status || BookingStatus.ACCEPTED, - endTime: overrides?.endTime !== undefined ? overrides.endTime : futureDate(5), + startTime: overrides?.startTime !== undefined ? overrides.startTime : defaultStartTime, + endTime: overrides?.endTime !== undefined ? overrides.endTime : defaultEndTime, responses: overrides?.responses || { name: "John Doe", email: "john.doe@example.com", }, + userId: overrides?.userId !== undefined ? overrides.userId : 1, eventType: overrides?.eventType !== undefined ? overrides.eventType @@ -47,19 +55,23 @@ const createTestBooking = (overrides?: { disableRescheduling: false, allowReschedulingPastBookings: false, allowBookingFromCancelledBookingReschedule: false, + minimumRescheduleNotice: null, teamId: null, }, dynamicEventSlugRef: overrides?.dynamicEventSlugRef !== undefined ? overrides.dynamicEventSlugRef : null, -}); + }; +}; const createReschedulePreventionRedirectInput = (overrides?: { booking?: ReturnType; eventUrl?: string; forceRescheduleForCancelledBooking?: boolean; + currentUserId?: number | null; }): ReschedulePreventionRedirectInput => ({ booking: overrides?.booking || createTestBooking(), eventUrl: overrides?.eventUrl || "https://example.com/event", forceRescheduleForCancelledBooking: overrides?.forceRescheduleForCancelledBooking || false, + currentUserId: overrides?.currentUserId !== undefined ? overrides.currentUserId : null, }); const daysAgo = (days: number) => new Date(Date.now() - days * 24 * 60 * 60 * 1000); @@ -409,4 +421,238 @@ describe("determineReschedulePreventionRedirect", () => { expectToNotPreventReschedule(result); }); }); + + describe("when minimum reschedule notice is set", () => { + it("should not prevent reschedule when booking is outside the minimum notice period", () => { + const bookingStartTime = new Date(Date.now() + 48 * 60 * 60 * 1000); // 48 hours from now + const bookingEndTime = new Date(bookingStartTime.getTime() + 30 * 60 * 1000); // 30 minutes later + + const input = createReschedulePreventionRedirectInput({ + booking: createTestBooking({ + status: BookingStatus.ACCEPTED, + startTime: bookingStartTime, + endTime: bookingEndTime, + eventType: { + minimumRescheduleNotice: 1440, // 24 hours (1440 minutes) + }, + }), + }); + const result = determineReschedulePreventionRedirect(input); + + expectToNotPreventReschedule(result); + }); + + it("should redirect to booking details page when booking is within the minimum notice period", () => { + const bookingStartTime = new Date(Date.now() + 12 * 60 * 60 * 1000); // 12 hours from now + const bookingEndTime = new Date(bookingStartTime.getTime() + 30 * 60 * 1000); // 30 minutes later + + const input = createReschedulePreventionRedirectInput({ + booking: createTestBooking({ + status: BookingStatus.ACCEPTED, + startTime: bookingStartTime, + endTime: bookingEndTime, + eventType: { + minimumRescheduleNotice: 1440, // 24 hours (1440 minutes) + }, + }), + }); + const result = determineReschedulePreventionRedirect(input); + + expectRedirectToBookingDetailsPage(result, input.booking.uid); + }); + + it("should not prevent reschedule when minimum reschedule notice is 0", () => { + const bookingStartTime = new Date(Date.now() + 1 * 60 * 60 * 1000); // 1 hour from now + const bookingEndTime = new Date(bookingStartTime.getTime() + 30 * 60 * 1000); // 30 minutes later + + const input = createReschedulePreventionRedirectInput({ + booking: createTestBooking({ + status: BookingStatus.ACCEPTED, + startTime: bookingStartTime, + endTime: bookingEndTime, + eventType: { + minimumRescheduleNotice: 0, + }, + }), + }); + const result = determineReschedulePreventionRedirect(input); + + expectToNotPreventReschedule(result); + }); + + it("should not prevent reschedule when minimum reschedule notice is null", () => { + const bookingStartTime = new Date(Date.now() + 1 * 60 * 60 * 1000); // 1 hour from now + const bookingEndTime = new Date(bookingStartTime.getTime() + 30 * 60 * 1000); // 30 minutes later + + const input = createReschedulePreventionRedirectInput({ + booking: createTestBooking({ + status: BookingStatus.ACCEPTED, + startTime: bookingStartTime, + endTime: bookingEndTime, + eventType: { + minimumRescheduleNotice: null, + }, + }), + }); + const result = determineReschedulePreventionRedirect(input); + + expectToNotPreventReschedule(result); + }); + + it("should not prevent reschedule when booking has already started (in the past)", () => { + const bookingStartTime = new Date(Date.now() - 1 * 60 * 60 * 1000); // 1 hour ago + const bookingEndTime = new Date(bookingStartTime.getTime() + 30 * 60 * 1000); // 30 minutes later + + const input = createReschedulePreventionRedirectInput({ + booking: createTestBooking({ + status: BookingStatus.ACCEPTED, + startTime: bookingStartTime, + endTime: bookingEndTime, + eventType: { + minimumRescheduleNotice: 1440, // 24 hours + allowReschedulingPastBookings: false, + }, + }), + }); + const result = determineReschedulePreventionRedirect(input); + + // When booking is in the past, minimum reschedule notice doesn't apply + // The past booking logic handles it - it redirects to event URL with params + expectRedirectToEventBookingPageWithParams({ + result, + eventUrl: "https://example.com/event", + params: { + name: "John Doe", + email: "john.doe@example.com", + }, + }); + }); + + it("should not prevent reschedule when startTime is null", () => { + const input = createReschedulePreventionRedirectInput({ + booking: createTestBooking({ + status: BookingStatus.ACCEPTED, + startTime: null, + endTime: futureDate(5), + eventType: { + minimumRescheduleNotice: 1440, // 24 hours + }, + }), + }); + const result = determineReschedulePreventionRedirect(input); + + expectToNotPreventReschedule(result); + }); + + it("should prevent reschedule exactly at the minimum notice boundary", () => { + const minimumNoticeMinutes = 1440; // 24 hours + const bookingStartTime = new Date(Date.now() + minimumNoticeMinutes * 60 * 1000 - 1); // Just 1ms before the boundary + const bookingEndTime = new Date(bookingStartTime.getTime() + 30 * 60 * 1000); // 30 minutes later + + const input = createReschedulePreventionRedirectInput({ + booking: createTestBooking({ + status: BookingStatus.ACCEPTED, + startTime: bookingStartTime, + endTime: bookingEndTime, + eventType: { + minimumRescheduleNotice: minimumNoticeMinutes, + }, + }), + }); + const result = determineReschedulePreventionRedirect(input); + + expectRedirectToBookingDetailsPage(result, input.booking.uid); + }); + + it("should allow reschedule exactly at the minimum notice boundary (edge case)", () => { + const minimumNoticeMinutes = 1440; // 24 hours + const bookingStartTime = new Date(Date.now() + minimumNoticeMinutes * 60 * 1000); // Exactly at the boundary + const bookingEndTime = new Date(bookingStartTime.getTime() + 30 * 60 * 1000); // 30 minutes later + + const input = createReschedulePreventionRedirectInput({ + booking: createTestBooking({ + status: BookingStatus.ACCEPTED, + startTime: bookingStartTime, + endTime: bookingEndTime, + eventType: { + minimumRescheduleNotice: minimumNoticeMinutes, + }, + }), + }); + const result = determineReschedulePreventionRedirect(input); + + // At exactly the boundary, timeUntilBooking equals minimumRescheduleNoticeMs, so it should not prevent + expectToNotPreventReschedule(result); + }); + + it("should allow organizer to reschedule even within the minimum notice period", () => { + const organizerUserId = 1; + const bookingStartTime = new Date(Date.now() + 12 * 60 * 60 * 1000); // 12 hours from now (within 24h notice) + const bookingEndTime = new Date(bookingStartTime.getTime() + 30 * 60 * 1000); // 30 minutes later + + const input = createReschedulePreventionRedirectInput({ + booking: createTestBooking({ + status: BookingStatus.ACCEPTED, + userId: organizerUserId, + startTime: bookingStartTime, + endTime: bookingEndTime, + eventType: { + minimumRescheduleNotice: 1440, // 24 hours (1440 minutes) + }, + }), + currentUserId: organizerUserId, // User is the organizer + }); + const result = determineReschedulePreventionRedirect(input); + + // Organizer should be able to reschedule even within minimum notice period + expectToNotPreventReschedule(result); + }); + + it("should prevent attendee from rescheduling within the minimum notice period", () => { + const organizerUserId = 1; + const attendeeUserId = 2; + const bookingStartTime = new Date(Date.now() + 12 * 60 * 60 * 1000); // 12 hours from now (within 24h notice) + const bookingEndTime = new Date(bookingStartTime.getTime() + 30 * 60 * 1000); // 30 minutes later + + const input = createReschedulePreventionRedirectInput({ + booking: createTestBooking({ + status: BookingStatus.ACCEPTED, + userId: organizerUserId, + startTime: bookingStartTime, + endTime: bookingEndTime, + eventType: { + minimumRescheduleNotice: 1440, // 24 hours (1440 minutes) + }, + }), + currentUserId: attendeeUserId, // User is NOT the organizer + }); + const result = determineReschedulePreventionRedirect(input); + + // Attendee should be prevented from rescheduling within minimum notice period + expectRedirectToBookingDetailsPage(result, input.booking.uid); + }); + + it("should prevent unauthenticated user from rescheduling within the minimum notice period", () => { + const organizerUserId = 1; + const bookingStartTime = new Date(Date.now() + 12 * 60 * 60 * 1000); // 12 hours from now (within 24h notice) + const bookingEndTime = new Date(bookingStartTime.getTime() + 30 * 60 * 1000); // 30 minutes later + + const input = createReschedulePreventionRedirectInput({ + booking: createTestBooking({ + status: BookingStatus.ACCEPTED, + userId: organizerUserId, + startTime: bookingStartTime, + endTime: bookingEndTime, + eventType: { + minimumRescheduleNotice: 1440, // 24 hours (1440 minutes) + }, + }), + currentUserId: null, // User is not authenticated + }); + const result = determineReschedulePreventionRedirect(input); + + // Unauthenticated user should be prevented from rescheduling within minimum notice period + expectRedirectToBookingDetailsPage(result, input.booking.uid); + }); + }); }); diff --git a/packages/features/bookings/lib/reschedule/determineReschedulePreventionRedirect.ts b/packages/features/bookings/lib/reschedule/determineReschedulePreventionRedirect.ts index 8ecf467122..b5e98d0a4f 100644 --- a/packages/features/bookings/lib/reschedule/determineReschedulePreventionRedirect.ts +++ b/packages/features/bookings/lib/reschedule/determineReschedulePreventionRedirect.ts @@ -6,21 +6,27 @@ import { getSafe } from "@calcom/lib/getSafe"; import { BookingStatus } from "@calcom/prisma/enums"; import type { JsonValue } from "@calcom/types/Json"; +import { isWithinMinimumRescheduleNotice } from "./isWithinMinimumRescheduleNotice"; + export type ReschedulePreventionRedirectInput = { booking: { uid: string; status: BookingStatus; + startTime: Date | null; endTime: Date | null; responses?: JsonValue; + userId: number | null; // Booking organizer's user ID eventType: { disableRescheduling: boolean; allowReschedulingPastBookings: boolean; allowBookingFromCancelledBookingReschedule: boolean; + minimumRescheduleNotice: number | null; teamId: number | null; }; }; eventUrl: string; forceRescheduleForCancelledBooking?: boolean; + currentUserId?: number | null; // Currently authenticated user's ID (if any) bookingSeat?: { data: JsonValue; booking: { @@ -83,6 +89,18 @@ export function determineReschedulePreventionRedirect( return allowedToBeBookedThroughCancelledBookingRescheduleLink ? eventUrl : `/booking/${booking.uid}`; } + // Check if rescheduling is prevented due to minimum reschedule notice + // Only apply this restriction if the user is NOT the booking organizer + const isUserOrganizer = input.currentUserId && booking.userId && input.currentUserId === booking.userId; + const { minimumRescheduleNotice } = booking.eventType; + if ( + !isUserOrganizer && + isWithinMinimumRescheduleNotice(booking.startTime, minimumRescheduleNotice ?? null) + ) { + // Rescheduling is not allowed within the minimum notice period (only for non-organizers) + return `/booking/${booking.uid}`; + } + const isBookingInPast = booking.endTime && new Date(booking.endTime) < new Date(); if (isBookingInPast && !booking.eventType.allowReschedulingPastBookings) { // Check if this team should apply the redirect behavior for past bookings diff --git a/packages/features/bookings/lib/reschedule/isWithinMinimumRescheduleNotice.ts b/packages/features/bookings/lib/reschedule/isWithinMinimumRescheduleNotice.ts new file mode 100644 index 0000000000..ed13028879 --- /dev/null +++ b/packages/features/bookings/lib/reschedule/isWithinMinimumRescheduleNotice.ts @@ -0,0 +1,15 @@ +export function isWithinMinimumRescheduleNotice( + bookingStartTime: Date | null, + minimumRescheduleNotice: number | null +): boolean { + if (!minimumRescheduleNotice || minimumRescheduleNotice <= 0 || !bookingStartTime) { + return false; + } + + const now = new Date(); + const bookingStart = new Date(bookingStartTime); + const timeUntilBooking = bookingStart.getTime() - now.getTime(); + const minimumRescheduleNoticeMs = minimumRescheduleNotice * 60 * 1000; + + return timeUntilBooking > 0 && timeUntilBooking < minimumRescheduleNoticeMs; +} diff --git a/packages/features/bookings/lib/service/RegularBookingService.ts b/packages/features/bookings/lib/service/RegularBookingService.ts index e6a639970b..647509f8a4 100644 --- a/packages/features/bookings/lib/service/RegularBookingService.ts +++ b/packages/features/bookings/lib/service/RegularBookingService.ts @@ -127,6 +127,7 @@ import { validateBookingTimeIsNotOutOfBounds } from "../handleNewBooking/validat import { validateEventLength } from "../handleNewBooking/validateEventLength"; import handleSeats from "../handleSeats/handleSeats"; import type { IBookingService } from "../interfaces/IBookingService"; +import { isWithinMinimumRescheduleNotice } from "../reschedule/isWithinMinimumRescheduleNotice"; import { makeGuestActor } from "../types/actor"; const translator = short(); @@ -468,6 +469,57 @@ export interface IBookingServiceDependencies { bookingEventHandler: BookingEventHandlerService; } +async function validateRescheduleRestrictions({ + rescheduleUid, + userId, + eventType, +}: { + rescheduleUid: string | null | undefined; + userId: number | null; + eventType: { seatsPerTimeSlot: number | null; minimumRescheduleNotice: number | null } | null; +}): Promise { + if (!rescheduleUid || !eventType) { + return; // Not a reschedule, skip validation + } + + const bookingSeat = rescheduleUid ? await getSeatedBooking(rescheduleUid) : null; + const actualRescheduleUid = bookingSeat ? bookingSeat.booking.uid : rescheduleUid; + + if (!actualRescheduleUid) { + return; // No valid reschedule UID + } + + try { + const originalRescheduledBooking = await getOriginalRescheduledBooking( + actualRescheduleUid, + !!eventType.seatsPerTimeSlot + ); + + // Check if user is the organizer + const isUserOrganizer = + userId && originalRescheduledBooking.userId && userId === originalRescheduledBooking.userId; + + // Check minimum reschedule notice (only for non-organizers) + const { minimumRescheduleNotice } = originalRescheduledBooking.eventType || {}; + if ( + !isUserOrganizer && + isWithinMinimumRescheduleNotice(originalRescheduledBooking.startTime, minimumRescheduleNotice ?? null) + ) { + throw new HttpError({ + statusCode: 403, + message: "Rescheduling is not allowed within the minimum notice period before the event", + }); + } + } catch (error) { + // Re-throw HttpError (including our 403 validation error) + if (error instanceof HttpError) { + throw error; + } + // For other errors (like booking not found), let the service handle it later + // We don't want to fail early validation for these cases + } +} + /** * TODO: Ideally we should send organizationId directly to handleNewBooking. * webapp can derive from domain and API V2 knows it already through its endpoint URL @@ -547,6 +599,18 @@ async function handler( eventTypeSlug: rawBookingData.eventTypeSlug, }); + // Early validation: Check reschedule restrictions if rescheduling + await validateRescheduleRestrictions({ + rescheduleUid: rawBookingData.rescheduleUid, + userId: userId ?? null, + eventType: eventType + ? { + seatsPerTimeSlot: eventType.seatsPerTimeSlot, + minimumRescheduleNotice: eventType.minimumRescheduleNotice ?? null, + } + : null, + }); + const bookingDataSchema = bookingDataSchemaGetter({ view: rawBookingData.rescheduleUid ? "reschedule" : "booking", bookingFields: eventType.bookingFields, @@ -895,6 +959,7 @@ async function handler( users: IsFixedAwareUserWithCredentials[]; } = { ...eventType, + minimumRescheduleNotice: eventType.minimumRescheduleNotice ?? null, users: users as IsFixedAwareUserWithCredentials[], ...(eventType.recurringEvent && { recurringEvent: { diff --git a/packages/features/bookings/repositories/BookingRepository.ts b/packages/features/bookings/repositories/BookingRepository.ts index e3587554ea..765940e730 100644 --- a/packages/features/bookings/repositories/BookingRepository.ts +++ b/packages/features/bookings/repositories/BookingRepository.ts @@ -1133,6 +1133,14 @@ export class BookingRepository { }, }, }, + eventType: { + select: { + id: true, + minimumRescheduleNotice: true, + disableRescheduling: true, + userId: true, + }, + }, destinationCalendar: true, payment: true, references: true, diff --git a/packages/features/eventtypes/components/tabs/advanced/DisableReschedulingController.tsx b/packages/features/eventtypes/components/tabs/advanced/DisableReschedulingController.tsx new file mode 100644 index 0000000000..ed99ecd6a3 --- /dev/null +++ b/packages/features/eventtypes/components/tabs/advanced/DisableReschedulingController.tsx @@ -0,0 +1,200 @@ +import * as RadioGroup from "@radix-ui/react-radio-group"; +import { useEffect, useRef, useState } from "react"; +import { Controller, useFormContext } from "react-hook-form"; + +import useLockedFieldsManager from "@calcom/features/ee/managed-event-types/hooks/useLockedFieldsManager"; +import { LearnMoreLink } from "@calcom/features/eventtypes/components/LearnMoreLink"; +import type { EventTypeSetup, SettingsToggleClassNames } from "@calcom/features/eventtypes/lib/types"; +import type { FormValues } from "@calcom/features/eventtypes/lib/types"; +import ServerTrans from "@calcom/lib/components/ServerTrans"; +import { useLocale } from "@calcom/lib/hooks/useLocale"; +import classNames from "@calcom/ui/classNames"; +import { Input } from "@calcom/ui/components/form"; +import { SettingsToggle } from "@calcom/ui/components/form"; +import { RadioField } from "@calcom/ui/components/radio"; + +export type DisableReschedulingCustomClassNames = SettingsToggleClassNames & { + radioGroupContainer?: string; + alwaysRescheduleRadio?: string; + conditionalRescheduleRadio?: { + container?: string; + timeInput?: string; + }; +}; + +type DisableReschedulingControllerProps = { + eventType: EventTypeSetup; + disableRescheduling: boolean; + onDisableRescheduling: (val: boolean) => void; + customClassNames?: DisableReschedulingCustomClassNames; +}; + +export default function DisableReschedulingController({ + eventType, + disableRescheduling, + onDisableRescheduling, + customClassNames, +}: DisableReschedulingControllerProps) { + const { t } = useLocale(); + const formMethods = useFormContext(); + + const currentMinimumRescheduleNotice = formMethods.watch("minimumRescheduleNotice"); + const [minimumRescheduleNoticeValue, setMinimumRescheduleNoticeValue] = useState( + currentMinimumRescheduleNotice && currentMinimumRescheduleNotice > 0 ? currentMinimumRescheduleNotice : 60 + ); + const radioGroupOnValueChangeRef = useRef<((val: string) => void) | null>(null); + + const shouldShowRadioButtons = + disableRescheduling || + (currentMinimumRescheduleNotice !== null && currentMinimumRescheduleNotice > 0) || + eventType.disableRescheduling !== undefined; + + useEffect(() => { + if (currentMinimumRescheduleNotice && currentMinimumRescheduleNotice > 0) { + setMinimumRescheduleNoticeValue(currentMinimumRescheduleNotice); + } + }, [currentMinimumRescheduleNotice]); + + const { shouldLockDisableProps } = useLockedFieldsManager({ eventType, translate: t, formMethods }); + const disableReschedulingLocked = shouldLockDisableProps("disableRescheduling"); + const minimumRescheduleNoticeLocked = shouldLockDisableProps("minimumRescheduleNotice"); + + return ( +
+
+ ( + + } + checked={shouldShowRadioButtons} + onCheckedChange={(val) => { + if (val) { + onChange(true); + onDisableRescheduling(true); + formMethods.setValue("minimumRescheduleNotice", null, { shouldDirty: true }); + } else { + onChange(false); + onDisableRescheduling(false); + formMethods.setValue("minimumRescheduleNotice", null, { shouldDirty: true }); + } + }}> + {shouldShowRadioButtons && ( +
+ 0 + ? "notice" + : "always" + } + onValueChange={(val) => { + const handler = (val: string) => { + if (val === "always") { + onChange(true); + onDisableRescheduling(true); + formMethods.setValue("minimumRescheduleNotice", null, { shouldDirty: true }); + setMinimumRescheduleNoticeValue(0); + } else if (val === "notice") { + onChange(false); + onDisableRescheduling(false); + const valueToSet = + minimumRescheduleNoticeValue > 0 ? minimumRescheduleNoticeValue : 60; + formMethods.setValue("minimumRescheduleNotice", valueToSet, { shouldDirty: true }); + setMinimumRescheduleNoticeValue(valueToSet); + } + }; + radioGroupOnValueChangeRef.current = handler; + handler(val); + }}> +
+ + + + { + const val = Number(evt.target?.value); + if (val > 0) { + setMinimumRescheduleNoticeValue(val); + formMethods.setValue("minimumRescheduleNotice", val, { + shouldDirty: true, + }); + radioGroupOnValueChangeRef.current?.("notice"); + } + }} + className={classNames( + "border-default m-0! block w-20 text-sm [appearance:textfield] focus:z-10", + customClassNames?.conditionalRescheduleRadio?.timeInput + )} + defaultValue={ + currentMinimumRescheduleNotice && currentMinimumRescheduleNotice > 0 + ? currentMinimumRescheduleNotice + : 60 + } + /> +
, + ]} + /> + + } + id="notice" + value="notice" + /> +
+ +
+ )} + + )} + /> +
+
+ ); +} diff --git a/packages/features/eventtypes/components/tabs/advanced/EventAdvancedTab.tsx b/packages/features/eventtypes/components/tabs/advanced/EventAdvancedTab.tsx index 3d002d2368..3163e47752 100644 --- a/packages/features/eventtypes/components/tabs/advanced/EventAdvancedTab.tsx +++ b/packages/features/eventtypes/components/tabs/advanced/EventAdvancedTab.tsx @@ -72,6 +72,8 @@ import type { EmailNotificationToggleCustomClassNames } from "./DisableAllEmails import { DisableAllEmailsSetting } from "./DisableAllEmailsSetting"; import type { RequiresConfirmationCustomClassNames } from "./RequiresConfirmationController"; import RequiresConfirmationController from "./RequiresConfirmationController"; +import type { DisableReschedulingCustomClassNames } from "./DisableReschedulingController"; +import DisableReschedulingController from "./DisableReschedulingController"; export type EventAdvancedTabCustomClassNames = { destinationCalendar?: SelectClassNames; @@ -85,6 +87,7 @@ export type EventAdvancedTabCustomClassNames = { }; }; requiresConfirmation?: RequiresConfirmationCustomClassNames; + disableRescheduling?: DisableReschedulingCustomClassNames; bookerEmailVerification?: SettingsToggleClassNames; canSendCalVideoTranscriptionEmails?: SettingsToggleClassNames; calendarNotes?: SettingsToggleClassNames; @@ -177,7 +180,7 @@ const destinationCalendarComponents = { const selectedSecondaryEmailId = formMethods.getValues("secondaryEmailId") || -1; return (
-
+
{showConnectedCalendarSettings && (
setShowEventNameTip((old) => !old)}> @@ -301,7 +304,7 @@ const destinationCalendarComponents = { DestinationCalendarSettingsSkeleton() { return (
-
+
@@ -614,7 +617,7 @@ export const EventAdvancedTab = ({ }, [isPlatform]); return ( -
+
{t("booking_questions_title")}
-

+

- ( - - } - checked={disableRescheduling} - onCheckedChange={(val) => { - setDisableRescheduling(val); - onChange(val); - }} - /> - )} + )} diff --git a/packages/features/eventtypes/components/tabs/limits/EventLimitsTab.tsx b/packages/features/eventtypes/components/tabs/limits/EventLimitsTab.tsx index 84b5d8e40b..fd0692dd78 100644 --- a/packages/features/eventtypes/components/tabs/limits/EventLimitsTab.tsx +++ b/packages/features/eventtypes/components/tabs/limits/EventLimitsTab.tsx @@ -136,7 +136,7 @@ function RangeLimitRadioItem({ + className="bg-default border-default flex h-4 w-4 min-w-4 cursor-pointer items-center rounded-full border focus:border-2 focus:outline-none ltr:mr-2 rtl:ml-2"> )} @@ -145,7 +145,7 @@ function RangeLimitRadioItem({

+ className="bg-default border-default flex h-4 w-4 min-w-4 cursor-pointer items-center rounded-full border focus:border-2 focus:outline-none ltr:mr-2 rtl:ml-2"> )} @@ -423,7 +423,7 @@ export const EventLimitsTab = ({ eventType, customClassNames }: EventLimitsTabPr "border-subtle stack-y-6 rounded-lg border p-6", customClassNames?.bufferAndNoticeSection?.container )}> -
+
-
+
{ seatsPerTimeSlot: true, disableCancelling: true, disableRescheduling: true, + minimumRescheduleNotice: true, allowReschedulingCancelledBookings: true, seatsShowAvailabilityCount: true, bookingFields: true, diff --git a/packages/features/eventtypes/lib/types.ts b/packages/features/eventtypes/lib/types.ts index b8ffffd365..e61b881b98 100644 --- a/packages/features/eventtypes/lib/types.ts +++ b/packages/features/eventtypes/lib/types.ts @@ -115,6 +115,7 @@ export type FormValues = { useEventLevelSelectedCalendars: boolean; disabledCancelling: boolean; disabledRescheduling: boolean; + minimumRescheduleNotice: number | null; periodType: PeriodType; /** * Number of days(Applicable only for ROLLING period type) diff --git a/packages/features/eventtypes/repositories/eventTypeRepository.ts b/packages/features/eventtypes/repositories/eventTypeRepository.ts index 7bd9bef976..86a8f8095d 100644 --- a/packages/features/eventtypes/repositories/eventTypeRepository.ts +++ b/packages/features/eventtypes/repositories/eventTypeRepository.ts @@ -588,6 +588,7 @@ export class EventTypeRepository { disableGuests: true, disableCancelling: true, disableRescheduling: true, + minimumRescheduleNotice: true, allowReschedulingCancelledBookings: true, minimumBookingNotice: true, beforeEventBuffer: true, @@ -887,6 +888,7 @@ export class EventTypeRepository { disableGuests: true, disableCancelling: true, disableRescheduling: true, + minimumRescheduleNotice: true, allowReschedulingCancelledBookings: true, minimumBookingNotice: true, beforeEventBuffer: true, diff --git a/packages/lib/server/eventTypeSelect.ts b/packages/lib/server/eventTypeSelect.ts index 26c8b1ee94..45b82226be 100644 --- a/packages/lib/server/eventTypeSelect.ts +++ b/packages/lib/server/eventTypeSelect.ts @@ -39,6 +39,7 @@ export const eventTypeSelect = { disableGuests: true, disableCancelling: true, disableRescheduling: true, + minimumRescheduleNotice: true, allowReschedulingCancelledBookings: true, hideCalendarNotes: true, minimumBookingNotice: true, diff --git a/packages/lib/test/builder.ts b/packages/lib/test/builder.ts index 43ca4440c3..d541b86f1b 100644 --- a/packages/lib/test/builder.ts +++ b/packages/lib/test/builder.ts @@ -128,6 +128,7 @@ export const buildEventType = (eventType?: Partial): EventType => { seatsShowAttendees: null, disableCancelling: false, disableRescheduling: false, + minimumRescheduleNotice: null, allowReschedulingCancelledBookings: false, seatsShowAvailabilityCount: null, maxLeadThreshold: null, diff --git a/packages/platform/atoms/event-types/hooks/useEventTypeForm.ts b/packages/platform/atoms/event-types/hooks/useEventTypeForm.ts index e68a08cca9..02cfdc0b6d 100644 --- a/packages/platform/atoms/event-types/hooks/useEventTypeForm.ts +++ b/packages/platform/atoms/event-types/hooks/useEventTypeForm.ts @@ -94,6 +94,9 @@ export const useEventTypeForm = ({ requiresConfirmationForFreeEmail: eventType.requiresConfirmationForFreeEmail, slotInterval: eventType.slotInterval, minimumBookingNotice: eventType.minimumBookingNotice, + minimumRescheduleNotice: eventType.minimumRescheduleNotice ?? null, + disabledCancelling: eventType.disableCancelling ?? false, + disabledRescheduling: eventType.disableRescheduling ?? false, allowReschedulingPastBookings: eventType.allowReschedulingPastBookings, hideOrganizerEmail: eventType.hideOrganizerEmail, metadata: eventType.metadata, @@ -375,7 +378,7 @@ export const useEventTypeForm = ({ } // eslint-disable-next-line @typescript-eslint/no-unused-vars - const { availability, users, scheduleName, ...rest } = input; + const { availability, users, scheduleName, disabledCancelling, disabledRescheduling, ...rest } = input; const payload = { ...rest, length, @@ -400,6 +403,8 @@ export const useEventTypeForm = ({ children, assignAllTeamMembers, multiplePrivateLinks: values.multiplePrivateLinks, + disableCancelling: disabledCancelling, + disableRescheduling: disabledRescheduling, aiPhoneCallConfig: rest.aiPhoneCallConfig ? { ...rest.aiPhoneCallConfig, templateType: rest.aiPhoneCallConfig.templateType as TemplateType } : undefined, diff --git a/packages/prisma/migrations/20251203102204_add_min_reschedule_period/migration.sql b/packages/prisma/migrations/20251203102204_add_min_reschedule_period/migration.sql new file mode 100644 index 0000000000..e27514e06e --- /dev/null +++ b/packages/prisma/migrations/20251203102204_add_min_reschedule_period/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE "public"."EventType" ADD COLUMN "minimumRescheduleNotice" INTEGER; diff --git a/packages/prisma/schema.prisma b/packages/prisma/schema.prisma index cedfb16986..42cb6517b9 100644 --- a/packages/prisma/schema.prisma +++ b/packages/prisma/schema.prisma @@ -197,6 +197,8 @@ model EventType { showOptimizedSlots Boolean? @default(false) disableCancelling Boolean? @default(false) disableRescheduling Boolean? @default(false) + /// @zod.number.min(0) + minimumRescheduleNotice Int? seatsShowAttendees Boolean? @default(false) seatsShowAvailabilityCount Boolean? @default(true) schedulingType SchedulingType? diff --git a/packages/prisma/zod-utils.ts b/packages/prisma/zod-utils.ts index 87224533f3..f24741d5f1 100644 --- a/packages/prisma/zod-utils.ts +++ b/packages/prisma/zod-utils.ts @@ -15,7 +15,6 @@ import { EventTypeCustomInputType } from "@calcom/prisma/enums"; import type { Prisma } from "./client"; - /** @see https://github.com/colinhacks/zod/issues/3155#issuecomment-2060045794 */ export const emailRegex = /* eslint-disable-next-line no-useless-escape */ @@ -123,46 +122,44 @@ const raqbChildSchema = z.object({ .optional(), }); -const raqbChildren1Schema = z - .record(raqbChildSchema) - .superRefine((children1, ctx) => { - if (!children1) return; - const isObject = (value: unknown): value is Record => - typeof value === "object" && value !== null; - Object.entries(children1).forEach(([, _rule]) => { - const rule = _rule as unknown; - if (!isObject(rule) || rule.type !== "rule") return; - if (!isObject(rule.properties)) return; +const raqbChildren1Schema = z.record(raqbChildSchema).superRefine((children1, ctx) => { + if (!children1) return; + const isObject = (value: unknown): value is Record => + typeof value === "object" && value !== null; + Object.entries(children1).forEach(([, _rule]) => { + const rule = _rule as unknown; + if (!isObject(rule) || rule.type !== "rule") return; + if (!isObject(rule.properties)) return; - const value = rule.properties.value || []; - const valueSrc = rule.properties.valueSrc; - if (!(value instanceof Array) || !(valueSrc instanceof Array)) { - return; - } + const value = rule.properties.value || []; + const valueSrc = rule.properties.valueSrc; + if (!(value instanceof Array) || !(valueSrc instanceof Array)) { + return; + } - if (!valueSrc.length) { - // If valueSrc is empty, value could be empty for operators like is_empty, is_not_empty - return; - } + if (!valueSrc.length) { + // If valueSrc is empty, value could be empty for operators like is_empty, is_not_empty + return; + } - // MultiSelect array can be 2D array - const flattenedValues = value.flat(); + // MultiSelect array can be 2D array + const flattenedValues = value.flat(); - const validValues = flattenedValues.filter((value: unknown) => { - // Might want to restrict it to filter out null and empty string as well. But for now we know that Prisma errors only for undefined values when saving it in JSON field - // Also, it is possible that RAQB has some requirements to support null or empty string values. - if (value === undefined) return false; - return true; - }); - - if (!validValues.length) { - ctx.addIssue({ - code: z.ZodIssueCode.custom, - message: "Looks like you are trying to create a rule with no value", - }); - } + const validValues = flattenedValues.filter((value: unknown) => { + // Might want to restrict it to filter out null and empty string as well. But for now we know that Prisma errors only for undefined values when saving it in JSON field + // Also, it is possible that RAQB has some requirements to support null or empty string values. + if (value === undefined) return false; + return true; }); + + if (!validValues.length) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: "Looks like you are trying to create a rule with no value", + }); + } }); +}); const raqbQueryValueSchema = z.union([ z.object({ @@ -181,7 +178,6 @@ const raqbQueryValueSchema = z.union([ const zodAttributesQueryValue = raqbQueryValueSchema; - // Let's not import 118kb just to get an enum export enum Frequency { YEARLY = 0, @@ -810,6 +806,7 @@ export const allManagedEventTypeProps: { [k in keyof Omit