fix: Booker reschedule behaviour for org admin (#26530)

* fix: reschedule behaviour for org admin

* fix: use pbac for permissions check

---------

Co-authored-by: Peer Richelsen <peeroke@gmail.com>
Co-authored-by: Morgan <33722304+ThyMinimalDev@users.noreply.github.com>
This commit is contained in:
Rajiv Sahal
2026-01-13 18:12:32 +00:00
committed by GitHub
co-authored by Peer Richelsen Morgan
parent 36cf6e3703
commit f32ea8b4c5
2 changed files with 169 additions and 41 deletions
+70 -13
View File
@@ -1,8 +1,10 @@
import { bookingResponsesDbSchema } from "@calcom/features/bookings/lib/getBookingResponsesSchema";
import { PermissionCheckService } from "@calcom/features/pbac/services/permission-check.service";
import slugify from "@calcom/lib/slugify";
import type { PrismaClient } from "@calcom/prisma";
import prisma from "@calcom/prisma";
import type { Prisma } from "@calcom/prisma/client";
import { MembershipRole } from "@calcom/prisma/enums";
type BookingSelect = {
description: true;
@@ -24,7 +26,8 @@ function getResponsesFromOldBooking(
) {
const customInputs = rawBooking.customInputs || {};
const responses = Object.keys(customInputs).reduce((acc, label) => {
acc[slugify(label) as keyof typeof acc] = customInputs[label as keyof typeof customInputs];
acc[slugify(label) as keyof typeof acc] =
customInputs[label as keyof typeof customInputs];
return acc;
}, {});
return {
@@ -43,7 +46,11 @@ function getResponsesFromOldBooking(
};
}
async function getBooking(prisma: PrismaClient, uid: string, isSeatedEvent?: boolean) {
async function getBooking(
prisma: PrismaClient,
uid: string,
isSeatedEvent?: boolean
) {
const rawBooking = await prisma.booking.findUnique({
where: {
uid,
@@ -80,6 +87,7 @@ async function getBooking(prisma: PrismaClient, uid: string, isSeatedEvent?: boo
user: {
select: {
id: true,
username: true,
},
},
},
@@ -94,8 +102,12 @@ async function getBooking(prisma: PrismaClient, uid: string, isSeatedEvent?: boo
if (booking) {
// @NOTE: had to do this because Server side cant return [Object objects]
// probably fixable with json.stringify -> json.parse
booking["startTime"] = (booking?.startTime as Date)?.toISOString() as unknown as Date;
booking["endTime"] = (booking?.endTime as Date)?.toISOString() as unknown as Date;
booking["startTime"] = (
booking?.startTime as Date
)?.toISOString() as unknown as Date;
booking["endTime"] = (
booking?.endTime as Date
)?.toISOString() as unknown as Date;
}
return booking;
@@ -115,7 +127,9 @@ export const getBookingWithResponses = <
) => {
return {
...booking,
responses: isSeatedEvent ? booking.responses : booking.responses || getResponsesFromOldBooking(booking),
responses: isSeatedEvent
? booking.responses
: booking.responses || getResponsesFromOldBooking(booking),
} as Omit<T, "responses"> & { responses: Record<string, any> };
};
@@ -130,9 +144,15 @@ export const getBookingForReschedule = async (uid: string, userId?: number) => {
select: {
id: true,
userId: true,
user: {
select: {
organizationId: true,
},
},
eventType: {
select: {
seatsPerTimeSlot: true,
teamId: true,
hosts: {
select: {
userId: true,
@@ -152,7 +172,10 @@ export const getBookingForReschedule = async (uid: string, userId?: number) => {
// If no booking is found via the uid, it's probably a booking seat
// that its being rescheduled, which we query next.
let attendeeEmail: string | null = null;
let bookingSeatData: { description?: string; responses: Prisma.JsonValue } | null = null;
let bookingSeatData: {
description?: string;
responses: Prisma.JsonValue;
} | null = null;
if (!theBooking) {
const bookingSeat = await prisma.bookingSeat.findFirst({
where: {
@@ -175,7 +198,10 @@ export const getBookingForReschedule = async (uid: string, userId?: number) => {
},
});
if (bookingSeat) {
bookingSeatData = bookingSeat.data as unknown as { description?: string; responses: Prisma.JsonValue };
bookingSeatData = bookingSeat.data as unknown as {
description?: string;
responses: Prisma.JsonValue;
};
bookingSeatReferenceUid = bookingSeat.id;
rescheduleUid = bookingSeat.booking.uid;
attendeeEmail = bookingSeat.attendee.email;
@@ -185,14 +211,37 @@ export const getBookingForReschedule = async (uid: string, userId?: number) => {
// If we have the booking and not bookingSeat, we need to make sure the booking belongs to the userLoggedIn
// Otherwise, we return null here.
let hasOwnershipOnBooking = false;
if (theBooking && theBooking?.eventType?.seatsPerTimeSlot && bookingSeatReferenceUid === null) {
if (
theBooking &&
theBooking?.eventType?.seatsPerTimeSlot &&
bookingSeatReferenceUid === null
) {
const isOwnerOfBooking = theBooking.userId === userId;
const isHostOfEventType = theBooking?.eventType?.hosts.some((host) => host.userId === userId);
const isHostOfEventType = theBooking?.eventType?.hosts.some(
(host) => host.userId === userId
);
const isUserIdInBooking = theBooking.userId === userId;
if (!isOwnerOfBooking && !isHostOfEventType && !isUserIdInBooking) return null;
let hasOrgAccess = false;
if (userId && theBooking.user?.organizationId) {
const permissionCheckService = new PermissionCheckService();
hasOrgAccess = await permissionCheckService.checkPermission({
userId,
teamId: theBooking.user.organizationId,
permission: "booking.readOrgBookings",
fallbackRoles: [MembershipRole.OWNER, MembershipRole.ADMIN],
});
}
if (
!isOwnerOfBooking &&
!isHostOfEventType &&
!isUserIdInBooking &&
!hasOrgAccess
)
return null;
hasOwnershipOnBooking = true;
}
@@ -200,13 +249,19 @@ export const getBookingForReschedule = async (uid: string, userId?: number) => {
// and we return null here.
if (!theBooking && !rescheduleUid) return null;
const booking = await getBooking(prisma, rescheduleUid || uid, bookingSeatReferenceUid ? true : false);
const booking = await getBooking(
prisma,
rescheduleUid || uid,
bookingSeatReferenceUid ? true : false
);
if (!booking) return null;
if (bookingSeatReferenceUid) {
booking["description"] = bookingSeatData?.description ?? null;
booking["responses"] = bookingResponsesDbSchema.parse(bookingSeatData?.responses ?? {});
booking["responses"] = bookingResponsesDbSchema.parse(
bookingSeatData?.responses ?? {}
);
}
return {
...booking,
@@ -244,6 +299,7 @@ export const getBookingForSeatedEvent = async (uid: string) => {
user: {
select: {
id: true,
username: true,
},
},
},
@@ -293,6 +349,7 @@ export const getMultipleDurationValue = (
defaultValue: number
) => {
if (!multipleDurationConfig) return null;
if (multipleDurationConfig.includes(Number(queryDuration))) return Number(queryDuration);
if (multipleDurationConfig.includes(Number(queryDuration)))
return Number(queryDuration);
return defaultValue;
};
@@ -1,7 +1,14 @@
/* eslint-disable react-hooks/exhaustive-deps */
import { useQueryClient } from "@tanstack/react-query";
import debounce from "lodash/debounce";
import { useMemo, useEffect, useCallback, useState, useRef, useContext } from "react";
import {
useMemo,
useEffect,
useCallback,
useState,
useRef,
useContext,
} from "react";
import { shallow } from "zustand/shallow";
import dayjs from "@calcom/dayjs";
@@ -49,7 +56,9 @@ import type {
} from "./types";
const BookerPlatformWrapperComponent = (
props: BookerPlatformWrapperAtomPropsForIndividual | BookerPlatformWrapperAtomPropsForTeam
props:
| BookerPlatformWrapperAtomPropsForIndividual
| BookerPlatformWrapperAtomPropsForTeam
) => {
const {
view = "MONTH_VIEW",
@@ -75,19 +84,29 @@ const BookerPlatformWrapperComponent = (
const layout = BookerLayouts[view];
const { clientId } = useAtomsContext();
const teamId: number | undefined = props.isTeamEvent ? props.teamId : undefined;
const teamId: number | undefined = props.isTeamEvent
? props.teamId
: undefined;
const [_bookerState, setBookerState] = useBookerStoreContext(
(state) => [state.state, state.setState],
shallow
);
const setSelectedDate = useBookerStoreContext((state) => state.setSelectedDate);
const setSelectedDuration = useBookerStoreContext((state) => state.setSelectedDuration);
const setSelectedDate = useBookerStoreContext(
(state) => state.setSelectedDate
);
const setSelectedDuration = useBookerStoreContext(
(state) => state.setSelectedDuration
);
const setBookingData = useBookerStoreContext((state) => state.setBookingData);
const setOrg = useBookerStoreContext((state) => state.setOrg);
const bookingData = useBookerStoreContext((state) => state.bookingData);
const setSelectedTimeslot = useBookerStoreContext((state) => state.setSelectedTimeslot);
const setSelectedTimeslot = useBookerStoreContext(
(state) => state.setSelectedTimeslot
);
const setSelectedMonth = useBookerStoreContext((state) => state.setMonth);
const selectedDuration = useBookerStoreContext((state) => state.selectedDuration);
const selectedDuration = useBookerStoreContext(
(state) => state.selectedDuration
);
const [isOverlayCalendarEnabled, setIsOverlayCalendarEnabled] = useState(
Boolean(localStorage?.getItem?.("overlayCalendarSwitchDefault"))
@@ -102,9 +121,14 @@ const BookerPlatformWrapperComponent = (
}, []);
const debouncedStateChange = useMemo(() => {
return debounce(
(currentStateValues: BookerStoreValues, callback: (values: BookerStoreValues) => void) => {
(
currentStateValues: BookerStoreValues,
callback: (values: BookerStoreValues) => void
) => {
const prevState = prevStateRef.current;
const stateChanged = !prevState || JSON.stringify(prevState) !== JSON.stringify(currentStateValues);
const stateChanged =
!prevState ||
JSON.stringify(prevState) !== JSON.stringify(currentStateValues);
if (stateChanged) {
callback(currentStateValues);
@@ -132,7 +156,12 @@ const BookerPlatformWrapperComponent = (
unsubscribe();
debouncedStateChange.cancel();
};
}, [onBookerStateChange, getStateValues, debouncedStateChange, bookerStoreContext]);
}, [
onBookerStateChange,
getStateValues,
debouncedStateChange,
bookerStoreContext,
]);
useGetBookingForReschedule({
uid: props.rescheduleUid ?? props.bookingUid ?? "",
@@ -141,12 +170,18 @@ const BookerPlatformWrapperComponent = (
},
});
const queryClient = useQueryClient();
const username = useMemo(() => {
// when rescheduling, prefer the booking host's username from bookingData
// this ensures we fetch the correct event type even when an org admin reschedules
if (bookingData?.user?.username) {
return formatUsername(bookingData.user.username);
}
if (props.username) {
return formatUsername(props.username);
}
return "";
}, [props.username]);
}, [props.username, bookingData?.user?.username]);
useEffect(() => {
setSelectedDuration(props.duration ?? null);
@@ -205,7 +240,10 @@ const BookerPlatformWrapperComponent = (
allowUpdatingUrlParams,
defaultPhoneCountry,
});
const [dayCount] = useBookerStoreContext((state) => [state.dayCount, state.setDayCount], shallow);
const [dayCount] = useBookerStoreContext(
(state) => [state.dayCount, state.setDayCount],
shallow
);
const selectedDate = useBookerStoreContext((state) => state.selectedDate);
const month = useBookerStoreContext((state) => state.month);
@@ -213,7 +251,11 @@ const BookerPlatformWrapperComponent = (
const { data: session } = useMe();
const hasSession = !!session;
const { name: defaultName, guests: defaultGuests, ...restFormValues } = props.defaultFormValues ?? {};
const {
name: defaultName,
guests: defaultGuests,
...restFormValues
} = props.defaultFormValues ?? {};
const prefillFormParamName = useMemo(() => {
if (defaultName) {
@@ -247,7 +289,8 @@ const BookerPlatformWrapperComponent = (
});
const startTime =
customStartTime && dayjs(customStartTime).isAfter(dayjs(calculatedStartTime))
customStartTime &&
dayjs(customStartTime).isAfter(dayjs(calculatedStartTime))
? dayjs(customStartTime).toISOString()
: calculatedStartTime;
const endTime = calculatedEndTime;
@@ -263,8 +306,10 @@ const BookerPlatformWrapperComponent = (
? new URLSearchParams(routingFormSearchParams)
: new URLSearchParams(window.location.search);
const routedTeamMemberIds = getRoutedTeamMemberIdsFromSearchParams(searchParams);
const skipContactOwner = searchParams.get("cal.skipContactOwner") === "true";
const routedTeamMemberIds =
getRoutedTeamMemberIdsFromSearchParams(searchParams);
const skipContactOwner =
searchParams.get("cal.skipContactOwner") === "true";
const isBookingDryRun =
searchParams?.get("cal.isBookingDryRun")?.toLowerCase() === "true" ||
@@ -304,7 +349,12 @@ const BookerPlatformWrapperComponent = (
});
useEffect(() => {
if (schedule.data && !schedule.isPending && !schedule.error && onTimeslotsLoaded) {
if (
schedule.data &&
!schedule.isPending &&
!schedule.error &&
onTimeslotsLoaded
) {
onTimeslotsLoaded(schedule.data.slots);
}
}, [schedule.data, schedule.isPending, schedule.error, onTimeslotsLoaded]);
@@ -379,14 +429,17 @@ const BookerPlatformWrapperComponent = (
onReserveSlotError: props.onReserveSlotError,
onDeleteSlotSuccess: props.onDeleteSlotSuccess,
onDeleteSlotError: props.onDeleteSlotError,
isBookingDryRun: props.isBookingDryRun ? props.isBookingDryRun : routingParams?.isBookingDryRun,
isBookingDryRun: props.isBookingDryRun
? props.isBookingDryRun
: routingParams?.isBookingDryRun,
handleSlotReservation,
});
const verifyEmail = useVerifyEmail({
email: bookerForm.formEmail,
name: bookerForm.formName,
requiresBookerEmailVerification: event?.data?.requiresBookerEmailVerification,
requiresBookerEmailVerification:
event?.data?.requiresBookerEmailVerification,
onVerifyEmail: bookerForm.beforeVerifyEmail,
});
@@ -400,9 +453,10 @@ const BookerPlatformWrapperComponent = (
},
});
const { data: connectedCalendars, isPending: fetchingConnectedCalendars } = useConnectedCalendars({
enabled: hasSession,
});
const { data: connectedCalendars, isPending: fetchingConnectedCalendars } =
useConnectedCalendars({
enabled: hasSession,
});
const calendars = connectedCalendars as ConnectedDestinationCalendars;
const { set, clearSet } = useLocalSet<{
@@ -423,7 +477,11 @@ const BookerPlatformWrapperComponent = (
onError: () => {
clearSet();
},
enabled: Boolean(hasSession && isOverlayCalendarEnabled && latestCalendarsToLoad?.length > 0),
enabled: Boolean(
hasSession &&
isOverlayCalendarEnabled &&
latestCalendarsToLoad?.length > 0
),
});
const handleBookEvent = useHandleBookEvent({
@@ -490,7 +548,9 @@ const BookerPlatformWrapperComponent = (
if (isOverlayCalendarEnabled && view === "MONTH_VIEW") {
localStorage?.removeItem("overlayCalendarSwitchDefault");
}
setIsOverlayCalendarEnabled(Boolean(localStorage?.getItem?.("overlayCalendarSwitchDefault")));
setIsOverlayCalendarEnabled(
Boolean(localStorage?.getItem?.("overlayCalendarSwitchDefault"))
);
}, [view, isOverlayCalendarEnabled]);
return (
@@ -542,8 +602,14 @@ const BookerPlatformWrapperComponent = (
bookingForm: bookerForm.bookingForm,
bookerFormErrorRef: bookerForm.bookerFormErrorRef,
errors: {
hasDataErrors: isCreateBookingError || isCreateRecBookingError || isCreateInstantBookingError,
dataErrors: createBookingError || createRecBookingError || createInstantBookingError,
hasDataErrors:
isCreateBookingError ||
isCreateRecBookingError ||
isCreateInstantBookingError,
dataErrors:
createBookingError ||
createRecBookingError ||
createInstantBookingError,
},
loadingStates: {
creatingBooking: creatingBooking,
@@ -569,7 +635,10 @@ const BookerPlatformWrapperComponent = (
event={event}
schedule={schedule}
orgBannerUrl={bannerUrl ?? event.data?.bannerUrl}
bookerLayout={{ ...bookerLayout, hideEventTypeDetails: hideEventMetadata }}
bookerLayout={{
...bookerLayout,
hideEventTypeDetails: hideEventMetadata,
}}
verifyCode={verifyCode}
isPlatform
hasValidLicense={true}
@@ -582,7 +651,9 @@ const BookerPlatformWrapperComponent = (
};
export const BookerPlatformWrapper = (
props: BookerPlatformWrapperAtomPropsForIndividual | BookerPlatformWrapperAtomPropsForTeam
props:
| BookerPlatformWrapperAtomPropsForIndividual
| BookerPlatformWrapperAtomPropsForTeam
) => {
return (
<BookerStoreProvider>