* feat: filter embed query params from booking success redirects - Add filterEmbedParams option to getNewSearchParams function - Filter out embed, layout, embedType, and ui.color-scheme params when redirecting to external pages - Add comprehensive unit tests for bookingSuccessRedirect covering: - External redirects with and without parameter forwarding - Embed parameter filtering functionality - Internal redirects to booking pages - Booking parameter extraction - All tests pass and type checking succeeds Fixes #20469 Co-Authored-By: hariom@cal.com <hariom@cal.com> * chore: retrigger CI checks Co-Authored-By: hariom@cal.com <hariom@cal.com> * chore: remove temporary CI retrigger file Co-Authored-By: hariom@cal.com <hariom@cal.com> * Handle a case --------- Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Co-authored-by: hariom@cal.com <hariom@cal.com> Co-authored-by: Hariom Balhara <hariombalhara@gmail.com>
254 lines
8.7 KiB
TypeScript
254 lines
8.7 KiB
TypeScript
import type { EventType } from "@prisma/client";
|
|
import { useRouter } from "next/navigation";
|
|
|
|
import dayjs from "@calcom/dayjs";
|
|
import type { PaymentPageProps } from "@calcom/ee/payments/pages/payment";
|
|
import { useIsEmbed } from "@calcom/embed-core/embed-iframe";
|
|
import type { BookingResponse } from "@calcom/features/bookings/types";
|
|
import { useCompatSearchParams } from "@calcom/lib/hooks/useCompatSearchParams";
|
|
import { navigateInTopWindow } from "@calcom/lib/navigateInTopWindow";
|
|
|
|
import { getSafe } from "./getSafe";
|
|
|
|
export function getNewSearchParams(args: {
|
|
query: Record<string, string | null | undefined | boolean>;
|
|
searchParams?: URLSearchParams;
|
|
filterInternalParams?: boolean;
|
|
}) {
|
|
const { query, searchParams, filterInternalParams = false } = args;
|
|
const newSearchParams = new URLSearchParams();
|
|
|
|
// Embed-specific params
|
|
const embedParams = new Set(["embed", "layout", "embedType", "ui.color-scheme"]);
|
|
|
|
// Webapp-specific params
|
|
const webappParams = new Set(["overlayCalendar"]);
|
|
|
|
// Add non-excluded params from searchParams if provided
|
|
if (searchParams) {
|
|
searchParams.forEach((value, key) => {
|
|
if (shouldExcludeParam(key)) {
|
|
return;
|
|
}
|
|
newSearchParams.append(key, value);
|
|
});
|
|
}
|
|
|
|
// Add params from query, filtering excluded params
|
|
Object.entries(query).forEach(([key, value]) => {
|
|
if (value === null || value === undefined) {
|
|
return;
|
|
}
|
|
|
|
if (shouldExcludeParam(key)) {
|
|
return;
|
|
}
|
|
|
|
newSearchParams.append(key, String(value));
|
|
});
|
|
|
|
function shouldExcludeParam(key: string) {
|
|
if (filterInternalParams) {
|
|
return embedParams.has(key) || webappParams.has(key);
|
|
}
|
|
return false;
|
|
}
|
|
|
|
return newSearchParams;
|
|
}
|
|
|
|
type SuccessRedirectBookingType = Pick<
|
|
BookingResponse | PaymentPageProps["booking"],
|
|
"uid" | "title" | "description" | "startTime" | "endTime" | "location" | "attendees" | "user" | "responses"
|
|
>;
|
|
|
|
type BookingResponseKey = keyof SuccessRedirectBookingType;
|
|
|
|
type ResultType = {
|
|
[key in BookingResponseKey]?: SuccessRedirectBookingType[key];
|
|
} & {
|
|
hostName?: string[];
|
|
attendeeName?: string | null;
|
|
hostStartTime?: string | null;
|
|
attendeeStartTime?: string | null;
|
|
guestEmails?: string[] | null;
|
|
phone?: string | null;
|
|
attendeeFirstName?: string | null;
|
|
attendeeLastName?: string | null;
|
|
};
|
|
|
|
export const getBookingRedirectExtraParams = (booking: SuccessRedirectBookingType) => {
|
|
const redirectQueryParamKeys: BookingResponseKey[] = [
|
|
"title",
|
|
"description",
|
|
"startTime",
|
|
"endTime",
|
|
"location",
|
|
"attendees",
|
|
"user",
|
|
"responses",
|
|
];
|
|
|
|
// Helper function to extract response details (e.g., phone, attendee's first and last name)
|
|
function extractResponseDetails(booking: SuccessRedirectBookingType, obj: ResultType): ResultType {
|
|
const result: ResultType = { ...obj };
|
|
const phone = getSafe<string>(booking.responses, ["phone"]);
|
|
const firstName = getSafe<string>(booking.responses, ["name", "firstName"]);
|
|
const lastName = getSafe<string>(booking.responses, ["name", "lastName"]);
|
|
const name = getSafe<string>(booking.responses, ["name"]);
|
|
|
|
if (phone) result.phone = phone;
|
|
if (firstName) result.attendeeFirstName = firstName;
|
|
if (lastName) result.attendeeLastName = lastName;
|
|
else if (name && typeof name === "string") result.attendeeName = name; // Fallback if `name` is a string instead of an object
|
|
|
|
return result;
|
|
}
|
|
|
|
// Helper function to extract user details (e.g., host name and time zone)
|
|
function extractUserDetails(booking: SuccessRedirectBookingType, obj: ResultType): ResultType {
|
|
if (booking.user?.name) {
|
|
const hostStartTime = dayjs(booking.startTime).tz(booking.user.timeZone).format();
|
|
return {
|
|
...obj,
|
|
hostName: [...(obj.hostName || []), booking.user.name],
|
|
hostStartTime,
|
|
};
|
|
}
|
|
return obj;
|
|
}
|
|
|
|
// Helper function to extract attendee and guest details
|
|
function extractAttendeesAndGuests(booking: SuccessRedirectBookingType, obj: ResultType): ResultType {
|
|
if (!Array.isArray(booking.attendees) || booking.attendees.length === 0) return obj;
|
|
|
|
const attendeeName = booking.attendees[0]?.name || null;
|
|
const attendeeTimeZone = booking.attendees[0]?.timeZone || "UTC";
|
|
const attendeeStartTime = dayjs(booking.startTime).tz(attendeeTimeZone).format();
|
|
|
|
const { hostNames, guestEmails } = booking.attendees.slice(1).reduce(
|
|
(acc, attendee) => {
|
|
if (attendee.name) {
|
|
acc.hostNames.push(attendee.name);
|
|
} else if (attendee.email) {
|
|
acc.guestEmails.push(attendee.email);
|
|
}
|
|
return acc;
|
|
},
|
|
{ hostNames: [], guestEmails: [] } as { hostNames: string[]; guestEmails: string[] }
|
|
);
|
|
|
|
return {
|
|
...obj,
|
|
attendeeName,
|
|
attendeeStartTime,
|
|
hostName: [...(obj.hostName || []), ...hostNames],
|
|
guestEmails: guestEmails.length > 0 ? guestEmails : undefined,
|
|
};
|
|
}
|
|
|
|
const bookingParams = (Object.keys(booking) as BookingResponseKey[])
|
|
.filter((key) => redirectQueryParamKeys.includes(key))
|
|
.reduce<ResultType>(
|
|
(obj, key) => {
|
|
if (key === "responses") return extractResponseDetails(booking, obj);
|
|
if (key === "user") return extractUserDetails(booking, obj);
|
|
if (key === "attendees") return extractAttendeesAndGuests(booking, obj);
|
|
return { ...obj, [key]: booking[key] };
|
|
},
|
|
{ uid: booking.uid }
|
|
);
|
|
|
|
const queryCompatibleParams: Record<string, string | boolean | null | undefined> = {
|
|
...Object.fromEntries(
|
|
Object.entries(bookingParams).map(([key, value]) => {
|
|
if (Array.isArray(value)) {
|
|
return [key, value.join(", ")];
|
|
}
|
|
if (typeof value === "object" && value !== null) {
|
|
// Skip complex objects (user, attendees) as we are extracting only needed fields
|
|
return [key, undefined];
|
|
}
|
|
return [key, value];
|
|
})
|
|
),
|
|
hostName: bookingParams.hostName?.join(", "),
|
|
attendeeName: bookingParams.attendeeName || undefined,
|
|
hostStartTime: bookingParams.hostStartTime || undefined,
|
|
attendeeStartTime: bookingParams.attendeeStartTime || undefined,
|
|
};
|
|
|
|
return queryCompatibleParams;
|
|
};
|
|
|
|
export const useBookingSuccessRedirect = () => {
|
|
const router = useRouter();
|
|
const searchParams = useCompatSearchParams();
|
|
const isEmbed = useIsEmbed();
|
|
const bookingSuccessRedirect = ({
|
|
successRedirectUrl,
|
|
query,
|
|
booking,
|
|
forwardParamsSuccessRedirect,
|
|
}: {
|
|
successRedirectUrl: EventType["successRedirectUrl"];
|
|
forwardParamsSuccessRedirect: EventType["forwardParamsSuccessRedirect"];
|
|
query: Record<string, string | null | undefined | boolean>;
|
|
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.
|
|
if (!forwardParamsSuccessRedirect) {
|
|
navigateInTopWindow(url.toString());
|
|
return;
|
|
}
|
|
|
|
const bookingExtraParams = getBookingRedirectExtraParams(booking);
|
|
|
|
// Filter internal Cal.com params when redirecting to external URLs.
|
|
// - It prevents leaking internal state.
|
|
// - Certain websites might break due to the presence of certain params e.g. Wordpress has different meaning for `embed` param and an embed param passed by Cal.com breaks a wordpress webpage
|
|
const newSearchParams = getNewSearchParams({
|
|
query: {
|
|
...query,
|
|
...bookingExtraParams,
|
|
isEmbed,
|
|
},
|
|
searchParams: new URLSearchParams(searchParams.toString()),
|
|
filterInternalParams: true,
|
|
});
|
|
|
|
newSearchParams.forEach((value, key) => {
|
|
url.searchParams.append(key, value);
|
|
});
|
|
|
|
navigateInTopWindow(url.toString());
|
|
return;
|
|
}
|
|
|
|
// TODO: Abstract it out and reuse at other places where we navigate within the embed. Though this is needed only in case of hard navigation happening but we aren't sure where hard navigation happens and where a soft navigation
|
|
// This is specially true after App Router it seems
|
|
const headersRelatedSearchParams = searchParams
|
|
? {
|
|
"flag.coep": searchParams.get("flag.coep") ?? "false",
|
|
}
|
|
: undefined;
|
|
|
|
// We don't want to forward all search params, as they could possibly break the booking page.
|
|
const newSearchParams = getNewSearchParams({
|
|
query,
|
|
searchParams: new URLSearchParams(headersRelatedSearchParams),
|
|
});
|
|
return router.push(`/booking/${booking.uid}${isEmbed ? "/embed" : ""}?${newSearchParams.toString()}`);
|
|
};
|
|
|
|
return bookingSuccessRedirect;
|
|
};
|