From c0f6a1b0887d34f1be4340ecd473aa73b3c1c33e Mon Sep 17 00:00:00 2001 From: Joe Au-Yeung <65426560+joeauyeung@users.noreply.github.com> Date: Mon, 2 Feb 2026 13:48:23 -0500 Subject: [PATCH] feat: add redirect option for non-routed visits to event types (#27468) * feat: add redirect option for non-routing form bookings Add a new event type option that redirects to a custom URL when the booking was not made through a routing form (no cal.routingFormResponseId or cal.queuedFormResponseId query parameters). Changes: - Add redirectUrlOnNoRoutingFormResponse field to EventType schema - Add UI toggle in Advanced tab to configure the redirect URL - Implement redirect logic in bookingSuccessRedirect hook - Add translations for new UI strings Co-Authored-By: joe@cal.com * fix: add redirectUrlOnNoRoutingFormResponse to test builder Co-Authored-By: joe@cal.com * fix: add redirectUrlOnNoRoutingFormResponse to BookerEvent type Co-Authored-By: joe@cal.com * fix: add redirectUrlOnNoRoutingFormResponse to getPublicEventSelect Co-Authored-By: joe@cal.com * fix: add redirect logic to getServerSideProps for non-routing form bookings Co-Authored-By: joe@cal.com * fix: remove redirectUrlOnNoRoutingFormResponse from booking success flow Co-Authored-By: joe@cal.com * fix: use 'in' operator for type narrowing on redirectUrlOnNoRoutingFormResponse Co-Authored-By: joe@cal.com * fix: exclude redirectUrlOnNoRoutingFormResponse from test assertions Co-Authored-By: joe@cal.com * fix: include redirectUrlOnNoRoutingFormResponse in test assertions and update description Co-Authored-By: joe@cal.com * fix: add redirectUrlOnNoRoutingFormResponse to mockUpdatedEventType Co-Authored-By: joe@cal.com * fix: remove redirect logic from instant meetings Co-Authored-By: joe@cal.com * fix: add redirectUrlOnNoRoutingFormResponse to EventTypeRepository select Co-Authored-By: joe@cal.com * fix: add redirectUrlOnNoRoutingFormResponse to form default values Co-Authored-By: joe@cal.com * Reword string * fix: bust tRPC cache for redirectUrlOnNoRoutingFormResponse Co-Authored-By: joe@cal.com * fix: guard redirect when rescheduleUid or bookingUid is present Co-Authored-By: joe@cal.com * fix: add redirectUrlOnNoRoutingFormResponse to dynamicEvent defaults Co-Authored-By: joe@cal.com * fix: add bookingUid guard to team getServerSideProps redirect Co-Authored-By: joe@cal.com --------- Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- .../team/[slug]/[type]/getServerSideProps.ts | 14 ++--- .../team/[slug]/[type]/getServerSideProps.tsx | 51 ++++++++++++------- .../tabs/advanced/EventAdvancedTab.tsx | 48 +++++++++++++++++ apps/web/public/static/locales/en/common.json | 2 + .../lib/[user]/[type]/getServerSideProps.ts | 38 +++++++++++--- .../test/lib/handleChildrenEventTypes.test.ts | 9 ++++ .../bookings/lib/bookingSuccessRedirect.ts | 12 +++++ .../features/eventtypes/lib/defaultEvents.ts | 1 + .../features/eventtypes/lib/getPublicEvent.ts | 7 +-- packages/features/eventtypes/lib/types.ts | 1 + .../repositories/eventTypeRepository.ts | 2 + packages/lib/test/builder.ts | 1 + .../event-types/hooks/useEventTypeForm.ts | 1 + .../migration.sql | 2 + packages/prisma/schema.prisma | 2 + .../routers/viewer/eventTypes/get.handler.ts | 2 +- .../server/routers/viewer/eventTypes/types.ts | 2 + 17 files changed, 159 insertions(+), 36 deletions(-) create mode 100644 packages/prisma/migrations/20260201053520_add_redirect_url_on_no_routing_form_response/migration.sql diff --git a/apps/web/lib/org/[orgSlug]/instant-meeting/team/[slug]/[type]/getServerSideProps.ts b/apps/web/lib/org/[orgSlug]/instant-meeting/team/[slug]/[type]/getServerSideProps.ts index a6881912f0..fee25b43d4 100644 --- a/apps/web/lib/org/[orgSlug]/instant-meeting/team/[slug]/[type]/getServerSideProps.ts +++ b/apps/web/lib/org/[orgSlug]/instant-meeting/team/[slug]/[type]/getServerSideProps.ts @@ -62,14 +62,14 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) => session?.user?.id ); - if (!eventData) { - return { - notFound: true, - } as const; - } + if (!eventData) { + return { + notFound: true, + } as const; + } - return { - props: { + return { + props: { eventData, entity: eventData.entity, eventTypeId: eventData.id, diff --git a/apps/web/lib/team/[slug]/[type]/getServerSideProps.tsx b/apps/web/lib/team/[slug]/[type]/getServerSideProps.tsx index 57cf20a313..831db5c073 100644 --- a/apps/web/lib/team/[slug]/[type]/getServerSideProps.tsx +++ b/apps/web/lib/team/[slug]/[type]/getServerSideProps.tsx @@ -30,8 +30,8 @@ function hasApiV2RouteInEnv() { export const getServerSideProps = async (context: GetServerSidePropsContext) => { const { req, params, query } = context; const session = await getServerSession({ req }); - const { slug: teamSlug, type: meetingSlug } = paramsSchema.parse(params); - const { rescheduleUid, isInstantMeeting: queryIsInstantMeeting } = query; + const { slug: teamSlug, type: meetingSlug } = paramsSchema.parse(params); + const { rescheduleUid, bookingUid, isInstantMeeting: queryIsInstantMeeting } = query; const allowRescheduleForCancelledBooking = query.allowRescheduleForCancelledBooking === "true"; const { currentOrgDomain, isValidOrgDomain } = orgDomainConfig(req, params?.orgSlug); @@ -55,11 +55,23 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) => const eventData = team.eventTypes[0]; - if (eventData.schedulingType === SchedulingType.MANAGED) { - return { notFound: true } as const; - } + if (eventData.schedulingType === SchedulingType.MANAGED) { + return { notFound: true } as const; + } - if (rescheduleUid && eventData.disableRescheduling) { + // Redirect if no routing form response and redirect URL is configured + // Don't redirect if this is a reschedule flow or seated booking flow + const hasRoutingFormResponse = query["cal.routingFormResponseId"] || query["cal.queuedFormResponseId"]; + if (!hasRoutingFormResponse && !rescheduleUid && !bookingUid && eventData.redirectUrlOnNoRoutingFormResponse) { + return { + redirect: { + destination: eventData.redirectUrlOnNoRoutingFormResponse, + permanent: false, + }, + }; + } + + if (rescheduleUid && eventData.disableRescheduling) { return { redirect: { destination: `/booking/${rescheduleUid}`, permanent: false } }; } @@ -239,19 +251,20 @@ const getTeamWithEventsData = async ( where: { slug: meetingSlug, }, - select: { - id: true, - title: true, - isInstantEvent: true, - schedulingType: true, - metadata: true, - length: true, - hidden: true, - disableCancelling: true, - disableRescheduling: true, - allowReschedulingCancelledBookings: true, - interfaceLanguage: true, - hosts: { + select: { + id: true, + title: true, + isInstantEvent: true, + schedulingType: true, + metadata: true, + length: true, + hidden: true, + disableCancelling: true, + disableRescheduling: true, + allowReschedulingCancelledBookings: true, + redirectUrlOnNoRoutingFormResponse: true, + interfaceLanguage: true, + hosts: { take: 3, select: { user: { diff --git a/apps/web/modules/event-types/components/tabs/advanced/EventAdvancedTab.tsx b/apps/web/modules/event-types/components/tabs/advanced/EventAdvancedTab.tsx index 4e3348a23d..a44fb9f3d4 100644 --- a/apps/web/modules/event-types/components/tabs/advanced/EventAdvancedTab.tsx +++ b/apps/web/modules/event-types/components/tabs/advanced/EventAdvancedTab.tsx @@ -445,6 +445,9 @@ export const EventAdvancedTab = ({ setInterfaceLanguageVisible(watchedInterfaceLanguage !== null && watchedInterfaceLanguage !== undefined); }, [watchedInterfaceLanguage]); const [redirectUrlVisible, setRedirectUrlVisible] = useState(!!formMethods.getValues("successRedirectUrl")); + const [noRoutingFormRedirectUrlVisible, setNoRoutingFormRedirectUrlVisible] = useState( + !!formMethods.getValues("redirectUrlOnNoRoutingFormResponse") + ); const bookingFields: Prisma.JsonObject = {}; const workflows = eventType.workflows.map((workflowOnEventType) => workflowOnEventType.workflow); @@ -949,6 +952,51 @@ export const EventAdvancedTab = ({ )} /> + ( + <> + { + setNoRoutingFormRedirectUrlVisible(e); + onChange(e ? value : ""); + }}> +
+ +
+
+ + )} + /> {!isPlatform && ( Learn more", diff --git a/apps/web/server/lib/[user]/[type]/getServerSideProps.ts b/apps/web/server/lib/[user]/[type]/getServerSideProps.ts index 2923b8674a..d8c7400b56 100644 --- a/apps/web/server/lib/[user]/[type]/getServerSideProps.ts +++ b/apps/web/server/lib/[user]/[type]/getServerSideProps.ts @@ -168,6 +168,19 @@ async function getDynamicGroupPageProps(context: GetServerSidePropsContext) { } as const; } + // Redirect if no routing form response and redirect URL is configured + // Don't redirect if this is a reschedule or seated booking flow + const hasRoutingFormResponse = + context.query["cal.routingFormResponseId"] || context.query["cal.queuedFormResponseId"]; + if (!hasRoutingFormResponse && !rescheduleUid && !bookingUid && "redirectUrlOnNoRoutingFormResponse" in eventData && eventData.redirectUrlOnNoRoutingFormResponse) { + return { + redirect: { + destination: eventData.redirectUrlOnNoRoutingFormResponse, + permanent: false, + }, + }; + } + const props: Props = { eventData: { ...eventData, @@ -254,13 +267,26 @@ async function getUserPageProps(context: GetServerSidePropsContext) { session?.user?.id ); - if (!eventData) { - return { - notFound: true, - } as const; - } + if (!eventData) { + return { + notFound: true, + } as const; + } - const allowSEOIndexing = org + // Redirect if no routing form response and redirect URL is configured + // Don't redirect if this is a reschedule or seated booking flow + const hasRoutingFormResponse = + context.query["cal.routingFormResponseId"] || context.query["cal.queuedFormResponseId"]; + if (!hasRoutingFormResponse && !rescheduleUid && !bookingUid && "redirectUrlOnNoRoutingFormResponse" in eventData && eventData.redirectUrlOnNoRoutingFormResponse) { + return { + redirect: { + destination: eventData.redirectUrlOnNoRoutingFormResponse, + permanent: false, + }, + }; + } + + const allowSEOIndexing= org ? user?.profile?.organization?.organizationSettings?.allowSEOIndexing ? user?.allowSEOIndexing : false diff --git a/apps/web/test/lib/handleChildrenEventTypes.test.ts b/apps/web/test/lib/handleChildrenEventTypes.test.ts index 58fc53827e..e4e98f4b3a 100644 --- a/apps/web/test/lib/handleChildrenEventTypes.test.ts +++ b/apps/web/test/lib/handleChildrenEventTypes.test.ts @@ -144,6 +144,7 @@ describe("handleChildrenEventTypes", () => { includeNoShowInRRCalculation, instantMeetingScheduleId, enablePerHostLocations, + redirectUrlOnNoRoutingFormResponse, ...evType } = mockFindFirstEventType({ id: 123, @@ -171,6 +172,7 @@ describe("handleChildrenEventTypes", () => { includeNoShowInRRCalculation, instantMeetingScheduleId, enablePerHostLocations, + redirectUrlOnNoRoutingFormResponse, }; prismaMock.eventType.createManyAndReturn.mockResolvedValue([createdEventType]); @@ -233,6 +235,7 @@ describe("handleChildrenEventTypes", () => { includeNoShowInRRCalculation, instantMeetingScheduleId, enablePerHostLocations, + redirectUrlOnNoRoutingFormResponse, ...evType } = mockFindFirstEventType({ metadata: { managedEventConfig: {} }, @@ -360,6 +363,7 @@ describe("handleChildrenEventTypes", () => { instantMeetingScheduleId, assignRRMembersUsingSegment, enablePerHostLocations, + redirectUrlOnNoRoutingFormResponse, ...evType } = mockFindFirstEventType({ id: 123, @@ -388,6 +392,7 @@ describe("handleChildrenEventTypes", () => { instantMeetingScheduleId, assignRRMembersUsingSegment, enablePerHostLocations, + redirectUrlOnNoRoutingFormResponse, }; prismaMock.eventType.createManyAndReturn.mockResolvedValue([createdEventType]); @@ -452,6 +457,7 @@ describe("handleChildrenEventTypes", () => { rrSegmentQueryValue, useEventLevelSelectedCalendars, enablePerHostLocations, + redirectUrlOnNoRoutingFormResponse, ...evType } = mockFindFirstEventType({ metadata: { managedEventConfig: {} }, @@ -527,6 +533,7 @@ describe("handleChildrenEventTypes", () => { instantMeetingScheduleId, assignRRMembersUsingSegment, enablePerHostLocations, + redirectUrlOnNoRoutingFormResponse, ...evType } = mockFindFirstEventType({ metadata: { managedEventConfig: {} }, @@ -562,6 +569,7 @@ describe("handleChildrenEventTypes", () => { instantMeetingScheduleId, assignRRMembersUsingSegment, enablePerHostLocations, + redirectUrlOnNoRoutingFormResponse, }; prismaMock.eventType.createManyAndReturn.mockResolvedValue([createdEventType]); @@ -588,6 +596,7 @@ describe("handleChildrenEventTypes", () => { assignRRMembersUsingSegment: false, includeNoShowInRRCalculation: false, enablePerHostLocations, + redirectUrlOnNoRoutingFormResponse, ...evType, }; diff --git a/packages/features/bookings/lib/bookingSuccessRedirect.ts b/packages/features/bookings/lib/bookingSuccessRedirect.ts index 55fc7d1c91..2319942b85 100644 --- a/packages/features/bookings/lib/bookingSuccessRedirect.ts +++ b/packages/features/bookings/lib/bookingSuccessRedirect.ts @@ -189,9 +189,11 @@ export const useBookingSuccessRedirect = () => { query, booking, forwardParamsSuccessRedirect, + redirectUrlOnNoRoutingFormResponse, }: { successRedirectUrl: EventType["successRedirectUrl"]; forwardParamsSuccessRedirect: EventType["forwardParamsSuccessRedirect"]; + redirectUrlOnNoRoutingFormResponse?: EventType["redirectUrlOnNoRoutingFormResponse"]; query: Record; booking: SuccessRedirectBookingType; }) => { @@ -201,6 +203,16 @@ export const useBookingSuccessRedirect = () => { "cal.rerouting": searchParams.get("cal.rerouting"), }; + // Check if there's no routing form response and redirect URL is configured + const hasRoutingFormResponse = + searchParams.get("cal.routingFormResponseId") || searchParams.get("cal.queuedFormResponseId"); + + if (!hasRoutingFormResponse && redirectUrlOnNoRoutingFormResponse) { + const url = new URL(redirectUrlOnNoRoutingFormResponse); + navigateInTopWindow(url.toString()); + return; + } + if (successRedirectUrl) { const url = new URL(successRedirectUrl); // Using parent ensures, Embed iframe would redirect outside of the iframe. diff --git a/packages/features/eventtypes/lib/defaultEvents.ts b/packages/features/eventtypes/lib/defaultEvents.ts index 969e30bbd2..bf611fb787 100644 --- a/packages/features/eventtypes/lib/defaultEvents.ts +++ b/packages/features/eventtypes/lib/defaultEvents.ts @@ -155,6 +155,7 @@ const commons = { updatedAt: null, rrHostSubsetEnabled: false, enablePerHostLocations: false, + redirectUrlOnNoRoutingFormResponse: null, }; export const dynamicEvent = { diff --git a/packages/features/eventtypes/lib/getPublicEvent.ts b/packages/features/eventtypes/lib/getPublicEvent.ts index b240741c46..9fe8a0d386 100644 --- a/packages/features/eventtypes/lib/getPublicEvent.ts +++ b/packages/features/eventtypes/lib/getPublicEvent.ts @@ -129,9 +129,10 @@ export const getPublicEventSelect = (fetchAllUsers: boolean) => { }, }, }, - successRedirectUrl: true, - forwardParamsSuccessRedirect: true, - workflows: { + successRedirectUrl: true, + forwardParamsSuccessRedirect: true, + redirectUrlOnNoRoutingFormResponse: true, + workflows: { include: { workflow: { include: { diff --git a/packages/features/eventtypes/lib/types.ts b/packages/features/eventtypes/lib/types.ts index 39c3c51ed1..07c7b43281 100644 --- a/packages/features/eventtypes/lib/types.ts +++ b/packages/features/eventtypes/lib/types.ts @@ -160,6 +160,7 @@ export type FormValues = { externalId: string; }; successRedirectUrl: string; + redirectUrlOnNoRoutingFormResponse: string; durationLimits?: IntervalLimit; bookingLimits?: IntervalLimit; onlyShowFirstAvailableSlot: boolean; diff --git a/packages/features/eventtypes/repositories/eventTypeRepository.ts b/packages/features/eventtypes/repositories/eventTypeRepository.ts index 8282eba047..53831fe9ca 100644 --- a/packages/features/eventtypes/repositories/eventTypeRepository.ts +++ b/packages/features/eventtypes/repositories/eventTypeRepository.ts @@ -622,6 +622,7 @@ export class EventTypeRepository implements IEventTypesRepository { isRRWeightsEnabled: true, rescheduleWithSameRoundRobinHost: true, successRedirectUrl: true, + redirectUrlOnNoRoutingFormResponse: true, forwardParamsSuccessRedirect: true, currency: true, bookingFields: true, @@ -937,6 +938,7 @@ export class EventTypeRepository implements IEventTypesRepository { isRRWeightsEnabled: true, rescheduleWithSameRoundRobinHost: true, successRedirectUrl: true, + redirectUrlOnNoRoutingFormResponse: true, forwardParamsSuccessRedirect: true, currency: true, bookingFields: true, diff --git a/packages/lib/test/builder.ts b/packages/lib/test/builder.ts index 2387a2e704..1f0f676bc4 100644 --- a/packages/lib/test/builder.ts +++ b/packages/lib/test/builder.ts @@ -147,6 +147,7 @@ export const buildEventType = (eventType?: Partial): EventType => { metadata: null, successRedirectUrl: null, forwardParamsSuccessRedirect: true, + redirectUrlOnNoRoutingFormResponse: null, bookingFields: [], parentId: null, profileId: null, diff --git a/packages/platform/atoms/event-types/hooks/useEventTypeForm.ts b/packages/platform/atoms/event-types/hooks/useEventTypeForm.ts index cce37f545c..625279de04 100644 --- a/packages/platform/atoms/event-types/hooks/useEventTypeForm.ts +++ b/packages/platform/atoms/event-types/hooks/useEventTypeForm.ts @@ -103,6 +103,7 @@ export const useEventTypeForm = ({ hosts: eventType.hosts.sort((a, b) => sortHosts(a, b, eventType.isRRWeightsEnabled)), hostGroups: eventType.hostGroups || [], successRedirectUrl: eventType.successRedirectUrl || "", + redirectUrlOnNoRoutingFormResponse: eventType.redirectUrlOnNoRoutingFormResponse || "", forwardParamsSuccessRedirect: eventType.forwardParamsSuccessRedirect, users: eventType.users, useEventTypeDestinationCalendarEmail: eventType.useEventTypeDestinationCalendarEmail, diff --git a/packages/prisma/migrations/20260201053520_add_redirect_url_on_no_routing_form_response/migration.sql b/packages/prisma/migrations/20260201053520_add_redirect_url_on_no_routing_form_response/migration.sql new file mode 100644 index 0000000000..57ba25c19b --- /dev/null +++ b/packages/prisma/migrations/20260201053520_add_redirect_url_on_no_routing_form_response/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE "public"."EventType" ADD COLUMN "redirectUrlOnNoRoutingFormResponse" TEXT; diff --git a/packages/prisma/schema.prisma b/packages/prisma/schema.prisma index 62c5d6c583..75df681615 100644 --- a/packages/prisma/schema.prisma +++ b/packages/prisma/schema.prisma @@ -235,6 +235,8 @@ model EventType { /// @zod.import(["import { successRedirectUrl } from '../../zod-utils'"]).custom.use(successRedirectUrl) successRedirectUrl String? forwardParamsSuccessRedirect Boolean? @default(true) + /// @zod.import(["import { successRedirectUrl } from '../../zod-utils'"]).custom.use(successRedirectUrl) + redirectUrlOnNoRoutingFormResponse String? workflows WorkflowsOnEventTypes[] /// @zod.import(["import { intervalLimitsType } from '../../zod-utils'"]).custom.use(intervalLimitsType) bookingLimits Json? diff --git a/packages/trpc/server/routers/viewer/eventTypes/get.handler.ts b/packages/trpc/server/routers/viewer/eventTypes/get.handler.ts index 1c464ac12d..028d166dad 100644 --- a/packages/trpc/server/routers/viewer/eventTypes/get.handler.ts +++ b/packages/trpc/server/routers/viewer/eventTypes/get.handler.ts @@ -1,4 +1,4 @@ -// Cache bust: workflows.activeOn.eventType no longer includes _count.children +// Cache bust: added redirectUrlOnNoRoutingFormResponse to eventType import getEventTypeById from "@calcom/features/eventtypes/lib/getEventTypeById"; import type { PrismaClient } from "@calcom/prisma"; diff --git a/packages/trpc/server/routers/viewer/eventTypes/types.ts b/packages/trpc/server/routers/viewer/eventTypes/types.ts index 3a6bac9192..2c93435502 100644 --- a/packages/trpc/server/routers/viewer/eventTypes/types.ts +++ b/packages/trpc/server/routers/viewer/eventTypes/types.ts @@ -204,6 +204,7 @@ export type TUpdateInputSchema = { metadata?: EventTypeMetadata; successRedirectUrl?: string | null; forwardParamsSuccessRedirect?: boolean | null; + redirectUrlOnNoRoutingFormResponse?: string | null; bookingLimits?: IntervalLimit | null; durationLimits?: IntervalLimit | null; isInstantEvent?: boolean; @@ -401,6 +402,7 @@ const BaseEventTypeUpdateInput: z.ZodType = z metadata: EventTypeMetaDataSchema.optional(), successRedirectUrl: z.string().nullable().optional(), forwardParamsSuccessRedirect: z.boolean().nullable().optional(), + redirectUrlOnNoRoutingFormResponse: z.string().nullable().optional(), bookingLimits: intervalLimitsType.nullable().optional(), durationLimits: intervalLimitsType.nullable().optional(), isInstantEvent: z.boolean().optional(),