diff --git a/apps/web/components/booking/BookingListItem.tsx b/apps/web/components/booking/BookingListItem.tsx index 5d6bb9272c..81e52af8c0 100644 --- a/apps/web/components/booking/BookingListItem.tsx +++ b/apps/web/components/booking/BookingListItem.tsx @@ -20,6 +20,7 @@ import { BookingStatus, SchedulingType } from "@calcom/prisma/enums"; import { bookingMetadataSchema } from "@calcom/prisma/zod-utils"; import type { RouterInputs, RouterOutputs } from "@calcom/trpc/react"; import { trpc } from "@calcom/trpc/react"; +import type { Ensure } from "@calcom/types/utils"; import type { ActionType } from "@calcom/ui"; import { Badge, @@ -48,6 +49,7 @@ import { AddGuestsDialog } from "@components/dialog/AddGuestsDialog"; import { ChargeCardDialog } from "@components/dialog/ChargeCardDialog"; import { EditLocationDialog } from "@components/dialog/EditLocationDialog"; import { ReassignDialog } from "@components/dialog/ReassignDialog"; +import { RerouteDialog } from "@components/dialog/RerouteDialog"; import { RescheduleDialog } from "@components/dialog/RescheduleDialog"; type BookingListingStatus = RouterInputs["viewer"]["bookings"]["get"]["filters"]["status"]; @@ -65,9 +67,43 @@ type BookingItemProps = BookingItem & { }; }; -function BookingListItem(booking: BookingItemProps) { - const { userId, userTimeZone, userTimeFormat, userEmail } = booking.loggedInUser; +type ParsedBooking = ReturnType; +type TeamEvent = Ensure, "team">; +type TeamEventBooking = Omit & { + eventType: TeamEvent; +}; +type ReroutableBooking = Ensure; +function buildParsedBooking(booking: BookingItemProps) { + // The way we fetch bookings there could be eventType object even without an eventType, but id confirms its existence + const bookingEventType = booking.eventType.id + ? (booking.eventType as Ensure< + typeof booking.eventType, + // It would only ensure that the props are present, if they are optional in the original type. So, it is safe to assert here. + "id" | "length" | "title" | "slug" | "schedulingType" | "team" + >) + : null; + + const bookingMetadata = bookingMetadataSchema.parse(booking.metadata ?? null); + return { + ...booking, + eventType: bookingEventType, + metadata: bookingMetadata, + }; +} + +const isBookingReroutable = (booking: ParsedBooking): booking is ReroutableBooking => { + // We support only team bookings for now for rerouting + // Though `routedFromRoutingFormReponse` could be there for a non-team booking, we don't want to support it for now. + // Let's not support re-routing for a booking without an event-type for now. + // Such a booking has its event-type deleted and there might not be something to reroute to. + return !!booking.routedFromRoutingFormReponse && !!booking.eventType?.team; +}; + +function BookingListItem(booking: BookingItemProps) { + const parsedBooking = buildParsedBooking(booking); + + const { userId, userTimeZone, userTimeFormat, userEmail } = booking.loggedInUser; const { t, i18n: { language }, @@ -107,7 +143,7 @@ function BookingListItem(booking: BookingItemProps) { const paymentAppData = getPaymentAppData(booking.eventType); const location = booking.location as ReturnType; - const locationVideoCallUrl = bookingMetadataSchema.parse(booking?.metadata || {})?.videoCallUrl; + const locationVideoCallUrl = parsedBooking.metadata?.videoCallUrl; const { resolvedTheme, forcedTheme } = useGetTheme(); const hasDarkTheme = !forcedTheme && resolvedTheme === "dark"; @@ -191,6 +227,18 @@ function BookingListItem(booking: BookingItemProps) { setIsOpenRescheduleDialog(true); }, }, + ...(isBookingReroutable(parsedBooking) + ? [ + { + id: "reroute", + label: t("reroute"), + onClick: () => { + setRerouteDialogIsOpen(true); + }, + icon: "waypoints" as const, + }, + ] + : []), { id: "change_location", label: t("edit_location"), @@ -275,6 +323,7 @@ function BookingListItem(booking: BookingItemProps) { const [isOpenReassignDialog, setIsOpenReassignDialog] = useState(false); const [isOpenSetLocationDialog, setIsOpenLocationDialog] = useState(false); const [isOpenAddGuestsDialog, setIsOpenAddGuestsDialog] = useState(false); + const [rerouteDialogIsOpen, setRerouteDialogIsOpen] = useState(false); const setLocationMutation = trpc.viewer.bookings.editLocation.useMutation({ onSuccess: () => { showToast(t("location_updated"), "success"); @@ -360,6 +409,7 @@ function BookingListItem(booking: BookingItemProps) { phoneNumber: attendee.phoneNumber, }; }); + return ( <> + {isBookingReroutable(parsedBooking) && ( + + )} ); } @@ -722,7 +779,7 @@ const FirstAttendee = ({ className=" hover:text-blue-500" href={`mailto:${user.email}`} onClick={(e) => e.stopPropagation()}> - {user.name} + {user.name || user.email} ); }; diff --git a/apps/web/components/dialog/RerouteDialog.tsx b/apps/web/components/dialog/RerouteDialog.tsx new file mode 100644 index 0000000000..afec6be8aa --- /dev/null +++ b/apps/web/components/dialog/RerouteDialog.tsx @@ -0,0 +1,910 @@ +import { useMutation } from "@tanstack/react-query"; +import Link from "next/link"; +import { useRouter } from "next/navigation"; +import { useState } from "react"; +import { useEffect, useCallback } from "react"; +import type { z } from "zod"; + +import FormInputFields, { + FormInputFieldsSkeleton, +} from "@calcom/app-store/routing-forms/components/FormInputFields"; +import { getAbsoluteEventTypeRedirectUrl } from "@calcom/app-store/routing-forms/getEventTypeRedirectUrl"; +import { findMatchingRoute } from "@calcom/app-store/routing-forms/lib/processRoute"; +import { substituteVariables } from "@calcom/app-store/routing-forms/lib/substituteVariables"; +import { getUrlSearchParamsToForwardForReroute } from "@calcom/app-store/routing-forms/pages/routing-link/getUrlSearchParamsToForward"; +import type { FormResponse, LocalRoute } from "@calcom/app-store/routing-forms/types/types"; +import { RouteActionType } from "@calcom/app-store/routing-forms/zod"; +import dayjs from "@calcom/dayjs"; +import { createBooking } from "@calcom/features/bookings/lib/create-booking"; +import { useBookerUrl } from "@calcom/lib/hooks/useBookerUrl"; +import { useLocale } from "@calcom/lib/hooks/useLocale"; +import type { EventType, User, Team, Attendee, Booking as PrismaBooking } from "@calcom/prisma/client"; +import { SchedulingType } from "@calcom/prisma/enums"; +import type { bookingMetadataSchema } from "@calcom/prisma/zod-utils"; +import type { RouterOutputs } from "@calcom/trpc/react"; +import { trpc } from "@calcom/trpc/react"; +import type { Ensure } from "@calcom/types/utils"; +import { Dialog, DialogContent, DialogFooter, DialogHeader } from "@calcom/ui"; +import { Button, Tooltip } from "@calcom/ui"; +import { showToast } from "@calcom/ui/components/toast"; +import useRouterQuery from "@calcom/web/lib/hooks/useRouterQuery"; + +const enum ReroutingStatusEnum { + REROUTING_NOT_INITIATED = "not_initiated", + REROUTING_IN_PROGRESS = "in_progress", + REROUTING_COMPLETE = "complete", + REROUTING_FAILED = "failed", +} + +type ResponseWithForm = RouterOutputs["viewer"]["appRoutingForms"]["getResponseWithFormFields"]; + +type BookingToReroute = Pick & { + routedFromRoutingFormReponse: { + id: number; + }; + startTime: string; + metadata: z.infer; + attendees: Pick[]; + eventType: Pick & { + team: Pick | null; + }; + user: Pick | null; +}; + +type TeamEventTypeBookingToReroute = Omit & { + user: NonNullable; + eventType: Omit & { + team: Ensure, "slug">; + }; +}; + +type RerouteDialogProps = { + isOpenDialog: boolean; + setIsOpenDialog: (isOpen: boolean) => void; + booking: BookingToReroute; +}; + +type ReroutingState = + | { + type: "reschedule_to_same_event_new_tab" | "reschedule_to_different_event_new_tab"; + /** + * UID of the rescheduled booking + */ + newBooking: string | null; + /** + * window for the new tab that is opened for rerouting + */ + reschedulerWindow: Window; + error?: Error; + } + | { + type: "same_timeslot"; + newBooking: string | null; + reschedulerWindow: null; + error?: Error; + }; + +type TeamMemberMatchingAttributeLogic = { + id: number; + name: string | null; + email: string; +}; + +// We can't let user change email identifier field as that would require contact owner information from salesforce which we don't support yet. +// Also, this isn't a requirement as well to allow correcting the email +const fieldsThatCannotBeCorrected = ["email"]; + +function getEventTypeUrlsForTheChosenRoute({ + chosenRoute, + form, + reroutingFormResponses, + currentResponse, + teamMemberIdsMatchingAttributeLogic, + booking, + searchParams, +}: { + form: ResponseWithForm["form"]; + currentResponse: FormResponse; + reroutingFormResponses: Record>; + teamMemberIdsMatchingAttributeLogic: number[] | null; + chosenRoute: LocalRoute | null; + booking: TeamEventTypeBookingToReroute; + searchParams: URLSearchParams; +}) { + if (!chosenRoute) { + return null; + } + + const formFields = form.fields || []; + const routedFromRoutingFormReponseId = booking.routedFromRoutingFormReponse.id; + + const allURLSearchParams = getUrlSearchParamsToForwardForReroute({ + formResponse: currentResponse, + formResponseId: routedFromRoutingFormReponseId, + fields: formFields, + searchParams, + teamMembersMatchingAttributeLogic: teamMemberIdsMatchingAttributeLogic, + attributeRoutingConfig: chosenRoute.attributeRoutingConfig ?? null, + rescheduleUid: booking.uid, + reroutingFormResponses, + }); + + const eventFullSlug = substituteVariables(chosenRoute.action.value, currentResponse, formFields); + const eventBookingAbsoluteUrl = getAbsoluteEventTypeRedirectUrl({ + form, + eventTypeRedirectUrl: eventFullSlug, + allURLSearchParams, + isEmbed: false, + }); + + return { eventFullSlug, eventBookingAbsoluteUrl }; +} + +function rescheduleInNewTab({ url }: { url: string }) { + const reschedulerWindow = window.open(url, "_blank"); + + if (!reschedulerWindow) { + throw new Error("Failed to open new tab"); + } + + return reschedulerWindow; +} + +const getFullSlugForEvent = ( + eventType: Pick +) => { + return `team/${eventType.team.slug}/${eventType.slug}`; +}; + +function isBookingTimeslotInPast(booking: BookingToReroute) { + return dayjs(booking.startTime).isBefore(dayjs()); +} + +const useReroutingState = ({ isOpenDialog }: Pick) => { + const [value, setValue] = useState(null); + const state = value; + + // If dialog is closed but the rerouting window is still open, close it + if (!isOpenDialog && state?.reschedulerWindow) { + state.reschedulerWindow.close(); + setValue(null); + } + + useEffect(() => { + const checkInterval = setInterval(() => { + if (state?.reschedulerWindow?.closed) { + // Ensure that render happens again + setValue(null); + clearInterval(checkInterval); + } + }, 1); + + return () => clearInterval(checkInterval); + }, [value, setValue]); + + const status = (() => { + if (!value) return ReroutingStatusEnum.REROUTING_NOT_INITIATED; + if (value.error) return ReroutingStatusEnum.REROUTING_FAILED; + if (!!value.newBooking) return ReroutingStatusEnum.REROUTING_COMPLETE; + return ReroutingStatusEnum.REROUTING_IN_PROGRESS; + })(); + + return { value: state, setValue, status }; +}; + +// const useUpdateIsReroutingQueryParam = ({ isOpenDialog }: { isOpenDialog: boolean }) => { +// const { setQuery: setIsReroutingQuery } = useRouterQuery("isRerouting"); +// useEffect(() => { +// if (isOpenDialog) { +// setIsReroutingQuery("true"); +// } else { +// setIsReroutingQuery(undefined); +// } +// }, [isOpenDialog]); +// }; + +const gotoBookingPage = ({ + booking, + router, +}: { + booking: Pick; + router: ReturnType; +}) => { + // TODO: Do we need to send other params as well? + router.push(`/booking/${booking.uid}?cal.rerouting=true`); +}; + +const useEventListeners = ({ reroutingState }: { reroutingState: ReturnType }) => { + const router = useRouter(); + const { t } = useLocale(); + const messageListener = useCallback( + (event: MessageEvent) => { + if (event.data.type !== "CAL:rescheduleBookingSuccessfulV2") { + return; + } + const calEventData = event.data.data; + showToast(t("rerouted_booking_successfully_redirecting_to_booking_page"), "success"); + if (!calEventData.uid) { + console.error("Booking UID is not there"); + throw new Error(t("something_went_wrong")); + } + const newBookingId = calEventData.uid; + reroutingState.setValue((prev) => { + if (!prev) return null; + return { ...prev, newBooking: newBookingId }; + }); + gotoBookingPage({ router, booking: { uid: newBookingId } }); + reroutingState.value?.reschedulerWindow?.close(); + }, + [reroutingState, reroutingState.value?.reschedulerWindow] + ); + + // Make sure to close the rescheduler window when this tab closes/reloads + const beforeUnloadListener = useCallback(() => { + reroutingState.value?.reschedulerWindow?.close(); + reroutingState.setValue(null); + }, [reroutingState.value?.reschedulerWindow]); + + // Ensure listeners are added once the component is mounted and removed once the component is unmounted + useEffect(() => { + window.addEventListener("message", messageListener); + window.addEventListener("beforeunload", beforeUnloadListener); + return () => { + window.removeEventListener("message", messageListener); + window.removeEventListener("beforeunload", beforeUnloadListener); + }; + // messageListener and beforeUnloadListener are memoized using useCallback, so the reference remains stable + }, [messageListener, beforeUnloadListener]); +}; + +const NewRoutingManager = ({ + chosenRoute, + booking, + form, + currentResponse, + teamMembersMatchingAttributeLogic, + reroutingState, +}: { + chosenRoute: LocalRoute; + booking: TeamEventTypeBookingToReroute; + form: ResponseWithForm["form"]; + currentResponse: FormResponse; + teamMembersMatchingAttributeLogic: { + isPending: boolean; + data: TeamMemberMatchingAttributeLogic[] | null; + }; + reroutingState: ReturnType; +}) => { + const { t } = useLocale(); + const router = useRouter(); + const bookerUrl = useBookerUrl(); + const teamMemberIdsMatchingAttributeLogic = + teamMembersMatchingAttributeLogic?.data?.map((member) => member.id) || null; + const routedFromRoutingFormReponseId = booking.routedFromRoutingFormReponse.id; + + const bookingEventType = booking.eventType; + + // Provide a reason for rescheduling that can be customized by user in the future + // const rescheduleReason = "Rerouted"; + const isRoundRobinScheduling = bookingEventType.schedulingType === SchedulingType.ROUND_ROBIN; + const reroutingFormResponses = Object.fromEntries( + Object.entries(currentResponse).map(([key, response]) => [key, { value: response.value }]) + ); + + const chosenEventUrls = getEventTypeUrlsForTheChosenRoute({ + chosenRoute, + form, + reroutingFormResponses, + teamMemberIdsMatchingAttributeLogic, + booking, + currentResponse, + searchParams: new URLSearchParams({ + // rescheduleReason + }), + }); + + const chosenEventTypeId = chosenRoute.action.eventTypeId; + const enableChosenEventTypeQuery = !!chosenEventTypeId; + // TODO: Get bare minimum eventType details + const { data: chosenEventTypeData, isPending: isChosenEventTypePending } = + trpc.viewer.eventTypes.get.useQuery( + // enabled prop ensures that the query is not run if the chosenEventTypeId is not there + { id: chosenEventTypeId! }, + { + enabled: enableChosenEventTypeQuery, + } + ); + + // isPending of the query is true even if enabled=false and no request was sent. So, identify if the request is actually in progress + const isChosenEventTypeRequestInProgress = enableChosenEventTypeQuery ? isChosenEventTypePending : false; + + const chosenEventType = chosenEventTypeData?.eventType; + + const currentBookingEventFullSlug = getFullSlugForEvent(booking.eventType); + const isReroutingToDifferentEvent = currentBookingEventFullSlug !== chosenEventUrls?.eventFullSlug; + + const createBookingMutation = useMutation({ + mutationFn: createBooking, + onSuccess: (booking) => { + showToast(t("rerouted_booking_successfully_redirecting_to_booking_page"), "success"); + if (!booking.uid) { + console.error("Booking UID is not there"); + throw new Error(t("something_went_wrong")); + } + + const newBookingId = booking.uid; + reroutingState.setValue((prev) => { + if (!prev) return null; + return { + ...prev, + newBooking: newBookingId, + }; + }); + gotoBookingPage({ router, booking: { uid: newBookingId } }); + }, + onError: (err, _, ctx) => { + reroutingState.setValue({ + error: err, + newBooking: null, + type: "same_timeslot", + reschedulerWindow: null, + }); + showToast( + "Looks like the timeslot is not available for the new team members. Try rescheduling with different timeslot", + "error" + ); + }, + }); + + useEventListeners({ reroutingState }); + + if (!chosenRoute) return null; + + const isReroutingInNewTab = + reroutingState.value?.type === "reschedule_to_same_event_new_tab" || + reroutingState.value?.type === "reschedule_to_different_event_new_tab"; + + return ( +
+

{t("new_routing_status")}

+
+ {reroutingState.status === ReroutingStatusEnum.REROUTING_NOT_INITIATED && reroutingPreview()} + {(reroutingState.status === ReroutingStatusEnum.REROUTING_NOT_INITIATED || !isReroutingInNewTab) && + reroutingCTAs()} + {isReroutingInNewTab && newTabReroutingStatus()} +
+
+ ); + + function rescheduleToSameTimeslotOfEvent({ + eventType: { id: eventTypeId, slug: eventTypeSlug, team: eventTypeTeam, length: eventTypeLength }, + }: { + eventType: Pick & { + team: { + slug: string | null; + } | null; + }; + }) { + if (!chosenRoute) { + console.error("Chosen route must be there for rerouting"); + throw new Error(t("something_went_wrong")); + } + + if (!eventTypeTeam?.slug) { + console.error("Event type team slug must be there for rerouting"); + throw new Error(t("something_went_wrong")); + } + + const teamSlug = eventTypeTeam.slug; + + if (isBookingTimeslotInPast(booking)) { + showToast("You cannot reschedule to a past timeslot", "error"); + return; + } + const booker = booking.attendees[0]; + const getFieldsThatRemainSame = () => { + return { + responses: booking.responses, + timeZone: booker.timeZone, + language: booker.locale || "en", + metadata: booking.metadata || {}, + ...getBookingSeatFields(), + ...getHashedLinkFields(), + }; + + function getBookingSeatFields() { + // During rerouting, do all the seats need to be re-routed? + // Let's not support re-routing for booking with seats for now. + return { + // bookingUid: booking.uid, + // seatReferenceUid: booking.eventType, + }; + } + + function getHashedLinkFields() { + // After booking hashedLink is deleted, so I don't believe we need to return anything here. + // Also, Rerouting is for a booking that was routed and routing doesn't involve hashed link even if it is enabled for the event. + return {}; + } + }; + + const getFieldFromEventTypeThatRemainSame = () => { + return { + user: teamSlug, + eventTypeId: eventTypeId, + eventTypeSlug: eventTypeSlug, + }; + }; + + const getTimeslotFields = () => { + return { + // It is possible that EvenType changed its duration, so we would consider that as well. + start: dayjs(booking.startTime).format(), + end: dayjs(booking.startTime) + // Defaults to the default event length in case no custom duration is set. + .add(eventTypeLength, "minute") + .format(), + }; + }; + + const getRoutingFormRelatedFields = () => { + return { + routedTeamMemberIds: teamMemberIdsMatchingAttributeLogic, + routingFormResponseId: routedFromRoutingFormReponseId, + skipContactOwner: chosenRoute.attributeRoutingConfig?.skipContactOwner, + }; + }; + + const getSalesforceContactOwnerFields = () => { + // Email field won't need to be corrected, so we don't need to support it. + return {}; + }; + + // TODO: Long term, we should refactor handleNewBooking and use a different route specific for this purpose, + createBookingMutation.mutate({ + rescheduleUid: booking.uid, + // rescheduleReason, + reroutingFormResponses: reroutingFormResponses, + ...getTimeslotFields(), + ...getFieldsThatRemainSame(), + ...getFieldFromEventTypeThatRemainSame(), + ...getRoutingFormRelatedFields(), + ...getSalesforceContactOwnerFields(), + }); + + reroutingState.setValue({ + newBooking: null, + type: "same_timeslot", + reschedulerWindow: null, + }); + } + + function rescheduleToSameTimeslotOfSameEvent() { + return rescheduleToSameTimeslotOfEvent({ + eventType: booking.eventType, + }); + } + + function rescheduleToSameTimeslotOfChosenEvent() { + if (!chosenEventTypeId && isReroutingToDifferentEvent) { + showToast( + "We don't have ID for the new event. Please go to the Routing Form and save it again.", + "error" + ); + return; + } + if (!chosenEventType) { + // There are disabled/loading state on button to ensure that this function is not called if chosenEventType is not there + console.error("Chosen event type must be there for rerouting"); + throw new Error(t("something_went_wrong")); + } + + return rescheduleToSameTimeslotOfEvent({ + eventType: chosenEventType, + }); + } + + function rescheduleSameEventInNewTab() { + if (!chosenEventUrls?.eventBookingAbsoluteUrl) { + console.error("URL must be there for opening new tab"); + throw new Error(t("something_went_wrong")); + } + + const reschedulerWindow = rescheduleInNewTab({ + url: chosenEventUrls.eventBookingAbsoluteUrl, + }); + + reroutingState.setValue({ + type: "reschedule_to_same_event_new_tab", + reschedulerWindow, + newBooking: null, + }); + } + + function rescheduleDifferentEventInNewTab() { + if (!chosenEventUrls?.eventBookingAbsoluteUrl) { + console.error("URL must be there for opening new tab"); + throw new Error(t("something_went_wrong")); + } + const reschedulerWindow = rescheduleInNewTab({ + url: chosenEventUrls.eventBookingAbsoluteUrl, + }); + + console.log("SETTING NEW VALUE", { + type: "reschedule_to_different_event_new_tab", + reschedulerWindow, + newBooking: null, + }); + reroutingState.setValue({ + type: "reschedule_to_different_event_new_tab", + reschedulerWindow, + newBooking: null, + }); + + console.log("VALUE NOW", JSON.stringify(reroutingState)); + } + + function reroutingPreview() { + if (chosenRoute.action.type === RouteActionType.CustomPageMessage) { + return {t("reroute_preview_custom_message")}; + } + if (chosenRoute.action.type === RouteActionType.ExternalRedirectUrl) { + return ( + + {t("reroute_preview_external_redirect", { externalUrl: chosenRoute.action.value })} + + ); + } + if (chosenRoute.action.type === RouteActionType.EventTypeRedirectUrl) { + // Computed value for eventTypeSlugToRedirect + const eventTypeSlugToRedirect = + chosenRoute.action.type === RouteActionType.EventTypeRedirectUrl ? chosenRoute.action.value : null; + + if (!eventTypeSlugToRedirect) { + console.error("Event type slug to redirect must be there if action is EventTypeRedirectUrl"); + throw new Error(t("something_went_wrong")); + } + return ( +
+ + {t("event_type")}:{" "} + + {eventTypeSlugToRedirect} + + + + + + {isRoundRobinScheduling ? t("reroute_preview_possible_host") : t("hosts")}: + + {" "} + {bookingHosts()} + +
+ ); + } + + function bookingHosts() { + if (teamMembersMatchingAttributeLogic.isPending) return t("loading"); + + const hostEmails = teamMembersMatchingAttributeLogic.data?.map((member) => member.email).join(", "); + return hostEmails || t("no_matching_members"); + } + return null; + } + + function reroutingCTAs() { + if(chosenRoute.action.type !== RouteActionType.EventTypeRedirectUrl) { + // There are no actions to perform if the new chosen route isn't an event type redirect + return null + } + const shouldDisableCTAs = teamMembersMatchingAttributeLogic.isPending || createBookingMutation.isPending; + return ( +
+ {!isReroutingToDifferentEvent ? ( + + ) : ( + + )} + {!isReroutingToDifferentEvent ? ( + + ) : ( + + )} +
+ ); + } + + function newTabReroutingStatus() { + if (reroutingState.status !== ReroutingStatusEnum.REROUTING_IN_PROGRESS) { + return null; + } + return ( +
+ + Continue with rerouting in the new{" "} + reroutingState.value?.reschedulerWindow?.focus()}> + tab + + +
+ ); + } +}; + +const CurrentRoutingStatus = ({ + booking, +}: { + booking: Pick; +}) => { + const fullSlug = getFullSlugForEvent(booking.eventType); + const { t } = useLocale(); + const bookerUrl = useBookerUrl(); + if (!fullSlug) return null; + return ( +
+

{t("current_routing_status")}

+
+ + {t("event_type")}:{" "} + + {fullSlug} + + + + {t("organizer")}: {booking.user.email} + + + {t("attendees")}:{" "} + {booking.attendees.map((attendee) => attendee.email).join(", ")} + +
+
+ ); +}; + +const RerouteDialogContentAndFooter = ({ + booking, + isOpenDialog, + setIsOpenDialog, +}: Pick & { + booking: TeamEventTypeBookingToReroute; +}) => { + const { data: responseWithForm, isPending: isRoutingFormLoading } = + trpc.viewer.appRoutingForms.getResponseWithFormFields.useQuery({ + formResponseId: booking.routedFromRoutingFormReponse.id, + }); + + const { t } = useLocale(); + // useUpdateIsReroutingQueryParam({ isOpenDialog }); + if (isRoutingFormLoading) + return ( + <> + + + + + + + ); + + if (!responseWithForm) return
{t("something_went_wrong")}
; + + return ( + + ); +}; + +const RerouteDialogContentAndFooterWithFormResponse = ({ + responseWithForm, + booking, + isOpenDialog, + setIsOpenDialog, +}: Pick & { + responseWithForm: ResponseWithForm; + booking: TeamEventTypeBookingToReroute; +}) => { + const form = responseWithForm.form; + const { t } = useLocale(); + const router = useRouter(); + const [responseFromOrganizer, setResponseFromOrganizer] = useState({}); + const isResponseFromOrganizerUnpopulated = Object.keys(responseFromOrganizer).length === 0; + const currentResponse = isResponseFromOrganizerUnpopulated + ? responseWithForm.response + : responseFromOrganizer; + const [chosenRoute, setChosenRoute] = useState(null); + + const reroutingState = useReroutingState({ + isOpenDialog, + }); + + const [teamMembersMatchingAttributeLogic, setTeamMembersMatchingAttributeLogic] = useState< + | { + id: number; + name: string | null; + email: string; + }[] + | null + >([]); + + const findTeamMembersMatchingAttributeLogicMutation = + trpc.viewer.appRoutingForms.findTeamMembersMatchingAttributeLogic.useMutation({ + onSuccess(data) { + setTeamMembersMatchingAttributeLogic(data); + }, + }); + + function verifyRoute() { + // Reset all states + reroutingState.setValue(null); + setTeamMembersMatchingAttributeLogic(null); + + const route = findMatchingRoute({ + form, + response: currentResponse, + }); + + setChosenRoute(route || null); + + if (!route) return; + + findTeamMembersMatchingAttributeLogicMutation.mutate({ + formId: form.id, + response: currentResponse, + routeId: route.id, + }); + } + + return ( +
+
+ + {form.name} + + +
+ + {chosenRoute && ( + + )} +
+
+ {renderFooter()} +
+ ); + + function renderFooter() { + if (reroutingState.status !== ReroutingStatusEnum.REROUTING_COMPLETE) { + return ( + + + + + ); + } + + if (reroutingState.status === ReroutingStatusEnum.REROUTING_COMPLETE) { + return ( + + + + ); + } + return null; + } +}; + +export const RerouteDialog = ({ isOpenDialog, setIsOpenDialog, booking }: RerouteDialogProps) => { + const { t } = useLocale(); + const bookingEventType = booking.eventType; + + if (!bookingEventType.team) { + console.error("Only team event can be re-routed"); + throw new Error(t("something_went_wrong")); + } + + if (!bookingEventType.team.slug) { + console.error("Team slug must be there for rerouting"); + throw new Error(t("something_went_wrong")); + } + + if (!booking.user) { + // Not a hard requirement, but better to be strict with data. + console.error("Booking must have a user"); + throw new Error(t("something_went_wrong")); + } + + const teamEventTypeBooking = { + ...booking, + user: booking.user, + eventType: { + ...bookingEventType, + team: { + ...bookingEventType.team, + slug: bookingEventType.team.slug, + }, + }, + }; + + return ( + + + + + + + ); +}; diff --git a/apps/web/components/dialog/__tests__/RerouteDialog.test.tsx b/apps/web/components/dialog/__tests__/RerouteDialog.test.tsx new file mode 100644 index 0000000000..0cebf9ecee --- /dev/null +++ b/apps/web/components/dialog/__tests__/RerouteDialog.test.tsx @@ -0,0 +1,497 @@ +import { act, fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { useRouter } from "next/navigation"; +import { vi } from "vitest"; + +import { RouteActionType } from "@calcom/app-store/routing-forms/zod"; +import { BookingStatus, SchedulingType } from "@calcom/prisma/enums"; + +import { RerouteDialog } from "../RerouteDialog"; + +const mockRouter = { + push: vi.fn((path: string) => { + return; + }), +}; + +vi.mock("next/navigation", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + useRouter: vi.fn(() => mockRouter), + }; +}); + +vi.mock("@calcom/app-store/routing-forms/lib/processRoute", () => ({ + findMatchingRoute: vi.fn(({ form, response }) => { + return form.routes.find((route: any) => route.__testMatching); + }), +})); + +const fakeNewTabWindow = { + close: vi.fn(), +}; + +const mockOpen = vi.fn((_url: string) => { + return fakeNewTabWindow; +}); + +vi.stubGlobal("open", mockOpen); + +vi.mock("@calcom/app-store/routing-forms/components/FormInputFields", () => ({ + default: vi.fn(({ response, form, setResponse, disabledFields }) => { + return ( +
+ {form.fields?.map((field: any) => ( +
+ +
{response[field.id]?.value}
+
+ ))} +
{disabledFields.join(", ")}
+ +
+ ); + }), + FormInputFieldsSkeleton: vi.fn(() =>
), +})); + +vi.mock("@calcom/lib/hooks/useLocale", () => ({ + useLocale: vi.fn(() => ({ t: (key: string) => key })), +})); + +vi.mock("@calcom/web/lib/hooks/useRouterQuery", () => ({ + default: vi.fn(() => { + return { + setQuery: vi.fn(), + }; + }), +})); + +vi.mock("@calcom/ui", async (importOriginal) => { + // @ts-ignore + const actual = await importOriginal("@calcom/ui"); + return { + ...actual, + Tooltip: vi.fn(({ children }) =>
{children}
), + }; +}); + +vi.mock("@calcom/trpc/react", () => ({ + trpc: { + viewer: { + appRoutingForms: { + getResponseWithFormFields: { + useQuery: vi.fn(() => ({ + data: { + form: { + id: "form-id", + name: "Test Form", + fields: [ + { + id: "company-size", + label: "Company Size", + type: "select", + options: [ + { label: "Small", value: "small" }, + { label: "Medium", value: "medium" }, + { label: "Large", value: "large" }, + ], + }, + { + id: "country", + label: "Country", + type: "select", + options: [ + { label: "USA", value: "usa" }, + { label: "Canada", value: "canada" }, + { label: "UK", value: "uk" }, + ], + }, + { + id: "email", + label: "Email", + type: "email", + required: true, + }, + ], + routes: [ + { + id: "mock-route-id", + action: { + eventTypeId: 123, + type: RouteActionType.EventTypeRedirectUrl, + value: "team/test-team/new-test-event", + }, + __testMatching: true, + }, + { + id: "fallback-route", + isFallback: true, + action: { + type: RouteActionType.CustomPageMessage, + value: "456", + }, + __testMatching: false, + }, + ], + }, + response: { + "company-size": { + label: "Company Size", + value: "small", + }, + country: { + label: "Country", + value: "usa", + }, + }, + }, + isPending: false, + })), + }, + findTeamMembersMatchingAttributeLogic: { + useMutation: vi.fn(({ onSuccess }) => { + return { + mutate: vi.fn(() => { + onSuccess([ + { + id: 1, + name: "Matching User 1", + email: "matching-user-1@example.com", + }, + ]); + }), + }; + }), + }, + }, + eventTypes: { + get: { + useQuery: vi.fn(() => ({ + data: { + eventType: { + id: "123", + title: "Mocked Event Type", + slug: "mocked-event-type", + length: 30, + schedulingType: "ROUND_ROBIN", + team: { slug: "mocked-team" }, + }, + }, + isPending: false, + })), + }, + }, + }, + }, +})); + +let mockMutateFn = vi.fn(({ __testOnSuccess }) => { + __testOnSuccess({ + uid: "RESCHEDULED_BOOKING_UID_SAME_TIMESLOT", + }); +}); + +vi.mock("@tanstack/react-query", () => ({ + useMutation: vi.fn(({ onSuccess }) => { + return { + mutate: vi.fn((payload) => { + mockMutateFn({ + ...payload, + __testOnSuccess: onSuccess, + }); + }), + isLoading: false, + isError: false, + error: null, + onSuccess: onSuccess, + }; + }), +})); + +async function mockMessageFromOpenedTab({ type, data }: { type: string; data: any }) { + const messageReceivedPromise = new Promise((resolve) => { + window.addEventListener("message", () => { + resolve(true); + }); + }); + window.postMessage( + { + type, + data, + }, + "*" + ); + + return messageReceivedPromise; +} + +function correctResponses() { + fireEvent.click(screen.getByTestId("mock-form-update-response-button")); +} + +function expectEventTypeInfoInCurrentRouting({ + eventTypeText, + eventTypeHref, +}: { + eventTypeText: string; + eventTypeHref: string; +}) { + const eventTypeEl = screen.getByTestId("current-routing-status-event-type"); + expect(eventTypeEl).toHaveTextContent(eventTypeText); + expect(eventTypeEl.querySelector("a")).toHaveAttribute("href", eventTypeHref); +} + +function expectEventTypeInfoInReroutePreview({ + eventTypeText, + eventTypeHref, +}: { + eventTypeText: string; + eventTypeHref: string; +}) { + const eventTypeEl = screen.getByTestId("reroute-preview-event-type"); + expect(eventTypeEl).toHaveTextContent(eventTypeText); + expect(eventTypeEl.querySelector("a")).toHaveAttribute("href", eventTypeHref); +} + +function expectOrganizerInfoInCurrentRouting({ organizerText }: { organizerText: string }) { + const organizerEl = screen.getByTestId("current-routing-status-organizer"); + expect(organizerEl).toHaveTextContent(organizerText); +} + +function expectAttendeesInfoInCurrentRouting({ attendeesText }: { attendeesText: string }) { + const attendeesEl = screen.getByTestId("current-routing-status-attendees"); + expect(attendeesEl).toHaveTextContent(attendeesText); +} + +const mockBooking = { + id: 1, + uid: "original-booking-uid", + title: "Test Booking", + startTime: new Date(new Date().setDate(new Date().getDate() + 1)).toISOString(), + metadata: {}, + responses: {}, + attendees: [{ email: "attendee@example.com", name: "Attendee", timeZone: "UTC", locale: "en" }], + eventType: { + id: 1, + slug: "test-event", + team: { slug: "test-team" }, + length: 60, + schedulingType: SchedulingType.ROUND_ROBIN, + title: "Test Event", + }, + user: { id: 1, name: "Test User", email: "user@example.com" }, + routedFromRoutingFormReponse: { id: 1 }, + status: BookingStatus.ACCEPTED, // Add this line +}; + +const buildBooking = () => { + return { + ...mockBooking, + }; +}; + +describe("RerouteDialog", () => { + const mockSetIsOpenDialog = vi.fn(); + + beforeEach(() => { + vi.clearAllMocks(); + }); + + test("renders the dialog when open", () => { + render(); + + expect(screen.getByText("reroute_booking")).toBeInTheDocument(); + expect(screen.getByText("reroute_booking_description")).toBeInTheDocument(); + }); + + test("doesn't render the dialog when closed", () => { + render( + + ); + + expect(screen.queryByText("reroute_booking")).not.toBeInTheDocument(); + }); + + test("displays current routing status", async () => { + render(); + expect(screen.getByText("current_routing_status")).toBeInTheDocument(); + + expectEventTypeInfoInCurrentRouting({ + eventTypeText: "team/test-team/test-event", + eventTypeHref: "https://cal.com/team/test-team/test-event", + }); + expectOrganizerInfoInCurrentRouting({ + organizerText: "user@example.com", + }); + expectAttendeesInfoInCurrentRouting({ + attendeesText: "attendee@example.com", + }); + // screen.logTestingPlaygroundURL() + }); + + test("verify_new_route button is disabled when form fields are not filled", async () => { + render(); + expect(screen.getByText("verify_new_route")).toBeDisabled(); + correctResponses(); + expect(screen.getByText("verify_new_route")).toBeEnabled(); + }); + + test("disabledFields are passed to FormInputFields with value ['email'] - email field is disabled", async () => { + render(); + expect(screen.getByTestId("mock-form-field-disabled-fields-identifiers")).toHaveTextContent(/^email$/); + }); + + test("Expect form fields and name to be rendered", async () => { + render(); + expect(screen.getByText("Test Form")).toBeInTheDocument(); + expect(screen.getByTestId("mock-form-field-company-size-label")).toHaveTextContent("Company Size"); + expect(screen.getByTestId("mock-form-field-company-size-value")).toHaveTextContent("small"); + expect(screen.getByTestId("mock-form-field-country-label")).toHaveTextContent("Country"); + expect(screen.getByTestId("mock-form-field-country-value")).toHaveTextContent("usa"); + }); + + test("cancel button closes the dialog", async () => { + render(); + + expect(screen.getByText("cancel")).toBeInTheDocument(); + + fireEvent.click(screen.getByText("cancel")); + expect(mockSetIsOpenDialog).toHaveBeenCalledWith(false); + }); + + describe("New Routing tests", () => { + test("when verify_new_route is clicked, the form is submitted", async () => { + render( + + ); + // Enables verify_new_route button + correctResponses(); + fireEvent.click(screen.getByText("verify_new_route")); + + expectEventTypeInfoInReroutePreview({ + eventTypeText: "team/test-team/new-test-event", + eventTypeHref: "https://cal.com/team/test-team/new-test-event", + }); + expect(screen.getByText("verify_new_route")).toBeEnabled(); + expect(screen.getByTestId("reroute-preview-hosts")).toHaveTextContent("reroute_preview_possible_host"); + expect(screen.getByTestId("reroute-preview-hosts")).toHaveTextContent("matching-user-1@example.com"); + + expect(screen.getByText("reschedule_to_the_new_event_with_different_timeslot")).toBeInTheDocument(); + expect(screen.getByText("reschedule_with_same_timeslot_of_new_event")).toBeInTheDocument(); + }); + + describe("New tab rescheduling", () => { + test("new tab is closed when new booking is rerouted", async () => { + render( + + ); + // Enables verify_new_route button + correctResponses(); + clickVerifyNewRouteButton(); + clickRescheduleToTheNewEventWithDifferentTimeslotButton(); + await mockMessageFromOpenedTab({ + type: "CAL:rescheduleBookingSuccessfulV2", + data: { + uid: "RESCHEDULED_BOOKING_UID_NEW_TAB", + }, + }); + + const rescheduleTabUrl = mockOpen.mock.calls[0][0] as unknown as string; + const rescheduleTabUrlObject = new URL(rescheduleTabUrl, "http://mockcal.com"); + expect(Object.fromEntries(rescheduleTabUrlObject.searchParams.entries())).toEqual( + expect.objectContaining({ + rescheduleUid: mockBooking.uid, + "cal.rerouting": "true", + "cal.reroutingFormResponses": JSON.stringify({ + "company-size": { + value: "small", + }, + country: { + value: "usa", + }, + }), + }) + ); + expect(fakeNewTabWindow.close).toHaveBeenCalled(); + expectToBeNavigatedToBookingPage({ + booking: { + uid: "RESCHEDULED_BOOKING_UID_NEW_TAB", + }, + }); + }); + + test("Rescheduling with same timeslot works", async () => { + render( + + ); + // Enables verify_new_route button + correctResponses(); + clickVerifyNewRouteButton(); + clickRescheduleWithSameTimeslotOfChosenEventButton(); + expect(mockMutateFn).toHaveBeenCalledWith( + expect.objectContaining({ + rescheduleUid: mockBooking.uid, + reroutingFormResponses: { + "company-size": { + value: "small", + }, + country: { + value: "usa", + }, + }, + }) + ); + + expectToBeNavigatedToBookingPage({ + booking: { + uid: "RESCHEDULED_BOOKING_UID_SAME_TIMESLOT", + }, + }); + }); + }); + }); +}); + +function clickVerifyNewRouteButton() { + act(() => { + fireEvent.click(screen.getByText("verify_new_route")); + }); +} + +function clickRescheduleToTheNewEventWithDifferentTimeslotButton() { + act(() => { + fireEvent.click(screen.getByText("reschedule_to_the_new_event_with_different_timeslot")); + }); +} + +function clickRescheduleWithSameTimeslotOfChosenEventButton() { + act(() => { + fireEvent.click(screen.getByText("reschedule_with_same_timeslot_of_new_event")); + }); +} + +function expectToBeNavigatedToBookingPage({ + booking, +}: { + booking: { + uid: string; + }; +}) { + expect(mockRouter.push).toHaveBeenCalledWith(`/booking/${booking.uid}?cal.rerouting=true`); +} diff --git a/apps/web/modules/bookings/views/bookings-listing-view.tsx b/apps/web/modules/bookings/views/bookings-listing-view.tsx index b806c3c1db..6dd5a6439b 100644 --- a/apps/web/modules/bookings/views/bookings-listing-view.tsx +++ b/apps/web/modules/bookings/views/bookings-listing-view.tsx @@ -78,7 +78,6 @@ export default function Bookings() { const { t } = useLocale(); const user = useMeQuery().data; const [isFiltersVisible, setIsFiltersVisible] = useState(false); - const query = trpc.viewer.bookings.get.useInfiniteQuery( { limit: 10, @@ -88,7 +87,6 @@ export default function Bookings() { }, }, { - // first render has status `undefined` enabled: true, getNextPageParam: (lastPage) => lastPage.nextCursor, } diff --git a/apps/web/modules/bookings/views/bookings-single-view.tsx b/apps/web/modules/bookings/views/bookings-single-view.tsx index 71e1ff14fe..a318e037ca 100644 --- a/apps/web/modules/bookings/views/bookings-single-view.tsx +++ b/apps/web/modules/bookings/views/bookings-single-view.tsx @@ -365,12 +365,16 @@ export default function Success(props: PageProps) { const isNotAttendingSeatedEvent = isCancelled && seatReferenceUid; const isEventCancelled = isCancelled && !seatReferenceUid; const isPastBooking = isBookingInPast; - + const isRerouting = searchParams?.get("cal.rerouting") === "true"; const successPageHeadline = (() => { if (needsConfirmationAndReschedulable) { return isRecurringBooking ? t("booking_submitted_recurring") : t("booking_submitted"); } + if (isRerouting) { + return t("This meeting has been rerouted"); + } + if (isNotAttendingSeatedEvent) { return t("no_longer_attending"); } @@ -476,6 +480,7 @@ export default function Success(props: PageProps) { id="modal-headline"> {successPageHeadline} +

{getTitle()}

@@ -691,6 +696,7 @@ export default function Success(props: PageProps) { {!requiresLoginToUpdate && (!needsConfirmation || !userIsOwner) && isReschedulable && + !isRerouting && (!isCancellationMode ? ( <>
@@ -750,6 +756,18 @@ export default function Success(props: PageProps) { /> ))} + {isRerouting && typeof window !== "undefined" && window.opener && ( +
+ +
+ )} {userIsOwner && !needsConfirmation && !isCancellationMode && diff --git a/apps/web/playwright/icons.e2e.ts-snapshots/icons--calcom-web-linux.png b/apps/web/playwright/icons.e2e.ts-snapshots/icons--calcom-web-linux.png index 356c3d227d..a55c7d607c 100644 Binary files a/apps/web/playwright/icons.e2e.ts-snapshots/icons--calcom-web-linux.png and b/apps/web/playwright/icons.e2e.ts-snapshots/icons--calcom-web-linux.png differ diff --git a/apps/web/public/icons/sprite.svg b/apps/web/public/icons/sprite.svg index 23f1abd825..e650b0f6c5 100644 --- a/apps/web/public/icons/sprite.svg +++ b/apps/web/public/icons/sprite.svg @@ -659,6 +659,15 @@ + + + + + + + + + diff --git a/apps/web/public/static/locales/en/common.json b/apps/web/public/static/locales/en/common.json index de167455ca..c28f8a3bd5 100644 --- a/apps/web/public/static/locales/en/common.json +++ b/apps/web/public/static/locales/en/common.json @@ -2655,11 +2655,26 @@ "salesforce_if_account_does_not_exist": "If the contact does not exist under an account, create new lead from attendee", "salesforce_create_new_contact_under_account": "Create a new contact under an account based on email domain of attendee and existing contacts", "mass_assign_attributes": "Mass assign attributes", + "reroute_preview_custom_message": "It results in showing custom message. Try changing the response to route to an event", + "reroute_preview_external_redirect": "It results in redirecting to {{externalUrl}}. Try changing the response to route to an event", + "reroute_preview_possible_host": "Possible host", + "current_routing_status": "Current routing status", + "reroute_booking": "Reroute booking", + "reroute_booking_description": "Reroute the booking to different team members", + "verify_new_route": "Verify new route", + "reroute": "Reroute", + "new_routing_status": "New routing status", "salesforce_book_directly_with_attendee_owner": "If attendee exists in Salesforce, book directly with the owner", "salesforce_check_owner_of": "Entity to check owner of to book directly", "account": "Account", "choose_hosts_schedule": "Choose host schedules", "hosts_schedule_description": "You can select a different schedule for each host. Each host's default schedule is chosen by default.", "no_hosts_description": "No hosts assigned, add hosts in assignment tab.", + "reschedule_with_same_timeslot": "Reschedule with same timeslot", + "reschedule_with_same_timeslot_of_new_event": "Reschedule with same timeslot of the new event", + "reschedule_to_the_new_event_with_different_timeslot": "Reschedule to the new event with different timeslot", + "reschedule_with_different_timeslot": "Reschedule with different timeslot", + "we_dont_have_id_for_the_new_event": "We don't have ID for the new event. Please go to the Routing Form and save it again.", + "rerouted_booking_successfully_redirecting_to_booking_page": "Rerouted booking successfully. Redirecting to booking page", "ADD_NEW_STRINGS_ABOVE_THIS_LINE_TO_PREVENT_MERGE_CONFLICTS": "↑↑↑↑↑↑↑↑↑↑↑↑↑ Add your new strings above here ↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑" } diff --git a/packages/app-store/BookingPageTagManager.tsx b/packages/app-store/BookingPageTagManager.tsx index b8dcefba97..3c5343d44b 100644 --- a/packages/app-store/BookingPageTagManager.tsx +++ b/packages/app-store/BookingPageTagManager.tsx @@ -76,6 +76,16 @@ export function handleEvent(event: { detail: Record & { type: s }); }); + // Support sending all events to opener which is currently used by ReroutingDialog to identify if the booking is successfully rescheduled. + if (window.opener) { + window.opener.postMessage( + { + type: `CAL:${name}`, + ...data, + }, + "*" + ); + } return true; } diff --git a/packages/app-store/routing-forms/TODO.md b/packages/app-store/routing-forms/TODO.md index 1df208c830..94505be4db 100644 --- a/packages/app-store/routing-forms/TODO.md +++ b/packages/app-store/routing-forms/TODO.md @@ -1,3 +1,8 @@ +## Ready for Review Requirements +- Authorization check for fetch form response and form + + + ## TODO - Routing - [x] Use teamMemberIds to calculate availabilithy as well in addition to doing it in handleNewBooking - [x] Don't let the Routing Form with attributesQueryValue be picked @@ -7,13 +12,32 @@ - [x] Fix bug with Multiselect attribute - [x] Connect Form Response to Booking - [x] Test with non option based attributes and form field input +- [x] We might want to disable attributes query builder for non-orgs. Same for Re-routing. - [ ] Switching from Select to MultiSelect in attribute - [ ] It immediately makes the Routing logic to not work. Fix it. - [ ] It removes the selected option from Routing. Fix it. - [ ] Warnings are needed with recommendation to create new attribute instead in unsupported scenarios. We need to test for more such changes to Attribute types. -- [ ] When choosing 'Value of Field' option in RAQB, show a warning somewhere if all options of the Form field aren't present in the attribute option. - - So, if Form Field is 'What is Comapny Size' with options Enterprise, Large, Medium, Small and Attribute for which Value of 'What is compnay size' has options Enterprise, Large, Medium. It would show a warning that 'Small' option is not present in the attribute. -- [ ] When logic can't be built(Short text maps to an attribute with given set of options but the short text value doesn't match any of the options), the team member matching route throws error but Test Preview still shows the previous value - It show should the error in there. +- [ ] Test Seated events as routing targets. +- Improvements + - [ ] When choosing 'Value of Field' option in RAQB, show a warning somewhere if all options of the Form field aren't present in the attribute option. + - So, if Form Field is 'What is Comapny Size' with options Enterprise, Large, Medium, Small and Attribute for which Value of 'What is compnay size' has options Enterprise, Large, Medium. It would show a warning that 'Small' option is not present in the attribute. + - [ ] When logic can't be built(Short text maps to an attribute with given set of options but the short text value doesn't match any of the options), the team member matching route throws error but Test Preview still shows the previous value - It show should the error in there. + - [ ] When routing to a team that doesn't have the team members assigned that are chosen, we are unable to select those users. FIX it by ignoring the assigned members(except the fixed hosts) and just using the attributes matched team members. +- [x] Start storing eventTypeId in routing Action. +- [x] Make sure that without making the FORM dirty, user can't reroute +- [x] When 'Verify Route' is clicked, it doesn't seem to run the form field logic as there is a warning in console. Fix it. +- [x] Give a proper icon to Reroute button +- [ ] Fetch bookings on window focus but not when rerouting dialog is open. As soon as it is closed, we need to refetch again. +- [ ] Seated Events + - [ ] Disable routing for seated events. Disable only during creation. +- [ ] When rescheduling to a new event, the booking title remains same which will most probably not match the event-type title. This is the behaviour of reschedule. Should we use the new event-type title? +- [ ] What params should we forward to the booking page that we show after rerouting +- [ ] On completing the rerouting just show the new Routing status +- [ ] We need to disallow changing the email identifier field as during a reschedule user isn't allowed to change their email currently. Allowing to change that email response would mean that we support changing email during reschedule which might be disabled for some reasons unknown yet. +- [ ] What if there are changes to Form before rerouting but after initial routing. +- [ ] Not able to prefill rescheduleReason due to a bug in useInitialFormValues hook +- [ ] It is becoming a requirement to have BookingAttempt record to allow to store reroutingFormResponses as well as teamMemberIds in it. Other query params could also be stored later on when needed. It is important to reduce the payload of the query params due to routedTeamMemberIds and reroutingFormResponses + ### Performance Tests and Improvements - [ ] Test with ~1000 team members and ~100 attributes. @@ -34,22 +58,22 @@ - [x] getRoutedUsers tests - [ ] getAvailableSlots - [ ] should use routedTeamMemberIds in availability query +- [ ] RerouteDialog + - [x] BASIC + - [ ] New Tab rescheduling ### Documentation / Tooltip - [ ] Document well that the option label in Routing Form Field and Attribute Option label must be same to connect them. - Due to the connection requirement b/w Attribute Option and Field Option, we use label(lowercased) to match attributes instead of attribute slug - [ ] Fixed hosts of the event will be included through attribute routing as well. They aren't tested for attribute routing logic. +- [ ] When re-routed, the booking page doesn't allow rescheduling/cancellation to avoid any possible issues. We can open it up later. +- [ ] Booking with seats not supported yet. Need to figure out which seats should be re-routed or if re-routing makes sense for it even? +- [ ] Hashed link re-routing doesn't make sense with. With Routing you route to an event from form and it doesn't involve hashed link even if it is enabled +- [ ] User needs to ensure for now that the team event they redirect to has 'Assign all and future members of the team' checked. ### V2.0 - [ ] Fallback for when no team member matches the criteria. - Fallback will be attributes query builder that would match a different set of users. Though the booking will use the team members assigned to the event type, it might be better to be able to identify such a scenario and use a different set of users. It also makes it easy to identify when the fallback scenario happens. - [ ] cal.routedTeamMembersIds query param - Could possible become a big payload and possibly break the URL limit. We could work on a short-lived row in a table that would hold that info and we pass the id of that row only in query param. handleNewBooking can then retrieve the routedTeamMembersIds from that short-lived row and delete the entry after successfully creating a booking. -- [ ] Better ability to test with contact owner from Routing Form Preview itself(if possible). Right now, we need to test the entire booking flow to verify that. - - -## TODO - Attributes -- [ ] Attributes configuration - 1. through DB initially for POC - 2. through CSV upload - 3. through Salesforce Connect \ No newline at end of file +- [ ] Better ability to test with contact owner from Routing Form Preview itself(if possible). Right now, we need to test the entire booking flow to verify that. \ No newline at end of file diff --git a/packages/app-store/routing-forms/components/FormInputFields.tsx b/packages/app-store/routing-forms/components/FormInputFields.tsx index 07baf4bf52..76f6c1e202 100644 --- a/packages/app-store/routing-forms/components/FormInputFields.tsx +++ b/packages/app-store/routing-forms/components/FormInputFields.tsx @@ -1,6 +1,8 @@ import type { App_RoutingForms_Form } from "@prisma/client"; import type { Dispatch, SetStateAction } from "react"; +import { SkeletonText } from "@calcom/ui"; + import getFieldIdentifier from "../lib/getFieldIdentifier"; import { getQueryBuilderConfigForFormFields } from "../lib/getQueryBuilderConfig"; import isRouterLinkedField from "../lib/isRouterLinkedField"; @@ -8,14 +10,21 @@ import { getUIOptionsForSelect } from "../lib/selectOptions"; import { getFieldResponseForJsonLogic } from "../lib/transformResponse"; import type { SerializableForm, FormResponse } from "../types/types"; -type Props = { - form: SerializableForm; +export type FormInputFieldsProps = { + form: Pick, "fields">; + /** + * Make sure that response is updated by setResponse + */ response: FormResponse; setResponse: Dispatch>; + /** + * Identifier of the fields that should be disabled + */ + disabledFields?: string[]; }; -export default function FormInputFields(props: Props) { - const { form, response, setResponse } = props; +export default function FormInputFields(props: FormInputFieldsProps) { + const { form, response, setResponse, disabledFields = [] } = props; const formFieldsQueryBuilderConfig = getQueryBuilderConfigForFormFields(form); @@ -33,6 +42,7 @@ export default function FormInputFields(props: Props) { const Component = widget.factory; const options = getUIOptionsForSelect(field); + const fieldIdentifier = getFieldIdentifier(field); return (
@@ -48,10 +58,10 @@ export default function FormInputFields(props: Props) { /* @ts-ignore */ required={!!field.required} listValues={options} - data-testid={`form-field-${getFieldIdentifier(field)}`} + disabled={disabledFields?.includes(fieldIdentifier)} + data-testid={`form-field-${fieldIdentifier}`} setValue={(value: number | string | string[]) => { - setResponse((response) => { - response = response || {}; + setResponse(() => { return { ...response, [field.id]: { @@ -68,3 +78,17 @@ export default function FormInputFields(props: Props) { ); } + +export const FormInputFieldsSkeleton = () => { + const numberOfFields = 5; + return ( + <> + {Array.from({ length: numberOfFields }).map((_, index) => ( +
+ + +
+ ))} + + ); +}; diff --git a/packages/app-store/routing-forms/lib/RoutingPages.ts b/packages/app-store/routing-forms/lib/RoutingPages.ts index 182267f6c7..e735b65244 100644 --- a/packages/app-store/routing-forms/lib/RoutingPages.ts +++ b/packages/app-store/routing-forms/lib/RoutingPages.ts @@ -1,16 +1,17 @@ import type { LocalRoute } from "../types/types"; +import { RouteActionType } from "../zod"; export const RoutingPages: { label: string; value: NonNullable["type"] }[] = [ { label: "Custom Page", - value: "customPageMessage", + value: RouteActionType.CustomPageMessage, }, { label: "External Redirect", - value: "externalRedirectUrl", + value: RouteActionType.ExternalRedirectUrl, }, { label: "Event Redirect", - value: "eventTypeRedirectUrl", + value: RouteActionType.EventTypeRedirectUrl, }, ]; diff --git a/packages/app-store/routing-forms/lib/createFallbackRoute.ts b/packages/app-store/routing-forms/lib/createFallbackRoute.ts index c762d3b686..4e4ff8b37a 100644 --- a/packages/app-store/routing-forms/lib/createFallbackRoute.ts +++ b/packages/app-store/routing-forms/lib/createFallbackRoute.ts @@ -1,6 +1,7 @@ import { Utils as QbUtils } from "react-awesome-query-builder"; import type { GlobalRoute, LocalRoute, SerializableRoute } from "../types/types"; +import { RouteActionType } from "../zod"; export const createFallbackRoute = (): Exclude => { const uuid = QbUtils.uuid(); @@ -8,7 +9,7 @@ export const createFallbackRoute = (): Exclude = id: uuid, isFallback: true, action: { - type: "customPageMessage", + type: RouteActionType.CustomPageMessage, value: "Thank you for your interest! We will be in touch soon.", }, queryValue: { id: uuid, type: "group" } as LocalRoute["queryValue"], diff --git a/packages/app-store/routing-forms/lib/processRoute.tsx b/packages/app-store/routing-forms/lib/processRoute.tsx index 3a17eef731..ec5b38ddc1 100644 --- a/packages/app-store/routing-forms/lib/processRoute.tsx +++ b/packages/app-store/routing-forms/lib/processRoute.tsx @@ -14,7 +14,7 @@ export function findMatchingRoute({ form, response, }: { - form: SerializableForm; + form: Pick, "routes" | "fields">; response: Record>; }) { const queryBuilderConfig = getQueryBuilderConfigForFormFields(form); diff --git a/packages/app-store/routing-forms/pages/route-builder/[...appPages].tsx b/packages/app-store/routing-forms/pages/route-builder/[...appPages].tsx index 15c90a6e03..99d8eb87b5 100644 --- a/packages/app-store/routing-forms/pages/route-builder/[...appPages].tsx +++ b/packages/app-store/routing-forms/pages/route-builder/[...appPages].tsx @@ -42,6 +42,7 @@ import { import isRouter from "../../lib/isRouter"; import type { SerializableForm } from "../../types/types"; import type { GlobalRoute, LocalRoute, SerializableRoute, Attribute } from "../../types/types"; +import { RouteActionType } from "../../zod"; type FormFieldsQueryBuilderState = { tree: ImmutableTree; @@ -61,6 +62,7 @@ type LocalRouteWithRaqbStates = LocalRoute & { type Form = inferSSRProps["form"]; type Route = LocalRouteWithRaqbStates | GlobalRoute; +type SetRoute = (id: string, route: Partial) => void; const RoundRobinContactOwnerOverrideSwitch = ({ route, @@ -93,6 +95,42 @@ type AttributesQueryValue = NonNullable; type FormFieldsQueryValue = LocalRoute["queryValue"]; type AttributeRoutingConfig = NonNullable; +/** + * We need eventTypeId in every redirect url action now for Rerouting to work smoothly. + * This hook ensures that it is there as soon as someone lands on a Routing Form and next save would automatically update it for them. + */ +function useEnsureEventTypeIdInRedirectUrlAction({ + route, + eventOptions, + setRoute, +}: { + route: Route; + eventOptions: { label: string; value: string; eventTypeId: number }[]; + setRoute: SetRoute; +}) { + useEffect(() => { + if (isRouter(route)) { + return; + } + + if ( + route.action.type !== RouteActionType.EventTypeRedirectUrl || + // Must not be set already. Could be zero as well for custom + route.action.eventTypeId !== undefined + ) { + return; + } + + const matchingOption = eventOptions.find((eventOption) => eventOption.value === route.action.value); + if (!matchingOption) { + return; + } + setRoute(route.id, { + action: { ...route.action, eventTypeId: matchingOption.eventTypeId }, + }); + }, [eventOptions, setRoute, route.id, (route as unknown as any).action?.value]); +} + const hasRules = (route: Route) => { if (isRouter(route)) return false; route.queryValue.children1 && Object.keys(route.queryValue.children1).length; @@ -106,7 +144,7 @@ const getEmptyRoute = (): Exclude => { return { id: uuid, action: { - type: "eventTypeRedirectUrl", + type: RouteActionType.EventTypeRedirectUrl, value: "", }, // It is actually formFieldsQueryValue @@ -184,7 +222,7 @@ const Route = ({ form: Form; route: Route; routes: Route[]; - setRoute: (id: string, route: Partial) => void; + setRoute: SetRoute; setAttributeRoutingConfig: (id: string, attributeRoutingConfig: Partial) => void; formFieldsQueryBuilderConfig: FormFieldsQueryBuilderConfigWithRaqbFields; attributesQueryBuilderConfig: AttributesQueryBuilderConfigWithRaqbFields | null; @@ -221,6 +259,12 @@ const Route = ({ } }, [isLoading]); + useEnsureEventTypeIdInRedirectUrlAction({ + route, + eventOptions, + setRoute, + }); + const onChangeFormFieldsQuery = ( route: Route, immutableTree: ImmutableTree, @@ -336,7 +380,7 @@ const Route = ({ ) : null; const attributesQueryBuilder = - route.action?.type === "eventTypeRedirectUrl" && isTeamForm ? ( + route.action?.type === RouteActionType.EventTypeRedirectUrl && isTeamForm ? (
and use only the Team Members that match the following criteria(matches all by default) @@ -453,11 +497,13 @@ const Route = ({ } if (option.value !== "custom") { setRoute(route.id, { - action: { ...route.action, value: option.value }, + action: { ...route.action, value: option.value, eventTypeId: option.eventTypeId }, }); setCustomEventTypeSlug(""); } else { - setRoute(route.id, { action: { ...route.action, value: "custom" } }); + setRoute(route.id, { + action: { ...route.action, value: "custom", eventTypeId: 0 }, + }); setCustomEventTypeSlug(""); } }} diff --git a/packages/app-store/routing-forms/pages/routing-link/getUrlSearchParamsToForward.ts b/packages/app-store/routing-forms/pages/routing-link/getUrlSearchParamsToForward.ts index 83c86a47d3..3ca6067cd4 100644 --- a/packages/app-store/routing-forms/pages/routing-link/getUrlSearchParamsToForward.ts +++ b/packages/app-store/routing-forms/pages/routing-link/getUrlSearchParamsToForward.ts @@ -1,19 +1,13 @@ import type { inferSSRProps } from "@calcom/types/inferSSRProps"; import getFieldIdentifier from "../../lib/getFieldIdentifier"; -import type { LocalRoute } from "../../types/types"; +import type { FormResponse, LocalRoute } from "../../types/types"; import type { getServerSideProps } from "./getServerSideProps"; +type FormResponseValueOnly = { [key: string]: { value: FormResponse[keyof FormResponse]["value"] } }; type Props = inferSSRProps; type AttributeRoutingConfig = NonNullable; -export function getUrlSearchParamsToForward({ - formResponse, - fields, - searchParams, - teamMembersMatchingAttributeLogic, - formResponseId, - attributeRoutingConfig, -}: { +type GetUrlSearchParamsToForwardOptions = { formResponse: Record< string, { @@ -28,7 +22,18 @@ export function getUrlSearchParamsToForward({ formResponseId: number; teamMembersMatchingAttributeLogic: number[] | null; attributeRoutingConfig: AttributeRoutingConfig | null; -}) { + reroutingFormResponses?: FormResponseValueOnly; +}; + +export function getUrlSearchParamsToForward({ + formResponse, + fields, + searchParams, + teamMembersMatchingAttributeLogic, + formResponseId, + attributeRoutingConfig, + reroutingFormResponses, +}: GetUrlSearchParamsToForwardOptions) { type Params = Record; const paramsFromResponse: Params = {}; const paramsFromCurrentUrl: Params = {}; @@ -84,6 +89,9 @@ export function getUrlSearchParamsToForward({ : null), ["cal.routingFormResponseId"]: String(formResponseId), ...(attributeRoutingConfig?.skipContactOwner ? { ["cal.skipContactOwner"]: "true" } : {}), + ...(reroutingFormResponses + ? { ["cal.reroutingFormResponses"]: JSON.stringify(reroutingFormResponses) } + : null), }; const allQueryURLSearchParams = new URLSearchParams(); @@ -98,3 +106,29 @@ export function getUrlSearchParamsToForward({ return allQueryURLSearchParams; } + +export function getUrlSearchParamsToForwardForReroute({ + formResponse, + formResponseId, + fields, + searchParams, + teamMembersMatchingAttributeLogic, + attributeRoutingConfig, + rescheduleUid, + reroutingFormResponses, +}: GetUrlSearchParamsToForwardOptions & { + rescheduleUid: string; + reroutingFormResponses: FormResponseValueOnly; +}) { + searchParams.set("rescheduleUid", rescheduleUid); + searchParams.set("cal.rerouting", "true"); + return getUrlSearchParamsToForward({ + formResponse, + formResponseId, + fields, + searchParams, + teamMembersMatchingAttributeLogic, + attributeRoutingConfig, + reroutingFormResponses, + }); +} diff --git a/packages/app-store/routing-forms/trpc/__tests__/utils.test.ts b/packages/app-store/routing-forms/trpc/__tests__/utils.test.ts index da67d79b4e..f478b7082b 100644 --- a/packages/app-store/routing-forms/trpc/__tests__/utils.test.ts +++ b/packages/app-store/routing-forms/trpc/__tests__/utils.test.ts @@ -2,6 +2,7 @@ import type { BaseWidget } from "react-awesome-query-builder"; import { describe, it, expect, vi, beforeEach } from "vitest"; import type { AttributeType } from "@calcom/prisma/enums"; +import { RouteActionType } from "@calcom/app-store/routing-forms/zod"; import { RoutingFormFieldType } from "../../lib/FieldTypes"; import { RaqbLogicResult } from "../../lib/evaluateRaqbLogic"; @@ -141,7 +142,7 @@ function buildRoute({ }: { id: string; action: { - type: "customPageMessage" | "externalRedirectUrl" | "eventTypeRedirectUrl"; + type: RouteActionType; value: string; }; queryValue: FormFieldsQueryValue; @@ -164,7 +165,7 @@ function buildDefaultCustomPageRoute({ }) { return buildRoute({ id, - action: { type: "customPageMessage", value: "test" }, + action: { type: RouteActionType.CustomPageMessage, value: "test" }, queryValue: { type: "group" } as unknown as FormFieldsQueryValue, attributesQueryValue, }); @@ -193,7 +194,7 @@ describe("findTeamMembersMatchingAttributeLogicOfRoute", () => { { id: "test-route", queryValue: { type: "group" } as unknown as FormFieldsQueryValue, - action: { type: "customPageMessage", value: "test" }, + action: { type: RouteActionType.CustomPageMessage, value: "test" }, }, ], fields: [], @@ -238,7 +239,7 @@ describe("findTeamMembersMatchingAttributeLogicOfRoute", () => { routes: [ { id: "test-route", - action: { type: "customPageMessage", value: "test" }, + action: { type: RouteActionType.CustomPageMessage, value: "test" }, queryValue: { type: "group", } as unknown as FormFieldsQueryValue, diff --git a/packages/app-store/routing-forms/trpc/_router.ts b/packages/app-store/routing-forms/trpc/_router.ts index a7fcdcd555..806634d921 100644 --- a/packages/app-store/routing-forms/trpc/_router.ts +++ b/packages/app-store/routing-forms/trpc/_router.ts @@ -1,3 +1,5 @@ +import { z } from "zod"; + import authedProcedure from "@calcom/trpc/server/procedures/authedProcedure"; import publicProcedure from "@calcom/trpc/server/procedures/publicProcedure"; import { router } from "@calcom/trpc/server/trpc"; @@ -45,6 +47,12 @@ const getHandler = async < return UNSTABLE_HANDLER_CACHE[nameInCache] as unknown as T["default"]; }; +export const ZFormByResponseIdInputSchema = z.object({ + formResponseId: z.number(), +}); + +export type TFormQueryInputSchema = z.infer; + const appRoutingForms = router({ public: router({ response: publicProcedure.input(ZResponseInputSchema).mutation(async ({ ctx, input }) => { @@ -57,6 +65,15 @@ const appRoutingForms = router({ const handler = await getHandler("formQuery", () => import("./formQuery.handler")); return handler({ ctx, input }); }), + getResponseWithFormFields: authedProcedure + .input(ZFormByResponseIdInputSchema) + .query(async ({ ctx, input }) => { + const handler = await getHandler( + "getResponseWithFormFields", + () => import("./getResponseWithFormFields.handler") + ); + return handler({ ctx, input }); + }), formMutation: authedProcedure.input(ZFormMutationInputSchema).mutation(async ({ ctx, input }) => { const handler = await getHandler("formMutation", () => import("./formMutation.handler")); return handler({ ctx, input }); diff --git a/packages/app-store/routing-forms/trpc/getResponseWithFormFields.handler.ts b/packages/app-store/routing-forms/trpc/getResponseWithFormFields.handler.ts new file mode 100644 index 0000000000..904f734a3f --- /dev/null +++ b/packages/app-store/routing-forms/trpc/getResponseWithFormFields.handler.ts @@ -0,0 +1,93 @@ +import type { z } from "zod"; + +import { prisma } from "@calcom/prisma"; +import type { TrpcSessionUser } from "@calcom/trpc/server/trpc"; + +import { TRPCError } from "@trpc/server"; + +import { enrichFormWithMigrationData } from "../enrichFormWithMigrationData"; +import { getSerializableForm } from "../lib/getSerializableForm"; +import type { FormResponse } from "../types/types"; +import type { ZFormByResponseIdInputSchema } from "./_router"; +import { canEditEntity } from "@calcom/lib/entityPermissionUtils"; + +type GetResponseWithFormFieldsOptions = { + ctx: { + user: NonNullable; + }; + input: z.infer; +}; + +async function getResponseWithFormFieldsHandler({ ctx, input }: GetResponseWithFormFieldsOptions) { + const { user } = ctx; + const { formResponseId } = input; + + + const formResponse = await prisma.app_RoutingForms_FormResponse.findUnique({ + where: { + id: formResponseId, + }, + include: { + form: { + include: { + user: { + select: { + id: true, + movedToProfileId: true, + organization: { + select: { + slug: true, + }, + }, + username: true, + theme: true, + brandColor: true, + darkBrandColor: true, + metadata: true, + }, + }, + team: { + select: { + members: true, + slug: true, + parent: { + select: { slug: true }, + }, + parentId: true, + metadata: true, + }, + }, + }, + }, + }, + }); + + if (!formResponse) { + throw new TRPCError({ + code: "NOT_FOUND", + message: "Form response not found", + }); + } + + const form = formResponse.form; + + if (!canEditEntity(form, user.id)) { + throw new TRPCError({ + code: "FORBIDDEN", + message: "You don't have access to this form", + }); + } + + const { UserRepository } = await import("@calcom/lib/server/repository/user"); + const formWithUserProfile = { + ...form, + user: await UserRepository.enrichUserWithItsProfile({ user: form.user }), + }; + + return { + response: formResponse.response as FormResponse, + form: await getSerializableForm({ form: enrichFormWithMigrationData(formWithUserProfile) }), + }; +} + +export default getResponseWithFormFieldsHandler; diff --git a/packages/app-store/routing-forms/trpc/raqbUtils.ts b/packages/app-store/routing-forms/trpc/raqbUtils.ts index 45575ce093..b1c25553cd 100644 --- a/packages/app-store/routing-forms/trpc/raqbUtils.ts +++ b/packages/app-store/routing-forms/trpc/raqbUtils.ts @@ -36,7 +36,7 @@ function compatibleForAttributeAndFormFieldMatch( ) as T extends string[] ? string[] : string; } -const raqbQueryValueUtils = { +export const raqbQueryValueUtils = { isQueryValueARuleGroup: function isQueryValueARuleGroup(queryValue: JsonTree): queryValue is JsonGroup { return queryValue.type === "group"; }, @@ -77,6 +77,12 @@ const raqbQueryValueUtils = { return raqbFieldValueType; }, + isQueryValueEmpty: function isQueryValueEmpty(queryValue: JsonTree | null): queryValue is null { + if (!queryValue) { + return true; + } + return !queryValue.children1; + }, }; /** diff --git a/packages/app-store/routing-forms/trpc/utils.ts b/packages/app-store/routing-forms/trpc/utils.ts index cddbfeb554..3a5392404f 100644 --- a/packages/app-store/routing-forms/trpc/utils.ts +++ b/packages/app-store/routing-forms/trpc/utils.ts @@ -19,7 +19,7 @@ import isRouter from "../lib/isRouter"; import jsonLogic from "../lib/jsonLogic"; import type { SerializableField, OrderedResponses, AttributesQueryValue } from "../types/types"; import type { FormResponse, SerializableForm } from "../types/types"; -import { acrossQueryValueCompatiblity } from "./raqbUtils"; +import { acrossQueryValueCompatiblity, raqbQueryValueUtils } from "./raqbUtils"; const { getAttributesData: getAttributes, @@ -201,7 +201,7 @@ export async function findTeamMembersMatchingAttributeLogicOfRoute( }) ); - if (!attributesQueryValue) { + if (raqbQueryValueUtils.isQueryValueEmpty(attributesQueryValue)) { return { teamMembersMatchingAttributeLogic: null, timeTaken: { diff --git a/packages/app-store/routing-forms/zod.ts b/packages/app-store/routing-forms/zod.ts index faf97a0b8b..60f076b684 100644 --- a/packages/app-store/routing-forms/zod.ts +++ b/packages/app-store/routing-forms/zod.ts @@ -55,6 +55,13 @@ const queryValueSchema = z.object({ properties: z.any(), }); +export enum RouteActionType { + CustomPageMessage = "customPageMessage", + ExternalRedirectUrl = "externalRedirectUrl", + EventTypeRedirectUrl = "eventTypeRedirectUrl", +} + +export const routeActionTypeSchema = z.nativeEnum(RouteActionType); /** * Stricter schema for validating before saving to DB */ @@ -132,12 +139,8 @@ export const zodNonRouterRoute = z.object({ attributesQueryValue: queryValueSchema.brand<"attributesQueryValue">().optional(), isFallback: z.boolean().optional(), action: z.object({ - // TODO: Make it a union type of "customPageMessage" and .. - type: z.union([ - z.literal("customPageMessage"), - z.literal("externalRedirectUrl"), - z.literal("eventTypeRedirectUrl"), - ]), + type: routeActionTypeSchema, + eventTypeId: z.number().optional(), value: z.string(), }), }); @@ -170,3 +173,11 @@ export const zodRoutesView = z.union([z.array(zodRouteView), z.null()]).optional export const appDataSchema = z.any(); export const appKeysSchema = z.object({}); + +// This is different from FormResponse in types.d.ts in that it has label optional. We don't seem to be using label at this point, so we might want to use this only while saving the response when Routing Form is submitted +export const routingFormResponseInDbSchema = z.record( + z.object({ + label: z.string().optional(), + value: z.union([z.string(), z.number(), z.array(z.string())]), + }) +); diff --git a/packages/features/bookings/lib/book-event-form/booking-to-mutation-input-mapper.tsx b/packages/features/bookings/lib/book-event-form/booking-to-mutation-input-mapper.tsx index d2ed62b7d5..c98e490bfa 100644 --- a/packages/features/bookings/lib/book-event-form/booking-to-mutation-input-mapper.tsx +++ b/packages/features/bookings/lib/book-event-form/booking-to-mutation-input-mapper.tsx @@ -47,7 +47,7 @@ export const mapBookingToMutationInput = ({ const routingFormResponseIdParam = searchParams.get("cal.routingFormResponseId"); const routingFormResponseId = routingFormResponseIdParam ? Number(routingFormResponseIdParam) : undefined; const skipContactOwner = searchParams.get("cal.skipContactOwner") === "true"; - + const reroutingFormResponses = searchParams.get("cal.reroutingFormResponses"); return { ...values, user: username, @@ -72,6 +72,8 @@ export const mapBookingToMutationInput = ({ routedTeamMemberIds, routingFormResponseId, skipContactOwner, + // In case of rerouting, the form responses are actually the responses that we need to update. + reroutingFormResponses: reroutingFormResponses ? JSON.parse(reroutingFormResponses) : undefined, }; }; diff --git a/packages/features/bookings/lib/handleNewBooking.ts b/packages/features/bookings/lib/handleNewBooking.ts index 58ea737248..22af828b4e 100644 --- a/packages/features/bookings/lib/handleNewBooking.ts +++ b/packages/features/bookings/lib/handleNewBooking.ts @@ -235,6 +235,8 @@ async function handler( rescheduleReason, luckyUsers, routedTeamMemberIds, + reroutingFormResponses, + routingFormResponseId, ...reqBody } = bookingData; @@ -918,24 +920,12 @@ async function handler( }) ); - // update original rescheduled booking (no seats event) - if (!eventType.seatsPerTimeSlot && originalRescheduledBooking?.uid) { - await prisma.booking.update({ - where: { - id: originalRescheduledBooking.id, - }, - data: { - rescheduled: true, - status: BookingStatus.CANCELLED, - rescheduledBy: reqBody.rescheduledBy, - }, - }); - } - try { booking = await createBooking({ uid, - routedFromRoutingFormResponseId: reqBody.routingFormResponseId, + rescheduledBy: reqBody.rescheduledBy, + routingFormResponseId: routingFormResponseId, + reroutingFormResponses: reroutingFormResponses ?? null, reqBody: { user: reqBody.user, metadata: reqBody.metadata, diff --git a/packages/features/bookings/lib/handleNewBooking/createBooking.ts b/packages/features/bookings/lib/handleNewBooking/createBooking.ts index ab215e6903..8019d7875d 100644 --- a/packages/features/bookings/lib/handleNewBooking/createBooking.ts +++ b/packages/features/bookings/lib/handleNewBooking/createBooking.ts @@ -1,6 +1,8 @@ import { Prisma } from "@prisma/client"; import type short from "short-uuid"; +import type { z } from "zod"; +import type { routingFormResponseInDbSchema } from "@calcom/app-store/routing-forms/zod"; import dayjs from "@calcom/dayjs"; import { isPrismaObjOrUndefined } from "@calcom/lib"; import prisma from "@calcom/prisma"; @@ -21,7 +23,9 @@ type ReqBodyWithEnd = TgetBookingDataSchema & { end: string }; type CreateBookingParams = { uid: short.SUUID; - routedFromRoutingFormResponseId: number | undefined; + routingFormResponseId: number | undefined; + reroutingFormResponses: z.infer | null; + rescheduledBy: string | undefined; reqBody: { user: ReqBodyWithEnd["user"]; metadata: ReqBodyWithEnd["metadata"]; @@ -78,21 +82,20 @@ export async function createBooking({ input, evt, originalRescheduledBooking, - routedFromRoutingFormResponseId, -}: CreateBookingParams) { + routingFormResponseId, + reroutingFormResponses, + rescheduledBy, +}: CreateBookingParams & { rescheduledBy: string | undefined }) { updateEventDetails(evt, originalRescheduledBooking, input.changedOrganizer); - const associatedBookingForFormResponse = routedFromRoutingFormResponseId - ? await getAssociatedBookingForFormResponse(routedFromRoutingFormResponseId) + const associatedBookingForFormResponse = routingFormResponseId + ? await getAssociatedBookingForFormResponse(routingFormResponseId) : null; - const newBookingData = buildNewBookingData({ + const bookingAndAssociatedData = buildNewBookingData({ uid, - // We allow only the first booking to be connected to the form response - // Other bookings could happen due to user doing a booking by using browser back button to reach the same booking form with same query params and changing the time - // Such case isn't what the Routing Form redirected the user to, so we avoid this at the moment. - routedFromRoutingFormResponseId: associatedBookingForFormResponse - ? undefined - : routedFromRoutingFormResponseId, + rescheduledBy, + routingFormResponseId: shouldConnectBookingToFormResponse() ? routingFormResponseId : undefined, + reroutingFormResponses, reqBody, eventType, input, @@ -101,19 +104,38 @@ export async function createBooking({ }); return await saveBooking( - newBookingData, + bookingAndAssociatedData, originalRescheduledBooking, eventType.paymentAppData, eventType.organizerUser ); + + function shouldConnectBookingToFormResponse() { + const isRerouting = !!reroutingFormResponses; + + // During rerouting, we want to connect the new booking to the existing form response for booking being rescheduled(original booking) + if (isRerouting) { + return true; + } + // If not rerouting and there is already an associated booking for the form response, we don't want to connect the new booking to the form response + // We allow only the first booking to be connected to the form response + // Other bookings could happen due to user doing a booking by using browser back button to reach the same booking form with same query params and changing the time + // Such case isn't what the Routing Form redirected the user to, so we avoid this at the moment. + if (associatedBookingForFormResponse) { + return false; + } + return true; + } } async function saveBooking( - newBookingData: Prisma.BookingCreateInput, + bookingAndAssociatedData: ReturnType, originalRescheduledBooking: OriginalRescheduledBooking, paymentAppData: PaymentAppData, organizerUser: CreateBookingParams["eventType"]["organizerUser"] ) { + const { newBookingData, reroutingFormResponseUpdateData, originalBookingUpdateDataForCancellation } = + bookingAndAssociatedData; const createBookingObj = { include: { user: { @@ -143,7 +165,21 @@ async function saveBooking( }); } - return prisma.booking.create(createBookingObj); + /** + * Reschedule(Cancellation + Creation) with an update of reroutingFormResponse should be atomic + */ + return prisma.$transaction(async (tx) => { + if (originalBookingUpdateDataForCancellation) { + await tx.booking.update(originalBookingUpdateDataForCancellation); + } + + const booking = await tx.booking.create(createBookingObj); + if (reroutingFormResponseUpdateData) { + await tx.app_RoutingForms_FormResponse.update(reroutingFormResponseUpdateData); + } + + return booking; + }); } function getEventTypeRel(eventTypeId: EventTypeId) { @@ -164,12 +200,25 @@ function getAttendeesData(evt: Pick) { })); } -function buildNewBookingData(params: CreateBookingParams): Prisma.BookingCreateInput { - const { uid, evt, reqBody, eventType, input, originalRescheduledBooking, routedFromRoutingFormResponseId } = - params; +function buildNewBookingData(params: CreateBookingParams) { + const { + uid, + evt, + reqBody, + eventType, + input, + originalRescheduledBooking, + routingFormResponseId, + reroutingFormResponses, + rescheduledBy, + } = params; const attendeesData = getAttendeesData(evt); const eventTypeRel = getEventTypeRel(eventType.id); + const reroutingFormResponseUpdateData = getReroutingFormResponseUpdateData({ + reroutingFormResponses, + routingFormResponseId, + }); const newBookingData: Prisma.BookingCreateInput = { uid, @@ -206,8 +255,8 @@ function buildNewBookingData(params: CreateBookingParams): Prisma.BookingCreateI } : undefined, - routedFromRoutingFormReponse: routedFromRoutingFormResponseId - ? { connect: { id: routedFromRoutingFormResponseId } } + routedFromRoutingFormReponse: routingFormResponseId + ? { connect: { id: routingFormResponseId } } : undefined, }; @@ -215,6 +264,8 @@ function buildNewBookingData(params: CreateBookingParams): Prisma.BookingCreateI newBookingData.recurringEventId = reqBody.recurringEventId; } + let originalBookingUpdateDataForCancellation: Prisma.BookingUpdateArgs | undefined = undefined; + if (originalRescheduledBooking) { newBookingData.metadata = { ...(typeof originalRescheduledBooking.metadata === "object" && originalRescheduledBooking.metadata), @@ -235,12 +286,53 @@ function buildNewBookingData(params: CreateBookingParams): Prisma.BookingCreateI (attendee) => attendee.email === input.bookerEmail ); } + if (originalRescheduledBooking.recurringEventId) { newBookingData.recurringEventId = originalRescheduledBooking.recurringEventId; } + + if (!evt.seatsPerTimeSlot && originalRescheduledBooking?.uid) { + originalBookingUpdateDataForCancellation = { + where: { + id: originalRescheduledBooking.id, + }, + data: { + rescheduled: true, + status: BookingStatus.CANCELLED, + rescheduledBy: rescheduledBy, + }, + }; + } } - return newBookingData; + return { + newBookingData, + reroutingFormResponseUpdateData, + originalBookingUpdateDataForCancellation, + }; + + function getReroutingFormResponseUpdateData({ + reroutingFormResponses, + routingFormResponseId, + }: { + reroutingFormResponses: z.infer | null; + routingFormResponseId: number | undefined | null; + }) { + if (!routingFormResponseId) { + return null; + } + + if (!reroutingFormResponses) { + return null; + } + + return { + where: { id: routingFormResponseId }, + data: { + response: reroutingFormResponses, + }, + }; + } } export type Booking = Prisma.PromiseReturnType; diff --git a/packages/lib/bookingSuccessRedirect.ts b/packages/lib/bookingSuccessRedirect.ts index 627511845a..f112ea077b 100644 --- a/packages/lib/bookingSuccessRedirect.ts +++ b/packages/lib/bookingSuccessRedirect.ts @@ -57,6 +57,12 @@ export const useBookingSuccessRedirect = () => { query: Record; booking: SuccessRedirectBookingType; }) => { + // Ensures that the param is added both to external redirect url and booking success page URL + query = { + ...query, + "cal.rerouting": searchParams.get("cal.rerouting"), + }; + if (successRedirectUrl) { const url = new URL(successRedirectUrl); // Using parent ensures, Embed iframe would redirect outside of the iframe. diff --git a/packages/prisma/schema.prisma b/packages/prisma/schema.prisma index 4577eaa93a..af63d6d981 100644 --- a/packages/prisma/schema.prisma +++ b/packages/prisma/schema.prisma @@ -631,6 +631,7 @@ model Booking { cancelledBy String? /// @zod.email() rescheduledBy String? + // Ah, made a typo here. Should have been routedFromRoutingFormRe"s"ponse. Live with it :( routedFromRoutingFormReponse App_RoutingForms_FormResponse? @@index([eventTypeId]) diff --git a/packages/prisma/zod-utils.ts b/packages/prisma/zod-utils.ts index d7194b0955..5bece0d056 100644 --- a/packages/prisma/zod-utils.ts +++ b/packages/prisma/zod-utils.ts @@ -13,6 +13,7 @@ import type { } from "zod"; import { appDataSchemas } from "@calcom/app-store/apps.schemas.generated"; +import { routingFormResponseInDbSchema } from "@calcom/app-store/routing-forms/zod"; import dayjs from "@calcom/dayjs"; import { isPasswordValid } from "@calcom/features/auth/lib/isPasswordValid"; import type { FieldType as FormBuilderFieldType } from "@calcom/features/form-builder/schema"; @@ -252,6 +253,11 @@ export const bookingCreateBodySchema = z.object({ routedTeamMemberIds: z.array(z.number()).nullish(), routingFormResponseId: z.number().optional(), skipContactOwner: z.boolean().optional(), + + /** + * Holds the corrected responses of the Form for a booking, provided during rerouting + */ + reroutingFormResponses: routingFormResponseInDbSchema.optional(), }); export const requiredCustomInputSchema = z.union([ @@ -745,4 +751,4 @@ export const getStringAsNumberRequiredSchema = (t: TFunction) => export const bookingSeatDataSchema = z.object({ description: z.string().optional(), responses: bookingResponses, -}); \ No newline at end of file +}); diff --git a/packages/trpc/server/routers/viewer/bookings/get.handler.ts b/packages/trpc/server/routers/viewer/bookings/get.handler.ts index ac00188940..c8e4eeee34 100644 --- a/packages/trpc/server/routers/viewer/bookings/get.handler.ts +++ b/packages/trpc/server/routers/viewer/bookings/get.handler.ts @@ -210,12 +210,23 @@ export async function getBookings({ const bookingSelect = { ...bookingMinimalSelect, uid: true, + responses: true, + /** + * Who uses it - + * 1. We need to be able to decide which booking can have a 'Reroute' action + */ + routedFromRoutingFormReponse: { + select: { + id: true, + }, + }, recurringEventId: true, location: true, eventType: { select: { slug: true, id: true, + title: true, eventName: true, price: true, recurringEvent: true, @@ -225,10 +236,12 @@ export async function getBookings({ seatsShowAvailabilityCount: true, eventTypeColor: true, schedulingType: true, + length: true, team: { select: { id: true, name: true, + slug: true, }, }, }, diff --git a/packages/ui/components/dialog/Dialog.tsx b/packages/ui/components/dialog/Dialog.tsx index c1b09b158d..48e17b04c9 100644 --- a/packages/ui/components/dialog/Dialog.tsx +++ b/packages/ui/components/dialog/Dialog.tsx @@ -90,12 +90,16 @@ type DialogContentProps = React.ComponentProps<(typeof DialogPrimitive)["Content Icon?: IconName; enableOverflow?: boolean; forceOverlayWhenNoModal?: boolean; + /** + * Disables the ability to close the dialog by clicking outside of it. Could be useful when the dialog is doing something critical which might be in progress. + */ + preventCloseOnOutsideClick?: boolean; }; // enableOverflow:- use this prop whenever content inside DialogContent could overflow and require scrollbar export const DialogContent = React.forwardRef( ( - { children, title, Icon: icon, enableOverflow, forceOverlayWhenNoModal, type = "creation", ...props }, + { children, title, Icon: icon, enableOverflow, forceOverlayWhenNoModal, type = "creation", preventCloseOnOutsideClick, ...props }, forwardedRef ) => { const isPlatform = useIsPlatform(); @@ -119,6 +123,11 @@ export const DialogContent = React.forwardRef { + if (preventCloseOnOutsideClick) { + e.preventDefault(); + } + }} className={classNames( "fadeIn bg-default scroll-bar fixed left-1/2 top-1/2 z-50 w-full max-w-[22rem] -translate-x-1/2 -translate-y-1/2 rounded-md text-left shadow-xl focus-visible:outline-none sm:align-middle", props.size == "xl" diff --git a/packages/ui/components/icon/icon-list.mjs b/packages/ui/components/icon/icon-list.mjs index e706bcfb44..42f6e9e226 100644 --- a/packages/ui/components/icon/icon-list.mjs +++ b/packages/ui/components/icon/icon-list.mjs @@ -135,4 +135,5 @@ export const lucideIconList = new Set([ "webhook", "x", "zap", + "waypoints" ]); diff --git a/packages/ui/components/icon/icon-names.ts b/packages/ui/components/icon/icon-names.ts index edb4673d8a..0c86290053 100644 --- a/packages/ui/components/icon/icon-names.ts +++ b/packages/ui/components/icon/icon-names.ts @@ -132,6 +132,7 @@ export type IconName = | "users" | "venetian-mask" | "video" + | "waypoints" | "webhook" | "x" | "zap"; diff --git a/vitest.config.ts b/vitest.config.ts index 1cd76c3983..b710d9cdcc 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -1,5 +1,4 @@ import { defineConfig } from "vitest/config"; - process.env.INTEGRATION_TEST_MODE = "true"; export default defineConfig({