Files
calendar/packages/features/ee/integration-attribute-sync/services/IntegrationAttributeSyncService.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

260 lines
7.8 KiB
TypeScript

import type { CredentialRepository } from "@calcom/features/credentials/repositories/CredentialRepository";
import type { TeamRepository } from "@calcom/features/ee/teams/repositories/TeamRepository";
import type { ZCreateAttributeSyncSchema } from "@calcom/trpc/server/routers/viewer/attribute-sync/createAttributeSync.schema";
import { enabledAppSlugs } from "../constants";
import {
type IIntegrationAttributeSyncRepository,
type ISyncFormData,
type ITeamCondition,
type IAttributeSyncRule,
AttributeSyncIntegrations,
ConditionIdentifierEnum,
} from "../repositories/IIntegrationAttributeSyncRepository";
import { attributeSyncRuleSchema } from "../schemas/zod";
export class DuplicateAttributeWithinSyncError extends Error {
constructor(public attributeId: string) {
super("Attribute is already mapped in this sync");
this.name = "DuplicateAttributeWithinSyncError";
}
}
export class DuplicateAttributeAcrossSyncsError extends Error {
constructor(public attributeId: string) {
super("Attribute is already mapped in another sync");
this.name = "DuplicateAttributeAcrossSyncsError";
}
}
export class UnauthorizedAttributeError extends Error {
constructor(public attributeIds: string[]) {
super("One or more attributes do not belong to this organization");
this.name = "UnauthorizedAttributeError";
}
}
export class CredentialNotFoundError extends Error {
constructor() {
super("Credential not found");
this.name = "CredentialNotFoundError";
}
}
interface IIntegrationAttributeSyncServiceDeps {
credentialRepository: CredentialRepository;
integrationAttributeSyncRepository: IIntegrationAttributeSyncRepository;
teamRepository: TeamRepository;
}
export class IntegrationAttributeSyncService {
constructor(private readonly deps: IIntegrationAttributeSyncServiceDeps) {}
private extractTeamIdsFromRule(rule: IAttributeSyncRule): number[] {
return rule.conditions
.filter(
(c): c is ITeamCondition =>
c.identifier === ConditionIdentifierEnum.TEAM_ID
)
.flatMap((c) => c.value);
}
private async validateTeamsBelongToOrg(
teamIds: number[],
organizationId: number
): Promise<void> {
if (teamIds.length === 0) return;
const invalidTeams =
await this.deps.teamRepository.findTeamsNotBelongingToOrgByIds({
teamIds,
orgId: organizationId,
});
if (invalidTeams.length > 0) {
throw new Error(
`Teams do not belong to this organization: ${invalidTeams
.map((t) => t.id)
.join(", ")}`
);
}
}
private validateWithinSyncUniqueness(
mappings: { attributeId: string }[]
): void {
const seenAttributes = new Set<string>();
for (const mapping of mappings) {
if (seenAttributes.has(mapping.attributeId)) {
throw new DuplicateAttributeWithinSyncError(mapping.attributeId);
}
seenAttributes.add(mapping.attributeId);
}
}
private async validateCrossSyncUniqueness(
organizationId: number,
mappings: { attributeId: string }[],
excludeSyncId?: string
): Promise<void> {
const existingMappedIds =
await this.deps.integrationAttributeSyncRepository.getMappedAttributeIdsByOrganization(
organizationId,
excludeSyncId
);
const existingSet = new Set(existingMappedIds);
for (const mapping of mappings) {
if (existingSet.has(mapping.attributeId)) {
throw new DuplicateAttributeAcrossSyncsError(mapping.attributeId);
}
}
}
private async validateAttributeOwnership(
organizationId: number,
attributeIds: string[]
): Promise<void> {
const validAttributeIds =
await this.deps.integrationAttributeSyncRepository.getAttributeIdsByOrganization(
organizationId,
attributeIds
);
if (validAttributeIds.length !== attributeIds.length) {
const validSet = new Set(validAttributeIds);
const invalidIds = attributeIds.filter((id) => !validSet.has(id));
throw new UnauthorizedAttributeError(invalidIds);
}
}
async getEnabledAppCredentials(organizationId: number) {
return this.deps.credentialRepository.findByTeamIdAndSlugs({
teamId: organizationId,
slugs: enabledAppSlugs,
});
}
async getAllIntegrationAttributeSyncs(organizationId: number) {
return this.deps.integrationAttributeSyncRepository.getByOrganizationId(
organizationId
);
}
async getById(id: string) {
return this.deps.integrationAttributeSyncRepository.getById(id);
}
async createAttributeSync(
input: ZCreateAttributeSyncSchema,
organizationId: number
) {
const credential = await this.deps.credentialRepository.findByIdAndTeamId({
id: input.credentialId,
teamId: organizationId,
});
if (!credential) {
throw new CredentialNotFoundError();
}
const parsedRule = attributeSyncRuleSchema.parse(input.rule);
const teamIds = this.extractTeamIdsFromRule(parsedRule);
await this.validateTeamsBelongToOrg(teamIds, organizationId);
const integrationValue = credential.app?.slug || credential.type;
if (
!Object.values(AttributeSyncIntegrations).includes(
integrationValue as AttributeSyncIntegrations
)
) {
throw new Error(`Unsupported integration type: ${integrationValue}`);
}
this.validateWithinSyncUniqueness(input.syncFieldMappings);
await this.validateCrossSyncUniqueness(
organizationId,
input.syncFieldMappings
);
const attributeIds = input.syncFieldMappings.map((m) => m.attributeId);
await this.validateAttributeOwnership(organizationId, attributeIds);
return this.deps.integrationAttributeSyncRepository.create({
name: input.name,
organizationId,
integration: integrationValue as AttributeSyncIntegrations,
credentialId: input.credentialId,
enabled: input.enabled,
rule: parsedRule,
syncFieldMappings: input.syncFieldMappings,
});
}
async updateIncludeRulesAndMappings(data: ISyncFormData) {
const { syncFieldMappings, rule, ruleId, ...integrationAttributeSync } =
data;
this.validateWithinSyncUniqueness(syncFieldMappings);
await this.validateCrossSyncUniqueness(
data.organizationId,
syncFieldMappings,
data.id
);
const attributeIds = syncFieldMappings.map((m) => m.attributeId);
await this.validateAttributeOwnership(data.organizationId, attributeIds);
const existingFieldMappings =
await this.deps.integrationAttributeSyncRepository.getSyncFieldMappings(
data.id
);
const parsedRule = attributeSyncRuleSchema.parse(rule);
const teamIds = this.extractTeamIdsFromRule(parsedRule);
await this.validateTeamsBelongToOrg(teamIds, data.organizationId);
const incomingMappingIds = new Set(
syncFieldMappings.reduce((ids, mapping) => {
if ("id" in mapping) ids.push(mapping.id);
return ids;
}, [] as string[])
);
const fieldMappingsToDelete = existingFieldMappings
.filter((mapping) => !incomingMappingIds.has(mapping.id))
.map((mapping) => mapping.id);
const fieldMappingsToCreate = syncFieldMappings.filter((m) => !("id" in m));
const fieldMappingsToUpdate = syncFieldMappings.filter(
(m): m is typeof m & { id: string } => "id" in m
);
await this.deps.integrationAttributeSyncRepository.updateTransactionWithRuleAndMappings(
{
integrationAttributeSync,
attributeSyncRule: {
id: ruleId,
rule: parsedRule,
},
fieldMappingsToCreate,
fieldMappingsToUpdate,
fieldMappingsToDelete,
}
);
}
async deleteById(id: string) {
return this.deps.integrationAttributeSyncRepository.deleteById(id);
}
async getAllByCredentialId(credentialId: number) {
return this.deps.integrationAttributeSyncRepository.getAllByCredentialId(
credentialId
);
}
}