diff --git a/packages/twenty-front/src/modules/action-menu/actions/record-actions/constants/DefaultRecordActionsConfig.tsx b/packages/twenty-front/src/modules/action-menu/actions/record-actions/constants/DefaultRecordActionsConfig.tsx
index a30a8496224..4a70e811c5f 100644
--- a/packages/twenty-front/src/modules/action-menu/actions/record-actions/constants/DefaultRecordActionsConfig.tsx
+++ b/packages/twenty-front/src/modules/action-menu/actions/record-actions/constants/DefaultRecordActionsConfig.tsx
@@ -1,4 +1,5 @@
import { ActionLink } from '@/action-menu/actions/components/ActionLink';
+import { ActionOpenSidePanelPage } from '@/action-menu/actions/components/ActionOpenSidePanelPage';
import { DeleteMultipleRecordsAction } from '@/action-menu/actions/record-actions/multiple-records/components/DeleteMultipleRecordsAction';
import { DestroyMultipleRecordsAction } from '@/action-menu/actions/record-actions/multiple-records/components/DestroyMultipleRecordsAction';
import { ExportMultipleRecordsAction } from '@/action-menu/actions/record-actions/multiple-records/components/ExportMultipleRecordsAction';
@@ -24,6 +25,7 @@ import { ActionConfig } from '@/action-menu/actions/types/ActionConfig';
import { ActionScope } from '@/action-menu/actions/types/ActionScope';
import { ActionType } from '@/action-menu/actions/types/ActionType';
import { ActionViewType } from '@/action-menu/actions/types/ActionViewType';
+import { CommandMenuPages } from '@/command-menu/types/CommandMenuPages';
import { CoreObjectNamePlural } from '@/object-metadata/types/CoreObjectNamePlural';
import { CoreObjectNameSingular } from '@/object-metadata/types/CoreObjectNameSingular';
import { BACKEND_BATCH_REQUEST_MAX_COUNT } from '@/object-record/constants/BackendBatchRequestMaxCount';
@@ -31,8 +33,11 @@ import { AppPath } from '@/types/AppPath';
import { SettingsPath } from '@/types/SettingsPath';
import { msg } from '@lingui/core/macro';
import { isNonEmptyString } from '@sniptt/guards';
+import { MUTATION_MAX_MERGE_RECORDS } from 'twenty-shared/constants';
+
import { isDefined } from 'twenty-shared/utils';
import {
+ IconArrowMerge,
IconBuildingSkyscraper,
IconCheckbox,
IconChevronDown,
@@ -173,13 +178,42 @@ export const DEFAULT_RECORD_ACTIONS_CONFIG: Record<
component: ,
requiredPermissionFlag: PermissionFlagType.EXPORT_CSV,
},
+ [MultipleRecordsActionKeys.MERGE]: {
+ type: ActionType.Standard,
+ scope: ActionScope.RecordSelection,
+ key: MultipleRecordsActionKeys.MERGE,
+ label: msg`Merge records`,
+ shortLabel: msg`Merge`,
+ position: 5,
+ Icon: IconArrowMerge,
+ accent: 'default',
+ isPinned: false,
+ shouldBeRegistered: ({
+ objectMetadataItem,
+ numberOfSelectedRecords,
+ objectPermissions,
+ }) =>
+ isDefined(objectMetadataItem?.duplicateCriteria) &&
+ isDefined(numberOfSelectedRecords) &&
+ Boolean(objectPermissions.canUpdateObjectRecords) &&
+ Boolean(objectPermissions.canDestroyObjectRecords) &&
+ numberOfSelectedRecords <= MUTATION_MAX_MERGE_RECORDS,
+ availableOn: [ActionViewType.INDEX_PAGE_BULK_SELECTION],
+ component: (
+
+ ),
+ },
[MultipleRecordsActionKeys.EXPORT]: {
type: ActionType.Standard,
scope: ActionScope.RecordSelection,
key: MultipleRecordsActionKeys.EXPORT,
label: msg`Export records`,
shortLabel: msg`Export`,
- position: 5,
+ position: 6,
Icon: IconFileExport,
accent: 'default',
isPinned: false,
@@ -194,7 +228,7 @@ export const DEFAULT_RECORD_ACTIONS_CONFIG: Record<
key: NoSelectionRecordActionKeys.IMPORT_RECORDS,
label: msg`Import records`,
shortLabel: msg`Import`,
- position: 6,
+ position: 7,
Icon: IconFileImport,
accent: 'default',
isPinned: false,
@@ -210,7 +244,7 @@ export const DEFAULT_RECORD_ACTIONS_CONFIG: Record<
key: NoSelectionRecordActionKeys.EXPORT_VIEW,
label: msg`Export view`,
shortLabel: msg`Export`,
- position: 7,
+ position: 8,
Icon: IconFileExport,
accent: 'default',
isPinned: false,
@@ -225,7 +259,7 @@ export const DEFAULT_RECORD_ACTIONS_CONFIG: Record<
key: SingleRecordActionKeys.DELETE,
label: msg`Delete`,
shortLabel: msg`Delete`,
- position: 8,
+ position: 9,
Icon: IconTrash,
accent: 'default',
isPinned: true,
@@ -252,7 +286,7 @@ export const DEFAULT_RECORD_ACTIONS_CONFIG: Record<
key: MultipleRecordsActionKeys.DELETE,
label: msg`Delete records`,
shortLabel: msg`Delete`,
- position: 9,
+ position: 10,
Icon: IconTrash,
accent: 'default',
isPinned: true,
@@ -277,7 +311,7 @@ export const DEFAULT_RECORD_ACTIONS_CONFIG: Record<
key: NoSelectionRecordActionKeys.SEE_DELETED_RECORDS,
label: msg`See deleted records`,
shortLabel: msg`Deleted records`,
- position: 10,
+ position: 11,
Icon: IconRotate2,
accent: 'default',
isPinned: false,
@@ -292,7 +326,7 @@ export const DEFAULT_RECORD_ACTIONS_CONFIG: Record<
key: NoSelectionRecordActionKeys.CREATE_NEW_VIEW,
label: msg`Create View`,
shortLabel: msg`Create View`,
- position: 11,
+ position: 12,
Icon: IconLayout,
accent: 'default',
isPinned: false,
@@ -307,7 +341,7 @@ export const DEFAULT_RECORD_ACTIONS_CONFIG: Record<
key: NoSelectionRecordActionKeys.HIDE_DELETED_RECORDS,
label: msg`Hide deleted records`,
shortLabel: msg`Hide deleted`,
- position: 12,
+ position: 13,
Icon: IconEyeOff,
accent: 'default',
isPinned: false,
@@ -322,7 +356,7 @@ export const DEFAULT_RECORD_ACTIONS_CONFIG: Record<
key: SingleRecordActionKeys.DESTROY,
label: msg`Permanently destroy record`,
shortLabel: msg`Destroy`,
- position: 13,
+ position: 14,
Icon: IconTrashX,
accent: 'danger',
isPinned: true,
@@ -342,7 +376,7 @@ export const DEFAULT_RECORD_ACTIONS_CONFIG: Record<
scope: ActionScope.RecordSelection,
key: SingleRecordActionKeys.NAVIGATE_TO_PREVIOUS_RECORD,
label: msg`Navigate to previous record`,
- position: 14,
+ position: 15,
isPinned: true,
Icon: IconChevronUp,
shouldBeRegistered: ({ isInRightDrawer }) => !isInRightDrawer,
@@ -354,7 +388,7 @@ export const DEFAULT_RECORD_ACTIONS_CONFIG: Record<
scope: ActionScope.RecordSelection,
key: SingleRecordActionKeys.NAVIGATE_TO_NEXT_RECORD,
label: msg`Navigate to next record`,
- position: 15,
+ position: 16,
isPinned: true,
Icon: IconChevronDown,
shouldBeRegistered: ({ isInRightDrawer }) => !isInRightDrawer,
@@ -367,7 +401,7 @@ export const DEFAULT_RECORD_ACTIONS_CONFIG: Record<
key: MultipleRecordsActionKeys.DESTROY,
label: msg`Permanently destroy records`,
shortLabel: msg`Destroy`,
- position: 16,
+ position: 17,
Icon: IconTrashX,
accent: 'danger',
isPinned: true,
@@ -393,7 +427,7 @@ export const DEFAULT_RECORD_ACTIONS_CONFIG: Record<
key: SingleRecordActionKeys.RESTORE,
label: msg`Restore record`,
shortLabel: msg`Restore`,
- position: 17,
+ position: 18,
Icon: IconRefresh,
accent: 'default',
isPinned: true,
@@ -422,7 +456,7 @@ export const DEFAULT_RECORD_ACTIONS_CONFIG: Record<
key: MultipleRecordsActionKeys.RESTORE,
label: msg`Restore records`,
shortLabel: msg`Restore`,
- position: 18,
+ position: 19,
Icon: IconRefresh,
accent: 'default',
isPinned: true,
@@ -448,7 +482,7 @@ export const DEFAULT_RECORD_ACTIONS_CONFIG: Record<
key: NoSelectionRecordActionKeys.GO_TO_WORKFLOWS,
label: msg`Go to workflows`,
shortLabel: msg`See workflows`,
- position: 19,
+ position: 20,
Icon: IconSettingsAutomation,
accent: 'default',
isPinned: false,
@@ -480,7 +514,7 @@ export const DEFAULT_RECORD_ACTIONS_CONFIG: Record<
key: NoSelectionRecordActionKeys.GO_TO_PEOPLE,
label: msg`Go to People`,
shortLabel: msg`People`,
- position: 20,
+ position: 21,
Icon: IconUser,
isPinned: false,
availableOn: [
@@ -511,7 +545,7 @@ export const DEFAULT_RECORD_ACTIONS_CONFIG: Record<
key: NoSelectionRecordActionKeys.GO_TO_COMPANIES,
label: msg`Go to Companies`,
shortLabel: msg`Companies`,
- position: 21,
+ position: 22,
Icon: IconBuildingSkyscraper,
isPinned: false,
availableOn: [
@@ -542,7 +576,7 @@ export const DEFAULT_RECORD_ACTIONS_CONFIG: Record<
key: NoSelectionRecordActionKeys.GO_TO_OPPORTUNITIES,
label: msg`Go to Opportunities`,
shortLabel: msg`Opportunities`,
- position: 22,
+ position: 23,
Icon: IconTargetArrow,
isPinned: false,
availableOn: [
@@ -575,7 +609,7 @@ export const DEFAULT_RECORD_ACTIONS_CONFIG: Record<
key: NoSelectionRecordActionKeys.GO_TO_SETTINGS,
label: msg`Go to Settings`,
shortLabel: msg`Settings`,
- position: 23,
+ position: 24,
Icon: IconSettings,
isPinned: false,
availableOn: [
@@ -601,7 +635,7 @@ export const DEFAULT_RECORD_ACTIONS_CONFIG: Record<
key: NoSelectionRecordActionKeys.GO_TO_TASKS,
label: msg`Go to Tasks`,
shortLabel: msg`Tasks`,
- position: 24,
+ position: 25,
Icon: IconCheckbox,
isPinned: false,
availableOn: [
@@ -632,7 +666,7 @@ export const DEFAULT_RECORD_ACTIONS_CONFIG: Record<
key: NoSelectionRecordActionKeys.GO_TO_NOTES,
label: msg`Go to Notes`,
shortLabel: msg`Notes`,
- position: 25,
+ position: 26,
Icon: IconCheckbox,
isPinned: false,
availableOn: [
diff --git a/packages/twenty-front/src/modules/action-menu/actions/record-actions/multiple-records/types/MultipleRecordsActionKeys.ts b/packages/twenty-front/src/modules/action-menu/actions/record-actions/multiple-records/types/MultipleRecordsActionKeys.ts
index bc9c148d11f..eff98147ab3 100644
--- a/packages/twenty-front/src/modules/action-menu/actions/record-actions/multiple-records/types/MultipleRecordsActionKeys.ts
+++ b/packages/twenty-front/src/modules/action-menu/actions/record-actions/multiple-records/types/MultipleRecordsActionKeys.ts
@@ -1,6 +1,7 @@
export enum MultipleRecordsActionKeys {
DELETE = 'delete-multiple-records',
EXPORT = 'export-multiple-records',
+ MERGE = 'merge-multiple-records',
DESTROY = 'destroy-multiple-records',
RESTORE = 'restore-multiple-records',
}
diff --git a/packages/twenty-front/src/modules/command-menu/constants/CommandMenuPagesConfig.tsx b/packages/twenty-front/src/modules/command-menu/constants/CommandMenuPagesConfig.tsx
index 05ddfad993c..833a7cbeed4 100644
--- a/packages/twenty-front/src/modules/command-menu/constants/CommandMenuPagesConfig.tsx
+++ b/packages/twenty-front/src/modules/command-menu/constants/CommandMenuPagesConfig.tsx
@@ -3,6 +3,7 @@ import { CommandMenuAIChatThreadsPage } from '@/command-menu/pages/AIChatThreads
import { CommandMenuAskAIPage } from '@/command-menu/pages/ask-ai/components/CommandMenuAskAIPage';
import { CommandMenuCalendarEventPage } from '@/command-menu/pages/calendar-event/components/CommandMenuCalendarEventPage';
import { CommandMenuMessageThreadPage } from '@/command-menu/pages/message-thread/components/CommandMenuMessageThreadPage';
+import { CommandMenuMergeRecordPage } from '@/command-menu/pages/record-page/components/CommandMenuMergeRecordPage';
import { CommandMenuRecordPage } from '@/command-menu/pages/record-page/components/CommandMenuRecordPage';
import { CommandMenuEditRichTextPage } from '@/command-menu/pages/rich-text-page/components/CommandMenuEditRichTextPage';
import { CommandMenuSearchRecordsPage } from '@/command-menu/pages/search/components/CommandMenuSearchRecordsPage';
@@ -19,6 +20,7 @@ export const COMMAND_MENU_PAGES_CONFIG = new Map<
>([
[CommandMenuPages.Root, ],
[CommandMenuPages.ViewRecord, ],
+ [CommandMenuPages.MergeRecords, ],
[CommandMenuPages.ViewEmailThread, ],
[CommandMenuPages.ViewCalendarEvent, ],
[CommandMenuPages.EditRichText, ],
diff --git a/packages/twenty-front/src/modules/command-menu/pages/record-page/components/CommandMenuMergeRecordPage.tsx b/packages/twenty-front/src/modules/command-menu/pages/record-page/components/CommandMenuMergeRecordPage.tsx
new file mode 100644
index 00000000000..1edad710e67
--- /dev/null
+++ b/packages/twenty-front/src/modules/command-menu/pages/record-page/components/CommandMenuMergeRecordPage.tsx
@@ -0,0 +1,65 @@
+import { ActionMenuComponentInstanceContext } from '@/action-menu/states/contexts/ActionMenuComponentInstanceContext';
+import { CommandMenuPageComponentInstanceContext } from '@/command-menu/states/contexts/CommandMenuPageComponentInstanceContext';
+import { MAIN_CONTEXT_STORE_INSTANCE_ID } from '@/context-store/constants/MainContextStoreInstanceId';
+import { useContextStoreObjectMetadataItemOrThrow } from '@/context-store/hooks/useContextStoreObjectMetadataItemOrThrow';
+import { ContextStoreComponentInstanceContext } from '@/context-store/states/contexts/ContextStoreComponentInstanceContext';
+import { RecordFilterGroupsComponentInstanceContext } from '@/object-record/record-filter-group/states/context/RecordFilterGroupsComponentInstanceContext';
+import { RecordFiltersComponentInstanceContext } from '@/object-record/record-filter/states/context/RecordFiltersComponentInstanceContext';
+import { MergeRecordsContainer } from '@/object-record/record-merge/components/MergeRecordsContainer';
+import { RecordSortsComponentInstanceContext } from '@/object-record/record-sort/states/context/RecordSortsComponentInstanceContext';
+import { useIsMobile } from '@/ui/utilities/responsive/hooks/useIsMobile';
+import { useComponentInstanceStateContext } from '@/ui/utilities/state/component-state/hooks/useComponentInstanceStateContext';
+import styled from '@emotion/styled';
+
+const StyledRightDrawerRecord = styled.div<{
+ isMobile: boolean;
+}>`
+ height: ${({ theme, isMobile }) => {
+ const mobileOffset = isMobile ? theme.spacing(16) : '0px';
+
+ return `calc(100% - ${mobileOffset})`;
+ }};
+`;
+
+export const CommandMenuMergeRecordPage = () => {
+ const isMobile = useIsMobile();
+
+ const { objectMetadataItem } = useContextStoreObjectMetadataItemOrThrow();
+
+ const commandMenuPageInstanceId = useComponentInstanceStateContext(
+ CommandMenuPageComponentInstanceContext,
+ )?.instanceId;
+
+ if (!commandMenuPageInstanceId) {
+ throw new Error('Command menu page instance id is not defined');
+ }
+
+ return (
+
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+};
diff --git a/packages/twenty-front/src/modules/command-menu/types/CommandMenuPages.ts b/packages/twenty-front/src/modules/command-menu/types/CommandMenuPages.ts
index a860cb0034f..52c0459b8ff 100644
--- a/packages/twenty-front/src/modules/command-menu/types/CommandMenuPages.ts
+++ b/packages/twenty-front/src/modules/command-menu/types/CommandMenuPages.ts
@@ -1,6 +1,7 @@
export enum CommandMenuPages {
Root = 'root',
ViewRecord = 'view-record',
+ MergeRecords = 'merge-records',
ViewEmailThread = 'view-email-thread',
ViewCalendarEvent = 'view-calendar-event',
EditRichText = 'edit-rich-text',
diff --git a/packages/twenty-front/src/modules/object-record/hooks/useMergeManyRecords.ts b/packages/twenty-front/src/modules/object-record/hooks/useMergeManyRecords.ts
new file mode 100644
index 00000000000..f5680ba773b
--- /dev/null
+++ b/packages/twenty-front/src/modules/object-record/hooks/useMergeManyRecords.ts
@@ -0,0 +1,103 @@
+import { useCallback, useState } from 'react';
+
+import { useApolloCoreClient } from '@/object-metadata/hooks/useApolloCoreClient';
+import { useObjectMetadataItem } from '@/object-metadata/hooks/useObjectMetadataItem';
+import { RecordGqlOperationGqlRecordFields } from '@/object-record/graphql/types/RecordGqlOperationGqlRecordFields';
+import { generateDepthOneRecordGqlFields } from '@/object-record/graphql/utils/generateDepthOneRecordGqlFields';
+import { useMergeManyRecordsMutation } from '@/object-record/hooks/useMergeManyRecordsMutation';
+import { useRefetchAggregateQueries } from '@/object-record/hooks/useRefetchAggregateQueries';
+import { ObjectRecord } from '@/object-record/types/ObjectRecord';
+import { getMergeManyRecordsMutationResponseField } from '@/object-record/utils/getMergeManyRecordsMutationResponseField';
+
+export type MergeManySettings = {
+ conflictPriorityIndex: number;
+};
+
+export type UseMergeManyRecordsProps = {
+ objectNameSingular: string;
+ recordGqlFields?: RecordGqlOperationGqlRecordFields;
+};
+
+export const useMergeManyRecords = <
+ MergedObjectRecord extends ObjectRecord = ObjectRecord,
+>({
+ objectNameSingular,
+ recordGqlFields,
+}: UseMergeManyRecordsProps) => {
+ const apolloCoreClient = useApolloCoreClient();
+ const [loading, setLoading] = useState(false);
+
+ const { objectMetadataItem } = useObjectMetadataItem({
+ objectNameSingular,
+ });
+
+ const computedRecordGqlFields =
+ recordGqlFields ?? generateDepthOneRecordGqlFields({ objectMetadataItem });
+
+ const { mergeManyRecordsMutation } = useMergeManyRecordsMutation({
+ objectNameSingular,
+ recordGqlFields: computedRecordGqlFields,
+ });
+
+ const { refetchAggregateQueries } = useRefetchAggregateQueries({
+ objectMetadataNamePlural: objectMetadataItem.namePlural,
+ });
+
+ type MergeManyRecordsProps = {
+ recordIds: string[];
+ mergeSettings: MergeManySettings;
+ preview?: boolean;
+ };
+
+ const mergeManyRecords = useCallback(
+ async ({
+ recordIds,
+ mergeSettings,
+ preview = false,
+ }: MergeManyRecordsProps): Promise => {
+ setLoading(true);
+
+ const mutationResponseField = getMergeManyRecordsMutationResponseField(
+ objectMetadataItem.namePlural,
+ );
+
+ try {
+ const mergedObject = await apolloCoreClient.mutate({
+ mutation: mergeManyRecordsMutation,
+ variables: {
+ ids: recordIds,
+ conflictPriorityIndex: mergeSettings.conflictPriorityIndex,
+ dryRun: preview,
+ },
+ // Prevent cache updates during dry run to avoid overwriting original record data
+ ...(preview && {
+ fetchPolicy: 'no-cache',
+ errorPolicy: 'ignore',
+ }),
+ });
+
+ setLoading(false);
+
+ if (!preview) {
+ await refetchAggregateQueries();
+ }
+
+ return mergedObject.data?.[mutationResponseField] ?? null;
+ } catch (error) {
+ setLoading(false);
+ throw error;
+ }
+ },
+ [
+ apolloCoreClient,
+ mergeManyRecordsMutation,
+ objectMetadataItem.namePlural,
+ refetchAggregateQueries,
+ ],
+ );
+
+ return {
+ mergeManyRecords,
+ loading,
+ };
+};
diff --git a/packages/twenty-front/src/modules/object-record/hooks/useMergeManyRecordsMutation.ts b/packages/twenty-front/src/modules/object-record/hooks/useMergeManyRecordsMutation.ts
new file mode 100644
index 00000000000..11630febf3e
--- /dev/null
+++ b/packages/twenty-front/src/modules/object-record/hooks/useMergeManyRecordsMutation.ts
@@ -0,0 +1,62 @@
+import gql from 'graphql-tag';
+import { useRecoilValue } from 'recoil';
+
+import { useObjectMetadataItem } from '@/object-metadata/hooks/useObjectMetadataItem';
+import { objectMetadataItemsState } from '@/object-metadata/states/objectMetadataItemsState';
+import { mapObjectMetadataToGraphQLQuery } from '@/object-metadata/utils/mapObjectMetadataToGraphQLQuery';
+import { EMPTY_MUTATION } from '@/object-record/constants/EmptyMutation';
+import { RecordGqlOperationGqlRecordFields } from '@/object-record/graphql/types/RecordGqlOperationGqlRecordFields';
+import { generateDepthOneRecordGqlFields } from '@/object-record/graphql/utils/generateDepthOneRecordGqlFields';
+import { useObjectPermissions } from '@/object-record/hooks/useObjectPermissions';
+import { getMergeManyRecordsMutationResponseField } from '@/object-record/utils/getMergeManyRecordsMutationResponseField';
+import { capitalize } from 'twenty-shared/utils';
+import { isUndefinedOrNull } from '~/utils/isUndefinedOrNull';
+
+export const useMergeManyRecordsMutation = ({
+ objectNameSingular,
+ recordGqlFields,
+}: {
+ objectNameSingular: string;
+ recordGqlFields?: RecordGqlOperationGqlRecordFields;
+}) => {
+ const { objectMetadataItem } = useObjectMetadataItem({
+ objectNameSingular,
+ });
+
+ const appliedRecordGqlFields =
+ recordGqlFields ??
+ generateDepthOneRecordGqlFields({
+ objectMetadataItem,
+ });
+
+ const objectMetadataItems = useRecoilValue(objectMetadataItemsState);
+
+ const { objectPermissionsByObjectMetadataId } = useObjectPermissions();
+
+ if (isUndefinedOrNull(objectMetadataItem)) {
+ return { mergeManyRecordsMutation: EMPTY_MUTATION };
+ }
+
+ const capitalizedObjectName = capitalize(objectMetadataItem.nameSingular);
+
+ const mutationResponseField = getMergeManyRecordsMutationResponseField(
+ objectMetadataItem.namePlural,
+ );
+
+ const mergeManyRecordsMutation = gql`
+ mutation merge${capitalizedObjectName}s($ids: [UUID!]!, $conflictPriorityIndex: Int!, $dryRun: Boolean) {
+ ${mutationResponseField}(ids: $ids, conflictPriorityIndex: $conflictPriorityIndex, dryRun: $dryRun) ${mapObjectMetadataToGraphQLQuery(
+ {
+ objectMetadataItems,
+ objectMetadataItem,
+ recordGqlFields: appliedRecordGqlFields,
+ objectPermissionsByObjectMetadataId,
+ },
+ )}
+ }
+ `;
+
+ return {
+ mergeManyRecordsMutation,
+ };
+};
diff --git a/packages/twenty-front/src/modules/object-record/record-merge/components/MergePreviewTab.tsx b/packages/twenty-front/src/modules/object-record/record-merge/components/MergePreviewTab.tsx
new file mode 100644
index 00000000000..94bea90da8c
--- /dev/null
+++ b/packages/twenty-front/src/modules/object-record/record-merge/components/MergePreviewTab.tsx
@@ -0,0 +1,55 @@
+import { useMergePreview } from '@/object-record/record-merge/hooks/useMergePreview';
+import { CardComponents } from '@/object-record/record-show/components/CardComponents';
+import { SummaryCard } from '@/object-record/record-show/components/SummaryCard';
+import styled from '@emotion/styled';
+import { isDefined } from 'twenty-shared/utils';
+import { Section } from 'twenty-ui/layout';
+
+const StyledLoadingContainer = styled.div`
+ align-items: center;
+ display: flex;
+ height: 100px;
+ justify-content: center;
+`;
+
+type MergePreviewTabProps = {
+ objectNameSingular: string;
+};
+
+export const MergePreviewTab = ({
+ objectNameSingular,
+}: MergePreviewTabProps) => {
+ const { mergePreviewRecord, isGeneratingPreview } = useMergePreview({
+ objectNameSingular,
+ });
+
+ if (isGeneratingPreview) {
+ return (
+
+ Generating merge preview...
+
+ );
+ }
+
+ if (!isDefined(mergePreviewRecord)) {
+ return null;
+ }
+
+ return (
+
+ );
+};
diff --git a/packages/twenty-front/src/modules/object-record/record-merge/components/MergeRecordTab.tsx b/packages/twenty-front/src/modules/object-record/record-merge/components/MergeRecordTab.tsx
new file mode 100644
index 00000000000..4d0919e59a9
--- /dev/null
+++ b/packages/twenty-front/src/modules/object-record/record-merge/components/MergeRecordTab.tsx
@@ -0,0 +1,32 @@
+import { CardComponents } from '@/object-record/record-show/components/CardComponents';
+import { SummaryCard } from '@/object-record/record-show/components/SummaryCard';
+import { Section } from 'twenty-ui/layout';
+
+type MergeRecordTabProps = {
+ isInRightDrawer?: boolean;
+ objectNameSingular: string;
+ recordId: string;
+};
+
+export const MergeRecordTab = ({
+ objectNameSingular,
+ recordId,
+}: MergeRecordTabProps) => {
+ return (
+
+ );
+};
diff --git a/packages/twenty-front/src/modules/object-record/record-merge/components/MergeRecordsContainer.tsx b/packages/twenty-front/src/modules/object-record/record-merge/components/MergeRecordsContainer.tsx
new file mode 100644
index 00000000000..844cee1bcbb
--- /dev/null
+++ b/packages/twenty-front/src/modules/object-record/record-merge/components/MergeRecordsContainer.tsx
@@ -0,0 +1,87 @@
+import styled from '@emotion/styled';
+
+import { ShowPageContainer } from '@/ui/layout/page/components/ShowPageContainer';
+import { RightDrawerProvider } from '@/ui/layout/right-drawer/contexts/RightDrawerContext';
+import { TabList } from '@/ui/layout/tab-list/components/TabList';
+import { activeTabIdComponentState } from '@/ui/layout/tab-list/states/activeTabIdComponentState';
+import { TabListComponentInstanceContext } from '@/ui/layout/tab-list/states/contexts/TabListComponentInstanceContext';
+import { useRecoilComponentValueV2 } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValueV2';
+
+import { useMergeRecordsSettings } from '@/object-record/record-merge/hooks/useMergeRecordsSettings';
+import { MergeRecordsTabId } from '@/object-record/record-merge/types/MergeRecordsTabId';
+import { useMergeRecordsContainerTabs } from '../hooks/useMergeRecordsContainerTabs';
+import { MergePreviewTab } from './MergePreviewTab';
+import { MergeRecordsFooter } from './MergeRecordsFooter';
+import { MergeRecordTab } from './MergeRecordTab';
+import { MergeSettingsTab } from './MergeSettingsTab';
+
+const StyledShowPageRightContainer = styled.div`
+ display: flex;
+ flex-direction: column;
+ justify-content: start;
+ width: 100%;
+ height: 100%;
+ overflow: auto;
+`;
+
+const StyledTabList = styled(TabList)`
+ background-color: ${({ theme }) => theme.background.secondary};
+ padding-left: ${({ theme }) => theme.spacing(2)};
+`;
+
+const StyledContentContainer = styled.div`
+ flex: 1;
+ overflow-y: auto;
+ background: ${({ theme }) => theme.background.primary};
+ padding-bottom: ${({ theme }) => theme.spacing(16)};
+`;
+
+type MergeRecordsContainerProps = {
+ componentInstanceId: string;
+ objectNameSingular: string;
+};
+
+export const MergeRecordsContainer = ({
+ componentInstanceId,
+ objectNameSingular,
+}: MergeRecordsContainerProps) => {
+ const { selectedRecords } = useMergeRecordsSettings();
+
+ const activeTabId = useRecoilComponentValueV2(
+ activeTabIdComponentState,
+ componentInstanceId,
+ );
+
+ const { tabs } = useMergeRecordsContainerTabs(selectedRecords);
+
+ return (
+
+
+
+
+
+
+
+ {activeTabId === MergeRecordsTabId.MERGE_PREVIEW && (
+
+ )}
+ {activeTabId === MergeRecordsTabId.SETTINGS && }
+ {selectedRecords.some((record) => record.id === activeTabId) && (
+
+ )}
+
+
+
+
+
+ );
+};
diff --git a/packages/twenty-front/src/modules/object-record/record-merge/components/MergeRecordsFooter.tsx b/packages/twenty-front/src/modules/object-record/record-merge/components/MergeRecordsFooter.tsx
new file mode 100644
index 00000000000..3fd47162024
--- /dev/null
+++ b/packages/twenty-front/src/modules/object-record/record-merge/components/MergeRecordsFooter.tsx
@@ -0,0 +1,72 @@
+import { SIDE_PANEL_FOCUS_ID } from '@/command-menu/constants/SidePanelFocusId';
+import { useMergeRecordsActions } from '@/object-record/record-merge/hooks/useMergeRecordsActions';
+import { useHotkeysOnFocusedElement } from '@/ui/utilities/hotkey/hooks/useHotkeysOnFocusedElement';
+import styled from '@emotion/styled';
+import { Key } from 'ts-key-enum';
+import { IconArrowMerge, IconDotsVertical } from 'twenty-ui/display';
+import { Button } from 'twenty-ui/input';
+
+const StyledFooterContainer = styled.div`
+ align-items: flex-end;
+ background: ${({ theme }) => theme.background.primary};
+ border-top: 1px solid ${({ theme }) => theme.border.color.light};
+ display: flex;
+ gap: ${({ theme }) => theme.spacing(2)};
+ justify-content: flex-end;
+ padding: ${({ theme }) => theme.spacing(3)};
+`;
+
+const StyledFooterActions = styled.div`
+ display: flex;
+ align-items: flex-end;
+ gap: ${({ theme }) => theme.spacing(2)};
+`;
+
+type MergeRecordsFooterProps = {
+ objectNameSingular: string;
+};
+
+export const MergeRecordsFooter = ({
+ objectNameSingular,
+}: MergeRecordsFooterProps) => {
+ const { handleMergeRecords, isMerging } = useMergeRecordsActions({
+ objectNameSingular,
+ });
+
+ useHotkeysOnFocusedElement({
+ keys: [`${Key.Control}+${Key.Enter}`, `${Key.Meta}+${Key.Enter}`],
+ callback: () => {
+ if (!isMerging) {
+ handleMergeRecords();
+ }
+ },
+ focusId: SIDE_PANEL_FOCUS_ID,
+ dependencies: [handleMergeRecords, isMerging],
+ });
+
+ return (
+
+
+
+
+
+
+
+ );
+};
diff --git a/packages/twenty-front/src/modules/object-record/record-merge/components/MergeSettingsTab.tsx b/packages/twenty-front/src/modules/object-record/record-merge/components/MergeSettingsTab.tsx
new file mode 100644
index 00000000000..12cb3d061d4
--- /dev/null
+++ b/packages/twenty-front/src/modules/object-record/record-merge/components/MergeSettingsTab.tsx
@@ -0,0 +1,38 @@
+import { useMergeRecordsSettings } from '@/object-record/record-merge/hooks/useMergeRecordsSettings';
+import { Select } from '@/ui/input/components/Select';
+import styled from '@emotion/styled';
+import { Section } from 'twenty-ui/layout';
+import { getPositionNumberIcon } from '../utils/getPositionNumberIcon';
+import { getPositionWordLabel } from '../utils/getPositionWordLabel';
+
+const StyledSection = styled(Section)`
+ margin: ${({ theme }) => theme.spacing(4)};
+`;
+
+export const MergeSettingsTab = () => {
+ const { mergeSettings, updatePriorityRecordIndex, selectedRecords } =
+ useMergeRecordsSettings();
+
+ const priorityOptions = selectedRecords.map((_, index) => ({
+ value: index,
+ label: `${getPositionWordLabel(index)} record holds priority`,
+ Icon: getPositionNumberIcon(index),
+ recordIndex: index,
+ }));
+
+ const handleSelectionChange = (index: number) => {
+ updatePriorityRecordIndex(index);
+ };
+
+ return (
+
+
+
+ );
+};
diff --git a/packages/twenty-front/src/modules/object-record/record-merge/hooks/useMergePreview.ts b/packages/twenty-front/src/modules/object-record/record-merge/hooks/useMergePreview.ts
new file mode 100644
index 00000000000..054e0f1dcb7
--- /dev/null
+++ b/packages/twenty-front/src/modules/object-record/record-merge/hooks/useMergePreview.ts
@@ -0,0 +1,75 @@
+import { useEffect, useState } from 'react';
+import { useRecoilValue } from 'recoil';
+
+import { useFindManyRecordsSelectedInContextStore } from '@/context-store/hooks/useFindManyRecordsSelectedInContextStore';
+import { useMergeManyRecords } from '@/object-record/hooks/useMergeManyRecords';
+import { useMergeRecordRelationships } from '@/object-record/record-merge/hooks/useMergeRecordRelationships';
+import { useUpsertRecordsInStore } from '@/object-record/record-store/hooks/useUpsertRecordsInStore';
+import { ObjectRecord } from '@/object-record/types/ObjectRecord';
+import { mergeSettingsState } from '../states/mergeSettingsState';
+
+type UseMergePreviewProps = {
+ objectNameSingular: string;
+};
+
+export const useMergePreview = ({
+ objectNameSingular,
+}: UseMergePreviewProps) => {
+ const [mergePreviewRecord, setMergePreviewRecord] =
+ useState(null);
+ const [isGeneratingPreview, setIsGeneratingPreview] = useState(false);
+
+ const mergeSettings = useRecoilValue(mergeSettingsState);
+ const { records: selectedRecords } = useFindManyRecordsSelectedInContextStore(
+ {
+ limit: 10,
+ },
+ );
+
+ const { mergeManyRecords } = useMergeManyRecords({
+ objectNameSingular,
+ });
+ const { upsertRecords } = useUpsertRecordsInStore();
+
+ const { isLoading: isLoadingRelationships } = useMergeRecordRelationships({
+ objectNameSingular,
+ previewRecordId: mergePreviewRecord?.id || '',
+ selectedRecords: selectedRecords,
+ });
+
+ useEffect(() => {
+ const fetchPreview = async () => {
+ if (selectedRecords.length < 2) return;
+ setIsGeneratingPreview(true);
+ try {
+ const previewRecord = await mergeManyRecords({
+ recordIds: selectedRecords.map((record) => record.id),
+ mergeSettings,
+ preview: true,
+ });
+
+ if (!previewRecord) {
+ setMergePreviewRecord(null);
+ return;
+ }
+
+ setMergePreviewRecord(previewRecord);
+ upsertRecords([previewRecord]);
+ } catch (error) {
+ setMergePreviewRecord(null);
+ } finally {
+ setIsGeneratingPreview(false);
+ }
+ };
+
+ if (selectedRecords.length > 0) {
+ fetchPreview();
+ }
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [selectedRecords, mergeSettings]);
+
+ return {
+ mergePreviewRecord,
+ isGeneratingPreview: isGeneratingPreview || isLoadingRelationships,
+ };
+};
diff --git a/packages/twenty-front/src/modules/object-record/record-merge/hooks/useMergeRecordRelationships.ts b/packages/twenty-front/src/modules/object-record/record-merge/hooks/useMergeRecordRelationships.ts
new file mode 100644
index 00000000000..cb3f4c094f7
--- /dev/null
+++ b/packages/twenty-front/src/modules/object-record/record-merge/hooks/useMergeRecordRelationships.ts
@@ -0,0 +1,126 @@
+import { useEffect, useMemo, useState } from 'react';
+import { useRecoilCallback } from 'recoil';
+
+import { useObjectMetadataItem } from '@/object-metadata/hooks/useObjectMetadataItem';
+import { useObjectMetadataItems } from '@/object-metadata/hooks/useObjectMetadataItems';
+import { useLazyFindManyRecords } from '@/object-record/hooks/useLazyFindManyRecords';
+import { mergeRecordRelationshipData } from '@/object-record/record-merge/utils/mergeRelationshipData';
+import { buildFindOneRecordForShowPageOperationSignature } from '@/object-record/record-show/graphql/operations/factories/findOneRecordForShowPageOperationSignatureFactory';
+import { recordStoreFamilyState } from '@/object-record/record-store/states/recordStoreFamilyState';
+import { ObjectRecord } from '@/object-record/types/ObjectRecord';
+
+type UseMergeRecordRelationshipsProps = {
+ objectNameSingular: string;
+ previewRecordId: string;
+ selectedRecords: ObjectRecord[];
+};
+
+type MergedRelationshipData = Record<
+ string,
+ ObjectRecord[] | ObjectRecord | null
+>;
+
+type UseMergeRecordRelationshipsResult = {
+ isLoading: boolean;
+ mergedRelationshipData: MergedRelationshipData;
+ error?: Error;
+};
+
+export const useMergeRecordRelationships = ({
+ objectNameSingular,
+ previewRecordId,
+ selectedRecords,
+}: UseMergeRecordRelationshipsProps): UseMergeRecordRelationshipsResult => {
+ const { objectMetadataItem } = useObjectMetadataItem({
+ objectNameSingular,
+ });
+ const { objectMetadataItems } = useObjectMetadataItems();
+ const [completeRecords, setCompleteRecords] = useState([]);
+ const [isLoading, setIsLoading] = useState(false);
+ const [error, setError] = useState();
+
+ const recordGqlFields = useMemo(() => {
+ return buildFindOneRecordForShowPageOperationSignature({
+ objectMetadataItem,
+ objectMetadataItems,
+ }).fields;
+ }, [objectMetadataItem, objectMetadataItems]);
+
+ const { findManyRecordsLazy } = useLazyFindManyRecords({
+ objectNameSingular,
+ recordGqlFields,
+ filter: {
+ id: {
+ in: selectedRecords.map((record) => record.id),
+ },
+ },
+ });
+
+ useEffect(() => {
+ if (selectedRecords.length === 0 || !previewRecordId) {
+ setCompleteRecords([]);
+ setError(undefined);
+ return;
+ }
+
+ const fetchCompleteRecords = async () => {
+ setIsLoading(true);
+ setError(undefined);
+
+ try {
+ const { records } = await findManyRecordsLazy();
+ setCompleteRecords(records || []);
+ } catch (fetchError) {
+ const errorMessage =
+ fetchError instanceof Error
+ ? fetchError.message
+ : 'Failed to fetch complete records';
+ setError(
+ new Error(`Failed to merge record relationships: ${errorMessage}`),
+ );
+ setCompleteRecords([]);
+ } finally {
+ setIsLoading(false);
+ }
+ };
+
+ fetchCompleteRecords();
+ }, [selectedRecords, findManyRecordsLazy, previewRecordId]);
+
+ const mergedRelationshipData = useMemo((): MergedRelationshipData => {
+ return mergeRecordRelationshipData(
+ completeRecords,
+ objectMetadataItem.fields,
+ isLoading,
+ );
+ }, [completeRecords, objectMetadataItem.fields, isLoading]);
+
+ const updatePreviewRecordStore = useRecoilCallback(
+ ({ set }) =>
+ async (relationshipData: MergedRelationshipData) => {
+ if (Object.keys(relationshipData).length === 0) return;
+
+ set(recordStoreFamilyState(previewRecordId), (prevRecord) => {
+ if (!prevRecord) return prevRecord;
+
+ return {
+ ...prevRecord,
+ ...relationshipData,
+ };
+ });
+ },
+ [previewRecordId],
+ );
+
+ useEffect(() => {
+ if (!isLoading && Object.keys(mergedRelationshipData).length > 0) {
+ updatePreviewRecordStore(mergedRelationshipData);
+ }
+ }, [isLoading, mergedRelationshipData, updatePreviewRecordStore]);
+
+ return {
+ isLoading,
+ mergedRelationshipData,
+ error,
+ };
+};
diff --git a/packages/twenty-front/src/modules/object-record/record-merge/hooks/useMergeRecordsActions.ts b/packages/twenty-front/src/modules/object-record/record-merge/hooks/useMergeRecordsActions.ts
new file mode 100644
index 00000000000..64cce40001e
--- /dev/null
+++ b/packages/twenty-front/src/modules/object-record/record-merge/hooks/useMergeRecordsActions.ts
@@ -0,0 +1,92 @@
+import { useLingui } from '@lingui/react/macro';
+import { useRecoilValue } from 'recoil';
+
+import { useOpenRecordInCommandMenu } from '@/command-menu/hooks/useOpenRecordInCommandMenu';
+import { useFindManyRecordsSelectedInContextStore } from '@/context-store/hooks/useFindManyRecordsSelectedInContextStore';
+import { contextStoreCurrentViewIdComponentState } from '@/context-store/states/contextStoreCurrentViewIdComponentState';
+import { useObjectNamePluralFromSingular } from '@/object-metadata/hooks/useObjectNamePluralFromSingular';
+import { useMergeManyRecords } from '@/object-record/hooks/useMergeManyRecords';
+import { useRecordTable } from '@/object-record/record-table/hooks/useRecordTable';
+import { getRecordIndexIdFromObjectNamePluralAndViewId } from '@/object-record/utils/getRecordIndexIdFromObjectNamePluralAndViewId';
+import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar';
+import { useRecoilComponentValueV2 } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValueV2';
+import { mergeSettingsState } from '../states/mergeSettingsState';
+
+type UseMergeRecordsActionsProps = {
+ objectNameSingular: string;
+};
+
+export const useMergeRecordsActions = ({
+ objectNameSingular,
+}: UseMergeRecordsActionsProps) => {
+ const mergeSettings = useRecoilValue(mergeSettingsState);
+ const { records: selectedRecords } = useFindManyRecordsSelectedInContextStore(
+ {
+ limit: 10,
+ },
+ );
+
+ const { mergeManyRecords, loading: isMerging } = useMergeManyRecords({
+ objectNameSingular,
+ });
+
+ const { t } = useLingui();
+ const { enqueueSuccessSnackBar, enqueueErrorSnackBar } = useSnackBar();
+ const { openRecordInCommandMenu } = useOpenRecordInCommandMenu();
+ const { objectNamePlural } = useObjectNamePluralFromSingular({
+ objectNameSingular,
+ });
+ const contextStoreCurrentViewId = useRecoilComponentValueV2(
+ contextStoreCurrentViewIdComponentState,
+ );
+
+ if (!contextStoreCurrentViewId) {
+ throw new Error('Current view ID is not defined');
+ }
+
+ const { resetTableRowSelection } = useRecordTable({
+ recordTableId: getRecordIndexIdFromObjectNamePluralAndViewId(
+ objectNamePlural,
+ contextStoreCurrentViewId,
+ ),
+ });
+
+ const handleMergeRecords = async () => {
+ try {
+ const mergedRecord = await mergeManyRecords({
+ recordIds: selectedRecords.map((record) => record.id),
+ mergeSettings,
+ preview: false,
+ });
+
+ if (!mergedRecord) {
+ throw new Error('Failed to merge records');
+ }
+
+ const recordCount = selectedRecords.length;
+
+ enqueueSuccessSnackBar({
+ message: t`Successfully merged ${recordCount} records`,
+ });
+
+ resetTableRowSelection();
+ openRecordInCommandMenu({
+ objectNameSingular,
+ recordId: mergedRecord.id,
+ });
+ } catch (error) {
+ enqueueErrorSnackBar({
+ message:
+ error instanceof Error
+ ? error.message
+ : 'Failed to merge records. Please try again.',
+ });
+ }
+ };
+
+ return {
+ handleMergeRecords,
+ isMerging,
+ selectedRecords,
+ };
+};
diff --git a/packages/twenty-front/src/modules/object-record/record-merge/hooks/useMergeRecordsContainerTabs.ts b/packages/twenty-front/src/modules/object-record/record-merge/hooks/useMergeRecordsContainerTabs.ts
new file mode 100644
index 00000000000..744b04a36d4
--- /dev/null
+++ b/packages/twenty-front/src/modules/object-record/record-merge/hooks/useMergeRecordsContainerTabs.ts
@@ -0,0 +1,43 @@
+import { useMemo } from 'react';
+
+import { useLingui } from '@lingui/react/macro';
+
+import { IconArrowMerge, IconSettings } from 'twenty-ui/display';
+
+import { ObjectRecord } from '@/object-record/types/ObjectRecord';
+import { SingleTabProps } from '@/ui/layout/tab-list/types/SingleTabProps';
+import { MergeRecordsTabId } from '../types/MergeRecordsTabId';
+import { getPositionNumberIcon } from '../utils/getPositionNumberIcon';
+import { getPositionWordLabel } from '../utils/getPositionWordLabel';
+
+export const useMergeRecordsContainerTabs = (
+ selectedRecords: ObjectRecord[],
+): { tabs: SingleTabProps[] } => {
+ const { t } = useLingui();
+
+ const tabs = useMemo(() => {
+ const mergePreviewTab: SingleTabProps = {
+ id: MergeRecordsTabId.MERGE_PREVIEW,
+ title: t`Merge preview`,
+ Icon: IconArrowMerge,
+ };
+
+ const recordTabs: SingleTabProps[] = selectedRecords.map(
+ (record, index) => ({
+ id: record.id,
+ title: getPositionWordLabel(index),
+ Icon: getPositionNumberIcon(index),
+ }),
+ );
+
+ const settingsTab: SingleTabProps = {
+ id: MergeRecordsTabId.SETTINGS,
+ title: t`Settings`,
+ Icon: IconSettings,
+ };
+
+ return [mergePreviewTab, ...recordTabs, settingsTab];
+ }, [t, selectedRecords]);
+
+ return { tabs };
+};
diff --git a/packages/twenty-front/src/modules/object-record/record-merge/hooks/useMergeRecordsSettings.ts b/packages/twenty-front/src/modules/object-record/record-merge/hooks/useMergeRecordsSettings.ts
new file mode 100644
index 00000000000..08a445eb802
--- /dev/null
+++ b/packages/twenty-front/src/modules/object-record/record-merge/hooks/useMergeRecordsSettings.ts
@@ -0,0 +1,32 @@
+import { useRecoilState } from 'recoil';
+
+import { useFindManyRecordsSelectedInContextStore } from '@/context-store/hooks/useFindManyRecordsSelectedInContextStore';
+import { MergeManySettings } from '@/object-record/hooks/useMergeManyRecords';
+import { mergeSettingsState } from '../states/mergeSettingsState';
+
+export const useMergeRecordsSettings = () => {
+ const [mergeSettings, setMergeSettings] = useRecoilState(mergeSettingsState);
+ const { records: selectedRecords } = useFindManyRecordsSelectedInContextStore(
+ {
+ limit: 10,
+ },
+ );
+
+ const updateMergeSettings = (settings: MergeManySettings) => {
+ setMergeSettings(settings);
+ };
+
+ const updatePriorityRecordIndex = (conflictPriorityIndex: number) => {
+ setMergeSettings({
+ ...mergeSettings,
+ conflictPriorityIndex,
+ });
+ };
+
+ return {
+ selectedRecords,
+ mergeSettings,
+ updateMergeSettings,
+ updatePriorityRecordIndex,
+ };
+};
diff --git a/packages/twenty-front/src/modules/object-record/record-merge/states/mergeSettingsState.ts b/packages/twenty-front/src/modules/object-record/record-merge/states/mergeSettingsState.ts
new file mode 100644
index 00000000000..8397d630ed0
--- /dev/null
+++ b/packages/twenty-front/src/modules/object-record/record-merge/states/mergeSettingsState.ts
@@ -0,0 +1,9 @@
+import { MergeManySettings } from '@/object-record/hooks/useMergeManyRecords';
+import { createState } from 'twenty-ui/utilities';
+
+export const mergeSettingsState = createState({
+ key: 'mergeSettingsState',
+ defaultValue: {
+ conflictPriorityIndex: 0,
+ },
+});
diff --git a/packages/twenty-front/src/modules/object-record/record-merge/types/FixedLengthArray.ts b/packages/twenty-front/src/modules/object-record/record-merge/types/FixedLengthArray.ts
new file mode 100644
index 00000000000..c85a039c5db
--- /dev/null
+++ b/packages/twenty-front/src/modules/object-record/record-merge/types/FixedLengthArray.ts
@@ -0,0 +1 @@
+export type FixedLengthArray = T[] & { length: L };
diff --git a/packages/twenty-front/src/modules/object-record/record-merge/types/MergeRecordsTabId.tsx b/packages/twenty-front/src/modules/object-record/record-merge/types/MergeRecordsTabId.tsx
new file mode 100644
index 00000000000..419936a7d14
--- /dev/null
+++ b/packages/twenty-front/src/modules/object-record/record-merge/types/MergeRecordsTabId.tsx
@@ -0,0 +1,6 @@
+export type MergeRecordsTabIdType = 'merge-preview' | 'settings';
+
+export enum MergeRecordsTabId {
+ MERGE_PREVIEW = 'merge-preview',
+ SETTINGS = 'settings',
+}
diff --git a/packages/twenty-front/src/modules/object-record/record-merge/utils/__tests__/mergeRelationshipDataUtils.test.ts b/packages/twenty-front/src/modules/object-record/record-merge/utils/__tests__/mergeRelationshipDataUtils.test.ts
new file mode 100644
index 00000000000..4bcef213276
--- /dev/null
+++ b/packages/twenty-front/src/modules/object-record/record-merge/utils/__tests__/mergeRelationshipDataUtils.test.ts
@@ -0,0 +1,356 @@
+import { FieldMetadataItem } from '@/object-metadata/types/FieldMetadataItem';
+import { mergeManyToOneRelationship } from '@/object-record/record-merge/utils/mergeManyToOneRelationship';
+import { mergeOneToManyRelationships } from '@/object-record/record-merge/utils/mergeOneToManyRelationships';
+import { mergeRecordRelationshipData } from '@/object-record/record-merge/utils/mergeRelationshipData';
+import { ObjectRecord } from '@/object-record/types/ObjectRecord';
+import { FieldMetadataType, RelationType } from '~/generated-metadata/graphql';
+
+describe('mergeOneToManyRelationships', () => {
+ it('should merge one-to-many relationships and remove duplicates', () => {
+ const records: ObjectRecord[] = [
+ {
+ __typename: 'Company',
+ id: 'record1',
+ opportunities: [
+ { __typename: 'Opportunity', id: 'opp1', name: 'Opportunity 1' },
+ { __typename: 'Opportunity', id: 'opp2', name: 'Opportunity 2' },
+ ],
+ },
+ {
+ __typename: 'Company',
+ id: 'record2',
+ opportunities: [
+ { __typename: 'Opportunity', id: 'opp2', name: 'Opportunity 2' }, // Duplicate
+ { __typename: 'Opportunity', id: 'opp3', name: 'Opportunity 3' },
+ ],
+ },
+ ];
+
+ const result = mergeOneToManyRelationships(records, 'opportunities');
+
+ expect(result).toHaveLength(3);
+ expect(result).toEqual([
+ { __typename: 'Opportunity', id: 'opp1', name: 'Opportunity 1' },
+ { __typename: 'Opportunity', id: 'opp2', name: 'Opportunity 2' },
+ { __typename: 'Opportunity', id: 'opp3', name: 'Opportunity 3' },
+ ]);
+ });
+
+ it('should handle empty arrays', () => {
+ const records: ObjectRecord[] = [
+ {
+ __typename: 'Company',
+ id: 'record1',
+ opportunities: [],
+ },
+ {
+ __typename: 'Company',
+ id: 'record2',
+ opportunities: [],
+ },
+ ];
+
+ const result = mergeOneToManyRelationships(records, 'opportunities');
+
+ expect(result).toEqual([]);
+ });
+
+ it('should handle records with null or undefined relationship values', () => {
+ const records: ObjectRecord[] = [
+ {
+ __typename: 'Company',
+ id: 'record1',
+ opportunities: [
+ { __typename: 'Opportunity', id: 'opp1', name: 'Opportunity 1' },
+ ],
+ },
+ {
+ __typename: 'Company',
+ id: 'record2',
+ opportunities: null,
+ },
+ {
+ __typename: 'Company',
+ id: 'record3',
+ opportunities: undefined,
+ },
+ ];
+
+ const result = mergeOneToManyRelationships(records, 'opportunities');
+
+ expect(result).toEqual([
+ { __typename: 'Opportunity', id: 'opp1', name: 'Opportunity 1' },
+ ]);
+ });
+
+ it('should handle empty records array', () => {
+ const result = mergeOneToManyRelationships([], 'opportunities');
+
+ expect(result).toEqual([]);
+ });
+});
+
+describe('mergeManyToOneRelationship', () => {
+ it('should return the first non-null value', () => {
+ const records: ObjectRecord[] = [
+ {
+ __typename: 'Person',
+ id: 'record1',
+ company: null,
+ },
+ {
+ __typename: 'Person',
+ id: 'record2',
+ company: { __typename: 'Company', id: 'company1', name: 'Company 1' },
+ },
+ {
+ __typename: 'Person',
+ id: 'record3',
+ company: { __typename: 'Company', id: 'company2', name: 'Company 2' },
+ },
+ ];
+
+ const result = mergeManyToOneRelationship(records, 'company');
+
+ expect(result).toEqual({
+ __typename: 'Company',
+ id: 'company1',
+ name: 'Company 1',
+ });
+ });
+
+ it('should return the first non-undefined value', () => {
+ const records: ObjectRecord[] = [
+ {
+ __typename: 'Person',
+ id: 'record1',
+ company: undefined,
+ },
+ {
+ __typename: 'Person',
+ id: 'record2',
+ company: { __typename: 'Company', id: 'company1', name: 'Company 1' },
+ },
+ ];
+
+ const result = mergeManyToOneRelationship(records, 'company');
+
+ expect(result).toEqual({
+ __typename: 'Company',
+ id: 'company1',
+ name: 'Company 1',
+ });
+ });
+
+ it('should return null if all values are null or undefined', () => {
+ const records: ObjectRecord[] = [
+ {
+ __typename: 'Person',
+ id: 'record1',
+ company: null,
+ },
+ {
+ __typename: 'Person',
+ id: 'record2',
+ company: undefined,
+ },
+ ];
+
+ const result = mergeManyToOneRelationship(records, 'company');
+
+ expect(result).toBeNull();
+ });
+
+ it('should handle empty records array', () => {
+ const result = mergeManyToOneRelationship([], 'company');
+
+ expect(result).toBeNull();
+ });
+});
+
+describe('mergeRecordRelationshipData', () => {
+ const mockFieldMetadataItems: FieldMetadataItem[] = [
+ {
+ id: 'field1',
+ name: 'opportunities',
+ type: FieldMetadataType.RELATION,
+ relation: {
+ type: RelationType.ONE_TO_MANY,
+ },
+ } as FieldMetadataItem,
+ {
+ id: 'field2',
+ name: 'company',
+ type: FieldMetadataType.RELATION,
+ relation: {
+ type: RelationType.MANY_TO_ONE,
+ },
+ } as FieldMetadataItem,
+ {
+ id: 'field3',
+ name: 'name',
+ type: FieldMetadataType.TEXT,
+ } as FieldMetadataItem,
+ ];
+
+ it('should merge all relationship fields correctly', () => {
+ const records: ObjectRecord[] = [
+ {
+ __typename: 'Company',
+ id: 'record1',
+ opportunities: [
+ { __typename: 'Opportunity', id: 'opp1', name: 'Opportunity 1' },
+ { __typename: 'Opportunity', id: 'opp2', name: 'Opportunity 2' },
+ ],
+ company: null,
+ name: 'Record 1',
+ },
+ {
+ __typename: 'Company',
+ id: 'record2',
+ opportunities: [
+ { __typename: 'Opportunity', id: 'opp2', name: 'Opportunity 2' },
+ { __typename: 'Opportunity', id: 'opp3', name: 'Opportunity 3' },
+ ],
+ company: { __typename: 'Company', id: 'company1', name: 'Company 1' },
+ name: 'Record 2',
+ },
+ ];
+
+ const result = mergeRecordRelationshipData(
+ records,
+ mockFieldMetadataItems,
+ false,
+ );
+
+ expect(result).toEqual({
+ opportunities: [
+ { __typename: 'Opportunity', id: 'opp1', name: 'Opportunity 1' },
+ { __typename: 'Opportunity', id: 'opp2', name: 'Opportunity 2' },
+ { __typename: 'Opportunity', id: 'opp3', name: 'Opportunity 3' },
+ ],
+ company: { __typename: 'Company', id: 'company1', name: 'Company 1' },
+ });
+ });
+
+ it('should return empty object when isLoading is true', () => {
+ const records: ObjectRecord[] = [
+ {
+ __typename: 'Company',
+ id: 'record1',
+ opportunities: [
+ { __typename: 'Opportunity', id: 'opp1', name: 'Opportunity 1' },
+ ],
+ company: { __typename: 'Company', id: 'company1', name: 'Company 1' },
+ },
+ ];
+
+ const result = mergeRecordRelationshipData(
+ records,
+ mockFieldMetadataItems,
+ true,
+ );
+
+ expect(result).toEqual({});
+ });
+
+ it('should return empty object when records array is empty', () => {
+ const result = mergeRecordRelationshipData(
+ [],
+ mockFieldMetadataItems,
+ false,
+ );
+
+ expect(result).toEqual({});
+ });
+
+ it('should only process relation fields', () => {
+ const records: ObjectRecord[] = [
+ {
+ __typename: 'Company',
+ id: 'record1',
+ opportunities: [
+ { __typename: 'Opportunity', id: 'opp1', name: 'Opportunity 1' },
+ ],
+ company: { __typename: 'Company', id: 'company1', name: 'Company 1' },
+ name: 'Record 1',
+ email: 'test@example.com',
+ },
+ ];
+
+ const result = mergeRecordRelationshipData(
+ records,
+ mockFieldMetadataItems,
+ false,
+ );
+
+ expect(result).toEqual({
+ opportunities: [
+ { __typename: 'Opportunity', id: 'opp1', name: 'Opportunity 1' },
+ ],
+ company: { __typename: 'Company', id: 'company1', name: 'Company 1' },
+ });
+ expect(result).not.toHaveProperty('name');
+ expect(result).not.toHaveProperty('email');
+ });
+
+ it('should handle fields without relation metadata', () => {
+ const fieldsWithoutRelation: FieldMetadataItem[] = [
+ {
+ id: 'field1',
+ name: 'opportunities',
+ type: FieldMetadataType.RELATION,
+ relation: null,
+ } as FieldMetadataItem,
+ ];
+
+ const records: ObjectRecord[] = [
+ {
+ __typename: 'Company',
+ id: 'record1',
+ opportunities: [
+ { __typename: 'Opportunity', id: 'opp1', name: 'Opportunity 1' },
+ ],
+ },
+ ];
+
+ const result = mergeRecordRelationshipData(
+ records,
+ fieldsWithoutRelation,
+ false,
+ );
+
+ expect(result).toEqual({});
+ });
+
+ it('should handle unknown relation types gracefully', () => {
+ const fieldsWithUnknownRelation: FieldMetadataItem[] = [
+ {
+ id: 'field1',
+ name: 'unknownRelation',
+ type: FieldMetadataType.RELATION,
+ relation: {
+ type: 'UNKNOWN_TYPE' as RelationType,
+ },
+ } as FieldMetadataItem,
+ ];
+
+ const records: ObjectRecord[] = [
+ {
+ __typename: 'Person',
+ id: 'record1',
+ unknownRelation: [
+ { __typename: 'Unknown', id: 'unknown1', name: 'Unknown 1' },
+ ],
+ },
+ ];
+
+ const result = mergeRecordRelationshipData(
+ records,
+ fieldsWithUnknownRelation,
+ false,
+ );
+
+ expect(result).toEqual({});
+ });
+});
diff --git a/packages/twenty-front/src/modules/object-record/record-merge/utils/getPositionNumberIcon.ts b/packages/twenty-front/src/modules/object-record/record-merge/utils/getPositionNumberIcon.ts
new file mode 100644
index 00000000000..f893b68a262
--- /dev/null
+++ b/packages/twenty-front/src/modules/object-record/record-merge/utils/getPositionNumberIcon.ts
@@ -0,0 +1,32 @@
+import { MUTATION_MAX_MERGE_RECORDS } from 'twenty-shared/constants';
+import {
+ IconSquareNumber1,
+ IconSquareNumber2,
+ IconSquareNumber3,
+ IconSquareNumber4,
+ IconSquareNumber5,
+ IconSquareNumber6,
+ IconSquareNumber7,
+ IconSquareNumber8,
+ IconSquareNumber9,
+} from 'twenty-ui/display';
+import { FixedLengthArray } from '../types/FixedLengthArray';
+
+export const getPositionNumberIcon = (index: number) => {
+ const iconMapping: FixedLengthArray<
+ typeof IconSquareNumber1,
+ typeof MUTATION_MAX_MERGE_RECORDS
+ > = [
+ IconSquareNumber1,
+ IconSquareNumber2,
+ IconSquareNumber3,
+ IconSquareNumber4,
+ IconSquareNumber5,
+ IconSquareNumber6,
+ IconSquareNumber7,
+ IconSquareNumber8,
+ IconSquareNumber9,
+ ];
+
+ return iconMapping[index] || IconSquareNumber1;
+};
diff --git a/packages/twenty-front/src/modules/object-record/record-merge/utils/getPositionWordLabel.ts b/packages/twenty-front/src/modules/object-record/record-merge/utils/getPositionWordLabel.ts
new file mode 100644
index 00000000000..1a5765b106a
--- /dev/null
+++ b/packages/twenty-front/src/modules/object-record/record-merge/utils/getPositionWordLabel.ts
@@ -0,0 +1,21 @@
+import { msg } from '@lingui/core/macro';
+import { MUTATION_MAX_MERGE_RECORDS } from 'twenty-shared/constants';
+import { FixedLengthArray } from '../types/FixedLengthArray';
+
+export const getPositionWordLabel = (index: number): string => {
+ const labels: FixedLengthArray<
+ ReturnType,
+ typeof MUTATION_MAX_MERGE_RECORDS
+ > = [
+ msg`First`,
+ msg`Second`,
+ msg`Third`,
+ msg`Fourth`,
+ msg`Fifth`,
+ msg`Sixth`,
+ msg`Seventh`,
+ msg`Eighth`,
+ msg`Ninth`,
+ ];
+ return labels[index] ? (labels[index].message as string) : '';
+};
diff --git a/packages/twenty-front/src/modules/object-record/record-merge/utils/mergeManyToOneRelationship.ts b/packages/twenty-front/src/modules/object-record/record-merge/utils/mergeManyToOneRelationship.ts
new file mode 100644
index 00000000000..7a6879014ed
--- /dev/null
+++ b/packages/twenty-front/src/modules/object-record/record-merge/utils/mergeManyToOneRelationship.ts
@@ -0,0 +1,13 @@
+import { ObjectRecord } from '@/object-record/types/ObjectRecord';
+import { isDefined } from 'twenty-shared/utils';
+
+export const mergeManyToOneRelationship = (
+ records: ObjectRecord[],
+ fieldName: string,
+): ObjectRecord | null => {
+ const firstNonNullValue = records
+ .map((record) => record[fieldName])
+ .find((value) => isDefined(value) && value !== null);
+
+ return firstNonNullValue || null;
+};
diff --git a/packages/twenty-front/src/modules/object-record/record-merge/utils/mergeOneToManyRelationships.ts b/packages/twenty-front/src/modules/object-record/record-merge/utils/mergeOneToManyRelationships.ts
new file mode 100644
index 00000000000..4d87c6069c7
--- /dev/null
+++ b/packages/twenty-front/src/modules/object-record/record-merge/utils/mergeOneToManyRelationships.ts
@@ -0,0 +1,20 @@
+import { ObjectRecord } from '@/object-record/types/ObjectRecord';
+
+export const mergeOneToManyRelationships = (
+ records: ObjectRecord[],
+ fieldName: string,
+): ObjectRecord[] => {
+ const allRelatedRecords: ObjectRecord[] = [];
+
+ records.forEach((record) => {
+ const relationValue = record[fieldName];
+ if (Array.isArray(relationValue)) {
+ allRelatedRecords.push(...relationValue);
+ }
+ });
+
+ return allRelatedRecords.filter(
+ (record, index, array) =>
+ array.findIndex((r) => r.id === record.id) === index,
+ );
+};
diff --git a/packages/twenty-front/src/modules/object-record/record-merge/utils/mergeRelationshipData.ts b/packages/twenty-front/src/modules/object-record/record-merge/utils/mergeRelationshipData.ts
new file mode 100644
index 00000000000..eb6a24ccf77
--- /dev/null
+++ b/packages/twenty-front/src/modules/object-record/record-merge/utils/mergeRelationshipData.ts
@@ -0,0 +1,34 @@
+import { FieldMetadataItem } from '@/object-metadata/types/FieldMetadataItem';
+import { mergeManyToOneRelationship } from '@/object-record/record-merge/utils/mergeManyToOneRelationship';
+import { mergeOneToManyRelationships } from '@/object-record/record-merge/utils/mergeOneToManyRelationships';
+import { ObjectRecord } from '@/object-record/types/ObjectRecord';
+import { FieldMetadataType, RelationType } from '~/generated-metadata/graphql';
+
+export const mergeRecordRelationshipData = (
+ records: ObjectRecord[],
+ fieldMetadataItems: FieldMetadataItem[],
+ isLoading: boolean,
+): Record => {
+ if (isLoading || records.length === 0) {
+ return {};
+ }
+
+ const mergedData: Record = {};
+
+ const relationFieldMetadataItems = fieldMetadataItems.filter(
+ (field) => field.type === FieldMetadataType.RELATION,
+ );
+
+ relationFieldMetadataItems.forEach((fieldMetadataItem) => {
+ const fieldName = fieldMetadataItem.name;
+ const relationType = fieldMetadataItem.relation?.type;
+
+ if (relationType === RelationType.ONE_TO_MANY) {
+ mergedData[fieldName] = mergeOneToManyRelationships(records, fieldName);
+ } else if (relationType === RelationType.MANY_TO_ONE) {
+ mergedData[fieldName] = mergeManyToOneRelationship(records, fieldName);
+ }
+ });
+
+ return mergedData;
+};
diff --git a/packages/twenty-front/src/modules/object-record/record-show/components/RecordShowContainer.tsx b/packages/twenty-front/src/modules/object-record/record-show/components/RecordShowContainer.tsx
index b03a1c066f0..9dbe0753606 100644
--- a/packages/twenty-front/src/modules/object-record/record-show/components/RecordShowContainer.tsx
+++ b/packages/twenty-front/src/modules/object-record/record-show/components/RecordShowContainer.tsx
@@ -10,8 +10,8 @@ import { useRecordShowContainerData } from '@/object-record/record-show/hooks/us
import { useRecordShowContainerTabs } from '@/object-record/record-show/hooks/useRecordShowContainerTabs';
import { recordStoreFamilySelector } from '@/object-record/record-store/states/selectors/recordStoreFamilySelector';
import { ShowPageSubContainer } from '@/ui/layout/show-page/components/ShowPageSubContainer';
-import { useRecoilValue } from 'recoil';
import styled from '@emotion/styled';
+import { useRecoilValue } from 'recoil';
const StyledShowPageBannerContainer = styled.div`
z-index: 1;
diff --git a/packages/twenty-front/src/modules/object-record/utils/getMergeManyRecordsMutationResponseField.ts b/packages/twenty-front/src/modules/object-record/utils/getMergeManyRecordsMutationResponseField.ts
new file mode 100644
index 00000000000..8f41d660ae3
--- /dev/null
+++ b/packages/twenty-front/src/modules/object-record/utils/getMergeManyRecordsMutationResponseField.ts
@@ -0,0 +1,7 @@
+import { capitalize } from 'twenty-shared/utils';
+
+export const getMergeManyRecordsMutationResponseField = (
+ objectNamePlural: string,
+): string => {
+ return `merge${capitalize(objectNamePlural)}`;
+};
diff --git a/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/graphql-query-runner.module.ts b/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/graphql-query-runner.module.ts
index 0561315d271..06bd978c637 100644
--- a/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/graphql-query-runner.module.ts
+++ b/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/graphql-query-runner.module.ts
@@ -13,6 +13,7 @@ import { GraphqlQueryDestroyOneResolverService } from 'src/engine/api/graphql/gr
import { GraphqlQueryFindDuplicatesResolverService } from 'src/engine/api/graphql/graphql-query-runner/resolvers/graphql-query-find-duplicates-resolver.service';
import { GraphqlQueryFindManyResolverService } from 'src/engine/api/graphql/graphql-query-runner/resolvers/graphql-query-find-many-resolver.service';
import { GraphqlQueryFindOneResolverService } from 'src/engine/api/graphql/graphql-query-runner/resolvers/graphql-query-find-one-resolver.service';
+import { GraphqlQueryMergeManyResolverService } from 'src/engine/api/graphql/graphql-query-runner/resolvers/graphql-query-merge-many-resolver.service';
import { GraphqlQueryRestoreManyResolverService } from 'src/engine/api/graphql/graphql-query-runner/resolvers/graphql-query-restore-many-resolver.service';
import { GraphqlQueryRestoreOneResolverService } from 'src/engine/api/graphql/graphql-query-runner/resolvers/graphql-query-restore-one-resolver.service';
import { GraphqlQueryUpdateManyResolverService } from 'src/engine/api/graphql/graphql-query-runner/resolvers/graphql-query-update-many-resolver.service';
@@ -33,6 +34,7 @@ const graphqlQueryResolvers = [
GraphqlQueryFindDuplicatesResolverService,
GraphqlQueryFindManyResolverService,
GraphqlQueryFindOneResolverService,
+ GraphqlQueryMergeManyResolverService,
GraphqlQueryRestoreManyResolverService,
GraphqlQueryRestoreOneResolverService,
GraphqlQueryUpdateManyResolverService,
diff --git a/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/resolvers/graphql-query-merge-many-resolver.service.ts b/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/resolvers/graphql-query-merge-many-resolver.service.ts
new file mode 100644
index 00000000000..38598051d9d
--- /dev/null
+++ b/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/resolvers/graphql-query-merge-many-resolver.service.ts
@@ -0,0 +1,437 @@
+import { Injectable, Logger } from '@nestjs/common';
+
+import {
+ MUTATION_MAX_MERGE_RECORDS,
+ QUERY_MAX_RECORDS,
+} from 'twenty-shared/constants';
+import { FieldMetadataType } from 'twenty-shared/types';
+import { isDefined } from 'twenty-shared/utils';
+import { In } from 'typeorm';
+import { v4 as uuidv4 } from 'uuid';
+
+import {
+ GraphqlQueryBaseResolverService,
+ GraphqlQueryResolverExecutionArgs,
+} from 'src/engine/api/graphql/graphql-query-runner/interfaces/base-resolver-service';
+import { ObjectRecord } from 'src/engine/api/graphql/workspace-query-builder/interfaces/object-record.interface';
+import { WorkspaceQueryRunnerOptions } from 'src/engine/api/graphql/workspace-query-runner/interfaces/query-runner-option.interface';
+import { MergeManyResolverArgs } from 'src/engine/api/graphql/workspace-resolver-builder/interfaces/workspace-resolvers-builder.interface';
+import { FieldMetadataRelationSettings } from 'src/engine/metadata-modules/field-metadata/interfaces/field-metadata-settings.interface';
+import { RelationType } from 'src/engine/metadata-modules/field-metadata/interfaces/relation-type.interface';
+
+import {
+ GraphqlQueryRunnerException,
+ GraphqlQueryRunnerExceptionCode,
+} from 'src/engine/api/graphql/graphql-query-runner/errors/graphql-query-runner.exception';
+import { ObjectRecordsToGraphqlConnectionHelper } from 'src/engine/api/graphql/graphql-query-runner/helpers/object-records-to-graphql-connection.helper';
+import { buildColumnsToReturn } from 'src/engine/api/graphql/graphql-query-runner/utils/build-columns-to-return';
+import { hasRecordFieldValue } from 'src/engine/api/graphql/graphql-query-runner/utils/has-record-field-value.util';
+import { AuthContext } from 'src/engine/core-modules/auth/types/auth-context.type';
+import { assertMutationNotOnRemoteObject } from 'src/engine/metadata-modules/object-metadata/utils/assert-mutation-not-on-remote-object.util';
+import { ObjectMetadataItemWithFieldMaps } from 'src/engine/metadata-modules/types/object-metadata-item-with-field-maps';
+import { ObjectMetadataMaps } from 'src/engine/metadata-modules/types/object-metadata-maps';
+import { isFieldMetadataEntityOfType } from 'src/engine/utils/is-field-metadata-of-type.util';
+
+@Injectable()
+export class GraphqlQueryMergeManyResolverService extends GraphqlQueryBaseResolverService<
+ MergeManyResolverArgs,
+ ObjectRecord
+> {
+ private readonly logger = new Logger(
+ GraphqlQueryMergeManyResolverService.name,
+ );
+
+ async resolve(
+ executionArgs: GraphqlQueryResolverExecutionArgs,
+ ): Promise {
+ const { authContext, objectMetadataItemWithFieldMaps, objectMetadataMaps } =
+ executionArgs.options;
+ const { ids, conflictPriorityIndex, dryRun } = executionArgs.args;
+ const { roleId } = executionArgs;
+
+ const recordsToMerge = await this.fetchRecordsToMerge(executionArgs, ids);
+
+ const priorityRecord = this.validateAndGetPriorityRecord(
+ recordsToMerge,
+ ids,
+ conflictPriorityIndex,
+ );
+
+ const mergedData = this.performDeepMerge(
+ recordsToMerge,
+ priorityRecord.id,
+ objectMetadataItemWithFieldMaps,
+ );
+
+ if (dryRun) {
+ return this.createDryRunResponse(
+ priorityRecord,
+ mergedData,
+ objectMetadataItemWithFieldMaps,
+ objectMetadataMaps,
+ );
+ }
+
+ const idsToDelete = ids.filter((id) => id !== priorityRecord.id);
+
+ await this.migrateRelatedRecords(
+ executionArgs,
+ idsToDelete,
+ priorityRecord.id,
+ );
+
+ const queryBuilder = executionArgs.repository.createQueryBuilder(
+ objectMetadataItemWithFieldMaps.nameSingular,
+ );
+
+ await queryBuilder
+ .softDelete()
+ .whereInIds(idsToDelete)
+ .returning('*')
+ .execute();
+
+ const updatedRecord = await this.updatePriorityRecord(
+ executionArgs,
+ priorityRecord.id,
+ mergedData,
+ );
+
+ if (roleId) {
+ await this.processNestedRelations(
+ executionArgs,
+ [updatedRecord],
+ authContext,
+ roleId,
+ );
+ }
+
+ return this.formatResponse(
+ updatedRecord,
+ objectMetadataItemWithFieldMaps,
+ objectMetadataMaps,
+ );
+ }
+
+ private async fetchRecordsToMerge(
+ executionArgs: GraphqlQueryResolverExecutionArgs,
+ ids: string[],
+ ): Promise {
+ const recordsToMerge = await executionArgs.repository.find({
+ where: { id: In(ids) },
+ });
+
+ if (recordsToMerge.length !== ids.length) {
+ throw new GraphqlQueryRunnerException(
+ 'One or more records not found',
+ GraphqlQueryRunnerExceptionCode.RECORD_NOT_FOUND,
+ );
+ }
+
+ return recordsToMerge as ObjectRecord[];
+ }
+
+ private validateAndGetPriorityRecord(
+ recordsToMerge: ObjectRecord[],
+ ids: string[],
+ conflictPriorityIndex: number,
+ ): ObjectRecord {
+ const priorityRecordId = ids[conflictPriorityIndex];
+ const priorityRecord = recordsToMerge.find(
+ (record) => record.id === priorityRecordId,
+ );
+
+ if (!priorityRecord) {
+ throw new GraphqlQueryRunnerException(
+ 'Priority record not found',
+ GraphqlQueryRunnerExceptionCode.RECORD_NOT_FOUND,
+ );
+ }
+
+ return priorityRecord;
+ }
+
+ private performDeepMerge(
+ recordsToMerge: ObjectRecord[],
+ priorityRecordId: string,
+ objectMetadataItemWithFieldMaps: ObjectMetadataItemWithFieldMaps,
+ ): Partial {
+ const mergedResult: Partial = {};
+
+ const allFieldNames = new Set();
+
+ recordsToMerge.forEach((record) => {
+ Object.keys(record).forEach((fieldName) => {
+ if (
+ !this.shouldExcludeFieldFromMerge(
+ fieldName,
+ objectMetadataItemWithFieldMaps,
+ )
+ ) {
+ allFieldNames.add(fieldName);
+ }
+ });
+ });
+
+ allFieldNames.forEach((fieldName) => {
+ const recordsWithValues: { value: unknown; recordId: string }[] = [];
+
+ recordsToMerge.forEach((record) => {
+ const fieldValue = record[fieldName];
+
+ if (hasRecordFieldValue(fieldValue)) {
+ recordsWithValues.push({ value: fieldValue, recordId: record.id });
+ }
+ });
+
+ if (recordsWithValues.length === 0) {
+ return;
+ } else if (recordsWithValues.length === 1) {
+ mergedResult[fieldName] = recordsWithValues[0].value;
+ } else {
+ const priorityValue = recordsWithValues.find(
+ (item) => item.recordId === priorityRecordId,
+ );
+
+ if (priorityValue) {
+ mergedResult[fieldName] = priorityValue.value;
+ } else {
+ mergedResult[fieldName] = recordsWithValues[0].value;
+ }
+ }
+ });
+
+ return mergedResult;
+ }
+
+ private shouldExcludeFieldFromMerge(
+ fieldName: string,
+ objectMetadataItemWithFieldMaps: ObjectMetadataItemWithFieldMaps,
+ ): boolean {
+ const fieldMetadata = Object.values(
+ objectMetadataItemWithFieldMaps.fieldsById,
+ ).find((field) => field?.name === fieldName);
+
+ if (fieldMetadata?.isSystem) {
+ return true;
+ }
+
+ return false;
+ }
+
+ private createDryRunResponse(
+ priorityRecord: ObjectRecord,
+ mergedData: Partial,
+ objectMetadataItemWithFieldMaps: ObjectMetadataItemWithFieldMaps,
+ objectMetadataMaps: ObjectMetadataMaps,
+ ): ObjectRecord {
+ const dryRunRecord = {
+ ...priorityRecord,
+ ...mergedData,
+ id: uuidv4(),
+ deletedAt: new Date().toISOString(),
+ } as ObjectRecord;
+
+ const typeORMObjectRecordsParser =
+ new ObjectRecordsToGraphqlConnectionHelper(objectMetadataMaps);
+
+ return typeORMObjectRecordsParser.processRecord({
+ objectRecord: dryRunRecord,
+ objectName: objectMetadataItemWithFieldMaps.nameSingular,
+ take: 1,
+ totalCount: 1,
+ });
+ }
+
+ private async updatePriorityRecord(
+ executionArgs: GraphqlQueryResolverExecutionArgs,
+ priorityRecordId: string,
+ mergedData: Partial,
+ ): Promise {
+ const { objectMetadataItemWithFieldMaps } = executionArgs.options;
+
+ const queryBuilder = executionArgs.repository.createQueryBuilder(
+ objectMetadataItemWithFieldMaps.nameSingular,
+ );
+
+ const columnsToReturn = buildColumnsToReturn({
+ select: executionArgs.graphqlQuerySelectedFieldsResult.select,
+ relations: executionArgs.graphqlQuerySelectedFieldsResult.relations,
+ objectMetadataItemWithFieldMaps,
+ });
+
+ const updatedObjectRecords = await queryBuilder
+ .update()
+ .set(mergedData)
+ .where({ id: priorityRecordId })
+ .returning(columnsToReturn)
+ .execute();
+
+ if (!updatedObjectRecords.generatedMaps.length) {
+ throw new GraphqlQueryRunnerException(
+ 'Failed to update record',
+ GraphqlQueryRunnerExceptionCode.RECORD_NOT_FOUND,
+ );
+ }
+
+ const updatedRecord = updatedObjectRecords.generatedMaps[0] as ObjectRecord;
+
+ return updatedRecord;
+ }
+
+ private async migrateRelatedRecords(
+ executionArgs: GraphqlQueryResolverExecutionArgs,
+ fromIds: string[],
+ toId: string,
+ ): Promise {
+ const { objectMetadataMaps, objectMetadataItemWithFieldMaps } =
+ executionArgs.options;
+
+ const relationFieldsPointingToCurrentObject = Object.values(
+ objectMetadataMaps.byId,
+ )
+ .filter(isDefined)
+ .flatMap((metadata) => {
+ const relationFields = Object.values(metadata.fieldsById)
+ .filter(isDefined)
+ .filter((field) =>
+ isFieldMetadataEntityOfType(field, FieldMetadataType.RELATION),
+ )
+ .filter(
+ (field) =>
+ field.relationTargetObjectMetadataId ===
+ objectMetadataItemWithFieldMaps.id && field.isActive,
+ );
+
+ return relationFields
+ .filter((field) => {
+ const relationSettings =
+ field.settings as FieldMetadataRelationSettings;
+
+ return (
+ relationSettings?.relationType === RelationType.MANY_TO_ONE &&
+ relationSettings?.joinColumnName
+ );
+ })
+ .map((field) => {
+ const relationSettings =
+ field.settings as FieldMetadataRelationSettings;
+
+ return {
+ objectMetadata: metadata,
+ fieldName: field.name,
+ fieldId: field.id,
+ joinColumnName: relationSettings.joinColumnName,
+ };
+ });
+ });
+
+ for (const relationField of relationFieldsPointingToCurrentObject) {
+ if (!relationField.joinColumnName) {
+ continue;
+ }
+
+ try {
+ const repository = executionArgs.workspaceDataSource.getRepository(
+ relationField.objectMetadata.nameSingular,
+ executionArgs.isExecutedByApiKey,
+ executionArgs.roleId,
+ );
+
+ const whereCondition = { [relationField.joinColumnName]: In(fromIds) };
+
+ const existingRecords = await repository.find({
+ where: whereCondition,
+ });
+
+ if (existingRecords.length > 0) {
+ await repository.update(whereCondition, {
+ [relationField.joinColumnName]: toId,
+ });
+ }
+ } catch (error) {
+ this.logger.warn(
+ `Failed to migrate relation field "${relationField.fieldName}" (${relationField.joinColumnName}) in object "${relationField.objectMetadata.nameSingular}":`,
+ error.message,
+ );
+ }
+ }
+ }
+
+ private async processNestedRelations(
+ executionArgs: GraphqlQueryResolverExecutionArgs,
+ updatedRecords: ObjectRecord[],
+ authContext: AuthContext,
+ roleId: string,
+ ): Promise {
+ const { objectMetadataMaps, objectMetadataItemWithFieldMaps } =
+ executionArgs.options;
+
+ if (executionArgs.graphqlQuerySelectedFieldsResult.relations) {
+ await this.processNestedRelationsHelper.processNestedRelations({
+ objectMetadataMaps,
+ parentObjectMetadataItem: objectMetadataItemWithFieldMaps,
+ parentObjectRecords: updatedRecords,
+ relations: executionArgs.graphqlQuerySelectedFieldsResult.relations,
+ limit: QUERY_MAX_RECORDS,
+ authContext,
+ workspaceDataSource: executionArgs.workspaceDataSource,
+ roleId,
+ shouldBypassPermissionChecks: executionArgs.isExecutedByApiKey,
+ selectedFields: executionArgs.graphqlQuerySelectedFieldsResult.select,
+ });
+ }
+ }
+
+ private formatResponse(
+ updatedRecord: ObjectRecord,
+ objectMetadataItemWithFieldMaps: ObjectMetadataItemWithFieldMaps,
+ objectMetadataMaps: ObjectMetadataMaps,
+ ): ObjectRecord {
+ const typeORMObjectRecordsParser =
+ new ObjectRecordsToGraphqlConnectionHelper(objectMetadataMaps);
+
+ return typeORMObjectRecordsParser.processRecord({
+ objectRecord: updatedRecord,
+ objectName: objectMetadataItemWithFieldMaps.nameSingular,
+ take: 1,
+ totalCount: 1,
+ });
+ }
+
+ async validate(
+ args: MergeManyResolverArgs,
+ options: WorkspaceQueryRunnerOptions,
+ ): Promise {
+ assertMutationNotOnRemoteObject(options.objectMetadataItemWithFieldMaps);
+
+ if (!isDefined(options.objectMetadataItemWithFieldMaps.duplicateCriteria)) {
+ throw new GraphqlQueryRunnerException(
+ `Merge is only available for objects with duplicate criteria. Object '${options.objectMetadataItemWithFieldMaps.nameSingular}' does not have duplicate criteria defined.`,
+ GraphqlQueryRunnerExceptionCode.INVALID_QUERY_INPUT,
+ );
+ }
+
+ const { ids, conflictPriorityIndex } = args;
+
+ if (!ids || ids.length < 2) {
+ throw new GraphqlQueryRunnerException(
+ 'At least 2 record IDs are required for merge',
+ GraphqlQueryRunnerExceptionCode.INVALID_QUERY_INPUT,
+ );
+ }
+
+ if (ids.length > MUTATION_MAX_MERGE_RECORDS) {
+ throw new GraphqlQueryRunnerException(
+ `Maximum ${MUTATION_MAX_MERGE_RECORDS} records can be merged at once`,
+ GraphqlQueryRunnerExceptionCode.INVALID_QUERY_INPUT,
+ );
+ }
+
+ if (conflictPriorityIndex < 0 || conflictPriorityIndex >= ids.length) {
+ throw new GraphqlQueryRunnerException(
+ `Invalid conflict priority '${conflictPriorityIndex}'. Valid options for ${ids.length} records: 0-${ids.length - 1}`,
+ GraphqlQueryRunnerExceptionCode.INVALID_QUERY_INPUT,
+ );
+ }
+ }
+}
diff --git a/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/utils/has-record-field-value.util.ts b/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/utils/has-record-field-value.util.ts
new file mode 100644
index 00000000000..f28f6ed582f
--- /dev/null
+++ b/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/utils/has-record-field-value.util.ts
@@ -0,0 +1,29 @@
+export const hasRecordFieldValue = (value: unknown): boolean => {
+ if (value === null || value === undefined) {
+ return false;
+ }
+
+ if (typeof value === 'string') {
+ return value.trim() !== '';
+ }
+
+ if (typeof value === 'number') {
+ return !isNaN(value);
+ }
+
+ if (typeof value === 'boolean') {
+ return true;
+ }
+
+ if (Array.isArray(value)) {
+ return value.length > 0;
+ }
+
+ if (typeof value === 'object') {
+ const objectValue = value as Record;
+
+ return Object.values(objectValue).some((val) => hasRecordFieldValue(val));
+ }
+
+ return true;
+};
diff --git a/packages/twenty-server/src/engine/api/graphql/workspace-query-runner/factories/query-runner-args.factory.ts b/packages/twenty-server/src/engine/api/graphql/workspace-query-runner/factories/query-runner-args.factory.ts
index e2cce404cc2..934f2bff9d9 100644
--- a/packages/twenty-server/src/engine/api/graphql/workspace-query-runner/factories/query-runner-args.factory.ts
+++ b/packages/twenty-server/src/engine/api/graphql/workspace-query-runner/factories/query-runner-args.factory.ts
@@ -14,6 +14,7 @@ import {
FindDuplicatesResolverArgs,
FindManyResolverArgs,
FindOneResolverArgs,
+ MergeManyResolverArgs,
ResolverArgs,
ResolverArgsType,
UpdateManyResolverArgs,
@@ -145,6 +146,22 @@ export class QueryRunnerArgsFactory {
) ?? [],
),
} satisfies FindDuplicatesResolverArgs;
+ case ResolverArgsType.MergeMany:
+ return {
+ ...args,
+ ids: (await Promise.all(
+ (args as MergeManyResolverArgs).ids?.map((id) =>
+ this.overrideValueByFieldMetadata(
+ 'id',
+ id,
+ fieldMetadataMapByNameByName,
+ ),
+ ) ?? [],
+ )) as string[],
+ conflictPriorityIndex: (args as MergeManyResolverArgs)
+ .conflictPriorityIndex,
+ dryRun: (args as MergeManyResolverArgs).dryRun,
+ } satisfies MergeManyResolverArgs;
default:
return args;
}
diff --git a/packages/twenty-server/src/engine/api/graphql/workspace-query-runner/workspace-query-hook/types/workspace-query-hook.type.ts b/packages/twenty-server/src/engine/api/graphql/workspace-query-runner/workspace-query-hook/types/workspace-query-hook.type.ts
index dc031e8f420..3ced38b7d38 100644
--- a/packages/twenty-server/src/engine/api/graphql/workspace-query-runner/workspace-query-hook/types/workspace-query-hook.type.ts
+++ b/packages/twenty-server/src/engine/api/graphql/workspace-query-runner/workspace-query-hook/types/workspace-query-hook.type.ts
@@ -8,6 +8,7 @@ import {
FindDuplicatesResolverArgs,
FindManyResolverArgs,
FindOneResolverArgs,
+ MergeManyResolverArgs,
RestoreManyResolverArgs,
UpdateManyResolverArgs,
UpdateOneResolverArgs,
@@ -42,4 +43,6 @@ export type WorkspacePreQueryHookPayload = T extends 'createMany'
? DestroyManyResolverArgs
: T extends 'destroyOne'
? DestroyOneResolverArgs
- : never;
+ : T extends 'mergeMany'
+ ? MergeManyResolverArgs
+ : never;
diff --git a/packages/twenty-server/src/engine/api/graphql/workspace-resolver-builder/constants/resolver-method-names.ts b/packages/twenty-server/src/engine/api/graphql/workspace-resolver-builder/constants/resolver-method-names.ts
index 3a529bea4f6..a680ab39dec 100644
--- a/packages/twenty-server/src/engine/api/graphql/workspace-resolver-builder/constants/resolver-method-names.ts
+++ b/packages/twenty-server/src/engine/api/graphql/workspace-resolver-builder/constants/resolver-method-names.ts
@@ -12,4 +12,5 @@ export const RESOLVER_METHOD_NAMES = {
RESTORE_ONE: 'restoreOne',
DESTROY_MANY: 'destroyMany',
DESTROY_ONE: 'destroyOne',
+ MERGE_MANY: 'mergeMany',
} as const;
diff --git a/packages/twenty-server/src/engine/api/graphql/workspace-resolver-builder/factories/factories.ts b/packages/twenty-server/src/engine/api/graphql/workspace-resolver-builder/factories/factories.ts
index 4c08455ec13..3581c372122 100644
--- a/packages/twenty-server/src/engine/api/graphql/workspace-resolver-builder/factories/factories.ts
+++ b/packages/twenty-server/src/engine/api/graphql/workspace-resolver-builder/factories/factories.ts
@@ -11,6 +11,7 @@ import { DeleteOneResolverFactory } from './delete-one-resolver.factory';
import { FindDuplicatesResolverFactory } from './find-duplicates-resolver.factory';
import { FindManyResolverFactory } from './find-many-resolver.factory';
import { FindOneResolverFactory } from './find-one-resolver.factory';
+import { MergeManyResolverFactory } from './merge-many-resolver.factory';
import { UpdateOneResolverFactory } from './update-one-resolver.factory';
export const workspaceResolverBuilderFactories = [
@@ -27,6 +28,7 @@ export const workspaceResolverBuilderFactories = [
DestroyManyResolverFactory,
RestoreOneResolverFactory,
RestoreManyResolverFactory,
+ MergeManyResolverFactory,
];
export const workspaceResolverBuilderMethodNames = {
@@ -46,5 +48,6 @@ export const workspaceResolverBuilderMethodNames = {
DestroyManyResolverFactory.methodName,
RestoreOneResolverFactory.methodName,
RestoreManyResolverFactory.methodName,
+ MergeManyResolverFactory.methodName,
],
} as const;
diff --git a/packages/twenty-server/src/engine/api/graphql/workspace-resolver-builder/factories/merge-many-resolver.factory.ts b/packages/twenty-server/src/engine/api/graphql/workspace-resolver-builder/factories/merge-many-resolver.factory.ts
new file mode 100644
index 00000000000..8ca24861c0a
--- /dev/null
+++ b/packages/twenty-server/src/engine/api/graphql/workspace-resolver-builder/factories/merge-many-resolver.factory.ts
@@ -0,0 +1,45 @@
+import { Injectable } from '@nestjs/common';
+
+import { WorkspaceQueryRunnerOptions } from 'src/engine/api/graphql/workspace-query-runner/interfaces/query-runner-option.interface';
+import { WorkspaceResolverBuilderFactoryInterface } from 'src/engine/api/graphql/workspace-resolver-builder/interfaces/workspace-resolver-builder-factory.interface';
+import {
+ MergeManyResolverArgs,
+ Resolver,
+} from 'src/engine/api/graphql/workspace-resolver-builder/interfaces/workspace-resolvers-builder.interface';
+import { WorkspaceSchemaBuilderContext } from 'src/engine/api/graphql/workspace-schema-builder/interfaces/workspace-schema-builder-context.interface';
+
+import { GraphqlQueryMergeManyResolverService } from 'src/engine/api/graphql/graphql-query-runner/resolvers/graphql-query-merge-many-resolver.service';
+import { RESOLVER_METHOD_NAMES } from 'src/engine/api/graphql/workspace-resolver-builder/constants/resolver-method-names';
+
+@Injectable()
+export class MergeManyResolverFactory
+ implements WorkspaceResolverBuilderFactoryInterface
+{
+ public static methodName = RESOLVER_METHOD_NAMES.MERGE_MANY;
+
+ constructor(
+ private readonly graphqlQueryRunnerService: GraphqlQueryMergeManyResolverService,
+ ) {}
+
+ create(
+ context: WorkspaceSchemaBuilderContext,
+ ): Resolver {
+ const internalContext = context;
+
+ return async (_source, args, _context, info) => {
+ const options: WorkspaceQueryRunnerOptions = {
+ authContext: internalContext.authContext,
+ info,
+ objectMetadataMaps: internalContext.objectMetadataMaps,
+ objectMetadataItemWithFieldMaps:
+ internalContext.objectMetadataItemWithFieldMaps,
+ };
+
+ return await this.graphqlQueryRunnerService.execute(
+ args,
+ options,
+ MergeManyResolverFactory.methodName,
+ );
+ };
+ }
+}
diff --git a/packages/twenty-server/src/engine/api/graphql/workspace-resolver-builder/interfaces/workspace-resolvers-builder.interface.ts b/packages/twenty-server/src/engine/api/graphql/workspace-resolver-builder/interfaces/workspace-resolvers-builder.interface.ts
index 4f88ac6bb35..3eb87418fd1 100644
--- a/packages/twenty-server/src/engine/api/graphql/workspace-resolver-builder/interfaces/workspace-resolvers-builder.interface.ts
+++ b/packages/twenty-server/src/engine/api/graphql/workspace-resolver-builder/interfaces/workspace-resolvers-builder.interface.ts
@@ -26,6 +26,7 @@ export enum ResolverArgsType {
RestoreMany = 'RestoreMany',
DestroyMany = 'DestroyMany',
DestroyOne = 'DestroyOne',
+ MergeMany = 'MergeMany',
}
export interface FindManyResolverArgs<
@@ -99,6 +100,12 @@ export interface RestoreManyResolverArgs {
filter: Filter;
}
+export interface MergeManyResolverArgs {
+ ids: string[];
+ conflictPriorityIndex: number;
+ dryRun?: boolean;
+}
+
export interface DestroyOneResolverArgs {
id: string;
}
@@ -132,6 +139,7 @@ export type ResolverArgs =
| FindDuplicatesResolverArgs
| FindManyResolverArgs
| FindOneResolverArgs
+ | MergeManyResolverArgs
| RestoreManyResolverArgs
| RestoreOneResolverArgs
| UpdateManyResolverArgs
diff --git a/packages/twenty-server/src/engine/api/graphql/workspace-resolver-builder/workspace-resolver-builder.service.ts b/packages/twenty-server/src/engine/api/graphql/workspace-resolver-builder/workspace-resolver-builder.service.ts
index 7131b6d8575..89d0947664c 100644
--- a/packages/twenty-server/src/engine/api/graphql/workspace-resolver-builder/workspace-resolver-builder.service.ts
+++ b/packages/twenty-server/src/engine/api/graphql/workspace-resolver-builder/workspace-resolver-builder.service.ts
@@ -5,6 +5,7 @@ import { isDefined } from 'twenty-shared/utils';
import { WorkspaceResolverBuilderMethodNames } from 'src/engine/api/graphql/workspace-resolver-builder/interfaces/workspace-resolvers-builder.interface';
import { FindDuplicatesResolverFactory } from 'src/engine/api/graphql/workspace-resolver-builder/factories/find-duplicates-resolver.factory';
+import { MergeManyResolverFactory } from 'src/engine/api/graphql/workspace-resolver-builder/factories/merge-many-resolver.factory';
import { ObjectMetadataEntity } from 'src/engine/metadata-modules/object-metadata/object-metadata.entity';
@Injectable()
@@ -18,6 +19,8 @@ export class WorkspaceResolverBuilderService {
switch (methodName) {
case FindDuplicatesResolverFactory.methodName:
return isDefined(objectMetadata.duplicateCriteria);
+ case MergeManyResolverFactory.methodName:
+ return isDefined(objectMetadata.duplicateCriteria);
default:
return true;
}
diff --git a/packages/twenty-server/src/engine/api/graphql/workspace-resolver-builder/workspace-resolver.factory.ts b/packages/twenty-server/src/engine/api/graphql/workspace-resolver-builder/workspace-resolver.factory.ts
index 03118258f59..e3280b8c51b 100644
--- a/packages/twenty-server/src/engine/api/graphql/workspace-resolver-builder/workspace-resolver.factory.ts
+++ b/packages/twenty-server/src/engine/api/graphql/workspace-resolver-builder/workspace-resolver.factory.ts
@@ -6,6 +6,7 @@ import { isDefined } from 'twenty-shared/utils';
import { DeleteManyResolverFactory } from 'src/engine/api/graphql/workspace-resolver-builder/factories/delete-many-resolver.factory';
import { DestroyManyResolverFactory } from 'src/engine/api/graphql/workspace-resolver-builder/factories/destroy-many-resolver.factory';
import { DestroyOneResolverFactory } from 'src/engine/api/graphql/workspace-resolver-builder/factories/destroy-one-resolver.factory';
+import { MergeManyResolverFactory } from 'src/engine/api/graphql/workspace-resolver-builder/factories/merge-many-resolver.factory';
import { RestoreManyResolverFactory } from 'src/engine/api/graphql/workspace-resolver-builder/factories/restore-many-resolver.factory';
import { RestoreOneResolverFactory } from 'src/engine/api/graphql/workspace-resolver-builder/factories/restore-one-resolver.factory';
import { UpdateManyResolverFactory } from 'src/engine/api/graphql/workspace-resolver-builder/factories/update-many-resolver.factory';
@@ -52,6 +53,7 @@ export class WorkspaceResolverFactory {
private readonly restoreOneResolverFactory: RestoreOneResolverFactory,
private readonly restoreManyResolverFactory: RestoreManyResolverFactory,
private readonly destroyManyResolverFactory: DestroyManyResolverFactory,
+ private readonly mergeManyResolverFactory: MergeManyResolverFactory,
private readonly workspaceResolverBuilderService: WorkspaceResolverBuilderService,
private readonly featureFlagService: FeatureFlagService,
) {}
@@ -74,6 +76,7 @@ export class WorkspaceResolverFactory {
['findDuplicates', this.findDuplicatesResolverFactory],
['findMany', this.findManyResolverFactory],
['findOne', this.findOneResolverFactory],
+ ['mergeMany', this.mergeManyResolverFactory],
['restoreMany', this.restoreManyResolverFactory],
['restoreOne', this.restoreOneResolverFactory],
['updateMany', this.updateManyResolverFactory],
@@ -154,12 +157,19 @@ export class WorkspaceResolverFactory {
throw new Error(`Unknown mutation resolver type: ${methodName}`);
}
- // @ts-expect-error legacy noImplicitAny
- resolvers.Mutation[resolverName] = resolverFactory.create({
- authContext,
- objectMetadataMaps,
- objectMetadataItemWithFieldMaps: objectMetadata,
- });
+ if (
+ this.workspaceResolverBuilderService.shouldBuildResolver(
+ objectMetadata,
+ methodName,
+ )
+ ) {
+ // @ts-expect-error legacy noImplicitAny
+ resolvers.Mutation[resolverName] = resolverFactory.create({
+ authContext,
+ objectMetadataMaps,
+ objectMetadataItemWithFieldMaps: objectMetadata,
+ });
+ }
}
}
diff --git a/packages/twenty-server/src/engine/api/graphql/workspace-schema-builder/utils/get-resolver-args.util.ts b/packages/twenty-server/src/engine/api/graphql/workspace-schema-builder/utils/get-resolver-args.util.ts
index 520435b6ae0..da05cb2b000 100644
--- a/packages/twenty-server/src/engine/api/graphql/workspace-schema-builder/utils/get-resolver-args.util.ts
+++ b/packages/twenty-server/src/engine/api/graphql/workspace-schema-builder/utils/get-resolver-args.util.ts
@@ -145,6 +145,22 @@ export const getResolverArgs = (
isNullable: false,
},
};
+ case 'mergeMany':
+ return {
+ ids: {
+ type: UUIDScalarType,
+ isNullable: false,
+ isArray: true,
+ },
+ conflictPriorityIndex: {
+ type: GraphQLInt,
+ isNullable: false,
+ },
+ dryRun: {
+ type: GraphQLBoolean,
+ isNullable: true,
+ },
+ };
default:
throw new Error(`Unknown resolver type: ${type}`);
}
diff --git a/packages/twenty-server/src/engine/utils/get-resolver-name.util.ts b/packages/twenty-server/src/engine/utils/get-resolver-name.util.ts
index 1196a11bb18..087ed9856de 100644
--- a/packages/twenty-server/src/engine/utils/get-resolver-name.util.ts
+++ b/packages/twenty-server/src/engine/utils/get-resolver-name.util.ts
@@ -41,6 +41,9 @@ export const getResolverName = (
case 'restoreMany':
return `restore${pascalCase(objectMetadata.namePlural)}`;
+ case 'mergeMany':
+ return `merge${pascalCase(objectMetadata.namePlural)}`;
+
default:
throw new Error(`Unknown resolver type: ${type}`);
}
diff --git a/packages/twenty-shared/src/constants/MutationMaxMergeRecords.ts b/packages/twenty-shared/src/constants/MutationMaxMergeRecords.ts
new file mode 100644
index 00000000000..30fc93d2689
--- /dev/null
+++ b/packages/twenty-shared/src/constants/MutationMaxMergeRecords.ts
@@ -0,0 +1 @@
+export const MUTATION_MAX_MERGE_RECORDS = 9;
diff --git a/packages/twenty-shared/src/constants/index.ts b/packages/twenty-shared/src/constants/index.ts
index 2060f3a41f0..448031a871d 100644
--- a/packages/twenty-shared/src/constants/index.ts
+++ b/packages/twenty-shared/src/constants/index.ts
@@ -11,6 +11,7 @@ export { FIELD_FOR_TOTAL_COUNT_AGGREGATE_OPERATION } from './FieldForTotalCountA
export { MAX_OPTIONS_TO_DISPLAY } from './FieldMetadataMaxOptionsToDisplay';
export { FIELD_RESTRICTED_ADDITIONAL_PERMISSIONS_REQUIRED } from './FieldRestrictedAdditionalPermissionsRequired';
export { LABEL_IDENTIFIER_FIELD_METADATA_TYPES } from './LabelIdentifierFieldMetadataTypes';
+export { MUTATION_MAX_MERGE_RECORDS } from './MutationMaxMergeRecords';
export { PermissionsOnAllObjectRecords } from './PermissionsOnAllObjectRecords';
export { QUERY_MAX_RECORDS } from './QueryMaxRecords';
export { RELATION_NESTED_QUERY_KEYWORDS } from './RelationNestedQueriesKeyword';
diff --git a/packages/twenty-ui/src/display/icon/components/TablerIcons.ts b/packages/twenty-ui/src/display/icon/components/TablerIcons.ts
index 29b6c6027c2..8bd2fec90e8 100644
--- a/packages/twenty-ui/src/display/icon/components/TablerIcons.ts
+++ b/packages/twenty-ui/src/display/icon/components/TablerIcons.ts
@@ -11,6 +11,7 @@ export {
IconArrowBackUp,
IconArrowDown,
IconArrowLeft,
+ IconArrowMerge,
IconArrowRight,
IconArrowsDiagonal,
IconArrowsVertical,
@@ -276,6 +277,15 @@ export {
IconSql,
IconSquare,
IconSquareKey,
+ IconSquareNumber1,
+ IconSquareNumber2,
+ IconSquareNumber3,
+ IconSquareNumber4,
+ IconSquareNumber5,
+ IconSquareNumber6,
+ IconSquareNumber7,
+ IconSquareNumber8,
+ IconSquareNumber9,
IconSquareRoundedCheck,
IconSquareRoundedX,
IconStatusChange,
diff --git a/packages/twenty-ui/src/display/index.ts b/packages/twenty-ui/src/display/index.ts
index 3b2e1dd12e6..ee2aa277b4f 100644
--- a/packages/twenty-ui/src/display/index.ts
+++ b/packages/twenty-ui/src/display/index.ts
@@ -73,6 +73,7 @@ export {
IconArrowBackUp,
IconArrowDown,
IconArrowLeft,
+ IconArrowMerge,
IconArrowRight,
IconArrowsDiagonal,
IconArrowsVertical,
@@ -338,6 +339,15 @@ export {
IconSql,
IconSquare,
IconSquareKey,
+ IconSquareNumber1,
+ IconSquareNumber2,
+ IconSquareNumber3,
+ IconSquareNumber4,
+ IconSquareNumber5,
+ IconSquareNumber6,
+ IconSquareNumber7,
+ IconSquareNumber8,
+ IconSquareNumber9,
IconSquareRoundedCheck,
IconSquareRoundedX,
IconStatusChange,