Files
calendar/packages/features/attributes/lib/getAttributes.ts
T
Benny JooGitHubDevin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
00e5770273 refactor: Move attribute and routing-forms code from app-store/lib to features (#26025)
* mv

* wip

* wip

* wip

* wip

* plural

* fix: resolve TypeScript type errors for attribute refactoring

- Add TransformedAttributeOption type to handle transformed contains field
- Update imports to use routing-forms Attribute type where needed
- Fix getValueOfAttributeOption to use TransformedAttributeOption type
- Update AttributeOptionValueWithType to use TransformedAttributeOption
- Fix pre-existing lint warnings (any -> unknown, proper type casting)

Co-Authored-By: benny@cal.com <sldisek783@gmail.com>

* fix

* cleanup

* cleanup

* fix

* fix

* fix

* fix

* fix

* fix: add explicit type annotation to reduce callback in getAttributes.ts

Co-Authored-By: benny@cal.com <sldisek783@gmail.com>

* fix: use RouterOutputs from @calcom/trpc/react for consistent type inference

- Update AppPage.tsx to use RouterOutputs from @calcom/trpc/react instead of inferRouterOutputs<AppRouter>
- Update MultiDisconnectIntegration.tsx to use the same RouterOutputs type source
- Add eslint-disable comments for pre-existing warnings (useEffect deps, img elements)

Co-Authored-By: benny@cal.com <sldisek783@gmail.com>

* fix: explicitly type attribute property in AttributeOptionAssignment

The attribute property from Pick<AttributeOption, 'attribute'> was typed as
'unknown' because Prisma relations don't have explicit types when picked.
This explicitly defines the attribute shape with id, type, and isLocked
properties that are used in assignValueToUser.ts.

Co-Authored-By: benny@cal.com <sldisek783@gmail.com>

* fix: remove unused assignedUsers from AttributeOptionAssignment Pick

The assignedUsers property was not being used in the code but was required
by the Pick type, causing a type mismatch when the actual data from the
database query didn't include it.

Co-Authored-By: benny@cal.com <sldisek783@gmail.com>

* fix: update import path for AttributeOptionAssignment and BulkAttributeAssigner types

The types.d.ts file was moved from packages/lib/service/attribute/ to
packages/app-store/routing-forms/types/. Updated the import path in utils.ts
to point to the new location.

Co-Authored-By: benny@cal.com <sldisek783@gmail.com>

* fix

---------

Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2025-12-29 21:51:06 -03:00

374 lines
11 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 [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 PrismaAttributeToUserRepository.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 });
}