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>
This commit is contained in:
Joe Au-Yeung
2026-01-28 12:45:15 -05:00
committed by GitHub
co-authored by unknown joe@cal.com Devin AI Pedro Castro
parent c9bd18e866
commit 620445d85f
24 changed files with 2152 additions and 71 deletions
@@ -5,6 +5,7 @@ import { z, ZodError } from "zod";
import { onSubmissionOfFormResponse } from "@calcom/app-store/routing-forms/lib/formSubmissionUtils";
import { getResponseToStore } from "@calcom/app-store/routing-forms/lib/getResponseToStore";
import { getSerializableForm } from "@calcom/app-store/routing-forms/lib/getSerializableForm";
import { PrismaPendingRoutingTraceRepository } from "@calcom/features/routing-trace/repositories/PrismaPendingRoutingTraceRepository";
import logger from "@calcom/lib/logger";
import { safeStringify } from "@calcom/lib/safeStringify";
import { RoutingFormResponseRepository } from "@calcom/lib/server/repository/formResponse";
@@ -58,6 +59,18 @@ export const queuedResponseHandler = async ({
chosenRouteId: queuedFormResponse.chosenRouteId,
});
// Link the pending routing trace to the new formResponseId so it can be found when booking is created
try {
const pendingTraceRepo = new PrismaPendingRoutingTraceRepository(prisma);
await pendingTraceRepo.linkToFormResponse({
queuedFormResponseId: queuedFormResponse.id,
formResponseId: formResponse.id,
});
} catch (error) {
// Log but don't fail - trace linking is not critical
logger.warn("Failed to link pending routing trace to form response", safeStringify(error));
}
const chosenRoute = serializableForm.routes?.find((r) => r.id === queuedFormResponse.chosenRouteId);
await onSubmissionOfFormResponse({
form: {
@@ -1,5 +1,6 @@
import { getCRMContactOwnerForRRLeadSkip } from "@calcom/app-store/_utils/CRMRoundRobinSkip";
import { EventTypeRepository } from "@calcom/features/eventtypes/repositories/eventTypeRepository";
import type { RoutingTraceService } from "@calcom/features/routing-trace/services/RoutingTraceService";
import { prisma } from "@calcom/prisma";
import { SchedulingType } from "@calcom/prisma/enums";
@@ -10,10 +11,12 @@ export default async function routerGetCrmContactOwnerEmail({
attributeRoutingConfig,
identifierKeyedResponse,
action,
routingTraceService,
}: {
attributeRoutingConfig: LocalRoute["attributeRoutingConfig"];
identifierKeyedResponse: Record<string, string | string[]> | null;
action: LocalRoute["action"];
routingTraceService?: RoutingTraceService;
}) {
// Check if route is skipping CRM contact check
if (attributeRoutingConfig?.skipContactOwner) return null;
@@ -23,7 +26,8 @@ export default async function routerGetCrmContactOwnerEmail({
for (const identifier of Object.keys(identifierKeyedResponse)) {
const fieldResponse = identifierKeyedResponse[identifier];
if (identifier === "email") {
prospectEmail = fieldResponse instanceof Array ? fieldResponse[0] : fieldResponse;
prospectEmail =
fieldResponse instanceof Array ? fieldResponse[0] : fieldResponse;
break;
}
}
@@ -31,11 +35,15 @@ export default async function routerGetCrmContactOwnerEmail({
if (!prospectEmail) return null;
// Determine if the action is an event type redirect
if (action.type !== "eventTypeRedirectUrl" || !action.eventTypeId) return null;
if (action.type !== "eventTypeRedirectUrl" || !action.eventTypeId)
return null;
const eventTypeRepo = new EventTypeRepository(prisma);
const eventType = await eventTypeRepo.findByIdIncludeHostsAndTeam({ id: action.eventTypeId });
if (!eventType || eventType.schedulingType !== SchedulingType.ROUND_ROBIN) return null;
const eventType = await eventTypeRepo.findByIdIncludeHostsAndTeam({
id: action.eventTypeId,
});
if (!eventType || eventType.schedulingType !== SchedulingType.ROUND_ROBIN)
return null;
const eventTypeMetadata = eventType.metadata;
if (!eventTypeMetadata) return null;
@@ -53,16 +61,23 @@ export default async function routerGetCrmContactOwnerEmail({
};
// Determine if there is a CRM option enabled in the chosen route
for (const appSlug of enabledAppSlugs) {
const routingOptions = attributeRoutingConfig?.[appSlug as keyof typeof attributeRoutingConfig];
const routingOptions =
attributeRoutingConfig?.[appSlug as keyof typeof attributeRoutingConfig];
if (!routingOptions) continue;
// See if any options are true
if (Object.values(routingOptions).some((option) => option === true)) {
const appBookingFormHandler = (await import("@calcom/app-store/routing-forms/appBookingFormHandler"))
.default;
const appHandler = appBookingFormHandler[appSlug as keyof typeof appBookingFormHandler];
const appBookingFormHandler = (
await import("@calcom/app-store/routing-forms/appBookingFormHandler")
).default;
const appHandler =
appBookingFormHandler[appSlug as keyof typeof appBookingFormHandler];
const ownerQuery = await appHandler(prospectEmail, attributeRoutingConfig, action.eventTypeId);
const ownerQuery = await appHandler(
prospectEmail,
attributeRoutingConfig,
action.eventTypeId
);
if (ownerQuery?.email) {
contactOwner = { ...ownerQuery, crmAppSlug: appSlug };
@@ -72,13 +87,33 @@ export default async function routerGetCrmContactOwnerEmail({
}
if (!contactOwner || (!contactOwner.email && !contactOwner.recordType)) {
const ownerQuery = await getCRMContactOwnerForRRLeadSkip(prospectEmail, eventTypeMetadata);
const ownerQuery = await getCRMContactOwnerForRRLeadSkip(
prospectEmail,
eventTypeMetadata
);
if (ownerQuery?.email) contactOwner = ownerQuery;
}
if (!contactOwner) return null;
// Check that the contact owner is a part of the event type
if (!eventType.hosts.some((host) => host.user.email === contactOwner.email)) return null;
if (!eventType.hosts.some((host) => host.user.email === contactOwner.email))
return null;
if (routingTraceService && contactOwner.email && contactOwner.crmAppSlug) {
const salesforceSettings = attributeRoutingConfig?.salesforce;
routingTraceService.addStep({
domain: contactOwner.crmAppSlug,
step: `${contactOwner.crmAppSlug}_assignment`,
data: {
email: contactOwner.email,
recordType: contactOwner.recordType,
recordId: contactOwner.recordId,
crmAppSlug: contactOwner.crmAppSlug,
rrSkipToAccountLookupField: salesforceSettings?.rrSkipToAccountLookupField,
rrSKipToAccountLookupFieldName: salesforceSettings?.rrSKipToAccountLookupFieldName,
},
});
}
return contactOwner;
}
@@ -46,6 +46,7 @@ import { BookingEmailAndSmsTasker } from "@calcom/features/bookings/lib/tasker/B
import { getSpamCheckService } from "@calcom/features/di/watchlist/containers/SpamCheckService.container";
import { CreditService } from "@calcom/features/ee/billing/credit-service";
import { getBookerBaseUrl } from "@calcom/features/ee/organizations/lib/getBookerUrlServer";
import { getRoutingTraceService } from "@calcom/features/routing-trace/di/RoutingTraceService.container";
import AssignmentReasonRecorder from "@calcom/features/ee/round-robin/assignmentReason/AssignmentReasonRecorder";
import { BookingLocationService } from "@calcom/features/ee/round-robin/lib/bookingLocationService";
import { getAllWorkflowsFromEventType } from "@calcom/features/ee/workflows/lib/getAllWorkflowsFromEventType";
@@ -1945,25 +1946,22 @@ async function handler(
}
// If it's a round robin event, record the reason for the host assignment
if (eventType.schedulingType === SchedulingType.ROUND_ROBIN) {
if (reqBody.crmOwnerRecordType && reqBody.crmAppSlug && contactOwnerEmail && routingFormResponseId) {
assignmentReason = await AssignmentReasonRecorder.CRMOwnership({
if (eventType.schedulingType === SchedulingType.ROUND_ROBIN && routingFormResponseId) {
try {
const routingTraceService = getRoutingTraceService();
const result = await routingTraceService.processForBooking({
formResponseId: routingFormResponseId,
bookingId: booking.id,
crmAppSlug: reqBody.crmAppSlug,
teamMemberEmail: contactOwnerEmail,
recordType: reqBody.crmOwnerRecordType,
routingFormResponseId,
recordId: crmRecordId,
});
} else if (routingFormResponseId && teamId) {
assignmentReason = await AssignmentReasonRecorder.routingFormRoute({
bookingId: booking.id,
routingFormResponseId,
organizerId: organizerUser.id,
teamId,
bookingUid: booking.uid,
organizerEmail: organizerUser.email,
isRerouting: !!reroutingFormResponses,
reroutedByEmail: reqBody.rescheduledBy,
});
if (result?.assignmentReason) {
assignmentReason = result.assignmentReason;
}
} catch (error) {
criticalLogger.warn("Failed to process routing trace", { error });
}
}
@@ -6,6 +6,11 @@ import { Utils as QbUtils } from "react-awesome-query-builder/lib";
import { acrossQueryValueCompatiblity, raqbQueryValueUtils } from "@calcom/app-store/_utils/raqb/raqbUtils";
import type { Attribute } from "@calcom/app-store/routing-forms/types/types";
import { getAttributesAssignmentData } from "@calcom/features/attributes/lib/getAttributes";
import type { RoutingTraceService } from "@calcom/features/routing-trace/services/RoutingTraceService";
import {
ROUTING_TRACE_DOMAINS,
ROUTING_TRACE_STEPS,
} from "@calcom/features/routing-trace/services/RoutingTraceService";
import { RaqbLogicResult } from "@calcom/lib/raqb/evaluateRaqbLogic";
import jsonLogic from "@calcom/lib/raqb/jsonLogic";
import type { dynamicFieldValueOperands, AttributesQueryValue } from "@calcom/lib/raqb/types";
@@ -130,6 +135,136 @@ function buildTroubleshooterData({ type, data }: { type: TroubleshooterCase; dat
};
}
/**
* Checks if a string is a field template placeholder (e.g., "{field:uuid}")
*/
function isFieldTemplate(str: string): boolean {
const trimmedStr = str.trim();
return trimmedStr.startsWith("{field:") && trimmedStr.endsWith("}");
}
/**
* Extracts field ID from a field template string
*/
function extractFieldIdFromTemplate(template: string): string | null {
const trimmedTemplate = template.trim();
if (!isFieldTemplate(trimmedTemplate)) return null;
return trimmedTemplate.slice(7, -1);
}
/**
* Resolves a field template to its actual value from the form response
*/
function resolveFieldTemplateValue(
value: string,
dynamicFieldValueOperands?: dynamicFieldValueOperands
): string {
if (!isFieldTemplate(value) || !dynamicFieldValueOperands) {
return value;
}
const fieldId = extractFieldIdFromTemplate(value);
if (!fieldId) {
return value;
}
const { fields, response } = dynamicFieldValueOperands;
const field = fields.find((f) => f.id === fieldId);
if (!field) {
// Field definition not found - return original template
return value;
}
const fieldResponse = response[field.id];
if (!fieldResponse) {
// Field not in response object - return original template
return value;
}
const fieldValue = fieldResponse.value;
if (fieldValue === null || fieldValue === undefined || fieldValue === "") {
return "(empty)";
}
if (Array.isArray(fieldValue)) {
return fieldValue.join(", ");
}
return String(fieldValue);
}
/**
* Extracts attribute name/value pairs from a resolved attributesQueryValue.
* This is used to build the assignment reason string.
*/
function extractAttributeRoutingDetails({
resolvedAttributesQueryValue,
attributesOfTheOrg,
dynamicFieldValueOperands,
}: {
resolvedAttributesQueryValue: AttributesQueryValue | null;
attributesOfTheOrg: Attribute[];
dynamicFieldValueOperands?: dynamicFieldValueOperands;
}): Array<{ attributeName: string; attributeValue: string }> {
if (!resolvedAttributesQueryValue) {
return [];
}
if (!raqbQueryValueUtils.isQueryValueARuleGroup(resolvedAttributesQueryValue as JsonTree)) {
return [];
}
const children1 = resolvedAttributesQueryValue.children1;
if (!children1) {
return [];
}
const result: Array<{ attributeName: string; attributeValue: string }> = [];
for (const ruleId of Object.keys(children1)) {
const rule = children1[ruleId];
// Check if it's a rule (has properties with field)
if (rule.type !== "rule" || !rule.properties) {
continue;
}
const properties = rule.properties;
const fieldId = properties.field;
const value = properties.value;
if (!fieldId || !value || value[0] === null || value[0] === undefined) {
continue;
}
const attribute = attributesOfTheOrg.find((attr) => attr.id === fieldId);
if (!attribute) {
continue;
}
const attributeValueString = (() => {
const firstValue = value[0];
if (Array.isArray(firstValue)) {
// Handle array values - resolve any field templates within
const resolvedValues = firstValue.map((v) =>
typeof v === "string" ? resolveFieldTemplateValue(v, dynamicFieldValueOperands) : String(v)
);
return resolvedValues.join(", ");
}
const stringValue = String(firstValue);
// Resolve field template if present
return resolveFieldTemplateValue(stringValue, dynamicFieldValueOperands);
})();
result.push({
attributeName: attribute.name,
attributeValue: attributeValueString,
});
}
return result;
}
async function getLogicResultForAllMembers(
{
teamMembersWithAttributeOptionValuePerAttribute,
@@ -193,6 +328,7 @@ async function runAttributeLogic(data: RunAttributeLogicData, options: RunAttrib
return {
logicBuildingWarnings: null,
teamMembersMatchingAttributeLogic: null,
resolvedAttributesQueryValue: null,
timeTaken: null,
...buildTroubleshooterData({
type: TroubleshooterCase.MATCHES_ALL_MEMBERS_BECAUSE_OF_EMPTY_QUERY_VALUE,
@@ -217,6 +353,7 @@ async function runAttributeLogic(data: RunAttributeLogicData, options: RunAttrib
return {
teamMembersMatchingAttributeLogic: null,
logicBuildingWarnings: null,
resolvedAttributesQueryValue: attributesQueryValue,
timeTaken: {
ttgetAttributesQueryBuilderConfigHavingListofLabels,
},
@@ -256,6 +393,7 @@ async function runAttributeLogic(data: RunAttributeLogicData, options: RunAttrib
return {
teamMembersMatchingAttributeLogic,
logicBuildingWarnings,
resolvedAttributesQueryValue: attributesQueryValue,
timeTaken: {
ttgetAttributesQueryBuilderConfigHavingListofLabels,
ttTeamMembersMatchingAttributeLogic,
@@ -322,15 +460,18 @@ export async function findTeamMembersMatchingAttributeLogic(
fallbackAttributesQueryValue?: AttributesQueryValue | null;
dynamicFieldValueOperands?: dynamicFieldValueOperands;
isPreview?: boolean;
routeName?: string;
routeIsFallback?: boolean;
},
options: {
enablePerf?: boolean;
concurrency?: number;
enableTroubleshooter?: boolean;
routingTraceService?: RoutingTraceService;
} = {}
) {
// Higher value of concurrency might not be performant as it might overwhelm the system. So, use a lower value as default.
const { enablePerf = false, concurrency = 2, enableTroubleshooter = false } = options;
const { enablePerf = false, concurrency = 2, enableTroubleshooter = false, routingTraceService } = options;
// Any explicit value being passed should cause fallback to be considered. Even undefined
const considerFallback = "fallbackAttributesQueryValue" in data;
@@ -342,6 +483,8 @@ export async function findTeamMembersMatchingAttributeLogic(
fallbackAttributesQueryValue,
dynamicFieldValueOperands,
isPreview,
routeName,
routeIsFallback,
} = data;
const {
@@ -374,6 +517,7 @@ export async function findTeamMembersMatchingAttributeLogic(
timeTaken: teamMembersMatchingMainAttributeLogicTimeTaken,
troubleshooter,
logicBuildingWarnings: mainAttributeLogicBuildingWarnings,
resolvedAttributesQueryValue,
} = await runMainAttributeLogic(
{
...runAttributeLogicData,
@@ -382,14 +526,39 @@ export async function findTeamMembersMatchingAttributeLogic(
runAttributeLogicOptions
);
// Helper to add trace step for attribute logic evaluation
const addTraceStep = (checkedFallback: boolean) => {
if (routingTraceService) {
const attributeRoutingDetails = extractAttributeRoutingDetails({
resolvedAttributesQueryValue,
attributesOfTheOrg,
dynamicFieldValueOperands,
});
routingTraceService.addStep({
domain: ROUTING_TRACE_DOMAINS.ROUTING_FORM,
step: ROUTING_TRACE_STEPS.ATTRIBUTE_LOGIC_EVALUATED,
data: {
routeName,
routeIsFallback,
checkedFallback,
attributeRoutingDetails,
},
});
}
};
// It being null means that no logic was found and thus all members match. In such case, we don't fallback intentionally.
// This is the case when user added no rules so, he expects to match all members
if (!teamMembersMatchingMainAttributeLogic) {
addTraceStep(false);
return {
teamMembersMatchingAttributeLogic: null,
checkedFallback: false,
mainAttributeLogicBuildingWarnings,
fallbackAttributeLogicBuildingWarnings: [],
routeName,
routeIsFallback,
timeTaken: {
...teamMembersMatchingMainAttributeLogicTimeTaken,
ttGetAttributesForLogic,
@@ -418,11 +587,14 @@ export async function findTeamMembersMatchingAttributeLogic(
runAttributeLogicOptions
);
addTraceStep(true);
return {
teamMembersMatchingAttributeLogic: teamMembersMatchingFallbackLogic,
checkedFallback: true,
fallbackAttributeLogicBuildingWarnings,
mainAttributeLogicBuildingWarnings,
routeName,
routeIsFallback,
timeTaken: {
...teamMembersMatchingFallbackLogicTimeTaken,
ttGetAttributesForLogic,
@@ -436,11 +608,14 @@ export async function findTeamMembersMatchingAttributeLogic(
};
}
addTraceStep(false);
return {
teamMembersMatchingAttributeLogic: teamMembersMatchingMainAttributeLogic,
checkedFallback: false,
mainAttributeLogicBuildingWarnings,
fallbackAttributeLogicBuildingWarnings: [],
routeName,
routeIsFallback,
timeTaken: {
...teamMembersMatchingMainAttributeLogicTimeTaken,
ttGetAttributesForLogic,
@@ -1,9 +1,6 @@
import "@calcom/lib/__mocks__/logger";
import { createHash } from "node:crypto";
import type { GetServerSidePropsContext } from "next";
import { beforeEach, describe, expect, it, vi } from "vitest";
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";
@@ -14,6 +11,8 @@ import { isAuthorizedToViewFormOnOrgDomain } from "@calcom/features/routing-form
import { PrismaRoutingFormRepository } from "@calcom/features/routing-forms/repositories/PrismaRoutingFormRepository";
import { UserRepository } from "@calcom/features/users/repositories/UserRepository";
import { checkRateLimitAndThrowError } from "@calcom/lib/checkRateLimitAndThrowError";
import type { GetServerSidePropsContext } from "next";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { getRoutedUrl } from "./getRoutedUrl";
import { getUrlSearchParamsToForward } from "./getUrlSearchParamsToForward";
@@ -26,9 +25,11 @@ vi.mock("@calcom/lib/checkRateLimitAndThrowError");
vi.mock("@calcom/features/routing-forms/repositories/PrismaRoutingFormRepository");
vi.mock("@calcom/features/users/repositories/UserRepository", () => {
return {
UserRepository: vi.fn().mockImplementation(function() { return {
enrichUserWithItsProfile: vi.fn(),
}; }),
UserRepository: vi.fn().mockImplementation(function () {
return {
enrichUserWithItsProfile: vi.fn(),
};
}),
};
});
vi.mock("@calcom/features/ee/organizations/lib/orgDomains");
@@ -44,8 +45,33 @@ vi.mock("@calcom/app-store/routing-forms/enrichFormWithMigrationData", () => ({
vi.mock("@calcom/lib/sentryWrapper", () => ({
withReporting: (fn: unknown) => fn,
}));
vi.mock("@calcom/features/routing-trace/di/RoutingTraceService.container", () => ({
getRoutingTraceService: vi.fn(() => ({
addStep: vi.fn(),
getStepsCount: vi.fn().mockReturnValue(0),
savePendingRoutingTrace: vi.fn().mockResolvedValue(undefined),
processForBooking: vi.fn().mockResolvedValue(undefined),
})),
}));
const mockForm = {
const mockForm: {
id: string;
user: { id: number; name: string };
team: null;
name: string;
fields: never[];
routes: never[];
userId: number;
teamId: null;
createdAt: Date;
updatedAt: Date;
description: null;
enabled: boolean;
isDefault: boolean;
fieldsOrder: never[];
position: number;
slug: string;
} = {
id: "form-id",
user: { id: 1, name: "Test User" },
team: null,
@@ -64,7 +90,12 @@ const mockForm = {
slug: "test-form",
};
const mockSerializableForm = {
const mockSerializableForm: {
id: string;
fields: { id: string; type: string; label: string; identifier: string }[];
routes: never[];
user: { id: number };
} = {
id: "form-id",
fields: [{ id: "email", type: "email", label: "Email", identifier: "email" }],
routes: [],
@@ -73,7 +104,7 @@ const mockSerializableForm = {
const mockContext = (
query: Record<string, unknown>,
url = "/link/form-id"
url: string = "/link/form-id"
): Pick<GetServerSidePropsContext, "query" | "req"> => ({
query: { form: "form-id", ...query },
req: { url },
@@ -92,8 +123,7 @@ describe("getRoutedUrl", () => {
mockUserRepository.mockImplementation(function () {
return {
enrichUserWithItsProfile: mockEnrichUserWithItsProfile,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
} as any;
} as unknown as InstanceType<typeof UserRepository>;
});
}
vi.mocked(isAuthorizedToViewFormOnOrgDomain).mockReturnValue(true);
@@ -16,6 +16,7 @@ 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 { UserRepository } from "@calcom/features/users/repositories/UserRepository";
import { checkRateLimitAndThrowError } from "@calcom/lib/checkRateLimitAndThrowError";
import { HttpError } from "@calcom/lib/http-error";
@@ -36,7 +37,9 @@ const querySchema = z
})
.catchall(z.string().or(z.array(z.string())));
const getDeterministicHashForResponse = (fieldsResponses: Record<string, unknown>) => {
const getDeterministicHashForResponse = (
fieldsResponses: Record<string, unknown>
) => {
const sortedFields = Object.keys(fieldsResponses)
.sort()
.reduce((obj: Record<string, unknown>, key) => {
@@ -53,7 +56,13 @@ export function hasEmbedPath(pathWithQuery: string) {
return onlyPath.endsWith("/embed") || onlyPath.endsWith("/embed/");
}
const _getRoutedUrl = async (context: Pick<GetServerSidePropsContext, "query" | "req">, fetchCrm = true) => {
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 = {
@@ -87,7 +96,9 @@ const _getRoutedUrl = async (context: Pick<GetServerSidePropsContext, "query" |
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),
...(isBookingDryRunParam
? { "cal.isBookingDryRun": isBookingDryRunParam }
: null),
};
const { currentOrgDomain } = orgDomainConfig(context.req);
@@ -95,7 +106,8 @@ const _getRoutedUrl = async (context: Pick<GetServerSidePropsContext, "query" |
let timeTaken: Record<string, number | null> = {};
const formQueryStart = performance.now();
const form = await PrismaRoutingFormRepository.findFormByIdIncludeUserTeamAndOrg(formId);
const form =
await PrismaRoutingFormRepository.findFormByIdIncludeUserTeamAndOrg(formId);
timeTaken.formQuery = performance.now() - formQueryStart;
if (!form) {
@@ -113,7 +125,11 @@ const _getRoutedUrl = async (context: Pick<GetServerSidePropsContext, "query" |
timeTaken.profileEnrichment = performance.now() - profileEnrichmentStart;
if (
!isAuthorizedToViewFormOnOrgDomain({ user: formWithUserProfile.user, currentOrgDomain, team: form.team })
!isAuthorizedToViewFormOnOrgDomain({
user: formWithUserProfile.user,
currentOrgDomain,
team: form.team,
})
) {
return {
notFound: true,
@@ -144,10 +160,10 @@ const _getRoutedUrl = async (context: Pick<GetServerSidePropsContext, "query" |
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 queuedFormResponseId;
try {
const result = await handleResponse({
form: serializableForm,
@@ -158,18 +174,31 @@ const _getRoutedUrl = async (context: Pick<GetServerSidePropsContext, "query" |
isPreview: isBookingDryRun,
queueFormResponse: shouldQueueFormResponse,
fetchCrm,
traceService: isBookingDryRun ? undefined : routingTraceService,
});
teamMembersMatchingAttributeLogic = result.teamMembersMatchingAttributeLogic;
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,
};
crmContactOwnerEmail = result.crmContactOwnerEmail;
crmContactOwnerRecordType = result.crmContactOwnerRecordType;
crmAppSlug = result.crmAppSlug;
// 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 {
@@ -214,17 +243,20 @@ const _getRoutedUrl = async (context: Pick<GetServerSidePropsContext, "query" |
formResponse: response,
fields: serializableForm.fields,
searchParams: new URLSearchParams(
stringify({ ...paramsToBeForwardedAsIs, "cal.action": "eventTypeRedirectUrl" })
stringify({
...paramsToBeForwardedAsIs,
"cal.action": "eventTypeRedirectUrl",
})
),
teamMembersMatchingAttributeLogic,
formResponseId: formResponseId ?? null,
queuedFormResponseId: queuedFormResponseId ?? null,
attributeRoutingConfig: attributeRoutingConfig ?? null,
teamId: form?.teamId,
orgId: form.team?.parentId,
crmContactOwnerEmail,
crmContactOwnerRecordType,
crmAppSlug,
teamId: form?.teamId,
orgId: form.team?.parentId,
}),
isEmbed: pageProps.isEmbed,
}),
@@ -234,7 +266,9 @@ const _getRoutedUrl = async (context: Pick<GetServerSidePropsContext, "query" |
} else if (decidedAction.type === "externalRedirectUrl") {
return {
redirect: {
destination: `${decidedAction.value}?${stringify(context.query)}&cal.action=externalRedirectUrl`,
destination: `${decidedAction.value}?${stringify(
context.query
)}&cal.action=externalRedirectUrl`,
permanent: false,
},
};
@@ -27,12 +27,12 @@ type GetUrlSearchParamsToForwardOptions = {
queuedFormResponseId: string | null;
teamMembersMatchingAttributeLogic: number[] | null;
attributeRoutingConfig: AttributeRoutingConfig | null;
reroutingFormResponses?: FormResponseValueOnly;
teamId?: number | null;
orgId?: number | null;
crmContactOwnerEmail?: string | null;
crmContactOwnerRecordType?: string | null;
crmAppSlug?: string | null;
reroutingFormResponses?: FormResponseValueOnly;
teamId?: number | null;
orgId?: number | null;
};
export function getUrlSearchParamsToForward({
@@ -43,12 +43,12 @@ export function getUrlSearchParamsToForward({
formResponseId,
queuedFormResponseId,
attributeRoutingConfig,
reroutingFormResponses,
teamId,
orgId,
crmContactOwnerEmail,
crmContactOwnerRecordType,
crmAppSlug,
reroutingFormResponses,
teamId,
orgId,
}: GetUrlSearchParamsToForwardOptions) {
type Params = Record<string, string | string[]>;
const paramsFromResponse: Params = {};
@@ -118,9 +118,6 @@ export function getUrlSearchParamsToForward({
const allQueryParams: Params = {
...(teamId && { ["cal.teamId"]: `${teamId}` }),
...(orgId && { ["cal.orgId"]: `${orgId}` }),
...(crmContactOwnerEmail && { ["cal.crmContactOwnerEmail"]: `${crmContactOwnerEmail}` }),
...(crmContactOwnerRecordType && { ["cal.crmContactOwnerRecordType"]: `${crmContactOwnerRecordType}` }),
...(crmAppSlug && { ["cal.crmAppSlug"]: `${crmAppSlug}` }),
...paramsFromCurrentUrl,
// In case of conflict b/w paramsFromResponse and paramsFromCurrentUrl, paramsFromResponse should win as the booker probably improved upon the prefilled value.
...paramsFromResponse,
@@ -132,6 +129,9 @@ export function getUrlSearchParamsToForward({
: 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),
@@ -6,6 +6,7 @@ import {
type TargetRoutingFormForResponse,
} from "@calcom/app-store/routing-forms/lib/formSubmissionUtils";
import isRouter from "@calcom/app-store/routing-forms/lib/isRouter";
import type { RoutingTraceService } from "@calcom/features/routing-trace/services/RoutingTraceService";
import { emailSchema } from "@calcom/lib/emailSchema";
import { HttpError } from "@calcom/lib/http-error";
import logger from "@calcom/lib/logger";
@@ -29,6 +30,7 @@ const _handleResponse = async ({
isPreview,
queueFormResponse,
fetchCrm,
traceService,
}: {
response: Record<
string,
@@ -45,6 +47,7 @@ const _handleResponse = async ({
isPreview: boolean;
queueFormResponse?: boolean;
fetchCrm?: boolean;
traceService?: RoutingTraceService;
}) => {
try {
if (!form.fields) {
@@ -104,6 +107,7 @@ const _handleResponse = async ({
let crmAppSlug: string | null = null;
let crmRecordId: string | null = null;
let timeTaken: Record<string, number | null> = {};
let checkedFallback = false;
if (chosenRoute) {
if (isRouter(chosenRoute)) {
throw new HttpError({
@@ -121,6 +125,7 @@ const _handleResponse = async ({
attributeRoutingConfig: chosenRoute.attributeRoutingConfig,
identifierKeyedResponse,
action: chosenRoute.action,
routingTraceService: traceService,
})
: null;
crmContactOwnerEmail = contactOwnerQuery?.email ?? null;
@@ -141,9 +146,12 @@ const _handleResponse = async ({
fallbackAttributesQueryValue: chosenRoute.fallbackAttributesQueryValue,
teamId: formTeamId,
orgId: formOrgId,
routeName: chosenRoute.name,
routeIsFallback: chosenRoute.isFallback,
},
{
enablePerf: true,
routingTraceService: traceService,
}
)
: null;
@@ -161,6 +169,7 @@ const _handleResponse = async ({
: null;
timeTaken = teamMembersMatchingAttributeLogicWithResult?.timeTaken ?? {};
checkedFallback = teamMembersMatchingAttributeLogicWithResult?.checkedFallback ?? false;
})(),
]);
@@ -226,6 +235,7 @@ const _handleResponse = async ({
? chosenRoute.attributeRoutingConfig
: null
: null,
checkedFallback,
timeTaken,
};
} catch (e) {
@@ -0,0 +1,23 @@
import { bindModuleToClassOnToken, createModule, type ModuleLoader } from "@calcom/features/di/di";
import { moduleLoader as prismaModuleLoader } from "@calcom/features/di/modules/Prisma";
import { PrismaPendingRoutingTraceRepository } from "../repositories/PrismaPendingRoutingTraceRepository";
import { ROUTING_TRACE_DI_TOKENS } from "./tokens";
export const pendingRoutingTraceRepositoryModule = createModule();
const token = ROUTING_TRACE_DI_TOKENS.PENDING_ROUTING_TRACE_REPOSITORY;
const moduleToken = ROUTING_TRACE_DI_TOKENS.PENDING_ROUTING_TRACE_REPOSITORY_MODULE;
const loadModule = bindModuleToClassOnToken({
module: pendingRoutingTraceRepositoryModule,
moduleToken,
token,
classs: PrismaPendingRoutingTraceRepository,
dep: prismaModuleLoader,
});
export const moduleLoader: ModuleLoader = {
token,
loadModule,
};
export type { PrismaPendingRoutingTraceRepository };
@@ -0,0 +1,23 @@
import { bindModuleToClassOnToken, createModule, type ModuleLoader } from "@calcom/features/di/di";
import { moduleLoader as prismaModuleLoader } from "@calcom/features/di/modules/Prisma";
import { PrismaRoutingTraceRepository } from "../repositories/PrismaRoutingTraceRepository";
import { ROUTING_TRACE_DI_TOKENS } from "./tokens";
export const routingTraceRepositoryModule = createModule();
const token = ROUTING_TRACE_DI_TOKENS.ROUTING_TRACE_REPOSITORY;
const moduleToken = ROUTING_TRACE_DI_TOKENS.ROUTING_TRACE_REPOSITORY_MODULE;
const loadModule = bindModuleToClassOnToken({
module: routingTraceRepositoryModule,
moduleToken,
token,
classs: PrismaRoutingTraceRepository,
dep: prismaModuleLoader,
});
export const moduleLoader: ModuleLoader = {
token,
loadModule,
};
export type { PrismaRoutingTraceRepository };
@@ -0,0 +1,37 @@
import { AssignmentReasonRepository } from "@calcom/features/assignment-reason/repositories/AssignmentReasonRepository";
import { createContainer } from "@calcom/features/di/di";
import prisma from "@calcom/prisma";
import type { IPendingRoutingTraceRepository } from "../repositories/PendingRoutingTraceRepository.interface";
import type { IRoutingTraceRepository } from "../repositories/RoutingTraceRepository.interface";
import { RoutingTraceService } from "../services/RoutingTraceService";
import { moduleLoader as pendingRoutingTraceRepositoryModuleLoader } from "./PendingRoutingTraceRepository.module";
import { moduleLoader as routingTraceRepositoryModuleLoader } from "./RoutingTraceRepository.module";
const routingTraceContainer = createContainer();
function getPendingRoutingTraceRepository(): IPendingRoutingTraceRepository {
pendingRoutingTraceRepositoryModuleLoader.loadModule(routingTraceContainer);
return routingTraceContainer.get<IPendingRoutingTraceRepository>(
pendingRoutingTraceRepositoryModuleLoader.token
);
}
function getRoutingTraceRepository(): IRoutingTraceRepository {
routingTraceRepositoryModuleLoader.loadModule(routingTraceContainer);
return routingTraceContainer.get<IRoutingTraceRepository>(routingTraceRepositoryModuleLoader.token);
}
/**
* Creates a RoutingTraceService instance with all required repositories.
* Use this for processing pending traces during booking creation.
*/
export function getRoutingTraceService(): RoutingTraceService {
return new RoutingTraceService({
pendingRoutingTraceRepository: getPendingRoutingTraceRepository(),
routingTraceRepository: getRoutingTraceRepository(),
assignmentReasonRepository: new AssignmentReasonRepository(prisma),
});
}
export { RoutingTraceService };
@@ -0,0 +1,8 @@
export const ROUTING_TRACE_DI_TOKENS = {
ROUTING_TRACE_SERVICE: Symbol("RoutingTraceService"),
ROUTING_TRACE_SERVICE_MODULE: Symbol("RoutingTraceServiceModule"),
PENDING_ROUTING_TRACE_REPOSITORY: Symbol("PendingRoutingTraceRepository"),
PENDING_ROUTING_TRACE_REPOSITORY_MODULE: Symbol("PendingRoutingTraceRepositoryModule"),
ROUTING_TRACE_REPOSITORY: Symbol("RoutingTraceRepository"),
ROUTING_TRACE_REPOSITORY_MODULE: Symbol("RoutingTraceRepositoryModule"),
};
@@ -0,0 +1,43 @@
import type {
BaseRoutingTrace,
RoutingTrace,
} from "./RoutingTraceRepository.interface";
export interface RoutingTraceWithQueuedResponse extends BaseRoutingTrace {
queuedFormResponseId: string;
}
export interface PendingRoutingTraceRecord {
id: string;
createdAt: Date;
trace: RoutingTrace;
formResponseId: number | null;
queuedFormResponseId: string | null;
}
export interface IPendingRoutingTraceRepository {
create(args: IPendingRoutingTraceRepositoryCreateArgs): Promise<void>;
findByFormResponseId(formResponseId: number): Promise<PendingRoutingTraceRecord | null>;
findByQueuedFormResponseId(queuedFormResponseId: string): Promise<PendingRoutingTraceRecord | null>;
/** Link a pending trace (created with queuedFormResponseId) to a formResponseId when the queued response is processed */
linkToFormResponse(args: {
queuedFormResponseId: string;
formResponseId: number;
}): Promise<void>;
}
interface BaseCreateArgs {
trace: RoutingTrace;
}
interface CreateWithFormResponse extends BaseCreateArgs {
formResponseId: number;
}
interface CreateWithQueuedFormResponse extends BaseCreateArgs {
queuedFormResponseId: string;
}
export type IPendingRoutingTraceRepositoryCreateArgs =
| CreateWithFormResponse
| CreateWithQueuedFormResponse;
@@ -0,0 +1,208 @@
import type { PrismaClient } from "@calcom/prisma";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { PrismaPendingRoutingTraceRepository } from "./PrismaPendingRoutingTraceRepository";
describe("PrismaPendingRoutingTraceRepository", () => {
let repository: PrismaPendingRoutingTraceRepository;
const mockPrisma = {
pendingRoutingTrace: {
create: vi.fn(),
findUnique: vi.fn(),
},
} as unknown as PrismaClient;
beforeEach(() => {
vi.clearAllMocks();
repository = new PrismaPendingRoutingTraceRepository(mockPrisma);
});
describe("create", () => {
it("should create a pending routing trace with formResponseId", async () => {
const trace = [
{
domain: "routing_form",
step: "attribute-logic-evaluated",
timestamp: Date.now(),
data: { routeIsFallback: false },
},
];
vi.mocked(mockPrisma.pendingRoutingTrace.create).mockResolvedValue({
id: "pending-1",
createdAt: new Date(),
trace,
formResponseId: 123,
queuedFormResponseId: null,
});
await repository.create({
trace,
formResponseId: 123,
});
expect(mockPrisma.pendingRoutingTrace.create).toHaveBeenCalledWith({
data: {
createdAt: expect.any(Date),
trace,
formResponseId: 123,
queuedFormResponseId: undefined,
},
});
});
it("should create a pending routing trace with queuedFormResponseId", async () => {
const trace = [
{
domain: "routing_form",
step: "attribute-logic-evaluated",
timestamp: Date.now(),
data: { routeIsFallback: true },
},
];
vi.mocked(mockPrisma.pendingRoutingTrace.create).mockResolvedValue({
id: "pending-2",
createdAt: new Date(),
trace,
formResponseId: null,
queuedFormResponseId: "queued-123",
});
await repository.create({
trace,
queuedFormResponseId: "queued-123",
});
expect(mockPrisma.pendingRoutingTrace.create).toHaveBeenCalledWith({
data: {
createdAt: expect.any(Date),
trace,
formResponseId: undefined,
queuedFormResponseId: "queued-123",
},
});
});
it("should create a pending routing trace with empty trace array", async () => {
const trace: never[] = [];
vi.mocked(mockPrisma.pendingRoutingTrace.create).mockResolvedValue({
id: "pending-3",
createdAt: new Date(),
trace,
formResponseId: 456,
queuedFormResponseId: null,
});
await repository.create({
trace,
formResponseId: 456,
});
expect(mockPrisma.pendingRoutingTrace.create).toHaveBeenCalledWith({
data: {
createdAt: expect.any(Date),
trace,
formResponseId: 456,
queuedFormResponseId: undefined,
},
});
});
});
describe("findByFormResponseId", () => {
it("should find a pending routing trace by formResponseId", async () => {
const trace = [
{
domain: "routing_form",
step: "attribute-logic-evaluated",
timestamp: Date.now(),
data: { routeIsFallback: false },
},
];
const mockResult = {
id: "pending-1",
createdAt: new Date("2025-01-01T10:00:00Z"),
trace,
formResponseId: 123,
queuedFormResponseId: null,
};
vi.mocked(mockPrisma.pendingRoutingTrace.findUnique).mockResolvedValue(mockResult);
const result = await repository.findByFormResponseId(123);
expect(mockPrisma.pendingRoutingTrace.findUnique).toHaveBeenCalledWith({
where: { formResponseId: 123 },
});
expect(result).toEqual({
id: "pending-1",
createdAt: new Date("2025-01-01T10:00:00Z"),
trace,
formResponseId: 123,
queuedFormResponseId: null,
});
});
it("should return null when no pending routing trace is found", async () => {
vi.mocked(mockPrisma.pendingRoutingTrace.findUnique).mockResolvedValue(null);
const result = await repository.findByFormResponseId(999);
expect(mockPrisma.pendingRoutingTrace.findUnique).toHaveBeenCalledWith({
where: { formResponseId: 999 },
});
expect(result).toBeNull();
});
});
describe("findByQueuedFormResponseId", () => {
it("should find a pending routing trace by queuedFormResponseId", async () => {
const trace = [
{
domain: "salesforce",
step: "salesforce_assignment",
timestamp: Date.now(),
data: { email: "owner@example.com" },
},
];
const mockResult = {
id: "pending-2",
createdAt: new Date("2025-01-02T10:00:00Z"),
trace,
formResponseId: null,
queuedFormResponseId: "queued-456",
};
vi.mocked(mockPrisma.pendingRoutingTrace.findUnique).mockResolvedValue(mockResult);
const result = await repository.findByQueuedFormResponseId("queued-456");
expect(mockPrisma.pendingRoutingTrace.findUnique).toHaveBeenCalledWith({
where: { queuedFormResponseId: "queued-456" },
});
expect(result).toEqual({
id: "pending-2",
createdAt: new Date("2025-01-02T10:00:00Z"),
trace,
formResponseId: null,
queuedFormResponseId: "queued-456",
});
});
it("should return null when no pending routing trace is found by queuedFormResponseId", async () => {
vi.mocked(mockPrisma.pendingRoutingTrace.findUnique).mockResolvedValue(null);
const result = await repository.findByQueuedFormResponseId("non-existent");
expect(mockPrisma.pendingRoutingTrace.findUnique).toHaveBeenCalledWith({
where: { queuedFormResponseId: "non-existent" },
});
expect(result).toBeNull();
});
});
});
@@ -0,0 +1,64 @@
import type { PrismaClient } from "@calcom/prisma";
import type { Prisma } from "@calcom/prisma/client";
import type { RoutingTrace } from "./RoutingTraceRepository.interface";
import type {
IPendingRoutingTraceRepository,
IPendingRoutingTraceRepositoryCreateArgs,
PendingRoutingTraceRecord,
} from "./PendingRoutingTraceRepository.interface";
export class PrismaPendingRoutingTraceRepository
implements IPendingRoutingTraceRepository
{
constructor(private readonly prisma: PrismaClient) {}
async create(args: IPendingRoutingTraceRepositoryCreateArgs): Promise<void> {
await this.prisma.pendingRoutingTrace.create({
data: {
createdAt: new Date(),
trace: args.trace as Prisma.InputJsonValue,
formResponseId:
"formResponseId" in args ? args.formResponseId : undefined,
queuedFormResponseId:
"queuedFormResponseId" in args
? args.queuedFormResponseId
: undefined,
},
});
}
async findByFormResponseId(formResponseId: number): Promise<PendingRoutingTraceRecord | null> {
const result = await this.prisma.pendingRoutingTrace.findUnique({
where: { formResponseId },
});
if (!result) return null;
return {
...result,
trace: result.trace as RoutingTrace,
};
}
async findByQueuedFormResponseId(
queuedFormResponseId: string
): Promise<PendingRoutingTraceRecord | null> {
const result = await this.prisma.pendingRoutingTrace.findUnique({
where: { queuedFormResponseId },
});
if (!result) return null;
return {
...result,
trace: result.trace as RoutingTrace,
};
}
async linkToFormResponse(args: {
queuedFormResponseId: string;
formResponseId: number;
}): Promise<void> {
await this.prisma.pendingRoutingTrace.update({
where: { queuedFormResponseId: args.queuedFormResponseId },
data: { formResponseId: args.formResponseId },
});
}
}
@@ -0,0 +1,252 @@
import type { PrismaClient } from "@calcom/prisma";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { PrismaRoutingTraceRepository } from "./PrismaRoutingTraceRepository";
describe("PrismaRoutingTraceRepository", () => {
let repository: PrismaRoutingTraceRepository;
const mockPrisma = {
routingTrace: {
create: vi.fn(),
},
} as unknown as PrismaClient;
beforeEach(() => {
vi.clearAllMocks();
repository = new PrismaRoutingTraceRepository(mockPrisma);
});
describe("create", () => {
it("should create a routing trace with all fields", async () => {
const trace = [
{
domain: "routing_form",
step: "attribute-logic-evaluated",
timestamp: Date.now(),
data: { routeIsFallback: false },
},
];
const mockResult = {
id: "trace-1",
createdAt: new Date("2025-01-01T10:00:00Z"),
trace,
formResponseId: 123,
queuedFormResponseId: null,
bookingUid: "booking-uid-123",
assignmentReasonId: 42,
};
vi.mocked(mockPrisma.routingTrace.create).mockResolvedValue(mockResult);
const result = await repository.create({
trace,
formResponseId: 123,
bookingUid: "booking-uid-123",
assignmentReasonId: 42,
});
expect(mockPrisma.routingTrace.create).toHaveBeenCalledWith({
data: {
createdAt: expect.any(Date),
trace,
formResponseId: 123,
queuedFormResponseId: undefined,
bookingUid: "booking-uid-123",
assignmentReasonId: 42,
},
});
expect(result).toEqual({
id: "trace-1",
createdAt: new Date("2025-01-01T10:00:00Z"),
trace,
formResponseId: 123,
queuedFormResponseId: null,
bookingUid: "booking-uid-123",
assignmentReasonId: 42,
});
});
it("should create a routing trace with queuedFormResponseId", async () => {
const trace = [
{
domain: "salesforce",
step: "salesforce_assignment",
timestamp: Date.now(),
data: { email: "owner@example.com" },
},
];
const mockResult = {
id: "trace-2",
createdAt: new Date("2025-01-02T10:00:00Z"),
trace,
formResponseId: null,
queuedFormResponseId: "queued-456",
bookingUid: "booking-uid-456",
assignmentReasonId: 100,
};
vi.mocked(mockPrisma.routingTrace.create).mockResolvedValue(mockResult);
const result = await repository.create({
trace,
queuedFormResponseId: "queued-456",
bookingUid: "booking-uid-456",
assignmentReasonId: 100,
});
expect(mockPrisma.routingTrace.create).toHaveBeenCalledWith({
data: {
createdAt: expect.any(Date),
trace,
formResponseId: undefined,
queuedFormResponseId: "queued-456",
bookingUid: "booking-uid-456",
assignmentReasonId: 100,
},
});
expect(result).toEqual({
id: "trace-2",
createdAt: new Date("2025-01-02T10:00:00Z"),
trace,
formResponseId: null,
queuedFormResponseId: "queued-456",
bookingUid: "booking-uid-456",
assignmentReasonId: 100,
});
});
it("should create a routing trace without assignmentReasonId", async () => {
const trace = [
{
domain: "other_domain",
step: "some_step",
timestamp: Date.now(),
data: {},
},
];
const mockResult = {
id: "trace-3",
createdAt: new Date("2025-01-03T10:00:00Z"),
trace,
formResponseId: 789,
queuedFormResponseId: null,
bookingUid: "booking-uid-789",
assignmentReasonId: null,
};
vi.mocked(mockPrisma.routingTrace.create).mockResolvedValue(mockResult);
const result = await repository.create({
trace,
formResponseId: 789,
bookingUid: "booking-uid-789",
});
expect(mockPrisma.routingTrace.create).toHaveBeenCalledWith({
data: {
createdAt: expect.any(Date),
trace,
formResponseId: 789,
queuedFormResponseId: undefined,
bookingUid: "booking-uid-789",
assignmentReasonId: undefined,
},
});
expect(result).toEqual({
id: "trace-3",
createdAt: new Date("2025-01-03T10:00:00Z"),
trace,
formResponseId: 789,
queuedFormResponseId: null,
bookingUid: "booking-uid-789",
assignmentReasonId: null,
});
});
it("should create a routing trace with multiple trace steps", async () => {
const trace = [
{
domain: "salesforce",
step: "salesforce_assignment",
timestamp: Date.now() - 1000,
data: { email: "owner@example.com", recordType: "Contact", recordId: "003ABC" },
},
{
domain: "routing_form",
step: "attribute-logic-evaluated",
timestamp: Date.now(),
data: { routeIsFallback: false },
},
];
const mockResult = {
id: "trace-4",
createdAt: new Date("2025-01-04T10:00:00Z"),
trace,
formResponseId: 111,
queuedFormResponseId: null,
bookingUid: "booking-uid-111",
assignmentReasonId: 50,
};
vi.mocked(mockPrisma.routingTrace.create).mockResolvedValue(mockResult);
const result = await repository.create({
trace,
formResponseId: 111,
bookingUid: "booking-uid-111",
assignmentReasonId: 50,
});
expect(mockPrisma.routingTrace.create).toHaveBeenCalledWith({
data: {
createdAt: expect.any(Date),
trace,
formResponseId: 111,
queuedFormResponseId: undefined,
bookingUid: "booking-uid-111",
assignmentReasonId: 50,
},
});
expect(result.trace).toHaveLength(2);
});
it("should create a routing trace with empty trace array", async () => {
const trace: never[] = [];
const mockResult = {
id: "trace-5",
createdAt: new Date("2025-01-05T10:00:00Z"),
trace,
formResponseId: 222,
queuedFormResponseId: null,
bookingUid: "booking-uid-222",
assignmentReasonId: null,
};
vi.mocked(mockPrisma.routingTrace.create).mockResolvedValue(mockResult);
const result = await repository.create({
trace,
formResponseId: 222,
bookingUid: "booking-uid-222",
});
expect(mockPrisma.routingTrace.create).toHaveBeenCalledWith({
data: {
createdAt: expect.any(Date),
trace,
formResponseId: 222,
queuedFormResponseId: undefined,
bookingUid: "booking-uid-222",
assignmentReasonId: undefined,
},
});
expect(result.trace).toEqual([]);
});
});
});
@@ -0,0 +1,30 @@
import type { PrismaClient } from "@calcom/prisma";
import type { Prisma } from "@calcom/prisma/client";
import type {
IRoutingTraceRepository,
IRoutingTraceRepositoryCreateArgs,
RoutingTrace,
RoutingTraceRecord,
} from "./RoutingTraceRepository.interface";
export class PrismaRoutingTraceRepository implements IRoutingTraceRepository {
constructor(private readonly prisma: PrismaClient) {}
async create(args: IRoutingTraceRepositoryCreateArgs): Promise<RoutingTraceRecord> {
const result = await this.prisma.routingTrace.create({
data: {
createdAt: new Date(),
trace: args.trace as Prisma.InputJsonValue,
formResponseId: args.formResponseId,
queuedFormResponseId: args.queuedFormResponseId,
bookingUid: args.bookingUid,
assignmentReasonId: args.assignmentReasonId,
},
});
return {
...result,
trace: result.trace as RoutingTrace,
};
}
}
@@ -0,0 +1,38 @@
import z from "zod";
import type { routingStepSchema } from "../schema/zod";
export type RoutingStep = z.infer<typeof routingStepSchema>;
export type RoutingTrace = RoutingStep[];
export interface BaseRoutingTrace {
id: string;
createdAt: Date;
trace: string;
}
export interface RoutingTraceWithFormResponse extends BaseRoutingTrace {
formResponseId: string;
}
export interface RoutingTraceRecord {
id: string;
createdAt: Date;
trace: RoutingTrace;
formResponseId: number | null;
queuedFormResponseId: string | null;
bookingUid: string | null;
assignmentReasonId: number | null;
}
export interface IRoutingTraceRepositoryCreateArgs {
trace: RoutingTrace;
formResponseId?: number;
queuedFormResponseId?: string;
bookingUid: string;
assignmentReasonId?: number;
}
export interface IRoutingTraceRepository {
create(args: IRoutingTraceRepositoryCreateArgs): Promise<RoutingTraceRecord>;
}
@@ -0,0 +1,8 @@
import z from "zod";
export const routingStepSchema = z.object({
domain: z.string(),
step: z.string(),
timestamp: z.number(),
data: z.record(z.unknown()),
});
@@ -0,0 +1,663 @@
import type { AssignmentReasonRepository } from "@calcom/features/assignment-reason/repositories/AssignmentReasonRepository";
import { AssignmentReasonEnum } from "@calcom/prisma/enums";
import { beforeEach, describe, expect, it, vi } from "vitest";
import type {
IPendingRoutingTraceRepository,
PendingRoutingTraceRecord,
} from "../repositories/PendingRoutingTraceRepository.interface";
import type { IRoutingTraceRepository } from "../repositories/RoutingTraceRepository.interface";
import { RoutingTraceService } from "./RoutingTraceService";
describe("RoutingTraceService", () => {
let service: RoutingTraceService;
let mockPendingRoutingTraceRepository: IPendingRoutingTraceRepository;
let mockRoutingTraceRepository: IRoutingTraceRepository;
let mockAssignmentReasonRepository: Pick<AssignmentReasonRepository, "createAssignmentReason">;
function createMockPendingRoutingTraceRepository(): IPendingRoutingTraceRepository {
return {
create: vi.fn(),
findByFormResponseId: vi.fn(),
findByQueuedFormResponseId: vi.fn(),
};
}
function createMockRoutingTraceRepository(): IRoutingTraceRepository {
return {
create: vi.fn(),
};
}
function createMockAssignmentReasonRepository(): Pick<AssignmentReasonRepository, "createAssignmentReason"> {
return {
createAssignmentReason: vi.fn(),
};
}
beforeEach(() => {
vi.clearAllMocks();
mockPendingRoutingTraceRepository = createMockPendingRoutingTraceRepository();
mockRoutingTraceRepository = createMockRoutingTraceRepository();
mockAssignmentReasonRepository = createMockAssignmentReasonRepository();
service = new RoutingTraceService({
pendingRoutingTraceRepository: mockPendingRoutingTraceRepository,
routingTraceRepository: mockRoutingTraceRepository,
assignmentReasonRepository: mockAssignmentReasonRepository as AssignmentReasonRepository,
});
});
describe("addStep", () => {
it("should add a step to the routing trace", () => {
service.addStep({
domain: "routing_form",
step: "attribute-logic-evaluated",
data: { routeIsFallback: false },
});
expect(service.getStepsCount()).toBe(1);
});
it("should add multiple steps to the routing trace", () => {
service.addStep({
domain: "routing_form",
step: "attribute-logic-evaluated",
data: { routeIsFallback: false },
});
service.addStep({
domain: "salesforce",
step: "salesforce_assignment",
data: { email: "owner@example.com" },
});
expect(service.getStepsCount()).toBe(2);
});
it("should add step with empty data by default", () => {
service.addStep({
domain: "test_domain",
step: "test_step",
});
expect(service.getStepsCount()).toBe(1);
});
});
describe("getStepsCount", () => {
it("should return 0 when no steps have been added", () => {
expect(service.getStepsCount()).toBe(0);
});
it("should return correct count after adding steps", () => {
service.addStep({ domain: "test", step: "step1" });
service.addStep({ domain: "test", step: "step2" });
service.addStep({ domain: "test", step: "step3" });
expect(service.getStepsCount()).toBe(3);
});
});
describe("savePendingRoutingTrace", () => {
it("should save pending trace with formResponseId", async () => {
service.addStep({
domain: "routing_form",
step: "attribute-logic-evaluated",
data: { routeIsFallback: false },
});
await service.savePendingRoutingTrace({ formResponseId: 123 });
expect(mockPendingRoutingTraceRepository.create).toHaveBeenCalledWith({
trace: expect.arrayContaining([
expect.objectContaining({
domain: "routing_form",
step: "attribute-logic-evaluated",
data: { routeIsFallback: false },
}),
]),
formResponseId: 123,
});
});
it("should save pending trace with queuedFormResponseId", async () => {
service.addStep({
domain: "routing_form",
step: "attribute-logic-evaluated",
data: { routeIsFallback: true },
});
await service.savePendingRoutingTrace({ queuedFormResponseId: "queued-123" });
expect(mockPendingRoutingTraceRepository.create).toHaveBeenCalledWith({
trace: expect.arrayContaining([
expect.objectContaining({
domain: "routing_form",
step: "attribute-logic-evaluated",
data: { routeIsFallback: true },
}),
]),
queuedFormResponseId: "queued-123",
});
});
});
describe("processForBooking", () => {
const baseProcessArgs = {
bookingId: 1,
bookingUid: "booking-uid-123",
organizerEmail: "organizer@example.com",
isRerouting: false,
};
it("should return null if no pending trace is found", async () => {
vi.mocked(mockPendingRoutingTraceRepository.findByFormResponseId).mockResolvedValue(null);
const result = await service.processForBooking({
...baseProcessArgs,
formResponseId: 123,
});
expect(result).toBeNull();
});
it("should return null if no pending trace is found for queuedFormResponseId", async () => {
vi.mocked(mockPendingRoutingTraceRepository.findByQueuedFormResponseId).mockResolvedValue(null);
const result = await service.processForBooking({
...baseProcessArgs,
queuedFormResponseId: "queued-123",
});
expect(result).toBeNull();
});
describe("routing form assignment", () => {
it("should extract ROUTING_FORM_ROUTING assignment reason", async () => {
const pendingTrace: PendingRoutingTraceRecord = {
id: "pending-1",
createdAt: new Date(),
trace: [
{
domain: "routing_form",
step: "attribute-logic-evaluated",
timestamp: Date.now(),
data: { routeIsFallback: false },
},
],
formResponseId: 123,
queuedFormResponseId: null,
};
vi.mocked(mockPendingRoutingTraceRepository.findByFormResponseId).mockResolvedValue(pendingTrace);
vi.mocked(mockAssignmentReasonRepository.createAssignmentReason).mockResolvedValue({
id: 1,
bookingId: 1,
reasonEnum: AssignmentReasonEnum.ROUTING_FORM_ROUTING,
reasonString: "",
});
vi.mocked(mockRoutingTraceRepository.create).mockResolvedValue({
id: "trace-1",
createdAt: new Date(),
trace: pendingTrace.trace,
formResponseId: 123,
queuedFormResponseId: null,
bookingUid: "booking-uid-123",
assignmentReasonId: 1,
});
const result = await service.processForBooking({
...baseProcessArgs,
formResponseId: 123,
});
expect(result).toEqual({
assignmentReason: {
reasonEnum: AssignmentReasonEnum.ROUTING_FORM_ROUTING,
reasonString: "",
},
});
expect(mockAssignmentReasonRepository.createAssignmentReason).toHaveBeenCalledWith({
bookingId: 1,
reasonEnum: AssignmentReasonEnum.ROUTING_FORM_ROUTING,
reasonString: "",
});
});
it("should extract ROUTING_FORM_ROUTING_FALLBACK assignment reason when routeIsFallback is true", async () => {
const pendingTrace: PendingRoutingTraceRecord = {
id: "pending-1",
createdAt: new Date(),
trace: [
{
domain: "routing_form",
step: "attribute-logic-evaluated",
timestamp: Date.now(),
data: { routeIsFallback: true },
},
],
formResponseId: 123,
queuedFormResponseId: null,
};
vi.mocked(mockPendingRoutingTraceRepository.findByFormResponseId).mockResolvedValue(pendingTrace);
vi.mocked(mockAssignmentReasonRepository.createAssignmentReason).mockResolvedValue({
id: 1,
bookingId: 1,
reasonEnum: AssignmentReasonEnum.ROUTING_FORM_ROUTING_FALLBACK,
reasonString: "",
});
vi.mocked(mockRoutingTraceRepository.create).mockResolvedValue({
id: "trace-1",
createdAt: new Date(),
trace: pendingTrace.trace,
formResponseId: 123,
queuedFormResponseId: null,
bookingUid: "booking-uid-123",
assignmentReasonId: 1,
});
const result = await service.processForBooking({
...baseProcessArgs,
formResponseId: 123,
});
expect(result).toEqual({
assignmentReason: {
reasonEnum: AssignmentReasonEnum.ROUTING_FORM_ROUTING_FALLBACK,
reasonString: "",
},
});
});
it("should extract REROUTED assignment reason when isRerouting is true", async () => {
const pendingTrace: PendingRoutingTraceRecord = {
id: "pending-1",
createdAt: new Date(),
trace: [
{
domain: "routing_form",
step: "attribute-logic-evaluated",
timestamp: Date.now(),
data: { routeIsFallback: false },
},
],
formResponseId: 123,
queuedFormResponseId: null,
};
vi.mocked(mockPendingRoutingTraceRepository.findByFormResponseId).mockResolvedValue(pendingTrace);
vi.mocked(mockAssignmentReasonRepository.createAssignmentReason).mockResolvedValue({
id: 1,
bookingId: 1,
reasonEnum: AssignmentReasonEnum.REROUTED,
reasonString: "Rerouted by admin@example.com",
});
vi.mocked(mockRoutingTraceRepository.create).mockResolvedValue({
id: "trace-1",
createdAt: new Date(),
trace: pendingTrace.trace,
formResponseId: 123,
queuedFormResponseId: null,
bookingUid: "booking-uid-123",
assignmentReasonId: 1,
});
const result = await service.processForBooking({
...baseProcessArgs,
formResponseId: 123,
isRerouting: true,
reroutedByEmail: "admin@example.com",
});
expect(result).toEqual({
assignmentReason: {
reasonEnum: AssignmentReasonEnum.REROUTED,
reasonString: "Rerouted by admin@example.com",
},
});
});
});
describe("salesforce assignment", () => {
it("should extract SALESFORCE_ASSIGNMENT when organizer matches CRM contact owner", async () => {
const pendingTrace: PendingRoutingTraceRecord = {
id: "pending-1",
createdAt: new Date(),
trace: [
{
domain: "salesforce",
step: "salesforce_assignment",
timestamp: Date.now(),
data: {
email: "organizer@example.com",
recordType: "Contact",
recordId: "003ABC123",
},
},
],
formResponseId: 123,
queuedFormResponseId: null,
};
vi.mocked(mockPendingRoutingTraceRepository.findByFormResponseId).mockResolvedValue(pendingTrace);
vi.mocked(mockAssignmentReasonRepository.createAssignmentReason).mockResolvedValue({
id: 1,
bookingId: 1,
reasonEnum: AssignmentReasonEnum.SALESFORCE_ASSIGNMENT,
reasonString: "Salesforce contact owner: organizer@example.com (Contact ID: 003ABC123)",
});
vi.mocked(mockRoutingTraceRepository.create).mockResolvedValue({
id: "trace-1",
createdAt: new Date(),
trace: pendingTrace.trace,
formResponseId: 123,
queuedFormResponseId: null,
bookingUid: "booking-uid-123",
assignmentReasonId: 1,
});
const result = await service.processForBooking({
...baseProcessArgs,
formResponseId: 123,
});
expect(result).toEqual({
assignmentReason: {
reasonEnum: AssignmentReasonEnum.SALESFORCE_ASSIGNMENT,
reasonString: "Salesforce contact owner: organizer@example.com (Contact ID: 003ABC123)",
},
});
});
it("should use account lookup field reason string when rrSkipToAccountLookupField is true", async () => {
const pendingTrace: PendingRoutingTraceRecord = {
id: "pending-1",
createdAt: new Date(),
trace: [
{
domain: "salesforce",
step: "salesforce_assignment",
timestamp: Date.now(),
data: {
email: "organizer@example.com",
recordType: "Account",
recordId: "001ABC123",
rrSkipToAccountLookupField: true,
rrSKipToAccountLookupFieldName: "Account_Owner__c",
},
},
],
formResponseId: 123,
queuedFormResponseId: null,
};
vi.mocked(mockPendingRoutingTraceRepository.findByFormResponseId).mockResolvedValue(pendingTrace);
vi.mocked(mockAssignmentReasonRepository.createAssignmentReason).mockResolvedValue({
id: 1,
bookingId: 1,
reasonEnum: AssignmentReasonEnum.SALESFORCE_ASSIGNMENT,
reasonString:
"Salesforce account lookup field: Account_Owner__c - organizer@example.com (Account ID: 001ABC123)",
});
vi.mocked(mockRoutingTraceRepository.create).mockResolvedValue({
id: "trace-1",
createdAt: new Date(),
trace: pendingTrace.trace,
formResponseId: 123,
queuedFormResponseId: null,
bookingUid: "booking-uid-123",
assignmentReasonId: 1,
});
const result = await service.processForBooking({
...baseProcessArgs,
formResponseId: 123,
});
expect(result).toEqual({
assignmentReason: {
reasonEnum: AssignmentReasonEnum.SALESFORCE_ASSIGNMENT,
reasonString:
"Salesforce account lookup field: Account_Owner__c - organizer@example.com (Account ID: 001ABC123)",
},
});
});
it("should fall back to routing form assignment when organizer does not match CRM contact owner", async () => {
const pendingTrace: PendingRoutingTraceRecord = {
id: "pending-1",
createdAt: new Date(),
trace: [
{
domain: "salesforce",
step: "salesforce_assignment",
timestamp: Date.now(),
data: {
email: "different-owner@example.com",
recordType: "Contact",
recordId: "003ABC123",
},
},
{
domain: "routing_form",
step: "attribute-logic-evaluated",
timestamp: Date.now(),
data: { routeIsFallback: false },
},
],
formResponseId: 123,
queuedFormResponseId: null,
};
vi.mocked(mockPendingRoutingTraceRepository.findByFormResponseId).mockResolvedValue(pendingTrace);
vi.mocked(mockAssignmentReasonRepository.createAssignmentReason).mockResolvedValue({
id: 1,
bookingId: 1,
reasonEnum: AssignmentReasonEnum.ROUTING_FORM_ROUTING,
reasonString: "",
});
vi.mocked(mockRoutingTraceRepository.create).mockResolvedValue({
id: "trace-1",
createdAt: new Date(),
trace: pendingTrace.trace,
formResponseId: 123,
queuedFormResponseId: null,
bookingUid: "booking-uid-123",
assignmentReasonId: 1,
});
const result = await service.processForBooking({
...baseProcessArgs,
formResponseId: 123,
});
expect(result).toEqual({
assignmentReason: {
reasonEnum: AssignmentReasonEnum.ROUTING_FORM_ROUTING,
reasonString: "",
},
});
});
it("should handle case-insensitive email matching for CRM assignment", async () => {
const pendingTrace: PendingRoutingTraceRecord = {
id: "pending-1",
createdAt: new Date(),
trace: [
{
domain: "salesforce",
step: "salesforce_assignment",
timestamp: Date.now(),
data: {
email: "ORGANIZER@EXAMPLE.COM",
recordType: "Contact",
recordId: "003ABC123",
},
},
],
formResponseId: 123,
queuedFormResponseId: null,
};
vi.mocked(mockPendingRoutingTraceRepository.findByFormResponseId).mockResolvedValue(pendingTrace);
vi.mocked(mockAssignmentReasonRepository.createAssignmentReason).mockResolvedValue({
id: 1,
bookingId: 1,
reasonEnum: AssignmentReasonEnum.SALESFORCE_ASSIGNMENT,
reasonString: "Salesforce contact owner: ORGANIZER@EXAMPLE.COM (Contact ID: 003ABC123)",
});
vi.mocked(mockRoutingTraceRepository.create).mockResolvedValue({
id: "trace-1",
createdAt: new Date(),
trace: pendingTrace.trace,
formResponseId: 123,
queuedFormResponseId: null,
bookingUid: "booking-uid-123",
assignmentReasonId: 1,
});
const result = await service.processForBooking({
...baseProcessArgs,
formResponseId: 123,
});
expect(result?.assignmentReason?.reasonEnum).toBe(AssignmentReasonEnum.SALESFORCE_ASSIGNMENT);
});
});
it("should create permanent routing trace with assignment reason", async () => {
const pendingTrace: PendingRoutingTraceRecord = {
id: "pending-1",
createdAt: new Date(),
trace: [
{
domain: "routing_form",
step: "attribute-logic-evaluated",
timestamp: Date.now(),
data: { routeIsFallback: false },
},
],
formResponseId: 123,
queuedFormResponseId: null,
};
vi.mocked(mockPendingRoutingTraceRepository.findByFormResponseId).mockResolvedValue(pendingTrace);
vi.mocked(mockAssignmentReasonRepository.createAssignmentReason).mockResolvedValue({
id: 42,
bookingId: 1,
reasonEnum: AssignmentReasonEnum.ROUTING_FORM_ROUTING,
reasonString: "",
});
vi.mocked(mockRoutingTraceRepository.create).mockResolvedValue({
id: "trace-1",
createdAt: new Date(),
trace: pendingTrace.trace,
formResponseId: 123,
queuedFormResponseId: null,
bookingUid: "booking-uid-123",
assignmentReasonId: 42,
});
await service.processForBooking({
...baseProcessArgs,
formResponseId: 123,
});
expect(mockRoutingTraceRepository.create).toHaveBeenCalledWith({
trace: pendingTrace.trace,
formResponseId: 123,
queuedFormResponseId: undefined,
bookingUid: "booking-uid-123",
assignmentReasonId: 42,
});
});
it("should handle pending trace without assignment reason data", async () => {
const pendingTrace: PendingRoutingTraceRecord = {
id: "pending-1",
createdAt: new Date(),
trace: [
{
domain: "other_domain",
step: "some_step",
timestamp: Date.now(),
data: {},
},
],
formResponseId: 123,
queuedFormResponseId: null,
};
vi.mocked(mockPendingRoutingTraceRepository.findByFormResponseId).mockResolvedValue(pendingTrace);
vi.mocked(mockRoutingTraceRepository.create).mockResolvedValue({
id: "trace-1",
createdAt: new Date(),
trace: pendingTrace.trace,
formResponseId: 123,
queuedFormResponseId: null,
bookingUid: "booking-uid-123",
assignmentReasonId: null,
});
const result = await service.processForBooking({
...baseProcessArgs,
formResponseId: 123,
});
expect(result).toEqual({
assignmentReason: undefined,
});
expect(mockAssignmentReasonRepository.createAssignmentReason).not.toHaveBeenCalled();
expect(mockRoutingTraceRepository.create).toHaveBeenCalledWith({
trace: pendingTrace.trace,
formResponseId: 123,
queuedFormResponseId: undefined,
bookingUid: "booking-uid-123",
assignmentReasonId: undefined,
});
});
it("should process pending trace found by queuedFormResponseId", async () => {
const pendingTrace: PendingRoutingTraceRecord = {
id: "pending-1",
createdAt: new Date(),
trace: [
{
domain: "routing_form",
step: "attribute-logic-evaluated",
timestamp: Date.now(),
data: { routeIsFallback: false },
},
],
formResponseId: null,
queuedFormResponseId: "queued-123",
};
vi.mocked(mockPendingRoutingTraceRepository.findByQueuedFormResponseId).mockResolvedValue(pendingTrace);
vi.mocked(mockAssignmentReasonRepository.createAssignmentReason).mockResolvedValue({
id: 1,
bookingId: 1,
reasonEnum: AssignmentReasonEnum.ROUTING_FORM_ROUTING,
reasonString: "",
});
vi.mocked(mockRoutingTraceRepository.create).mockResolvedValue({
id: "trace-1",
createdAt: new Date(),
trace: pendingTrace.trace,
formResponseId: null,
queuedFormResponseId: "queued-123",
bookingUid: "booking-uid-123",
assignmentReasonId: 1,
});
const result = await service.processForBooking({
...baseProcessArgs,
queuedFormResponseId: "queued-123",
});
expect(mockPendingRoutingTraceRepository.findByQueuedFormResponseId).toHaveBeenCalledWith("queued-123");
expect(result?.assignmentReason?.reasonEnum).toBe(AssignmentReasonEnum.ROUTING_FORM_ROUTING);
});
});
});
@@ -0,0 +1,269 @@
import type { AssignmentReasonRepository } from "@calcom/features/assignment-reason/repositories/AssignmentReasonRepository";
import logger from "@calcom/lib/logger";
import { AssignmentReasonEnum } from "@calcom/prisma/enums";
import type { IPendingRoutingTraceRepository } from "../repositories/PendingRoutingTraceRepository.interface";
import type {
IRoutingTraceRepository,
RoutingStep,
RoutingTrace,
} from "../repositories/RoutingTraceRepository.interface";
export const ROUTING_TRACE_DOMAINS = {
SALESFORCE: "salesforce",
ROUTING_FORM: "routing_form",
} as const;
export const ROUTING_TRACE_STEPS = {
SALESFORCE_ASSIGNMENT: "salesforce_assignment",
ATTRIBUTE_LOGIC_EVALUATED: "attribute-logic-evaluated",
} as const;
interface IRoutingTraceServiceDeps {
pendingRoutingTraceRepository: IPendingRoutingTraceRepository;
routingTraceRepository: IRoutingTraceRepository;
assignmentReasonRepository: AssignmentReasonRepository;
}
export interface ProcessRoutingTraceResult {
assignmentReason?: {
reasonEnum: AssignmentReasonEnum;
reasonString: string;
};
}
export class RoutingTraceService {
private routingTraceSteps: RoutingStep[] = [];
constructor(private readonly deps: IRoutingTraceServiceDeps) {}
/** For debugging - get the number of steps recorded */
getStepsCount(): number {
return this.routingTraceSteps.length;
}
/** To be called by the domain specific routing trace services */
addStep({
domain,
step,
data = {},
}: {
domain: string;
step: string;
data?: Record<string, unknown>;
}) {
this.routingTraceSteps.push({ domain, step, timestamp: Date.now(), data });
}
/** Save pending trace when routing form is submitted (before booking is created) */
async savePendingRoutingTrace(
args: { formResponseId: number } | { queuedFormResponseId: string }
) {
await this.deps.pendingRoutingTraceRepository.create({
trace: this.routingTraceSteps,
...args,
});
}
/**
* Process pending routing trace for a booking.
* Looks up the pending trace, extracts assignment reason, creates permanent trace.
*/
async processForBooking(args: {
formResponseId?: number;
queuedFormResponseId?: string;
bookingId: number;
bookingUid: string;
organizerEmail: string;
isRerouting: boolean;
reroutedByEmail?: string | null;
}): Promise<ProcessRoutingTraceResult | null> {
const { formResponseId, queuedFormResponseId, bookingId, bookingUid, organizerEmail, isRerouting, reroutedByEmail } = args;
// 1. Look up pending trace by formResponseId or queuedFormResponseId
let pendingTrace = null;
if (formResponseId) {
pendingTrace = await this.deps.pendingRoutingTraceRepository.findByFormResponseId(formResponseId);
} else if (queuedFormResponseId) {
pendingTrace = await this.deps.pendingRoutingTraceRepository.findByQueuedFormResponseId(queuedFormResponseId);
}
if (!pendingTrace) {
logger.warn("Could not find pending routing trace for form response", { formResponseId, queuedFormResponseId, bookingId });
return null;
}
// 2. Extract assignment reason from trace
const assignmentReasonData = this.extractAssignmentReasonFromTrace(
pendingTrace.trace,
{
organizerEmail,
isRerouting,
reroutedByEmail,
}
);
let assignmentReasonId: number | undefined;
// 3. Create assignment reason record if we have trace-based data
if (assignmentReasonData) {
const createdReason = await this.deps.assignmentReasonRepository.createAssignmentReason({
bookingId,
reasonEnum: assignmentReasonData.reasonEnum,
reasonString: assignmentReasonData.reasonString,
});
assignmentReasonId = createdReason.id;
}
// 4. Create permanent routing trace
await this.deps.routingTraceRepository.create({
trace: pendingTrace.trace,
formResponseId: pendingTrace.formResponseId ?? undefined,
queuedFormResponseId: pendingTrace.queuedFormResponseId ?? undefined,
bookingUid,
assignmentReasonId,
});
return {
assignmentReason: assignmentReasonData
? {
reasonEnum: assignmentReasonData.reasonEnum,
reasonString: assignmentReasonData.reasonString,
}
: undefined,
};
}
/**
* Extract assignment reason from trace steps.
* Priority: CRM (Salesforce) > Routing Form
* CRM assignment is only used if the booking organizer matches the CRM contact owner.
*/
private extractAssignmentReasonFromTrace(
trace: RoutingTrace,
context: {
organizerEmail: string;
isRerouting: boolean;
reroutedByEmail?: string | null;
}
): { reasonEnum: AssignmentReasonEnum; reasonString: string } | null {
const crmAssignmentStep = trace.find(
(step) =>
step.domain === ROUTING_TRACE_DOMAINS.SALESFORCE &&
step.step === ROUTING_TRACE_STEPS.SALESFORCE_ASSIGNMENT
);
if (crmAssignmentStep && crmAssignmentStep.data) {
const {
email,
recordType,
recordId,
rrSkipToAccountLookupField,
rrSKipToAccountLookupFieldName,
} = crmAssignmentStep.data as {
email?: string;
recordType?: string | null;
recordId?: string;
rrSkipToAccountLookupField?: boolean;
rrSKipToAccountLookupFieldName?: string | null;
};
// Only use CRM assignment if the booking organizer matches the contact owner
if (
email &&
email.toLowerCase() === context.organizerEmail.toLowerCase()
) {
return {
reasonEnum: AssignmentReasonEnum.SALESFORCE_ASSIGNMENT,
reasonString: this.buildSalesforceReasonString({
email,
recordType: recordType ?? "Contact", // Default to Contact if not specified
recordId,
rrSkipToAccountLookupField,
rrSKipToAccountLookupFieldName,
}),
};
}
}
const routingFormStep = trace.find(
(step) =>
step.domain === ROUTING_TRACE_DOMAINS.ROUTING_FORM &&
step.step === ROUTING_TRACE_STEPS.ATTRIBUTE_LOGIC_EVALUATED
);
if (routingFormStep && routingFormStep.data) {
const { routeIsFallback, attributeRoutingDetails } = routingFormStep.data as {
routeIsFallback?: boolean;
attributeRoutingDetails?: Array<{ attributeName: string; attributeValue: string }>;
};
const reasonEnum = context.isRerouting
? AssignmentReasonEnum.REROUTED
: routeIsFallback
? AssignmentReasonEnum.ROUTING_FORM_ROUTING_FALLBACK
: AssignmentReasonEnum.ROUTING_FORM_ROUTING;
return {
reasonEnum,
reasonString: this.buildRoutingFormReasonString({
isRerouting: context.isRerouting,
reroutedByEmail: context.reroutedByEmail,
attributeRoutingDetails,
}),
};
}
logger.warn("Could not extract assignment reason from routing trace - no matching steps found", {
traceSteps: trace.map((s) => `${s.domain}:${s.step}`),
});
return null;
}
private buildRoutingFormReasonString(data: {
isRerouting: boolean;
reroutedByEmail?: string | null;
attributeRoutingDetails?: Array<{ attributeName: string; attributeValue: string }>;
}): string {
const { isRerouting, reroutedByEmail, attributeRoutingDetails } = data;
const reroutingPart = isRerouting && reroutedByEmail ? `Rerouted by ${reroutedByEmail}` : "";
const attributesPart =
attributeRoutingDetails && attributeRoutingDetails.length > 0
? attributeRoutingDetails
.map(({ attributeName, attributeValue }) => `${attributeName}: ${attributeValue}`)
.join(", ")
: "";
// Combine both parts, matching main branch behavior
return `${reroutingPart} ${attributesPart}`.trim();
}
private buildSalesforceReasonString(data: {
email: string;
recordType: string;
recordId?: string;
rrSkipToAccountLookupField?: boolean;
rrSKipToAccountLookupFieldName?: string | null;
}): string {
const {
email,
recordType,
recordId,
rrSkipToAccountLookupField,
rrSKipToAccountLookupFieldName,
} = data;
if (rrSkipToAccountLookupField && rrSKipToAccountLookupFieldName) {
return `Salesforce account lookup field: ${rrSKipToAccountLookupFieldName} - ${email}${
recordId ? ` (Account ID: ${recordId})` : ""
}`;
}
const recordLabel = recordType.toLowerCase();
return `Salesforce ${recordLabel} owner: ${email}${
recordId ? ` (${recordType} ID: ${recordId})` : ""
}`;
}
}
@@ -0,0 +1,65 @@
-- CreateTable
CREATE TABLE "public"."PendingRoutingTrace" (
"id" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"trace" JSONB NOT NULL,
"formResponseId" INTEGER,
"queuedFormResponseId" TEXT,
CONSTRAINT "PendingRoutingTrace_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "public"."RoutingTrace" (
"id" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"trace" JSONB NOT NULL,
"formResponseId" INTEGER,
"queuedFormResponseId" TEXT,
"bookingUid" TEXT,
"assignmentReasonId" INTEGER,
CONSTRAINT "RoutingTrace_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "PendingRoutingTrace_formResponseId_key" ON "public"."PendingRoutingTrace"("formResponseId");
-- CreateIndex
CREATE UNIQUE INDEX "PendingRoutingTrace_queuedFormResponseId_key" ON "public"."PendingRoutingTrace"("queuedFormResponseId");
-- CreateIndex
CREATE UNIQUE INDEX "RoutingTrace_formResponseId_key" ON "public"."RoutingTrace"("formResponseId");
-- CreateIndex
CREATE UNIQUE INDEX "RoutingTrace_queuedFormResponseId_key" ON "public"."RoutingTrace"("queuedFormResponseId");
-- CreateIndex
CREATE UNIQUE INDEX "RoutingTrace_bookingUid_key" ON "public"."RoutingTrace"("bookingUid");
-- CreateIndex
CREATE UNIQUE INDEX "RoutingTrace_assignmentReasonId_key" ON "public"."RoutingTrace"("assignmentReasonId");
-- AddForeignKey
ALTER TABLE "public"."PendingRoutingTrace" ADD CONSTRAINT "PendingRoutingTrace_formResponseId_fkey" FOREIGN KEY ("formResponseId") REFERENCES "public"."App_RoutingForms_FormResponse"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "public"."PendingRoutingTrace" ADD CONSTRAINT "PendingRoutingTrace_queuedFormResponseId_fkey" FOREIGN KEY ("queuedFormResponseId") REFERENCES "public"."App_RoutingForms_QueuedFormResponse"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "public"."RoutingTrace" ADD CONSTRAINT "RoutingTrace_formResponseId_fkey" FOREIGN KEY ("formResponseId") REFERENCES "public"."App_RoutingForms_FormResponse"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "public"."RoutingTrace" ADD CONSTRAINT "RoutingTrace_queuedFormResponseId_fkey" FOREIGN KEY ("queuedFormResponseId") REFERENCES "public"."App_RoutingForms_QueuedFormResponse"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "public"."RoutingTrace" ADD CONSTRAINT "RoutingTrace_bookingUid_fkey" FOREIGN KEY ("bookingUid") REFERENCES "public"."Booking"("uid") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "public"."RoutingTrace" ADD CONSTRAINT "RoutingTrace_assignmentReasonId_fkey" FOREIGN KEY ("assignmentReasonId") REFERENCES "public"."AssignmentReason"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddCheckConstraint: Ensure at least one of formResponseId or queuedFormResponseId is set
ALTER TABLE "public"."PendingRoutingTrace" ADD CONSTRAINT "PendingRoutingTrace_at_least_one_response_id" CHECK ("formResponseId" IS NOT NULL OR "queuedFormResponseId" IS NOT NULL);
-- AddCheckConstraint: Ensure at least one of formResponseId or queuedFormResponseId is set
ALTER TABLE "public"."RoutingTrace" ADD CONSTRAINT "RoutingTrace_at_least_one_response_id" CHECK ("formResponseId" IS NOT NULL OR "queuedFormResponseId" IS NOT NULL);
+45 -9
View File
@@ -920,6 +920,7 @@ model Booking {
routingFormResponses RoutingFormResponseDenormalized[]
expenseLogs CreditExpenseLog[]
report BookingReport?
routingTrace RoutingTrace?
// @@partial_index([reassignById])
@@ -1349,6 +1350,8 @@ model App_RoutingForms_FormResponse {
routingFormResponseFields RoutingFormResponseField[]
routingFormResponses RoutingFormResponseDenormalized[]
queuedFormResponse App_RoutingForms_QueuedFormResponse?
pendingRoutingTrace PendingRoutingTrace?
routingTrace RoutingTrace?
@@unique([formFillerId, formId])
@@index([formFillerId])
@@ -1357,15 +1360,17 @@ model App_RoutingForms_FormResponse {
}
model App_RoutingForms_QueuedFormResponse {
id String @id @default(cuid())
form App_RoutingForms_Form @relation(fields: [formId], references: [id], onDelete: Cascade)
formId String
response Json
chosenRouteId String?
createdAt DateTime @default(now())
updatedAt DateTime? @updatedAt
actualResponseId Int? @unique
actualResponse App_RoutingForms_FormResponse? @relation(fields: [actualResponseId], references: [id], onDelete: Cascade)
id String @id @default(cuid())
form App_RoutingForms_Form @relation(fields: [formId], references: [id], onDelete: Cascade)
formId String
response Json
chosenRouteId String?
createdAt DateTime @default(now())
updatedAt DateTime? @updatedAt
actualResponseId Int? @unique
actualResponse App_RoutingForms_FormResponse? @relation(fields: [actualResponseId], references: [id], onDelete: Cascade)
pendingRoutingTrace PendingRoutingTrace?
routingTrace RoutingTrace?
}
model RoutingFormResponseField {
@@ -2334,6 +2339,7 @@ model AssignmentReason {
booking Booking @relation(fields: [bookingId], references: [id], onDelete: Cascade)
reasonEnum AssignmentReasonEnum
reasonString String
routingTrace RoutingTrace?
@@index([bookingId])
}
@@ -3207,3 +3213,33 @@ model AttributeSyncFieldMapping {
@@unique([integrationAttributeSyncId, attributeId])
@@index([integrationAttributeSyncId])
}
model PendingRoutingTrace {
id String @id @default(uuid())
createdAt DateTime @default(now())
trace Json
formResponseId Int? @unique
formResponse App_RoutingForms_FormResponse? @relation(fields: [formResponseId], references: [id], onDelete: Cascade)
queuedFormResponseId String? @unique
queuedFormResponse App_RoutingForms_QueuedFormResponse? @relation(fields: [queuedFormResponseId], references: [id], onDelete: Cascade)
}
model RoutingTrace {
id String @id @default(uuid())
createdAt DateTime @default(now())
trace Json
formResponseId Int? @unique
formResponse App_RoutingForms_FormResponse? @relation(fields: [formResponseId], references: [id], onDelete: Cascade)
queuedFormResponseId String? @unique
queuedFormResponse App_RoutingForms_QueuedFormResponse? @relation(fields: [queuedFormResponseId], references: [id], onDelete: Cascade)
bookingUid String? @unique
booking Booking? @relation(fields: [bookingUid], references: [uid], onDelete: Cascade)
assignmentReasonId Int? @unique
assignmentReason AssignmentReason? @relation(fields: [assignmentReasonId], references: [id], onDelete: Cascade)
}
@@ -1,5 +1,6 @@
import { getSerializableForm } from "@calcom/app-store/routing-forms/lib/getSerializableForm";
import { handleResponse } from "@calcom/features/routing-forms/lib/handleResponse";
import { getRoutingTraceService } from "@calcom/features/routing-trace/di/RoutingTraceService.container";
import type { PrismaClient } from "@calcom/prisma";
import { TRPCError } from "@trpc/server";
@@ -46,14 +47,32 @@ export const responseHandler = async ({ ctx, input }: ResponseHandlerOptions) =>
form,
});
return handleResponse({
// Initialize trace service for tracking routing decisions
const traceService = isPreview ? undefined : getRoutingTraceService();
const result = await handleResponse({
response,
identifierKeyedResponse: null,
form: serializableForm,
formFillerId,
chosenRouteId,
isPreview,
traceService,
});
// Save the pending trace
if (traceService) {
const formResponseId = result.formResponse?.id;
const queuedFormResponseId = result.queuedFormResponse?.id;
if (formResponseId) {
await traceService.savePendingRoutingTrace({ formResponseId });
} else if (queuedFormResponseId) {
await traceService.savePendingRoutingTrace({ queuedFormResponseId });
}
}
return result;
};
export default responseHandler;