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"; 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) .filter((id) => { // We don't want to reroute to the same user who booked the booking return id !== booking.user?.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, error: formResponseFetchError, } = trpc.viewer.appRoutingForms.getResponseWithFormFields.useQuery({ formResponseId: booking.routedFromRoutingFormReponse.id, }); const { t } = useLocale(); if (isRoutingFormLoading) return ( <> ); if (formResponseFetchError) { return
{formResponseFetchError.message}
; } 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.routingForms.findTeamMembersMatchingAttributeLogicOfRoute.useMutation({ onSuccess(data) { setTeamMembersMatchingAttributeLogic(data.result ? data.result.users : data.result); }, }); 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, route, }); } 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 ( ); };