From 8dbe3848e713af038b64ea39dc7d9ccecf206dd4 Mon Sep 17 00:00:00 2001 From: Joe Au-Yeung <65426560+joeauyeung@users.noreply.github.com> Date: Mon, 12 Jan 2026 17:57:28 -0500 Subject: [PATCH] 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 * 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 * fix: remove incorrect test that expected extra fields to pass through syncFormDataSchema Co-Authored-By: joe@cal.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 * 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 * refactor: rename updateFeatureForUser to setUserFeatureState Rename to match the convention used for setTeamFeatureState Co-Authored-By: eunjae@cal.com * 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 - 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 * 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 * 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 * refactor: use team.isOrganization and clarify computeEffectiveState comment Co-Authored-By: eunjae@cal.com * refactor: use MembershipRepository.findAllByUserId with isOrganization Co-Authored-By: eunjae@cal.com * feat: add featureId validation using isOptInFeature type guard Co-Authored-By: eunjae@cal.com * 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 * 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 * 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 * Pull test file from main --------- Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Co-authored-by: Eunjae Lee --- .../(org-admin-only)/attributes/sync/page.tsx | 62 +++ .../pages/api/trpc/attributeSync/[trpc].ts | 4 + apps/web/public/static/locales/en/common.json | 42 ++ .../repositories/CredentialRepository.ts | 29 + .../IntegrationAttributeSyncView.tsx | 106 ++++ .../EditIntegrationAttributeSyncCard.tsx | 57 ++ .../components/FieldMappingBuilder.tsx | 196 +++++++ .../IntegrationAttributeSyncCard.tsx | 312 +++++++++++ .../NewIntegrationAttributeSyncCard.tsx | 47 ++ .../components/RuleBuilder.tsx | 495 ++++++++++++++++++ .../integration-attribute-sync/constants.ts | 2 + ...tegrationAttributeSyncRepository.module.ts | 23 + ...tegrationAttributeSyncService.container.ts | 16 + .../IntegrationAttributeSyncService.module.ts | 27 + .../integration-attribute-sync/di/tokens.ts | 6 + .../lib/fieldMappingHelpers.ts | 7 + .../lib/ruleHelpers.ts | 108 ++++ .../IntegrationAttributeSyncOutputMapper.ts | 53 ++ .../IIntegrationAttributeSyncRepository.ts | 145 +++++ ...rismaIntegrationAttributeSyncRepository.ts | 210 ++++++++ .../schemas/zod.test.ts | 419 +++++++++++++++ .../integration-attribute-sync/schemas/zod.ts | 82 +++ .../IntegrationAttributeSyncService.test.ts | 444 ++++++++++++++++ .../IntegrationAttributeSyncService.ts | 212 ++++++++ .../migration.sql | 64 +++ packages/prisma/schema.prisma | 82 ++- packages/trpc/react/shared.ts | 1 + .../trpc/server/routers/viewer/_router.tsx | 2 + .../routers/viewer/attribute-sync/_router.ts | 36 ++ .../createAttributeSync.handler.ts | 65 +++ .../createAttributeSync.schema.ts | 17 + .../deleteAttributeSync.handler.ts | 29 + .../deleteAttributeSync.schema.ts | 7 + .../getAllAttributeSyncs.handler.ts | 30 ++ .../getAllAttributeSyncs.schema.ts | 6 + .../updateAttributeSync.handler.ts | 65 +++ .../updateAttributeSync.schema.ts | 20 + .../routers/viewer/attribute-sync/util.ts | 2 + .../viewer/organizations/getTeams.handler.ts | 8 +- packages/ui/components/card/FormCard.tsx | 5 +- 40 files changed, 3524 insertions(+), 19 deletions(-) create mode 100644 apps/web/app/(use-page-wrapper)/settings/(settings-layout)/organizations/(org-admin-only)/attributes/sync/page.tsx create mode 100644 apps/web/pages/api/trpc/attributeSync/[trpc].ts create mode 100644 packages/features/ee/integration-attribute-sync/IntegrationAttributeSyncView.tsx create mode 100644 packages/features/ee/integration-attribute-sync/components/EditIntegrationAttributeSyncCard.tsx create mode 100644 packages/features/ee/integration-attribute-sync/components/FieldMappingBuilder.tsx create mode 100644 packages/features/ee/integration-attribute-sync/components/IntegrationAttributeSyncCard.tsx create mode 100644 packages/features/ee/integration-attribute-sync/components/NewIntegrationAttributeSyncCard.tsx create mode 100644 packages/features/ee/integration-attribute-sync/components/RuleBuilder.tsx create mode 100644 packages/features/ee/integration-attribute-sync/constants.ts create mode 100644 packages/features/ee/integration-attribute-sync/di/IntegrationAttributeSyncRepository.module.ts create mode 100644 packages/features/ee/integration-attribute-sync/di/IntegrationAttributeSyncService.container.ts create mode 100644 packages/features/ee/integration-attribute-sync/di/IntegrationAttributeSyncService.module.ts create mode 100644 packages/features/ee/integration-attribute-sync/di/tokens.ts create mode 100644 packages/features/ee/integration-attribute-sync/lib/fieldMappingHelpers.ts create mode 100644 packages/features/ee/integration-attribute-sync/lib/ruleHelpers.ts create mode 100644 packages/features/ee/integration-attribute-sync/mappers/IntegrationAttributeSyncOutputMapper.ts create mode 100644 packages/features/ee/integration-attribute-sync/repositories/IIntegrationAttributeSyncRepository.ts create mode 100644 packages/features/ee/integration-attribute-sync/repositories/PrismaIntegrationAttributeSyncRepository.ts create mode 100644 packages/features/ee/integration-attribute-sync/schemas/zod.test.ts create mode 100644 packages/features/ee/integration-attribute-sync/schemas/zod.ts create mode 100644 packages/features/ee/integration-attribute-sync/services/IntegrationAttributeSyncService.test.ts create mode 100644 packages/features/ee/integration-attribute-sync/services/IntegrationAttributeSyncService.ts create mode 100644 packages/prisma/migrations/20260112172746_add_integration_attribute_sync/migration.sql create mode 100644 packages/trpc/server/routers/viewer/attribute-sync/_router.ts create mode 100644 packages/trpc/server/routers/viewer/attribute-sync/createAttributeSync.handler.ts create mode 100644 packages/trpc/server/routers/viewer/attribute-sync/createAttributeSync.schema.ts create mode 100644 packages/trpc/server/routers/viewer/attribute-sync/deleteAttributeSync.handler.ts create mode 100644 packages/trpc/server/routers/viewer/attribute-sync/deleteAttributeSync.schema.ts create mode 100644 packages/trpc/server/routers/viewer/attribute-sync/getAllAttributeSyncs.handler.ts create mode 100644 packages/trpc/server/routers/viewer/attribute-sync/getAllAttributeSyncs.schema.ts create mode 100644 packages/trpc/server/routers/viewer/attribute-sync/updateAttributeSync.handler.ts create mode 100644 packages/trpc/server/routers/viewer/attribute-sync/updateAttributeSync.schema.ts create mode 100644 packages/trpc/server/routers/viewer/attribute-sync/util.ts 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 ( +
+
+
+ +
+ + + +
+ + + {/* 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 "NUMBER": + return ( +
+ +
+ ); + } + }; + + return ( + <> + {t("attribute").toLowerCase()} + + + + {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 ( +
+
+
+ +
+ {t("attribute_sync_user_filter_rules")} +
+ +
+ {t("attribute_sync_sync_users_where")} +