Files
calendar/packages/features/ee/dsync/lib/assignValueToUser.ts
T
Joe Au-YeungGitHubDevin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>Hariom Balharacubic-dev-ai[bot] <191113872+cubic-dev-ai[bot]@users.noreply.github.com>
6501743e5e feat: Add attribute sync logic from Salesforce (#26517)
* Add db schema

* Add `CredentialRepository.findByTeamIdAndSlugs`

* Add enabled app slugs for attribute syncing

* Create repository for `IntegrationAttributeSync`

* Create zod schemas

* Create `AttributeSyncUserRuleOutputMapper`

* Create `IntegrationAttributeSyncService`

* Create DI contianer

* Create trpc endpoints

* Create page

* Include team name in `CredentialRepository.findByTeamIdAndSlugs`

* Update schema and relations

* Update types and schemas

* Add more methods to IntegrationAttributeSyncRepository

* Add more services to `IntegrationAttributeSyncService`
- getById
- Init updateIncludeRulesAndMappings

* Refactor `getTeams.handler` to use repository

* Create `createAttributeSync` trpc endpoint

* Create `updateAttributeSync` trpc endpoint

* Add router to trpc

* Create attribute sync child components

* Pass custom actions to `FormCard`

* Create `IntegrationAttributeSyncCard`

* Pass inital props via server side

* Fix prop

* Only refetch on mutation

* Fixes

* Add form error when duplicate field and attribute combo

* Add `updateTransactionWithRUleAndMappings` logic

* Adjust zod schemas

* Service add `updateIncludeRulesAndMappings`

* Pass orgId from server to component

* Rename types

* Add deleteById method to repository

* Add name to integrationAttributeSync

* Add deleteById method to service

* Rename method

* Add deleteAttributeSync trpc endpoint

* Make the IntegrationAttributeSyncCard a dummy component

* test: add tests for IntegrationAttributeSync feature

Co-Authored-By: joe@cal.com <j.auyeung419@gmail.com>

* Move creating a attribute sync record to the service

* Add i18n strings

* Safe select credential in find by id and team

* Fix default credentialId value in form

* Update repository return types

* Add i18n string

* Make credentialId optional for form schema

* Fix label

* Add cascade deletes

* Add verification that syncs belong to org

* Create mapper for repository output

* Type fixes

* Remove old test file

* Pass `attributeOptions` from parent to children

* Infer types from zod schema

* Type fixes

* Type fix

* Clean up

* Add i18n strings

* Remove unused file

* Address feedback

* Add migration file

* Address feedback

* Add validation for new integration values

* Remove unused router

* Move away from z.infer to z.ZodType

* Clean up comments

* Type fix

* Type fixes

* Type fix

* fix: add passthrough to syncFormDataSchema to preserve extra fields

Co-Authored-By: joe@cal.com <j.auyeung419@gmail.com>

* fix: remove incorrect test that expected extra fields to pass through syncFormDataSchema

Co-Authored-By: joe@cal.com <j.auyeung419@gmail.com>

* Add endpoint for SF to call

* Create scratch org config

* Create sf cli scripts

* Create package logic

* Update README

* Remove unused file

* Add indexes

* Add aria label

* Address feedback - consistent validation

* Fix import paths for attribute types

* Add `CredentialRepository.findByAppIdAndKeyValue`

* Get credential by instance URL

* Verify incoming sfdc orgId matches credential sfdc orgId

* Rename method

* Get user name integration syncs

* refactor: change attributeSyncRules array to singular attributeSyncRule

The database schema enforces a one-to-one relationship between
IntegrationAttributeSync and AttributeSyncRule (via @unique constraint),
and the UI only supports a single rule. This change makes the TypeScript
type match the database schema and UI behavior.

Changes:
- Update IntegrationAttributeSync interface to use attributeSyncRule: AttributeSyncRule | null
- Update mapper to return singular rule instead of wrapping in array
- Update UI component to access sync.attributeSyncRule directly
- Update IIntegrationAttributeSyncUpdateParams Omit type
- Update tests to use attributeSyncRule: null instead of attributeSyncRules: []

Co-Authored-By: joe@cal.com <j.auyeung419@gmail.com>

* Convert `membershipRepository.findAllByUserId` to a normal method

* Add temp files to git ignore

* Init  and process team conditions

* Biome formatting

* Add `AttributeService` to get user attributes

* Add DI container for AttributeService

* AttributeSyncService evaluate attribute conditions

* Create DI container for attributeSyncService

* Return result for full condition

* Evaluate if attribute sync should apply to user

* Add  method

* Change PrismaAttributeOptionRepository to instance methods

* Init AttributeSyncFieldMappingsService and process attribute syncs

* Add AttributeSyncFieldMappingService DI container

* Refactor orgId to organizationId

* Add membership validation to sync field service

* AttributeSYncFieldMappingService use repository methods

* AttributeSyncFieldMappingService.processMappings add mapping logic

* Add DI tokens

* user-sync endpoint to implement attribute syncing

* Validate team belongs to org for rule

* test: add tests for AttributeSyncRuleService, AttributeSyncFieldMappingService, and AttributeService

Co-Authored-By: joe@cal.com <j.auyeung419@gmail.com>

* Remove duplicate migration file

* Fix merge conflict

* fix: resolve type errors in attribute sync feature (#26814)

Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* Rename  variable

* Fix log typo

* Fix file name

* Add error logging

* Use credential teamId

* fix: add missing mockTeamRepository and team validation to tests

Co-Authored-By: joe@cal.com <j.auyeung419@gmail.com>

* Fix naming

* Fix file import

* Type fix

* Pass MembershipRepository as a dep in AttributeSyncFieldMappingService

* Type fix

* fix: add mockMembershipRepository to AttributeSyncFieldMappingService tests

Co-Authored-By: joe@cal.com <j.auyeung419@gmail.com>

* Update packages/app-store/salesforce/api/user-sync.ts

Add error handling when getting orgId from stored salesforce id

Co-authored-by: cubic-dev-ai[bot] <191113872+cubic-dev-ai[bot]@users.noreply.github.com>

* fix: address Cubic code review comments

- PrismaAttributeRepository: use nested select instead of include: true for options
- CredentialRepository: use this.primaClient instead of global prisma, use select instead of include for relations
- AttributeSyncFieldMappingService: optimize O(n*m) complexity with Map lookup for O(1) access

Co-Authored-By: joe@cal.com <j.auyeung419@gmail.com>

* Fix typo in CredentialRepository

* Update README

* Update sfdx-project

* Add SFDC package tests

* fix: improve Salesforce Apex test assertions to verify actual behavior

- Enhanced CalComHttpMock to track HTTP callout invocations and capture requests
- Updated UserUpdateHandlerTest to verify HTTP callouts are made with correct data
- Updated CalComCalloutQueueableTest to verify HTTP callouts are made correctly
- Replaced System.assert(true, ...) with meaningful assertions that verify:
  - Correct number of HTTP callouts
  - Request body contains expected fields
  - Request method is POST

Addresses Cubic AI review feedback (confidence 9/10 issues only)

Co-Authored-By: unknown <>

---------

Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
Co-authored-by: Hariom Balhara <hariombalhara@gmail.com>
Co-authored-by: cubic-dev-ai[bot] <191113872+cubic-dev-ai[bot]@users.noreply.github.com>
2026-01-15 09:19:57 -05:00

521 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[]>
),
});
const attributeOptionRepository = new PrismaAttributeOptionRepository(prisma);
await attributeOptionRepository.createMany({
createManyInput: attributeOptionCreateManyInput,
});
// We need fetch all the attribute options to ensure that we have the newly created options as well.
const allAttributeOptions = (
await attributeOptionRepository.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 membershipRepository = new MembershipRepository();
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,
};
};