feat: Create Integration Attribute Sync records (#26007)

* 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 indexes

* Add aria label

* Address feedback - consistent validation

* Fix import paths for attribute types

* 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>

* feat: implement FeatureOptInService (#25805)

* 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>

* Prevent duplicate field and attribute mappings

* Add validation that attribute belongs to the org

* fix: address Cubic AI review feedback

- 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>

* fix: address remaining cubic review feedback

- 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>

* Pull test file from main

---------

Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
Co-authored-by: Eunjae Lee <hey@eunjae.dev>
This commit is contained in:
Joe Au-Yeung
2026-01-12 22:57:28 +00:00
committed by GitHub
co-authored by joe@cal.com Devin AI Eunjae Lee
parent 9202028ab6
commit 8dbe3848e7
40 changed files with 3524 additions and 19 deletions
@@ -0,0 +1,62 @@
import { _generateMetadata, getTranslate } from "app/_utils";
import IntegrationAttributeSyncView from "@calcom/features/ee/integration-attribute-sync/IntegrationAttributeSyncView";
import { getIntegrationAttributeSyncService } from "@calcom/features/ee/integration-attribute-sync/di/IntegrationAttributeSyncService.container";
import { TeamRepository } from "@calcom/features/ee/teams/repositories/TeamRepository";
import SettingsHeader from "@calcom/features/settings/appDir/SettingsHeader";
import { PrismaAttributeRepository } from "@calcom/features/attributes/repositories/PrismaAttributeRepository";
import { prisma } from "@calcom/prisma";
import { validateUserHasOrgPerms } from "../../../actions/validateUserHasOrgPerms";
export const generateMetadata = async () =>
await _generateMetadata(
(t) => t("attribute_sync"),
(t) => t("attribute_sync_description"),
undefined,
undefined,
"/settings/organizations/attributes/sync"
);
const Page = async () => {
const t = await getTranslate();
const session = await validateUserHasOrgPerms({
permission: "organization.attributes.create",
fallbackRoles: ["ADMIN", "OWNER"],
redirectTo: "/settings/my-account/profile",
});
const organizationId = session.user.org.id;
const integrationAttributeSyncService = getIntegrationAttributeSyncService();
const teamRepository = new TeamRepository(prisma);
const attributeRepo = new PrismaAttributeRepository(prisma);
const [credentialData, integrationAttributeSyncs, organizationTeams, attributes] = await Promise.all([
integrationAttributeSyncService.getEnabledAppCredentials(organizationId),
integrationAttributeSyncService.getAllIntegrationAttributeSyncs(organizationId),
teamRepository.findAllByParentId({
parentId: organizationId,
select: {
id: true,
name: true,
},
}),
attributeRepo.findAllByOrgIdWithOptions({ orgId: organizationId }),
]);
return (
<SettingsHeader title={t("attribute_sync")} description={t("attribute_sync_description")}>
<IntegrationAttributeSyncView
credentialsData={credentialData}
initialIntegrationAttributeSyncs={integrationAttributeSyncs}
organizationTeams={organizationTeams}
attributes={attributes}
organizationId={organizationId}
/>
</SettingsHeader>
);
};
export default Page;
@@ -0,0 +1,4 @@
import { createNextApiHandler } from "@calcom/trpc/server/createNextApiHandler";
import { attributeSyncRouter } from "@calcom/trpc/server/routers/viewer/attribute-sync/_router";
export default createNextApiHandler(attributeSyncRouter);
@@ -374,6 +374,7 @@
"done": "Done",
"all_done": "All done!",
"all": "All",
"any": "Any",
"yours": "Your account",
"available_apps": "Available Apps",
"available_apps_lower_case": "Available apps",
@@ -3101,6 +3102,47 @@
"back_to_attributes": "Back to attributes",
"delete_attribute": "Are you sure you want to delete this option?",
"delete_attribute_description": "This option is assigned to {{numberOfUsers}} members. Deleting it will remove it from their profile.",
"attribute_sync": "Attribute Sync",
"attribute_sync_description": "Setup attribute syncing with 3rd party integrations",
"attribute_sync_new_integration_sync": "New Integration Sync",
"attribute_sync_credential": "Credential",
"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",
"attribute_sync_user_filter_rules": "User Filter Rules",
"attribute_sync_user_filter_rules_description": "Define which users to sync based on team membership or attributes",
"attribute_sync_field_mappings": "Field Mappings",
"attribute_sync_duplicate_field_mapping": "Duplicate field name and attribute combination found",
"attribute_sync_duplicate_attribute_mapping": "Each attribute can only be mapped once",
"attribute_sync_field_mappings_description": "Map integration field names to Cal.com attributes for syncing",
"attribute_sync_created_successfully": "Attribute sync created successfully",
"attribute_sync_updated_successfully": "Attribute sync updated successfully",
"attribute_sync_deleted_successfully": "Attribute sync deleted successfully",
"attribute_sync_delete_title": "Delete Attribute Sync",
"attribute_sync_delete_confirmation": "Are you sure you want to delete this sync? This action cannot be undone.",
"attribute_sync_no_mappings": "No field mappings added. Click below to add your first mapping.",
"attribute_sync_add_mapping": "Add mapping",
"attribute_sync_field_placeholder": "e.g., Department, Title...",
"attribute_sync_select_attribute": "Select Cal.com attribute...",
"attribute_sync_add_new": "Add new sync",
"attribute_sync_select_teams": "Select team(s)...",
"attribute_sync_select_attribute_first": "Select an attribute first",
"attribute_sync_select_values": "Select value(s)...",
"attribute_sync_enter_text_value": "Enter text value...",
"attribute_sync_enter_number_value": "Enter number value...",
"attribute_sync_sync_users_where": "Sync users where",
"attribute_sync_conditions_match": "conditions match:",
"attribute_sync_no_conditions": "No conditions added. Click below to add your first condition.",
"attribute_sync_add_condition": "Add condition",
"attribute_sync_operator_is_any_of": "is any of",
"attribute_sync_operator_is_not_any_of": "is not any of",
"attribute_sync_operator_is": "is",
"attribute_sync_operator_is_not": "is not",
"attribute_sync_operator_includes_any_of": "includes any of",
"attribute_sync_operator_does_not_include": "does not include",
"attribute_sync_operator_equals": "equals",
"attribute_sync_operator_not_equals": "not equals",
"disable_all_emails_to_attendees": "Disable standard emails to attendees related to this event type",
"disable_all_emails_description": "Disables standard email communication related to this event type, including booking confirmations, reminders, and cancellations.",
"disable_all_emails_to_hosts": "Disable standard emails to hosts related to this event type",
@@ -300,4 +300,33 @@ export class CredentialRepository {
},
});
}
findByTeamIdAndSlugs({ teamId, slugs }: { teamId: number; slugs: string[] }) {
return this.primaClient.credential.findMany({
where: {
teamId,
appId: {
in: slugs,
},
},
select: { ...safeCredentialSelect, team: { select: { name: true } } },
});
}
findByIdAndTeamId({ id, teamId }: { id: number; teamId: number }) {
return this.primaClient.credential.findFirst({
where: {
id,
teamId,
},
select: {
...safeCredentialSelect,
app: {
select: {
slug: true,
},
},
},
});
}
}
@@ -0,0 +1,106 @@
"use client";
import { useState } from "react";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import type { Attribute } from "@calcom/app-store/routing-forms/types/types";
import { trpc } from "@calcom/trpc/react";
import { Button } from "@calcom/ui/components/button";
import EditIntegrationAttributeSyncCard from "./components/EditIntegrationAttributeSyncCard";
import NewIntegrationAttributeSyncCard from "./components/NewIntegrationAttributeSyncCard";
import type { IntegrationAttributeSync } from "./repositories/IIntegrationAttributeSyncRepository";
interface IIntegrationAttributeSyncViewProps {
credentialsData: {
id: number;
type: string;
team: {
name: string;
} | null;
}[];
initialIntegrationAttributeSyncs: IntegrationAttributeSync[];
organizationTeams: {
id: number;
name: string;
}[];
attributes: Attribute[];
organizationId: number;
}
const IntegrationAttributeSyncView = (props: IIntegrationAttributeSyncViewProps) => {
const { credentialsData, initialIntegrationAttributeSyncs, organizationTeams, attributes, organizationId } =
props;
const { t } = useLocale();
const [showNewSync, setShowNewSync] = useState(false);
const { data: integrationAttributeSyncs } = trpc.viewer.attributeSync.getAllAttributeSyncs.useQuery(
{},
{
initialData: initialIntegrationAttributeSyncs,
}
);
const credentialOptions =
credentialsData?.map((cred) => ({
value: String(cred.id),
label: `${cred.type} ${cred.team?.name ? `(${cred.team.name})` : ""}`,
})) ?? [];
const teamOptions =
organizationTeams?.map((team) => ({
value: String(team.id),
label: team.name,
})) ?? [];
const attributeOptions =
attributes?.map((attr) => ({
value: attr.id,
label: attr.name,
})) ?? [];
const handleAddNewSync = () => {
setShowNewSync(true);
};
const handleCancel = () => {
setShowNewSync(false);
};
return (
<>
{integrationAttributeSyncs && integrationAttributeSyncs.length > 0 && (
<div className="mb-4 space-y-2">
{integrationAttributeSyncs.map((sync) => (
<EditIntegrationAttributeSyncCard
key={sync.id}
sync={sync}
credentialOptions={credentialOptions}
teamOptions={teamOptions}
attributes={attributes}
attributeOptions={attributeOptions}
organizationId={organizationId}
/>
))}
</div>
)}
{showNewSync && (
<NewIntegrationAttributeSyncCard
credentialOptions={credentialOptions}
teamOptions={teamOptions}
attributes={attributes}
attributeOptions={attributeOptions}
organizationId={organizationId}
onCancel={handleCancel}
/>
)}
<Button StartIcon="plus" onClick={handleAddNewSync}>
{t("attribute_sync_add_new")}
</Button>
</>
);
};
export default IntegrationAttributeSyncView;
@@ -0,0 +1,57 @@
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { trpc } from "@calcom/trpc/react";
import { showToast } from "@calcom/ui/components/toast";
import type { IntegrationAttributeSync, ISyncFormData } from "../repositories/IIntegrationAttributeSyncRepository";
import IntegrationAttributeSyncCard from "./IntegrationAttributeSyncCard";
import type { IIntegrationAttributeSyncCardProps } from "./IntegrationAttributeSyncCard";
type IEditIntegrationAttributeSyncCardProps = Pick<
IIntegrationAttributeSyncCardProps,
"credentialOptions" | "teamOptions" | "attributes" | "organizationId" | "attributeOptions"
> & {
sync: IntegrationAttributeSync;
};
const EditIntegrationAttributeSyncCard = (props: IEditIntegrationAttributeSyncCardProps) => {
const { t } = useLocale();
const utils = trpc.useUtils();
const updateMutation = trpc.viewer.attributeSync.updateAttributeSync.useMutation({
onSuccess: () => {
utils.viewer.attributeSync.getAllAttributeSyncs.invalidate();
showToast(t("attribute_sync_updated_successfully"), "success");
},
onError: (error) => {
showToast(error.message, "error");
},
});
const deleteMutation = trpc.viewer.attributeSync.deleteAttributeSync.useMutation({
onSuccess: () => {
utils.viewer.attributeSync.getAllAttributeSyncs.invalidate();
showToast(t("attribute_sync_deleted_successfully"), "success");
},
onError: (error) => {
showToast(error.message, "error");
},
});
const onSubmit = (data: ISyncFormData) => {
updateMutation.mutate({ ...data });
};
const onDelete = () => {
deleteMutation.mutate({ id: props.sync.id });
};
return (
<IntegrationAttributeSyncCard
{...props}
onSubmit={onSubmit}
onDelete={onDelete}
isSubmitting={updateMutation.isPending}
/>
);
};
export default EditIntegrationAttributeSyncCard;
@@ -0,0 +1,196 @@
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { Button } from "@calcom/ui/components/button";
import { Input, Select, Switch } from "@calcom/ui/components/form";
import { Icon } from "@calcom/ui/components/icon";
import { getDefaultFieldMapping } from "../lib/fieldMappingHelpers";
import type {
IFieldMappingFormState,
IFieldMappingWithOptionalId,
} from "../repositories/IIntegrationAttributeSyncRepository";
type AttributeOptions = {
label: string;
value: string;
}[];
interface FieldMappingBuilderProps {
value: IFieldMappingFormState;
onChange: (value: IFieldMappingFormState) => void;
attributeOptions: AttributeOptions;
isLoadingAttributes?: boolean;
}
interface MappingRowProps {
mapping: IFieldMappingWithOptionalId;
onChange: (mapping: IFieldMappingWithOptionalId) => void;
onRemove: () => void;
attributeOptions: AttributeOptions;
isLoading?: boolean;
error?: string;
}
const MappingRow = ({ mapping, onChange, onRemove, attributeOptions, isLoading, error }: MappingRowProps) => {
const { t } = useLocale();
const selectedAttribute = attributeOptions.find((opt) => opt.value === mapping.attributeId);
const handleFieldNameChange = (e: React.ChangeEvent<HTMLInputElement>) => {
onChange({
...mapping,
integrationFieldName: e.target.value,
});
};
const handleAttributeChange = (selected: { value: string; label: string } | null) => {
if (!selected) return;
onChange({
...mapping,
attributeId: selected.value,
});
};
const handleEnabledChange = (checked: boolean) => {
onChange({
...mapping,
enabled: checked,
});
};
return (
<div>
<div
className={`bg-default border-subtle flex flex-wrap items-center gap-3 rounded-lg border p-3 ${error ? "border-red-500" : ""}`}>
<div className="flex-1" style={{ minWidth: "200px" }}>
<Input
type="text"
value={mapping.integrationFieldName}
onChange={handleFieldNameChange}
disabled={isLoading}
placeholder={t("attribute_sync_field_placeholder")}
className="h-7 text-sm"
/>
</div>
<Icon name="arrow-right" className="text-subtle h-4 w-4" />
<div className="flex-1" style={{ minWidth: "200px" }}>
<Select
size="sm"
placeholder={t("attribute_sync_select_attribute")}
options={attributeOptions}
value={selectedAttribute}
onChange={handleAttributeChange}
isLoading={isLoading}
isDisabled={isLoading}
className="w-full"
/>
</div>
<Switch checked={mapping.enabled} onCheckedChange={handleEnabledChange} disabled={isLoading} />
<Button
color="minimal"
size="sm"
StartIcon="trash-2"
onClick={onRemove}
disabled={isLoading}
className="text-subtle hover:text-default"
aria-label={t("remove")}
/>
</div>
{error && <p className="mt-1 text-xs text-red-600">{error}</p>}
</div>
);
};
export const FieldMappingBuilder = ({
value,
onChange,
attributeOptions,
isLoadingAttributes,
}: FieldMappingBuilderProps) => {
const { t } = useLocale();
// Find indices of rows with duplicate attribute mappings
const duplicateIndices = new Set<number>();
const seenAttributes = new Map<string, number>(); // attributeId -> first index
value.mappings.forEach((mapping, index) => {
if (mapping.attributeId) {
const firstIndex = seenAttributes.get(mapping.attributeId);
if (firstIndex !== undefined) {
// Mark both the first occurrence and current as duplicates
duplicateIndices.add(firstIndex);
duplicateIndices.add(index);
} else {
seenAttributes.set(mapping.attributeId, index);
}
}
});
const handleAddMapping = () => {
onChange({
...value,
mappings: [...value.mappings, getDefaultFieldMapping()],
});
};
const handleMappingChange = (index: number, newMapping: IFieldMappingWithOptionalId) => {
const newMappings = [...value.mappings];
newMappings[index] = newMapping;
onChange({
...value,
mappings: newMappings,
});
};
const handleRemoveMapping = (index: number) => {
onChange({
...value,
mappings: value.mappings.filter((_, i) => i !== index),
});
};
return (
<div className="bg-default border-subtle rounded-2xl border p-2">
<div className="ml-2 flex items-center gap-0.5">
<div className="border-subtle rounded-lg border p-1">
<Icon name="link" className="text-subtle h-4 w-4" />
</div>
<span className="text-emphasis ml-2 text-sm font-medium">{t("attribute_sync_field_mappings")}</span>
</div>
<div className="bg-muted mt-2 space-y-2 rounded-xl p-2">
{value.mappings.length === 0 ? (
<div className="text-subtle py-6 text-center text-sm">{t("attribute_sync_no_mappings")}</div>
) : (
value.mappings.map((mapping, index) => (
<MappingRow
key={mapping.id || `new-${index}`}
mapping={mapping}
onChange={(newMapping) => handleMappingChange(index, newMapping)}
onRemove={() => handleRemoveMapping(index)}
attributeOptions={attributeOptions}
isLoading={isLoadingAttributes}
error={
duplicateIndices.has(index)
? t("attribute_sync_duplicate_attribute_mapping")
: undefined
}
/>
))
)}
</div>
<Button
color="minimal"
size="sm"
StartIcon="plus"
className="mt-2"
onClick={handleAddMapping}
disabled={isLoadingAttributes}>
{t("attribute_sync_add_mapping")}
</Button>
</div>
);
};
@@ -0,0 +1,312 @@
import { useEffect, useState } from "react";
import { useForm, Controller } from "react-hook-form";
import { Dialog } from "@calcom/features/components/controlled-dialog";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import type { Attribute } from "@calcom/app-store/routing-forms/types/types";
import { Button } from "@calcom/ui/components/button";
import { FormCard, FormCardBody } from "@calcom/ui/components/card";
import { ConfirmationDialogContent } from "@calcom/ui/components/dialog";
import { SelectField, Switch } from "@calcom/ui/components/form";
import { Icon } from "@calcom/ui/components/icon";
import {
type IntegrationAttributeSync,
type ISyncFormData,
RuleOperatorEnum,
} from "../repositories/IIntegrationAttributeSyncRepository";
import { FieldMappingBuilder } from "./FieldMappingBuilder";
import { RuleBuilder } from "./RuleBuilder";
export interface IIntegrationAttributeSyncCardProps {
sync?: IntegrationAttributeSync; // Optional for create mode
credentialOptions: {
value: string;
label: string;
}[];
teamOptions: {
value: string;
label: string;
}[];
attributes: Attribute[];
attributeOptions: {
value: string;
label: string;
}[];
organizationId: number;
onSubmit: (data: ISyncFormData) => void;
isSubmitting: boolean;
onCancel?: () => void;
onDelete?: () => void;
}
const IntegrationAttributeSyncCard = (
props: IIntegrationAttributeSyncCardProps
) => {
const {
sync,
credentialOptions,
teamOptions,
attributes,
attributeOptions,
organizationId,
onSubmit,
onCancel,
onDelete,
isSubmitting,
} = props;
const { t } = useLocale();
const isCreateMode = !sync;
const [showDeleteDialog, setShowDeleteDialog] = useState(false);
// Initialize form with sync data or defaults
const form = useForm<ISyncFormData>({
defaultValues: sync
? {
id: sync.id,
name: sync.name,
credentialId: sync.credentialId ?? undefined,
enabled: sync.enabled,
organizationId: sync.organizationId,
ruleId: sync.attributeSyncRule?.id || "",
rule: sync.attributeSyncRule?.rule || {
operator: RuleOperatorEnum.AND,
conditions: [],
},
syncFieldMappings: Array.isArray(sync.syncFieldMappings)
? sync.syncFieldMappings
: [],
}
: {
id: "",
name: t("attribute_sync_new_integration_sync"),
credentialId: undefined,
enabled: true,
organizationId,
ruleId: "",
rule: { operator: RuleOperatorEnum.AND, conditions: [] },
syncFieldMappings: [],
},
});
// Update form when sync prop changes (after refetch with new IDs)
useEffect(() => {
if (sync) {
form.reset({
id: sync.id,
name: sync.name,
credentialId: sync.credentialId ?? undefined,
enabled: sync.enabled,
organizationId: sync.organizationId,
ruleId: sync.attributeSyncRule?.id || "",
rule: sync.attributeSyncRule?.rule || {
operator: RuleOperatorEnum.AND,
conditions: [],
},
syncFieldMappings: Array.isArray(sync.syncFieldMappings)
? sync.syncFieldMappings
: [],
});
}
// Only need to run if the sync changes
}, [sync?.id]);
const onFormSubmit = (data: ISyncFormData) => {
onSubmit(data);
};
const handleDelete = () => {
if (!sync) return;
setShowDeleteDialog(true);
};
const confirmDelete = () => {
if (!sync || !onDelete) return;
onDelete();
setShowDeleteDialog(false);
};
const formName = form.watch("name");
const isPending = isSubmitting;
return (
<>
<form onSubmit={form.handleSubmit(onFormSubmit)}>
<FormCard
label={formName}
leftIcon="link"
collapsible={true}
isLabelEditable={true}
onLabelChange={(label) => {
form.setValue("name", label, { shouldDirty: true });
}}
customActions={
<Controller
name="enabled"
control={form.control}
render={({ field }) => (
<Switch
checked={field.value}
onCheckedChange={field.onChange}
onClick={(e) => e.stopPropagation()}
/>
)}
/>
}
deleteField={
!isCreateMode && onDelete
? {
check: () => true,
fn: handleDelete,
}
: undefined
}
>
<FormCardBody>
<div className="space-y-4">
<div className="bg-default border-subtle rounded-2xl border p-2">
<div className="ml-2 flex items-center gap-0.5">
<div className="border-subtle rounded-lg border p-1">
<Icon name="key" className="text-subtle h-4 w-4" />
</div>
<span className="text-emphasis ml-2 text-sm font-medium">
{t("attribute_sync_credential")}
</span>
</div>
<div className="mt-2">
<Controller
name="credentialId"
control={form.control}
rules={{
required: t("attribute_sync_credential_required"),
validate: (value) => {
if (!value || value <= 0) {
return t("attribute_sync_credential_validation");
}
return true;
},
}}
render={({ field, fieldState }) => (
<>
<SelectField
placeholder={t("attribute_sync_select_credential")}
options={credentialOptions}
value={
credentialOptions.find(
(opt) => Number(opt.value) === field.value
) || null
}
onChange={(option) =>
field.onChange(Number(option?.value))
}
/>
{fieldState.error && (
<p className="mt-1 text-xs text-red-600">
{fieldState.error.message}
</p>
)}
</>
)}
/>
<p className="text-subtle mt-1 text-xs">
{t("attribute_sync_credential_description")}
</p>
</div>
</div>
<div>
<label className="text-emphasis mb-2 block text-sm font-medium">
{t("attribute_sync_user_filter_rules")}
</label>
<Controller
name="rule"
control={form.control}
render={({ field }) => (
<RuleBuilder
value={field.value}
onChange={field.onChange}
teamOptions={teamOptions}
attributes={attributes ?? []}
/>
)}
/>
<p className="text-subtle mt-1 text-xs">
{t("attribute_sync_user_filter_rules_description")}
</p>
</div>
<div>
<label className="text-emphasis mb-2 block text-sm font-medium">
{t("attribute_sync_field_mappings")}
</label>
<Controller
name="syncFieldMappings"
control={form.control}
rules={{
validate: (mappings) => {
const seenAttributes = new Set<string>();
for (const mapping of mappings || []) {
if (
mapping.attributeId &&
seenAttributes.has(mapping.attributeId)
) {
return t(
"attribute_sync_duplicate_attribute_mapping"
);
}
if (mapping.attributeId)
seenAttributes.add(mapping.attributeId);
}
return true;
},
}}
render={({ field }) => (
<FieldMappingBuilder
value={{ mappings: field.value || [] }}
onChange={(fieldMappings) =>
field.onChange(fieldMappings.mappings)
}
attributeOptions={attributeOptions}
/>
)}
/>
<p className="text-subtle mt-1 text-xs">
{t("attribute_sync_field_mappings_description")}
</p>
</div>
<div className="border-subtle flex justify-end gap-2 border-t pt-4">
{isCreateMode && onCancel && (
<Button type="button" color="secondary" onClick={onCancel}>
{t("cancel")}
</Button>
)}
<Button
type="submit"
loading={isPending}
disabled={!form.formState.isDirty}
>
{isCreateMode ? t("create") : t("save")}
</Button>
</div>
</div>
</FormCardBody>
</FormCard>
</form>
<Dialog open={showDeleteDialog} onOpenChange={setShowDeleteDialog}>
<ConfirmationDialogContent
variety="danger"
title={t("attribute_sync_delete_title")}
confirmBtnText={t("delete")}
onConfirm={confirmDelete}
>
{t("attribute_sync_delete_confirmation")}
</ConfirmationDialogContent>
</Dialog>
</>
);
};
export default IntegrationAttributeSyncCard;
@@ -0,0 +1,47 @@
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { trpc } from "@calcom/trpc/react";
import { showToast } from "@calcom/ui/components/toast";
import type { ISyncFormData } from "../repositories/IIntegrationAttributeSyncRepository";
import IntegrationAttributeSyncCard from "./IntegrationAttributeSyncCard";
import type { IIntegrationAttributeSyncCardProps } from "./IntegrationAttributeSyncCard";
type INewIntegrationAttributeSyncCardProps = Pick<
IIntegrationAttributeSyncCardProps,
"credentialOptions" | "teamOptions" | "attributes" | "organizationId" | "onCancel" | "attributeOptions"
>;
const NewIntegrationAttributeSyncCard = (props: INewIntegrationAttributeSyncCardProps) => {
const { t } = useLocale();
const utils = trpc.useUtils();
const createMutation = trpc.viewer.attributeSync.createAttributeSync.useMutation({
onSuccess: () => {
utils.viewer.attributeSync.getAllAttributeSyncs.invalidate();
showToast(t("attribute_sync_created_successfully"), "success");
props.onCancel?.();
},
onError: (error) => {
showToast(error.message, "error");
},
});
const onSubmit = (data: ISyncFormData) => {
if (!data.credentialId) {
showToast(t("attribute_sync_credential_required"), "error");
return;
}
createMutation.mutate({
name: data.name,
credentialId: data.credentialId,
rule: data.rule,
syncFieldMappings: data.syncFieldMappings,
enabled: data.enabled,
});
};
return (
<IntegrationAttributeSyncCard {...props} onSubmit={onSubmit} isSubmitting={createMutation.isPending} />
);
};
export default NewIntegrationAttributeSyncCard;
@@ -0,0 +1,495 @@
import type { MultiValue, SingleValue } from "react-select";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import type { Attribute } from "@calcom/app-store/routing-forms/types/types";
import { Button } from "@calcom/ui/components/button";
import { Input, Select } from "@calcom/ui/components/form";
import { Icon } from "@calcom/ui/components/icon";
import {
formatConditionValue,
generateConditionId,
getConditionTypeOptions,
getDefaultAttributeCondition,
getDefaultTeamCondition,
getOperatorOptionsForAttributeType,
getParentOperatorOptions,
getTeamOperatorOptions,
isArrayOperator,
isTeamCondition,
type TAttributeSyncRuleConditionWithId,
} from "../lib/ruleHelpers";
import {
ConditionIdentifierEnum,
type ConditionOperatorEnum,
type IAttributeCondition,
type IAttributeSyncRule,
type ITeamCondition,
type RuleOperatorEnum,
type TAttributeSyncRuleCondition,
} from "../repositories/IIntegrationAttributeSyncRepository";
const ensureConditionHasId = (condition: TAttributeSyncRuleCondition): TAttributeSyncRuleConditionWithId => {
if ("_id" in condition && condition._id) {
return condition as TAttributeSyncRuleConditionWithId;
}
return { ...condition, _id: generateConditionId() } as TAttributeSyncRuleConditionWithId;
};
interface RuleBuilderProps {
value: IAttributeSyncRule;
onChange: (value: IAttributeSyncRule) => void;
teamOptions: { value: string; label: string }[];
attributes: Attribute[];
isLoadingTeams?: boolean;
isLoadingAttributes?: boolean;
}
interface ConditionComponentProps {
condition: TAttributeSyncRuleCondition;
onChange: (condition: TAttributeSyncRuleCondition) => void;
onRemove: () => void;
teamOptions: { value: string; label: string }[];
attributes: Attribute[];
isLoading?: boolean;
}
const ConditionComponent = ({
condition,
onChange,
onRemove,
teamOptions,
attributes,
isLoading,
}: ConditionComponentProps) => {
const { t } = useLocale();
const conditionTypeOptions = getConditionTypeOptions(t);
const conditionTypeOption = conditionTypeOptions.find((opt) => opt.value === condition.identifier);
const handleTypeChange = (newType: { value: ConditionIdentifierEnum; label: string } | null) => {
if (!newType) return;
if (newType.value === ConditionIdentifierEnum.TEAM_ID) {
onChange(getDefaultTeamCondition());
} else {
onChange(getDefaultAttributeCondition());
}
};
return (
<div className="bg-default border-subtle flex flex-wrap items-center gap-2 rounded-lg border p-3">
{/* Condition type selector */}
<Select
size="sm"
className="w-32"
value={conditionTypeOption}
onChange={handleTypeChange}
options={conditionTypeOptions}
isDisabled={isLoading}
/>
{/* Render team or attribute specific fields */}
{isTeamCondition(condition) ? (
<TeamConditionFields
condition={condition}
onChange={onChange}
teamOptions={teamOptions}
isLoading={isLoading}
/>
) : (
<AttributeConditionFields
condition={condition}
onChange={onChange}
attributes={attributes}
isLoading={isLoading}
/>
)}
<Button
color="minimal"
size="sm"
StartIcon="trash-2"
onClick={onRemove}
disabled={isLoading}
className="text-subtle hover:text-default"
/>
</div>
);
};
interface TeamConditionFieldsProps {
condition: ITeamCondition;
onChange: (condition: TAttributeSyncRuleCondition) => void;
teamOptions: { value: string; label: string }[];
isLoading?: boolean;
}
const TeamConditionFields = ({ condition, onChange, teamOptions, isLoading }: TeamConditionFieldsProps) => {
const { t } = useLocale();
const teamOperatorOptions = getTeamOperatorOptions(t);
const operatorOption = teamOperatorOptions.find((opt) => opt.value === condition.operator);
const isMulti = isArrayOperator(condition.operator);
const selectedTeams = isMulti
? teamOptions.filter((opt) => condition.value.includes(Number(opt.value)))
: teamOptions.find((opt) => Number(opt.value) === condition.value[0]);
const handleOperatorChange = (newOperator: { value: ConditionOperatorEnum; label: string } | null) => {
if (!newOperator) return;
const formattedValue = formatConditionValue(newOperator.value, condition.value) as number[];
onChange({
...condition,
operator: newOperator.value,
value: formattedValue,
});
};
const handleTeamsChange = (
selected: MultiValue<{ value: string; label: string }> | SingleValue<{ value: string; label: string }>
) => {
if (!selected) {
onChange({ ...condition, value: [] });
return;
}
if (Array.isArray(selected)) {
onChange({
...condition,
value: selected.map((s) => Number(s.value)),
});
} else {
const singleValue = selected as { value: string; label: string };
onChange({
...condition,
value: [Number(singleValue.value)],
});
}
};
return (
<>
<span className="text-default text-sm">{t("team").toLowerCase()}</span>
<Select
size="sm"
className="w-40"
value={operatorOption}
onChange={handleOperatorChange}
options={teamOperatorOptions}
isDisabled={isLoading}
/>
<div className="flex-1" style={{ minWidth: "200px" }}>
<Select
size="sm"
isMulti={isMulti}
value={selectedTeams}
onChange={handleTeamsChange}
options={teamOptions}
isLoading={isLoading}
isDisabled={isLoading}
placeholder={t("attribute_sync_select_teams")}
className="w-full"
/>
</div>
</>
);
};
interface AttributeConditionFieldsProps {
condition: IAttributeCondition;
onChange: (condition: TAttributeSyncRuleCondition) => void;
attributes: Attribute[];
isLoading?: boolean;
}
const AttributeConditionFields = ({
condition,
onChange,
attributes,
isLoading,
}: AttributeConditionFieldsProps) => {
const { t } = useLocale();
const selectedAttribute = attributes.find((attr) => attr.id === condition.attributeId);
const attributeOptions = attributes.map((attr) => ({
value: attr.id,
label: attr.name,
}));
const operatorOptions = selectedAttribute
? getOperatorOptionsForAttributeType(selectedAttribute.type, t)
: [];
const operatorOption = operatorOptions.find((opt) => opt.value === condition.operator);
const handleAttributeChange = (selected: { value: string; label: string } | null) => {
if (!selected) return;
const newAttribute = attributes.find((attr) => attr.id === selected.value);
if (!newAttribute) return;
// Reset operator and value when attribute changes
const defaultOperator = getOperatorOptionsForAttributeType(newAttribute.type, t)[0];
onChange({
...condition,
attributeId: selected.value,
operator: defaultOperator.value,
value: [],
});
};
const handleOperatorChange = (newOperator: { value: ConditionOperatorEnum; label: string } | null) => {
if (!newOperator) return;
const formattedValue = formatConditionValue(newOperator.value, condition.value) as string[];
onChange({
...condition,
operator: newOperator.value,
value: formattedValue,
});
};
const handleValueChange = (
selected: MultiValue<{ value: string; label: string }> | SingleValue<{ value: string; label: string }>
) => {
if (!selected) {
onChange({ ...condition, value: [] });
return;
}
if (Array.isArray(selected)) {
onChange({
...condition,
value: selected.map((s) => s.value),
});
} else {
const singleValue = selected as { value: string; label: string };
onChange({
...condition,
value: [singleValue.value],
});
}
};
const handleTextValueChange = (e: React.ChangeEvent<HTMLInputElement>) => {
onChange({
...condition,
value: [e.target.value],
});
};
const handleNumberValueChange = (e: React.ChangeEvent<HTMLInputElement>) => {
onChange({
...condition,
value: [e.target.value],
});
};
// Render value input based on attribute type
const renderValueInput = () => {
if (!selectedAttribute) {
return (
<div className="text-subtle flex-1 text-sm" style={{ minWidth: "200px" }}>
{t("attribute_sync_select_attribute_first")}
</div>
);
}
const isMulti = isArrayOperator(condition.operator);
switch (selectedAttribute.type) {
case "SINGLE_SELECT":
case "MULTI_SELECT": {
const valueOptions = selectedAttribute.options.map((opt: { id: string; value: string }) => ({
value: opt.id,
label: opt.value,
}));
const selectedValues = isMulti
? valueOptions.filter((opt: { value: string; label: string }) => condition.value.includes(opt.value))
: valueOptions.find((opt: { value: string; label: string }) => condition.value[0] === opt.value);
return (
<div className="flex-1" style={{ minWidth: "200px" }}>
<Select
size="sm"
isMulti={isMulti}
value={selectedValues}
onChange={handleValueChange}
options={valueOptions}
isLoading={isLoading}
isDisabled={isLoading}
placeholder={t("attribute_sync_select_values")}
className="w-full"
/>
</div>
);
}
case "TEXT":
return (
<div className="flex-1" style={{ minWidth: "200px" }}>
<Input
type="text"
value={condition.value[0] || ""}
onChange={handleTextValueChange}
disabled={isLoading}
placeholder={t("attribute_sync_enter_text_value")}
className="h-7 text-sm"
/>
</div>
);
case "NUMBER":
return (
<div className="flex-1" style={{ minWidth: "200px" }}>
<Input
type="number"
value={condition.value[0] || ""}
onChange={handleNumberValueChange}
disabled={isLoading}
placeholder={t("attribute_sync_enter_number_value")}
className="h-7 text-sm"
/>
</div>
);
}
};
return (
<>
<span className="text-default text-sm">{t("attribute").toLowerCase()}</span>
<Select
size="sm"
className="w-48"
value={attributeOptions.find((opt) => opt.value === condition.attributeId)}
onChange={handleAttributeChange}
options={attributeOptions}
isLoading={isLoading}
isDisabled={isLoading}
placeholder={t("attribute_sync_select_attribute")}
/>
{selectedAttribute && (
<>
<Select
size="sm"
className="w-40"
value={operatorOption}
onChange={handleOperatorChange}
options={operatorOptions}
isDisabled={isLoading}
/>
{renderValueInput()}
</>
)}
</>
);
};
export const RuleBuilder = ({
value,
onChange,
teamOptions,
attributes,
isLoadingTeams,
isLoadingAttributes,
}: RuleBuilderProps) => {
const { t } = useLocale();
const parentOperatorOptions = getParentOperatorOptions(t);
const parentOperatorOption = parentOperatorOptions.find((opt) => opt.value === value.operator);
const isLoading = isLoadingTeams || isLoadingAttributes;
const handleOperatorChange = (newOperator: { value: RuleOperatorEnum; label: string } | null) => {
if (!newOperator) return;
onChange({
...value,
operator: newOperator.value,
});
};
const handleAddCondition = () => {
onChange({
...value,
conditions: [...value.conditions, getDefaultTeamCondition()],
});
};
const handleConditionChange = (index: number, newCondition: TAttributeSyncRuleCondition) => {
const newConditions = [...value.conditions];
newConditions[index] = newCondition;
onChange({
...value,
conditions: newConditions,
});
};
const handleRemoveCondition = (index: number) => {
onChange({
...value,
conditions: value.conditions.filter((_, i) => i !== index),
});
};
return (
<div className="bg-default border-subtle rounded-2xl border p-2">
<div className="ml-2 flex items-center gap-0.5">
<div className="border-subtle rounded-lg border p-1">
<Icon name="filter" className="text-subtle h-4 w-4" />
</div>
<span className="text-emphasis ml-2 text-sm font-medium">{t("attribute_sync_user_filter_rules")}</span>
</div>
<div className="mt-2 flex flex-wrap items-center gap-2 px-2">
<span className="text-default text-sm">{t("attribute_sync_sync_users_where")}</span>
<Select
size="sm"
className="w-28"
value={parentOperatorOption}
onChange={handleOperatorChange}
options={parentOperatorOptions}
isDisabled={isLoading}
/>
<span className="text-default text-sm">{t("attribute_sync_conditions_match")}</span>
</div>
<div className="bg-muted mt-2 space-y-2 rounded-xl p-2">
{value.conditions.length === 0 ? (
<div className="text-subtle py-6 text-center text-sm">
{t("attribute_sync_no_conditions")}
</div>
) : (
value.conditions.map((condition, index) => {
const conditionWithId = ensureConditionHasId(condition);
return (
<ConditionComponent
key={conditionWithId._id}
condition={conditionWithId}
onChange={(newCondition) => handleConditionChange(index, newCondition)}
onRemove={() => handleRemoveCondition(index)}
teamOptions={teamOptions}
attributes={attributes}
isLoading={isLoading}
/>
);
})
)}
</div>
<Button
color="minimal"
size="sm"
StartIcon="plus"
className="mt-2"
onClick={handleAddCondition}
disabled={isLoading}>
{t("attribute_sync_add_condition")}
</Button>
</div>
);
};
@@ -0,0 +1,2 @@
/* Slugs of apps that support attribute syncing */
export const enabledAppSlugs = ["salesforce"];
@@ -0,0 +1,23 @@
import { bindModuleToClassOnToken, createModule, type ModuleLoader } from "@calcom/features/di/di";
import { moduleLoader as prismaModuleLoader } from "@calcom/features/di/modules/Prisma";
import { PrismaIntegrationAttributeSyncRepository } from "../repositories/PrismaIntegrationAttributeSyncRepository";
import { INTEGRATION_ATTRIBUTE_SYNC_DI_TOKENS } from "./tokens";
export const integrationAttributeSyncRepositoryModule = createModule();
const token = INTEGRATION_ATTRIBUTE_SYNC_DI_TOKENS.INTEGRATION_ATTRIBUTE_SYNC_REPOSITORY;
const moduleToken = INTEGRATION_ATTRIBUTE_SYNC_DI_TOKENS.INTEGRATION_ATTRIBUTE_SYNC_REPOSITORY_MODULE;
const loadModule = bindModuleToClassOnToken({
module: integrationAttributeSyncRepositoryModule,
moduleToken,
token,
classs: PrismaIntegrationAttributeSyncRepository,
dep: prismaModuleLoader,
});
export const moduleLoader: ModuleLoader = {
token,
loadModule,
};
export type { IIntegrationAttributeSyncRepository } from "../repositories/IIntegrationAttributeSyncRepository";
@@ -0,0 +1,16 @@
import { createContainer } from "@calcom/features/di/di";
import {
type IntegrationAttributeSyncService,
moduleLoader as integrationAttributeSyncServiceModule,
} from "./IntegrationAttributeSyncService.module";
const integrationAttributeSyncServiceContainer = createContainer();
export function getIntegrationAttributeSyncService(): IntegrationAttributeSyncService {
integrationAttributeSyncServiceModule.loadModule(integrationAttributeSyncServiceContainer);
return integrationAttributeSyncServiceContainer.get<IntegrationAttributeSyncService>(
integrationAttributeSyncServiceModule.token
);
}
@@ -0,0 +1,27 @@
import { bindModuleToClassOnToken, createModule, type ModuleLoader } from "@calcom/features/di/di";
import { moduleLoader as credentialRepositoryModuleLoader } from "@calcom/features/di/modules/Credential";
import { IntegrationAttributeSyncService } from "@calcom/features/ee/integration-attribute-sync/services/IntegrationAttributeSyncService";
import { INTEGRATION_ATTRIBUTE_SYNC_DI_TOKENS } from "./tokens";
import { moduleLoader as integrationAttributeSyncRepositoryModuleLoader } from "./IntegrationAttributeSyncRepository.module";
export const integrationAttributeSyncServiceModule = createModule();
const token = INTEGRATION_ATTRIBUTE_SYNC_DI_TOKENS.INTEGRATION_ATTRIBUTE_SYNC_SERVICE;
const moduleToken = INTEGRATION_ATTRIBUTE_SYNC_DI_TOKENS.INTEGRATION_ATTRIBUTE_SYNC_SERVICE_MODULE;
const loadModule = bindModuleToClassOnToken({
module: integrationAttributeSyncServiceModule,
moduleToken,
token,
classs: IntegrationAttributeSyncService,
depsMap: {
credentialRepository: credentialRepositoryModuleLoader,
integrationAttributeSyncRepository: integrationAttributeSyncRepositoryModuleLoader,
},
});
export const moduleLoader: ModuleLoader = {
token,
loadModule,
};
export type { IntegrationAttributeSyncService };
@@ -0,0 +1,6 @@
export const INTEGRATION_ATTRIBUTE_SYNC_DI_TOKENS = {
INTEGRATION_ATTRIBUTE_SYNC_SERVICE: Symbol("IntegrationAttributeSyncService"),
INTEGRATION_ATTRIBUTE_SYNC_SERVICE_MODULE: Symbol("IntegrationAttributeSyncServiceModule"),
INTEGRATION_ATTRIBUTE_SYNC_REPOSITORY: Symbol("IntegrationAttributeSyncRepository"),
INTEGRATION_ATTRIBUTE_SYNC_REPOSITORY_MODULE: Symbol("IntegrationAttributeSyncRepositoryModule"),
};
@@ -0,0 +1,7 @@
import type { IFieldMappingWithOptionalId } from "../repositories/IIntegrationAttributeSyncRepository";
export const getDefaultFieldMapping = (): IFieldMappingWithOptionalId => ({
integrationFieldName: "",
attributeId: "",
enabled: true,
});
@@ -0,0 +1,108 @@
import type { AttributeType } from "@calcom/prisma/enums";
import {
ConditionIdentifierEnum,
ConditionOperatorEnum,
type IAttributeCondition,
type ITeamCondition,
RuleOperatorEnum,
type TAttributeSyncRuleCondition,
} from "../repositories/IIntegrationAttributeSyncRepository";
type TFunction = (key: string) => string;
// Unique ID generator for React keys
export const generateConditionId = (): string => {
return crypto.randomUUID();
};
// Extended condition types with client-side ID for React keys
export interface ITeamConditionWithId extends ITeamCondition {
_id: string;
}
export interface IAttributeConditionWithId extends IAttributeCondition {
_id: string;
}
export type TAttributeSyncRuleConditionWithId = ITeamConditionWithId | IAttributeConditionWithId;
// Factory functions
export const getDefaultTeamCondition = (): ITeamConditionWithId => ({
_id: generateConditionId(),
identifier: ConditionIdentifierEnum.TEAM_ID,
operator: ConditionOperatorEnum.IN,
value: [],
});
export const getDefaultAttributeCondition = (): IAttributeConditionWithId => ({
_id: generateConditionId(),
identifier: ConditionIdentifierEnum.ATTRIBUTE_ID,
attributeId: "",
operator: ConditionOperatorEnum.EQUALS,
value: [],
});
// Type guards
export const isTeamCondition = (condition: TAttributeSyncRuleCondition): condition is ITeamCondition => {
return condition.identifier === ConditionIdentifierEnum.TEAM_ID;
};
export const isAttributeCondition = (
condition: TAttributeSyncRuleCondition
): condition is IAttributeCondition => {
return condition.identifier === ConditionIdentifierEnum.ATTRIBUTE_ID;
};
export const isArrayOperator = (operator: ConditionOperatorEnum): boolean => {
return operator === ConditionOperatorEnum.IN || operator === ConditionOperatorEnum.NOT_IN;
};
export const formatConditionValue = (
operator: ConditionOperatorEnum,
value: number[] | string[]
): number[] | string[] => {
if (isArrayOperator(operator)) {
return Array.isArray(value) ? value : value ? [value] : [];
} else {
return Array.isArray(value) ? value.slice(0, 1) : value ? [value] : [];
}
};
// Operator options for teams
export const getTeamOperatorOptions = (t: TFunction) => [
{ value: ConditionOperatorEnum.IN, label: t("attribute_sync_operator_is_any_of") },
{ value: ConditionOperatorEnum.NOT_IN, label: t("attribute_sync_operator_is_not_any_of") },
];
// Operator options based on attribute type
export const getOperatorOptionsForAttributeType = (type: AttributeType, t: TFunction) => {
switch (type) {
case "SINGLE_SELECT":
return [
{ value: ConditionOperatorEnum.EQUALS, label: t("attribute_sync_operator_is") },
{ value: ConditionOperatorEnum.NOT_EQUALS, label: t("attribute_sync_operator_is_not") },
];
case "MULTI_SELECT":
return [
{ value: ConditionOperatorEnum.IN, label: t("attribute_sync_operator_includes_any_of") },
{ value: ConditionOperatorEnum.NOT_IN, label: t("attribute_sync_operator_does_not_include") },
];
case "TEXT":
case "NUMBER":
return [
{ value: ConditionOperatorEnum.EQUALS, label: t("attribute_sync_operator_equals") },
{ value: ConditionOperatorEnum.NOT_EQUALS, label: t("attribute_sync_operator_not_equals") },
];
}
};
export const getParentOperatorOptions = (t: TFunction) => [
{ value: RuleOperatorEnum.AND, label: t("all") },
{ value: RuleOperatorEnum.OR, label: t("any") },
];
export const getConditionTypeOptions = (t: TFunction) => [
{ value: ConditionIdentifierEnum.TEAM_ID, label: t("team") },
{ value: ConditionIdentifierEnum.ATTRIBUTE_ID, label: t("attribute") },
];
@@ -0,0 +1,53 @@
import type {
AttributeSyncRule as PrismaAttributeSyncRule,
IntegrationAttributeSync as PrismaIntegrationAttributeSync,
AttributeSyncFieldMapping as PrismaAttributeSyncFieldMapping,
} from "@calcom/prisma/client";
import type {
AttributeSyncRule,
IntegrationAttributeSync,
} from "../repositories/IIntegrationAttributeSyncRepository";
import { AttributeSyncIntegrations } from "../repositories/IIntegrationAttributeSyncRepository";
import { attributeSyncRuleSchema } from "../schemas/zod";
type PrismaIntegrationAttributeSyncWithRelations = Pick<
PrismaIntegrationAttributeSync,
"id" | "name" | "organizationId" | "integration" | "credentialId" | "enabled"
> & {
attributeSyncRule: PrismaAttributeSyncRule | null;
syncFieldMappings: Pick<
PrismaAttributeSyncFieldMapping,
"id" | "integrationFieldName" | "attributeId" | "enabled"
>[];
};
export class IntegrationAttributeSyncOutputMapper {
static attributeSyncRuleToDomain(prisma: PrismaAttributeSyncRule): AttributeSyncRule {
const parsedRules = attributeSyncRuleSchema.parse(prisma.rule);
return {
...prisma,
rule: parsedRules,
};
}
static toDomain(prisma: PrismaIntegrationAttributeSyncWithRelations): IntegrationAttributeSync {
return {
id: prisma.id,
name: prisma.name,
organizationId: prisma.organizationId,
integration: prisma.integration as AttributeSyncIntegrations,
credentialId: prisma.credentialId ?? undefined,
enabled: prisma.enabled,
attributeSyncRule: prisma.attributeSyncRule
? IntegrationAttributeSyncOutputMapper.attributeSyncRuleToDomain(prisma.attributeSyncRule)
: null,
syncFieldMappings: prisma.syncFieldMappings,
};
}
static toDomainList(prismaList: PrismaIntegrationAttributeSyncWithRelations[]): IntegrationAttributeSync[] {
return prismaList.map(IntegrationAttributeSyncOutputMapper.toDomain);
}
}
@@ -0,0 +1,145 @@
export enum AttributeSyncIntegrations {
SALESFORCE = "salesforce",
}
export enum RuleOperatorEnum {
AND = "AND",
OR = "OR",
}
export enum ConditionIdentifierEnum {
TEAM_ID = "teamId",
ATTRIBUTE_ID = "attributeId",
}
export enum ConditionOperatorEnum {
EQUALS = "equals",
NOT_EQUALS = "notEquals",
IN = "in",
NOT_IN = "notIn",
}
// Condition types
export interface ITeamCondition {
identifier: ConditionIdentifierEnum.TEAM_ID;
operator: ConditionOperatorEnum;
value: number[];
}
export interface IAttributeCondition {
identifier: ConditionIdentifierEnum.ATTRIBUTE_ID;
operator: ConditionOperatorEnum;
attributeId: string;
value: string[];
}
export type TAttributeSyncRuleCondition = ITeamCondition | IAttributeCondition;
export interface IAttributeSyncRule {
operator: RuleOperatorEnum;
conditions: TAttributeSyncRuleCondition[];
}
// Field mapping types
export interface INewFieldMapping {
integrationFieldName: string;
attributeId: string;
enabled: boolean;
}
export interface IFieldMapping extends INewFieldMapping {
id: string;
}
export interface IFieldMappingWithOptionalId extends INewFieldMapping {
id?: string;
}
export interface IFieldMappingFormState {
mappings: IFieldMappingWithOptionalId[];
}
export interface ISyncFormData {
id: string;
name: string;
credentialId?: number;
enabled: boolean;
organizationId: number;
ruleId: string;
rule: IAttributeSyncRule;
syncFieldMappings: (IFieldMapping | INewFieldMapping)[];
}
export interface IntegrationAttributeSync {
id: string;
organizationId: number;
name: string;
integration: AttributeSyncIntegrations;
credentialId?: number;
enabled: boolean;
attributeSyncRule: AttributeSyncRule | null;
syncFieldMappings: AttributeSyncFieldMapping[];
}
export interface AttributeSyncRule {
id: string;
rule: IAttributeSyncRule;
}
export interface AttributeSyncFieldMapping {
id: string;
integrationFieldName: string;
attributeId: string;
enabled: boolean;
}
export interface IIntegrationAttributeSyncCreateParams {
name: string;
organizationId: number;
integration: AttributeSyncIntegrations;
credentialId: number;
enabled: boolean;
rule: IAttributeSyncRule;
syncFieldMappings: Omit<AttributeSyncFieldMapping, "id">[];
}
// TRPC schema types
export interface ICreateAttributeSyncInput {
name: string;
credentialId: number;
rule: IAttributeSyncRule;
syncFieldMappings: INewFieldMapping[];
enabled: boolean;
}
export interface IUpdateAttributeSyncInput {
id: string;
name: string;
credentialId?: number;
enabled: boolean;
organizationId: number;
ruleId: string;
rule: IAttributeSyncRule;
syncFieldMappings: IFieldMappingWithOptionalId[];
}
export interface IIntegrationAttributeSyncRepository {
getByOrganizationId(organizationId: number): Promise<IntegrationAttributeSync[]>;
getById(id: string): Promise<IntegrationAttributeSync | null>;
getSyncFieldMappings(integrationAttributeSyncId: string): Promise<AttributeSyncFieldMapping[]>;
getMappedAttributeIdsByOrganization(organizationId: number, excludeSyncId?: string): Promise<string[]>;
getAttributeIdsByOrganization(organizationId: number, attributeIds: string[]): Promise<string[]>;
create(params: IIntegrationAttributeSyncCreateParams): Promise<IntegrationAttributeSync>;
updateTransactionWithRuleAndMappings(params: IIntegrationAttributeSyncUpdateParams): Promise<void>;
deleteById(id: string): Promise<void>;
}
export interface IIntegrationAttributeSyncUpdateParams {
integrationAttributeSync: Omit<
IntegrationAttributeSync,
"attributeSyncRule" | "syncFieldMappings" | "integration"
>;
attributeSyncRule: AttributeSyncRule;
fieldMappingsToCreate: Omit<AttributeSyncFieldMapping, "id">[];
fieldMappingsToUpdate: AttributeSyncFieldMapping[];
fieldMappingsToDelete: string[];
}
@@ -0,0 +1,210 @@
import type { Prisma } from "@calcom/prisma/client";
import type { PrismaClient } from "@calcom/prisma";
import { IntegrationAttributeSyncOutputMapper } from "../mappers/IntegrationAttributeSyncOutputMapper";
import type {
IIntegrationAttributeSyncCreateParams,
IIntegrationAttributeSyncRepository,
IIntegrationAttributeSyncUpdateParams,
} from "./IIntegrationAttributeSyncRepository";
export class PrismaIntegrationAttributeSyncRepository implements IIntegrationAttributeSyncRepository {
constructor(private readonly prismaClient: PrismaClient) {}
async getByOrganizationId(organizationId: number) {
const integrationAttributeSyncQuery = await this.prismaClient.integrationAttributeSync.findMany({
where: {
organizationId,
},
select: {
id: true,
name: true,
organizationId: true,
integration: true,
credentialId: true,
enabled: true,
attributeSyncRule: true,
syncFieldMappings: true,
},
});
return IntegrationAttributeSyncOutputMapper.toDomainList(integrationAttributeSyncQuery);
}
async getById(id: string) {
const integrationAttributeSyncQuery = await this.prismaClient.integrationAttributeSync.findUnique({
where: {
id,
},
select: {
id: true,
name: true,
organizationId: true,
integration: true,
credentialId: true,
enabled: true,
attributeSyncRule: true,
syncFieldMappings: true,
},
});
if (!integrationAttributeSyncQuery) return null;
return IntegrationAttributeSyncOutputMapper.toDomain(integrationAttributeSyncQuery);
}
async getSyncFieldMappings(integrationAttributeSyncId: string) {
const result = await this.prismaClient.integrationAttributeSync.findUnique({
where: {
id: integrationAttributeSyncId,
},
select: {
syncFieldMappings: {
select: {
id: true,
integrationFieldName: true,
attributeId: true,
enabled: true,
},
},
},
});
return result?.syncFieldMappings || [];
}
async getMappedAttributeIdsByOrganization(
organizationId: number,
excludeSyncId?: string
): Promise<string[]> {
const result = await this.prismaClient.attributeSyncFieldMapping.findMany({
where: {
integrationAttributeSync: {
organizationId,
...(excludeSyncId && { id: { not: excludeSyncId } }),
},
},
select: {
attributeId: true,
},
distinct: ["attributeId"],
});
return result.map((r) => r.attributeId);
}
async getAttributeIdsByOrganization(organizationId: number, attributeIds: string[]): Promise<string[]> {
const result = await this.prismaClient.attribute.findMany({
where: {
id: { in: attributeIds },
teamId: organizationId,
},
select: { id: true },
});
return result.map((r) => r.id);
}
async create(params: IIntegrationAttributeSyncCreateParams) {
const { name, organizationId, integration, credentialId, enabled, rule, syncFieldMappings } = params;
const created = await this.prismaClient.integrationAttributeSync.create({
data: {
name,
organizationId,
integration,
credentialId,
enabled,
attributeSyncRule: {
create: {
rule: rule as unknown as Prisma.InputJsonValue,
},
},
syncFieldMappings: {
create: syncFieldMappings.map((mapping) => ({
integrationFieldName: mapping.integrationFieldName,
attributeId: mapping.attributeId,
enabled: mapping.enabled,
})),
},
},
include: {
attributeSyncRule: true,
syncFieldMappings: {
select: {
id: true,
integrationFieldName: true,
attributeId: true,
enabled: true,
},
},
},
});
return IntegrationAttributeSyncOutputMapper.toDomain(created);
}
updateTransactionWithRuleAndMappings(params: IIntegrationAttributeSyncUpdateParams) {
const {
integrationAttributeSync,
attributeSyncRule,
fieldMappingsToCreate,
fieldMappingsToUpdate,
fieldMappingsToDelete,
} = params;
const { id: syncId, ...syncData } = integrationAttributeSync;
return this.prismaClient
.$transaction([
this.prismaClient.integrationAttributeSync.update({
where: {
id: syncId,
},
data: syncData,
}),
this.prismaClient.attributeSyncRule.update({
where: {
id: attributeSyncRule.id,
},
data: {
rule: attributeSyncRule.rule as unknown as Prisma.InputJsonValue,
},
}),
...fieldMappingsToUpdate.map(({ id, ...mappingData }) =>
this.prismaClient.attributeSyncFieldMapping.update({
where: {
id,
},
data: mappingData,
})
),
...(fieldMappingsToCreate.length > 0
? [
this.prismaClient.attributeSyncFieldMapping.createMany({
data: fieldMappingsToCreate.map((mapping) => ({
...mapping,
integrationAttributeSyncId: syncId,
})),
}),
]
: []),
...(fieldMappingsToDelete.length > 0
? [
this.prismaClient.attributeSyncFieldMapping.deleteMany({
where: { id: { in: fieldMappingsToDelete } },
}),
]
: []),
])
.then(() => {});
}
deleteById(id: string) {
return this.prismaClient.integrationAttributeSync
.delete({
where: {
id,
},
})
.then(() => {});
}
}
@@ -0,0 +1,419 @@
import { describe, it, expect } from "vitest";
import {
attributeSyncRuleConditionSchema,
attributeSyncRuleSchema,
syncFormDataSchema,
} from "./zod";
describe("attributeSyncRuleConditionSchema", () => {
describe("team conditions", () => {
it("should validate a valid team condition with equals operator", () => {
const condition = {
identifier: "teamId",
operator: "equals",
value: [1, 2, 3],
};
const result = attributeSyncRuleConditionSchema.safeParse(condition);
expect(result.success).toBe(true);
if (result.success) {
expect(result.data).toEqual(condition);
}
});
it("should validate a valid team condition with notEquals operator", () => {
const condition = {
identifier: "teamId",
operator: "notEquals",
value: [5],
};
const result = attributeSyncRuleConditionSchema.safeParse(condition);
expect(result.success).toBe(true);
});
it("should validate a valid team condition with in operator", () => {
const condition = {
identifier: "teamId",
operator: "in",
value: [1, 2],
};
const result = attributeSyncRuleConditionSchema.safeParse(condition);
expect(result.success).toBe(true);
});
it("should validate a valid team condition with notIn operator", () => {
const condition = {
identifier: "teamId",
operator: "notIn",
value: [3, 4],
};
const result = attributeSyncRuleConditionSchema.safeParse(condition);
expect(result.success).toBe(true);
});
it("should reject team condition with string values", () => {
const condition = {
identifier: "teamId",
operator: "equals",
value: ["team-1", "team-2"],
};
const result = attributeSyncRuleConditionSchema.safeParse(condition);
expect(result.success).toBe(false);
});
it("should reject team condition with invalid operator", () => {
const condition = {
identifier: "teamId",
operator: "contains",
value: [1],
};
const result = attributeSyncRuleConditionSchema.safeParse(condition);
expect(result.success).toBe(false);
});
});
describe("attribute conditions", () => {
it("should validate a valid attribute condition with equals operator", () => {
const condition = {
identifier: "attributeId",
attributeId: "attr-123",
operator: "equals",
value: ["option-1", "option-2"],
};
const result = attributeSyncRuleConditionSchema.safeParse(condition);
expect(result.success).toBe(true);
if (result.success) {
expect(result.data).toEqual(condition);
}
});
it("should validate a valid attribute condition with in operator", () => {
const condition = {
identifier: "attributeId",
attributeId: "attr-456",
operator: "in",
value: ["opt-a", "opt-b", "opt-c"],
};
const result = attributeSyncRuleConditionSchema.safeParse(condition);
expect(result.success).toBe(true);
});
it("should reject attribute condition without attributeId", () => {
const condition = {
identifier: "attributeId",
operator: "equals",
value: ["option-1"],
};
const result = attributeSyncRuleConditionSchema.safeParse(condition);
expect(result.success).toBe(false);
});
it("should reject attribute condition with number values", () => {
const condition = {
identifier: "attributeId",
attributeId: "attr-123",
operator: "equals",
value: [1, 2, 3],
};
const result = attributeSyncRuleConditionSchema.safeParse(condition);
expect(result.success).toBe(false);
});
});
describe("invalid conditions", () => {
it("should reject condition with invalid identifier", () => {
const condition = {
identifier: "invalidId",
operator: "equals",
value: [1],
};
const result = attributeSyncRuleConditionSchema.safeParse(condition);
expect(result.success).toBe(false);
});
it("should reject condition without value", () => {
const condition = {
identifier: "teamId",
operator: "equals",
};
const result = attributeSyncRuleConditionSchema.safeParse(condition);
expect(result.success).toBe(false);
});
});
});
describe("attributeSyncRuleSchema", () => {
it("should validate a rule with AND operator and team conditions", () => {
const rule = {
operator: "AND",
conditions: [
{
identifier: "teamId",
operator: "equals",
value: [1, 2],
},
],
};
const result = attributeSyncRuleSchema.safeParse(rule);
expect(result.success).toBe(true);
if (result.success) {
expect(result.data).toEqual(rule);
}
});
it("should validate a rule with OR operator and attribute conditions", () => {
const rule = {
operator: "OR",
conditions: [
{
identifier: "attributeId",
attributeId: "attr-1",
operator: "in",
value: ["opt-1"],
},
{
identifier: "attributeId",
attributeId: "attr-2",
operator: "notIn",
value: ["opt-2"],
},
],
};
const result = attributeSyncRuleSchema.safeParse(rule);
expect(result.success).toBe(true);
});
it("should validate a rule with mixed conditions", () => {
const rule = {
operator: "AND",
conditions: [
{
identifier: "teamId",
operator: "equals",
value: [1],
},
{
identifier: "attributeId",
attributeId: "attr-1",
operator: "in",
value: ["opt-1"],
},
],
};
const result = attributeSyncRuleSchema.safeParse(rule);
expect(result.success).toBe(true);
});
it("should validate a rule with empty conditions array", () => {
const rule = {
operator: "AND",
conditions: [],
};
const result = attributeSyncRuleSchema.safeParse(rule);
expect(result.success).toBe(true);
});
it("should reject a rule with invalid operator", () => {
const rule = {
operator: "XOR",
conditions: [],
};
const result = attributeSyncRuleSchema.safeParse(rule);
expect(result.success).toBe(false);
});
it("should reject a rule without operator", () => {
const rule = {
conditions: [],
};
const result = attributeSyncRuleSchema.safeParse(rule);
expect(result.success).toBe(false);
});
it("should reject a rule without conditions", () => {
const rule = {
operator: "AND",
};
const result = attributeSyncRuleSchema.safeParse(rule);
expect(result.success).toBe(false);
});
});
describe("syncFormDataSchema", () => {
const validFormData = {
id: "sync-123",
name: "Test Sync",
credentialId: 1,
enabled: true,
organizationId: 123,
ruleId: "rule-123",
rule: {
operator: "AND",
conditions: [
{
identifier: "teamId",
operator: "equals",
value: [1],
},
],
},
syncFieldMappings: [
{
id: "mapping-1",
integrationFieldName: "field1",
attributeId: "attr-1",
enabled: true,
},
],
};
it("should validate valid form data with existing mappings", () => {
const result = syncFormDataSchema.safeParse(validFormData);
expect(result.success).toBe(true);
if (result.success) {
expect(result.data.id).toBe("sync-123");
expect(result.data.name).toBe("Test Sync");
}
});
it("should validate form data with new mappings (without id)", () => {
const formData = {
...validFormData,
syncFieldMappings: [
{
integrationFieldName: "newField",
attributeId: "attr-new",
enabled: false,
},
],
};
const result = syncFormDataSchema.safeParse(formData);
expect(result.success).toBe(true);
});
it("should validate form data with mixed new and existing mappings", () => {
const formData = {
...validFormData,
syncFieldMappings: [
{
id: "existing-mapping",
integrationFieldName: "existingField",
attributeId: "attr-1",
enabled: true,
},
{
integrationFieldName: "newField",
attributeId: "attr-2",
enabled: false,
},
],
};
const result = syncFormDataSchema.safeParse(formData);
expect(result.success).toBe(true);
});
it("should validate form data with empty mappings array", () => {
const formData = {
...validFormData,
syncFieldMappings: [],
};
const result = syncFormDataSchema.safeParse(formData);
expect(result.success).toBe(true);
});
it("should reject form data with empty name", () => {
const formData = {
...validFormData,
name: "",
};
const result = syncFormDataSchema.safeParse(formData);
expect(result.success).toBe(false);
});
it("should reject form data without required fields", () => {
const formData = {
name: "Test",
};
const result = syncFormDataSchema.safeParse(formData);
expect(result.success).toBe(false);
});
it("should reject form data with invalid rule", () => {
const formData = {
...validFormData,
rule: {
operator: "INVALID",
conditions: [],
},
};
const result = syncFormDataSchema.safeParse(formData);
expect(result.success).toBe(false);
});
it("should reject form data with invalid mapping", () => {
const formData = {
...validFormData,
syncFieldMappings: [
{
integrationFieldName: "field",
// missing attributeId and enabled
},
],
};
const result = syncFormDataSchema.safeParse(formData);
expect(result.success).toBe(false);
});
});
@@ -0,0 +1,82 @@
import z from "zod";
import {
ConditionIdentifierEnum,
ConditionOperatorEnum,
type IAttributeSyncRule,
type IFieldMapping,
type IFieldMappingFormState,
type IFieldMappingWithOptionalId,
type INewFieldMapping,
type ISyncFormData,
RuleOperatorEnum,
type TAttributeSyncRuleCondition,
} from "../repositories/IIntegrationAttributeSyncRepository";
const conditionIdentifierSchema = z.nativeEnum(ConditionIdentifierEnum);
const conditionOperatorSchema = z.nativeEnum(ConditionOperatorEnum);
const ruleOperatorSchema = z.nativeEnum(RuleOperatorEnum);
// Base condition schema
const baseConditionSchema = z.object({
identifier: conditionIdentifierSchema,
operator: conditionOperatorSchema,
});
// Team condition schema
const teamConditionSchema = baseConditionSchema.extend({
identifier: z.literal(ConditionIdentifierEnum.TEAM_ID),
value: z.array(z.number()),
});
// Attribute condition schema
const attributeConditionSchema = baseConditionSchema.extend({
identifier: z.literal(ConditionIdentifierEnum.ATTRIBUTE_ID),
attributeId: z.string(),
value: z.array(z.string()),
});
// Discriminated union for conditions
export const attributeSyncRuleConditionSchema: z.ZodType<TAttributeSyncRuleCondition> =
z.discriminatedUnion("identifier", [teamConditionSchema, attributeConditionSchema]);
export const attributeSyncRuleSchema: z.ZodType<IAttributeSyncRule> = z.object({
operator: ruleOperatorSchema,
conditions: z.array(attributeSyncRuleConditionSchema),
});
// Define base schema without type annotation to preserve ZodObject methods like .extend()
const _newFieldMappingSchema = z.object({
integrationFieldName: z.string().min(1),
attributeId: z.string().min(1),
enabled: z.boolean(),
});
const _fieldMappingSchema = _newFieldMappingSchema.extend({
id: z.string(),
});
const _fieldMappingWithOptionalIdSchema = _newFieldMappingSchema.extend({
id: z.string().optional(),
});
// Cast to ZodType for type safety
export const newFieldMappingSchema: z.ZodType<INewFieldMapping> = _newFieldMappingSchema;
export const fieldMappingSchema: z.ZodType<IFieldMapping> = _fieldMappingSchema;
export const fieldMappingWithOptionalIdSchema: z.ZodType<IFieldMappingWithOptionalId> =
_fieldMappingWithOptionalIdSchema;
const _fieldMappingFormStateSchema: z.ZodType<IFieldMappingFormState> = z.object({
mappings: z.array(fieldMappingWithOptionalIdSchema),
});
export const syncFormDataSchema: z.ZodType<ISyncFormData> = z.object({
id: z.string(),
name: z.string().min(1, "Name is required"),
credentialId: z.number().optional(),
enabled: z.boolean(),
organizationId: z.number(),
ruleId: z.string(),
rule: attributeSyncRuleSchema,
syncFieldMappings: z.array(z.union([fieldMappingSchema, newFieldMappingSchema])),
});
@@ -0,0 +1,444 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import type { CredentialRepository } from "@calcom/features/credentials/repositories/CredentialRepository";
import { enabledAppSlugs } from "../constants";
import type { IIntegrationAttributeSyncRepository } from "../repositories/IIntegrationAttributeSyncRepository";
import { IntegrationAttributeSyncService, UnauthorizedAttributeError } from "./IntegrationAttributeSyncService";
describe("IntegrationAttributeSyncService", () => {
let service: IntegrationAttributeSyncService;
let mockCredentialRepository: {
findByTeamIdAndSlugs: ReturnType<typeof vi.fn>;
};
let mockIntegrationAttributeSyncRepository: {
getByOrganizationId: ReturnType<typeof vi.fn>;
getById: ReturnType<typeof vi.fn>;
getSyncFieldMappings: ReturnType<typeof vi.fn>;
getMappedAttributeIdsByOrganization: ReturnType<typeof vi.fn>;
getAttributeIdsByOrganization: ReturnType<typeof vi.fn>;
updateTransactionWithRuleAndMappings: ReturnType<typeof vi.fn>;
deleteById: ReturnType<typeof vi.fn>;
};
beforeEach(() => {
vi.resetAllMocks();
mockCredentialRepository = {
findByTeamIdAndSlugs: vi.fn(),
};
mockIntegrationAttributeSyncRepository = {
getByOrganizationId: vi.fn(),
getById: vi.fn(),
getSyncFieldMappings: vi.fn(),
getMappedAttributeIdsByOrganization: vi.fn(),
getAttributeIdsByOrganization: vi.fn(),
updateTransactionWithRuleAndMappings: vi.fn(),
deleteById: vi.fn(),
};
service = new IntegrationAttributeSyncService({
credentialRepository: mockCredentialRepository as unknown as CredentialRepository,
integrationAttributeSyncRepository:
mockIntegrationAttributeSyncRepository as unknown as IIntegrationAttributeSyncRepository,
});
});
describe("getEnabledAppCredentials", () => {
it("should call credentialRepository.findByTeamIdAndSlugs with correct params", async () => {
const organizationId = 123;
const expectedCredentials = [
{ id: 1, type: "salesforce", teamId: organizationId },
{ id: 2, type: "salesforce", teamId: organizationId },
];
mockCredentialRepository.findByTeamIdAndSlugs.mockResolvedValue(expectedCredentials);
const result = await service.getEnabledAppCredentials(organizationId);
expect(mockCredentialRepository.findByTeamIdAndSlugs).toHaveBeenCalledWith({
teamId: organizationId,
slugs: enabledAppSlugs,
});
expect(result).toEqual(expectedCredentials);
});
it("should return empty array when no credentials found", async () => {
const organizationId = 123;
mockCredentialRepository.findByTeamIdAndSlugs.mockResolvedValue([]);
const result = await service.getEnabledAppCredentials(organizationId);
expect(result).toEqual([]);
});
});
describe("getAllIntegrationAttributeSyncs", () => {
it("should call repository.getByOrganizationId with correct organizationId", async () => {
const organizationId = 456;
const expectedSyncs = [
{
id: "sync-1",
name: "Sync 1",
organizationId,
integration: "salesforce",
enabled: true,
attributeSyncRule: null,
syncFieldMappings: [],
},
];
mockIntegrationAttributeSyncRepository.getByOrganizationId.mockResolvedValue(expectedSyncs);
const result = await service.getAllIntegrationAttributeSyncs(organizationId);
expect(mockIntegrationAttributeSyncRepository.getByOrganizationId).toHaveBeenCalledWith(organizationId);
expect(result).toEqual(expectedSyncs);
});
it("should return empty array when no syncs found", async () => {
const organizationId = 456;
mockIntegrationAttributeSyncRepository.getByOrganizationId.mockResolvedValue([]);
const result = await service.getAllIntegrationAttributeSyncs(organizationId);
expect(result).toEqual([]);
});
});
describe("getById", () => {
it("should call repository.getById with correct id", async () => {
const syncId = "sync-123";
const expectedSync = {
id: syncId,
name: "Test Sync",
organizationId: 123,
integration: "salesforce",
enabled: true,
attributeSyncRule: null,
syncFieldMappings: [],
};
mockIntegrationAttributeSyncRepository.getById.mockResolvedValue(expectedSync);
const result = await service.getById(syncId);
expect(mockIntegrationAttributeSyncRepository.getById).toHaveBeenCalledWith(syncId);
expect(result).toEqual(expectedSync);
});
it("should return null when sync not found", async () => {
const syncId = "non-existent";
mockIntegrationAttributeSyncRepository.getById.mockResolvedValue(null);
const result = await service.getById(syncId);
expect(result).toBeNull();
});
});
describe("updateIncludeRulesAndMappings", () => {
const baseFormData = {
id: "sync-123",
name: "Test Sync",
credentialId: 1,
enabled: true,
organizationId: 123,
ruleId: "rule-123",
rule: {
operator: "AND" as const,
conditions: [
{
identifier: "teamId" as const,
operator: "equals" as const,
value: [1, 2],
},
],
},
};
it("should create new mappings when they don't have ids", async () => {
const formData = {
...baseFormData,
syncFieldMappings: [
{ integrationFieldName: "field1", attributeId: "attr-1", enabled: true },
{ integrationFieldName: "field2", attributeId: "attr-2", enabled: false },
],
};
mockIntegrationAttributeSyncRepository.getMappedAttributeIdsByOrganization.mockResolvedValue([]);
mockIntegrationAttributeSyncRepository.getAttributeIdsByOrganization.mockResolvedValue(["attr-1", "attr-2"]);
mockIntegrationAttributeSyncRepository.getSyncFieldMappings.mockResolvedValue([]);
mockIntegrationAttributeSyncRepository.updateTransactionWithRuleAndMappings.mockResolvedValue(undefined);
await service.updateIncludeRulesAndMappings(formData);
expect(mockIntegrationAttributeSyncRepository.updateTransactionWithRuleAndMappings).toHaveBeenCalledWith({
integrationAttributeSync: {
id: formData.id,
name: formData.name,
credentialId: formData.credentialId,
enabled: formData.enabled,
organizationId: formData.organizationId,
},
attributeSyncRule: {
id: formData.ruleId,
rule: formData.rule,
},
fieldMappingsToCreate: formData.syncFieldMappings,
fieldMappingsToUpdate: [],
fieldMappingsToDelete: [],
});
});
it("should update existing mappings when they have ids", async () => {
const formData = {
...baseFormData,
syncFieldMappings: [
{ id: "mapping-1", integrationFieldName: "field1", attributeId: "attr-1", enabled: true },
{ id: "mapping-2", integrationFieldName: "field2", attributeId: "attr-2", enabled: false },
],
};
mockIntegrationAttributeSyncRepository.getMappedAttributeIdsByOrganization.mockResolvedValue([]);
mockIntegrationAttributeSyncRepository.getAttributeIdsByOrganization.mockResolvedValue(["attr-1", "attr-2"]);
mockIntegrationAttributeSyncRepository.getSyncFieldMappings.mockResolvedValue([
{ id: "mapping-1", integrationFieldName: "field1", attributeId: "attr-1", enabled: true },
{ id: "mapping-2", integrationFieldName: "field2", attributeId: "attr-2", enabled: true },
]);
mockIntegrationAttributeSyncRepository.updateTransactionWithRuleAndMappings.mockResolvedValue(undefined);
await service.updateIncludeRulesAndMappings(formData);
expect(mockIntegrationAttributeSyncRepository.updateTransactionWithRuleAndMappings).toHaveBeenCalledWith({
integrationAttributeSync: {
id: formData.id,
name: formData.name,
credentialId: formData.credentialId,
enabled: formData.enabled,
organizationId: formData.organizationId,
},
attributeSyncRule: {
id: formData.ruleId,
rule: formData.rule,
},
fieldMappingsToCreate: [],
fieldMappingsToUpdate: formData.syncFieldMappings,
fieldMappingsToDelete: [],
});
});
it("should delete mappings that are no longer in the form data", async () => {
const formData = {
...baseFormData,
syncFieldMappings: [
{ id: "mapping-1", integrationFieldName: "field1", attributeId: "attr-1", enabled: true },
],
};
mockIntegrationAttributeSyncRepository.getMappedAttributeIdsByOrganization.mockResolvedValue([]);
mockIntegrationAttributeSyncRepository.getAttributeIdsByOrganization.mockResolvedValue(["attr-1"]);
mockIntegrationAttributeSyncRepository.getSyncFieldMappings.mockResolvedValue([
{ id: "mapping-1", integrationFieldName: "field1", attributeId: "attr-1", enabled: true },
{ id: "mapping-2", integrationFieldName: "field2", attributeId: "attr-2", enabled: true },
{ id: "mapping-3", integrationFieldName: "field3", attributeId: "attr-3", enabled: true },
]);
mockIntegrationAttributeSyncRepository.updateTransactionWithRuleAndMappings.mockResolvedValue(undefined);
await service.updateIncludeRulesAndMappings(formData);
expect(mockIntegrationAttributeSyncRepository.updateTransactionWithRuleAndMappings).toHaveBeenCalledWith({
integrationAttributeSync: {
id: formData.id,
name: formData.name,
credentialId: formData.credentialId,
enabled: formData.enabled,
organizationId: formData.organizationId,
},
attributeSyncRule: {
id: formData.ruleId,
rule: formData.rule,
},
fieldMappingsToCreate: [],
fieldMappingsToUpdate: [formData.syncFieldMappings[0]],
fieldMappingsToDelete: ["mapping-2", "mapping-3"],
});
});
it("should handle mixed create, update, and delete operations", async () => {
const formData = {
...baseFormData,
syncFieldMappings: [
{ id: "mapping-1", integrationFieldName: "field1-updated", attributeId: "attr-1", enabled: false },
{ integrationFieldName: "field-new", attributeId: "attr-new", enabled: true },
],
};
mockIntegrationAttributeSyncRepository.getMappedAttributeIdsByOrganization.mockResolvedValue([]);
mockIntegrationAttributeSyncRepository.getAttributeIdsByOrganization.mockResolvedValue(["attr-1", "attr-new"]);
mockIntegrationAttributeSyncRepository.getSyncFieldMappings.mockResolvedValue([
{ id: "mapping-1", integrationFieldName: "field1", attributeId: "attr-1", enabled: true },
{ id: "mapping-2", integrationFieldName: "field2", attributeId: "attr-2", enabled: true },
]);
mockIntegrationAttributeSyncRepository.updateTransactionWithRuleAndMappings.mockResolvedValue(undefined);
await service.updateIncludeRulesAndMappings(formData);
expect(mockIntegrationAttributeSyncRepository.updateTransactionWithRuleAndMappings).toHaveBeenCalledWith({
integrationAttributeSync: {
id: formData.id,
name: formData.name,
credentialId: formData.credentialId,
enabled: formData.enabled,
organizationId: formData.organizationId,
},
attributeSyncRule: {
id: formData.ruleId,
rule: formData.rule,
},
fieldMappingsToCreate: [
{ integrationFieldName: "field-new", attributeId: "attr-new", enabled: true },
],
fieldMappingsToUpdate: [
{ id: "mapping-1", integrationFieldName: "field1-updated", attributeId: "attr-1", enabled: false },
],
fieldMappingsToDelete: ["mapping-2"],
});
});
it("should handle attribute-based conditions in rules", async () => {
const formData = {
...baseFormData,
rule: {
operator: "OR" as const,
conditions: [
{
identifier: "attributeId" as const,
attributeId: "attr-123",
operator: "in" as const,
value: ["option-1", "option-2"],
},
],
},
syncFieldMappings: [],
};
mockIntegrationAttributeSyncRepository.getMappedAttributeIdsByOrganization.mockResolvedValue([]);
mockIntegrationAttributeSyncRepository.getAttributeIdsByOrganization.mockResolvedValue([]);
mockIntegrationAttributeSyncRepository.getSyncFieldMappings.mockResolvedValue([]);
mockIntegrationAttributeSyncRepository.updateTransactionWithRuleAndMappings.mockResolvedValue(undefined);
await service.updateIncludeRulesAndMappings(formData);
expect(mockIntegrationAttributeSyncRepository.updateTransactionWithRuleAndMappings).toHaveBeenCalledWith(
expect.objectContaining({
attributeSyncRule: {
id: formData.ruleId,
rule: formData.rule,
},
})
);
});
});
describe("validateAttributeOwnership", () => {
const baseFormData = {
id: "sync-123",
name: "Test Sync",
credentialId: 1,
enabled: true,
organizationId: 123,
ruleId: "rule-123",
rule: {
operator: "AND" as const,
conditions: [
{
identifier: "teamId" as const,
operator: "equals" as const,
value: [1, 2],
},
],
},
};
it("should throw UnauthorizedAttributeError when attribute does not belong to organization", async () => {
const formData = {
...baseFormData,
syncFieldMappings: [
{ integrationFieldName: "field1", attributeId: "attr-1", enabled: true },
{ integrationFieldName: "field2", attributeId: "attr-invalid", enabled: true },
],
};
mockIntegrationAttributeSyncRepository.getMappedAttributeIdsByOrganization.mockResolvedValue([]);
mockIntegrationAttributeSyncRepository.getAttributeIdsByOrganization.mockResolvedValue(["attr-1"]);
mockIntegrationAttributeSyncRepository.getSyncFieldMappings.mockResolvedValue([]);
await expect(service.updateIncludeRulesAndMappings(formData)).rejects.toThrow(UnauthorizedAttributeError);
});
it("should include invalid attribute ids in the error", async () => {
const formData = {
...baseFormData,
syncFieldMappings: [
{ integrationFieldName: "field1", attributeId: "attr-valid", enabled: true },
{ integrationFieldName: "field2", attributeId: "attr-invalid-1", enabled: true },
{ integrationFieldName: "field3", attributeId: "attr-invalid-2", enabled: true },
],
};
mockIntegrationAttributeSyncRepository.getMappedAttributeIdsByOrganization.mockResolvedValue([]);
mockIntegrationAttributeSyncRepository.getAttributeIdsByOrganization.mockResolvedValue(["attr-valid"]);
mockIntegrationAttributeSyncRepository.getSyncFieldMappings.mockResolvedValue([]);
await expect(service.updateIncludeRulesAndMappings(formData)).rejects.toSatisfy((error) => {
expect(error).toBeInstanceOf(UnauthorizedAttributeError);
expect((error as UnauthorizedAttributeError).attributeIds).toEqual(["attr-invalid-1", "attr-invalid-2"]);
return true;
});
});
it("should pass validation when all attributes belong to organization", async () => {
const formData = {
...baseFormData,
syncFieldMappings: [
{ integrationFieldName: "field1", attributeId: "attr-1", enabled: true },
{ integrationFieldName: "field2", attributeId: "attr-2", enabled: true },
],
};
mockIntegrationAttributeSyncRepository.getMappedAttributeIdsByOrganization.mockResolvedValue([]);
mockIntegrationAttributeSyncRepository.getAttributeIdsByOrganization.mockResolvedValue(["attr-1", "attr-2"]);
mockIntegrationAttributeSyncRepository.getSyncFieldMappings.mockResolvedValue([]);
mockIntegrationAttributeSyncRepository.updateTransactionWithRuleAndMappings.mockResolvedValue(undefined);
await expect(service.updateIncludeRulesAndMappings(formData)).resolves.not.toThrow();
});
it("should pass validation when syncFieldMappings is empty", async () => {
const formData = {
...baseFormData,
syncFieldMappings: [],
};
mockIntegrationAttributeSyncRepository.getMappedAttributeIdsByOrganization.mockResolvedValue([]);
mockIntegrationAttributeSyncRepository.getAttributeIdsByOrganization.mockResolvedValue([]);
mockIntegrationAttributeSyncRepository.getSyncFieldMappings.mockResolvedValue([]);
mockIntegrationAttributeSyncRepository.updateTransactionWithRuleAndMappings.mockResolvedValue(undefined);
await expect(service.updateIncludeRulesAndMappings(formData)).resolves.not.toThrow();
});
});
describe("deleteById", () => {
it("should call repository.deleteById with correct id", async () => {
const syncId = "sync-to-delete";
mockIntegrationAttributeSyncRepository.deleteById.mockResolvedValue(undefined);
await service.deleteById(syncId);
expect(mockIntegrationAttributeSyncRepository.deleteById).toHaveBeenCalledWith(syncId);
});
});
});
@@ -0,0 +1,212 @@
import type { CredentialRepository } from "@calcom/features/credentials/repositories/CredentialRepository";
import type { ZCreateAttributeSyncSchema } from "@calcom/trpc/server/routers/viewer/attribute-sync/createAttributeSync.schema";
import { enabledAppSlugs } from "../constants";
import {
type IIntegrationAttributeSyncRepository,
type ISyncFormData,
AttributeSyncIntegrations,
} 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;
}
export class IntegrationAttributeSyncService {
constructor(private readonly deps: IIntegrationAttributeSyncServiceDeps) {}
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 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 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);
}
}
@@ -0,0 +1,64 @@
-- CreateTable
CREATE TABLE "public"."IntegrationAttributeSync" (
"id" TEXT NOT NULL,
"organizationId" INTEGER NOT NULL,
"name" TEXT NOT NULL,
"integration" TEXT NOT NULL,
"credentialId" INTEGER,
"enabled" BOOLEAN NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "IntegrationAttributeSync_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "public"."AttributeSyncRule" (
"id" TEXT NOT NULL,
"integrationAttributeSyncId" TEXT NOT NULL,
"rule" JSONB NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "AttributeSyncRule_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "public"."AttributeSyncFieldMapping" (
"id" TEXT NOT NULL,
"integrationFieldName" TEXT NOT NULL,
"attributeId" TEXT NOT NULL,
"enabled" BOOLEAN NOT NULL,
"integrationAttributeSyncId" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "AttributeSyncFieldMapping_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE INDEX "IntegrationAttributeSync_organizationId_idx" ON "public"."IntegrationAttributeSync"("organizationId");
-- CreateIndex
CREATE UNIQUE INDEX "AttributeSyncRule_integrationAttributeSyncId_key" ON "public"."AttributeSyncRule"("integrationAttributeSyncId");
-- CreateIndex
CREATE INDEX "AttributeSyncFieldMapping_integrationAttributeSyncId_idx" ON "public"."AttributeSyncFieldMapping"("integrationAttributeSyncId");
-- CreateIndex
CREATE UNIQUE INDEX "AttributeSyncFieldMapping_integrationAttributeSyncId_attrib_key" ON "public"."AttributeSyncFieldMapping"("integrationAttributeSyncId", "attributeId");
-- AddForeignKey
ALTER TABLE "public"."IntegrationAttributeSync" ADD CONSTRAINT "IntegrationAttributeSync_organizationId_fkey" FOREIGN KEY ("organizationId") REFERENCES "public"."Team"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "public"."IntegrationAttributeSync" ADD CONSTRAINT "IntegrationAttributeSync_credentialId_fkey" FOREIGN KEY ("credentialId") REFERENCES "public"."Credential"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "public"."AttributeSyncRule" ADD CONSTRAINT "AttributeSyncRule_integrationAttributeSyncId_fkey" FOREIGN KEY ("integrationAttributeSyncId") REFERENCES "public"."IntegrationAttributeSync"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "public"."AttributeSyncFieldMapping" ADD CONSTRAINT "AttributeSyncFieldMapping_attributeId_fkey" FOREIGN KEY ("attributeId") REFERENCES "public"."Attribute"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "public"."AttributeSyncFieldMapping" ADD CONSTRAINT "AttributeSyncFieldMapping_integrationAttributeSyncId_fkey" FOREIGN KEY ("integrationAttributeSyncId") REFERENCES "public"."IntegrationAttributeSync"("id") ON DELETE CASCADE ON UPDATE CASCADE;
+68 -14
View File
@@ -295,13 +295,14 @@ model Credential {
paymentStatus String?
billingCycleStart Int?
destinationCalendars DestinationCalendar[]
selectedCalendars SelectedCalendar[]
invalid Boolean? @default(false)
CalendarCache CalendarCache[]
references BookingReference[]
delegationCredentialId String?
delegationCredential DelegationCredential? @relation(fields: [delegationCredentialId], references: [id], onDelete: Cascade)
destinationCalendars DestinationCalendar[]
selectedCalendars SelectedCalendar[]
invalid Boolean? @default(false)
CalendarCache CalendarCache[]
references BookingReference[]
delegationCredentialId String?
delegationCredential DelegationCredential? @relation(fields: [delegationCredentialId], references: [id], onDelete: Cascade)
integrationAttributeSyncs IntegrationAttributeSync[]
@@index([appId])
@@index([subscriptionId])
@@ -614,8 +615,9 @@ model Team {
managedOrganizations ManagedOrganization[] @relation("ManagerOrganization")
filterSegments FilterSegment[]
teamBilling TeamBilling? @relation("TeamBilling")
organizationBilling OrganizationBilling? @relation("OrganizationBilling")
teamBilling TeamBilling? @relation("TeamBilling")
organizationBilling OrganizationBilling? @relation("OrganizationBilling")
integrationAttributeSyncs IntegrationAttributeSync[]
autoOptInFeatures Boolean @default(false)
@@ -2230,11 +2232,12 @@ model Attribute {
usersCanEditRelation Boolean @default(false)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
options AttributeOption[]
isWeightsEnabled Boolean @default(false)
isLocked Boolean @default(false)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
options AttributeOption[]
isWeightsEnabled Boolean @default(false)
isLocked Boolean @default(false)
attributeSyncFieldMappings AttributeSyncFieldMapping[]
@@unique([teamId, slug])
@@index([teamId])
@@ -3103,3 +3106,54 @@ model CalendarCacheEvent {
@@index([start, end, status])
@@index([selectedCalendarId, iCalUID])
}
model IntegrationAttributeSync {
id String @id @default(uuid())
organizationId Int
organization Team @relation(fields: [organizationId], references: [id], onDelete: Cascade)
name String
integration String
credentialId Int?
credential Credential? @relation(fields: [credentialId], references: [id], onDelete: Cascade)
enabled Boolean
attributeSyncRule AttributeSyncRule?
syncFieldMappings AttributeSyncFieldMapping[]
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([organizationId])
@@index([credentialId])
}
model AttributeSyncRule {
id String @id @default(uuid())
integrationAttributeSyncId String @unique
integrationAttributeSync IntegrationAttributeSync @relation(fields: [integrationAttributeSyncId], references: [id], onDelete: Cascade)
rule Json
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
model AttributeSyncFieldMapping {
id String @id @default(uuid())
integrationFieldName String
attributeId String
attribute Attribute @relation(fields: [attributeId], references: [id], onDelete: Cascade)
enabled Boolean
integrationAttributeSyncId String
integrationAttributeSync IntegrationAttributeSync @relation(fields: [integrationAttributeSyncId], references: [id], onDelete: Cascade)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@unique([integrationAttributeSyncId, attributeId])
@@index([integrationAttributeSyncId])
}
+1
View File
@@ -45,4 +45,5 @@ export const ENDPOINTS = [
"aiVoiceAgent",
"phoneNumber",
"pbac",
"attributeSync",
] as const;
@@ -10,6 +10,7 @@ import { aiVoiceAgentRouter } from "./aiVoiceAgent/_router";
import { apiKeysRouter } from "./apiKeys/_router";
import { appsRouter } from "./apps/_router";
import { attributesRouter } from "./attributes/_router";
import { attributeSyncRouter } from "./attribute-sync/_router";
import { authRouter } from "./auth/_router";
import { availabilityRouter } from "./availability/_router";
import { bookingsRouter } from "./bookings/_router";
@@ -83,6 +84,7 @@ export const viewerRouter = router({
googleWorkspace: googleWorkspaceRouter,
admin: adminRouter,
attributes: attributesRouter,
attributeSync: attributeSyncRouter,
routingForms: routingFormsRouter,
credits: creditsRouter,
ooo: oooRouter,
@@ -0,0 +1,36 @@
import { router } from "../../../trpc";
import { createAttributeSyncSchema } from "./createAttributeSync.schema";
import { deleteAttributeSyncSchema } from "./deleteAttributeSync.schema";
import { getAllAttributeSyncsSchema } from "./getAllAttributeSyncs.schema";
import { updateAttributeSyncSchema } from "./updateAttributeSync.schema";
import { createAttributePbacProcedure } from "./util";
export const attributeSyncRouter = router({
getAllAttributeSyncs: createAttributePbacProcedure("organization.attributes.read")
.input(getAllAttributeSyncsSchema)
.query(async (opts) => {
const { default: handler } = await import("./getAllAttributeSyncs.handler");
return handler(opts);
}),
createAttributeSync: createAttributePbacProcedure("organization.attributes.create")
.input(createAttributeSyncSchema)
.mutation(async (opts) => {
const { default: handler } = await import("./createAttributeSync.handler");
return handler(opts);
}),
updateAttributeSync: createAttributePbacProcedure("organization.attributes.update")
.input(updateAttributeSyncSchema)
.mutation(async (opts) => {
const { default: handler } = await import("./updateAttributeSync.handler");
return handler(opts);
}),
deleteAttributeSync: createAttributePbacProcedure("organization.attributes.delete")
.input(deleteAttributeSyncSchema)
.mutation(async (opts) => {
const { default: handler } = await import("./deleteAttributeSync.handler");
return handler(opts);
}),
});
@@ -0,0 +1,65 @@
import { getIntegrationAttributeSyncService } from "@calcom/ee/integration-attribute-sync/di/IntegrationAttributeSyncService.container";
import {
CredentialNotFoundError,
DuplicateAttributeWithinSyncError,
DuplicateAttributeAcrossSyncsError,
} from "@calcom/ee/integration-attribute-sync/services/IntegrationAttributeSyncService";
import { TRPCError } from "@trpc/server";
import type { TrpcSessionUser } from "../../../types";
import type { ZCreateAttributeSyncSchema } from "./createAttributeSync.schema";
type CreateAttributeSyncOptions = {
ctx: {
user: NonNullable<TrpcSessionUser>;
};
input: ZCreateAttributeSyncSchema;
};
const createAttributeSyncHandler = async ({
ctx,
input,
}: CreateAttributeSyncOptions) => {
const org = ctx.user.organization;
if (!org?.id) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "You need to be part of an organization to use this feature",
});
}
const integrationAttributeSyncService = getIntegrationAttributeSyncService();
try {
return await integrationAttributeSyncService.createAttributeSync(
input,
org.id
);
} catch (error) {
if (error instanceof CredentialNotFoundError) {
throw new TRPCError({
code: "NOT_FOUND",
message: "Credential not found",
});
}
if (error instanceof DuplicateAttributeWithinSyncError) {
throw new TRPCError({
code: "BAD_REQUEST",
message:
"Each attribute can only be mapped once per sync. Please remove duplicate attribute mappings.",
});
}
if (error instanceof DuplicateAttributeAcrossSyncsError) {
throw new TRPCError({
code: "BAD_REQUEST",
message:
"An attribute is already mapped in another sync. Each attribute can only be synced from one source.",
});
}
throw error;
}
};
export default createAttributeSyncHandler;
@@ -0,0 +1,17 @@
import { z } from "zod";
import type { ICreateAttributeSyncInput } from "@calcom/features/ee/integration-attribute-sync/repositories/IIntegrationAttributeSyncRepository";
import {
attributeSyncRuleSchema,
newFieldMappingSchema,
} from "@calcom/features/ee/integration-attribute-sync/schemas/zod";
export const createAttributeSyncSchema: z.ZodType<ICreateAttributeSyncInput> = z.object({
name: z.string().min(1, "Name is required"),
credentialId: z.number(),
rule: attributeSyncRuleSchema,
syncFieldMappings: z.array(newFieldMappingSchema),
enabled: z.boolean(),
});
export type ZCreateAttributeSyncSchema = ICreateAttributeSyncInput;
@@ -0,0 +1,29 @@
import { getIntegrationAttributeSyncService } from "@calcom/ee/integration-attribute-sync/di/IntegrationAttributeSyncService.container";
import { TRPCError } from "@trpc/server";
import type { TrpcSessionUser } from "../../../types";
import type { ZDeleteAttributeSyncSchema } from "./deleteAttributeSync.schema";
type DeleteAttributeSyncOptions = {
ctx: {
user: NonNullable<TrpcSessionUser>;
};
input: ZDeleteAttributeSyncSchema;
};
const deleteAttributeSyncHandler = async ({ ctx, input }: DeleteAttributeSyncOptions) => {
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);
};
export default deleteAttributeSyncHandler;
@@ -0,0 +1,7 @@
import { z } from "zod";
export const deleteAttributeSyncSchema = z.object({
id: z.string(),
});
export type ZDeleteAttributeSyncSchema = z.infer<typeof deleteAttributeSyncSchema>;
@@ -0,0 +1,30 @@
import { getIntegrationAttributeSyncService } from "@calcom/features/ee/integration-attribute-sync/di/IntegrationAttributeSyncService.container";
import { TRPCError } from "@trpc/server";
import type { TrpcSessionUser } from "../../../types";
import type { ZGetAllAttributeSyncsSchema } from "./getAllAttributeSyncs.schema";
type GetAllAttributeSyncsOptions = {
ctx: {
user: NonNullable<TrpcSessionUser>;
};
input: ZGetAllAttributeSyncsSchema;
};
const getAllAttributeSyncsHandler = async ({ ctx }: GetAllAttributeSyncsOptions) => {
const org = ctx.user.organization;
if (!org?.id) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "You need to be part of an organization to use this feature",
});
}
const service = getIntegrationAttributeSyncService();
return await service.getAllIntegrationAttributeSyncs(org.id);
};
export default getAllAttributeSyncsHandler;
@@ -0,0 +1,6 @@
import { z } from "zod";
// No input required - organizationId comes from context
export const getAllAttributeSyncsSchema = z.object({});
export type ZGetAllAttributeSyncsSchema = z.infer<typeof getAllAttributeSyncsSchema>;
@@ -0,0 +1,65 @@
import { getIntegrationAttributeSyncService } from "@calcom/ee/integration-attribute-sync/di/IntegrationAttributeSyncService.container";
import {
DuplicateAttributeWithinSyncError,
DuplicateAttributeAcrossSyncsError,
} from "@calcom/ee/integration-attribute-sync/services/IntegrationAttributeSyncService";
import { TRPCError } from "@trpc/server";
import type { TrpcSessionUser } from "../../../types";
import { ZUpdateAttributeSyncSchema } from "./updateAttributeSync.schema";
type UpdateAttributeSyncOptions = {
ctx: {
user: NonNullable<TrpcSessionUser>;
};
input: ZUpdateAttributeSyncSchema;
};
const updateAttributeSyncHandler = async ({
ctx,
input,
}: UpdateAttributeSyncOptions) => {
const org = ctx.user.organization;
if (!org?.id) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "You need to be part of an organization to use this feature",
});
}
const integrationAttributeSyncService = getIntegrationAttributeSyncService();
// Verify the sync exists and belongs to the user's organization
const integrationAttributeSync =
await integrationAttributeSyncService.getById(input.id);
if (!integrationAttributeSync) throw new TRPCError({ code: "NOT_FOUND" });
if (integrationAttributeSync.organizationId !== org.id) {
throw new TRPCError({ code: "UNAUTHORIZED" });
}
try {
await integrationAttributeSyncService.updateIncludeRulesAndMappings(input);
} catch (error) {
if (error instanceof DuplicateAttributeWithinSyncError) {
throw new TRPCError({
code: "BAD_REQUEST",
message:
"Each attribute can only be mapped once per sync. Please remove duplicate attribute mappings.",
});
}
if (error instanceof DuplicateAttributeAcrossSyncsError) {
throw new TRPCError({
code: "BAD_REQUEST",
message:
"This attribute is already mapped in another sync. Each attribute can only be synced from one source.",
});
}
throw error;
}
};
export default updateAttributeSyncHandler;
@@ -0,0 +1,20 @@
import { z } from "zod";
import type { IUpdateAttributeSyncInput } from "@calcom/features/ee/integration-attribute-sync/repositories/IIntegrationAttributeSyncRepository";
import {
attributeSyncRuleSchema,
fieldMappingWithOptionalIdSchema,
} from "@calcom/features/ee/integration-attribute-sync/schemas/zod";
export const updateAttributeSyncSchema: z.ZodType<IUpdateAttributeSyncInput> = z.object({
id: z.string(),
name: z.string().min(1, "Name is required"),
credentialId: z.number().optional(),
enabled: z.boolean(),
organizationId: z.number(),
ruleId: z.string(),
rule: attributeSyncRuleSchema,
syncFieldMappings: z.array(fieldMappingWithOptionalIdSchema),
});
export type ZUpdateAttributeSyncSchema = IUpdateAttributeSyncInput;
@@ -0,0 +1,2 @@
// Re-export the attribute PBAC procedure for reuse in attribute-sync endpoints
export { createAttributePbacProcedure } from "../attributes/util";
@@ -1,3 +1,4 @@
import { TeamRepository } from "@calcom/features/ee/teams/repositories/TeamRepository";
import { PermissionCheckService } from "@calcom/features/pbac/services/permission-check.service";
import { prisma } from "@calcom/prisma";
import { MembershipRole } from "@calcom/prisma/enums";
@@ -34,10 +35,9 @@ export async function getTeamsHandler({ ctx }: GetTeamsHandler) {
});
}
const allOrgTeams = await prisma.team.findMany({
where: {
parentId: currentUserOrgId,
},
const teamRepository = new TeamRepository(prisma);
const allOrgTeams = await teamRepository.findAllByParentId({
parentId: currentUserOrgId,
select: {
id: true,
name: true,
+4 -1
View File
@@ -103,6 +103,7 @@ export default function FormCard({
badge,
collapsible = true,
leftIcon,
customActions,
...restProps
}: {
children: React.ReactNode;
@@ -117,6 +118,7 @@ export default function FormCard({
badge?: { text: string; href?: string; variant: BadgeProps["variant"] } | null;
leftIcon?: IconName;
collapsible?: boolean;
customActions?: React.ReactNode;
} & JSX.IntrinsicElements["div"]) {
className = classNames(
"flex items-center group relative w-full rounded-2xl p-1 border border-subtle bg-cal-muted mb-2",
@@ -192,7 +194,8 @@ export default function FormCard({
</Badge>
)}
</div>
<div>
<div className="flex items-center gap-2">
{customActions}
<FormCardActions deleteField={deleteField} duplicateField={duplicateField} />
</div>
</div>