Files
calendar/packages/features/attributes/lib/getAttributes.ts
T
Joe Au-YeungGitHubDevin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>Claude Opus 4.5
f91511b927 feat(salesforce): add field rules for round robin routing (#27402)
* 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>
2026-02-01 23:03:19 -05:00

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 });
}