* feat(salesforce): add field rules for round robin skip - Add rrSkipFieldRules schema to appDataSchema in zod.ts - Implement applyFieldRules method in CrmService.ts for post-query filtering - Integrate field rules into getContacts method when forRoundRobinSkip=true - Add UI component for configuring field rules in EventTypeAppCardInterface - Add translations for new UI strings - Add comprehensive tests for field rules functionality Field rules allow users to specify conditions based on Salesforce record fields with 'ignore' or 'must_include' actions. Rules are evaluated with AND logic and gracefully handle missing fields by skipping those rules. Co-Authored-By: joe@cal.com <j.auyeung419@gmail.com> * fix: add children prop to Section.SubSectionHeader for field rules Co-Authored-By: joe@cal.com <j.auyeung419@gmail.com> * feat(salesforce): add edit button for field rules Co-Authored-By: joe@cal.com <j.auyeung419@gmail.com> * feat(salesforce): apply field rules to GraphQL results Co-Authored-By: joe@cal.com <j.auyeung419@gmail.com> * feat(salesforce): add Redis caching for field validation Co-Authored-By: joe@cal.com <j.auyeung419@gmail.com> * fix(salesforce): remove Redis caching to fix ERR_INVALID_THIS error - Removed Redis caching for field validation that was causing ERR_INVALID_THIS errors - Simplified field rules filtering to try query directly and skip filtering if it fails - Restored ensureFieldsExistOnObject method for other uses (write to record) - Field rules now gracefully handle invalid fields by skipping filtering Co-Authored-By: joe@cal.com <j.auyeung419@gmail.com> * chore: reformat getAttributes.ts Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * feat(salesforce): dynamically build GraphQL query for field rules with multi-edge iteration - Add buildDynamicAccountQuery to inject field rule fields into GraphQL query - Add passesFieldRules to evaluate ignore/must_include rules against UIAPI nodes - Validate field rules via getObjectFieldNames (in-memory + Redis cache) before branching - Remove applyFieldRulesToGraphQLRecords (used jsforce conn in GraphQL path) - Iterate all edges in Tiers 1 and 2 instead of only the first result - Rank Tier 3 accounts by contact count and fallback to next on field rule failure Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * feat(salesforce): add field rule routing trace steps Add trace calls alongside field rule info logs for routing visibility: - fieldRulesValidated: records validation result in CrmService - fieldRuleFilteredRecord: records when account is filtered at each tier - fieldRuleEvaluated: records individual rule evaluation details - allRecordsFilteredByFieldRules: records when SOQL records are all filtered Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * fix(salesforce): remove duplicate field validation and fix disabled Select options applyFieldRules now receives pre-validated rules from the early validation block instead of re-validating internally. Also adds the missing options prop to the disabled Select in the field rules UI. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * feat(salesforce): include failed rule details in field rule trace steps Rename passesFieldRules to getFailingFieldRule so the caller receives the specific rule that caused filtering. The fieldRuleFilteredRecord trace step now includes failedRule with field, value, and action. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * test(salesforce): add GraphQL path tests for field rules Cover field rule filtering across all three GraphQL resolution tiers (contact, account, related contacts) with ignore and must_include actions, multi-edge fallback, case-insensitivity, and dynamic query selection. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * Abstract new field rules settings * Reduce extra SOQL call * fix(salesforce): fix type errors in CrmService and GraphQL client - Add missing import for getRedisService from @calcom/features/di/containers/Redis - Fix ensureFieldsExistOnObject to properly return Field[] instead of incomplete function - Use Array.from() instead of spread operator for Set and Map iterators to fix downlevelIteration errors 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>
465 lines
12 KiB
TypeScript
465 lines
12 KiB
TypeScript
// TODO: Queries in this file are not optimized. Need to optimize them.
|
|
import type { Attribute } from "@calcom/app-store/routing-forms/types/types";
|
|
import type {
|
|
AttributeId,
|
|
AttributeOptionValueWithType,
|
|
} from "@calcom/app-store/routing-forms/types/types";
|
|
import { PrismaAttributeRepository } from "@calcom/features/attributes/repositories/PrismaAttributeRepository";
|
|
import { PrismaAttributeToUserRepository } from "@calcom/features/attributes/repositories/PrismaAttributeToUserRepository";
|
|
import logger from "@calcom/lib/logger";
|
|
import { safeStringify } from "@calcom/lib/safeStringify";
|
|
import prisma from "@calcom/prisma";
|
|
import type { AttributeToUser } from "@calcom/prisma/client";
|
|
import type { AttributeType } from "@calcom/prisma/enums";
|
|
|
|
type UserId = number;
|
|
type OrgMembershipId = number;
|
|
type AttributeOptionId = string;
|
|
type AssignmentForTheTeam = {
|
|
userId: number;
|
|
attributeOption: {
|
|
id: string;
|
|
value: string;
|
|
slug: string;
|
|
contains: string[];
|
|
isGroup: boolean;
|
|
};
|
|
attribute: {
|
|
id: string;
|
|
name: string;
|
|
type: AttributeType;
|
|
};
|
|
};
|
|
|
|
type FullAttribute = {
|
|
name: string;
|
|
slug: string;
|
|
type: AttributeType;
|
|
id: string;
|
|
options: {
|
|
id: string;
|
|
value: string;
|
|
slug: string;
|
|
isGroup: boolean;
|
|
contains: string[];
|
|
}[];
|
|
};
|
|
|
|
async function _findMembershipsForBothOrgAndTeam({
|
|
orgId,
|
|
teamId,
|
|
}: {
|
|
orgId: number;
|
|
teamId: number;
|
|
}) {
|
|
const memberships = await prisma.membership.findMany({
|
|
where: {
|
|
teamId: {
|
|
in: [orgId, teamId],
|
|
},
|
|
},
|
|
});
|
|
|
|
type Membership = (typeof memberships)[number];
|
|
|
|
const { teamMemberships, orgMemberships } = memberships.reduce<{
|
|
teamMemberships: Membership[];
|
|
orgMemberships: Membership[];
|
|
}>(
|
|
(acc, membership) => {
|
|
if (membership.teamId === teamId) {
|
|
acc.teamMemberships.push(membership);
|
|
} else if (membership.teamId === orgId) {
|
|
acc.orgMemberships.push(membership);
|
|
}
|
|
return acc;
|
|
},
|
|
{ teamMemberships: [], orgMemberships: [] }
|
|
);
|
|
|
|
return {
|
|
teamMemberships,
|
|
orgMemberships,
|
|
};
|
|
}
|
|
|
|
function _prepareAssignmentData({
|
|
assignmentsForTheTeam,
|
|
attributesOfTheOrg,
|
|
}: {
|
|
assignmentsForTheTeam: AssignmentForTheTeam[];
|
|
attributesOfTheOrg: Attribute[];
|
|
}) {
|
|
const teamMembersThatHaveOptionAssigned = assignmentsForTheTeam.reduce(
|
|
(acc, attributeToUser) => {
|
|
const userId = attributeToUser.userId;
|
|
const attributeOption = attributeToUser.attributeOption;
|
|
const attribute = attributeToUser.attribute;
|
|
|
|
if (!acc[userId]) {
|
|
acc[userId] = { userId, attributes: {} };
|
|
}
|
|
|
|
const attributes = acc[userId].attributes;
|
|
const currentAttributeOptionValue =
|
|
attributes[attribute.id]?.attributeOption;
|
|
const newAttributeOptionValue = {
|
|
isGroup: attributeOption.isGroup,
|
|
value: attributeOption.value,
|
|
contains: tranformContains({
|
|
contains: attributeOption.contains,
|
|
attribute,
|
|
}),
|
|
};
|
|
|
|
if (currentAttributeOptionValue instanceof Array) {
|
|
attributes[attribute.id].attributeOption = [
|
|
...currentAttributeOptionValue,
|
|
newAttributeOptionValue,
|
|
];
|
|
} else if (currentAttributeOptionValue) {
|
|
attributes[attribute.id].attributeOption = [
|
|
currentAttributeOptionValue,
|
|
{
|
|
isGroup: attributeOption.isGroup,
|
|
value: attributeOption.value,
|
|
contains: tranformContains({
|
|
contains: attributeOption.contains,
|
|
attribute,
|
|
}),
|
|
},
|
|
];
|
|
} else {
|
|
// Set the first value
|
|
attributes[attribute.id] = {
|
|
type: attribute.type,
|
|
attributeOption: newAttributeOptionValue,
|
|
};
|
|
}
|
|
return acc;
|
|
},
|
|
{} as Record<
|
|
UserId,
|
|
{
|
|
userId: UserId;
|
|
attributes: Record<AttributeId, AttributeOptionValueWithType>;
|
|
}
|
|
>
|
|
);
|
|
|
|
return Object.values(teamMembersThatHaveOptionAssigned);
|
|
|
|
/**
|
|
* Transforms ["optionId1", "optionId2"] to [{
|
|
* id: "optionId1",
|
|
* value: "optionValue1",
|
|
* slug: "optionSlug1",
|
|
* }, {
|
|
* id: "optionId2",
|
|
* value: "optionValue2",
|
|
* slug: "optionSlug2",
|
|
* }]
|
|
*/
|
|
function tranformContains({
|
|
contains,
|
|
attribute,
|
|
}: {
|
|
contains: string[];
|
|
attribute: { id: string; name: string };
|
|
}) {
|
|
return contains
|
|
.map((optionId) => {
|
|
const allOptions = attributesOfTheOrg.find(
|
|
(_attribute) => _attribute.id === attribute.id
|
|
)?.options;
|
|
const option = allOptions?.find((option) => option.id === optionId);
|
|
if (!option) {
|
|
console.error(
|
|
`Enriching "contains" for attribute ${
|
|
attribute.name
|
|
}: Option with id ${optionId} not found. Looked up in ${JSON.stringify(
|
|
allOptions
|
|
)}`
|
|
);
|
|
return null;
|
|
}
|
|
return {
|
|
id: option.id,
|
|
value: option.value,
|
|
slug: option.slug,
|
|
};
|
|
})
|
|
.filter(
|
|
(option): option is NonNullable<typeof option> => option !== null
|
|
);
|
|
}
|
|
}
|
|
|
|
function _getAttributeFromAttributeOption({
|
|
allAttributesOfTheOrg,
|
|
attributeOptionId,
|
|
}: {
|
|
allAttributesOfTheOrg: Attribute[];
|
|
attributeOptionId: AttributeOptionId;
|
|
}) {
|
|
return allAttributesOfTheOrg.find((attribute) =>
|
|
attribute.options.some((option) => option.id === attributeOptionId)
|
|
);
|
|
}
|
|
|
|
function _getAttributeOptionFromAttributeOption({
|
|
allAttributesOfTheOrg,
|
|
attributeOptionId,
|
|
}: {
|
|
allAttributesOfTheOrg: FullAttribute[];
|
|
attributeOptionId: AttributeOptionId;
|
|
}) {
|
|
const matchingOption = allAttributesOfTheOrg.reduce((found, attribute) => {
|
|
if (found) return found;
|
|
return (
|
|
attribute.options.find((option) => option.id === attributeOptionId) ||
|
|
null
|
|
);
|
|
}, null as null | (typeof allAttributesOfTheOrg)[number]["options"][number]);
|
|
return matchingOption;
|
|
}
|
|
|
|
async function _getOrgMembershipToUserIdForTeam({
|
|
orgId,
|
|
teamId,
|
|
}: {
|
|
orgId: number;
|
|
teamId: number;
|
|
}) {
|
|
const { orgMemberships, teamMemberships } =
|
|
await _findMembershipsForBothOrgAndTeam({
|
|
orgId,
|
|
teamId,
|
|
});
|
|
|
|
// Using map for performance lookup as it matters in the below loop working with 1000s of records
|
|
const orgMembershipsByUserId = new Map(
|
|
orgMemberships.map((m) => [m.userId, m])
|
|
);
|
|
|
|
/**
|
|
* Holds the records of orgMembershipId to userId for the sub-team's members only.
|
|
*/
|
|
const orgMembershipToUserIdForTeamMembers = new Map<
|
|
OrgMembershipId,
|
|
UserId
|
|
>();
|
|
|
|
/**
|
|
* For an organization with 3000 users and 10 teams, with every team having around 300 members, the total memberships we get for a team are 3000+300 = 3300
|
|
* So, these are not a lot of records and we could afford to do in memory computations on them.
|
|
*/
|
|
teamMemberships.forEach((teamMembership) => {
|
|
const orgMembership = orgMembershipsByUserId.get(teamMembership.userId);
|
|
if (!orgMembership) {
|
|
// console.error(
|
|
// `Org membership not found for userId ${teamMembership.userId} in the organization's memberships`
|
|
// );
|
|
return;
|
|
}
|
|
orgMembershipToUserIdForTeamMembers.set(
|
|
orgMembership.id,
|
|
orgMembership.userId
|
|
);
|
|
});
|
|
|
|
return orgMembershipToUserIdForTeamMembers;
|
|
}
|
|
|
|
async function _queryAllData({
|
|
orgId,
|
|
teamId,
|
|
}: {
|
|
orgId: number;
|
|
teamId: number;
|
|
}) {
|
|
const attributeRepo = new PrismaAttributeRepository(prisma);
|
|
const attributeToUserRepo = new PrismaAttributeToUserRepository(prisma);
|
|
|
|
const [orgMembershipToUserIdForTeamMembers, attributesOfTheOrg] =
|
|
await Promise.all([
|
|
_getOrgMembershipToUserIdForTeam({ orgId, teamId }),
|
|
attributeRepo.findManyByOrgId({ orgId }),
|
|
]);
|
|
|
|
const orgMembershipIds = Array.from(
|
|
orgMembershipToUserIdForTeamMembers.keys()
|
|
);
|
|
|
|
// Get all the attributes assigned to the members of the team
|
|
const attributesToUsersForTeam =
|
|
await attributeToUserRepo.findManyByOrgMembershipIds({
|
|
orgMembershipIds,
|
|
});
|
|
|
|
return {
|
|
attributesOfTheOrg,
|
|
attributesToUsersForTeam,
|
|
orgMembershipToUserIdForTeamMembers,
|
|
};
|
|
}
|
|
|
|
async function getAttributesAssignedToMembersOfTeam({
|
|
teamId,
|
|
userId,
|
|
}: {
|
|
teamId: number;
|
|
userId?: number;
|
|
}) {
|
|
const log = logger.getSubLogger({
|
|
prefix: ["getAttributeToUserWithMembershipAndAttributes"],
|
|
});
|
|
|
|
const whereClauseForAttributesAssignedToMembersOfTeam = {
|
|
options: {
|
|
some: {
|
|
assignedUsers: {
|
|
some: {
|
|
member: {
|
|
userId,
|
|
user: {
|
|
teams: {
|
|
some: {
|
|
teamId,
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
};
|
|
|
|
log.debug(
|
|
safeStringify({
|
|
teamId,
|
|
whereClauseForAttributesAssignedToMembersOfTeam,
|
|
})
|
|
);
|
|
|
|
const assignedAttributeOptions = await prisma.attribute.findMany({
|
|
where: whereClauseForAttributesAssignedToMembersOfTeam,
|
|
select: {
|
|
id: true,
|
|
name: true,
|
|
type: true,
|
|
isWeightsEnabled: true,
|
|
options: {
|
|
select: {
|
|
id: true,
|
|
value: true,
|
|
slug: true,
|
|
contains: true,
|
|
isGroup: true,
|
|
},
|
|
},
|
|
slug: true,
|
|
},
|
|
});
|
|
|
|
return assignedAttributeOptions;
|
|
}
|
|
|
|
function _buildAssignmentsForTeam({
|
|
attributesToUsersForTeam,
|
|
orgMembershipToUserIdForTeamMembers,
|
|
attributesOfTheOrg,
|
|
}: {
|
|
attributesToUsersForTeam: AttributeToUser[];
|
|
orgMembershipToUserIdForTeamMembers: Map<OrgMembershipId, UserId>;
|
|
attributesOfTheOrg: FullAttribute[];
|
|
}) {
|
|
return attributesToUsersForTeam
|
|
.map((attributeToUser) => {
|
|
const orgMembershipId = attributeToUser.memberId;
|
|
const userId = orgMembershipToUserIdForTeamMembers.get(orgMembershipId);
|
|
if (!userId) {
|
|
console.error(
|
|
`No org membership found for membership id ${orgMembershipId}`
|
|
);
|
|
return null;
|
|
}
|
|
const attribute = _getAttributeFromAttributeOption({
|
|
allAttributesOfTheOrg: attributesOfTheOrg,
|
|
attributeOptionId: attributeToUser.attributeOptionId,
|
|
});
|
|
|
|
const attributeOption = _getAttributeOptionFromAttributeOption({
|
|
allAttributesOfTheOrg: attributesOfTheOrg,
|
|
attributeOptionId: attributeToUser.attributeOptionId,
|
|
});
|
|
|
|
if (!attributeOption || !attribute) {
|
|
console.error(
|
|
`Attribute option with id ${attributeToUser.attributeOptionId} not found in the organization's attributes`
|
|
);
|
|
return null;
|
|
}
|
|
|
|
return {
|
|
...attributeToUser,
|
|
userId,
|
|
attribute,
|
|
attributeOption,
|
|
};
|
|
})
|
|
.filter(
|
|
(assignment): assignment is NonNullable<typeof assignment> =>
|
|
assignment !== null
|
|
);
|
|
}
|
|
|
|
export async function getAttributesAssignmentData({
|
|
orgId,
|
|
teamId,
|
|
}: {
|
|
orgId: number;
|
|
teamId: number;
|
|
}) {
|
|
const {
|
|
attributesOfTheOrg,
|
|
attributesToUsersForTeam,
|
|
orgMembershipToUserIdForTeamMembers,
|
|
} = await _queryAllData({
|
|
orgId,
|
|
teamId,
|
|
});
|
|
|
|
const assignmentsForTheTeam = _buildAssignmentsForTeam({
|
|
attributesToUsersForTeam,
|
|
orgMembershipToUserIdForTeamMembers,
|
|
attributesOfTheOrg,
|
|
});
|
|
|
|
const attributesAssignedToTeamMembersWithOptions = _prepareAssignmentData({
|
|
attributesOfTheOrg,
|
|
assignmentsForTheTeam,
|
|
});
|
|
|
|
return {
|
|
attributesOfTheOrg,
|
|
attributesAssignedToTeamMembersWithOptions,
|
|
};
|
|
}
|
|
|
|
export async function getAttributesForTeam({ teamId }: { teamId: number }) {
|
|
const attributes = await getAttributesAssignedToMembersOfTeam({ teamId });
|
|
return attributes satisfies Attribute[];
|
|
}
|
|
|
|
export async function getUsersAttributes({
|
|
userId,
|
|
teamId,
|
|
}: {
|
|
userId: number;
|
|
teamId: number;
|
|
}) {
|
|
return await getAttributesAssignedToMembersOfTeam({ teamId, userId });
|
|
}
|