diff --git a/packages/twenty-server/src/database/commands/upgrade-version-command/1-21/1-21-deduplicate-engine-commands.command.ts b/packages/twenty-server/src/database/commands/upgrade-version-command/1-21/1-21-deduplicate-engine-commands.command.ts index c7cd51ea7e..ff996cffb3 100644 --- a/packages/twenty-server/src/database/commands/upgrade-version-command/1-21/1-21-deduplicate-engine-commands.command.ts +++ b/packages/twenty-server/src/database/commands/upgrade-version-command/1-21/1-21-deduplicate-engine-commands.command.ts @@ -6,6 +6,7 @@ import { WorkspaceIteratorService } from 'src/database/commands/command-runners/ import { type RunOnWorkspaceArgs } from 'src/database/commands/command-runners/workspace.command-runner'; 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'; @@ -21,11 +22,11 @@ const OLD_UNIVERSAL_IDENTIFIERS_TO_DELETE = new Set([ 'f71f68e5-7b6e-4c03-8161-c48434d7777c', // exportMultipleRecords ]); -const NEW_UNIVERSAL_IDENTIFIERS = new Set([ - 'd5a55d57-ed1d-4791-89b8-53b7e121d69d', // deleteRecords - '2d733846-8cc5-4314-ab79-916ae0801baa', // restoreRecords - '0ea2ebc4-02ca-4d15-b424-5352b9e487df', // destroyRecords - 'c6f5c54d-d52b-4e75-8188-2190d77126f2', // exportRecords +const NEW_UNIVERSAL_IDENTIFIERS = new Set([ + STANDARD_COMMAND_MENU_ITEMS.deleteRecords.universalIdentifier, + STANDARD_COMMAND_MENU_ITEMS.restoreRecords.universalIdentifier, + STANDARD_COMMAND_MENU_ITEMS.destroyRecords.universalIdentifier, + STANDARD_COMMAND_MENU_ITEMS.exportRecords.universalIdentifier, ]); @Command({ diff --git a/packages/twenty-server/src/database/commands/upgrade-version-command/1-21/1-21-fix-select-all-command-menu-items.command.ts b/packages/twenty-server/src/database/commands/upgrade-version-command/1-21/1-21-fix-select-all-command-menu-items.command.ts new file mode 100644 index 0000000000..a307afa1f1 --- /dev/null +++ b/packages/twenty-server/src/database/commands/upgrade-version-command/1-21/1-21-fix-select-all-command-menu-items.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 { 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.deleteRecords.universalIdentifier, + STANDARD_COMMAND_MENU_ITEMS.restoreRecords.universalIdentifier, + STANDARD_COMMAND_MENU_ITEMS.destroyRecords.universalIdentifier, +]); + +@Command({ + name: 'upgrade:1-21:fix-select-all-command-menu-items', + description: + 'Fix delete/restore/destroy command menu items to work in select-all (exclusion) mode', +}) +export class FixSelectAllCommandMenuItemsCommand 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 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( + `Select-all command menu item expressions 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 select-all expressions:\n${JSON.stringify(validateAndBuildResult, null, 2)}`, + ); + + throw new Error( + `Failed to fix select-all command menu item expressions 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/database/commands/upgrade-version-command/1-21/1-21-upgrade-version-command.module.ts b/packages/twenty-server/src/database/commands/upgrade-version-command/1-21/1-21-upgrade-version-command.module.ts index 893d3536ec..42c23ba9a5 100644 --- a/packages/twenty-server/src/database/commands/upgrade-version-command/1-21/1-21-upgrade-version-command.module.ts +++ b/packages/twenty-server/src/database/commands/upgrade-version-command/1-21/1-21-upgrade-version-command.module.ts @@ -6,6 +6,7 @@ import { AddGlobalKeyValuePairUniqueIndexCommand } from 'src/database/commands/u import { BackfillDatasourceToWorkspaceCommand } from 'src/database/commands/upgrade-version-command/1-21/1-21-backfill-datasource-to-workspace.command'; import { BackfillPageLayoutsAndFieldsWidgetViewFieldsCommand } from 'src/database/commands/upgrade-version-command/1-21/1-21-backfill-page-layouts-and-fields-widget-view-fields.command'; import { DeduplicateEngineCommandsCommand } from 'src/database/commands/upgrade-version-command/1-21/1-21-deduplicate-engine-commands.command'; +import { FixSelectAllCommandMenuItemsCommand } from 'src/database/commands/upgrade-version-command/1-21/1-21-fix-select-all-command-menu-items.command'; import { MigrateAiAgentTextToJsonResponseFormatCommand } from 'src/database/commands/upgrade-version-command/1-21/1-21-migrate-ai-agent-text-to-json-response-format.command'; import { UpdateEditLayoutCommandMenuItemLabelCommand } from 'src/database/commands/upgrade-version-command/1-21/1-21-update-edit-layout-command-menu-item-label.command'; import { ApplicationModule } from 'src/engine/core-modules/application/application.module'; @@ -31,6 +32,7 @@ import { WorkspaceMigrationModule } from 'src/engine/workspace-manager/workspace BackfillDatasourceToWorkspaceCommand, BackfillPageLayoutsAndFieldsWidgetViewFieldsCommand, DeduplicateEngineCommandsCommand, + FixSelectAllCommandMenuItemsCommand, MigrateAiAgentTextToJsonResponseFormatCommand, UpdateEditLayoutCommandMenuItemLabelCommand, ], @@ -39,6 +41,7 @@ import { WorkspaceMigrationModule } from 'src/engine/workspace-manager/workspace BackfillDatasourceToWorkspaceCommand, BackfillPageLayoutsAndFieldsWidgetViewFieldsCommand, DeduplicateEngineCommandsCommand, + FixSelectAllCommandMenuItemsCommand, MigrateAiAgentTextToJsonResponseFormatCommand, UpdateEditLayoutCommandMenuItemLabelCommand, ], diff --git a/packages/twenty-server/src/database/commands/upgrade-version-command/upgrade.command.ts b/packages/twenty-server/src/database/commands/upgrade-version-command/upgrade.command.ts index 70b8384534..069120e91e 100644 --- a/packages/twenty-server/src/database/commands/upgrade-version-command/upgrade.command.ts +++ b/packages/twenty-server/src/database/commands/upgrade-version-command/upgrade.command.ts @@ -29,6 +29,7 @@ import { AddGlobalKeyValuePairUniqueIndexCommand } from 'src/database/commands/u import { BackfillDatasourceToWorkspaceCommand } from 'src/database/commands/upgrade-version-command/1-21/1-21-backfill-datasource-to-workspace.command'; import { BackfillPageLayoutsAndFieldsWidgetViewFieldsCommand } from 'src/database/commands/upgrade-version-command/1-21/1-21-backfill-page-layouts-and-fields-widget-view-fields.command'; import { DeduplicateEngineCommandsCommand } from 'src/database/commands/upgrade-version-command/1-21/1-21-deduplicate-engine-commands.command'; +import { FixSelectAllCommandMenuItemsCommand } from 'src/database/commands/upgrade-version-command/1-21/1-21-fix-select-all-command-menu-items.command'; import { MigrateAiAgentTextToJsonResponseFormatCommand } from 'src/database/commands/upgrade-version-command/1-21/1-21-migrate-ai-agent-text-to-json-response-format.command'; import { UpdateEditLayoutCommandMenuItemLabelCommand } from 'src/database/commands/upgrade-version-command/1-21/1-21-update-edit-layout-command-menu-item-label.command'; import { CoreEngineVersionService } from 'src/engine/core-engine-version/services/core-engine-version.service'; @@ -72,6 +73,7 @@ export class UpgradeCommand extends UpgradeCommandRunner { private readonly backfillDatasourceToWorkspaceCommand: BackfillDatasourceToWorkspaceCommand, private readonly backfillPageLayoutsAndFieldsWidgetViewFieldsCommand: BackfillPageLayoutsAndFieldsWidgetViewFieldsCommand, private readonly deduplicateEngineCommandsCommand: DeduplicateEngineCommandsCommand, + private readonly fixSelectAllCommandMenuItemsCommand: FixSelectAllCommandMenuItemsCommand, private readonly migrateAiAgentTextToJsonResponseFormatCommand: MigrateAiAgentTextToJsonResponseFormatCommand, private readonly updateEditLayoutCommandMenuItemLabelCommand: UpdateEditLayoutCommandMenuItemLabelCommand, ) { @@ -109,6 +111,7 @@ export class UpgradeCommand extends UpgradeCommandRunner { this.backfillDatasourceToWorkspaceCommand, this.backfillPageLayoutsAndFieldsWidgetViewFieldsCommand, this.deduplicateEngineCommandsCommand, + this.fixSelectAllCommandMenuItemsCommand, this.migrateAiAgentTextToJsonResponseFormatCommand, this.updateEditLayoutCommandMenuItemLabelCommand, ]; 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 ab8f06bd35..5bc5c7bc39 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 @@ -59,7 +59,7 @@ export const STANDARD_COMMAND_MENU_ITEMS = { shortLabel: 'Delete', availabilityType: CommandMenuItemAvailabilityType.RECORD_SELECTION, conditionalAvailabilityExpression: - 'numberOfSelectedRecords >= 1 and not hasAnySoftDeleteFilterOnView and objectPermissions.canSoftDeleteObjectRecords and noneDefined(selectedRecords, "deletedAt") and numberOfSelectedRecords < 10000', + 'numberOfSelectedRecords >= 1 and not hasAnySoftDeleteFilterOnView and objectPermissions.canSoftDeleteObjectRecords and (isSelectAll or noneDefined(selectedRecords, "deletedAt"))', availabilityObjectMetadataUniversalIdentifier: null, frontComponentUniversalIdentifier: null, engineComponentKey: EngineComponentKey.DELETE_RECORDS, @@ -74,7 +74,7 @@ export const STANDARD_COMMAND_MENU_ITEMS = { shortLabel: 'Restore', availabilityType: CommandMenuItemAvailabilityType.RECORD_SELECTION, conditionalAvailabilityExpression: - 'numberOfSelectedRecords >= 1 and everyDefined(selectedRecords, "deletedAt") and objectPermissions.canSoftDeleteObjectRecords and (pageType == "RECORD_PAGE" or hasAnySoftDeleteFilterOnView) and numberOfSelectedRecords < 10000', + 'numberOfSelectedRecords >= 1 and (isSelectAll or everyDefined(selectedRecords, "deletedAt")) and objectPermissions.canSoftDeleteObjectRecords and (pageType == "RECORD_PAGE" or hasAnySoftDeleteFilterOnView)', availabilityObjectMetadataUniversalIdentifier: null, frontComponentUniversalIdentifier: null, engineComponentKey: EngineComponentKey.RESTORE_RECORDS, @@ -89,7 +89,7 @@ export const STANDARD_COMMAND_MENU_ITEMS = { shortLabel: 'Destroy', availabilityType: CommandMenuItemAvailabilityType.RECORD_SELECTION, conditionalAvailabilityExpression: - 'numberOfSelectedRecords >= 1 and objectPermissions.canDestroyObjectRecords and everyDefined(selectedRecords, "deletedAt") and numberOfSelectedRecords < 10000', + 'numberOfSelectedRecords >= 1 and objectPermissions.canDestroyObjectRecords and (isSelectAll or everyDefined(selectedRecords, "deletedAt")) and (pageType == "RECORD_PAGE" or hasAnySoftDeleteFilterOnView)', availabilityObjectMetadataUniversalIdentifier: null, frontComponentUniversalIdentifier: null, engineComponentKey: EngineComponentKey.DESTROY_RECORDS, diff --git a/packages/twenty-shared/src/utils/command-menu-items/__tests__/evaluateConditionalAvailabilityExpression.test.ts b/packages/twenty-shared/src/utils/command-menu-items/__tests__/evaluateConditionalAvailabilityExpression.test.ts index 9c7f546030..a34b6a556f 100644 --- a/packages/twenty-shared/src/utils/command-menu-items/__tests__/evaluateConditionalAvailabilityExpression.test.ts +++ b/packages/twenty-shared/src/utils/command-menu-items/__tests__/evaluateConditionalAvailabilityExpression.test.ts @@ -307,6 +307,81 @@ describe('evaluateConditionalAvailabilityExpression', () => { }); }); + describe('isSelectAll bypasses selectedRecords array checks', () => { + it('should return true for "isSelectAll or noneDefined(...)" when isSelectAll is true and selectedRecords is empty', () => { + const context = buildContext({ + isSelectAll: true, + selectedRecords: [], + }); + + expect( + evaluateConditionalAvailabilityExpression( + 'isSelectAll or noneDefined(selectedRecords, "deletedAt")', + context, + ), + ).toBe(true); + }); + + it('should return true for "isSelectAll or everyDefined(...)" when isSelectAll is true and selectedRecords is empty', () => { + const context = buildContext({ + isSelectAll: true, + selectedRecords: [], + }); + + expect( + evaluateConditionalAvailabilityExpression( + 'isSelectAll or everyDefined(selectedRecords, "deletedAt")', + context, + ), + ).toBe(true); + }); + + it('should evaluate the full deleteRecords expression to true in select-all mode', () => { + const expression = + 'numberOfSelectedRecords >= 1 and not hasAnySoftDeleteFilterOnView and objectPermissions.canSoftDeleteObjectRecords and (isSelectAll or noneDefined(selectedRecords, "deletedAt"))'; + + const context = buildContext({ + isSelectAll: true, + selectedRecords: [], + numberOfSelectedRecords: 50, + hasAnySoftDeleteFilterOnView: false, + objectPermissions: { + objectMetadataId: '', + canReadObjectRecords: true, + canUpdateObjectRecords: true, + canSoftDeleteObjectRecords: true, + canDestroyObjectRecords: false, + restrictedFields: {}, + rowLevelPermissionPredicates: [], + rowLevelPermissionPredicateGroups: [], + }, + }); + + expect( + evaluateConditionalAvailabilityExpression(expression, context), + ).toBe(true); + }); + + it('should still respect noneDefined when isSelectAll is false and records are present', () => { + const expression = + 'isSelectAll or noneDefined(selectedRecords, "deletedAt")'; + + const contextWithDeletedRecord = buildContext({ + isSelectAll: false, + selectedRecords: [ + { id: 'id-1', createdAt: '', updatedAt: '', deletedAt: '2024-01-01' }, + ], + }); + + expect( + evaluateConditionalAvailabilityExpression( + expression, + contextWithDeletedRecord, + ), + ).toBe(false); + }); + }); + describe('activateWorkflow expression regression', () => { const activateWorkflowExpression = 'everyDefined(selectedRecords, "currentVersion.trigger") and everyDefined(selectedRecords, "currentVersion.steps") and every(selectedRecords, "currentVersion.steps.length") and (everyEquals(selectedRecords, "currentVersion.status", "DRAFT") or includesNone(selectedRecords, "statuses", "ACTIVE")) and noneDefined(selectedRecords, "deletedAt")';