Files
calendar/packages/features/routing-forms/lib/findTeamMembersMatchingAttributeLogic.ts
T
Joe Au-YeungGitHubClaude Opus 4.5joe@cal.com <j.auyeung419@gmail.com>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>
f536100224 feat: Add RoutingFormTrace (#27316)
* Add DB schema

* Init zod schema

* Init RoutingTrace and PendingRoutingTrace repository interfaces

* Create PrismaPendingRoutingTraceRepository

* Init RoutingTraceService

* Create RoutingTraceService container

* User routing trace service in routing

* Create RoutingTraceRepository and PrismaRoutingTraceRepoistory

* Add findByFormResponseId and findByQueuedFormResponseId to PendingRoutingTraceRepository

* Update DI containers

* RoutingTraceService create process booking method

* Use pending routing trace rather than URL params

* Fix schema

* Fix writing assignment reason for routed booking

* Remove  from  service

* Refactor RoutingTraceService to not rely on async local storage

* Pass RoutingTraceService through routing call

* Add attribute-logic-evaluated to routing trace step

* Add routing trace to trpc endpoint

* Add CRM routing trace step

* Fix extracting routing trace to assignment reason

* Add back CRM params to prevent refetching

* test: Add unit tests for RoutingTraceService and repositories

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

* test: Add missing mock for RoutingTraceService in getRoutedUrl tests

Also fix pre-existing lint issues in the test file:
- Add explicit types to mockForm and mockSerializableForm variables
- Add explicit type to url parameter in mockContext
- Replace 'as any' with 'as unknown as InstanceType<typeof UserRepository>'

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

* Link pending form submission to routing trace

* Clean up

* Add lookup field assignment reasons

* Rename to PendingRoutingTrace

* Add migration file

* fix: Update RoutingTraceService tests to use assignmentReasonRepository mock

- Add getStepsCount() method back to RoutingTraceService
- Add queuedFormResponseId support to processForBooking method
- Update tests to use mockAssignmentReasonRepository instead of prisma mock
- Remove test for missing routingTraceRepository (all deps now required)

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

* fix: Remove PII (organizer email) from log payload in RoutingTraceService

Addresses Cubic AI review feedback with confidence 9/10.
Logging PII violates sensitive information logging rules.

Co-Authored-By: unknown <>

* Write field values at the time of routing

* Write attributes used to route

* fix: Add CHECK constraint to ensure at least one response ID is set in routing trace tables

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

* fix: Address PR review comments for routing trace feature

- Add checkedFallback to routing trace step data (Comment 11)
- Extract hardcoded domain/step strings to constants (Comment 13)
- Use @default(now()) for createdAt in PendingRoutingTrace and RoutingTrace (Comment 15)
- Add DEFAULT CURRENT_TIMESTAMP to migration for createdAt fields

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

* fix: Use UTC-safe timezone expression for createdAt defaults in routing trace tables

Addresses Cubic AI review feedback (confidence 9/10) to prevent timezone
issues by using dbgenerated("timezone('UTC', now())") instead of bare now()
for the createdAt fields in PendingRoutingTrace and RoutingTrace models.

Co-Authored-By: unknown <>

* fix: Align migration SQL with Prisma's expected timezone syntax

Use 'UTC'::text cast in timezone() function to match Prisma's generated SQL.

Co-Authored-By: unknown <>

* fix: Revert migration SQL to match Prisma schema timezone syntax

Remove ::text cast from timezone() function to match what Prisma generates
from the schema definition.

Co-Authored-By: unknown <>

* fix: Use explicit ::text cast in timezone() for PostgreSQL compatibility

PostgreSQL normalizes timezone('UTC', now()) to timezone('UTC'::text, now())
internally. Update both schema and migration to use the explicit cast to
ensure they match and pass the migration check.

Co-Authored-By: unknown <>

* feat: Add RoutingFormTraceService domain class for type-safe routing traces

- Create RoutingFormTraceService class with routeMatched, fallbackRouteUsed,
  attributeLogicEvaluated, and attributeFallbackUsed methods
- Add ROUTING_FORM_STEPS constants
- Add unit tests

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

* feat: Integrate RoutingFormTraceService for routing form tracing

- Add routingFormTraceService parameter to findMatchingRoute
- Record route_matched or fallback_route_used step when route is selected
- Use "Default Route" as name for form-level fallback routes
- Refactor findTeamMembersMatchingAttributeLogic to use RoutingFormTraceService
- Update handleResponse to accept and pass routingFormTraceService
- Create RoutingFormTraceService in getRoutedUrl and pass through the flow

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

* test: Add test coverage for RoutingFormTraceService integration

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

* feat: Integrate RoutingFormTraceService for routing form tracing

- Add routingFormTraceService parameter to findMatchingRoute
- Record route_matched or fallback_route_used step when route is selected
- Use "Default Route" as name for form-level fallback routes
- Refactor findTeamMembersMatchingAttributeLogic to use RoutingFormTraceService
- Update handleResponse to accept and pass routingFormTraceService
- Create RoutingFormTraceService in getRoutedUrl and pass through the flow

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

* fix: Restore schema and migration from main branch

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

* fix: Use correct parameter name routingFormTraceService in tests

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

* fix: Use 'default_route' instead of 'Default Route' for fallback route name

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

---------

Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-28 17:15:22 -03:00

626 lines
19 KiB
TypeScript

import { acrossQueryValueCompatiblity, raqbQueryValueUtils } from "@calcom/app-store/_utils/raqb/raqbUtils";
import type { Attribute } from "@calcom/app-store/routing-forms/types/types";
import { getAttributesAssignmentData } from "@calcom/features/attributes/lib/getAttributes";
import type { RoutingFormTraceService } from "@calcom/features/routing-trace/domains/RoutingFormTraceService";
import { RaqbLogicResult } from "@calcom/lib/raqb/evaluateRaqbLogic";
import jsonLogic from "@calcom/lib/raqb/jsonLogic";
import type { AttributesQueryValue, dynamicFieldValueOperands } from "@calcom/lib/raqb/types";
import async from "async";
import type { ImmutableTree, JsonLogicResult, JsonTree } from "react-awesome-query-builder";
import type { Config } from "react-awesome-query-builder/lib";
import { Utils as QbUtils } from "react-awesome-query-builder/lib";
const {
getAttributesData: getAttributes,
getAttributesQueryBuilderConfigHavingListofLabels,
getAttributesQueryValue,
} = acrossQueryValueCompatiblity;
type TeamMemberWithAttributeOptionValuePerAttribute = Awaited<
ReturnType<typeof getAttributesAssignmentData>
>["attributesAssignedToTeamMembersWithOptions"][number];
type RunAttributeLogicData = {
attributesQueryValue: AttributesQueryValue | null;
attributesData: {
attributesOfTheOrg: Attribute[];
teamMembersWithAttributeOptionValuePerAttribute: TeamMemberWithAttributeOptionValuePerAttribute[];
};
dynamicFieldValueOperands?: dynamicFieldValueOperands;
};
type RunAttributeLogicOptions = {
concurrency: number;
enablePerf: boolean;
isPreview: boolean;
enableTroubleshooter: boolean;
};
export enum TroubleshooterCase {
IS_A_ROUTER = "is-a-router",
NO_LOGIC_FOUND = "no-logic-found",
MATCH_RESULTS_READY = "match-results-ready",
MATCH_RESULTS_READY_WITH_FALLBACK = "match-results-ready-with-fallback",
MATCHES_ALL_MEMBERS_BECAUSE_OF_EMPTY_QUERY_VALUE = "matches-all-members-because-of-empty-query-value",
MATCHES_ALL_MEMBERS = "matches-all-members",
}
/**
* Performance wrapper for async functions
*/
async function asyncPerf<ReturnValue>(fn: () => Promise<ReturnValue>): Promise<[ReturnValue, number | null]> {
const start = performance.now();
const result = await fn();
const end = performance.now();
return [result, end - start];
}
/**
* Performance wrapper for sync functions
*/
function perf<ReturnValue>(fn: () => ReturnValue): [ReturnValue, number | null] {
const start = performance.now();
const result = fn();
const end = performance.now();
return [result, end - start];
}
function getErrorsFromImmutableTree(tree: ImmutableTree) {
const validatedQueryValue = QbUtils.getTree(tree);
if (!raqbQueryValueUtils.isQueryValueARuleGroup(validatedQueryValue)) {
return [];
}
if (!validatedQueryValue.children1) {
return [];
}
const errors: string[][] = [];
Object.values(validatedQueryValue.children1).map((rule) => {
if (rule.type !== "rule") {
return;
}
const valueError = rule.properties.valueError;
if (valueError) {
// Sometimes there are null values in it.
errors.push(valueError.filter(Boolean));
}
});
return errors;
}
function getJsonLogic({
attributesQueryValue,
attributesQueryBuilderConfig,
}: {
attributesQueryValue: AttributesQueryValue;
attributesQueryBuilderConfig: Config;
}) {
const state = {
tree: QbUtils.checkTree(
QbUtils.loadTree(attributesQueryValue as JsonTree),
// We know that attributesQueryBuilderConfig is a Config because getAttributesQueryBuilderConfigHavingListofLabels returns a Config. So, asserting it.
attributesQueryBuilderConfig as unknown as Config
),
config: attributesQueryBuilderConfig as unknown as Config,
};
const jsonLogicQuery = QbUtils.jsonLogicFormat(state.tree, state.config);
const logic = jsonLogicQuery.logic;
// Considering errors as warnings as we want to continue with the flow without throwing actual errors
// We expect fallback logic to take effect in case of errors in main logic
const warnings = getErrorsFromImmutableTree(state.tree).flat();
if (!logic) {
// If children1 is not empty, it means that some rules were added by use
if (attributesQueryValue.children1 && Object.keys(attributesQueryValue.children1).length > 0) {
// Possible reasons for this
// 1. The attribute option value used is not in the options list. Happens if 'Value of field' value is chosen and that field's response value doesn't exist in attribute options list.
return { logic, warnings: ["There is some error building the logic, please check the routes."] };
}
}
return { logic, warnings };
}
function buildTroubleshooterData({ type, data }: { type: TroubleshooterCase; data: Record<string, any> }) {
return {
troubleshooter: {
type,
data,
},
};
}
/**
* Checks if a string is a field template placeholder (e.g., "{field:uuid}")
*/
function isFieldTemplate(str: string): boolean {
const trimmedStr = str.trim();
return trimmedStr.startsWith("{field:") && trimmedStr.endsWith("}");
}
/**
* Extracts field ID from a field template string
*/
function extractFieldIdFromTemplate(template: string): string | null {
const trimmedTemplate = template.trim();
if (!isFieldTemplate(trimmedTemplate)) return null;
return trimmedTemplate.slice(7, -1);
}
/**
* Resolves a field template to its actual value from the form response
*/
function resolveFieldTemplateValue(
value: string,
dynamicFieldValueOperands?: dynamicFieldValueOperands
): string {
if (!isFieldTemplate(value) || !dynamicFieldValueOperands) {
return value;
}
const fieldId = extractFieldIdFromTemplate(value);
if (!fieldId) {
return value;
}
const { fields, response } = dynamicFieldValueOperands;
const field = fields.find((f) => f.id === fieldId);
if (!field) {
// Field definition not found - return original template
return value;
}
const fieldResponse = response[field.id];
if (!fieldResponse) {
// Field not in response object - return original template
return value;
}
const fieldValue = fieldResponse.value;
if (fieldValue === null || fieldValue === undefined || fieldValue === "") {
return "(empty)";
}
if (Array.isArray(fieldValue)) {
return fieldValue.join(", ");
}
return String(fieldValue);
}
/**
* Extracts attribute name/value pairs from a resolved attributesQueryValue.
* This is used to build the assignment reason string.
*/
function extractAttributeRoutingDetails({
resolvedAttributesQueryValue,
attributesOfTheOrg,
dynamicFieldValueOperands,
}: {
resolvedAttributesQueryValue: AttributesQueryValue | null;
attributesOfTheOrg: Attribute[];
dynamicFieldValueOperands?: dynamicFieldValueOperands;
}): Array<{ attributeName: string; attributeValue: string }> {
if (!resolvedAttributesQueryValue) {
return [];
}
if (!raqbQueryValueUtils.isQueryValueARuleGroup(resolvedAttributesQueryValue as JsonTree)) {
return [];
}
const children1 = resolvedAttributesQueryValue.children1;
if (!children1) {
return [];
}
const result: Array<{ attributeName: string; attributeValue: string }> = [];
for (const ruleId of Object.keys(children1)) {
const rule = children1[ruleId];
// Check if it's a rule (has properties with field)
if (rule.type !== "rule" || !rule.properties) {
continue;
}
const properties = rule.properties;
const fieldId = properties.field;
const value = properties.value;
if (!fieldId || !value || value[0] === null || value[0] === undefined) {
continue;
}
const attribute = attributesOfTheOrg.find((attr) => attr.id === fieldId);
if (!attribute) {
continue;
}
const attributeValueString = (() => {
const firstValue = value[0];
if (Array.isArray(firstValue)) {
// Handle array values - resolve any field templates within
const resolvedValues = firstValue.map((v) =>
typeof v === "string" ? resolveFieldTemplateValue(v, dynamicFieldValueOperands) : String(v)
);
return resolvedValues.join(", ");
}
const stringValue = String(firstValue);
// Resolve field template if present
return resolveFieldTemplateValue(stringValue, dynamicFieldValueOperands);
})();
result.push({
attributeName: attribute.name,
attributeValue: attributeValueString,
});
}
return result;
}
async function getLogicResultForAllMembers(
{
teamMembersWithAttributeOptionValuePerAttribute,
attributeJsonLogic,
attributesQueryValue,
}: {
teamMembersWithAttributeOptionValuePerAttribute: TeamMemberWithAttributeOptionValuePerAttribute[];
attributeJsonLogic: NonNullable<JsonLogicResult["logic"]>;
attributesQueryValue: AttributesQueryValue;
},
config: {
concurrency?: number;
enableTroubleshooter?: boolean;
} = {}
) {
const { concurrency = 2 } = config;
const teamMembersMatchingAttributeLogicMap = new Map<number, RaqbLogicResult>();
const attributesDataPerUser = new Map<number, ReturnType<typeof getAttributes>>();
await async.mapLimit<TeamMemberWithAttributeOptionValuePerAttribute, Promise<void>>(
teamMembersWithAttributeOptionValuePerAttribute,
concurrency,
async (member: TeamMemberWithAttributeOptionValuePerAttribute) => {
const attributesData = getAttributes({
attributesData: member.attributes,
attributesQueryValue,
});
attributesDataPerUser.set(member.userId, attributesData);
const result = jsonLogic.apply(attributeJsonLogic as any, attributesData)
? RaqbLogicResult.MATCH
: RaqbLogicResult.NO_MATCH;
if (result !== RaqbLogicResult.MATCH) {
return;
}
teamMembersMatchingAttributeLogicMap.set(member.userId, result);
}
);
return {
teamMembersMatchingAttributeLogicMap,
attributesDataPerUser,
};
}
async function runAttributeLogic(data: RunAttributeLogicData, options: RunAttributeLogicOptions) {
const {
attributesQueryValue: _attributesQueryValue,
attributesData: { attributesOfTheOrg, teamMembersWithAttributeOptionValuePerAttribute },
dynamicFieldValueOperands,
} = data;
const { concurrency, enablePerf, enableTroubleshooter } = options;
const attributesQueryValue = getAttributesQueryValue({
attributesQueryValue: _attributesQueryValue ?? null,
attributes: attributesOfTheOrg,
dynamicFieldValueOperands,
});
// TODO: Do we really need to do !attributesQueryValue check separately?
if (!attributesQueryValue || raqbQueryValueUtils.isQueryValueEmpty(attributesQueryValue as JsonTree)) {
return {
logicBuildingWarnings: null,
teamMembersMatchingAttributeLogic: null,
resolvedAttributesQueryValue: null,
timeTaken: null,
...buildTroubleshooterData({
type: TroubleshooterCase.MATCHES_ALL_MEMBERS_BECAUSE_OF_EMPTY_QUERY_VALUE,
data: { attributesQueryValue },
}),
};
}
const [attributesQueryBuilderConfig, ttgetAttributesQueryBuilderConfigHavingListofLabels] = pf(() =>
getAttributesQueryBuilderConfigHavingListofLabels({
dynamicFieldValueOperands,
attributes: attributesOfTheOrg,
})
);
const { logic, warnings: logicBuildingWarnings } = getJsonLogic({
attributesQueryValue,
attributesQueryBuilderConfig: attributesQueryBuilderConfig as unknown as Config,
});
if (!logic) {
return {
teamMembersMatchingAttributeLogic: null,
logicBuildingWarnings: null,
resolvedAttributesQueryValue: attributesQueryValue,
timeTaken: {
ttgetAttributesQueryBuilderConfigHavingListofLabels,
},
...buildTroubleshooterData({
type: TroubleshooterCase.NO_LOGIC_FOUND,
data: {
attributesQueryValue,
attributesQueryBuilderConfig,
teamMembersWithAttributeOptionValuePerAttribute,
},
}),
};
}
const [
{ teamMembersMatchingAttributeLogicMap, attributesDataPerUser },
ttTeamMembersMatchingAttributeLogic,
] = await aPf(async () =>
getLogicResultForAllMembers(
{
teamMembersWithAttributeOptionValuePerAttribute,
attributeJsonLogic: logic,
attributesQueryValue,
},
{
concurrency,
enableTroubleshooter,
}
)
);
const teamMembersMatchingAttributeLogic = Array.from(teamMembersMatchingAttributeLogicMap).map((item) => ({
userId: item[0],
result: item[1],
}));
return {
teamMembersMatchingAttributeLogic,
logicBuildingWarnings,
resolvedAttributesQueryValue: attributesQueryValue,
timeTaken: {
ttgetAttributesQueryBuilderConfigHavingListofLabels,
ttTeamMembersMatchingAttributeLogic,
},
...buildTroubleshooterData({
type: TroubleshooterCase.MATCH_RESULTS_READY,
data: {
attributesDataPerUser,
attributesQueryValue,
attributesQueryBuilderConfig,
logic,
attributesOfTheOrg,
},
}),
};
function pf<ReturnValue>(fn: () => ReturnValue): [ReturnValue, number | null] {
if (!enablePerf) {
return [fn(), null];
}
return perf(fn);
}
async function aPf<ReturnValue>(fn: () => Promise<ReturnValue>): Promise<[ReturnValue, number | null]> {
if (!enablePerf) {
return [await fn(), null];
}
return asyncPerf(fn);
}
}
async function runMainAttributeLogic(data: RunAttributeLogicData, options: RunAttributeLogicOptions) {
const { teamMembersMatchingAttributeLogic, ...rest } = await runAttributeLogic(data, options);
return {
teamMembersMatchingMainAttributeLogic: teamMembersMatchingAttributeLogic,
...rest,
};
}
async function runFallbackAttributeLogic(data: RunAttributeLogicData, options: RunAttributeLogicOptions) {
const { teamMembersMatchingAttributeLogic, ...rest } = await runAttributeLogic(data, options);
return {
teamMembersMatchingFallbackLogic: teamMembersMatchingAttributeLogic,
...rest,
};
}
export async function getAttributesForLogic({ teamId, orgId }: { teamId: number; orgId: number }) {
const [result, ttAttributes] = await asyncPerf(async () => {
return getAttributesAssignmentData({ teamId, orgId });
});
return {
attributesOfTheOrg: result.attributesOfTheOrg,
teamMembersWithAttributeOptionValuePerAttribute: result.attributesAssignedToTeamMembersWithOptions,
timeTaken: ttAttributes,
};
}
export async function findTeamMembersMatchingAttributeLogic(
data: {
teamId: number;
orgId: number;
attributesQueryValue: AttributesQueryValue | null;
fallbackAttributesQueryValue?: AttributesQueryValue | null;
dynamicFieldValueOperands?: dynamicFieldValueOperands;
isPreview?: boolean;
routeName?: string;
routeIsFallback?: boolean;
},
options: {
enablePerf?: boolean;
concurrency?: number;
enableTroubleshooter?: boolean;
routingFormTraceService?: RoutingFormTraceService;
} = {}
) {
// Higher value of concurrency might not be performant as it might overwhelm the system. So, use a lower value as default.
const { enablePerf = false, concurrency = 2, enableTroubleshooter = false, routingFormTraceService } = options;
// Any explicit value being passed should cause fallback to be considered. Even undefined
const considerFallback = "fallbackAttributesQueryValue" in data;
const {
teamId,
orgId,
attributesQueryValue,
fallbackAttributesQueryValue,
dynamicFieldValueOperands,
isPreview,
routeName,
routeIsFallback,
} = data;
const {
attributesOfTheOrg,
teamMembersWithAttributeOptionValuePerAttribute,
timeTaken: ttGetAttributesForLogic,
} = await getAttributesForLogic({
teamId,
orgId,
});
const runAttributeLogicOptions = {
concurrency,
enablePerf,
isPreview: !!isPreview,
enableTroubleshooter,
};
const runAttributeLogicData: Omit<RunAttributeLogicData, "attributesQueryValue"> = {
// Change it as per the main/fallback query
attributesData: {
attributesOfTheOrg,
teamMembersWithAttributeOptionValuePerAttribute,
},
dynamicFieldValueOperands,
};
const {
teamMembersMatchingMainAttributeLogic,
timeTaken: teamMembersMatchingMainAttributeLogicTimeTaken,
troubleshooter,
logicBuildingWarnings: mainAttributeLogicBuildingWarnings,
resolvedAttributesQueryValue,
} = await runMainAttributeLogic(
{
...runAttributeLogicData,
attributesQueryValue: attributesQueryValue,
},
runAttributeLogicOptions
);
// Helper to add trace step for attribute logic evaluation
const addTraceStep = (checkedFallback: boolean) => {
if (routingFormTraceService) {
const attributeRoutingDetails = extractAttributeRoutingDetails({
resolvedAttributesQueryValue,
attributesOfTheOrg,
dynamicFieldValueOperands,
});
routingFormTraceService.attributeLogicEvaluated({
routeName,
routeIsFallback,
checkedFallback,
attributeRoutingDetails,
});
}
};
// It being null means that no logic was found and thus all members match. In such case, we don't fallback intentionally.
// This is the case when user added no rules so, he expects to match all members
if (!teamMembersMatchingMainAttributeLogic) {
addTraceStep(false);
return {
teamMembersMatchingAttributeLogic: null,
checkedFallback: false,
mainAttributeLogicBuildingWarnings,
fallbackAttributeLogicBuildingWarnings: [],
routeName,
routeIsFallback,
timeTaken: {
...teamMembersMatchingMainAttributeLogicTimeTaken,
ttGetAttributesForLogic,
},
...(enableTroubleshooter
? buildTroubleshooterData({
...troubleshooter,
})
: null),
};
}
const noMatchingMembersFound = !teamMembersMatchingMainAttributeLogic.length;
if (noMatchingMembersFound && considerFallback) {
const {
teamMembersMatchingFallbackLogic,
timeTaken: teamMembersMatchingFallbackLogicTimeTaken,
troubleshooter,
logicBuildingWarnings: fallbackAttributeLogicBuildingWarnings,
} = await runFallbackAttributeLogic(
{
...runAttributeLogicData,
attributesQueryValue: fallbackAttributesQueryValue ?? null,
},
runAttributeLogicOptions
);
addTraceStep(true);
return {
teamMembersMatchingAttributeLogic: teamMembersMatchingFallbackLogic,
checkedFallback: true,
fallbackAttributeLogicBuildingWarnings,
mainAttributeLogicBuildingWarnings,
routeName,
routeIsFallback,
timeTaken: {
...teamMembersMatchingFallbackLogicTimeTaken,
ttGetAttributesForLogic,
},
...(enableTroubleshooter
? buildTroubleshooterData({
...troubleshooter,
type: TroubleshooterCase.MATCH_RESULTS_READY_WITH_FALLBACK,
})
: null),
};
}
addTraceStep(false);
return {
teamMembersMatchingAttributeLogic: teamMembersMatchingMainAttributeLogic,
checkedFallback: false,
mainAttributeLogicBuildingWarnings,
fallbackAttributeLogicBuildingWarnings: [],
routeName,
routeIsFallback,
timeTaken: {
...teamMembersMatchingMainAttributeLogicTimeTaken,
ttGetAttributesForLogic,
},
...(enableTroubleshooter
? buildTroubleshooterData({
...troubleshooter,
type: TroubleshooterCase.MATCH_RESULTS_READY,
data: {
...troubleshooter.data,
attributesOfTheOrg,
},
})
: null),
};
}