diff --git a/packages/features/bookings/lib/get-booking.ts b/packages/features/bookings/lib/get-booking.ts index 21f089c6bd7faa..5f4b71e48a5de8 100644 --- a/packages/features/bookings/lib/get-booking.ts +++ b/packages/features/bookings/lib/get-booking.ts @@ -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 & { responses: Record }; }; @@ -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; }; diff --git a/packages/platform/atoms/booker/BookerPlatformWrapper.tsx b/packages/platform/atoms/booker/BookerPlatformWrapper.tsx index 2af5115df399a1..de826773ddf5e7 100644 --- a/packages/platform/atoms/booker/BookerPlatformWrapper.tsx +++ b/packages/platform/atoms/booker/BookerPlatformWrapper.tsx @@ -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 (