Files
cal.diy/packages/features/attributes/lib/getAttributes.ts
T
8d79209e74 perf: Optmize DB queries around attribute routing (#27600)
* 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>
2026-02-04 11:28:46 -05:00

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