Files
calendar/packages/features/ee/dsync/lib/assignValueToUser.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

518 lines
16 KiB
TypeScript

import type {
AttributeId,
AttributeName,
BulkAttributeAssigner,
AttributeOptionAssignment,
} from "@calcom/app-store/routing-forms/types/types";
import { findAssignmentsForMember } from "@calcom/features/attributes/lib/utils";
import { PrismaAttributeOptionRepository } from "@calcom/features/attributes/repositories/PrismaAttributeOptionRepository";
import { PrismaAttributeRepository } from "@calcom/features/attributes/repositories/PrismaAttributeRepository";
import { MembershipRepository } from "@calcom/features/membership/repositories/MembershipRepository";
import logger from "@calcom/lib/logger";
import { safeStringify } from "@calcom/lib/safeStringify";
import prisma from "@calcom/prisma";
import {
doesSupportMultipleValues,
isAssignmentForLockedAttribute,
isAssignmentForTheSamePool,
isAssignmentSame,
buildSlugFromValue,
canSetValueBeyondOptions,
} from "./assignValueToUserUtils";
const log = logger.getSubLogger({ prefix: ["entity/attribute"] });
type AttributesIncludingOptions = Awaited<ReturnType<typeof findAttributesByName>>[number];
type AttributeOptionsToAssign = {
attribute: { id: AttributeId; isLocked: boolean };
optionsToAssign: { id: string | null; label: string }[];
};
type AttributeOptionsToAssignWithIdEnsured = {
attribute: { id: AttributeId; isLocked: boolean };
optionsToAssign: { id: string; label: string }[];
};
type AttributeLabelToValueMap = Record<AttributeName, string | string[]>;
const findAttributesByName = async ({
orgId,
attributeNames,
}: {
orgId: number;
attributeNames: AttributeName[];
}) => {
const attributeRepo = new PrismaAttributeRepository(prisma);
const attributesFromDb = await attributeRepo.findManyByNamesAndOrgIdIncludeOptions({
attributeNames,
orgId,
});
return attributesFromDb.map((attribute) => {
const { name, options, ...rest } = attribute;
return {
...rest,
label: name,
options: options.map((option) => {
return {
...option,
label: option.value,
};
}),
};
});
};
const lookupByLabels = <TWithLabel extends { label: string }>({
labels,
items,
}: {
labels: string[];
items: TWithLabel[];
}) => {
const map = new Map<string, TWithLabel | null>();
labels.forEach((label) => {
map.set(label, items.find((item) => item.label.toLowerCase() === label.toLowerCase()) ?? null);
});
return map;
};
const lookupByLabel = <TWithLabel extends { label: string }>({
label,
items,
}: {
label: string;
items: TWithLabel[];
}) => {
const map = lookupByLabels({ labels: [label], items });
return map.get(label);
};
export const buildPrismaQueriesForAttributeOptionToUser = ({
existingAttributeOptionAssignments,
attributeOptionsToAssign,
updater,
memberId,
}: {
orgId: number;
memberId: number;
existingAttributeOptionAssignments: AttributeOptionAssignment[];
attributeOptionsToAssign: AttributeOptionsToAssignWithIdEnsured[];
updater: BulkAttributeAssigner;
}) => {
const assignmentsToUpdateIds: string[] = [];
if (!attributeOptionsToAssign.length) {
log.debug("No attribute options to assign, skipping");
return {
attributeToUserCreateManyInput: [],
attributeToUserUpdateManyInput: null,
attributeToUserDeleteQueries: {
locked: null,
unlocked: null,
},
};
}
const { attributeToUserCreateManyInput } = attributeOptionsToAssign.reduce(
(acc, attributeOptionsToAssign) => {
const existingAttributeOptionAssignment = existingAttributeOptionAssignments.find(
(existingAttributeOptionForMember) => existingAttributeOptionForMember.attributeOption.attribute.id
);
// Prevent overriding the only value for an unlocked attribute
if (
existingAttributeOptionAssignment &&
!isAssignmentForLockedAttribute({
assignment: existingAttributeOptionAssignment,
}) &&
!doesSupportMultipleValues({
attribute: existingAttributeOptionAssignment.attributeOption.attribute,
}) &&
!isAssignmentForTheSamePool({
assignment: existingAttributeOptionAssignment,
updater,
})
) {
console.warn(
`Attribute ${existingAttributeOptionAssignment.attributeOption.attribute.id} already assigned to user ${memberId} and is not a multi-select attribute. So, skipping`
);
return acc;
}
attributeOptionsToAssign.optionsToAssign.forEach((optionToAssign) => {
// Prevent unnecessary recreation of the assignment, to avoid resetting the "weight"
if (
existingAttributeOptionAssignment &&
isAssignmentSame({
existingAssignment: existingAttributeOptionAssignment,
newOption: optionToAssign,
})
) {
log.debug(
`Updating existing assignment ${existingAttributeOptionAssignment.id} as it has same value`
);
assignmentsToUpdateIds.push(existingAttributeOptionAssignment.id);
return;
}
acc.attributeToUserCreateManyInput.push({
memberId,
attributeOptionId: optionToAssign.id,
...("dsyncId" in updater
? { createdByDSyncId: updater.dsyncId, createdById: null }
: {
createdById: updater.userId,
createdByDSyncId: null,
}),
});
});
return acc;
},
{
attributeToUserCreateManyInput: [] as {
memberId: number;
attributeOptionId: string;
createdByDSyncId: string | null;
createdById: number | null;
}[],
}
);
const lockedAttributeIds = attributeOptionsToAssign
.filter((option) => option.attribute.isLocked)
.map((option) => option.attribute.id);
const unlockedAttributeIds = attributeOptionsToAssign
.filter((option) => !option.attribute.isLocked)
.map((option) => option.attribute.id);
const unlockedAttributesAssignmentIdsInThePool = existingAttributeOptionAssignments
.filter((assignment) => {
return (
isAssignmentForTheSamePool({
assignment,
updater,
}) && unlockedAttributeIds.includes(assignment.attributeOption.attribute.id)
);
})
.map((existingAttributeOptionForMember) => existingAttributeOptionForMember.id);
const unlockedAttributesAssignmentIdsInThePoolWhichAreAllowedToBeCreated =
unlockedAttributesAssignmentIdsInThePool.filter((id) => !assignmentsToUpdateIds.includes(id));
const attributeToUserDeleteQueryWhereClauseForUnlockedAttributes =
unlockedAttributesAssignmentIdsInThePoolWhichAreAllowedToBeCreated.length
? scopedWhereClause({
memberId,
whereClause: {
id: {
in: unlockedAttributesAssignmentIdsInThePoolWhichAreAllowedToBeCreated,
},
},
})
: null;
const attributeToUserDeleteQueryWhereClauseForLockedAttributes = lockedAttributeIds.length
? scopedWhereClause({
memberId,
whereClause: {
...(assignmentsToUpdateIds.length
? {
id: {
notIn: assignmentsToUpdateIds,
},
}
: null),
// No matter who assigned the attribute(Cal.com or SCIM), we delete it because the source of truth is SCIM and only one SCIM can be be source of truth at one time.
attributeOption: {
attribute: {
id: {
in: lockedAttributeIds,
},
},
},
},
})
: null;
const attributeToUserUpdateManyInput = assignmentsToUpdateIds.length
? {
where: scopedWhereClause({
memberId,
whereClause: {
id: {
in: assignmentsToUpdateIds,
},
},
}),
data: {
...("dsyncId" in updater
? { updatedByDSyncId: updater.dsyncId, updatedById: null }
: {
updatedById: updater.userId,
updatedByDSyncId: null,
}),
},
}
: null;
log.debug("buildPrismaQueriesForAttributeOptionToUser", {
assignmentsToUpdateIds,
lockedAttributeIds,
unlockedAttributesAssignmentIdsInThePool,
unlockedAttributeIds,
existingAttributeOptionAssignments,
updater,
attributeToUserDeleteQueryWhereClauseForLockedAttributes: safeStringify(
attributeToUserDeleteQueryWhereClauseForLockedAttributes
),
attributeToUserDeleteQueryWhereClauseForUnlockedAttributes: safeStringify(
attributeToUserDeleteQueryWhereClauseForUnlockedAttributes
),
attributeToUserCreateManyInput: safeStringify(attributeToUserCreateManyInput),
attributeToUserUpdateManyInput,
});
return {
attributeToUserUpdateManyInput,
attributeToUserCreateManyInput,
attributeToUserDeleteQueries: {
locked: attributeToUserDeleteQueryWhereClauseForLockedAttributes,
unlocked: attributeToUserDeleteQueryWhereClauseForUnlockedAttributes,
},
};
function scopedWhereClause<TWhereClause extends object | null>({
memberId,
whereClause,
}: {
memberId: number;
whereClause: TWhereClause;
}) {
return {
...whereClause,
memberId,
};
}
};
const buildPrismaQueryForAttributeOptionCreation = ({
optionsToCreate,
}: {
optionsToCreate: Record<AttributeId, string[]>;
}) => {
const attributeOptionCreateManyInput = Object.entries(optionsToCreate).map(([attributeId, options]) =>
options.map((option) => ({
attributeId,
value: option,
slug: buildSlugFromValue({ value: option }),
}))
);
return attributeOptionCreateManyInput.flat();
};
const createMissingOptionsAndReturnAlongWithExisting = async <
TattributeOptionsToAssign extends AttributeOptionsToAssign
>({
attributeOptionsToAssignIncludingNonExistentOptions,
orgId,
}: {
attributeOptionsToAssignIncludingNonExistentOptions: TattributeOptionsToAssign[];
orgId: number;
}) => {
const attributeOptionCreateManyInput = buildPrismaQueryForAttributeOptionCreation({
optionsToCreate: attributeOptionsToAssignIncludingNonExistentOptions.reduce(
(acc, attributeOptionsToAssign) => {
const { attribute, optionsToAssign } = attributeOptionsToAssign;
acc[attribute.id] = optionsToAssign.filter((option) => !option.id).map((option) => option.label);
return acc;
},
{} as Record<AttributeId, string[]>
),
});
await PrismaAttributeOptionRepository.createMany({
createManyInput: attributeOptionCreateManyInput,
});
// We need fetch all the attribute options to ensure that we have the newly created options as well.
const allAttributeOptions = (
await PrismaAttributeOptionRepository.findMany({
orgId,
})
).map((attributeOption) => ({
...attributeOption,
label: attributeOption.value,
}));
return attributeOptionsToAssignIncludingNonExistentOptions.map(({ attribute, optionsToAssign }) => {
const optionsWithIdsOfTheSameAttribute = optionsToAssign
.map((option) =>
allAttributeOptions.find(
(attributeOption) =>
attributeOption.attributeId === attribute.id &&
attributeOption.label.toLowerCase() === option.label.toLowerCase()
)
)
.filter((option): option is NonNullable<typeof option> => option !== null);
return {
attribute,
optionsToAssign: optionsWithIdsOfTheSameAttribute,
};
});
};
const buildAttributeOptionsToAssign = async ({
attributeLabelToValueMap,
allAttributesBeingAssigned,
orgId,
}: {
attributeLabelToValueMap: AttributeLabelToValueMap;
allAttributesBeingAssigned: AttributesIncludingOptions[];
orgId: number;
}) => {
const attributeOptionsToAssignIncludingNonExistentOptions = Object.entries(attributeLabelToValueMap)
.map(([attributeLabel, value]) => {
const valueAsArray = value instanceof Array ? value : [value];
const attribute = lookupByLabel({ label: attributeLabel, items: allAttributesBeingAssigned });
if (!attribute) {
console.warn(`Attribute ${attributeLabel} not found, will not be assigned to user`);
return null;
}
const labelToFoundOptionMap = lookupByLabels({ labels: valueAsArray, items: attribute.options });
const labelsEntries = Array.from(labelToFoundOptionMap.entries());
const optionsToAssign = labelsEntries
.filter(([_, option]) => {
return canSetValueBeyondOptions({ attribute }) ? true : !!option;
})
.map(([label]) => {
const foundLabel = labelToFoundOptionMap.get(label) ?? null;
return {
label,
...foundLabel,
};
});
return {
attribute,
optionsToAssign: optionsToAssign.map((option) => ({
label: option.label,
id: option.id ?? null,
})),
};
})
.filter((option): option is NonNullable<typeof option> => option !== null);
return createMissingOptionsAndReturnAlongWithExisting({
attributeOptionsToAssignIncludingNonExistentOptions,
orgId,
});
};
/**
* For a user in an org, it assigns all the attributes with their values as per the attributeLabelToValueMap.
*/
export const assignValueToUserInOrgBulk = async ({
orgId,
userId,
attributeLabelToValueMap,
updater,
}: {
orgId: number;
userId: number;
attributeLabelToValueMap: AttributeLabelToValueMap;
updater: BulkAttributeAssigner;
}) => {
const membership = await MembershipRepository.findUniqueByUserIdAndTeamId({ userId, teamId: orgId });
const defaultReturn = { numOfAttributeOptionsSet: 0, numOfAttributeOptionsDeleted: 0 };
if (!membership) {
console.error(`User ${userId} not a member of org ${orgId}, not assigning attribute options`);
return defaultReturn;
}
const { id: memberId } = membership;
const attributeNames = Object.keys(attributeLabelToValueMap);
const allAttributesBeingAssigned = await findAttributesByName({
orgId,
attributeNames,
});
if (!allAttributesBeingAssigned.length) {
console.warn(`None of the attributes ${attributeNames.join(", ")} found in org ${orgId}`);
return defaultReturn;
}
const attributeOptionsToAssign = await buildAttributeOptionsToAssign({
attributeLabelToValueMap,
allAttributesBeingAssigned,
orgId,
});
// We need to fetch all the assigned options even those that aren't being set directly in call.
// We need to decide on the fate of all existing assignments
const existingAttributeOptionAssignments = await findAssignmentsForMember({ memberId });
const { attributeToUserUpdateManyInput, attributeToUserCreateManyInput, attributeToUserDeleteQueries } =
buildPrismaQueriesForAttributeOptionToUser({
orgId,
memberId,
attributeOptionsToAssign,
existingAttributeOptionAssignments,
updater,
});
const { numOfAttributeOptionsDeleted, numOfAttributeOptionsSet, numOfAttributeOptionsUpdated } =
await prisma.$transaction(async (tx) => {
// First delete all existing options, to allow them to be set again.
const deleteQueriesResult = await Promise.all(
Object.entries(attributeToUserDeleteQueries).map(([_, attributeToUserDeleteQuery]) =>
attributeToUserDeleteQuery
? tx.attributeToUser.deleteMany({
where: attributeToUserDeleteQuery,
})
: Promise.resolve({ count: 0 })
)
);
// Then set the new options.
const { count: numOfAttributeOptionsSet } = await tx.attributeToUser.createMany({
data: attributeToUserCreateManyInput,
// In case the values already exist, we skip them instead of throwing an error.
skipDuplicates: true,
});
let numOfAttributeOptionsUpdated = 0;
if (attributeToUserUpdateManyInput) {
({ count: numOfAttributeOptionsUpdated } = await tx.attributeToUser.updateMany({
where: attributeToUserUpdateManyInput?.where,
data: attributeToUserUpdateManyInput?.data,
}));
}
const numOfAttributeOptionsDeleted = deleteQueriesResult.reduce(
(acc, query) => acc + (query.count ?? 0),
0
);
return {
numOfAttributeOptionsDeleted,
numOfAttributeOptionsSet,
numOfAttributeOptionsUpdated,
};
});
log.debug({
numOfAttributeOptionsSet,
numOfAttributeOptionsDeleted,
numOfAttributeOptionsUpdated,
});
return {
numOfAttributeOptionsSet,
numOfAttributeOptionsDeleted,
numOfAttributeOptionsUpdated,
};
};