Files
calendar/packages/features/routing-forms/lib/getRoutedUrl.ts
T
Joe Au-YeungGitHubClaude Opus 4.5joe@cal.com <j.auyeung419@gmail.com>joe@cal.com <j.auyeung419@gmail.com>joe@cal.com <j.auyeung419@gmail.com>Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
f536100224 feat: Add RoutingFormTrace (#27316)
* Add DB schema

* Init zod schema

* Init RoutingTrace and PendingRoutingTrace repository interfaces

* Create PrismaPendingRoutingTraceRepository

* Init RoutingTraceService

* Create RoutingTraceService container

* User routing trace service in routing

* Create RoutingTraceRepository and PrismaRoutingTraceRepoistory

* Add findByFormResponseId and findByQueuedFormResponseId to PendingRoutingTraceRepository

* Update DI containers

* RoutingTraceService create process booking method

* Use pending routing trace rather than URL params

* Fix schema

* Fix writing assignment reason for routed booking

* Remove  from  service

* Refactor RoutingTraceService to not rely on async local storage

* Pass RoutingTraceService through routing call

* Add attribute-logic-evaluated to routing trace step

* Add routing trace to trpc endpoint

* Add CRM routing trace step

* Fix extracting routing trace to assignment reason

* Add back CRM params to prevent refetching

* test: Add unit tests for RoutingTraceService and repositories

Co-Authored-By: joe@cal.com <j.auyeung419@gmail.com>

* test: Add missing mock for RoutingTraceService in getRoutedUrl tests

Also fix pre-existing lint issues in the test file:
- Add explicit types to mockForm and mockSerializableForm variables
- Add explicit type to url parameter in mockContext
- Replace 'as any' with 'as unknown as InstanceType<typeof UserRepository>'

Co-Authored-By: joe@cal.com <j.auyeung419@gmail.com>

* Link pending form submission to routing trace

* Clean up

* Add lookup field assignment reasons

* Rename to PendingRoutingTrace

* Add migration file

* fix: Update RoutingTraceService tests to use assignmentReasonRepository mock

- Add getStepsCount() method back to RoutingTraceService
- Add queuedFormResponseId support to processForBooking method
- Update tests to use mockAssignmentReasonRepository instead of prisma mock
- Remove test for missing routingTraceRepository (all deps now required)

Co-Authored-By: joe@cal.com <j.auyeung419@gmail.com>

* fix: Remove PII (organizer email) from log payload in RoutingTraceService

Addresses Cubic AI review feedback with confidence 9/10.
Logging PII violates sensitive information logging rules.

Co-Authored-By: unknown <>

* Write field values at the time of routing

* Write attributes used to route

* fix: Add CHECK constraint to ensure at least one response ID is set in routing trace tables

Co-Authored-By: joe@cal.com <j.auyeung419@gmail.com>

* fix: Address PR review comments for routing trace feature

- Add checkedFallback to routing trace step data (Comment 11)
- Extract hardcoded domain/step strings to constants (Comment 13)
- Use @default(now()) for createdAt in PendingRoutingTrace and RoutingTrace (Comment 15)
- Add DEFAULT CURRENT_TIMESTAMP to migration for createdAt fields

Co-Authored-By: joe@cal.com <j.auyeung419@gmail.com>

* fix: Use UTC-safe timezone expression for createdAt defaults in routing trace tables

Addresses Cubic AI review feedback (confidence 9/10) to prevent timezone
issues by using dbgenerated("timezone('UTC', now())") instead of bare now()
for the createdAt fields in PendingRoutingTrace and RoutingTrace models.

Co-Authored-By: unknown <>

* fix: Align migration SQL with Prisma's expected timezone syntax

Use 'UTC'::text cast in timezone() function to match Prisma's generated SQL.

Co-Authored-By: unknown <>

* fix: Revert migration SQL to match Prisma schema timezone syntax

Remove ::text cast from timezone() function to match what Prisma generates
from the schema definition.

Co-Authored-By: unknown <>

* fix: Use explicit ::text cast in timezone() for PostgreSQL compatibility

PostgreSQL normalizes timezone('UTC', now()) to timezone('UTC'::text, now())
internally. Update both schema and migration to use the explicit cast to
ensure they match and pass the migration check.

Co-Authored-By: unknown <>

* feat: Add RoutingFormTraceService domain class for type-safe routing traces

- Create RoutingFormTraceService class with routeMatched, fallbackRouteUsed,
  attributeLogicEvaluated, and attributeFallbackUsed methods
- Add ROUTING_FORM_STEPS constants
- Add unit tests

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* feat: Integrate RoutingFormTraceService for routing form tracing

- Add routingFormTraceService parameter to findMatchingRoute
- Record route_matched or fallback_route_used step when route is selected
- Use "Default Route" as name for form-level fallback routes
- Refactor findTeamMembersMatchingAttributeLogic to use RoutingFormTraceService
- Update handleResponse to accept and pass routingFormTraceService
- Create RoutingFormTraceService in getRoutedUrl and pass through the flow

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* test: Add test coverage for RoutingFormTraceService integration

Co-Authored-By: joe@cal.com <j.auyeung419@gmail.com>

* feat: Integrate RoutingFormTraceService for routing form tracing

- Add routingFormTraceService parameter to findMatchingRoute
- Record route_matched or fallback_route_used step when route is selected
- Use "Default Route" as name for form-level fallback routes
- Refactor findTeamMembersMatchingAttributeLogic to use RoutingFormTraceService
- Update handleResponse to accept and pass routingFormTraceService
- Create RoutingFormTraceService in getRoutedUrl and pass through the flow

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* fix: Restore schema and migration from main branch

Co-Authored-By: joe@cal.com <j.auyeung419@gmail.com>

* fix: Use correct parameter name routingFormTraceService in tests

Co-Authored-By: joe@cal.com <j.auyeung419@gmail.com>

* fix: Use 'default_route' instead of 'Default Route' for fallback route name

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>
2026-01-28 17:15:22 -03:00

281 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;
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;
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));
//TODO: Maybe take action after successful mutation
if (decidedAction.type === "customPageMessage") {
return {
props: {
...pageProps,
form: serializableForm,
message: decidedAction.value,
errorMessage: null,
},
};
} else if (decidedAction.type === "eventTypeRedirectUrl") {
const eventTypeUrlWithResolvedVariables = substituteVariables(
decidedAction.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,
teamId: form?.teamId,
orgId: form.team?.parentId,
}),
isEmbed: pageProps.isEmbed,
}),
permanent: false,
},
};
} else if (decidedAction.type === "externalRedirectUrl") {
return {
redirect: {
destination: `${decidedAction.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");