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
This commit is contained in:
sean-brydon
2025-12-12 10:53:01 -03:00
committed by GitHub
parent 71c9567169
commit b46fb82695
26 changed files with 733 additions and 121 deletions
@@ -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":
+1
View File
@@ -44,6 +44,7 @@ export const getEventTypesFromDB = async (id: number) => {
hideOrganizerEmail: true,
disableCancelling: true,
disableRescheduling: true,
minimumRescheduleNotice: true,
disableGuests: true,
timeZone: true,
profile: {
@@ -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,
});
@@ -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 && (
<>
<div
className={classNames(isRoundRobin && "min-w-32 min-h-24 relative mx-auto h-24 w-32")}>
className={classNames(isRoundRobin && "relative mx-auto h-24 min-h-24 w-32 min-w-32")}>
{isRoundRobin && bookingInfo.user && (
<Avatar
className="mx-auto flex items-center justify-center"
@@ -539,7 +550,7 @@ export default function Success(props: PageProps) {
(bookingInfo.status === BookingStatus.CANCELLED ||
bookingInfo.status === BookingStatus.REJECTED) && <h4>{paymentStatusMessage}</h4>}
<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">
<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">
{(isCancelled || reschedule) && cancellationReason && (
<>
<div className="font-medium">
@@ -810,47 +821,56 @@ export default function Success(props: PageProps) {
canCancelOrReschedule &&
(!isCancellationMode ? (
<>
<hr className="border-subtle mb-8" />
<div className="text-center last:pb-0">
<span className="text-emphasis ltr:mr-2 rtl:ml-2">
{t("need_to_make_a_change")}
</span>
{/* 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 && (
<span className="text-default inline">
<span className="underline" data-testid="reschedule-link">
<Link
href={`/reschedule/${seatReferenceUid || bookingInfo?.uid}${
currentUserEmail
? `?rescheduledBy=${encodeURIComponent(currentUserEmail)}`
: ""
}`}
legacyBehavior>
{t("reschedule")}
</Link>
</span>
{!isBookingInPast && canCancel && (
<span className="mx-2">{t("or_lowercase")}</span>
)}
</span>
)}
<hr className="border-subtle mb-8" />
<div className="text-center last:pb-0">
<span className="text-emphasis ltr:mr-2 rtl:ml-2">
{t("need_to_make_a_change")}
</span>
{!isBookingInPast && canCancel && (
<button
data-testid="cancel"
className={classNames(
"text-default underline",
props.recurringBookings && "ltr:mr-2 rtl:ml-2"
<>
{!props.recurringBookings &&
(!isBookingInPast || eventType.allowReschedulingPastBookings) &&
canReschedule &&
!isRescheduleDisabled && (
<span className="text-default inline">
<span className="underline" data-testid="reschedule-link">
<Link
href={`/reschedule/${seatReferenceUid || bookingInfo?.uid}${
currentUserEmail
? `?rescheduledBy=${encodeURIComponent(currentUserEmail)}`
: ""
}`}
legacyBehavior>
{t("reschedule")}
</Link>
</span>
{!isBookingInPast && canCancel && (
<span className="mx-2">{t("or_lowercase")}</span>
)}
</span>
)}
{!isBookingInPast && canCancel && (
<button
data-testid="cancel"
className={classNames(
"text-default underline",
props.recurringBookings && "ltr:mr-2 rtl:ml-2"
)}
onClick={() => setIsCancellationMode(true)}>
{t("cancel")}
</button>
)}
onClick={() => setIsCancellationMode(true)}>
{t("cancel")}
</button>
)}
</>
</div>
</>
</div>
)}
</>
) : (
<>
@@ -1067,7 +1087,7 @@ export default function Success(props: PageProps) {
</div>
{isGmail && !isFeedbackMode && (
<Alert
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"
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"
severity="warning"
message={
<div>
@@ -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</0>",
"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></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",
@@ -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) => ({
@@ -22,6 +22,7 @@ const getEventTypesFromDBSelect = {
restrictionScheduleId: true,
useBookerTimezone: true,
disableRescheduling: true,
minimumRescheduleNotice: true,
disableCancelling: true,
users: {
select: {
@@ -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<typeof createTestBooking>;
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);
});
});
});
@@ -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
@@ -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;
}
@@ -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<void> {
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: {
@@ -1133,6 +1133,14 @@ export class BookingRepository {
},
},
},
eventType: {
select: {
id: true,
minimumRescheduleNotice: true,
disableRescheduling: true,
userId: true,
},
},
destinationCalendar: true,
payment: true,
references: true,
@@ -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<FormValues>();
const currentMinimumRescheduleNotice = formMethods.watch("minimumRescheduleNotice");
const [minimumRescheduleNoticeValue, setMinimumRescheduleNoticeValue] = useState<number>(
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 (
<div className="block items-start sm:flex">
<div className="w-full">
<Controller
name="disabledRescheduling"
control={formMethods.control}
render={({ field: { onChange } }) => (
<SettingsToggle
labelClassName={classNames("text-sm", customClassNames?.label)}
toggleSwitchAtTheEnd={true}
switchContainerClassName={classNames(
"border-subtle rounded-lg border py-6 px-4 sm:px-6",
shouldShowRadioButtons && "rounded-b-none",
customClassNames?.container
)}
childrenClassName={classNames("lg:ml-0", customClassNames?.children)}
descriptionClassName={customClassNames?.description}
title={t("disable_rescheduling")}
data-testid="disable-rescheduling-toggle"
disabled={disableReschedulingLocked.disabled}
LockedIcon={disableReschedulingLocked.LockedIcon}
description={
<LearnMoreLink
t={t}
i18nKey="description_disable_rescheduling"
href="https://cal.com/help/event-types/disable-canceling-rescheduling#disable-rescheduling"
/>
}
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 && (
<div className="border-subtle rounded-b-lg border border-t-0 p-6">
<RadioGroup.Root
value={
disableRescheduling
? "always"
: currentMinimumRescheduleNotice !== null && currentMinimumRescheduleNotice > 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);
}}>
<div
className={classNames(
"flex flex-col flex-wrap justify-start gap-y-2",
customClassNames?.radioGroupContainer
)}>
<RadioField
label={t("always")}
disabled={minimumRescheduleNoticeLocked.disabled}
id="always"
value="always"
className={customClassNames?.alwaysRescheduleRadio}
/>
<RadioField
disabled={minimumRescheduleNoticeLocked.disabled}
className={classNames(
"items-center",
customClassNames?.conditionalRescheduleRadio?.container
)}
label={
<>
<ServerTrans
t={t}
i18nKey="when_less_than_minutes_before_meeting"
components={[
<div
key="when_less_than_minutes_before_meeting"
className="mx-2 inline-flex items-center">
<Input
type="number"
min={1}
disabled={minimumRescheduleNoticeLocked.disabled}
onChange={(evt) => {
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
}
/>
</div>,
]}
/>
</>
}
id="notice"
value="notice"
/>
</div>
</RadioGroup.Root>
</div>
)}
</SettingsToggle>
)}
/>
</div>
</div>
);
}
@@ -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 (
<div className="border-subtle stack-y-6 rounded-lg border p-6">
<div className="flex flex-col stack-y-4 lg:flex-row lg:space-x-4 lg:stack-y-0">
<div className="stack-y-4 lg:stack-y-0 flex flex-col lg:flex-row lg:space-x-4">
{showConnectedCalendarSettings && (
<div
className={classNames(
@@ -222,7 +225,7 @@ const destinationCalendarComponents = {
color="minimal"
size="sm"
aria-label="edit custom name"
className="hover:stroke-3 hover:text-emphasis min-w-fit py-0! px-0 hover:bg-transparent"
className="hover:stroke-3 hover:text-emphasis py-0! min-w-fit px-0 hover:bg-transparent"
onClick={() => setShowEventNameTip((old) => !old)}>
<Icon name="pencil" className="h-4 w-4" />
</Button>
@@ -301,7 +304,7 @@ const destinationCalendarComponents = {
DestinationCalendarSettingsSkeleton() {
return (
<div className="border-subtle stack-y-6 rounded-lg border p-6">
<div className="flex flex-col stack-y-4 lg:flex-row lg:space-x-4 lg:stack-y-0">
<div className="stack-y-4 lg:stack-y-0 flex flex-col lg:flex-row lg:space-x-4">
<div className="flex w-full flex-col">
<div className="bg-emphasis h-4 w-32 animate-pulse rounded-md" />
<div className="bg-emphasis mt-2 h-10 w-full animate-pulse rounded-md" />
@@ -614,7 +617,7 @@ export const EventAdvancedTab = ({
}, [isPlatform]);
return (
<div className="flex flex-col stack-y-4">
<div className="stack-y-4 flex flex-col">
<calendarComponents.CalendarSettings
verifiedSecondaryEmails={verifiedSecondaryEmails}
userEmail={userEmail}
@@ -643,7 +646,7 @@ export const EventAdvancedTab = ({
<div className="text-default text-sm font-semibold leading-none ltr:mr-1 rtl:ml-1">
{t("booking_questions_title")}
</div>
<p className="text-subtle mt-1 max-w-[280px] wrap-break-word text-sm sm:max-w-[500px]">
<p className="text-subtle wrap-break-word mt-1 max-w-[280px] text-sm sm:max-w-[500px]">
<LearnMoreLink
t={t}
i18nKey="booking_questions_description"
@@ -715,30 +718,11 @@ export const EventAdvancedTab = ({
)}
/>
<Controller
name="disableRescheduling"
render={({ field: { onChange } }) => (
<SettingsToggle
labelClassName="text-sm"
toggleSwitchAtTheEnd={true}
switchContainerClassName="border-subtle rounded-lg border py-6 px-4 sm:px-6"
title={t("disable_rescheduling")}
data-testid="disable-rescheduling-toggle"
{...disableReschedulingLocked}
description={
<LearnMoreLink
t={t}
i18nKey="description_disable_rescheduling"
href="https://cal.com/help/event-types/disable-canceling-rescheduling#disable-rescheduling"
/>
}
checked={disableRescheduling}
onCheckedChange={(val) => {
setDisableRescheduling(val);
onChange(val);
}}
/>
)}
<DisableReschedulingController
eventType={eventType}
disableRescheduling={disableRescheduling}
onDisableRescheduling={setDisableRescheduling}
customClassNames={customClassNames?.disableRescheduling}
/>
</>
)}
@@ -136,7 +136,7 @@ function RangeLimitRadioItem({
<RadioGroup.Item
id={radioValue}
value={radioValue}
className="min-w-4 bg-default border-default flex h-4 w-4 cursor-pointer items-center rounded-full border focus:border-2 focus:outline-none ltr:mr-2 rtl:ml-2">
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">
<RadioGroup.Indicator className="after:bg-inverted relative flex h-4 w-4 items-center justify-center after:block after:h-2 after:w-2 after:rounded-full" />
</RadioGroup.Item>
)}
@@ -145,7 +145,7 @@ function RangeLimitRadioItem({
<div>
<div
className={classNames(
"mr-2 ml-0 mt-2 w-full sm:ml-2 sm:mt-0 sm:w-auto",
"ml-0 mr-2 mt-2 w-full sm:ml-2 sm:mt-0 sm:w-auto",
customClassNames?.datePickerWraper
)}>
<Controller
@@ -214,7 +214,7 @@ function RollingLimitRadioItem({
<RadioGroup.Item
id={radioValue}
value={radioValue}
className="min-w-4 bg-default border-default flex h-4 w-4 cursor-pointer items-center rounded-full border focus:border-2 focus:outline-none ltr:mr-2 rtl:ml-2">
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">
<RadioGroup.Indicator className="after:bg-inverted relative flex h-4 w-4 items-center justify-center after:block after:h-2 after:w-2 after:rounded-full" />
</RadioGroup.Item>
)}
@@ -423,7 +423,7 @@ export const EventLimitsTab = ({ eventType, customClassNames }: EventLimitsTabPr
"border-subtle stack-y-6 rounded-lg border p-6",
customClassNames?.bufferAndNoticeSection?.container
)}>
<div className="flex flex-col stack-y-4 lg:flex-row lg:space-x-4 lg:stack-y-0">
<div className="stack-y-4 lg:stack-y-0 flex flex-col lg:flex-row lg:space-x-4">
<div
className={classNames(
"w-full",
@@ -517,7 +517,7 @@ export const EventLimitsTab = ({ eventType, customClassNames }: EventLimitsTabPr
/>
</div>
</div>
<div className="flex flex-col stack-y-4 lg:flex-row lg:space-x-4 lg:stack-y-0">
<div className="stack-y-4 lg:stack-y-0 flex flex-col lg:flex-row lg:space-x-4">
<div
className={classNames(
"w-full",
@@ -90,6 +90,7 @@ const commons = {
seatsShowAvailabilityCount: null,
disableCancelling: false,
disableRescheduling: false,
minimumRescheduleNotice: null,
onlyShowFirstAvailableSlot: false,
allowReschedulingPastBookings: false,
allowReschedulingCancelledBookings: false,
@@ -89,6 +89,7 @@ export const getPublicEventSelect = (fetchAllUsers: boolean) => {
seatsPerTimeSlot: true,
disableCancelling: true,
disableRescheduling: true,
minimumRescheduleNotice: true,
allowReschedulingCancelledBookings: true,
seatsShowAvailabilityCount: true,
bookingFields: true,
@@ -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)
@@ -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,
+1
View File
@@ -39,6 +39,7 @@ export const eventTypeSelect = {
disableGuests: true,
disableCancelling: true,
disableRescheduling: true,
minimumRescheduleNotice: true,
allowReschedulingCancelledBookings: true,
hideCalendarNotes: true,
minimumBookingNotice: true,
+1
View File
@@ -128,6 +128,7 @@ export const buildEventType = (eventType?: Partial<EventType>): EventType => {
seatsShowAttendees: null,
disableCancelling: false,
disableRescheduling: false,
minimumRescheduleNotice: null,
allowReschedulingCancelledBookings: false,
seatsShowAvailabilityCount: null,
maxLeadThreshold: null,
@@ -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,
@@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "public"."EventType" ADD COLUMN "minimumRescheduleNotice" INTEGER;
+2
View File
@@ -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?
+33 -36
View File
@@ -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<string, unknown> =>
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<string, unknown> =>
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<Prisma.EventTypeSelect
availability: true,
recurringEvent: true,
customInputs: true,
minimumRescheduleNotice: true,
disableGuests: true,
disableCancelling: true,
disableRescheduling: true,
@@ -530,6 +530,7 @@ export async function getBookings({
"EventType.hideOrganizerEmail",
"EventType.disableCancelling",
"EventType.disableRescheduling",
"EventType.minimumRescheduleNotice",
"EventType.teamId",
"EventType.parentId",
eb