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:
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
@@ -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>;
|
||||
Reference in New Issue
Block a user