Files
calendar/packages/features/routing-forms/lib/getUrlSearchParamsToForward.ts
T
Joe Au-YeungGitHubunknown <>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>Pedro Castro
620445d85f feat: Use RoutingTraceService to write assignment reasons (#27225)
* 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 <>

* fix: Use CURRENT_TIMESTAMP for createdAt defaults (standard Cal.com pattern)

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

* fix: Use @default(now()) for createdAt in routing trace tables to match migration

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: Pedro Castro <pedro@cal.com>
2026-01-28 12:45:15 -05:00

205 lines
7.0 KiB
TypeScript

import { ROUTING_FORM_RESPONSE_ID_QUERY_STRING } from "@calcom/app-store/routing-forms/lib/constants";
import getFieldIdentifier from "@calcom/app-store/routing-forms/lib/getFieldIdentifier";
import type { FormResponse, LocalRoute } from "@calcom/app-store/routing-forms/types/types";
type FormResponseValueOnly = { [key: string]: { value: FormResponse[keyof FormResponse]["value"] } };
type AttributeRoutingConfig = NonNullable<LocalRoute["attributeRoutingConfig"]>;
type GetUrlSearchParamsToForwardOptions = {
formResponse: Record<
string,
{
value: number | string | string[];
}
>;
fields: {
id: string;
type: string;
label: string;
options?: {
id: string | null;
label: string;
}[];
identifier?: string;
}[];
searchParams: URLSearchParams;
formResponseId: number | null;
queuedFormResponseId: string | null;
teamMembersMatchingAttributeLogic: number[] | null;
attributeRoutingConfig: AttributeRoutingConfig | null;
crmContactOwnerEmail?: string | null;
crmContactOwnerRecordType?: string | null;
crmAppSlug?: string | null;
reroutingFormResponses?: FormResponseValueOnly;
teamId?: number | null;
orgId?: number | null;
};
export function getUrlSearchParamsToForward({
formResponse,
fields,
searchParams,
teamMembersMatchingAttributeLogic,
formResponseId,
queuedFormResponseId,
attributeRoutingConfig,
crmContactOwnerEmail,
crmContactOwnerRecordType,
crmAppSlug,
reroutingFormResponses,
teamId,
orgId,
}: GetUrlSearchParamsToForwardOptions) {
type Params = Record<string, string | string[]>;
const paramsFromResponse: Params = {};
const paramsFromCurrentUrl: Params = {};
// Build query params from response
Object.entries(formResponse).forEach(([key, fieldResponse]) => {
const foundField = fields.find((f) => f.id === key);
if (!foundField) {
// If for some reason, the field isn't there, let's just
return;
}
let valueAsStringOrStringArray =
typeof fieldResponse.value === "number" ? String(fieldResponse.value) : fieldResponse.value;
if (foundField.type === "select" || foundField.type === "multiselect") {
const options = foundField.options || [];
let arr =
valueAsStringOrStringArray instanceof Array
? valueAsStringOrStringArray
: [valueAsStringOrStringArray];
arr = arr.map((idOrLabel) => {
const foundOptionById = options.find((option) => {
return option.id === idOrLabel;
});
if (foundOptionById) {
return foundOptionById.label;
}
return idOrLabel;
});
valueAsStringOrStringArray = foundField.type === "select" ? arr[0] : arr;
}
paramsFromResponse[getFieldIdentifier(foundField) as keyof typeof paramsFromResponse] =
valueAsStringOrStringArray;
});
// Build query params from current URL. It excludes route params
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
for (const [name, value] of searchParams.entries()) {
const target = paramsFromCurrentUrl[name];
if (target instanceof Array) {
target.push(value);
} else {
paramsFromCurrentUrl[name] = [value];
}
}
const attributeRoutingConfigParams: Record<string, any> = {};
if (attributeRoutingConfig) {
for (const key of Object.keys(attributeRoutingConfig)) {
if (key === "skipContactOwner" && attributeRoutingConfig[key]) {
attributeRoutingConfigParams["cal.skipContactOwner"] = "true";
}
// TODO: How do we move this logic to their respective app packages
if (key === "salesforce") {
const salesforceData = attributeRoutingConfig[key];
if (salesforceData?.rrSkipToAccountLookupField && salesforceData.rrSKipToAccountLookupFieldName) {
attributeRoutingConfigParams["cal.salesforce.rrSkipToAccountLookupField"] = "true";
}
}
}
}
const allQueryParams: Params = {
...(teamId && { ["cal.teamId"]: `${teamId}` }),
...(orgId && { ["cal.orgId"]: `${orgId}` }),
...paramsFromCurrentUrl,
// In case of conflict b/w paramsFromResponse and paramsFromCurrentUrl, paramsFromResponse should win as the booker probably improved upon the prefilled value.
...paramsFromResponse,
...(teamMembersMatchingAttributeLogic
? { ["cal.routedTeamMemberIds"]: teamMembersMatchingAttributeLogic.join(",") }
: null),
...(typeof formResponseId === "number"
? { [ROUTING_FORM_RESPONSE_ID_QUERY_STRING]: String(formResponseId) }
: null),
...(queuedFormResponseId ? { ["cal.queuedFormResponseId"]: queuedFormResponseId } : null),
...attributeRoutingConfigParams,
...(crmContactOwnerEmail ? { ["cal.crmContactOwnerEmail"]: crmContactOwnerEmail } : null),
...(crmContactOwnerRecordType ? { ["cal.crmContactOwnerRecordType"]: crmContactOwnerRecordType } : null),
...(crmAppSlug ? { ["cal.crmAppSlug"]: crmAppSlug } : null),
...(reroutingFormResponses
? { ["cal.reroutingFormResponses"]: JSON.stringify(reroutingFormResponses) }
: null),
};
const allQueryURLSearchParams = new URLSearchParams();
// Make serializable URLSearchParams instance
Object.entries(allQueryParams).forEach(([param, value]) => {
const valueArray = value instanceof Array ? value : [value];
valueArray.forEach((v) => {
allQueryURLSearchParams.append(param, v);
});
});
return allQueryURLSearchParams;
}
export function getUrlSearchParamsToForwardForReroute({
formResponse,
formResponseId,
fields,
searchParams,
teamMembersMatchingAttributeLogic,
attributeRoutingConfig,
rescheduleUid,
reroutingFormResponses,
}: Omit<GetUrlSearchParamsToForwardOptions, "queuedFormResponseId"> & {
rescheduleUid: string;
reroutingFormResponses: FormResponseValueOnly;
}) {
searchParams.set("rescheduleUid", rescheduleUid);
searchParams.set("cal.rerouting", "true");
return getUrlSearchParamsToForward({
formResponse,
formResponseId,
// Queued form response id is not available in rerouting
queuedFormResponseId: null,
fields,
searchParams,
teamMembersMatchingAttributeLogic,
attributeRoutingConfig,
reroutingFormResponses,
});
}
export function getUrlSearchParamsToForwardForTestPreview({
formResponse,
fields,
attributeRoutingConfig,
teamMembersMatchingAttributeLogic,
}: Pick<
GetUrlSearchParamsToForwardOptions,
"formResponse" | "fields" | "attributeRoutingConfig" | "teamMembersMatchingAttributeLogic"
>) {
// There are no existing query params to forward in test preview. These are available only when doing the actual form submission
const searchParams = new URLSearchParams();
searchParams.set("cal.isTestPreviewLink", "true");
return getUrlSearchParamsToForward({
formResponse,
fields,
attributeRoutingConfig,
teamMembersMatchingAttributeLogic,
// There is no form response being stored in test preview
formResponseId: null,
// Queued form response id is not available in test preview
queuedFormResponseId: null,
searchParams,
});
}