Files
calendar/packages/app-store/routing-forms/lib/findTeamMembersMatchingAttributeLogicOfRoute.ts
T
ab65ce73c2 feat: Support Attribute Logic fallback (#17290)
* Add fallback

* Support attribute query fallback

* Refactor

* Add tests and cleanup SingleFofrm

* small text fixes

* With fallback in picture, we dont throw error in preview now instead we capture errors and show them gracefully

* Get attribute logic preview working without saving Fixes CAL-4582

* Abstract useRoutes out

* Update e2e

* Dont define Page component again and again

---------

Co-authored-by: CarinaWolli <wollencarina@gmail.com>
2024-10-31 12:53:34 -04:00

481 lines
14 KiB
TypeScript

import type { App_RoutingForms_Form } from "@prisma/client";
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";
import { getFieldResponse } from "../trpc/utils";
import type { Attribute, AttributesQueryValue, Route } from "../types/types";
import type { FormResponse, SerializableForm } from "../types/types";
import { RaqbLogicResult } from "./evaluateRaqbLogic";
import { getTeamMembersWithAttributeOptionValuePerAttribute, getAttributesForTeam } from "./getAttributes";
import isRouter from "./isRouter";
import jsonLogic from "./jsonLogic";
import { acrossQueryValueCompatiblity, raqbQueryValueUtils } from "./raqbUtils";
const {
getAttributesData: getAttributes,
getAttributesQueryBuilderConfig,
getAttributesQueryValue,
} = acrossQueryValueCompatiblity;
type TeamMemberWithAttributeOptionValuePerAttribute = Awaited<
ReturnType<typeof getTeamMembersWithAttributeOptionValuePerAttribute>
>[number];
type RunAttributeLogicData = {
attributesQueryValue: AttributesQueryValue | undefined;
attributesForTeam: Attribute[];
form: Pick<SerializableForm<App_RoutingForms_Form>, "fields">;
teamId: number;
response: FormResponse;
};
type RunAttributeLogicOptions = {
concurrency: number;
enablePerf: boolean;
isPreview: boolean;
enableTroubleshooter: boolean;
};
export const enum TroubleshooterCase {
EMPTY_QUERY_VALUE = "empty-query-value",
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 = "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((value) => !!value));
}
});
return errors;
}
function getJsonLogic({
attributesQueryValue,
attributesQueryBuilderConfig,
isPreview,
}: {
attributesQueryValue: JsonTree;
attributesQueryBuilderConfig: Config;
isPreview: boolean;
}) {
const state = {
tree: QbUtils.checkTree(
QbUtils.loadTree(attributesQueryValue),
// We know that attributesQueryBuilderConfig is a Config because getAttributesQueryBuilderConfig 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,
},
};
}
async function getLogicResultForAllMembers(
{
teamMembersWithAttributeOptionValuePerAttribute,
attributeJsonLogic,
attributesQueryValue,
}: {
teamMembersWithAttributeOptionValuePerAttribute: TeamMemberWithAttributeOptionValuePerAttribute[];
attributeJsonLogic: NonNullable<JsonLogicResult["logic"]>;
attributesQueryValue: AttributesQueryValue;
},
config: {
concurrency: number;
enableTroubleshooter: boolean;
}
) {
const { concurrency, enableTroubleshooter } = 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,
});
if (enableTroubleshooter) {
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, attributesForTeam, form, teamId, response } = data;
const { concurrency, enablePerf, isPreview, enableTroubleshooter } = options;
const [attributesQueryValue, ttGetAttributesQueryValue] = pf(() =>
getAttributesQueryValue({
attributesQueryValue: _attributesQueryValue,
attributes: attributesForTeam,
response,
fields: form.fields,
getFieldResponse,
})
);
if (raqbQueryValueUtils.isQueryValueEmpty(attributesQueryValue)) {
return {
logicBuildingWarnings: null,
teamMembersMatchingAttributeLogic: null,
...buildTroubleshooterData({
type: TroubleshooterCase.EMPTY_QUERY_VALUE,
data: { attributesQueryValue },
}),
timeTaken: {
ttGetAttributesQueryValue,
},
};
}
const [attributesQueryBuilderConfig, ttGetAttributesQueryBuilderConfig] = pf(() =>
getAttributesQueryBuilderConfig({
form,
attributes: attributesForTeam,
attributesQueryValue,
})
);
const [
teamMembersWithAttributeOptionValuePerAttribute,
ttGetTeamMembersWithAttributeOptionValuePerAttribute,
] = await aPf(() => getTeamMembersWithAttributeOptionValuePerAttribute({ teamId: teamId }));
const { logic, warnings: logicBuildingWarnings } = getJsonLogic({
attributesQueryValue: attributesQueryValue as JsonTree,
attributesQueryBuilderConfig: attributesQueryBuilderConfig as unknown as Config,
isPreview: !!isPreview,
});
if (!logic) {
return {
teamMembersMatchingAttributeLogic: null,
logicBuildingWarnings: null,
timeTaken: {
ttGetAttributesQueryValue,
ttGetAttributesQueryBuilderConfig,
ttGetTeamMembersWithAttributeOptionValuePerAttribute,
},
...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,
timeTaken: {
ttGetAttributesQueryBuilderConfig,
ttGetTeamMembersWithAttributeOptionValuePerAttribute,
ttTeamMembersMatchingAttributeLogic,
ttGetAttributesQueryValue,
},
...buildTroubleshooterData({
type: TroubleshooterCase.MATCH_RESULTS_READY,
data: {
attributesDataPerUser,
attributesQueryValue,
attributesQueryBuilderConfig,
logic,
attributesForTeam,
},
}),
};
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 findTeamMembersMatchingAttributeLogicOfRoute(
{
form,
response,
route,
teamId,
isPreview = false,
}: {
form: Pick<SerializableForm<App_RoutingForms_Form>, "fields">;
response: FormResponse;
route: Route;
teamId: number;
isPreview?: boolean;
},
options: {
enablePerf?: boolean;
concurrency?: number;
enableTroubleshooter?: boolean;
} = {}
) {
// Higher value of concurrency might not be performant as it might overwhelm the system. So, use a lower value as default.
const { enablePerf = false, concurrency = 2, enableTroubleshooter = false } = options;
const checkedFallback = false;
if (isRouter(route)) {
return {
teamMembersMatchingAttributeLogic: null,
mainAttributeLogicBuildingWarnings: null,
fallbackAttributeLogicBuildingWarnings: null,
checkedFallback,
timeTaken: null,
...buildTroubleshooterData({
type: TroubleshooterCase.IS_A_ROUTER,
data: { route },
}),
};
}
const [attributesForTeam, getAttributesForTeamTimeTaken] = await aPf(
async () => await getAttributesForTeam({ teamId: teamId })
);
const runAttributeLogicOptions = {
concurrency,
enablePerf,
isPreview,
enableTroubleshooter,
};
const runAttributeLogicData = {
// Change it as per the main/fallback query
attributesQueryValue: null,
attributesForTeam,
form,
teamId,
response,
};
const {
teamMembersMatchingMainAttributeLogic,
timeTaken: teamMembersMatchingMainAttributeLogicTimeTaken,
troubleshooter,
logicBuildingWarnings: mainAttributeLogicBuildingWarnings,
} = await runMainAttributeLogic(
{
...runAttributeLogicData,
attributesQueryValue: route.attributesQueryValue,
},
runAttributeLogicOptions
);
// 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) {
return {
teamMembersMatchingAttributeLogic: null,
checkedFallback,
mainAttributeLogicBuildingWarnings,
fallbackAttributeLogicBuildingWarnings: [],
timeTaken: {
...teamMembersMatchingMainAttributeLogicTimeTaken,
getAttributesForTeamTimeTaken,
},
...(enableTroubleshooter
? buildTroubleshooterData({
...troubleshooter,
type: TroubleshooterCase.MATCHES_ALL_MEMBERS,
})
: null),
};
}
const noMatchingMembersFound = !teamMembersMatchingMainAttributeLogic.length;
if (noMatchingMembersFound) {
const {
teamMembersMatchingFallbackLogic,
timeTaken: teamMembersMatchingFallbackLogicTimeTaken,
troubleshooter,
logicBuildingWarnings: fallbackAttributeLogicBuildingWarnings,
} = await runFallbackAttributeLogic(
{
...runAttributeLogicData,
attributesQueryValue: route.fallbackAttributesQueryValue,
},
runAttributeLogicOptions
);
return {
teamMembersMatchingAttributeLogic: teamMembersMatchingFallbackLogic,
checkedFallback: true,
fallbackAttributeLogicBuildingWarnings,
mainAttributeLogicBuildingWarnings,
timeTaken: {
...teamMembersMatchingFallbackLogicTimeTaken,
getAttributesForTeamTimeTaken,
},
...(enableTroubleshooter
? buildTroubleshooterData({
...troubleshooter,
type: TroubleshooterCase.MATCH_RESULTS_READY_WITH_FALLBACK,
})
: null),
};
}
return {
teamMembersMatchingAttributeLogic: teamMembersMatchingMainAttributeLogic,
checkedFallback,
mainAttributeLogicBuildingWarnings,
fallbackAttributeLogicBuildingWarnings: [],
timeTaken: {
...teamMembersMatchingMainAttributeLogicTimeTaken,
getAttributesForTeamTimeTaken,
},
...(enableTroubleshooter
? buildTroubleshooterData({
...troubleshooter,
type: TroubleshooterCase.MATCH_RESULTS_READY,
data: {
...troubleshooter.data,
attributesForTeam,
},
})
: null),
};
async function aPf<ReturnValue>(fn: () => Promise<ReturnValue>): Promise<[ReturnValue, number | null]> {
if (!enablePerf) {
return [await fn(), null];
}
return asyncPerf(fn);
}
}