Files
cal.diy/packages/platform/atoms/booker/BookerPlatformWrapper.tsx
T
6b62557a92 feat: add hostSubsetIds parameter for round robin host filtering (#25627)
* feat: add hostSubsetIds parameter for round robin host filtering

Add support for filtering round robin event type hosts via API v2.
When hostSubsetIds is provided, only the specified hosts are considered
for availability calculation and booking assignment.

Changes:
- Add hostSubsetIds to slots API input (GET /slots/available)
- Add hostSubsetIds to booking API input (POST /bookings)
- Update _findQualifiedHostsWithDelegationCredentials to filter by hostSubsetIds
- Pass hostSubsetIds through all layers: API -> tRPC -> slots/booking services

This allows API consumers to request availability and create bookings
for a subset of hosts within a round robin event type.

Co-Authored-By: morgan@cal.com <morgan@cal.com>

* chore: add e2e tests

* chore: add enableHostSubset team event-type setting

* fixup! chore: add enableHostSubset team event-type setting

* fix tests

* fix tests

* improve isWithinRRHostSubset

* rename to rrHost subset

* fix ai review

* fix: add booker platform wrapper rrHostSubsetIds prop

---------

Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2025-12-10 10:37:39 +00:00

609 lines
21 KiB
TypeScript

/* 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 { shallow } from "zustand/shallow";
import dayjs from "@calcom/dayjs";
import { Booker as BookerComponent } from "@calcom/features/bookings/Booker";
import {
BookerStoreProvider,
useInitializeBookerStoreContext,
useBookerStoreContext,
BookerStoreContext,
} from "@calcom/features/bookings/Booker/BookerStoreProvider";
import { useBookerLayout } from "@calcom/features/bookings/Booker/components/hooks/useBookerLayout";
import { useBookingForm } from "@calcom/features/bookings/Booker/components/hooks/useBookingForm";
import { useLocalSet } from "@calcom/features/bookings/Booker/components/hooks/useLocalSet";
import { usePrefetch } from "@calcom/features/bookings/Booker/components/hooks/usePrefetch";
import { useInitializeBookerStore } from "@calcom/features/bookings/Booker/store";
import { useTimePreferences } from "@calcom/features/bookings/lib";
import type { ConnectedDestinationCalendars } from "@calcom/features/calendars/lib/getConnectedDestinationCalendars";
import { getUsernameList } from "@calcom/features/eventtypes/lib/defaultEvents";
import { useTimesForSchedule } from "@calcom/features/schedules/lib/use-schedule/useTimesForSchedule";
import { getRoutedTeamMemberIdsFromSearchParams } from "@calcom/lib/bookings/getRoutedTeamMemberIdsFromSearchParams";
import { localStorage } from "@calcom/lib/webstorage";
import { BookerLayouts } from "@calcom/prisma/zod-utils";
import { useCreateBooking } from "../hooks/bookings/useCreateBooking";
import { useCreateInstantBooking } from "../hooks/bookings/useCreateInstantBooking";
import { useCreateRecurringBooking } from "../hooks/bookings/useCreateRecurringBooking";
import {
useGetBookingForReschedule,
QUERY_KEY as BOOKING_RESCHEDULE_KEY,
} from "../hooks/bookings/useGetBookingForReschedule";
import { useHandleBookEvent } from "../hooks/bookings/useHandleBookEvent";
import { useAtomGetPublicEvent } from "../hooks/event-types/public/useAtomGetPublicEvent";
import { useAtomsContext } from "../hooks/useAtomsContext";
import { useAvailableSlots } from "../hooks/useAvailableSlots";
import { useCalendarsBusyTimes } from "../hooks/useCalendarsBusyTimes";
import { useConnectedCalendars } from "../hooks/useConnectedCalendars";
import { useMe } from "../hooks/useMe";
import { useSlots } from "../hooks/useSlots";
import { useVerifyCode } from "../hooks/useVerifyCode";
import { useVerifyEmail } from "../hooks/useVerifyEmail";
import { AtomsWrapper } from "../src/components/atoms-wrapper";
import type {
BookerPlatformWrapperAtomPropsForIndividual,
BookerPlatformWrapperAtomPropsForTeam,
BookerStoreValues,
} from "./types";
const BookerPlatformWrapperComponent = (
props: BookerPlatformWrapperAtomPropsForIndividual | BookerPlatformWrapperAtomPropsForTeam
) => {
const {
view = "MONTH_VIEW",
bannerUrl,
routingFormSearchParams,
teamMemberEmail,
crmAppSlug,
crmOwnerRecordType,
preventEventTypeRedirect,
onBookerStateChange,
allowUpdatingUrlParams = false,
confirmButtonDisabled,
isBookingDryRun,
handleSlotReservation,
onTimeslotsLoaded,
startTime: customStartTime,
showNoAvailabilityDialog,
silentlyHandleCalendarFailures = false,
hideEventMetadata = false,
defaultPhoneCountry,
rrHostSubsetIds,
} = props;
const layout = BookerLayouts[view];
const { clientId } = useAtomsContext();
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 setBookingData = useBookerStoreContext((state) => state.setBookingData);
const setOrg = useBookerStoreContext((state) => state.setOrg);
const bookingData = useBookerStoreContext((state) => state.bookingData);
const setSelectedTimeslot = useBookerStoreContext((state) => state.setSelectedTimeslot);
const setSelectedMonth = useBookerStoreContext((state) => state.setMonth);
const selectedDuration = useBookerStoreContext((state) => state.selectedDuration);
const [isOverlayCalendarEnabled, setIsOverlayCalendarEnabled] = useState(
Boolean(localStorage?.getItem?.("overlayCalendarSwitchDefault"))
);
const prevStateRef = useRef<BookerStoreValues | null>(null);
const bookerStoreContext = useContext(BookerStoreContext);
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const getStateValues = useCallback((state: any): BookerStoreValues => {
return Object.fromEntries(
Object.entries(state).filter(([_, value]) => typeof value !== "function")
) as BookerStoreValues;
}, []);
const debouncedStateChange = useMemo(() => {
return debounce(
(currentStateValues: BookerStoreValues, callback: (values: BookerStoreValues) => void) => {
const prevState = prevStateRef.current;
const stateChanged = !prevState || JSON.stringify(prevState) !== JSON.stringify(currentStateValues);
if (stateChanged) {
callback(currentStateValues);
prevStateRef.current = currentStateValues;
}
},
50
);
}, []);
useEffect(() => {
if (!onBookerStateChange || !bookerStoreContext) return;
const unsubscribe = bookerStoreContext.subscribe((state) => {
const currentStateValues = getStateValues(state);
debouncedStateChange(currentStateValues, onBookerStateChange);
});
// Initial call with current state
const initialState = getStateValues(bookerStoreContext.getState());
onBookerStateChange(initialState);
prevStateRef.current = initialState;
return () => {
unsubscribe();
debouncedStateChange.cancel();
};
}, [onBookerStateChange, getStateValues, debouncedStateChange, bookerStoreContext]);
useGetBookingForReschedule({
uid: props.rescheduleUid ?? props.bookingUid ?? "",
onSuccess: (data) => {
setBookingData(data);
},
});
const queryClient = useQueryClient();
const username = useMemo(() => {
if (props.username) {
return formatUsername(props.username);
}
return "";
}, [props.username]);
useEffect(() => {
setSelectedDuration(props.duration ?? null);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [props.duration]);
useEffect(() => {
setOrg(props.entity?.orgSlug ?? null);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [props.entity?.orgSlug]);
const isDynamic = useMemo(() => {
return getUsernameList(username ?? "").length > 1;
}, [username]);
const event = useAtomGetPublicEvent({
username,
eventSlug: props.eventSlug,
isTeamEvent: props.isTeamEvent,
teamId,
selectedDuration,
});
const bookerLayout = useBookerLayout(event.data?.profile?.bookerLayouts);
useInitializeBookerStore({
...props,
teamMemberEmail,
crmAppSlug,
crmOwnerRecordType,
crmRecordId: props.crmRecordId,
eventId: event?.data?.id,
rescheduleUid: props.rescheduleUid ?? null,
bookingUid: props.bookingUid ?? null,
layout: layout,
org: props.entity?.orgSlug,
username,
bookingData,
isPlatform: true,
allowUpdatingUrlParams,
defaultPhoneCountry,
});
useInitializeBookerStoreContext({
...props,
teamMemberEmail,
crmAppSlug,
crmOwnerRecordType,
crmRecordId: props.crmRecordId,
eventId: event?.data?.id,
rescheduleUid: props.rescheduleUid ?? null,
bookingUid: props.bookingUid ?? null,
layout: layout,
org: props.entity?.orgSlug,
username,
bookingData,
isPlatform: true,
allowUpdatingUrlParams,
defaultPhoneCountry,
});
const [dayCount] = useBookerStoreContext((state) => [state.dayCount, state.setDayCount], shallow);
const selectedDate = useBookerStoreContext((state) => state.selectedDate);
const month = useBookerStoreContext((state) => state.month);
const eventSlug = useBookerStoreContext((state) => state.eventSlug);
const { data: session } = useMe();
const hasSession = !!session;
const { name: defaultName, guests: defaultGuests, ...restFormValues } = props.defaultFormValues ?? {};
const prefillFormParamName = useMemo(() => {
if (defaultName) {
return defaultName;
}
if (restFormValues.firstName) {
return `${restFormValues.firstName} ${restFormValues.lastName}`;
}
return null;
}, [defaultName, restFormValues]);
const prefillFormParams = useMemo(() => {
return {
name: prefillFormParamName,
guests: defaultGuests ?? [],
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [defaultName, defaultGuests]);
const extraOptions = useMemo(() => {
return restFormValues;
}, [restFormValues]);
const date = dayjs(selectedDate).format("YYYY-MM-DD");
const { prefetchNextMonth, monthCount } = usePrefetch({
date,
month,
bookerLayout,
bookerState,
});
const { timezone } = useTimePreferences();
const [calculatedStartTime, calculatedEndTime] = useTimesForSchedule({
month,
monthCount,
dayCount,
prefetchNextMonth,
selectedDate,
});
const startTime =
customStartTime && dayjs(customStartTime).isAfter(dayjs(calculatedStartTime))
? dayjs(customStartTime).toISOString()
: calculatedStartTime;
const endTime = calculatedEndTime;
const [routingParams, setRoutingParams] = useState<{
routedTeamMemberIds?: number[];
skipContactOwner?: boolean;
isBookingDryRun?: boolean;
}>({});
useEffect(() => {
const searchParams = routingFormSearchParams
? new URLSearchParams(routingFormSearchParams)
: new URLSearchParams(window.location.search);
const routedTeamMemberIds = getRoutedTeamMemberIdsFromSearchParams(searchParams);
const skipContactOwner = searchParams.get("cal.skipContactOwner") === "true";
const isBookingDryRun =
searchParams?.get("cal.isBookingDryRun")?.toLowerCase() === "true" ||
searchParams?.get("cal.sandbox")?.toLowerCase() === "true";
setRoutingParams({
...(skipContactOwner ? { skipContactOwner } : {}),
...(routedTeamMemberIds ? { routedTeamMemberIds } : {}),
...(isBookingDryRun ? { isBookingDryRun } : {}),
});
}, [routingFormSearchParams]);
const schedule = useAvailableSlots({
usernameList: getUsernameList(username),
eventTypeId: event?.data?.id ?? 0,
startTime,
endTime,
timeZone: timezone,
duration: selectedDuration ?? undefined,
rescheduleUid: props.rescheduleUid,
teamMemberEmail: teamMemberEmail ?? undefined,
...(props.isTeamEvent
? {
isTeamEvent: props.isTeamEvent,
teamId: teamId,
rrHostSubsetIds: rrHostSubsetIds,
}
: {}),
enabled:
Boolean(teamId || username) &&
Boolean(month) &&
Boolean(timezone) &&
!event?.isPending &&
event?.data?.id != null,
orgSlug: props.entity?.orgSlug ?? undefined,
eventTypeSlug: isDynamic ? "dynamic" : eventSlug || "",
_silentCalendarFailures: silentlyHandleCalendarFailures,
...routingParams,
});
useEffect(() => {
if (schedule.data && !schedule.isPending && !schedule.error && onTimeslotsLoaded) {
onTimeslotsLoaded(schedule.data.slots);
}
}, [schedule.data, schedule.isPending, schedule.error, onTimeslotsLoaded]);
const bookerForm = useBookingForm({
event: event?.data,
sessionEmail:
session?.data?.email && clientId
? session.data.email.replace(`+${clientId}`, "")
: session?.data?.email,
sessionUsername: session?.data?.username,
sessionName: session?.data?.username,
hasSession,
extraOptions: extraOptions ?? {},
prefillFormParams: prefillFormParams,
clientId,
});
const {
mutate: createBooking,
isPending: creatingBooking,
error: createBookingError,
isError: isCreateBookingError,
} = useCreateBooking({
onSuccess: (data) => {
if (data?.data?.isDryRun) {
props?.onDryRunSuccess?.();
}
schedule.refetch();
props.onCreateBookingSuccess?.(data);
if (!preventEventTypeRedirect && !!event?.data?.successRedirectUrl) {
window.location.href = event?.data?.successRedirectUrl;
}
},
onError: props.onCreateBookingError,
});
const {
mutate: createRecBooking,
isPending: creatingRecBooking,
error: createRecBookingError,
isError: isCreateRecBookingError,
} = useCreateRecurringBooking({
onSuccess: (data) => {
if (data?.data?.[0]?.isDryRun) {
props?.onDryRunSuccess?.();
}
schedule.refetch();
props.onCreateRecurringBookingSuccess?.(data);
if (event?.data?.successRedirectUrl) {
window.location.href = event?.data?.successRedirectUrl;
}
},
onError: props.onCreateRecurringBookingError,
});
const {
mutate: createInstantBooking,
isPending: creatingInstantBooking,
error: createInstantBookingError,
isError: isCreateInstantBookingError,
} = useCreateInstantBooking({
onSuccess: (data) => {
schedule.refetch();
props.onCreateInstantBookingSuccess?.(data);
},
onError: props.onCreateInstantBookingError,
});
const slots = useSlots(event, {
onReserveSlotSuccess: props.onReserveSlotSuccess,
onReserveSlotError: props.onReserveSlotError,
onDeleteSlotSuccess: props.onDeleteSlotSuccess,
onDeleteSlotError: props.onDeleteSlotError,
isBookingDryRun: props.isBookingDryRun ? props.isBookingDryRun : routingParams?.isBookingDryRun,
handleSlotReservation,
});
const verifyEmail = useVerifyEmail({
email: bookerForm.formEmail,
name: bookerForm.formName,
requiresBookerEmailVerification: event?.data?.requiresBookerEmailVerification,
onVerifyEmail: bookerForm.beforeVerifyEmail,
});
const verifyCode = useVerifyCode({
onSuccess: () => {
if (!bookerForm.formEmail) return;
verifyEmail.setVerifiedEmail(bookerForm.formEmail);
verifyEmail.setEmailVerificationModalVisible(false);
handleBookEvent();
},
});
const { data: connectedCalendars, isPending: fetchingConnectedCalendars } = useConnectedCalendars({
enabled: hasSession,
});
const calendars = connectedCalendars as ConnectedDestinationCalendars;
const { set, clearSet } = useLocalSet<{
credentialId: number;
externalId: string;
}>("toggledConnectedCalendars", []);
const [latestCalendarsToLoad, setLatestCalendarsToLoad] = useState(
Array.from(set).map((item) => ({
credentialId: item.credentialId,
externalId: item.externalId,
}))
);
const { data: overlayBusyDates } = useCalendarsBusyTimes({
loggedInUsersTz: timezone,
dateFrom: selectedDate,
dateTo: selectedDate,
calendarsToLoad: latestCalendarsToLoad,
onError: () => {
clearSet();
},
enabled: Boolean(hasSession && isOverlayCalendarEnabled && latestCalendarsToLoad?.length > 0),
});
const handleBookEvent = useHandleBookEvent({
event,
bookingForm: bookerForm.bookingForm,
hashedLink: props.hashedLink,
metadata: props.metadata ?? {},
handleBooking: props?.handleCreateBooking ?? createBooking,
handleInstantBooking: createInstantBooking,
handleRecBooking: props?.handleCreateRecurringBooking ?? createRecBooking,
locationUrl: props.locationUrl,
routingFormSearchParams,
isBookingDryRun: isBookingDryRun ?? routingParams?.isBookingDryRun,
...(props.isTeamEvent
? {
rrHostSubsetIds: rrHostSubsetIds,
}
: {}),
});
const onOverlaySwitchStateChange = useCallback(
(state: boolean) => {
setIsOverlayCalendarEnabled(state);
if (state) {
localStorage?.setItem("overlayCalendarSwitchDefault", "true");
} else {
localStorage?.removeItem("overlayCalendarSwitchDefault");
}
},
[setIsOverlayCalendarEnabled]
);
const selectedDateProp = useMemo(
() => dayjs(props.selectedDate).format("YYYY-MM-DD"),
[props.selectedDate]
);
useEffect(() => {
setSelectedDate({ date: selectedDateProp, omitUpdatingParams: true });
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [selectedDateProp]);
useEffect(() => {
// reset booker whenever it's unmounted
return () => {
slots.handleRemoveSlot();
setBookerState("loading");
setSelectedDate({ date: null });
setSelectedTimeslot(null);
setSelectedDuration(null);
setOrg(null);
setSelectedMonth(null);
if (props.rescheduleUid) {
// clean booking data from cache
queryClient.removeQueries({
queryKey: [BOOKING_RESCHEDULE_KEY, props.rescheduleUid],
exact: true,
});
setBookingData(null);
}
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
useEffect(() => {
if (isOverlayCalendarEnabled && view === "MONTH_VIEW") {
localStorage?.removeItem("overlayCalendarSwitchDefault");
}
setIsOverlayCalendarEnabled(Boolean(localStorage?.getItem?.("overlayCalendarSwitchDefault")));
}, [view, isOverlayCalendarEnabled]);
return (
<AtomsWrapper customClassName={props?.customClassNames?.atomsWrapper}>
<BookerComponent
timeZones={props.timeZones}
teamMemberEmail={teamMemberEmail}
crmAppSlug={crmAppSlug}
crmOwnerRecordType={crmOwnerRecordType}
customClassNames={props.customClassNames}
eventSlug={props.eventSlug}
username={username}
showNoAvailabilityDialog={showNoAvailabilityDialog}
entity={
event?.data?.entity ?? {
considerUnpublished: false,
orgSlug: undefined,
teamSlug: undefined,
name: undefined,
}
}
rescheduleUid={props.rescheduleUid ?? null}
rescheduledBy={props.rescheduledBy ?? null}
bookingUid={props.bookingUid ?? null}
isRedirect={false}
confirmButtonDisabled={confirmButtonDisabled}
fromUserNameRedirected=""
hasSession={hasSession}
onGoBackInstantMeeting={function (): void {
throw new Error("Function not implemented.");
}}
onConnectNowInstantMeeting={function (): void {
throw new Error("Function not implemented.");
}}
onOverlayClickNoCalendar={function (): void {
throw new Error("Function not implemented.");
}}
onClickOverlayContinue={function (): void {
throw new Error("Function not implemented.");
}}
onOverlaySwitchStateChange={onOverlaySwitchStateChange}
extraOptions={extraOptions ?? {}}
bookings={{
handleBookEvent: (timeSlot?: string) => {
handleBookEvent(timeSlot);
return;
},
expiryTime: undefined,
bookingForm: bookerForm.bookingForm,
bookerFormErrorRef: bookerForm.bookerFormErrorRef,
errors: {
hasDataErrors: isCreateBookingError || isCreateRecBookingError || isCreateInstantBookingError,
dataErrors: createBookingError || createRecBookingError || createInstantBookingError,
},
loadingStates: {
creatingBooking: creatingBooking,
creatingRecurringBooking: creatingRecBooking,
creatingInstantBooking: creatingInstantBooking,
},
instantVideoMeetingUrl: undefined,
}}
slots={slots}
calendars={{
overlayBusyDates: overlayBusyDates?.data,
isOverlayCalendarEnabled: isOverlayCalendarEnabled,
connectedCalendars: calendars?.connectedCalendars || [],
loadingConnectedCalendar: fetchingConnectedCalendars,
onToggleCalendar: (data) => {
const calendarsToLoad = Array.from(data ?? []);
setLatestCalendarsToLoad(calendarsToLoad);
return;
},
}}
verifyEmail={verifyEmail}
bookerForm={bookerForm}
event={event}
schedule={schedule}
orgBannerUrl={bannerUrl ?? event.data?.bannerUrl}
bookerLayout={{ ...bookerLayout, hideEventTypeDetails: hideEventMetadata }}
verifyCode={verifyCode}
isPlatform
hasValidLicense={true}
isBookingDryRun={isBookingDryRun ?? routingParams?.isBookingDryRun}
eventMetaChildren={props.eventMetaChildren}
roundRobinHideOrgAndTeam={props.roundRobinHideOrgAndTeam}
/>
</AtomsWrapper>
);
};
export const BookerPlatformWrapper = (
props: BookerPlatformWrapperAtomPropsForIndividual | BookerPlatformWrapperAtomPropsForTeam
) => {
return (
<BookerStoreProvider>
<BookerPlatformWrapperComponent {...props} />
</BookerStoreProvider>
);
};
export function formatUsername(username: string | string[]): string {
if (typeof username === "string") {
return username;
}
return username.join("+");
}