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")} +