import { Injectable } from '@nestjs/common'; import type DataLoader from 'dataloader'; import { type APP_LOCALES, SOURCE_LOCALE } from 'twenty-shared/translations'; import { isDefined } from 'twenty-shared/utils'; import { ApplicationService } from 'src/engine/core-modules/application/application.service'; import { I18nService } from 'src/engine/core-modules/i18n/i18n.service'; import { type ApplicationTranslationCatalogLoaderPayload, type ObjectMetadataLoaderPayload, type StandardApplicationIdLoaderPayload, } from 'src/engine/dataloaders/dataloader.service'; import { CommandMenuItemException, CommandMenuItemExceptionCode, } from 'src/engine/metadata-modules/command-menu-item/command-menu-item.exception'; import { type CommandMenuItemDTO } from 'src/engine/metadata-modules/command-menu-item/dtos/command-menu-item.dto'; import { type CreateCommandMenuItemInput } from 'src/engine/metadata-modules/command-menu-item/dtos/create-command-menu-item.input'; import { type UpdateCommandMenuItemInput } from 'src/engine/metadata-modules/command-menu-item/dtos/update-command-menu-item.input'; import { EngineComponentKey } from 'src/engine/metadata-modules/command-menu-item/enums/engine-component-key.enum'; import { interpolateNavigationCommandMenuItemField } from 'src/engine/metadata-modules/command-menu-item/utils/interpolate-navigation-command-menu-item-field.util'; import { isObjectMetadataCommandMenuItemPayload } from 'src/engine/metadata-modules/command-menu-item/utils/is-object-metadata-command-menu-item-payload.util'; import { type FlatCommandMenuItem } from 'src/engine/metadata-modules/flat-command-menu-item/types/flat-command-menu-item.type'; import { fromCreateCommandMenuItemInputToFlatCommandMenuItemToCreate } from 'src/engine/metadata-modules/flat-command-menu-item/utils/from-create-command-menu-item-input-to-flat-command-menu-item-to-create.util'; import { fromDeleteCommandMenuItemInputToFlatCommandMenuItemOrThrow } from 'src/engine/metadata-modules/flat-command-menu-item/utils/from-delete-command-menu-item-input-to-flat-command-menu-item-or-throw.util'; import { fromFlatCommandMenuItemToCommandMenuItemDto } from 'src/engine/metadata-modules/flat-command-menu-item/utils/from-flat-command-menu-item-to-command-menu-item-dto.util'; import { fromUpdateCommandMenuItemInputToFlatCommandMenuItemToUpdateOrThrow } from 'src/engine/metadata-modules/flat-command-menu-item/utils/from-update-command-menu-item-input-to-flat-command-menu-item-to-update-or-throw.util'; import { WorkspaceManyOrAllFlatEntityMapsCacheService } from 'src/engine/metadata-modules/flat-entity/services/workspace-many-or-all-flat-entity-maps-cache.service'; import { findFlatEntityByIdInFlatEntityMapsOrThrow } from 'src/engine/metadata-modules/flat-entity/utils/find-flat-entity-by-id-in-flat-entity-maps-or-throw.util'; import { findFlatEntityByIdInFlatEntityMaps } from 'src/engine/metadata-modules/flat-entity/utils/find-flat-entity-by-id-in-flat-entity-maps.util'; import { type ObjectMetadataDTO } from 'src/engine/metadata-modules/object-metadata/dtos/object-metadata.dto'; import { isCallerOverridingEntity } from 'src/engine/metadata-modules/utils/is-caller-overriding-entity.util'; import { WorkspaceMigrationBuilderException } from 'src/engine/workspace-manager/workspace-migration/exceptions/workspace-migration-builder-exception'; import { WorkspaceMigrationValidateBuildAndRunService } from 'src/engine/workspace-manager/workspace-migration/services/workspace-migration-validate-build-and-run-service'; @Injectable() export class CommandMenuItemService { constructor( private readonly workspaceMigrationValidateBuildAndRunService: WorkspaceMigrationValidateBuildAndRunService, private readonly workspaceManyOrAllFlatEntityMapsCacheService: WorkspaceManyOrAllFlatEntityMapsCacheService, private readonly applicationService: ApplicationService, private readonly i18nService: I18nService, ) {} async findAll(workspaceId: string): Promise { const { flatCommandMenuItemMaps } = await this.workspaceManyOrAllFlatEntityMapsCacheService.getOrRecomputeManyOrAllFlatEntityMaps( { workspaceId, flatMapsKeys: ['flatCommandMenuItemMaps'], }, ); return Object.values(flatCommandMenuItemMaps.byUniversalIdentifier) .filter(isDefined) .sort((a, b) => a.position - b.position) .map(fromFlatCommandMenuItemToCommandMenuItemDto); } async findById( id: string, workspaceId: string, ): Promise { const { flatCommandMenuItemMaps } = await this.workspaceManyOrAllFlatEntityMapsCacheService.getOrRecomputeManyOrAllFlatEntityMaps( { workspaceId, flatMapsKeys: ['flatCommandMenuItemMaps'], }, ); const flatCommandMenuItem = findFlatEntityByIdInFlatEntityMaps({ flatEntityId: id, flatEntityMaps: flatCommandMenuItemMaps, }); if (!isDefined(flatCommandMenuItem)) { return null; } return fromFlatCommandMenuItemToCommandMenuItemDto(flatCommandMenuItem); } async findByIdOrThrow( id: string, workspaceId: string, ): Promise { const commandMenuItem = await this.findById(id, workspaceId); if (!isDefined(commandMenuItem)) { throw new CommandMenuItemException( 'Command menu item not found', CommandMenuItemExceptionCode.COMMAND_MENU_ITEM_NOT_FOUND, ); } return commandMenuItem; } async create( input: CreateCommandMenuItemInput, workspaceId: string, ): Promise { const { flatObjectMetadataMaps, flatFrontComponentMaps, flatPageLayoutMaps, } = await this.workspaceManyOrAllFlatEntityMapsCacheService.getOrRecomputeManyOrAllFlatEntityMaps( { workspaceId, flatMapsKeys: [ 'flatObjectMetadataMaps', 'flatFrontComponentMaps', 'flatPageLayoutMaps', ], }, ); const { workspaceCustomFlatApplication } = await this.applicationService.findWorkspaceTwentyStandardAndCustomApplicationOrThrow( { workspaceId }, ); const flatCommandMenuItemToCreate = fromCreateCommandMenuItemInputToFlatCommandMenuItemToCreate({ createCommandMenuItemInput: input, workspaceId, flatApplication: workspaceCustomFlatApplication, flatObjectMetadataMaps, flatFrontComponentMaps, flatPageLayoutMaps, }); const validateAndBuildResult = await this.workspaceMigrationValidateBuildAndRunService.validateBuildAndRunWorkspaceMigration( { allFlatEntityOperationByMetadataName: { commandMenuItem: { flatEntityToCreate: [flatCommandMenuItemToCreate], flatEntityToDelete: [], flatEntityToUpdate: [], }, }, workspaceId, isSystemBuild: false, applicationUniversalIdentifier: workspaceCustomFlatApplication.universalIdentifier, }, ); if (validateAndBuildResult.status === 'fail') { throw new WorkspaceMigrationBuilderException( validateAndBuildResult, 'Multiple validation errors occurred while creating command menu item', ); } const { flatCommandMenuItemMaps: recomputedFlatCommandMenuItemMaps } = await this.workspaceManyOrAllFlatEntityMapsCacheService.getOrRecomputeManyOrAllFlatEntityMaps( { workspaceId, flatMapsKeys: ['flatCommandMenuItemMaps'], }, ); return fromFlatCommandMenuItemToCommandMenuItemDto( findFlatEntityByIdInFlatEntityMapsOrThrow({ flatEntityId: flatCommandMenuItemToCreate.id, flatEntityMaps: recomputedFlatCommandMenuItemMaps, }), ); } async update( input: UpdateCommandMenuItemInput, workspaceId: string, ): Promise { const { workspaceCustomFlatApplication } = await this.applicationService.findWorkspaceTwentyStandardAndCustomApplicationOrThrow( { workspaceId }, ); const { flatCommandMenuItemMaps: existingFlatCommandMenuItemMaps, flatObjectMetadataMaps: existingFlatObjectMetadataMaps, flatPageLayoutMaps: existingFlatPageLayoutMaps, } = await this.workspaceManyOrAllFlatEntityMapsCacheService.getOrRecomputeManyOrAllFlatEntityMaps( { workspaceId, flatMapsKeys: [ 'flatCommandMenuItemMaps', 'flatObjectMetadataMaps', 'flatPageLayoutMaps', ], }, ); const flatCommandMenuItemToUpdate = fromUpdateCommandMenuItemInputToFlatCommandMenuItemToUpdateOrThrow({ flatCommandMenuItemMaps: existingFlatCommandMenuItemMaps, updateCommandMenuItemInput: input, flatObjectMetadataMaps: existingFlatObjectMetadataMaps, flatPageLayoutMaps: existingFlatPageLayoutMaps, callerApplicationUniversalIdentifier: workspaceCustomFlatApplication.universalIdentifier, workspaceCustomApplicationUniversalIdentifier: workspaceCustomFlatApplication.universalIdentifier, }); const validateAndBuildResult = await this.workspaceMigrationValidateBuildAndRunService.validateBuildAndRunWorkspaceMigration( { allFlatEntityOperationByMetadataName: { commandMenuItem: { flatEntityToCreate: [], flatEntityToDelete: [], flatEntityToUpdate: [flatCommandMenuItemToUpdate], }, }, workspaceId, isSystemBuild: false, applicationUniversalIdentifier: workspaceCustomFlatApplication.universalIdentifier, }, ); if (validateAndBuildResult.status === 'fail') { throw new WorkspaceMigrationBuilderException( validateAndBuildResult, 'Multiple validation errors occurred while updating command menu item', ); } const { flatCommandMenuItemMaps: recomputedFlatCommandMenuItemMaps } = await this.workspaceManyOrAllFlatEntityMapsCacheService.getOrRecomputeManyOrAllFlatEntityMaps( { workspaceId, flatMapsKeys: ['flatCommandMenuItemMaps'], }, ); return fromFlatCommandMenuItemToCommandMenuItemDto( findFlatEntityByIdInFlatEntityMapsOrThrow({ flatEntityId: input.id, flatEntityMaps: recomputedFlatCommandMenuItemMaps, }), ); } async reset(id: string, workspaceId: string): Promise { const { workspaceCustomFlatApplication } = await this.applicationService.findWorkspaceTwentyStandardAndCustomApplicationOrThrow( { workspaceId }, ); const { flatCommandMenuItemMaps: existingFlatCommandMenuItemMaps } = await this.workspaceManyOrAllFlatEntityMapsCacheService.getOrRecomputeManyOrAllFlatEntityMaps( { workspaceId, flatMapsKeys: ['flatCommandMenuItemMaps'], }, ); const existingFlatCommandMenuItem = findFlatEntityByIdInFlatEntityMaps({ flatEntityId: id, flatEntityMaps: existingFlatCommandMenuItemMaps, }); if (!isDefined(existingFlatCommandMenuItem)) { throw new CommandMenuItemException( 'Command menu item not found', CommandMenuItemExceptionCode.COMMAND_MENU_ITEM_NOT_FOUND, ); } if ( existingFlatCommandMenuItem.applicationUniversalIdentifier === workspaceCustomFlatApplication.universalIdentifier ) { throw new CommandMenuItemException( 'Custom command menu item cannot be reset to default', CommandMenuItemExceptionCode.COMMAND_MENU_ITEM_CANNOT_BE_RESET, ); } const flatCommandMenuItemToUpdate: FlatCommandMenuItem = { ...existingFlatCommandMenuItem, isActive: true, overrides: null, universalOverrides: null, updatedAt: new Date().toISOString(), }; const validateAndBuildResult = await this.workspaceMigrationValidateBuildAndRunService.validateBuildAndRunWorkspaceMigration( { allFlatEntityOperationByMetadataName: { commandMenuItem: { flatEntityToCreate: [], flatEntityToDelete: [], flatEntityToUpdate: [flatCommandMenuItemToUpdate], }, }, workspaceId, isSystemBuild: false, applicationUniversalIdentifier: workspaceCustomFlatApplication.universalIdentifier, }, ); if (validateAndBuildResult.status === 'fail') { throw new WorkspaceMigrationBuilderException( validateAndBuildResult, 'Multiple validation errors occurred while resetting command menu item to default', ); } const { flatCommandMenuItemMaps: recomputedFlatCommandMenuItemMaps } = await this.workspaceManyOrAllFlatEntityMapsCacheService.getOrRecomputeManyOrAllFlatEntityMaps( { workspaceId, flatMapsKeys: ['flatCommandMenuItemMaps'], }, ); return fromFlatCommandMenuItemToCommandMenuItemDto( findFlatEntityByIdInFlatEntityMapsOrThrow({ flatEntityId: id, flatEntityMaps: recomputedFlatCommandMenuItemMaps, }), ); } async delete(id: string, workspaceId: string): Promise { const { workspaceCustomFlatApplication } = await this.applicationService.findWorkspaceTwentyStandardAndCustomApplicationOrThrow( { workspaceId }, ); const { flatCommandMenuItemMaps: existingFlatCommandMenuItemMaps } = await this.workspaceManyOrAllFlatEntityMapsCacheService.getOrRecomputeManyOrAllFlatEntityMaps( { workspaceId, flatMapsKeys: ['flatCommandMenuItemMaps'], }, ); const flatCommandMenuItemToDelete = fromDeleteCommandMenuItemInputToFlatCommandMenuItemOrThrow({ flatCommandMenuItemMaps: existingFlatCommandMenuItemMaps, commandMenuItemId: id, }); const shouldDeactivate = isCallerOverridingEntity({ callerApplicationUniversalIdentifier: workspaceCustomFlatApplication.universalIdentifier, entityApplicationUniversalIdentifier: flatCommandMenuItemToDelete.applicationUniversalIdentifier, workspaceCustomApplicationUniversalIdentifier: workspaceCustomFlatApplication.universalIdentifier, isSystemSideEffect: flatCommandMenuItemToDelete.isSystemSideEffect, }); const deactivatedFlatCommandMenuItem = { ...flatCommandMenuItemToDelete, isActive: false, updatedAt: new Date().toISOString(), }; const validateAndBuildResult = await this.workspaceMigrationValidateBuildAndRunService.validateBuildAndRunWorkspaceMigration( { allFlatEntityOperationByMetadataName: { commandMenuItem: { flatEntityToCreate: [], flatEntityToDelete: shouldDeactivate ? [] : [flatCommandMenuItemToDelete], flatEntityToUpdate: shouldDeactivate ? [deactivatedFlatCommandMenuItem] : [], }, }, workspaceId, isSystemBuild: false, applicationUniversalIdentifier: workspaceCustomFlatApplication.universalIdentifier, }, ); if (validateAndBuildResult.status === 'fail') { throw new WorkspaceMigrationBuilderException( validateAndBuildResult, 'Multiple validation errors occurred while deleting command menu item', ); } return fromFlatCommandMenuItemToCommandMenuItemDto( shouldDeactivate ? deactivatedFlatCommandMenuItem : flatCommandMenuItemToDelete, ); } async findAllFlatCommandMenuItems( workspaceId: string, ): Promise { const { flatCommandMenuItemMaps } = await this.workspaceManyOrAllFlatEntityMapsCacheService.getOrRecomputeManyOrAllFlatEntityMaps( { workspaceId, flatMapsKeys: ['flatCommandMenuItemMaps'], }, ); return Object.values(flatCommandMenuItemMaps.byUniversalIdentifier) .filter(isDefined) .sort((a, b) => a.position - b.position); } async loadNavigationObjectMetadata({ commandMenuItem, objectMetadataLoader, workspaceId, }: { commandMenuItem: CommandMenuItemDTO; objectMetadataLoader: DataLoader< ObjectMetadataLoaderPayload, ObjectMetadataDTO | null >; workspaceId: string; }): Promise { if ( commandMenuItem.engineComponentKey !== EngineComponentKey.NAVIGATION || !isObjectMetadataCommandMenuItemPayload(commandMenuItem.payload) ) { return null; } return objectMetadataLoader.load({ objectMetadataId: commandMenuItem.payload.objectMetadataItemId, workspaceId, }); } async resolveNavigationField({ commandMenuItem, fieldName, objectMetadataLoader, standardApplicationIdLoader, applicationTranslationCatalogLoader, workspaceId, locale, }: { commandMenuItem: CommandMenuItemDTO; fieldName: 'label' | 'shortLabel' | 'icon'; objectMetadataLoader: DataLoader< ObjectMetadataLoaderPayload, ObjectMetadataDTO | null >; standardApplicationIdLoader: DataLoader< StandardApplicationIdLoaderPayload, string >; applicationTranslationCatalogLoader: DataLoader< ApplicationTranslationCatalogLoaderPayload, Record | undefined >; workspaceId: string; locale: keyof typeof APP_LOCALES | undefined; }): Promise { const objectMetadata = await this.loadNavigationObjectMetadata({ commandMenuItem, objectMetadataLoader, workspaceId, }); const standardApplicationId = await standardApplicationIdLoader.load({ workspaceId, }); const isStandardApp = isDefined(objectMetadata) ? objectMetadata.applicationId === standardApplicationId : false; // The loader returns undefined for the standard app, so the standard-app // short-circuit lives in the loader, not here. const applicationCatalog = isDefined(objectMetadata) ? await applicationTranslationCatalogLoader.load({ applicationId: objectMetadata.applicationId, workspaceId, locale: locale ?? SOURCE_LOCALE, }) : undefined; return interpolateNavigationCommandMenuItemField({ commandMenuItem, fieldName, objectMetadata, isStandardApp, locale, i18nInstance: this.i18nService.getI18nInstance(locale ?? SOURCE_LOCALE), applicationCatalog, }); } async findByWorkflowVersionId( workflowVersionId: string, workspaceId: string, ): Promise { const { flatCommandMenuItemMaps } = await this.workspaceManyOrAllFlatEntityMapsCacheService.getOrRecomputeManyOrAllFlatEntityMaps( { workspaceId, flatMapsKeys: ['flatCommandMenuItemMaps'], }, ); const flatCommandMenuItem = Object.values( flatCommandMenuItemMaps.byUniversalIdentifier, ).find( (item) => isDefined(item) && item.workflowVersionId === workflowVersionId, ); if (!isDefined(flatCommandMenuItem)) { return null; } return fromFlatCommandMenuItemToCommandMenuItemDto(flatCommandMenuItem); } }