* feat: add no users found fallback action for routing forms - Add NoUsersFoundFallbackActionType enum with CustomPageMessage and ExternalRedirectUrl options - Add noUsersFoundFallbackAction field to zodNonRouterRoute schema - Add NoUsersFoundFallback UI component in RouteBuilder for configuring fallback action - Update handleResponse to return noUsersFoundFallbackAction when no team members match - Add i18n translation keys for new UI strings Co-Authored-By: joe@cal.com <j.auyeung419@gmail.com> * refactor: change fallback to use same action options as main route - Replace noUsersFoundFallbackAction with fallbackAction that has same structure as main action - Add action dropdown and input fields to fallback attributes query builder section - Support Event redirect, External URL, and Custom page options in fallback - Remove separate NoUsersFoundFallback toggle component - Update handleResponse to return fallbackAction Co-Authored-By: joe@cal.com <j.auyeung419@gmail.com> * fix: ensure fallbackAction type is always defined in onChange handlers Co-Authored-By: joe@cal.com <j.auyeung419@gmail.com> * fix: add guards for route.fallbackAction in onChange handlers Co-Authored-By: joe@cal.com <j.auyeung419@gmail.com> * fix: only return fallbackAction when no users are found Co-Authored-By: joe@cal.com <j.auyeung419@gmail.com> * refactor: restructure fallback section to mirror main route structure Co-Authored-By: joe@cal.com <j.auyeung419@gmail.com> * fix: ensure backwards compatibility for existing routes with fallbackAttributesQueryValue Co-Authored-By: joe@cal.com <j.auyeung419@gmail.com> * fix: initialize fallbackAction with main event type for backwards compatibility Co-Authored-By: joe@cal.com <j.auyeung419@gmail.com> * fix: include fallbackAction in getRoutesToSave to persist changes Co-Authored-By: joe@cal.com <j.auyeung419@gmail.com> * fix: use fallbackAction in getRoutedUrl when no team members found Co-Authored-By: joe@cal.com <j.auyeung419@gmail.com> * fix: use i18n for fallback label and add tests for fallback action Co-Authored-By: joe@cal.com <j.auyeung419@gmail.com> * fix: do not trigger fallback action when CRM contact owner is found Co-Authored-By: joe@cal.com <j.auyeung419@gmail.com> * fix: prioritize fallbackAction over fallbackAttributesQueryValue When a route has a fallbackAction configured, skip the fallbackAttributesQueryValue to ensure the fallbackAction is triggered when no team members are found. This maintains backwards compatibility by only using fallbackAttributesQueryValue when fallbackAction is not set. Also treats teamMemberIdsMatchingAttributeLogic being null (routing couldn't run, e.g., missing orgId) the same as an empty array for the purpose of triggering the fallbackAction. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * refactor: extract RouteActionSelector shared component for action selection UI Co-Authored-By: joe@cal.com <j.auyeung419@gmail.com> * fix: add i18n string for default custom page message Co-Authored-By: joe@cal.com <j.auyeung419@gmail.com> * feat: add webhook trigger when routing form fallback route is hit - Add ROUTING_FORM_FALLBACK_HIT to WebhookTriggerEvents enum in Prisma schema - Add new trigger to routing-forms webhook triggers in constants - Add translation string for the new webhook trigger - Implement triggerFallbackWebhook function in formSubmissionUtils.ts - Call webhook trigger from handleResponse.ts when fallback action is used Co-Authored-By: joe@cal.com <j.auyeung419@gmail.com> * fix: remove webhook object from error log to avoid exposing secrets Co-Authored-By: joe@cal.com <j.auyeung419@gmail.com> * revert: remove webhook trigger changes (to be moved to separate PR) Co-Authored-By: joe@cal.com <j.auyeung419@gmail.com> * fix: only show fallback query builder when fallbackAction is explicitly set The fallback attributes query builder was being shown for new routes when fallbackAction was not set, causing an extra 'Add rule' button to appear. This broke e2e tests that relied on the button index. Now the fallback query builder is only shown when fallbackAction is explicitly set to EventTypeRedirectUrl, which happens after the route is saved and reloaded (via auto-initialization in useRoutes). Co-Authored-By: joe@cal.com <j.auyeung419@gmail.com> * fix: use data-testid for attribute query builder selectors in e2e tests - Add data-testid='attributes-query-builder' to the main attributes query builder div - Update addAttributeRoutingRule and addAttributeRoutingRuleWithOperator to use the data-testid selector instead of nth() indices - This makes the tests more robust and independent of DOM structure changes Co-Authored-By: joe@cal.com <j.auyeung419@gmail.com> * fix: initialize fallbackAction for new routes in useCreateRoute hook - New routes created via useCreateRoute now initialize fallbackAction - This ensures new routes have a valid fallbackAction when saved - Matches the initialization logic for existing routes in useRoutes Co-Authored-By: joe@cal.com <j.auyeung419@gmail.com> * fix: initialize fallbackAction at save time when action is EventTypeRedirectUrl - When saving routes, if fallbackAction is undefined but action is EventTypeRedirectUrl, initialize fallbackAction from the main action - This handles the case where user creates a new route and changes action to an event type - Fixes e2e test failures where form save was failing due to undefined fallbackAction Co-Authored-By: joe@cal.com <j.auyeung419@gmail.com> * fix: initialize fallbackAction when main action changes to EventTypeRedirectUrl Co-Authored-By: joe@cal.com <j.auyeung419@gmail.com> * fix: only show fallback section when main action has valid event type selected Co-Authored-By: joe@cal.com <j.auyeung419@gmail.com> * fix: check for valid fallbackAction event type before preserving existing value Co-Authored-By: joe@cal.com <j.auyeung419@gmail.com> * Give more structure to main and fallback routes * Update comment * Rename fallbackAttributesQueryBuilder to matchingMembersFallbackRoute * Display custom event redirect input * fix: address Cubic AI review feedback for routing form fallback - Simplify getFallbackAction logic to use checkedFallback flag instead of manually parsing attributesQueryValue (confidence 9/10) - Add i18n translation for 'And connect with specific team members' string using t('connect_with_specific_team_members') (confidence 9/10) Co-Authored-By: unknown <> * fix: update getFallbackAction to use checkedFallback and handle missing orgId case - Use checkedFallback flag as primary indicator for triggering fallback action - Also handle edge case where attribute routing was configured but couldn't run due to missing orgId (teamMemberIdsMatchingAttributeLogic is null) - Update test to mock checkedFallback: true when expecting fallback action Co-Authored-By: unknown <> * Address UI comment * fix: Use fallbackAttributesQueryValue when fallback action is event redirect Only use fallbackAttributesQueryValue when: 1. fallbackAction is EventTypeRedirectUrl (attribute routing applies) 2. fallbackAction is not set (backwards compatibility) Skip it for CustomPageMessage and ExternalRedirectUrl fallback actions since attribute routing doesn't apply to those action types. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * fix: correct JSX syntax error in matchingMembersFallbackRoute Co-Authored-By: joe@cal.com <j.auyeung419@gmail.com> * refactor: extract getCustomSlug helper to deduplicate custom event type slug logic Co-Authored-By: joe@cal.com <j.auyeung419@gmail.com> * fix: only show fallback attribute filtering for teams under orgs Co-Authored-By: joe@cal.com <j.auyeung419@gmail.com> * fix: show fallback action for all team forms, gate attribute filtering on isOrganization Co-Authored-By: joe@cal.com <j.auyeung419@gmail.com> --------- Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com> Co-authored-by: Hariom Balhara <hariombalhara@gmail.com>
287 lines
10 KiB
TypeScript
287 lines
10 KiB
TypeScript
// !IMPORTANT! changes to this file requires publishing new version of platform libraries in order for the changes to be applied to APIV2
|
|
import { createHash } from "node:crypto";
|
|
import { stringify } from "node:querystring";
|
|
import { enrichFormWithMigrationData } from "@calcom/app-store/routing-forms/enrichFormWithMigrationData";
|
|
import { getAbsoluteEventTypeRedirectUrlWithEmbedSupport } from "@calcom/app-store/routing-forms/getEventTypeRedirectUrl";
|
|
import { getResponseToStore } from "@calcom/app-store/routing-forms/lib/getResponseToStore";
|
|
import { getSerializableForm } from "@calcom/app-store/routing-forms/lib/getSerializableForm";
|
|
import { getServerTimingHeader } from "@calcom/app-store/routing-forms/lib/getServerTimingHeader";
|
|
import { findMatchingRoute } from "@calcom/app-store/routing-forms/lib/processRoute";
|
|
import { substituteVariables } from "@calcom/app-store/routing-forms/lib/substituteVariables";
|
|
import type { FormResponse } from "@calcom/app-store/routing-forms/types/types";
|
|
import { orgDomainConfig } from "@calcom/features/ee/organizations/lib/orgDomains";
|
|
import { isAuthorizedToViewFormOnOrgDomain } from "@calcom/features/routing-forms/lib/isAuthorizedToViewForm";
|
|
import { PrismaRoutingFormRepository } from "@calcom/features/routing-forms/repositories/PrismaRoutingFormRepository";
|
|
import { getRoutingTraceService } from "@calcom/features/routing-trace/di/RoutingTraceService.container";
|
|
import { RoutingFormTraceService } from "@calcom/features/routing-trace/domains/RoutingFormTraceService";
|
|
import { UserRepository } from "@calcom/features/users/repositories/UserRepository";
|
|
import { checkRateLimitAndThrowError } from "@calcom/lib/checkRateLimitAndThrowError";
|
|
import { HttpError } from "@calcom/lib/http-error";
|
|
import logger from "@calcom/lib/logger";
|
|
import { safeStringify } from "@calcom/lib/safeStringify";
|
|
import { withReporting } from "@calcom/lib/sentryWrapper";
|
|
import prisma from "@calcom/prisma";
|
|
import { TRPCError } from "@trpc/server";
|
|
import type { GetServerSidePropsContext } from "next";
|
|
import { v4 as uuidv4 } from "uuid";
|
|
import z from "zod";
|
|
import { getUrlSearchParamsToForward } from "./getUrlSearchParamsToForward";
|
|
import { handleResponse } from "./handleResponse";
|
|
|
|
const log = logger.getSubLogger({ prefix: ["[routing-forms]", "[router]"] });
|
|
const querySchema = z
|
|
.object({
|
|
form: z.string(),
|
|
})
|
|
.catchall(z.string().or(z.array(z.string())));
|
|
|
|
const getDeterministicHashForResponse = (fieldsResponses: Record<string, unknown>) => {
|
|
const sortedFields = Object.keys(fieldsResponses)
|
|
.sort()
|
|
.reduce((obj: Record<string, unknown>, key) => {
|
|
obj[key] = fieldsResponses[key];
|
|
return obj;
|
|
}, {});
|
|
const paramsString = JSON.stringify(sortedFields);
|
|
const hash = createHash("sha256").update(paramsString).digest("hex");
|
|
return hash;
|
|
};
|
|
|
|
export function hasEmbedPath(pathWithQuery: string) {
|
|
const onlyPath = pathWithQuery.split("?")[0];
|
|
return onlyPath.endsWith("/embed") || onlyPath.endsWith("/embed/");
|
|
}
|
|
|
|
const _getRoutedUrl = async (context: Pick<GetServerSidePropsContext, "query" | "req">, fetchCrm = true) => {
|
|
// Initialize trace service for tracking routing decisions
|
|
const routingTraceService = getRoutingTraceService();
|
|
|
|
const queryParsed = querySchema.safeParse(context.query);
|
|
const isEmbed = hasEmbedPath(context.req.url || "");
|
|
const pageProps = {
|
|
isEmbed,
|
|
};
|
|
|
|
if (!queryParsed.success) {
|
|
log.warn("Error parsing query", { issues: queryParsed.error.issues });
|
|
return {
|
|
notFound: true,
|
|
};
|
|
}
|
|
|
|
// TODO: Known params reserved by Cal.com are form, embed, layout and other cal. prefixed params. We should exclude all of them from fieldsResponses.
|
|
// But they must be present in `paramsToBeForwardedAsIs` as they could be needed by Booking Page as well.
|
|
const {
|
|
form: formId,
|
|
"cal.isBookingDryRun": isBookingDryRunParam,
|
|
"cal.queueFormResponse": queueFormResponseParam,
|
|
...fieldsResponses
|
|
} = queryParsed.data;
|
|
|
|
const responseHash = getDeterministicHashForResponse(fieldsResponses);
|
|
|
|
await checkRateLimitAndThrowError({
|
|
identifier: `form:${formId}:hash:${responseHash}`,
|
|
});
|
|
|
|
const isBookingDryRun = isBookingDryRunParam === "true";
|
|
const shouldQueueFormResponse = queueFormResponseParam === "true";
|
|
const paramsToBeForwardedAsIs = {
|
|
...fieldsResponses,
|
|
// Must be forwarded if present to Booking Page. Setting it explicitly here as it is critical to be present in the URL.
|
|
...(isBookingDryRunParam ? { "cal.isBookingDryRun": isBookingDryRunParam } : null),
|
|
};
|
|
|
|
const { currentOrgDomain } = orgDomainConfig(context.req);
|
|
|
|
let timeTaken: Record<string, number | null> = {};
|
|
|
|
const formQueryStart = performance.now();
|
|
const form = await PrismaRoutingFormRepository.findFormByIdIncludeUserTeamAndOrg(formId);
|
|
timeTaken.formQuery = performance.now() - formQueryStart;
|
|
|
|
if (!form) {
|
|
return {
|
|
notFound: true,
|
|
};
|
|
}
|
|
|
|
const profileEnrichmentStart = performance.now();
|
|
const userRepo = new UserRepository(prisma);
|
|
const formWithUserProfile = {
|
|
...form,
|
|
user: await userRepo.enrichUserWithItsProfile({ user: form.user }),
|
|
};
|
|
timeTaken.profileEnrichment = performance.now() - profileEnrichmentStart;
|
|
|
|
if (
|
|
!isAuthorizedToViewFormOnOrgDomain({
|
|
user: formWithUserProfile.user,
|
|
currentOrgDomain,
|
|
team: form.team,
|
|
})
|
|
) {
|
|
return {
|
|
notFound: true,
|
|
};
|
|
}
|
|
|
|
const getSerializableFormStart = performance.now();
|
|
const serializableForm = await getSerializableForm({
|
|
form: enrichFormWithMigrationData(formWithUserProfile),
|
|
});
|
|
timeTaken.getSerializableForm = performance.now() - getSerializableFormStart;
|
|
|
|
if (!serializableForm.fields) {
|
|
throw new Error("Form has no fields");
|
|
}
|
|
const response: FormResponse = getResponseToStore({
|
|
formFields: serializableForm.fields,
|
|
fieldsResponses,
|
|
});
|
|
|
|
let routingFormTraceService: RoutingFormTraceService | undefined;
|
|
if (!isBookingDryRun) {
|
|
routingFormTraceService = new RoutingFormTraceService(routingTraceService);
|
|
}
|
|
const matchingRoute = findMatchingRoute({ form: serializableForm, response, routingFormTraceService });
|
|
if (!matchingRoute) {
|
|
throw new Error("No matching route could be found");
|
|
}
|
|
|
|
const decidedAction = matchingRoute.action;
|
|
|
|
let teamMembersMatchingAttributeLogic = null;
|
|
let formResponseId = null;
|
|
let attributeRoutingConfig = null;
|
|
let queuedFormResponseId;
|
|
let crmContactOwnerEmail: string | null = null;
|
|
let crmContactOwnerRecordType: string | null = null;
|
|
let crmAppSlug: string | null = null;
|
|
let fallbackAction: typeof decidedAction | null = null;
|
|
try {
|
|
const result = await handleResponse({
|
|
form: serializableForm,
|
|
formFillerId: uuidv4(),
|
|
response: response,
|
|
identifierKeyedResponse: fieldsResponses,
|
|
chosenRouteId: matchingRoute.id,
|
|
isPreview: isBookingDryRun,
|
|
queueFormResponse: shouldQueueFormResponse,
|
|
fetchCrm,
|
|
traceService: isBookingDryRun ? undefined : routingTraceService,
|
|
routingFormTraceService,
|
|
});
|
|
teamMembersMatchingAttributeLogic = result.teamMembersMatchingAttributeLogic;
|
|
formResponseId = result.formResponse?.id;
|
|
queuedFormResponseId = result.queuedFormResponse?.id;
|
|
attributeRoutingConfig = result.attributeRoutingConfig;
|
|
crmContactOwnerEmail = result.crmContactOwnerEmail;
|
|
crmContactOwnerRecordType = result.crmContactOwnerRecordType;
|
|
crmAppSlug = result.crmAppSlug;
|
|
fallbackAction = result.fallbackAction ?? null;
|
|
timeTaken = {
|
|
...timeTaken,
|
|
...result.timeTaken,
|
|
};
|
|
|
|
// Save the pending trace (trace steps are added inside handleResponse)
|
|
if (!isBookingDryRun) {
|
|
if (formResponseId) {
|
|
await routingTraceService.savePendingRoutingTrace({ formResponseId });
|
|
} else if (queuedFormResponseId) {
|
|
await routingTraceService.savePendingRoutingTrace({
|
|
queuedFormResponseId,
|
|
});
|
|
}
|
|
}
|
|
} catch (e) {
|
|
if (e instanceof HttpError || e instanceof TRPCError) {
|
|
return {
|
|
props: {
|
|
...pageProps,
|
|
form: serializableForm,
|
|
message: null,
|
|
errorMessage: e.message,
|
|
},
|
|
};
|
|
}
|
|
|
|
log.error("Error handling the response", safeStringify(e));
|
|
throw new Error("Error handling the response");
|
|
}
|
|
|
|
// TODO: To be done using sentry tracing
|
|
console.log("Server-Timing", getServerTimingHeader(timeTaken));
|
|
|
|
// Use fallbackAction if set (when no team members found), otherwise use the main decidedAction
|
|
const actionToUse = fallbackAction ?? decidedAction;
|
|
|
|
//TODO: Maybe take action after successful mutation
|
|
if (actionToUse.type === "customPageMessage") {
|
|
return {
|
|
props: {
|
|
...pageProps,
|
|
form: serializableForm,
|
|
message: actionToUse.value,
|
|
errorMessage: null,
|
|
},
|
|
};
|
|
} else if (actionToUse.type === "eventTypeRedirectUrl") {
|
|
const eventTypeUrlWithResolvedVariables = substituteVariables(
|
|
actionToUse.value,
|
|
response,
|
|
serializableForm.fields
|
|
);
|
|
return {
|
|
redirect: {
|
|
destination: getAbsoluteEventTypeRedirectUrlWithEmbedSupport({
|
|
eventTypeRedirectUrl: eventTypeUrlWithResolvedVariables,
|
|
form: serializableForm,
|
|
allURLSearchParams: getUrlSearchParamsToForward({
|
|
formResponse: response,
|
|
fields: serializableForm.fields,
|
|
searchParams: new URLSearchParams(
|
|
stringify({
|
|
...paramsToBeForwardedAsIs,
|
|
"cal.action": "eventTypeRedirectUrl",
|
|
})
|
|
),
|
|
teamMembersMatchingAttributeLogic,
|
|
formResponseId: formResponseId ?? null,
|
|
queuedFormResponseId: queuedFormResponseId ?? null,
|
|
attributeRoutingConfig: attributeRoutingConfig ?? null,
|
|
crmContactOwnerEmail,
|
|
crmContactOwnerRecordType,
|
|
crmAppSlug,
|
|
crmLookupDone: fetchCrm,
|
|
teamId: form?.teamId,
|
|
orgId: form.team?.parentId,
|
|
}),
|
|
isEmbed: pageProps.isEmbed,
|
|
}),
|
|
permanent: false,
|
|
},
|
|
};
|
|
} else if (actionToUse.type === "externalRedirectUrl") {
|
|
return {
|
|
redirect: {
|
|
destination: `${actionToUse.value}?${stringify(context.query)}&cal.action=externalRedirectUrl`,
|
|
permanent: false,
|
|
},
|
|
};
|
|
}
|
|
|
|
// TODO: Consider throwing error here as there is no value of decidedAction.type that would cause the flow to be here
|
|
return {
|
|
props: {
|
|
...pageProps,
|
|
form: serializableForm,
|
|
message: null,
|
|
errorMessage: "Unhandled type of action",
|
|
},
|
|
};
|
|
};
|
|
|
|
export const getRoutedUrl = withReporting(_getRoutedUrl, "getRoutedUrl");
|