From 7abbc8c22c8c0af804e8e2ab8cd2d7dd6492dff3 Mon Sep 17 00:00:00 2001 From: Joe Au-Yeung <65426560+joeauyeung@users.noreply.github.com> Date: Thu, 29 Jan 2026 13:26:44 -0500 Subject: [PATCH] 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 * 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 * 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 * 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 * 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 * 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 * test: Add tests for getRoutingTrace handler and findByBookingUid repository method Co-Authored-By: joe@cal.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 --------- Co-authored-by: Claude Opus 4.5 Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- .../components/booking/BookingListItem.tsx | 1 - .../components/booking/RoutingTraceSheet.tsx | 85 ++++++ .../actions/BookingActionsDropdown.tsx | 46 +++- apps/web/components/booking/actions/store.ts | 8 + apps/web/public/static/locales/en/common.json | 1 + .../routing-trace/components/DomainIcon.tsx | 27 ++ packages/features/routing-trace/constants.ts | 9 + .../domains/RoutingFormTraceService.test.ts | 2 +- .../domains/RoutingFormTraceService.ts | 2 +- .../RoutingFormTracePresenter.test.ts | 93 +++++++ .../presenters/RoutingFormTracePresenter.ts | 30 +++ .../presenters/RoutingTracePresenter.test.ts | 99 +++++++ .../presenters/RoutingTracePresenter.ts | 33 +++ .../SalesforceRoutingTracePresenter.test.ts | 177 +++++++++++++ .../SalesforceRoutingTracePresenter.ts | 51 ++++ .../presenters/getDomainLabel.ts | 10 + .../PrismaRoutingTraceRepository.test.ts | 96 ++++++- .../PrismaRoutingTraceRepository.ts | 11 + .../RoutingTraceRepository.interface.ts | 1 + .../services/RoutingTraceService.ts | 11 +- .../routers/viewer/bookings/_router.tsx | 9 + .../bookings/getRoutingTrace.handler.test.ts | 249 ++++++++++++++++++ .../bookings/getRoutingTrace.handler.ts | 52 ++++ .../viewer/bookings/getRoutingTrace.schema.ts | 7 + 24 files changed, 1086 insertions(+), 24 deletions(-) create mode 100644 apps/web/components/booking/RoutingTraceSheet.tsx create mode 100644 packages/features/routing-trace/components/DomainIcon.tsx create mode 100644 packages/features/routing-trace/constants.ts create mode 100644 packages/features/routing-trace/presenters/RoutingFormTracePresenter.test.ts create mode 100644 packages/features/routing-trace/presenters/RoutingFormTracePresenter.ts create mode 100644 packages/features/routing-trace/presenters/RoutingTracePresenter.test.ts create mode 100644 packages/features/routing-trace/presenters/RoutingTracePresenter.ts create mode 100644 packages/features/routing-trace/presenters/SalesforceRoutingTracePresenter.test.ts create mode 100644 packages/features/routing-trace/presenters/SalesforceRoutingTracePresenter.ts create mode 100644 packages/features/routing-trace/presenters/getDomainLabel.ts create mode 100644 packages/trpc/server/routers/viewer/bookings/getRoutingTrace.handler.test.ts create mode 100644 packages/trpc/server/routers/viewer/bookings/getRoutingTrace.handler.ts create mode 100644 packages/trpc/server/routers/viewer/bookings/getRoutingTrace.schema.ts diff --git a/apps/web/components/booking/BookingListItem.tsx b/apps/web/components/booking/BookingListItem.tsx index b11f673cb7..39a90dfa13 100644 --- a/apps/web/components/booking/BookingListItem.tsx +++ b/apps/web/components/booking/BookingListItem.tsx @@ -280,7 +280,6 @@ function BookingListItem(booking: BookingItemProps) { const setIsOpenWrongAssignmentDialog = useBookingActionsStoreContext( (state) => state.setIsOpenWrongAssignmentDialog ); - const reportAction = getReportAction(actionContext); const reportActionWithHandler = { ...reportAction, diff --git a/apps/web/components/booking/RoutingTraceSheet.tsx b/apps/web/components/booking/RoutingTraceSheet.tsx new file mode 100644 index 0000000000..1d3ce1fda3 --- /dev/null +++ b/apps/web/components/booking/RoutingTraceSheet.tsx @@ -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 ( + + + + {t("routing_trace")} + + + {isLoading && ( +
+ {[1, 2, 3].map((i) => ( +
+
+
+
+
+
+
+ ))} +
+ )} + {!isLoading && !data?.steps?.length && ( +

{t("no_results_found")}

+ )} + {!isLoading && data?.steps && data.steps.length > 0 && ( +
+ {data.steps.map((step, idx) => { + const isLast = idx === data.steps.length - 1; + return ( +
+ {/* Timeline connector line */} + {!isLast && ( +
+ )} + {/* Icon circle */} +
+ +
+ {/* Content */} +
+
+ + {getDomainLabel(step.domain)} + + + {dayjs(step.timestamp).format("h:mm:ss.SSS A")} + +
+

{step.message}

+
+
+ ); + })} +
+ )} + + + + ); +} diff --git a/apps/web/components/booking/actions/BookingActionsDropdown.tsx b/apps/web/components/booking/actions/BookingActionsDropdown.tsx index 651dc48816..be7ccbbcb6 100644 --- a/apps/web/components/booking/actions/BookingActionsDropdown.tsx +++ b/apps/web/components/booking/actions/BookingActionsDropdown.tsx @@ -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 && ( - + <> + + + )} {booking.paid && booking.payment[0] && ( ))} + {isBookingFromRoutingForm && ( + + { + e.stopPropagation(); + setIsOpenRoutingTraceSheet(true); + }} + data-testid="view_routing_trace"> + {t("routing_trace")} + + + )} {t("after_event")} {afterEventActions.map((action) => ( diff --git a/apps/web/components/booking/actions/store.ts b/apps/web/components/booking/actions/store.ts index f9104293e7..e73a62524f 100644 --- a/apps/web/components/booking/actions/store.ts +++ b/apps/web/components/booking/actions/store.ts @@ -18,6 +18,7 @@ export type BookingActionsStore = { rerouteDialogIsOpen: boolean; isCancelDialogOpen: boolean; isOpenWrongAssignmentDialog: boolean; + isOpenRoutingTraceSheet: boolean; // Dialog setters setRejectionDialogIsOpen: React.Dispatch>; @@ -33,6 +34,7 @@ export type BookingActionsStore = { setRerouteDialogIsOpen: React.Dispatch>; setIsCancelDialogOpen: React.Dispatch>; setIsOpenWrongAssignmentDialog: React.Dispatch>; + setIsOpenRoutingTraceSheet: React.Dispatch>; }; 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, + })), })); }; diff --git a/apps/web/public/static/locales/en/common.json b/apps/web/public/static/locales/en/common.json index df04761628..ed945c431f 100644 --- a/apps/web/public/static/locales/en/common.json +++ b/apps/web/public/static/locales/en/common.json @@ -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", diff --git a/packages/features/routing-trace/components/DomainIcon.tsx b/packages/features/routing-trace/components/DomainIcon.tsx new file mode 100644 index 0000000000..20871e92fe --- /dev/null +++ b/packages/features/routing-trace/components/DomainIcon.tsx @@ -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 = { + [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 {config.alt}; + } + + return ( + + ); +} diff --git a/packages/features/routing-trace/constants.ts b/packages/features/routing-trace/constants.ts new file mode 100644 index 0000000000..4b2533648f --- /dev/null +++ b/packages/features/routing-trace/constants.ts @@ -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; diff --git a/packages/features/routing-trace/domains/RoutingFormTraceService.test.ts b/packages/features/routing-trace/domains/RoutingFormTraceService.test.ts index 1be223b50c..2f1250ca28 100644 --- a/packages/features/routing-trace/domains/RoutingFormTraceService.test.ts +++ b/packages/features/routing-trace/domains/RoutingFormTraceService.test.ts @@ -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", () => { diff --git a/packages/features/routing-trace/domains/RoutingFormTraceService.ts b/packages/features/routing-trace/domains/RoutingFormTraceService.ts index f4b5cbf024..da0d5c7a61 100644 --- a/packages/features/routing-trace/domains/RoutingFormTraceService.ts +++ b/packages/features/routing-trace/domains/RoutingFormTraceService.ts @@ -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", diff --git a/packages/features/routing-trace/presenters/RoutingFormTracePresenter.test.ts b/packages/features/routing-trace/presenters/RoutingFormTracePresenter.test.ts new file mode 100644 index 0000000000..1e8e55e938 --- /dev/null +++ b/packages/features/routing-trace/presenters/RoutingFormTracePresenter.test.ts @@ -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 = {}): 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"); + }); +}); diff --git a/packages/features/routing-trace/presenters/RoutingFormTracePresenter.ts b/packages/features/routing-trace/presenters/RoutingFormTracePresenter.ts new file mode 100644 index 0000000000..fd0e79e256 --- /dev/null +++ b/packages/features/routing-trace/presenters/RoutingFormTracePresenter.ts @@ -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}`; + } + } +} diff --git a/packages/features/routing-trace/presenters/RoutingTracePresenter.test.ts b/packages/features/routing-trace/presenters/RoutingTracePresenter.test.ts new file mode 100644 index 0000000000..7b021151e2 --- /dev/null +++ b/packages/features/routing-trace/presenters/RoutingTracePresenter.test.ts @@ -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([]); + }); +}); diff --git a/packages/features/routing-trace/presenters/RoutingTracePresenter.ts b/packages/features/routing-trace/presenters/RoutingTracePresenter.ts new file mode 100644 index 0000000000..73cf4f3e8f --- /dev/null +++ b/packages/features/routing-trace/presenters/RoutingTracePresenter.ts @@ -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}`; + } + } +} diff --git a/packages/features/routing-trace/presenters/SalesforceRoutingTracePresenter.test.ts b/packages/features/routing-trace/presenters/SalesforceRoutingTracePresenter.test.ts new file mode 100644 index 0000000000..2b65b5b1e4 --- /dev/null +++ b/packages/features/routing-trace/presenters/SalesforceRoutingTracePresenter.test.ts @@ -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 = {}): 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"); + }); +}); diff --git a/packages/features/routing-trace/presenters/SalesforceRoutingTracePresenter.ts b/packages/features/routing-trace/presenters/SalesforceRoutingTracePresenter.ts new file mode 100644 index 0000000000..b5c21f8c51 --- /dev/null +++ b/packages/features/routing-trace/presenters/SalesforceRoutingTracePresenter.ts @@ -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}`; + } + } +} diff --git a/packages/features/routing-trace/presenters/getDomainLabel.ts b/packages/features/routing-trace/presenters/getDomainLabel.ts new file mode 100644 index 0000000000..16b3279b29 --- /dev/null +++ b/packages/features/routing-trace/presenters/getDomainLabel.ts @@ -0,0 +1,10 @@ +import { ROUTING_TRACE_DOMAINS } from "../constants"; + +const DOMAIN_LABELS: Record = { + [ROUTING_TRACE_DOMAINS.SALESFORCE]: "Salesforce", + [ROUTING_TRACE_DOMAINS.ROUTING_FORM]: "Routing Form", +}; + +export function getDomainLabel(domain: string): string { + return DOMAIN_LABELS[domain] ?? domain; +} diff --git a/packages/features/routing-trace/repositories/PrismaRoutingTraceRepository.test.ts b/packages/features/routing-trace/repositories/PrismaRoutingTraceRepository.test.ts index 0bdfd258ca..8fb3fb64f6 100644 --- a/packages/features/routing-trace/repositories/PrismaRoutingTraceRepository.test.ts +++ b/packages/features/routing-trace/repositories/PrismaRoutingTraceRepository.test.ts @@ -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"); + }); + }); }); diff --git a/packages/features/routing-trace/repositories/PrismaRoutingTraceRepository.ts b/packages/features/routing-trace/repositories/PrismaRoutingTraceRepository.ts index ed910763bf..e07e3d5761 100644 --- a/packages/features/routing-trace/repositories/PrismaRoutingTraceRepository.ts +++ b/packages/features/routing-trace/repositories/PrismaRoutingTraceRepository.ts @@ -27,4 +27,15 @@ export class PrismaRoutingTraceRepository implements IRoutingTraceRepository { trace: result.trace as RoutingTrace, }; } + + async findByBookingUid(bookingUid: string): Promise { + const result = await this.prisma.routingTrace.findFirst({ + where: { bookingUid }, + }); + if (!result) return null; + return { + ...result, + trace: result.trace as RoutingTrace, + }; + } } diff --git a/packages/features/routing-trace/repositories/RoutingTraceRepository.interface.ts b/packages/features/routing-trace/repositories/RoutingTraceRepository.interface.ts index 34b9042ae5..4caffe19a7 100644 --- a/packages/features/routing-trace/repositories/RoutingTraceRepository.interface.ts +++ b/packages/features/routing-trace/repositories/RoutingTraceRepository.interface.ts @@ -35,4 +35,5 @@ export interface IRoutingTraceRepositoryCreateArgs { export interface IRoutingTraceRepository { create(args: IRoutingTraceRepositoryCreateArgs): Promise; + findByBookingUid(bookingUid: string): Promise; } diff --git a/packages/features/routing-trace/services/RoutingTraceService.ts b/packages/features/routing-trace/services/RoutingTraceService.ts index aeb912f7b6..4d5871a1ba 100644 --- a/packages/features/routing-trace/services/RoutingTraceService.ts +++ b/packages/features/routing-trace/services/RoutingTraceService.ts @@ -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; diff --git a/packages/trpc/server/routers/viewer/bookings/_router.tsx b/packages/trpc/server/routers/viewer/bookings/_router.tsx index 306940e390..e4d32fce09 100644 --- a/packages/trpc/server/routers/viewer/bookings/_router.tsx +++ b/packages/trpc/server/routers/viewer/bookings/_router.tsx @@ -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, + }); + }), }); diff --git a/packages/trpc/server/routers/viewer/bookings/getRoutingTrace.handler.test.ts b/packages/trpc/server/routers/viewer/bookings/getRoutingTrace.handler.test.ts new file mode 100644 index 0000000000..579fb7e8c9 --- /dev/null +++ b/packages/trpc/server/routers/viewer/bookings/getRoutingTrace.handler.test.ts @@ -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; + + 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: [] }); + }); + }); +}); diff --git a/packages/trpc/server/routers/viewer/bookings/getRoutingTrace.handler.ts b/packages/trpc/server/routers/viewer/bookings/getRoutingTrace.handler.ts new file mode 100644 index 0000000000..d4367d6089 --- /dev/null +++ b/packages/trpc/server/routers/viewer/bookings/getRoutingTrace.handler.ts @@ -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; + }; + 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: [] }; +}; diff --git a/packages/trpc/server/routers/viewer/bookings/getRoutingTrace.schema.ts b/packages/trpc/server/routers/viewer/bookings/getRoutingTrace.schema.ts new file mode 100644 index 0000000000..cefd2b416c --- /dev/null +++ b/packages/trpc/server/routers/viewer/bookings/getRoutingTrace.schema.ts @@ -0,0 +1,7 @@ +import { z } from "zod"; + +export const ZGetRoutingTraceInputSchema = z.object({ + bookingUid: z.string(), +}); + +export type TGetRoutingTraceInputSchema = z.infer;