* 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>
272 lines
8.1 KiB
TypeScript
272 lines
8.1 KiB
TypeScript
import { PrismaAttributeOptionRepository } from "@calcom/features/attributes/repositories/PrismaAttributeOptionRepository";
|
|
import {
|
|
buildSlugFromValue,
|
|
canSetValueBeyondOptions,
|
|
doesSupportMultipleValues,
|
|
hasOptions,
|
|
} from "@calcom/features/ee/dsync/lib/assignValueToUserUtils";
|
|
import logger from "@calcom/lib/logger";
|
|
import { PrismaAttributeRepository } from "@calcom/features/attributes/repositories/PrismaAttributeRepository";
|
|
import { PrismaAttributeToUserRepository } from "@calcom/features/attributes/repositories/PrismaAttributeToUserRepository";
|
|
import type { MembershipRepository } from "@calcom/features/membership/repositories/MembershipRepository";
|
|
import type { IFieldMapping } from "../repositories/IIntegrationAttributeSyncRepository";
|
|
import type { AttributeType } from "@calcom/prisma/enums";
|
|
|
|
const log = logger.getSubLogger({
|
|
prefix: ["[AttributeSyncFieldMappingService]"],
|
|
});
|
|
|
|
interface IAttributeSyncFieldMappingServiceDeps {
|
|
attributeToUserRepository: PrismaAttributeToUserRepository;
|
|
attributeRepository: PrismaAttributeRepository;
|
|
attributeOptionRepository: PrismaAttributeOptionRepository;
|
|
membershipRepository: MembershipRepository;
|
|
}
|
|
|
|
export class AttributeSyncFieldMappingService {
|
|
constructor(private readonly deps: IAttributeSyncFieldMappingServiceDeps) {}
|
|
|
|
async syncIntegrationFieldsToAttributes({
|
|
userId,
|
|
organizationId,
|
|
syncFieldMappings,
|
|
integrationFields,
|
|
}: {
|
|
userId: number;
|
|
organizationId: number;
|
|
syncFieldMappings: IFieldMapping[];
|
|
integrationFields: Record<string, string>;
|
|
}): Promise<void> {
|
|
const membership = await this.deps.membershipRepository.findUniqueByUserIdAndTeamId({
|
|
userId,
|
|
teamId: organizationId,
|
|
});
|
|
|
|
if (!membership) {
|
|
log.warn(
|
|
`No membership found for user ${userId} in org ${organizationId}`
|
|
);
|
|
return;
|
|
}
|
|
|
|
const memberId = membership.id;
|
|
|
|
const enabledSyncFieldMappings = syncFieldMappings.filter(
|
|
(mapping) =>
|
|
mapping.enabled &&
|
|
integrationFields[mapping.integrationFieldName] !== undefined
|
|
);
|
|
|
|
if (enabledSyncFieldMappings.length === 0) {
|
|
log.warn("No enabled mappings with matching integration fields");
|
|
return;
|
|
}
|
|
|
|
const attributeIds = enabledSyncFieldMappings.map((m) => m.attributeId);
|
|
|
|
const attributes =
|
|
await this.deps.attributeRepository.findManyByIdsAndOrgIdWithOptions({
|
|
attributeIds,
|
|
orgId: organizationId,
|
|
});
|
|
|
|
const attributeMap = new Map(attributes.map((a) => [a.id, a]));
|
|
|
|
const { attributeIdsToSync, optionsToCreate, assignmentsToCreate } =
|
|
this.processMappings({
|
|
enabledMappings: enabledSyncFieldMappings,
|
|
attributeMap,
|
|
integrationFields,
|
|
memberId,
|
|
orgId: organizationId,
|
|
});
|
|
|
|
if (optionsToCreate.length > 0) {
|
|
const newAssignments = await this.createOptionsAndGetAssignments({
|
|
optionsToCreate,
|
|
memberId,
|
|
orgId: organizationId,
|
|
});
|
|
assignmentsToCreate.push(...newAssignments);
|
|
}
|
|
|
|
// Delete old assignments before creating new ones
|
|
if (attributeIdsToSync.length > 0) {
|
|
await this.deps.attributeToUserRepository.deleteMany({
|
|
memberId,
|
|
attributeOption: {
|
|
attributeId: { in: attributeIdsToSync },
|
|
},
|
|
});
|
|
}
|
|
|
|
if (assignmentsToCreate.length > 0) {
|
|
await this.deps.attributeToUserRepository.createManySkipDuplicates(assignmentsToCreate);
|
|
|
|
log.info(
|
|
`Synced ${assignmentsToCreate.length} attribute(s) for member ${memberId}`
|
|
);
|
|
}
|
|
}
|
|
|
|
private processMappings({
|
|
enabledMappings,
|
|
attributeMap,
|
|
integrationFields,
|
|
memberId,
|
|
orgId,
|
|
}: {
|
|
enabledMappings: IFieldMapping[];
|
|
attributeMap: Map<
|
|
string,
|
|
{
|
|
id: string;
|
|
name: string;
|
|
type: AttributeType;
|
|
options: { id: string; value: string; slug: string }[];
|
|
}
|
|
>;
|
|
integrationFields: Record<string, unknown>;
|
|
memberId: number;
|
|
orgId: number;
|
|
}): {
|
|
attributeIdsToSync: string[];
|
|
optionsToCreate: Array<{
|
|
attributeId: string;
|
|
value: string;
|
|
slug: string;
|
|
}>;
|
|
assignmentsToCreate: Array<{ memberId: number; attributeOptionId: string }>;
|
|
} {
|
|
const attributeIdsToSync: string[] = [];
|
|
const optionsToCreate: Array<{
|
|
attributeId: string;
|
|
value: string;
|
|
slug: string;
|
|
}> = [];
|
|
const assignmentsToCreate: Array<{
|
|
memberId: number;
|
|
attributeOptionId: string;
|
|
}> = [];
|
|
|
|
for (const mapping of enabledMappings) {
|
|
const attribute = attributeMap.get(mapping.attributeId);
|
|
if (!attribute) {
|
|
log.warn(`Attribute ${mapping.attributeId} not found for org ${orgId}`);
|
|
continue;
|
|
}
|
|
|
|
const rawFieldValue = String(
|
|
integrationFields[mapping.integrationFieldName]
|
|
);
|
|
|
|
if (hasOptions({ attribute })) {
|
|
// SINGLE_SELECT / MULTI_SELECT - must find existing option
|
|
|
|
const isMultiSelect = doesSupportMultipleValues({ attribute });
|
|
|
|
// For MULTI_SELECT, process all comma-separated values; for SINGLE_SELECT, take the first only
|
|
const fieldValues = isMultiSelect
|
|
? rawFieldValue
|
|
.split(",")
|
|
.map((v) => v.trim())
|
|
.filter(Boolean)
|
|
: [rawFieldValue.split(",")[0].trim()].filter(Boolean);
|
|
|
|
// Always mark for sync so old assignments get cleared (even if field is blank)
|
|
attributeIdsToSync.push(attribute.id);
|
|
|
|
for (const fieldValue of fieldValues) {
|
|
const matchingOption = attribute.options.find(
|
|
(opt) => opt.value.toLowerCase() === fieldValue.toLowerCase()
|
|
);
|
|
|
|
if (!matchingOption) {
|
|
log.warn(
|
|
`No matching option for value "${fieldValue}" in attribute ${attribute.id} (${attribute.name})`
|
|
);
|
|
continue;
|
|
}
|
|
|
|
assignmentsToCreate.push({
|
|
memberId,
|
|
attributeOptionId: matchingOption.id,
|
|
});
|
|
}
|
|
} else if (canSetValueBeyondOptions({ attribute })) {
|
|
// TEXT / NUMBER - find existing option or create new one
|
|
// For TEXT/NUMBER, use first value if comma-separated
|
|
const fieldValue = rawFieldValue.split(",")[0].trim();
|
|
const existingOption = attribute.options.find(
|
|
(opt) => opt.value.toLowerCase() === fieldValue.toLowerCase()
|
|
);
|
|
|
|
attributeIdsToSync.push(attribute.id);
|
|
|
|
if (existingOption) {
|
|
assignmentsToCreate.push({
|
|
memberId,
|
|
attributeOptionId: existingOption.id,
|
|
});
|
|
} else {
|
|
// Need to create a new option
|
|
optionsToCreate.push({
|
|
attributeId: attribute.id,
|
|
value: fieldValue,
|
|
slug: buildSlugFromValue({ value: fieldValue }),
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
return { attributeIdsToSync, optionsToCreate, assignmentsToCreate };
|
|
}
|
|
|
|
private async createOptionsAndGetAssignments({
|
|
optionsToCreate,
|
|
memberId,
|
|
orgId,
|
|
}: {
|
|
optionsToCreate: Array<{
|
|
attributeId: string;
|
|
value: string;
|
|
slug: string;
|
|
}>;
|
|
memberId: number;
|
|
orgId: number;
|
|
}): Promise<Array<{ memberId: number; attributeOptionId: string }>> {
|
|
await this.deps.attributeOptionRepository.createMany({
|
|
createManyInput: optionsToCreate,
|
|
});
|
|
|
|
const allOptions = await this.deps.attributeOptionRepository.findMany({
|
|
orgId,
|
|
});
|
|
|
|
const optionLookup = new Map(
|
|
allOptions.map((o) => [`${o.attributeId}:${o.value.toLowerCase()}`, o])
|
|
);
|
|
|
|
const assignments: Array<{ memberId: number; attributeOptionId: string }> =
|
|
[];
|
|
|
|
for (const newOption of optionsToCreate) {
|
|
const key = `${newOption.attributeId}:${newOption.value.toLowerCase()}`;
|
|
const createdOption = optionLookup.get(key);
|
|
|
|
if (createdOption) {
|
|
assignments.push({
|
|
memberId,
|
|
attributeOptionId: createdOption.id,
|
|
});
|
|
} else {
|
|
log.error(
|
|
`Failed to find newly created option for attribute ${newOption.attributeId}`
|
|
);
|
|
}
|
|
}
|
|
|
|
return assignments;
|
|
}
|
|
}
|