diff --git a/apps/web/app/(use-page-wrapper)/settings/(settings-layout)/organizations/(org-admin-only)/attributes/sync/page.tsx b/apps/web/app/(use-page-wrapper)/settings/(settings-layout)/organizations/(org-admin-only)/attributes/sync/page.tsx
new file mode 100644
index 0000000000..a43c7512a8
--- /dev/null
+++ b/apps/web/app/(use-page-wrapper)/settings/(settings-layout)/organizations/(org-admin-only)/attributes/sync/page.tsx
@@ -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 (
+
+
+
+ );
+};
+
+export default Page;
diff --git a/apps/web/pages/api/trpc/attributeSync/[trpc].ts b/apps/web/pages/api/trpc/attributeSync/[trpc].ts
new file mode 100644
index 0000000000..c679db1227
--- /dev/null
+++ b/apps/web/pages/api/trpc/attributeSync/[trpc].ts
@@ -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);
diff --git a/apps/web/public/static/locales/en/common.json b/apps/web/public/static/locales/en/common.json
index fbe5b15f97..1a7aa92746 100644
--- a/apps/web/public/static/locales/en/common.json
+++ b/apps/web/public/static/locales/en/common.json
@@ -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",
diff --git a/packages/features/credentials/repositories/CredentialRepository.ts b/packages/features/credentials/repositories/CredentialRepository.ts
index e4f4ab4ead..ed06ee2e8c 100644
--- a/packages/features/credentials/repositories/CredentialRepository.ts
+++ b/packages/features/credentials/repositories/CredentialRepository.ts
@@ -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,
+ },
+ },
+ },
+ });
+ }
}
diff --git a/packages/features/ee/integration-attribute-sync/IntegrationAttributeSyncView.tsx b/packages/features/ee/integration-attribute-sync/IntegrationAttributeSyncView.tsx
new file mode 100644
index 0000000000..89afb4982b
--- /dev/null
+++ b/packages/features/ee/integration-attribute-sync/IntegrationAttributeSyncView.tsx
@@ -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 && (
+
+ {integrationAttributeSyncs.map((sync) => (
+
+ ))}
+
+ )}
+
+ {showNewSync && (
+
+ )}
+
+ >
+ );
+};
+
+export default IntegrationAttributeSyncView;
diff --git a/packages/features/ee/integration-attribute-sync/components/EditIntegrationAttributeSyncCard.tsx b/packages/features/ee/integration-attribute-sync/components/EditIntegrationAttributeSyncCard.tsx
new file mode 100644
index 0000000000..0a5f2b90fc
--- /dev/null
+++ b/packages/features/ee/integration-attribute-sync/components/EditIntegrationAttributeSyncCard.tsx
@@ -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 (
+
+ );
+};
+
+export default EditIntegrationAttributeSyncCard;
diff --git a/packages/features/ee/integration-attribute-sync/components/FieldMappingBuilder.tsx b/packages/features/ee/integration-attribute-sync/components/FieldMappingBuilder.tsx
new file mode 100644
index 0000000000..22e8a2db27
--- /dev/null
+++ b/packages/features/ee/integration-attribute-sync/components/FieldMappingBuilder.tsx
@@ -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) => {
+ 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 (
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {error &&
{error}
}
+
+ );
+};
+
+export const FieldMappingBuilder = ({
+ value,
+ onChange,
+ attributeOptions,
+ isLoadingAttributes,
+}: FieldMappingBuilderProps) => {
+ const { t } = useLocale();
+
+ // Find indices of rows with duplicate attribute mappings
+ const duplicateIndices = new Set();
+ const seenAttributes = new Map(); // 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 (
+
+
+
+
+
+
{t("attribute_sync_field_mappings")}
+
+
+
+ {value.mappings.length === 0 ? (
+
{t("attribute_sync_no_mappings")}
+ ) : (
+ value.mappings.map((mapping, index) => (
+
handleMappingChange(index, newMapping)}
+ onRemove={() => handleRemoveMapping(index)}
+ attributeOptions={attributeOptions}
+ isLoading={isLoadingAttributes}
+ error={
+ duplicateIndices.has(index)
+ ? t("attribute_sync_duplicate_attribute_mapping")
+ : undefined
+ }
+ />
+ ))
+ )}
+
+
+
+
+ );
+};
diff --git a/packages/features/ee/integration-attribute-sync/components/IntegrationAttributeSyncCard.tsx b/packages/features/ee/integration-attribute-sync/components/IntegrationAttributeSyncCard.tsx
new file mode 100644
index 0000000000..215e144ed8
--- /dev/null
+++ b/packages/features/ee/integration-attribute-sync/components/IntegrationAttributeSyncCard.tsx
@@ -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({
+ 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 (
+ <>
+
+
+
+ >
+ );
+};
+
+export default IntegrationAttributeSyncCard;
diff --git a/packages/features/ee/integration-attribute-sync/components/NewIntegrationAttributeSyncCard.tsx b/packages/features/ee/integration-attribute-sync/components/NewIntegrationAttributeSyncCard.tsx
new file mode 100644
index 0000000000..e2c4e0c516
--- /dev/null
+++ b/packages/features/ee/integration-attribute-sync/components/NewIntegrationAttributeSyncCard.tsx
@@ -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 (
+
+ );
+};
+
+export default NewIntegrationAttributeSyncCard;
diff --git a/packages/features/ee/integration-attribute-sync/components/RuleBuilder.tsx b/packages/features/ee/integration-attribute-sync/components/RuleBuilder.tsx
new file mode 100644
index 0000000000..9aec9ac07c
--- /dev/null
+++ b/packages/features/ee/integration-attribute-sync/components/RuleBuilder.tsx
@@ -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 (
+
+ {/* Condition type selector */}
+
+
+ {/* Render team or attribute specific fields */}
+ {isTeamCondition(condition) ? (
+
+ ) : (
+
+ )}
+
+
+
+ );
+};
+
+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 (
+ <>
+ {t("team").toLowerCase()}
+
+
+
+
+
+
+ >
+ );
+};
+
+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) => {
+ onChange({
+ ...condition,
+ value: [e.target.value],
+ });
+ };
+
+ const handleNumberValueChange = (e: React.ChangeEvent) => {
+ onChange({
+ ...condition,
+ value: [e.target.value],
+ });
+ };
+
+ // Render value input based on attribute type
+ const renderValueInput = () => {
+ if (!selectedAttribute) {
+ return (
+
+ {t("attribute_sync_select_attribute_first")}
+
+ );
+ }
+
+ 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 (
+
+
+
+ );
+ }
+
+ case "TEXT":
+ return (
+
+
+
+ );
+
+ case "NUMBER":
+ return (
+
+
+
+ );
+ }
+ };
+
+ return (
+ <>
+ {t("attribute").toLowerCase()}
+
+