Skip to content

Comments

feat: Create Integration Attribute Sync records#26007

Merged
joeauyeung merged 92 commits intomainfrom
attribute-sync-ui
Jan 12, 2026
Merged

feat: Create Integration Attribute Sync records#26007
joeauyeung merged 92 commits intomainfrom
attribute-sync-ui

Conversation

@joeauyeung
Copy link
Contributor

@joeauyeung joeauyeung commented Dec 18, 2025

What does this PR do?

Adds an organization-level Attribute Sync feature that allows admins to create, edit, and delete integration attribute syncs with rules and field mappings. This enables syncing user attributes from third-party integrations (currently Salesforce) to Cal.com attributes.

https://www.loom.com/share/ddb1af433f5d4ce3a75cfbbacc680de1

Key changes:

  • New org settings page at /settings/organizations/attributes/sync with PBAC gating
  • TRPC endpoints for listing credentials, listing syncs, create/update/delete operations
  • New Prisma models: IntegrationAttributeSync, AttributeSyncRule, AttributeSyncFieldMapping
  • Rule Builder UI for user filters (team or attribute conditions with AND/OR logic)
  • Field Mapping Builder to map integration fields to Cal.com attributes with duplicate validation
  • Added findByTeamIdAndSlugs method to CredentialRepository
  • Extended FormCard component to support custom actions

Updates since last revision

Addressed cubic review feedback:

  • RuleBuilder.tsx: Use unique IDs (crypto.randomUUID()) for React keys instead of array index to prevent reconciliation bugs when removing conditions from the middle of the list
  • updateAttributeSync.handler.ts: Added early organization check before service calls for consistency with createAttributeSync handler
  • createAttributeSync.handler.ts: Added CredentialNotFoundError class for type-safe error handling instead of fragile string matching
  • IntegrationAttributeSyncService.test.ts: Replaced expect.fail() with proper Vitest rejects.toSatisfy() pattern

Previous updates:

  • Added comprehensive unit tests (45 tests) for IntegrationAttributeSyncService, AttributeSyncUserRuleOutputMapper, and Zod schema validation
  • Refactored attributeSyncRules array to singular attributeSyncRule to match the database schema's one-to-one relationship

Mandatory Tasks (DO NOT REMOVE)

  • I have self-reviewed the code (A decent size PR without self-review might be rejected).
  • I have updated the developer docs in /docs if this PR makes changes that would require a documentation change. N/A - internal feature
  • I confirm automated tests are in place that prove my fix is effective or that my feature works.

How should this be tested?

  1. Ensure you have an organization with attributes and teams set up
  2. Add a Salesforce credential to the organization
  3. Navigate to /settings/organizations/attributes/sync
  4. Test creating a new attribute sync:
    • Select a credential
    • Add rules (team or attribute conditions)
    • Add field mappings (integration field → Cal.com attribute)
    • Save and verify it appears in the list
  5. Test editing an existing sync
  6. Test deleting a sync

Prerequisites:

  • Organization with PBAC permissions enabled
  • At least one Salesforce credential connected to the org
  • Organization attributes and teams configured

Human Review Checklist

  • Verify PBAC permission check (organization.attributes.create) is working correctly
  • Verify database migrations run successfully
  • Test duplicate field mapping validation works as expected
  • Verify rule builder AND/OR logic is stored and retrieved correctly
  • Check that only enabled app credentials (Salesforce) appear in the dropdown
  • Verify the singular attributeSyncRule type works correctly when creating/editing syncs
  • Verify React key fix: removing conditions from the middle of the list should not cause state issues

Checklist

  • I have read the contributing guide
  • My code follows the style guidelines of this project
  • I have commented my code, particularly in hard-to-understand areas
  • I have checked if my changes generate no new warnings

Link to Devin run: https://app.devin.ai/sessions/888243402bd14cbd98b1cfe1c3a5424b
Requested by: joe@cal.com (joe@cal.com) (@joeauyeung)

"attribute_sync_credential_required": "Credential is required",
"attribute_sync_credential_validation": "Select a credential for the attribute sync",
"attribute_sync_select_credential": "Select a credential...",
"attribute_sync_credential_description": "Choose which integration credential to use for syncing attributes",
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why do we need to choose credential here? Can we not use the latest one or automatically decide from the list of credentials.

The problem with manual selection would be that if I somehow that credential is removed, syncing would stop or we might not be able to delete the credential because there are dependencies on it.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We don't want this to be dependent on just Salesforce. In the future we also want to expand this to other apps as well.

If a credential is removed then there are a whole host of other issues that aren't just unique to this. Also if the credential is removed, having it set as the target of the attribute sync won't be a blocker.

Comment on lines +16 to +27
const currentUserOrgId = ctx.user.organizationId;
const integrationAttributeSyncService = getIntegrationAttributeSyncService();

const integrationAttributeSync = await integrationAttributeSyncService.getById(input.id);

if (!integrationAttributeSync) throw new TRPCError({ code: "NOT_FOUND" });

if (integrationAttributeSync.organizationId !== currentUserOrgId) {
throw new TRPCError({ code: "UNAUTHORIZED" });
}
await integrationAttributeSyncService.deleteById(input.id);
};
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think we should bake permissions within the Service itself.

It allows easy integration with API V2 or any other controller, where the controller just provides the data and does no application logic.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I would argue against this because it feels like that violates the principle of single responsibility. It should be up to the main caller to check the permissions while the IntegrationAttributeSyncService should only focus on logic surrounding the attribute syncs.

credentialId: input.credentialId,
enabled: input.enabled,
rule: parsedRule,
syncFieldMappings: input.syncFieldMappings,
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We should also make sure probably that integrationFieldName is stored lowercase. AFAIU, salesfore custom field names are case-insensitive only

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Salesforce is case-insensitive but we might expand this to other integrations where that might not be the case. Ex. Hubspot is case-sensitive.

We can normalize when we receive a payload from Salesforce in #26517

Copy link
Member

@hariombalhara hariombalhara left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Left some suggestions !!

@github-actions github-actions bot marked this pull request as draft January 12, 2026 08:02
joeauyeung and others added 5 commits January 12, 2026 10:06
* feat: implement FeatureOptInService WIP

* clean up

* feat: consolidate feature repositories and add updateFeatureForUser

- Implement updateFeatureForUser in FeaturesRepository (similar to updateFeatureForTeam)
- Move getUserFeatureState and getTeamFeatureState from PrismaFeatureOptInRepository to FeaturesRepository
- Update FeatureOptInService to use only FeaturesRepository
- Add setUserFeatureState and setTeamFeatureState methods to FeatureOptInService
- Update _router.ts to remove PrismaFeatureOptInRepository usage
- Remove PrismaFeatureOptInRepository.ts and FeatureOptInRepositoryInterface.ts
- Update features.repository.interface.ts and features.repository.mock.ts
- Add integration tests for updateFeatureForUser, getUserFeatureState, getTeamFeatureState
- Update service.integration-test.ts to use FeaturesRepository

Co-Authored-By: eunjae@cal.com <hey@eunjae.dev>

* refactor: rename updateFeatureForUser to setUserFeatureState

Rename to match the convention used for setTeamFeatureState

Co-Authored-By: eunjae@cal.com <hey@eunjae.dev>

* refactor: return FeatureState type from getUserFeatureState and getTeamFeatureState

* fix integration tests

* clean up logics

* update services and router

* refactor: change getUserFeatureState and getTeamFeatureState to accept featureIds array

- Renamed getUserFeatureState to getUserFeatureStates
- Renamed getTeamFeatureState to getTeamFeatureStates
- Changed parameter from featureId: string to featureIds: string[]
- Changed return type from FeatureState to Record<string, FeatureState>
- Updated FeatureOptInService to use the new batch methods
- Added tests for querying multiple features in a single call
- Optimized listFeaturesForTeam to fetch all feature states in one query

Co-Authored-By: eunjae@cal.com <hey@eunjae.dev>

* feat: add getFeatureStateForTeams for batch querying multiple teams

- Added getFeatureStateForTeams method to query a single feature across multiple teams in one call
- Updated FeatureOptInService.resolveFeatureStateAcrossTeams to use the new batch method
- Replaces N+1 queries with a single database query for team states
- Added comprehensive integration tests for the new method

Co-Authored-By: eunjae@cal.com <hey@eunjae.dev>

* refactor: combine org and team state queries into single call

- Include orgId in the teamIds array passed to getFeatureStateForTeams
- Extract org state and team states from the combined result
- Reduces database queries from 3 to 2 in resolveFeatureStateAcrossTeams

Co-Authored-By: eunjae@cal.com <hey@eunjae.dev>

* refactor: use team.isOrganization and clarify computeEffectiveState comment

Co-Authored-By: eunjae@cal.com <hey@eunjae.dev>

* refactor: use MembershipRepository.findAllByUserId with isOrganization

Co-Authored-By: eunjae@cal.com <hey@eunjae.dev>

* feat: add featureId validation using isOptInFeature type guard

Co-Authored-By: eunjae@cal.com <hey@eunjae.dev>

* less queries

* add fallback value

* fix type error

* move files

* add autoOptInFeatures column

* use autoOptInFeatures flag within FeatureOptInService

* add setUserAutoOptIn and setTeamAutoOptIn

* fix computeEffectiveState logic

* rewrite computeEffectiveState

* clean up integration tests

* clean up in afterEach

* fix type error

* refactor: use FeaturesRepository methods instead of direct Prisma calls

Replace all manual userFeatures and teamFeatures Prisma operations with
the new setUserFeatureState and setTeamFeatureState repository methods.

Changes include:
- Admin handlers (assignFeatureToTeam, unassignFeatureFromTeam)
- Test fixtures and integration tests
- Playwright fixtures
- Development scripts

This ensures consistent feature flag management through the repository
pattern and supports the new tri-state semantics (enabled/disabled/inherit).

Co-Authored-By: eunjae@cal.com <hey@eunjae.dev>

* clean up

* fix the logic

* extract some logic into applyAutoOptIn()

* remove wrong code

* refactor: convert setUserFeatureState and setTeamFeatureState to object params with discriminated union

- Convert multiple positional parameters to single object parameter
- Use discriminated union types: assignedBy required for enabled/disabled, omitted for inherit
- Update all callers across repository, service, handlers, fixtures, and tests

* fix type error

* use Promise.all

* fix

---------

Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
@joeauyeung joeauyeung marked this pull request as ready for review January 12, 2026 19:06
Copy link
Contributor

@cubic-dev-ai cubic-dev-ai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

6 issues found across 41 files

Prompt for AI agents (all issues)

Check if these issues are valid — if so, understand the root cause of each and fix them.


<file name="packages/features/ee/integration-attribute-sync/components/RuleBuilder.tsx">

<violation number="1" location="packages/features/ee/integration-attribute-sync/components/RuleBuilder.tsx:459">
P2: Using `index` as `key` in a list that supports removal can cause React reconciliation bugs. When a condition is removed from the middle, subsequent conditions shift indices and may retain incorrect internal state. Consider generating a unique ID when creating conditions (e.g., using `crypto.randomUUID()` or a counter).</violation>
</file>

<file name="packages/prisma/schema.prisma">

<violation number="1" location="packages/prisma/schema.prisma:3128">
P2: Missing index on `credentialId` in `IntegrationAttributeSync`. The schema consistently indexes `credentialId` on other models with credential relations (DestinationCalendar, SelectedCalendar, CalendarCache). Add `@@index([credentialId])` for efficient cascade deletes and credential-based queries.</violation>
</file>

<file name="packages/trpc/server/routers/viewer/attribute-sync/updateAttributeSync.handler.ts">

<violation number="1" location="packages/trpc/server/routers/viewer/attribute-sync/updateAttributeSync.handler.ts:23">
P2: Add an early organization check before making service calls. The `createAttributeSync.handler.ts` validates the user has an organization before any service call, but this handler calls `getById` first. Following the early return pattern would be more efficient and provide a better error message for users without an organization.</violation>
</file>

<file name="packages/features/ee/integration-attribute-sync/components/NewIntegrationAttributeSyncCard.tsx">

<violation number="1" location="packages/features/ee/integration-attribute-sync/components/NewIntegrationAttributeSyncCard.tsx:30">
P2: Translation key `credential_required` does not exist. Use `attribute_sync_credential_required` to match the parent component and the existing locale definition.</violation>
</file>

<file name="packages/trpc/server/routers/viewer/attribute-sync/createAttributeSync.handler.ts">

<violation number="1" location="packages/trpc/server/routers/viewer/attribute-sync/createAttributeSync.handler.ts:40">
P2: String matching on error messages is fragile. The service already defines custom error classes (`DuplicateAttributeWithinSyncError`, `DuplicateAttributeAcrossSyncsError`). Consider adding a `CredentialNotFoundError` class for consistency and reliability. If the error message text changes in the service, this catch block will silently fail.</violation>
</file>

<file name="packages/features/ee/integration-attribute-sync/services/IntegrationAttributeSyncService.test.ts">

<violation number="1" location="packages/features/ee/integration-attribute-sync/services/IntegrationAttributeSyncService.test.ts:397">
P2: `expect.fail()` is not a standard Vitest API and will throw a TypeError at runtime. If the service doesn't throw as expected, this causes confusing test failures. Consider throwing an Error directly or using the `rejects.toSatisfy()` pattern to check error properties.</violation>
</file>

Reply with feedback, questions, or to request a fix. Tag @cubic-dev-ai to re-run a review.

@github-actions
Copy link
Contributor

Devin AI is addressing Cubic AI's review feedback

New feedback has been sent to the existing Devin session.

View Devin Session

devin-ai-integration bot and others added 3 commits January 12, 2026 19:17
- Add @@index([credentialId]) to IntegrationAttributeSync model for efficient cascade deletes and credential-based queries (confidence: 9/10)
- Fix translation key from 'credential_required' to 'attribute_sync_credential_required' to match existing locale definition (confidence: 10/10)

Co-Authored-By: joe@cal.com <j.auyeung419@gmail.com>
- RuleBuilder.tsx: Use unique IDs for React keys instead of array index to prevent reconciliation bugs when removing conditions
- updateAttributeSync.handler.ts: Add early organization check before service calls for consistency
- createAttributeSync.handler.ts: Add CredentialNotFoundError class for type-safe error handling instead of string matching
- IntegrationAttributeSyncService.test.ts: Replace expect.fail() with proper Vitest rejects.toSatisfy() pattern

Co-Authored-By: joe@cal.com <j.auyeung419@gmail.com>
@joeauyeung joeauyeung enabled auto-merge (squash) January 12, 2026 22:44
@joeauyeung joeauyeung dismissed hariombalhara’s stale review January 12, 2026 22:44

Addressed comments and reviewed by Volnei

@joeauyeung joeauyeung merged commit 8dbe384 into main Jan 12, 2026
74 of 77 checks passed
@joeauyeung joeauyeung deleted the attribute-sync-ui branch January 12, 2026 22:57
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

core area: core, team members only enterprise area: enterprise, audit log, organisation, SAML, SSO ❗️ migrations contains migration files ready-for-e2e size/XXL

Projects

None yet

Development

Successfully merging this pull request may close these issues.

5 participants