* Replace n x m array functions with maps * feat: add attributeIds filter to findManyByOrgMembershipIds Allow filtering attribute-to-user assignments by specific attribute IDs. This enables querying only the attributes needed for a routing rule instead of fetching all attributes for all team members. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * feat: extract attribute IDs from routing rules and filter queries Add extractAttributeIdsFromQueryValue helper to parse routing rules and identify which attributes are referenced. Pass these IDs through getAttributesAssignmentData to _queryAllData to only fetch the attribute assignments that are actually needed for evaluation. For a team with 785 members and 14 attributes, if a routing rule only references 2-3 attributes, this reduces the query from ~3500 rows to ~500 rows. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * feat: use attribute filtering in findTeamMembersMatchingAttributeLogic Extract attribute IDs from the routing rules (main + fallback) and pass them to getAttributesForLogic to only fetch necessary data. This completes the optimization to reduce database load when evaluating routing rules that only reference a subset of attributes. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * feat: filter attributes query by attribute IDs Also filter the attributes query (not just attributeToUser assignments) by the attribute IDs referenced in routing rules. For a routing rule that references 2 out of 14 attributes with 6524 total options, this reduces: - Attributes fetched: 14 → 2 - Options fetched: 6524 → ~930 (proportional reduction) - Fewer regex replacements in replaceAttributeOptionIdsWithOptionLabel Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * test: add comprehensive test coverage for attribute routing query optimization Co-Authored-By: joe@cal.com <j.auyeung419@gmail.com> * Revert "Replace n x m array functions with maps" This reverts commit 312eeb03bd1aba19760f093c798861a23b5fc66f. * fix: re-establish extractAttributeIdsFromQueryValue mock after resetAllMocks 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>
518 lines
13 KiB
TypeScript
518 lines
13 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 type { AttributesQueryValue } from "@calcom/lib/raqb/types";
|
|
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";
|
|
|
|
/**
|
|
* Extracts attribute IDs referenced in a routing rule's attributesQueryValue.
|
|
* This allows us to only fetch the attributes that are actually needed for evaluation.
|
|
*/
|
|
export function extractAttributeIdsFromQueryValue(
|
|
queryValue: AttributesQueryValue | null,
|
|
fallbackQueryValue?: AttributesQueryValue | null
|
|
): string[] {
|
|
const attributeIds = new Set<string>();
|
|
|
|
function walkRules(obj: unknown) {
|
|
if (!obj || typeof obj !== "object") return;
|
|
|
|
const record = obj as Record<string, unknown>;
|
|
|
|
// Check if this is a rule with a field property (the attribute ID)
|
|
if (record.type === "rule" && record.properties) {
|
|
const properties = record.properties as Record<string, unknown>;
|
|
if (typeof properties.field === "string") {
|
|
attributeIds.add(properties.field);
|
|
}
|
|
}
|
|
|
|
// Recursively walk children1 (the RAQB structure for nested rules)
|
|
if (record.children1 && typeof record.children1 === "object") {
|
|
for (const child of Object.values(record.children1 as Record<string, unknown>)) {
|
|
walkRules(child);
|
|
}
|
|
}
|
|
}
|
|
|
|
if (queryValue) {
|
|
walkRules(queryValue);
|
|
}
|
|
|
|
if (fallbackQueryValue) {
|
|
walkRules(fallbackQueryValue);
|
|
}
|
|
|
|
return Array.from(attributeIds);
|
|
}
|
|
|
|
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,
|
|
attributeIds,
|
|
}: {
|
|
orgId: number;
|
|
teamId: number;
|
|
/** If provided, only fetch attribute assignments for these attribute IDs */
|
|
attributeIds?: string[];
|
|
}) {
|
|
const attributeRepo = new PrismaAttributeRepository(prisma);
|
|
const attributeToUserRepo = new PrismaAttributeToUserRepository(prisma);
|
|
|
|
const [orgMembershipToUserIdForTeamMembers, attributesOfTheOrg] =
|
|
await Promise.all([
|
|
_getOrgMembershipToUserIdForTeam({ orgId, teamId }),
|
|
attributeRepo.findManyByOrgId({ orgId, attributeIds }),
|
|
]);
|
|
|
|
const orgMembershipIds = Array.from(
|
|
orgMembershipToUserIdForTeamMembers.keys()
|
|
);
|
|
|
|
// Get the attributes assigned to the members of the team
|
|
// If attributeIds is provided, only fetch assignments for those specific attributes
|
|
const attributesToUsersForTeam =
|
|
await attributeToUserRepo.findManyByOrgMembershipIds({
|
|
orgMembershipIds,
|
|
attributeIds,
|
|
});
|
|
|
|
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,
|
|
attributeIds,
|
|
}: {
|
|
orgId: number;
|
|
teamId: number;
|
|
/** If provided, only fetch attribute assignments for these attribute IDs.
|
|
* This significantly improves performance when only a few attributes are needed. */
|
|
attributeIds?: string[];
|
|
}) {
|
|
const {
|
|
attributesOfTheOrg,
|
|
attributesToUsersForTeam,
|
|
orgMembershipToUserIdForTeamMembers,
|
|
} = await _queryAllData({
|
|
orgId,
|
|
teamId,
|
|
attributeIds,
|
|
});
|
|
|
|
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 });
|
|
}
|