From f99c05f0e8383d972fdc55a978e03f9e42d3ef4d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rapha=C3=ABl=20Bosi?= <71827178+bosiraphael@users.noreply.github.com> Date: Fri, 10 Apr 2026 16:12:46 +0200 Subject: [PATCH] Fix merge command being available in exclusion mode (#19546) Fixes https://twenty-v7.sentry.io/issues/7398810663/?project=4507072563183616 ## Bug description The mergeMultipleRecords command's conditionalAvailabilityExpression used numberOfSelectedRecords >= 2, which evaluates correctly in both selection and exclusion (Select All) modes. However, when the command executes, buildHeadlessCommandContextApi returns an empty selectedRecords array in exclusion mode because it can't synchronously resolve record IDs from an "all minus excluded" set. This caused MergeMultipleRecordsCommand to throw on the empty array guard. ## Fix Prepended not isSelectAll and to the merge command's conditional expression, preventing it from appearing when records are selected via Select All. --- .../1-22-upgrade-version-command.module.ts | 2 + ...00-fix-merge-command-select-all.command.ts | 140 ++++++++++++++++++ .../standard-command-menu-item.constant.ts | 2 +- 3 files changed, 143 insertions(+), 1 deletion(-) create mode 100644 packages/twenty-server/src/database/commands/upgrade-version-command/1-22/1-22-workspace-command-1780000003000-fix-merge-command-select-all.command.ts diff --git a/packages/twenty-server/src/database/commands/upgrade-version-command/1-22/1-22-upgrade-version-command.module.ts b/packages/twenty-server/src/database/commands/upgrade-version-command/1-22/1-22-upgrade-version-command.module.ts index 8aebc1f9da..b893ef8c7d 100644 --- a/packages/twenty-server/src/database/commands/upgrade-version-command/1-22/1-22-upgrade-version-command.module.ts +++ b/packages/twenty-server/src/database/commands/upgrade-version-command/1-22/1-22-upgrade-version-command.module.ts @@ -3,6 +3,7 @@ import { Module } from '@nestjs/common'; import { WorkspaceIteratorModule } from 'src/database/commands/command-runners/workspace-iterator.module'; import { BackfillPageLayoutsAndFieldsWidgetViewFieldsCommand } from 'src/database/commands/upgrade-version-command/1-22/1-22-workspace-command-1780000001000-backfill-page-layouts-and-fields-widget-view-fields.command'; import { BackfillStandardSkillsCommand } from 'src/database/commands/upgrade-version-command/1-22/1-22-workspace-command-1780000002000-backfill-standard-skills.command'; +import { FixMergeCommandSelectAllCommand } from 'src/database/commands/upgrade-version-command/1-22/1-22-workspace-command-1780000003000-fix-merge-command-select-all.command'; import { AddSendEmailRecordSelectionCommandMenuItemsCommand } from 'src/database/commands/upgrade-version-command/1-22/1-22-workspace-command-1775500016000-add-send-email-record-selection-command-menu-items.command'; import { ApplicationModule } from 'src/engine/core-modules/application/application.module'; import { FeatureFlagModule } from 'src/engine/core-modules/feature-flag/feature-flag.module'; @@ -21,6 +22,7 @@ import { WorkspaceMigrationModule } from 'src/engine/workspace-manager/workspace AddSendEmailRecordSelectionCommandMenuItemsCommand, BackfillPageLayoutsAndFieldsWidgetViewFieldsCommand, BackfillStandardSkillsCommand, + FixMergeCommandSelectAllCommand, ], }) export class V1_22_UpgradeVersionCommandModule {} diff --git a/packages/twenty-server/src/database/commands/upgrade-version-command/1-22/1-22-workspace-command-1780000003000-fix-merge-command-select-all.command.ts b/packages/twenty-server/src/database/commands/upgrade-version-command/1-22/1-22-workspace-command-1780000003000-fix-merge-command-select-all.command.ts new file mode 100644 index 0000000000..e9f1793ec1 --- /dev/null +++ b/packages/twenty-server/src/database/commands/upgrade-version-command/1-22/1-22-workspace-command-1780000003000-fix-merge-command-select-all.command.ts @@ -0,0 +1,140 @@ +import { Command } from 'nest-commander'; +import { isDefined } from 'twenty-shared/utils'; + +import { ActiveOrSuspendedWorkspaceCommandRunner } from 'src/database/commands/command-runners/active-or-suspended-workspace.command-runner'; +import { WorkspaceIteratorService } from 'src/database/commands/command-runners/workspace-iterator.service'; +import { type RunOnWorkspaceArgs } from 'src/database/commands/command-runners/workspace.command-runner'; +import { RegisteredWorkspaceCommand } from 'src/engine/core-modules/upgrade/decorators/registered-workspace-command.decorator'; +import { ApplicationService } from 'src/engine/core-modules/application/application.service'; +import { WorkspaceCacheService } from 'src/engine/workspace-cache/services/workspace-cache.service'; +import { STANDARD_COMMAND_MENU_ITEMS } from 'src/engine/workspace-manager/twenty-standard-application/constants/standard-command-menu-item.constant'; +import { computeTwentyStandardApplicationAllFlatEntityMaps } from 'src/engine/workspace-manager/twenty-standard-application/utils/twenty-standard-application-all-flat-entity-maps.constant'; +import { WorkspaceMigrationValidateBuildAndRunService } from 'src/engine/workspace-manager/workspace-migration/services/workspace-migration-validate-build-and-run-service'; + +const UNIVERSAL_IDENTIFIERS_TO_FIX = new Set([ + STANDARD_COMMAND_MENU_ITEMS.mergeMultipleRecords.universalIdentifier, +]); + +@RegisteredWorkspaceCommand('1.22.0', 1780000003000) +@Command({ + name: 'upgrade:1-22:fix-merge-command-select-all', + description: + 'Fix merge command menu item to not appear in select-all (exclusion) mode', +}) +export class FixMergeCommandSelectAllCommand extends ActiveOrSuspendedWorkspaceCommandRunner { + constructor( + protected readonly workspaceIteratorService: WorkspaceIteratorService, + private readonly applicationService: ApplicationService, + private readonly workspaceMigrationValidateBuildAndRunService: WorkspaceMigrationValidateBuildAndRunService, + private readonly workspaceCacheService: WorkspaceCacheService, + ) { + super(workspaceIteratorService); + } + + override async runOnWorkspace({ + workspaceId, + options, + }: RunOnWorkspaceArgs): Promise { + const isDryRun = options.dryRun ?? false; + + this.logger.log( + `${isDryRun ? '[DRY RUN] ' : ''}Starting merge command select-all expression fix for workspace ${workspaceId}`, + ); + + const { twentyStandardFlatApplication } = + await this.applicationService.findWorkspaceTwentyStandardAndCustomApplicationOrThrow( + { workspaceId }, + ); + + const { flatCommandMenuItemMaps: existingFlatCommandMenuItemMaps } = + await this.workspaceCacheService.getOrRecompute(workspaceId, [ + 'flatCommandMenuItemMaps', + ]); + + const { allFlatEntityMaps: standardAllFlatEntityMaps } = + computeTwentyStandardApplicationAllFlatEntityMaps({ + shouldIncludeRecordPageLayouts: true, + now: new Date().toISOString(), + workspaceId, + twentyStandardApplicationId: twentyStandardFlatApplication.id, + }); + + const itemsToUpdate = [...UNIVERSAL_IDENTIFIERS_TO_FIX] + .map((universalIdentifier) => { + const standardItem = + standardAllFlatEntityMaps.flatCommandMenuItemMaps + .byUniversalIdentifier[universalIdentifier]; + const existingItem = + existingFlatCommandMenuItemMaps.byUniversalIdentifier[ + universalIdentifier + ]; + + if ( + !isDefined(standardItem) || + !isDefined(existingItem) || + existingItem.conditionalAvailabilityExpression === + standardItem.conditionalAvailabilityExpression + ) { + return undefined; + } + + return { + ...existingItem, + conditionalAvailabilityExpression: + standardItem.conditionalAvailabilityExpression, + updatedAt: new Date().toISOString(), + }; + }) + .filter(isDefined); + + if (itemsToUpdate.length === 0) { + this.logger.log( + `Merge command menu item expression already up to date for workspace ${workspaceId}`, + ); + + return; + } + + this.logger.log( + `Found ${itemsToUpdate.length} command menu item(s) to update for workspace ${workspaceId}`, + ); + + if (isDryRun) { + this.logger.log( + `[DRY RUN] Would update ${itemsToUpdate.length} command menu item expression(s) for workspace ${workspaceId}`, + ); + + return; + } + + const validateAndBuildResult = + await this.workspaceMigrationValidateBuildAndRunService.validateBuildAndRunWorkspaceMigration( + { + allFlatEntityOperationByMetadataName: { + commandMenuItem: { + flatEntityToCreate: [], + flatEntityToDelete: [], + flatEntityToUpdate: itemsToUpdate, + }, + }, + workspaceId, + applicationUniversalIdentifier: + twentyStandardFlatApplication.universalIdentifier, + }, + ); + + if (validateAndBuildResult.status === 'fail') { + this.logger.error( + `Failed to fix merge command expression:\n${JSON.stringify(validateAndBuildResult, null, 2)}`, + ); + + throw new Error( + `Failed to fix merge command menu item expression for workspace ${workspaceId}`, + ); + } + + this.logger.log( + `Successfully updated ${itemsToUpdate.length} command menu item expression(s) for workspace ${workspaceId}`, + ); + } +} diff --git a/packages/twenty-server/src/engine/workspace-manager/twenty-standard-application/constants/standard-command-menu-item.constant.ts b/packages/twenty-server/src/engine/workspace-manager/twenty-standard-application/constants/standard-command-menu-item.constant.ts index b0eaa4bf70..00dea0788c 100644 --- a/packages/twenty-server/src/engine/workspace-manager/twenty-standard-application/constants/standard-command-menu-item.constant.ts +++ b/packages/twenty-server/src/engine/workspace-manager/twenty-standard-application/constants/standard-command-menu-item.constant.ts @@ -178,7 +178,7 @@ export const STANDARD_COMMAND_MENU_ITEMS = { shortLabel: 'Merge', availabilityType: CommandMenuItemAvailabilityType.RECORD_SELECTION, conditionalAvailabilityExpression: - 'numberOfSelectedRecords >= 2 and isDefined(objectMetadataItem.duplicateCriteria) and objectPermissions.canUpdateObjectRecords and objectPermissions.canDestroyObjectRecords and numberOfSelectedRecords <= 9', + 'not isSelectAll and numberOfSelectedRecords >= 2 and isDefined(objectMetadataItem.duplicateCriteria) and objectPermissions.canUpdateObjectRecords and objectPermissions.canDestroyObjectRecords and numberOfSelectedRecords <= 9', availabilityObjectMetadataUniversalIdentifier: null, frontComponentUniversalIdentifier: null, engineComponentKey: EngineComponentKey.MERGE_MULTIPLE_RECORDS,