feat: Routing trace presenter (#27372)

* feat: Add routing trace presenters

Add domain-specific presenters (SalesforceRoutingTracePresenter,
RoutingFormTracePresenter) that format trace steps into human-readable
strings, and a core RoutingTracePresenter that delegates to them based
on step domain. Includes unit tests for all presenters.

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

* feat: Add findByBookingUid to RoutingTraceRepository

Add method to look up a routing trace by booking UID, needed by the
routing trace presenter tRPC endpoint.

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

* feat: Add getRoutingTrace tRPC endpoint

Expose routing trace data for a booking via viewer.bookings.getRoutingTrace.
Tries the permanent RoutingTrace first (round robin bookings), then falls
back to PendingRoutingTrace via the booking's form response relation.

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

* feat: Add routing trace side sheet to booking list item

Add a route icon button on booking list items that came from routing
forms. Clicking it opens a side sheet displaying the full routing trace
as human-readable steps. Adds RoutingTraceSheet component, store state,
and translation key.

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

* refactor: Move routing trace action to dropdown menu

Move the routing trace button from a standalone icon on the booking list
item into the actions dropdown menu alongside "Report wrong assignment".
The RoutingTraceSheet is now rendered from BookingActionsDropdown.

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

* fix: Improve routing trace UI and handle unnamed routes

- Redesign RoutingTraceSheet with vertical timeline layout, domain badges,
  skeleton loading state, and millisecond timestamps
- Use Salesforce app-store icon for Salesforce steps
- Fall back to "Unnamed route" when route name is missing or matches route ID
- Fix dropdown menu icon to git-merge (valid icon, unique in menu)

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

* test: Add tests for getRoutingTrace handler and findByBookingUid repository method

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

* Abstract functions

* Fix build error

* Add permission check

* fix: Update getRoutingTrace tests to include ctx and mock BookingAccessService

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

---------

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
Joe Au-Yeung
2026-01-29 15:26:44 -03:00
committed by GitHub
co-authored by Claude Opus 4.5 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>
parent e7d38755dd
commit 7abbc8c22c
24 changed files with 1086 additions and 24 deletions
@@ -280,7 +280,6 @@ function BookingListItem(booking: BookingItemProps) {
const setIsOpenWrongAssignmentDialog = useBookingActionsStoreContext(
(state) => state.setIsOpenWrongAssignmentDialog
);
const reportAction = getReportAction(actionContext);
const reportActionWithHandler = {
...reportAction,
@@ -0,0 +1,85 @@
"use client";
import dayjs from "@calcom/dayjs";
import { DomainIcon } from "@calcom/features/routing-trace/components/DomainIcon";
import { getDomainLabel } from "@calcom/features/routing-trace/presenters/getDomainLabel";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { trpc } from "@calcom/trpc/react";
import { Sheet, SheetContent, SheetHeader, SheetTitle, SheetBody } from "@calcom/ui/components/sheet";
interface RoutingTraceSheetProps {
isOpen: boolean;
setIsOpen: (open: boolean) => void;
bookingUid: string;
}
export function RoutingTraceSheet({ isOpen, setIsOpen, bookingUid }: RoutingTraceSheetProps) {
const { t } = useLocale();
const { data, isLoading } = trpc.viewer.bookings.getRoutingTrace.useQuery(
{ bookingUid },
{
enabled: isOpen,
staleTime: 10 * 60 * 1000,
}
);
return (
<Sheet open={isOpen} onOpenChange={setIsOpen}>
<SheetContent>
<SheetHeader>
<SheetTitle>{t("routing_trace")}</SheetTitle>
</SheetHeader>
<SheetBody>
{isLoading && (
<div className="flex flex-col gap-4 py-2">
{[1, 2, 3].map((i) => (
<div key={i} className="flex gap-3">
<div className="bg-muted h-8 w-8 animate-pulse rounded-full" />
<div className="flex flex-1 flex-col gap-1.5">
<div className="bg-muted h-3 w-24 animate-pulse rounded" />
<div className="bg-muted h-4 w-full animate-pulse rounded" />
</div>
</div>
))}
</div>
)}
{!isLoading && !data?.steps?.length && (
<p className="text-subtle text-sm">{t("no_results_found")}</p>
)}
{!isLoading && data?.steps && data.steps.length > 0 && (
<div className="relative flex flex-col">
{data.steps.map((step, idx) => {
const isLast = idx === data.steps.length - 1;
return (
<div key={idx} className="relative flex gap-3 pb-6 last:pb-0">
{/* Timeline connector line */}
{!isLast && (
<div className="border-subtle absolute left-4 top-8 bottom-0 border-l" />
)}
{/* Icon circle */}
<div className="bg-default border-subtle relative z-10 flex h-8 w-8 flex-shrink-0 items-center justify-center rounded-full border">
<DomainIcon domain={step.domain} />
</div>
{/* Content */}
<div className="flex min-w-0 flex-1 flex-col gap-0.5 pt-0.5">
<div className="flex items-center gap-2">
<span className="bg-subtle text-subtle rounded px-1.5 py-0.5 text-xs font-medium">
{getDomainLabel(step.domain)}
</span>
<span className="text-muted text-xs">
{dayjs(step.timestamp).format("h:mm:ss.SSS A")}
</span>
</div>
<p className="text-emphasis text-sm">{step.message}</p>
</div>
</div>
);
})}
</div>
)}
</SheetBody>
</SheetContent>
</Sheet>
);
}
@@ -32,6 +32,7 @@ import { ReportBookingDialog } from "@components/dialog/ReportBookingDialog";
import { RerouteDialog } from "@components/dialog/RerouteDialog";
import { RescheduleDialog } from "@components/dialog/RescheduleDialog";
import { WrongAssignmentDialog } from "@components/dialog/WrongAssignmentDialog";
import { RoutingTraceSheet } from "../RoutingTraceSheet";
import { useBookingConfirmation } from "../hooks/useBookingConfirmation";
import type { BookingItemProps } from "../types";
@@ -129,6 +130,10 @@ export function BookingActionsDropdown({
const setRerouteDialogIsOpen = useBookingActionsStoreContext((state) => state.setRerouteDialogIsOpen);
const isCancelDialogOpen = useBookingActionsStoreContext((state) => state.isCancelDialogOpen);
const setIsCancelDialogOpen = useBookingActionsStoreContext((state) => state.setIsCancelDialogOpen);
const isOpenRoutingTraceSheet = useBookingActionsStoreContext((state) => state.isOpenRoutingTraceSheet);
const setIsOpenRoutingTraceSheet = useBookingActionsStoreContext(
(state) => state.setIsOpenRoutingTraceSheet
);
const cardCharged = booking?.payment[0]?.success;
@@ -448,16 +453,23 @@ export function BookingActionsDropdown({
status={getBookingStatus()}
/>
{isBookingFromRoutingForm && (
<WrongAssignmentDialog
isOpenDialog={isOpenWrongAssignmentDialog}
setIsOpenDialog={setIsOpenWrongAssignmentDialog}
bookingUid={booking.uid}
routingReason={booking.assignmentReason[0]?.reasonString ?? null}
guestEmail={booking.attendees[0]?.email ?? ""}
hostEmail={booking.user?.email ?? ""}
hostName={booking.user?.name ?? null}
teamId={booking.eventType?.team?.id ?? null}
/>
<>
<WrongAssignmentDialog
isOpenDialog={isOpenWrongAssignmentDialog}
setIsOpenDialog={setIsOpenWrongAssignmentDialog}
bookingUid={booking.uid}
routingReason={booking.assignmentReason[0]?.reasonString ?? null}
guestEmail={booking.attendees[0]?.email ?? ""}
hostEmail={booking.user?.email ?? ""}
hostName={booking.user?.name ?? null}
teamId={booking.eventType?.team?.id ?? null}
/>
<RoutingTraceSheet
isOpen={isOpenRoutingTraceSheet}
setIsOpen={setIsOpenRoutingTraceSheet}
bookingUid={booking.uid}
/>
</>
)}
{booking.paid && booking.payment[0] && (
<ChargeCardDialog
@@ -661,6 +673,20 @@ export function BookingActionsDropdown({
</DropdownItem>
</DropdownMenuItem>
))}
{isBookingFromRoutingForm && (
<DropdownMenuItem className="rounded-lg" key="view_routing_trace">
<DropdownItem
type="button"
StartIcon="git-merge"
onClick={(e) => {
e.stopPropagation();
setIsOpenRoutingTraceSheet(true);
}}
data-testid="view_routing_trace">
{t("routing_trace")}
</DropdownItem>
</DropdownMenuItem>
)}
<DropdownMenuSeparator />
<DropdownMenuLabel className="px-2 pb-1 pt-1.5">{t("after_event")}</DropdownMenuLabel>
{afterEventActions.map((action) => (
@@ -18,6 +18,7 @@ export type BookingActionsStore = {
rerouteDialogIsOpen: boolean;
isCancelDialogOpen: boolean;
isOpenWrongAssignmentDialog: boolean;
isOpenRoutingTraceSheet: boolean;
// Dialog setters
setRejectionDialogIsOpen: React.Dispatch<React.SetStateAction<boolean>>;
@@ -33,6 +34,7 @@ export type BookingActionsStore = {
setRerouteDialogIsOpen: React.Dispatch<React.SetStateAction<boolean>>;
setIsCancelDialogOpen: React.Dispatch<React.SetStateAction<boolean>>;
setIsOpenWrongAssignmentDialog: React.Dispatch<React.SetStateAction<boolean>>;
setIsOpenRoutingTraceSheet: React.Dispatch<React.SetStateAction<boolean>>;
};
export const createBookingActionsStore = () => {
@@ -51,6 +53,7 @@ export const createBookingActionsStore = () => {
rerouteDialogIsOpen: false,
isCancelDialogOpen: false,
isOpenWrongAssignmentDialog: false,
isOpenRoutingTraceSheet: false,
// Dialog setters
setRejectionDialogIsOpen: (isOpen) =>
@@ -109,5 +112,10 @@ export const createBookingActionsStore = () => {
isOpenWrongAssignmentDialog:
typeof isOpen === "function" ? isOpen(state.isOpenWrongAssignmentDialog) : isOpen,
})),
setIsOpenRoutingTraceSheet: (isOpen) =>
set((state) => ({
isOpenRoutingTraceSheet:
typeof isOpen === "function" ? isOpen(state.isOpenRoutingTraceSheet) : isOpen,
})),
}));
};
@@ -4428,6 +4428,7 @@
"and_more_holidays_with_conflicts_one": "... and {{count}} more holiday with conflicts",
"and_more_holidays_with_conflicts_other": "... and {{count}} more holidays with conflicts",
"assignment_reason": "Assignment reason",
"routing_trace": "Routing trace",
"saved": "Saved",
"booking_history": "Booking history",
"booking_history_description": "View the history of actions performed on this booking",
@@ -0,0 +1,27 @@
import { Icon } from "@calcom/ui/components/icon";
import { ROUTING_TRACE_DOMAINS } from "../constants";
import type { IconName } from "@calcom/ui/components/icon";
const DOMAIN_ICONS: Record<string, { type: "icon"; name: IconName } | { type: "img"; src: string; alt: string }> = {
[ROUTING_TRACE_DOMAINS.SALESFORCE]: { type: "img", src: "/app-store/salesforce/icon.png", alt: "Salesforce" },
[ROUTING_TRACE_DOMAINS.ROUTING_FORM]: { type: "icon", name: "file-text" },
};
const DEFAULT_ICON: IconName = "shuffle";
export function DomainIcon({ domain }: { domain: string }) {
const config = DOMAIN_ICONS[domain];
if (config?.type === "img") {
return <img src={config.src} alt={config.alt} className="h-4 w-4" />;
}
return (
<Icon
name={config?.type === "icon" ? config.name : DEFAULT_ICON}
className="text-subtle h-4 w-4"
/>
);
}
@@ -0,0 +1,9 @@
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;
@@ -1,6 +1,6 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { ROUTING_TRACE_DOMAINS } from "../constants";
import type { RoutingTraceService } from "../services/RoutingTraceService";
import { ROUTING_TRACE_DOMAINS } from "../services/RoutingTraceService";
import { ROUTING_FORM_STEPS, RoutingFormTraceService } from "./RoutingFormTraceService";
describe("RoutingFormTraceService", () => {
@@ -1,5 +1,5 @@
import { ROUTING_TRACE_DOMAINS } from "../constants";
import type { RoutingTraceService } from "../services/RoutingTraceService";
import { ROUTING_TRACE_DOMAINS } from "../services/RoutingTraceService";
export const ROUTING_FORM_STEPS = {
ROUTE_MATCHED: "route_matched",
@@ -0,0 +1,93 @@
import { describe, expect, it } from "vitest";
import type { RoutingStep } from "../repositories/RoutingTraceRepository.interface";
import { RoutingFormTracePresenter } from "./RoutingFormTracePresenter";
function makeStep(step: string, data: Record<string, unknown> = {}): RoutingStep {
return { domain: "routing_form", step, timestamp: Date.now(), data };
}
describe("RoutingFormTracePresenter", () => {
it("presents route_matched", () => {
const result = RoutingFormTracePresenter.present(
makeStep("route_matched", { routeId: "route-1", routeName: "Enterprise" })
);
expect(result).toBe('Route matched: "Enterprise" (ID: route-1)');
});
it("presents fallback_route_used", () => {
const result = RoutingFormTracePresenter.present(
makeStep("fallback_route_used", { routeId: "route-2", routeName: "Default" })
);
expect(result).toBe('Fallback route used: "Default" (ID: route-2)');
});
it("presents attribute-logic-evaluated with all fields", () => {
const result = RoutingFormTracePresenter.present(
makeStep("attribute-logic-evaluated", {
routeName: "APAC",
routeIsFallback: false,
attributeRoutingDetails: [
{ attributeName: "Company Size", attributeValue: "Enterprise" },
{ attributeName: "Region", attributeValue: "APAC" },
],
})
);
expect(result).toBe(
'Attribute logic evaluated: Route: "APAC" Attributes: [Company Size=Enterprise, Region=APAC]'
);
});
it("presents attribute-logic-evaluated with fallback", () => {
const result = RoutingFormTracePresenter.present(
makeStep("attribute-logic-evaluated", {
routeName: "Fallback Route",
routeIsFallback: true,
})
);
expect(result).toBe('Attribute logic evaluated: Route: "Fallback Route" (fallback)');
});
it("presents attribute-logic-evaluated with no optional fields", () => {
const result = RoutingFormTracePresenter.present(makeStep("attribute-logic-evaluated", {}));
expect(result).toBe('Attribute logic evaluated: Route: "Unnamed route"');
});
it("presents attribute_fallback_used with routeName", () => {
const result = RoutingFormTracePresenter.present(
makeStep("attribute_fallback_used", { routeName: "Default Route" })
);
expect(result).toBe('Attribute fallback used: "Default Route"');
});
it("presents attribute_fallback_used without routeName", () => {
const result = RoutingFormTracePresenter.present(makeStep("attribute_fallback_used", {}));
expect(result).toBe('Attribute fallback used: "Unnamed route"');
});
it("presents route_matched without routeName", () => {
const result = RoutingFormTracePresenter.present(
makeStep("route_matched", { routeId: "route-1" })
);
expect(result).toBe('Route matched: "Unnamed route" (ID: route-1)');
});
it("presents fallback_route_used without routeName", () => {
const result = RoutingFormTracePresenter.present(
makeStep("fallback_route_used", { routeId: "route-2" })
);
expect(result).toBe('Fallback route used: "Unnamed route" (ID: route-2)');
});
it("treats routeName matching routeId as unnamed", () => {
const result = RoutingFormTracePresenter.present(
makeStep("route_matched", { routeId: "abc-123", routeName: "abc-123" })
);
expect(result).toBe('Route matched: "Unnamed route" (ID: abc-123)');
});
it("returns fallback for unknown step", () => {
const result = RoutingFormTracePresenter.present(makeStep("unknown_step", {}));
expect(result).toBe("Routing Form: unknown_step");
});
});
@@ -0,0 +1,30 @@
import type { RoutingStep } from "../repositories/RoutingTraceRepository.interface";
export class RoutingFormTracePresenter {
static present(step: RoutingStep): string {
const d = step.data;
const routeName = d.routeName && d.routeName !== d.routeId ? d.routeName : "Unnamed route";
switch (step.step) {
case "route_matched":
return `Route matched: "${routeName}" (ID: ${d.routeId})`;
case "fallback_route_used":
return `Fallback route used: "${routeName}" (ID: ${d.routeId})`;
case "attribute-logic-evaluated": {
const parts: string[] = [];
parts.push(`Route: "${routeName}"`);
if (d.routeIsFallback) parts.push("(fallback)");
if (d.attributeRoutingDetails && Array.isArray(d.attributeRoutingDetails)) {
const details = (d.attributeRoutingDetails as Array<{ attributeName: string; attributeValue: string }>)
.map((a) => `${a.attributeName}=${a.attributeValue}`)
.join(", ");
parts.push(`Attributes: [${details}]`);
}
return `Attribute logic evaluated: ${parts.join(" ")}`;
}
case "attribute_fallback_used":
return `Attribute fallback used: "${routeName}"`;
default:
return `Routing Form: ${step.step}`;
}
}
}
@@ -0,0 +1,99 @@
import { describe, expect, it } from "vitest";
import type { RoutingTrace } from "../repositories/RoutingTraceRepository.interface";
import { RoutingTracePresenter } from "./RoutingTracePresenter";
describe("RoutingTracePresenter", () => {
it("delegates salesforce steps to SalesforceRoutingTracePresenter", () => {
const trace: RoutingTrace = [
{
domain: "salesforce",
step: "searching_by_website_value",
timestamp: 1000,
data: { emailDomain: "acme.com" },
},
];
const result = RoutingTracePresenter.present(trace);
expect(result).toHaveLength(1);
expect(result[0]).toEqual({
message: 'Searching for Salesforce account by website matching domain "acme.com"',
domain: "salesforce",
step: "searching_by_website_value",
timestamp: 1000,
});
});
it("delegates routing form steps to RoutingFormTracePresenter", () => {
const trace: RoutingTrace = [
{
domain: "routing_form",
step: "route_matched",
timestamp: 2000,
data: { routeId: "route-1", routeName: "Enterprise" },
},
];
const result = RoutingTracePresenter.present(trace);
expect(result).toHaveLength(1);
expect(result[0]).toEqual({
message: 'Route matched: "Enterprise" (ID: route-1)',
domain: "routing_form",
step: "route_matched",
timestamp: 2000,
});
});
it("handles unknown domains with fallback", () => {
const trace: RoutingTrace = [
{
domain: "unknown_domain",
step: "some_step",
timestamp: 3000,
data: {},
},
];
const result = RoutingTracePresenter.present(trace);
expect(result).toHaveLength(1);
expect(result[0].message).toBe("unknown_domain: some_step");
});
it("presents a multi-step trace in order", () => {
const trace: RoutingTrace = [
{
domain: "routing_form",
step: "route_matched",
timestamp: 1000,
data: { routeId: "route-1", routeName: "Sales" },
},
{
domain: "salesforce",
step: "graphql_query_initiated",
timestamp: 2000,
data: { email: "user@acme.com", emailDomain: "acme.com" },
},
{
domain: "salesforce",
step: "salesforce_assignment",
timestamp: 3000,
data: { email: "owner@acme.com", recordType: "Contact", recordId: "003ABC" },
},
];
const result = RoutingTracePresenter.present(trace);
expect(result).toHaveLength(3);
expect(result[0].message).toBe('Route matched: "Sales" (ID: route-1)');
expect(result[1].message).toBe("GraphQL account resolution initiated for user@acme.com (domain: acme.com)");
expect(result[2].message).toBe("Salesforce assignment: owner@acme.com via Contact (ID: 003ABC)");
});
it("returns empty array for empty trace", () => {
const result = RoutingTracePresenter.present([]);
expect(result).toEqual([]);
});
});
@@ -0,0 +1,33 @@
import type { RoutingStep, RoutingTrace } from "../repositories/RoutingTraceRepository.interface";
import { ROUTING_TRACE_DOMAINS } from "../constants";
import { RoutingFormTracePresenter } from "./RoutingFormTracePresenter";
import { SalesforceRoutingTracePresenter } from "./SalesforceRoutingTracePresenter";
export interface PresentedStep {
message: string;
domain: string;
step: string;
timestamp: number;
}
export class RoutingTracePresenter {
static present(trace: RoutingTrace): PresentedStep[] {
return trace.map((step) => ({
message: RoutingTracePresenter.presentStep(step),
domain: step.domain,
step: step.step,
timestamp: step.timestamp,
}));
}
private static presentStep(step: RoutingStep): string {
switch (step.domain) {
case ROUTING_TRACE_DOMAINS.SALESFORCE:
return SalesforceRoutingTracePresenter.present(step);
case ROUTING_TRACE_DOMAINS.ROUTING_FORM:
return RoutingFormTracePresenter.present(step);
default:
return `${step.domain}: ${step.step}`;
}
}
}
@@ -0,0 +1,177 @@
import { describe, expect, it } from "vitest";
import type { RoutingStep } from "../repositories/RoutingTraceRepository.interface";
import { SalesforceRoutingTracePresenter } from "./SalesforceRoutingTracePresenter";
function makeStep(step: string, data: Record<string, unknown> = {}): RoutingStep {
return { domain: "salesforce", step, timestamp: Date.now(), data };
}
describe("SalesforceRoutingTracePresenter", () => {
it("presents searching_by_website_value", () => {
const result = SalesforceRoutingTracePresenter.present(
makeStep("searching_by_website_value", { emailDomain: "acme.com" })
);
expect(result).toBe('Searching for Salesforce account by website matching domain "acme.com"');
});
it("presents account_found_by_website", () => {
const result = SalesforceRoutingTracePresenter.present(
makeStep("account_found_by_website", { accountId: "001ABC", website: "acme.com" })
);
expect(result).toBe('Account found by website "acme.com" (Account ID: 001ABC)');
});
it("presents searching_by_contact_email_domain", () => {
const result = SalesforceRoutingTracePresenter.present(
makeStep("searching_by_contact_email_domain", { emailDomain: "acme.com", contactCount: 5 })
);
expect(result).toBe('Searching contacts with email domain "acme.com" (5 contacts found)');
});
it("presents account_selected_by_most_contacts", () => {
const result = SalesforceRoutingTracePresenter.present(
makeStep("account_selected_by_most_contacts", { accountId: "001ABC", contactCount: 3 })
);
expect(result).toBe("Account selected with most contacts: 001ABC (3 contacts)");
});
it("presents no_account_found", () => {
const result = SalesforceRoutingTracePresenter.present(
makeStep("no_account_found", { email: "user@acme.com", reason: "free email domain" })
);
expect(result).toBe("No account found for user@acme.com: free email domain");
});
it("presents lookup_field_query with accountId", () => {
const result = SalesforceRoutingTracePresenter.present(
makeStep("lookup_field_query", { fieldName: "Owner__c", salesforceObject: "Account", accountId: "001ABC" })
);
expect(result).toBe('Querying lookup field "Owner__c" on Account (Account: 001ABC)');
});
it("presents lookup_field_query without accountId", () => {
const result = SalesforceRoutingTracePresenter.present(
makeStep("lookup_field_query", { fieldName: "Owner__c", salesforceObject: "Account", accountId: null })
);
expect(result).toBe('Querying lookup field "Owner__c" on Account');
});
it("presents user_query_from_lookup_field", () => {
const result = SalesforceRoutingTracePresenter.present(
makeStep("user_query_from_lookup_field", { lookupFieldUserId: "005ABC", userEmail: "owner@acme.com" })
);
expect(result).toBe("Lookup field resolved to user owner@acme.com (User ID: 005ABC)");
});
it("presents contact_owner_lookup", () => {
const result = SalesforceRoutingTracePresenter.present(
makeStep("contact_owner_lookup", { contactId: "003ABC", ownerEmail: "owner@acme.com", ownerId: "005ABC" })
);
expect(result).toBe("Contact owner lookup: owner@acme.com (Contact: 003ABC)");
});
it("presents lead_owner_lookup", () => {
const result = SalesforceRoutingTracePresenter.present(
makeStep("lead_owner_lookup", { leadId: "00Q123", ownerEmail: "owner@acme.com", ownerId: "005ABC" })
);
expect(result).toBe("Lead owner lookup: owner@acme.com (Lead: 00Q123)");
});
it("presents account_owner_lookup", () => {
const result = SalesforceRoutingTracePresenter.present(
makeStep("account_owner_lookup", { accountId: "001ABC", ownerEmail: "owner@acme.com", ownerId: "005ABC" })
);
expect(result).toBe("Account owner lookup: owner@acme.com (Account: 001ABC)");
});
it("presents owner_validated", () => {
const result = SalesforceRoutingTracePresenter.present(
makeStep("owner_validated", { ownerEmail: "owner@acme.com", isTeamMember: true })
);
expect(result).toBe("Owner owner@acme.com validated as team member: true");
});
it("presents owner_lookup_skipped with email", () => {
const result = SalesforceRoutingTracePresenter.present(
makeStep("owner_lookup_skipped", { reason: "free email domain", email: "user@gmail.com" })
);
expect(result).toBe("Owner lookup skipped: free email domain (user@gmail.com)");
});
it("presents owner_lookup_skipped without email", () => {
const result = SalesforceRoutingTracePresenter.present(
makeStep("owner_lookup_skipped", { reason: "feature disabled" })
);
expect(result).toBe("Owner lookup skipped: feature disabled");
});
it("presents contact_owner_check_skipped", () => {
const result = SalesforceRoutingTracePresenter.present(
makeStep("contact_owner_check_skipped", { reason: "route config" })
);
expect(result).toBe("Contact owner check skipped: route config");
});
it("presents graphql_query_initiated", () => {
const result = SalesforceRoutingTracePresenter.present(
makeStep("graphql_query_initiated", { email: "user@acme.com", emailDomain: "acme.com" })
);
expect(result).toBe("GraphQL account resolution initiated for user@acme.com (domain: acme.com)");
});
it("presents graphql_existing_contact_found", () => {
const result = SalesforceRoutingTracePresenter.present(
makeStep("graphql_existing_contact_found", {
contactId: "003ABC",
accountId: "001ABC",
ownerEmail: "owner@acme.com",
})
);
expect(result).toBe("Existing contact found via GraphQL: 003ABC (Account: 001ABC, Owner: owner@acme.com)");
});
it("presents graphql_account_found_by_website", () => {
const result = SalesforceRoutingTracePresenter.present(
makeStep("graphql_account_found_by_website", { accountId: "001ABC", ownerEmail: "owner@acme.com" })
);
expect(result).toBe("Account found by website via GraphQL: 001ABC (Owner: owner@acme.com)");
});
it("presents graphql_searching_by_contact_domain", () => {
const result = SalesforceRoutingTracePresenter.present(
makeStep("graphql_searching_by_contact_domain", { emailDomain: "acme.com", contactCount: 3 })
);
expect(result).toBe('Searching contacts by domain via GraphQL: "acme.com" (3 contacts)');
});
it("presents graphql_dominant_account_selected", () => {
const result = SalesforceRoutingTracePresenter.present(
makeStep("graphql_dominant_account_selected", {
accountId: "001ABC",
contactCount: 5,
ownerEmail: "owner@acme.com",
})
);
expect(result).toBe("Dominant account selected via GraphQL: 001ABC (5 contacts, Owner: owner@acme.com)");
});
it("presents graphql_no_account_found", () => {
const result = SalesforceRoutingTracePresenter.present(
makeStep("graphql_no_account_found", { email: "user@acme.com", reason: "no matching records" })
);
expect(result).toBe("No account found via GraphQL for user@acme.com: no matching records");
});
it("presents salesforce_assignment", () => {
const result = SalesforceRoutingTracePresenter.present(
makeStep("salesforce_assignment", { email: "owner@acme.com", recordType: "Contact", recordId: "003ABC" })
);
expect(result).toBe("Salesforce assignment: owner@acme.com via Contact (ID: 003ABC)");
});
it("returns fallback for unknown step", () => {
const result = SalesforceRoutingTracePresenter.present(makeStep("unknown_step", {}));
expect(result).toBe("Salesforce: unknown_step");
});
});
@@ -0,0 +1,51 @@
import type { RoutingStep } from "../repositories/RoutingTraceRepository.interface";
export class SalesforceRoutingTracePresenter {
static present(step: RoutingStep): string {
const d = step.data;
switch (step.step) {
case "searching_by_website_value":
return `Searching for Salesforce account by website matching domain "${d.emailDomain}"`;
case "account_found_by_website":
return `Account found by website "${d.website}" (Account ID: ${d.accountId})`;
case "searching_by_contact_email_domain":
return `Searching contacts with email domain "${d.emailDomain}" (${d.contactCount} contacts found)`;
case "account_selected_by_most_contacts":
return `Account selected with most contacts: ${d.accountId} (${d.contactCount} contacts)`;
case "no_account_found":
return `No account found for ${d.email}: ${d.reason}`;
case "lookup_field_query":
return `Querying lookup field "${d.fieldName}" on ${d.salesforceObject}${d.accountId ? ` (Account: ${d.accountId})` : ""}`;
case "user_query_from_lookup_field":
return `Lookup field resolved to user ${d.userEmail} (User ID: ${d.lookupFieldUserId})`;
case "contact_owner_lookup":
return `Contact owner lookup: ${d.ownerEmail} (Contact: ${d.contactId})`;
case "lead_owner_lookup":
return `Lead owner lookup: ${d.ownerEmail} (Lead: ${d.leadId})`;
case "account_owner_lookup":
return `Account owner lookup: ${d.ownerEmail} (Account: ${d.accountId})`;
case "owner_validated":
return `Owner ${d.ownerEmail} validated as team member: ${d.isTeamMember}`;
case "owner_lookup_skipped":
return `Owner lookup skipped: ${d.reason}${d.email ? ` (${d.email})` : ""}`;
case "contact_owner_check_skipped":
return `Contact owner check skipped: ${d.reason}`;
case "graphql_query_initiated":
return `GraphQL account resolution initiated for ${d.email} (domain: ${d.emailDomain})`;
case "graphql_existing_contact_found":
return `Existing contact found via GraphQL: ${d.contactId} (Account: ${d.accountId}, Owner: ${d.ownerEmail})`;
case "graphql_account_found_by_website":
return `Account found by website via GraphQL: ${d.accountId} (Owner: ${d.ownerEmail})`;
case "graphql_searching_by_contact_domain":
return `Searching contacts by domain via GraphQL: "${d.emailDomain}" (${d.contactCount} contacts)`;
case "graphql_dominant_account_selected":
return `Dominant account selected via GraphQL: ${d.accountId} (${d.contactCount} contacts, Owner: ${d.ownerEmail})`;
case "graphql_no_account_found":
return `No account found via GraphQL for ${d.email}: ${d.reason}`;
case "salesforce_assignment":
return `Salesforce assignment: ${d.email} via ${d.recordType} (ID: ${d.recordId})`;
default:
return `Salesforce: ${step.step}`;
}
}
}
@@ -0,0 +1,10 @@
import { ROUTING_TRACE_DOMAINS } from "../constants";
const DOMAIN_LABELS: Record<string, string> = {
[ROUTING_TRACE_DOMAINS.SALESFORCE]: "Salesforce",
[ROUTING_TRACE_DOMAINS.ROUTING_FORM]: "Routing Form",
};
export function getDomainLabel(domain: string): string {
return DOMAIN_LABELS[domain] ?? domain;
}
@@ -1,6 +1,5 @@
import type { PrismaClient } from "@calcom/prisma";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { PrismaRoutingTraceRepository } from "./PrismaRoutingTraceRepository";
describe("PrismaRoutingTraceRepository", () => {
@@ -9,6 +8,7 @@ describe("PrismaRoutingTraceRepository", () => {
const mockPrisma = {
routingTrace: {
create: vi.fn(),
findFirst: vi.fn(),
},
} as unknown as PrismaClient;
@@ -249,4 +249,98 @@ describe("PrismaRoutingTraceRepository", () => {
expect(result.trace).toEqual([]);
});
});
describe("findByBookingUid", () => {
it("should return routing trace when found", async () => {
const trace = [
{
domain: "routing_form",
step: "route_matched",
timestamp: Date.now(),
data: { routeId: "route-1", routeName: "Enterprise" },
},
];
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.findFirst).mockResolvedValue(mockResult);
const result = await repository.findByBookingUid("booking-uid-123");
expect(mockPrisma.routingTrace.findFirst).toHaveBeenCalledWith({
where: { bookingUid: "booking-uid-123" },
});
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 return null when routing trace not found", async () => {
vi.mocked(mockPrisma.routingTrace.findFirst).mockResolvedValue(null);
const result = await repository.findByBookingUid("non-existent-uid");
expect(mockPrisma.routingTrace.findFirst).toHaveBeenCalledWith({
where: { bookingUid: "non-existent-uid" },
});
expect(result).toBeNull();
});
it("should return routing trace with multiple steps", async () => {
const trace = [
{
domain: "routing_form",
step: "route_matched",
timestamp: 1000,
data: { routeId: "route-1", routeName: "Sales" },
},
{
domain: "salesforce",
step: "graphql_query_initiated",
timestamp: 2000,
data: { email: "user@acme.com", emailDomain: "acme.com" },
},
{
domain: "salesforce",
step: "salesforce_assignment",
timestamp: 3000,
data: { email: "owner@acme.com", recordType: "Contact", recordId: "003ABC" },
},
];
const mockResult = {
id: "trace-2",
createdAt: new Date("2025-01-02T10:00:00Z"),
trace,
formResponseId: 456,
queuedFormResponseId: null,
bookingUid: "booking-uid-456",
assignmentReasonId: 100,
};
vi.mocked(mockPrisma.routingTrace.findFirst).mockResolvedValue(mockResult);
const result = await repository.findByBookingUid("booking-uid-456");
expect(result).not.toBeNull();
expect(result?.trace).toHaveLength(3);
expect(result?.trace[0].domain).toBe("routing_form");
expect(result?.trace[1].domain).toBe("salesforce");
expect(result?.trace[2].domain).toBe("salesforce");
});
});
});
@@ -27,4 +27,15 @@ export class PrismaRoutingTraceRepository implements IRoutingTraceRepository {
trace: result.trace as RoutingTrace,
};
}
async findByBookingUid(bookingUid: string): Promise<RoutingTraceRecord | null> {
const result = await this.prisma.routingTrace.findFirst({
where: { bookingUid },
});
if (!result) return null;
return {
...result,
trace: result.trace as RoutingTrace,
};
}
}
@@ -35,4 +35,5 @@ export interface IRoutingTraceRepositoryCreateArgs {
export interface IRoutingTraceRepository {
create(args: IRoutingTraceRepositoryCreateArgs): Promise<RoutingTraceRecord>;
findByBookingUid(bookingUid: string): Promise<RoutingTraceRecord | null>;
}
@@ -4,6 +4,7 @@ import type { AssignmentReasonRepository } from "@calcom/features/assignment-rea
import logger from "@calcom/lib/logger";
import { AssignmentReasonEnum } from "@calcom/prisma/enums";
import { ROUTING_TRACE_DOMAINS, ROUTING_TRACE_STEPS } from "../constants";
import type { IPendingRoutingTraceRepository } from "../repositories/PendingRoutingTraceRepository.interface";
import type {
IRoutingTraceRepository,
@@ -11,16 +12,6 @@ import type {
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;
@@ -12,6 +12,7 @@ import { ZGetBookingAttendeesInputSchema } from "./getBookingAttendees.schema";
import { ZGetBookingDetailsInputSchema } from "./getBookingDetails.schema";
import { ZGetBookingHistoryInputSchema } from "./getBookingHistory.schema";
import { ZInstantBookingInputSchema } from "./getInstantBookingLocation.schema";
import { ZGetRoutingTraceInputSchema } from "./getRoutingTrace.schema";
import { ZReportBookingInputSchema } from "./reportBooking.schema";
import { ZReportWrongAssignmentInputSchema } from "./reportWrongAssignment.schema";
import { ZRequestRescheduleInputSchema } from "./requestReschedule.schema";
@@ -136,4 +137,12 @@ export const bookingsRouter = router({
input,
});
}),
getRoutingTrace: authedProcedure.input(ZGetRoutingTraceInputSchema).query(async ({ input, ctx }) => {
const { getRoutingTraceHandler } = await import("./getRoutingTrace.handler");
return getRoutingTraceHandler({
ctx,
input,
});
}),
});
@@ -0,0 +1,249 @@
import { BookingAccessService } from "@calcom/features/bookings/services/BookingAccessService";
import { RoutingTracePresenter } from "@calcom/features/routing-trace/presenters/RoutingTracePresenter";
import { PrismaRoutingTraceRepository } from "@calcom/features/routing-trace/repositories/PrismaRoutingTraceRepository";
import type { RoutingTrace } from "@calcom/features/routing-trace/repositories/RoutingTraceRepository.interface";
import { prisma } from "@calcom/prisma";
import type { TrpcSessionUser } from "@calcom/trpc/server/types";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { getRoutingTraceHandler } from "./getRoutingTrace.handler";
vi.mock("@calcom/features/routing-trace/repositories/PrismaRoutingTraceRepository");
vi.mock("@calcom/features/routing-trace/presenters/RoutingTracePresenter");
vi.mock("@calcom/features/bookings/services/BookingAccessService");
vi.mock("@calcom/prisma", () => {
const mockPrisma = {
app_RoutingForms_FormResponse: {
findFirst: vi.fn(),
},
};
return {
default: mockPrisma,
prisma: mockPrisma,
};
});
describe("getRoutingTraceHandler", () => {
const mockRepository = {
findByBookingUid: vi.fn(),
};
const mockBookingAccessService = {
doesUserIdHaveAccessToBooking: vi.fn(),
};
const mockUser = {
id: 1,
email: "test@example.com",
} as NonNullable<TrpcSessionUser>;
const createCtx = () => ({
user: mockUser,
});
beforeEach(() => {
vi.clearAllMocks();
vi.mocked(PrismaRoutingTraceRepository).mockImplementation(function () {
return mockRepository;
});
vi.mocked(BookingAccessService).mockImplementation(function () {
return mockBookingAccessService;
});
mockBookingAccessService.doesUserIdHaveAccessToBooking.mockResolvedValue(true);
});
describe("when routing trace exists in RoutingTrace table", () => {
it("should return presented steps from permanent routing trace", async () => {
const trace: RoutingTrace = [
{
domain: "routing_form",
step: "route_matched",
timestamp: 1000,
data: { routeId: "route-1", routeName: "Enterprise" },
},
{
domain: "salesforce",
step: "salesforce_assignment",
timestamp: 2000,
data: { email: "owner@acme.com", recordType: "Contact", recordId: "003ABC" },
},
];
const mockTraceRecord = {
id: "trace-1",
createdAt: new Date("2025-01-01T10:00:00Z"),
trace,
formResponseId: 123,
queuedFormResponseId: null,
bookingUid: "booking-uid-123",
assignmentReasonId: 42,
};
const presentedSteps = [
{
message: 'Route matched: "Enterprise" (ID: route-1)',
domain: "routing_form",
step: "route_matched",
timestamp: 1000,
},
{
message: "Salesforce assignment: owner@acme.com via Contact (ID: 003ABC)",
domain: "salesforce",
step: "salesforce_assignment",
timestamp: 2000,
},
];
mockRepository.findByBookingUid.mockResolvedValue(mockTraceRecord);
vi.mocked(RoutingTracePresenter.present).mockReturnValue(presentedSteps);
const result = await getRoutingTraceHandler({
ctx: createCtx(),
input: { bookingUid: "booking-uid-123" },
});
expect(mockBookingAccessService.doesUserIdHaveAccessToBooking).toHaveBeenCalledWith({
userId: mockUser.id,
bookingUid: "booking-uid-123",
});
expect(mockRepository.findByBookingUid).toHaveBeenCalledWith("booking-uid-123");
expect(RoutingTracePresenter.present).toHaveBeenCalledWith(trace);
expect(result).toEqual({ steps: presentedSteps });
expect(prisma.app_RoutingForms_FormResponse.findFirst).not.toHaveBeenCalled();
});
});
describe("when routing trace does not exist in RoutingTrace table", () => {
it("should fall back to PendingRoutingTrace via form response", async () => {
const pendingTrace: RoutingTrace = [
{
domain: "routing_form",
step: "attribute-logic-evaluated",
timestamp: 3000,
data: { routeName: "Sales", routeIsFallback: false },
},
];
const mockFormResponse = {
id: 456,
routedToBookingUid: "booking-uid-456",
pendingRoutingTrace: {
id: "pending-trace-1",
trace: pendingTrace,
},
};
const presentedSteps = [
{
message: 'Attribute logic evaluated: Route: "Sales"',
domain: "routing_form",
step: "attribute-logic-evaluated",
timestamp: 3000,
},
];
mockRepository.findByBookingUid.mockResolvedValue(null);
vi.mocked(prisma.app_RoutingForms_FormResponse.findFirst).mockResolvedValue(mockFormResponse);
vi.mocked(RoutingTracePresenter.present).mockReturnValue(presentedSteps);
const result = await getRoutingTraceHandler({
ctx: createCtx(),
input: { bookingUid: "booking-uid-456" },
});
expect(mockRepository.findByBookingUid).toHaveBeenCalledWith("booking-uid-456");
expect(prisma.app_RoutingForms_FormResponse.findFirst).toHaveBeenCalledWith({
where: { routedToBookingUid: "booking-uid-456" },
include: { pendingRoutingTrace: true },
});
expect(RoutingTracePresenter.present).toHaveBeenCalledWith(pendingTrace);
expect(result).toEqual({ steps: presentedSteps });
});
it("should return empty steps when form response has no pending trace", async () => {
const mockFormResponse = {
id: 789,
routedToBookingUid: "booking-uid-789",
pendingRoutingTrace: null,
};
mockRepository.findByBookingUid.mockResolvedValue(null);
vi.mocked(prisma.app_RoutingForms_FormResponse.findFirst).mockResolvedValue(mockFormResponse);
const result = await getRoutingTraceHandler({
ctx: createCtx(),
input: { bookingUid: "booking-uid-789" },
});
expect(mockRepository.findByBookingUid).toHaveBeenCalledWith("booking-uid-789");
expect(prisma.app_RoutingForms_FormResponse.findFirst).toHaveBeenCalled();
expect(RoutingTracePresenter.present).not.toHaveBeenCalled();
expect(result).toEqual({ steps: [] });
});
it("should return empty steps when form response does not exist", async () => {
mockRepository.findByBookingUid.mockResolvedValue(null);
vi.mocked(prisma.app_RoutingForms_FormResponse.findFirst).mockResolvedValue(null);
const result = await getRoutingTraceHandler({
ctx: createCtx(),
input: { bookingUid: "non-existent-uid" },
});
expect(mockRepository.findByBookingUid).toHaveBeenCalledWith("non-existent-uid");
expect(prisma.app_RoutingForms_FormResponse.findFirst).toHaveBeenCalledWith({
where: { routedToBookingUid: "non-existent-uid" },
include: { pendingRoutingTrace: true },
});
expect(RoutingTracePresenter.present).not.toHaveBeenCalled();
expect(result).toEqual({ steps: [] });
});
it("should return empty steps when pending trace has no trace data", async () => {
const mockFormResponse = {
id: 111,
routedToBookingUid: "booking-uid-111",
pendingRoutingTrace: {
id: "pending-trace-2",
trace: null,
},
};
mockRepository.findByBookingUid.mockResolvedValue(null);
vi.mocked(prisma.app_RoutingForms_FormResponse.findFirst).mockResolvedValue(mockFormResponse);
const result = await getRoutingTraceHandler({
ctx: createCtx(),
input: { bookingUid: "booking-uid-111" },
});
expect(result).toEqual({ steps: [] });
expect(RoutingTracePresenter.present).not.toHaveBeenCalled();
});
});
describe("edge cases", () => {
it("should handle empty trace array from permanent routing trace", async () => {
const mockTraceRecord = {
id: "trace-empty",
createdAt: new Date("2025-01-01T10:00:00Z"),
trace: [],
formResponseId: 999,
queuedFormResponseId: null,
bookingUid: "booking-uid-empty",
assignmentReasonId: null,
};
mockRepository.findByBookingUid.mockResolvedValue(mockTraceRecord);
vi.mocked(RoutingTracePresenter.present).mockReturnValue([]);
const result = await getRoutingTraceHandler({
ctx: createCtx(),
input: { bookingUid: "booking-uid-empty" },
});
expect(RoutingTracePresenter.present).toHaveBeenCalledWith([]);
expect(result).toEqual({ steps: [] });
});
});
});
@@ -0,0 +1,52 @@
import { BookingAccessService } from "@calcom/features/bookings/services/BookingAccessService";
import { RoutingTracePresenter } from "@calcom/features/routing-trace/presenters/RoutingTracePresenter";
import { PrismaRoutingTraceRepository } from "@calcom/features/routing-trace/repositories/PrismaRoutingTraceRepository";
import type { RoutingTrace } from "@calcom/features/routing-trace/repositories/RoutingTraceRepository.interface";
import { prisma } from "@calcom/prisma";
import type { TrpcSessionUser } from "@calcom/trpc/server/types";
import { TRPCError } from "@trpc/server";
import type { TGetRoutingTraceInputSchema } from "./getRoutingTrace.schema";
type Options = {
ctx: {
user: NonNullable<TrpcSessionUser>;
};
input: TGetRoutingTraceInputSchema;
};
export const getRoutingTraceHandler = async ({ ctx, input }: Options) => {
const bookingAccessService = new BookingAccessService(prisma);
const hasAccess = await bookingAccessService.doesUserIdHaveAccessToBooking({
userId: ctx.user.id,
bookingUid: input.bookingUid,
});
if (!hasAccess) {
throw new TRPCError({ code: "FORBIDDEN", message: "You don't have access to this booking" });
}
const repository = new PrismaRoutingTraceRepository(prisma);
// Try permanent RoutingTrace first (exists for round robin bookings)
const traceRecord = await repository.findByBookingUid(input.bookingUid);
if (traceRecord) {
return { steps: RoutingTracePresenter.present(traceRecord.trace) };
}
// Fall back to PendingRoutingTrace via the booking's form response
const formResponse = await prisma.app_RoutingForms_FormResponse.findFirst({
where: { routedToBookingUid: input.bookingUid },
include: { pendingRoutingTrace: true },
});
if (formResponse?.pendingRoutingTrace?.trace) {
const trace = formResponse.pendingRoutingTrace.trace as unknown as RoutingTrace;
return { steps: RoutingTracePresenter.present(trace) };
}
return { steps: [] };
};
@@ -0,0 +1,7 @@
import { z } from "zod";
export const ZGetRoutingTraceInputSchema = z.object({
bookingUid: z.string(),
});
export type TGetRoutingTraceInputSchema = z.infer<typeof ZGetRoutingTraceInputSchema>;