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.
This commit is contained in:
Raphaël Bosi
2026-04-10 16:12:46 +02:00
committed by GitHub
parent c99db7d9c6
commit f99c05f0e8
3 changed files with 143 additions and 1 deletions
@@ -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 {}
@@ -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<string>([
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<void> {
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}`,
);
}
}
@@ -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,