diff --git a/packages/twenty-front/src/modules/navigation-menu-item/hooks/useNavigationMenuItemsByFolder.ts b/packages/twenty-front/src/modules/navigation-menu-item/hooks/useNavigationMenuItemsByFolder.ts index 9aee408ed3..27016674cb 100644 --- a/packages/twenty-front/src/modules/navigation-menu-item/hooks/useNavigationMenuItemsByFolder.ts +++ b/packages/twenty-front/src/modules/navigation-menu-item/hooks/useNavigationMenuItemsByFolder.ts @@ -21,11 +21,17 @@ export const useNavigationMenuItemsByFolder = () => { const coreViews = useRecoilValue(coreViewsState); const objectMetadataItems = useRecoilValue(objectMetadataItemsState); - const { navigationMenuItems } = usePrefetchedNavigationMenuItemsData(); + const { navigationMenuItems, workspaceNavigationMenuItems } = + usePrefetchedNavigationMenuItemsData(); const views = coreViews.map(convertCoreViewToView); - const { folders, itemsByFolderId } = navigationMenuItems.reduce<{ + const allNavigationMenuItems = [ + ...navigationMenuItems, + ...workspaceNavigationMenuItems, + ]; + + const { folders, itemsByFolderId } = allNavigationMenuItems.reduce<{ folders: Array<{ id: string; name: string }>; itemsByFolderId: Map; }>( diff --git a/packages/twenty-server/src/database/commands/upgrade-version-command/1-17/1-17-migrate-favorites-to-navigation-menu-items.command.ts b/packages/twenty-server/src/database/commands/upgrade-version-command/1-17/1-17-migrate-favorites-to-navigation-menu-items.command.ts new file mode 100644 index 0000000000..e4f5dd58d4 --- /dev/null +++ b/packages/twenty-server/src/database/commands/upgrade-version-command/1-17/1-17-migrate-favorites-to-navigation-menu-items.command.ts @@ -0,0 +1,704 @@ +import { Logger } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; + +import { Command } from 'nest-commander'; +import { STANDARD_OBJECTS } from 'twenty-shared/metadata'; +import { FieldMetadataType } from 'twenty-shared/types'; +import { isDefined } from 'twenty-shared/utils'; +import { In, IsNull, Not, Or, Repository } from 'typeorm'; +import { v4 as uuidv4 } from 'uuid'; + +import { ActiveOrSuspendedWorkspacesMigrationCommandRunner } from 'src/database/commands/command-runners/active-or-suspended-workspaces-migration.command-runner'; +import { RunOnWorkspaceArgs } from 'src/database/commands/command-runners/workspaces-migration.command-runner'; +import { ApplicationService } from 'src/engine/core-modules/application/services/application.service'; +import { FeatureFlagKey } from 'src/engine/core-modules/feature-flag/enums/feature-flag-key.enum'; +import { FeatureFlagService } from 'src/engine/core-modules/feature-flag/services/feature-flag.service'; +import { UserWorkspaceService } from 'src/engine/core-modules/user-workspace/user-workspace.service'; +import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity'; +import { DataSourceService } from 'src/engine/metadata-modules/data-source/data-source.service'; +import { FieldMetadataEntity } from 'src/engine/metadata-modules/field-metadata/field-metadata.entity'; +import { FlatEntityMaps } from 'src/engine/metadata-modules/flat-entity/types/flat-entity-maps.type'; +import { findFlatEntityByIdInFlatEntityMaps } from 'src/engine/metadata-modules/flat-entity/utils/find-flat-entity-by-id-in-flat-entity-maps.util'; +import { findFlatEntityByUniversalIdentifier } from 'src/engine/metadata-modules/flat-entity/utils/find-flat-entity-by-universal-identifier.util'; +import { findManyFlatEntityByIdInFlatEntityMapsOrThrow } from 'src/engine/metadata-modules/flat-entity/utils/find-many-flat-entity-by-id-in-flat-entity-maps-or-throw.util'; +import { FlatFieldMetadata } from 'src/engine/metadata-modules/flat-field-metadata/types/flat-field-metadata.type'; +import { isFlatFieldMetadataOfType } from 'src/engine/metadata-modules/flat-field-metadata/utils/is-flat-field-metadata-of-type.util'; +import { FlatNavigationMenuItemMaps } from 'src/engine/metadata-modules/flat-navigation-menu-item/types/flat-navigation-menu-item-maps.type'; +import { FlatNavigationMenuItem } from 'src/engine/metadata-modules/flat-navigation-menu-item/types/flat-navigation-menu-item.type'; +import { FlatObjectMetadata } from 'src/engine/metadata-modules/flat-object-metadata/types/flat-object-metadata.type'; +import { FlatView } from 'src/engine/metadata-modules/flat-view/types/flat-view.type'; +import { ObjectMetadataEntity } from 'src/engine/metadata-modules/object-metadata/object-metadata.entity'; +import { GlobalWorkspaceOrmManager } from 'src/engine/twenty-orm/global-workspace-datasource/global-workspace-orm.manager'; +import { buildSystemAuthContext } from 'src/engine/twenty-orm/utils/build-system-auth-context.util'; +import { WorkspaceCacheService } from 'src/engine/workspace-cache/services/workspace-cache.service'; +import { STANDARD_NAVIGATION_MENU_ITEMS } from 'src/engine/workspace-manager/twenty-standard-application/constants/standard-navigation-menu-item.constant'; +import { WorkspaceMigrationValidateBuildAndRunService } from 'src/engine/workspace-manager/workspace-migration/services/workspace-migration-validate-build-and-run-service'; +import { FavoriteFolderWorkspaceEntity } from 'src/modules/favorite-folder/standard-objects/favorite-folder.workspace-entity'; +import { FavoriteWorkspaceEntity } from 'src/modules/favorite/standard-objects/favorite.workspace-entity'; +import { WorkspaceMemberWorkspaceEntity } from 'src/modules/workspace-member/standard-objects/workspace-member.workspace-entity'; + +const WORKSPACE_LEVEL = 'workspace-level'; + +@Command({ + name: 'upgrade:1-17:migrate-favorites-to-navigation-menu-items', + description: 'Migrate favorites and favoriteFolders to navigationMenuItems', +}) +export class MigrateFavoritesToNavigationMenuItemsCommand extends ActiveOrSuspendedWorkspacesMigrationCommandRunner { + protected readonly logger = new Logger( + MigrateFavoritesToNavigationMenuItemsCommand.name, + ); + + constructor( + @InjectRepository(WorkspaceEntity) + protected readonly workspaceRepository: Repository, + @InjectRepository(ObjectMetadataEntity) + private readonly objectMetadataRepository: Repository, + @InjectRepository(FieldMetadataEntity) + private readonly fieldMetadataRepository: Repository, + protected readonly twentyORMGlobalManager: GlobalWorkspaceOrmManager, + protected readonly dataSourceService: DataSourceService, + private readonly applicationService: ApplicationService, + private readonly workspaceCacheService: WorkspaceCacheService, + private readonly workspaceMigrationValidateBuildAndRunService: WorkspaceMigrationValidateBuildAndRunService, + private readonly userWorkspaceService: UserWorkspaceService, + private readonly featureFlagService: FeatureFlagService, + ) { + super(workspaceRepository, twentyORMGlobalManager, dataSourceService); + } + + override async runOnWorkspace({ + workspaceId, + options, + }: RunOnWorkspaceArgs): Promise { + this.logger.log( + `Starting migration of favorites to navigation menu items for workspace ${workspaceId}`, + ); + + const isNavigationMenuItemEnabled = + await this.featureFlagService.isFeatureEnabled( + FeatureFlagKey.IS_NAVIGATION_MENU_ITEM_ENABLED, + workspaceId, + ); + + if (isNavigationMenuItemEnabled) { + this.logger.log( + `Navigation menu item feature flag is already enabled for workspace ${workspaceId}. Skipping migration.`, + ); + + return; + } + + if (options.dryRun) { + this.logger.log( + `[DRY RUN] Would migrate favorites to navigation menu items for workspace ${workspaceId}`, + ); + + return; + } + + const { twentyStandardFlatApplication, workspaceCustomFlatApplication } = + await this.applicationService.findWorkspaceTwentyStandardAndCustomApplicationOrThrow( + { + workspaceId, + }, + ); + + const { + flatObjectMetadataMaps, + flatViewMaps, + flatNavigationMenuItemMaps: existingFlatNavigationMenuItemMaps, + flatFieldMetadataMaps, + } = await this.workspaceCacheService.getOrRecompute(workspaceId, [ + 'flatObjectMetadataMaps', + 'flatViewMaps', + 'flatNavigationMenuItemMaps', + 'flatFieldMetadataMaps', + ]); + + const { + folderIdMapping, + flatNavigationMenuItemsToCreate: folderNavItemsToCreate, + } = await this.migrateFavoriteFolders({ + workspaceId, + workspaceCustomApplicationId: workspaceCustomFlatApplication.id, + existingFlatNavigationMenuItemMaps, + }); + + const { flatNavigationMenuItemsToCreate: favoriteNavItemsToCreate } = + await this.migrateFavorites({ + workspaceId, + twentyStandardApplicationId: twentyStandardFlatApplication.id, + workspaceCustomApplicationId: workspaceCustomFlatApplication.id, + flatObjectMetadataMaps, + flatViewMaps, + folderIdMapping, + existingFlatNavigationMenuItemMaps, + flatFieldMetadataMaps, + }); + + const allFlatNavigationMenuItemsToCreate = [ + ...folderNavItemsToCreate, + ...favoriteNavItemsToCreate, + ]; + + if (allFlatNavigationMenuItemsToCreate.length > 0) { + const { flatNavigationMenuItemMaps: existingFlatNavigationMenuItemMaps } = + await this.workspaceCacheService.getOrRecompute(workspaceId, [ + 'flatNavigationMenuItemMaps', + ]); + + const twentyStandardItems = allFlatNavigationMenuItemsToCreate.filter( + (item) => item.applicationId === twentyStandardFlatApplication.id, + ); + const workspaceCustomItems = allFlatNavigationMenuItemsToCreate.filter( + (item) => item.applicationId === workspaceCustomFlatApplication.id, + ); + + if (workspaceCustomItems.length > 0) { + await this.createNavigationMenuItems({ + workspaceId, + flatNavigationMenuItemsToCreate: workspaceCustomItems, + existingFlatNavigationMenuItemMaps, + applicationUniversalIdentifier: + workspaceCustomFlatApplication.universalIdentifier, + }); + } + + if (twentyStandardItems.length > 0) { + await this.createNavigationMenuItems({ + workspaceId, + flatNavigationMenuItemsToCreate: twentyStandardItems, + existingFlatNavigationMenuItemMaps, + applicationUniversalIdentifier: + twentyStandardFlatApplication.universalIdentifier, + }); + } + } + + await this.featureFlagService.upsertWorkspaceFeatureFlag({ + workspaceId, + featureFlag: FeatureFlagKey.IS_NAVIGATION_MENU_ITEM_ENABLED, + value: true, + }); + + this.logger.log( + `Successfully migrated favorites to navigation menu items for workspace ${workspaceId}`, + ); + } + + private async migrateFavoriteFolders({ + workspaceId, + workspaceCustomApplicationId, + existingFlatNavigationMenuItemMaps, + }: { + workspaceId: string; + workspaceCustomApplicationId: string; + existingFlatNavigationMenuItemMaps: FlatNavigationMenuItemMaps; + }): Promise<{ + folderIdMapping: Map>; + flatNavigationMenuItemsToCreate: FlatNavigationMenuItem[]; + }> { + const existingFlatNavigationMenuItemIds = Object.keys( + existingFlatNavigationMenuItemMaps.universalIdentifierById, + ); + + const favoriteFolderRepository = + await this.twentyORMGlobalManager.getRepository( + workspaceId, + 'favoriteFolder', + { shouldBypassPermissionChecks: true }, + ); + + const favoriteFolders = await favoriteFolderRepository.find({ + where: { + deletedAt: IsNull(), + id: Not(In(existingFlatNavigationMenuItemIds)), + }, + order: { position: 'ASC' }, + relations: { + favorites: true, + }, + }); + + if (favoriteFolders.length === 0) { + return { + folderIdMapping: new Map(), + flatNavigationMenuItemsToCreate: [], + }; + } + + const folderIdMapping = new Map>(); // favoriteFolderId -> workspaceMemberId or workspace-level -> navigationMenuItemId + const flatNavigationMenuItemsToCreate: FlatNavigationMenuItem[] = []; + + for (const favoriteFolder of favoriteFolders) { + const workspaceMemberIdToNavigationMenuItemIdMap = new Map< + string, + string + >(); + + const workspaceMemberIdsWithFavoritesInFolder = new Set( + favoriteFolder.favorites + .map((favorite) => favorite.forWorkspaceMemberId) + .filter(isDefined), + ); + + const hasWorkspaceScopedFavoritesInFolder = favoriteFolder.favorites.some( + (favorite) => !isDefined(favorite.forWorkspaceMemberId), + ); + + const now = new Date().toISOString(); + + for (const [index, workspaceMemberId] of Array.from( + workspaceMemberIdsWithFavoritesInFolder, + ).entries()) { + const navigationMenuItemId = index === 0 ? favoriteFolder.id : uuidv4(); + + workspaceMemberIdToNavigationMenuItemIdMap.set( + workspaceMemberId, + navigationMenuItemId, + ); + + const userWorkspaceId = + await this.getUserWorkspaceIdFromWorkspaceMemberId( + workspaceMemberId, + workspaceId, + ); + + const folderToCreate = { + id: navigationMenuItemId, + universalIdentifier: navigationMenuItemId, + userWorkspaceId, + targetRecordId: null, + targetObjectMetadataId: null, + viewId: null, + folderId: null, + name: favoriteFolder.name, + position: favoriteFolder.position, + workspaceId, + applicationId: workspaceCustomApplicationId, + createdAt: now, + updatedAt: now, + }; + + flatNavigationMenuItemsToCreate.push(folderToCreate); + } + + if (hasWorkspaceScopedFavoritesInFolder) { + const workspaceLevelNavigationMenuItemId = uuidv4(); + + workspaceMemberIdToNavigationMenuItemIdMap.set( + WORKSPACE_LEVEL, + workspaceLevelNavigationMenuItemId, + ); + + const workspaceLevelFolderToCreate = { + id: workspaceLevelNavigationMenuItemId, + universalIdentifier: workspaceLevelNavigationMenuItemId, + userWorkspaceId: null, + targetRecordId: null, + targetObjectMetadataId: null, + viewId: null, + folderId: null, + name: favoriteFolder.name, + position: favoriteFolder.position, + workspaceId, + applicationId: workspaceCustomApplicationId, + createdAt: now, + updatedAt: now, + }; + + flatNavigationMenuItemsToCreate.push(workspaceLevelFolderToCreate); + } + + folderIdMapping.set( + favoriteFolder.id, + workspaceMemberIdToNavigationMenuItemIdMap, + ); + } + + return { + folderIdMapping, + flatNavigationMenuItemsToCreate, + }; + } + + private async migrateFavorites({ + workspaceId, + twentyStandardApplicationId, + workspaceCustomApplicationId, + flatObjectMetadataMaps, + flatViewMaps, + folderIdMapping, + existingFlatNavigationMenuItemMaps, + flatFieldMetadataMaps, + }: { + workspaceId: string; + twentyStandardApplicationId: string; + workspaceCustomApplicationId: string; + flatObjectMetadataMaps: FlatEntityMaps; + flatViewMaps: FlatEntityMaps; + folderIdMapping: Map>; + existingFlatNavigationMenuItemMaps: FlatNavigationMenuItemMaps; + flatFieldMetadataMaps: FlatEntityMaps; + }): Promise<{ + migratedFavoriteIds: string[]; + flatNavigationMenuItemsToCreate: FlatNavigationMenuItem[]; + }> { + const favoriteRepository = + await this.twentyORMGlobalManager.getRepository( + workspaceId, + 'favorite', + { shouldBypassPermissionChecks: true }, + ); + + const existingFlatNavigationMenuItemIds = Object.keys( + existingFlatNavigationMenuItemMaps.universalIdentifierById, + ); + + const viewIdsFromExistingNavigationMenuItems = Object.values( + existingFlatNavigationMenuItemMaps.byUniversalIdentifier, + ) + .filter(isDefined) + .map((item): string | null => item.viewId) + .filter(isDefined); + + const favorites = await favoriteRepository.find({ + where: { + deletedAt: IsNull(), + id: Not(In(existingFlatNavigationMenuItemIds)), + viewId: Or(IsNull(), Not(In(viewIdsFromExistingNavigationMenuItems))), + }, + order: { position: 'ASC' }, + }); + + if (favorites.length === 0) { + return { migratedFavoriteIds: [], flatNavigationMenuItemsToCreate: [] }; + } + + const favoriteObjectMetadata = findFlatEntityByUniversalIdentifier({ + flatEntityMaps: flatObjectMetadataMaps, + universalIdentifier: STANDARD_OBJECTS.favorite.universalIdentifier, + }); + + if (!isDefined(favoriteObjectMetadata)) { + throw new Error('Favorite object metadata not found'); + } + + const favoriteRelationFields = + findManyFlatEntityByIdInFlatEntityMapsOrThrow({ + flatEntityMaps: flatFieldMetadataMaps, + flatEntityIds: favoriteObjectMetadata.fieldIds, + }).filter( + (field): field is FlatFieldMetadata => + isFlatFieldMetadataOfType(field, FieldMetadataType.RELATION), + ); + + const favoriteRelationFieldMap = new Map< + string, + { name: string; relationTargetUniversalIdentifier: string } + >(); + + for (const field of favoriteRelationFields) { + if ( + field.name !== 'forWorkspaceMember' && + field.name !== 'favoriteFolder' && + isDefined(field.relationTargetObjectMetadataId) + ) { + const targetObjectMetadata = findFlatEntityByIdInFlatEntityMaps({ + flatEntityMaps: flatObjectMetadataMaps, + flatEntityId: field.relationTargetObjectMetadataId, + }); + + if (isDefined(targetObjectMetadata)) { + favoriteRelationFieldMap.set(field.name, { + name: field.name, + relationTargetUniversalIdentifier: + targetObjectMetadata.universalIdentifier, + }); + } + } + } + + const flatNavigationMenuItemsToCreate: FlatNavigationMenuItem[] = []; + const migratedFavoriteIds: string[] = []; + + for (const favorite of favorites) { + const userWorkspaceId = !isDefined(favorite.forWorkspaceMemberId) + ? null + : await this.getUserWorkspaceIdFromWorkspaceMemberId( + favorite.forWorkspaceMemberId, + workspaceId, + ); + + if ( + isDefined(favorite.forWorkspaceMemberId) && + !isDefined(userWorkspaceId) + ) { + this.logger.warn( + `Skipping favorite ${favorite.id} - forWorkspaceMemberId ${favorite.forWorkspaceMemberId} did not resolve to a userWorkspaceId`, + ); + + continue; + } + + const folderId = favorite.favoriteFolderId + ? (folderIdMapping + .get(favorite.favoriteFolderId) + ?.get( + isDefined(favorite.forWorkspaceMemberId) + ? favorite.forWorkspaceMemberId + : WORKSPACE_LEVEL, + ) ?? null) + : null; + + if (isDefined(favorite.viewId)) { + const existingItem = Object.values( + existingFlatNavigationMenuItemMaps.byUniversalIdentifier, + ).find( + (item): item is FlatNavigationMenuItem => + isDefined(item) && + item.workspaceId === workspaceId && + item.userWorkspaceId === userWorkspaceId && + item.viewId === favorite.viewId && + item.folderId === folderId, + ); + + if (isDefined(existingItem)) { + this.logger.log( + `Skipping favorite ${favorite.id} - navigation menu item already exists for view ${favorite.viewId}`, + ); + + continue; + } + + const { applicationId, universalIdentifier } = + this.getApplicationIdAndUniversalIdentifierForViewFavorite({ + viewId: favorite.viewId, + flatViewMaps, + twentyStandardApplicationId, + workspaceCustomApplicationId, + }); + + const now = new Date().toISOString(); + + flatNavigationMenuItemsToCreate.push({ + id: favorite.id, + universalIdentifier, + userWorkspaceId, + targetRecordId: null, + targetObjectMetadataId: null, + viewId: favorite.viewId, + folderId, + name: null, + position: favorite.position, + workspaceId, + applicationId, + createdAt: now, + updatedAt: now, + }); + + migratedFavoriteIds.push(favorite.id); + + continue; + } + + const { targetRecordId, targetObjectMetadataId } = + this.getTargetRecordFromFavorite( + favorite, + flatObjectMetadataMaps, + favoriteRelationFieldMap, + ); + + if (!isDefined(targetRecordId) || !isDefined(targetObjectMetadataId)) { + this.logger.warn( + `Skipping favorite ${favorite.id} - no valid target record found`, + ); + + continue; + } + + const now = new Date().toISOString(); + + flatNavigationMenuItemsToCreate.push({ + id: favorite.id, + universalIdentifier: favorite.id, + userWorkspaceId, + targetRecordId, + targetObjectMetadataId, + viewId: null, + folderId, + name: null, + position: favorite.position, + workspaceId, + applicationId: workspaceCustomApplicationId, + createdAt: now, + updatedAt: now, + }); + + migratedFavoriteIds.push(favorite.id); + } + + return { + migratedFavoriteIds, + flatNavigationMenuItemsToCreate, + }; + } + + private getApplicationIdAndUniversalIdentifierForViewFavorite({ + viewId, + flatViewMaps, + twentyStandardApplicationId, + workspaceCustomApplicationId, + }: { + viewId: string; + flatViewMaps: FlatEntityMaps; + twentyStandardApplicationId: string; + workspaceCustomApplicationId: string; + }): { applicationId: string; universalIdentifier: string } { + const flatView = findFlatEntityByIdInFlatEntityMaps({ + flatEntityMaps: flatViewMaps, + flatEntityId: viewId, + }); + + if (!isDefined(flatView)) { + throw new Error(`View ${viewId} not found`); + } + + const matchingStandardItem = Object.values( + STANDARD_NAVIGATION_MENU_ITEMS, + ).find( + (item) => item.viewUniversalIdentifier === flatView.universalIdentifier, + ); + + return !isDefined(matchingStandardItem) + ? { + applicationId: workspaceCustomApplicationId, + universalIdentifier: uuidv4(), + } + : { + applicationId: twentyStandardApplicationId, + universalIdentifier: matchingStandardItem.universalIdentifier, + }; + } + + private async getUserWorkspaceIdFromWorkspaceMemberId( + workspaceMemberId: string | null | undefined, + workspaceId: string, + ): Promise { + if (!isDefined(workspaceMemberId)) { + return null; + } + const authContext = buildSystemAuthContext(workspaceId); + + try { + const workspaceMember = + await this.twentyORMGlobalManager.executeInWorkspaceContext( + async () => { + const workspaceMemberRepository = + await this.twentyORMGlobalManager.getRepository( + workspaceId, + 'workspaceMember', + { shouldBypassPermissionChecks: true }, + ); + + return await workspaceMemberRepository.findOne({ + where: { id: workspaceMemberId }, + }); + }, + authContext, + ); + + if (!isDefined(workspaceMember)) { + return null; + } + + const userWorkspace = + await this.userWorkspaceService.getUserWorkspaceForUserOrThrow({ + userId: workspaceMember.userId, + workspaceId, + }); + + return userWorkspace.id; + } catch (error) { + this.logger.warn( + `Failed to get userWorkspaceId for workspaceMemberId ${workspaceMemberId}: ${error}`, + ); + + return null; + } + } + + private getTargetRecordFromFavorite( + favorite: FavoriteWorkspaceEntity, + flatObjectMetadataMaps: FlatEntityMaps, + favoriteRelationFieldMap: Map< + string, + { name: string; relationTargetUniversalIdentifier: string } + >, + ): { + targetRecordId: string | null; + targetObjectMetadataId: string | null; + } { + for (const [ + fieldName, + { relationTargetUniversalIdentifier }, + ] of favoriteRelationFieldMap.entries()) { + const fieldIdName = `${fieldName}Id`; + const recordId = ( + favorite as unknown as Record + )[fieldIdName]; + + if (!isDefined(recordId)) { + continue; + } + + const targetObjectMetadata = findFlatEntityByUniversalIdentifier({ + flatEntityMaps: flatObjectMetadataMaps, + universalIdentifier: relationTargetUniversalIdentifier, + }); + + if (isDefined(targetObjectMetadata)) { + return { + targetRecordId: recordId, + targetObjectMetadataId: targetObjectMetadata.id, + }; + } + } + + return { targetRecordId: null, targetObjectMetadataId: null }; + } + + private async createNavigationMenuItems({ + workspaceId, + flatNavigationMenuItemsToCreate, + existingFlatNavigationMenuItemMaps: _existingFlatNavigationMenuItemMaps, + applicationUniversalIdentifier, + }: { + workspaceId: string; + flatNavigationMenuItemsToCreate: FlatNavigationMenuItem[]; + existingFlatNavigationMenuItemMaps: FlatNavigationMenuItemMaps; + applicationUniversalIdentifier: string; + }): Promise { + const validateAndBuildResult = + await this.workspaceMigrationValidateBuildAndRunService.validateBuildAndRunWorkspaceMigration( + { + allFlatEntityOperationByMetadataName: { + navigationMenuItem: { + flatEntityToCreate: flatNavigationMenuItemsToCreate, + flatEntityToDelete: [], + flatEntityToUpdate: [], + }, + }, + workspaceId, + isSystemBuild: true, + applicationUniversalIdentifier, + }, + ); + + if (isDefined(validateAndBuildResult)) { + throw new Error( + `Failed to create navigation menu items: ${JSON.stringify(validateAndBuildResult, null, 2)}`, + ); + } + } +} diff --git a/packages/twenty-server/src/database/commands/upgrade-version-command/1-17/1-17-upgrade-version-command.module.ts b/packages/twenty-server/src/database/commands/upgrade-version-command/1-17/1-17-upgrade-version-command.module.ts index 29a9ea4597..0863df954c 100644 --- a/packages/twenty-server/src/database/commands/upgrade-version-command/1-17/1-17-upgrade-version-command.module.ts +++ b/packages/twenty-server/src/database/commands/upgrade-version-command/1-17/1-17-upgrade-version-command.module.ts @@ -7,6 +7,7 @@ import { FixMorphRelationFieldNamesCommand } from 'src/database/commands/upgrade import { IdentifyWebhookMetadataCommand } from 'src/database/commands/upgrade-version-command/1-17/1-17-identify-webhook-metadata.command'; import { MakeWebhookUniversalIdentifierAndApplicationIdNotNullableMigrationCommand } from 'src/database/commands/upgrade-version-command/1-17/1-17-make-webhook-universal-identifier-and-application-id-not-nullable-migration.command'; import { MigrateAttachmentToMorphRelationsCommand } from 'src/database/commands/upgrade-version-command/1-17/1-17-migrate-attachment-to-morph-relations.command'; +import { MigrateFavoritesToNavigationMenuItemsCommand } from 'src/database/commands/upgrade-version-command/1-17/1-17-migrate-favorites-to-navigation-menu-items.command'; import { MigrateNoteTargetToMorphRelationsCommand } from 'src/database/commands/upgrade-version-command/1-17/1-17-migrate-note-target-to-morph-relations.command'; import { MigrateSendEmailRecipientsCommand } from 'src/database/commands/upgrade-version-command/1-17/1-17-migrate-send-email-recipients.command'; import { MigrateTaskTargetToMorphRelationsCommand } from 'src/database/commands/upgrade-version-command/1-17/1-17-migrate-task-target-to-morph-relations.command'; @@ -19,6 +20,7 @@ import { FeatureFlagModule } from 'src/engine/core-modules/feature-flag/feature- import { FileStorageModule } from 'src/engine/core-modules/file-storage/file-storage.module'; import { FileEntity } from 'src/engine/core-modules/file/entities/file.entity'; import { RecordPositionModule } from 'src/engine/core-modules/record-position/record-position.module'; +import { UserWorkspaceModule } from 'src/engine/core-modules/user-workspace/user-workspace.module'; import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity'; import { DataSourceModule } from 'src/engine/metadata-modules/data-source/data-source.module'; import { FieldMetadataEntity } from 'src/engine/metadata-modules/field-metadata/field-metadata.entity'; @@ -32,6 +34,7 @@ import { WorkspaceMetadataVersionModule } from 'src/engine/metadata-modules/work import { GlobalWorkspaceDataSourceModule } from 'src/engine/twenty-orm/global-workspace-datasource/global-workspace-datasource.module'; import { WorkspaceCacheStorageModule } from 'src/engine/workspace-cache-storage/workspace-cache-storage.module'; import { WorkspaceCacheModule } from 'src/engine/workspace-cache/workspace-cache.module'; +import { WorkspaceMigrationModule } from 'src/engine/workspace-manager/workspace-migration/workspace-migration.module'; import { AttachmentWorkspaceEntity } from 'src/modules/attachment/standard-objects/attachment.workspace-entity'; import { NoteTargetWorkspaceEntity } from 'src/modules/note/standard-objects/note-target.workspace-entity'; import { TaskTargetWorkspaceEntity } from 'src/modules/task/standard-objects/task-target.workspace-entity'; @@ -59,6 +62,8 @@ import { TaskTargetWorkspaceEntity } from 'src/modules/task/standard-objects/tas FieldMetadataModule, ObjectMetadataModule, ApplicationModule, + UserWorkspaceModule, + WorkspaceMigrationModule, LogicFunctionModule, RecordPositionModule, GlobalWorkspaceDataSourceModule, @@ -66,6 +71,7 @@ import { TaskTargetWorkspaceEntity } from 'src/modules/task/standard-objects/tas providers: [ FixMorphRelationFieldNamesCommand, MigrateAttachmentToMorphRelationsCommand, + MigrateFavoritesToNavigationMenuItemsCommand, MigrateNoteTargetToMorphRelationsCommand, MigrateTaskTargetToMorphRelationsCommand, IdentifyWebhookMetadataCommand, @@ -80,6 +86,7 @@ import { TaskTargetWorkspaceEntity } from 'src/modules/task/standard-objects/tas exports: [ FixMorphRelationFieldNamesCommand, MigrateAttachmentToMorphRelationsCommand, + MigrateFavoritesToNavigationMenuItemsCommand, MigrateNoteTargetToMorphRelationsCommand, MigrateTaskTargetToMorphRelationsCommand, IdentifyWebhookMetadataCommand, 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 dc52d8a130..bbad938852 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 @@ -15,6 +15,7 @@ import { FixMorphRelationFieldNamesCommand } from 'src/database/commands/upgrade import { IdentifyWebhookMetadataCommand } from 'src/database/commands/upgrade-version-command/1-17/1-17-identify-webhook-metadata.command'; import { MakeWebhookUniversalIdentifierAndApplicationIdNotNullableMigrationCommand } from 'src/database/commands/upgrade-version-command/1-17/1-17-make-webhook-universal-identifier-and-application-id-not-nullable-migration.command'; import { MigrateAttachmentToMorphRelationsCommand } from 'src/database/commands/upgrade-version-command/1-17/1-17-migrate-attachment-to-morph-relations.command'; +import { MigrateFavoritesToNavigationMenuItemsCommand } from 'src/database/commands/upgrade-version-command/1-17/1-17-migrate-favorites-to-navigation-menu-items.command'; import { MigrateNoteTargetToMorphRelationsCommand } from 'src/database/commands/upgrade-version-command/1-17/1-17-migrate-note-target-to-morph-relations.command'; import { MigrateTaskTargetToMorphRelationsCommand } from 'src/database/commands/upgrade-version-command/1-17/1-17-migrate-task-target-to-morph-relations.command'; import { MigrateWorkflowCodeStepsCommand } from 'src/database/commands/upgrade-version-command/1-17/1-17-migrate-workflow-code-steps.command'; @@ -42,6 +43,7 @@ export class UpgradeCommand extends UpgradeCommandRunner { protected readonly backfillApplicationPackageFilesCommand: BackfillApplicationPackageFilesCommand, protected readonly deleteFileRecordsCommand: DeleteFileRecordsCommand, protected readonly migrateAttachmentToMorphRelationsCommand: MigrateAttachmentToMorphRelationsCommand, + protected readonly migrateFavoritesToNavigationMenuItemsCommand: MigrateFavoritesToNavigationMenuItemsCommand, protected readonly migrateNoteTargetToMorphRelationsCommand: MigrateNoteTargetToMorphRelationsCommand, protected readonly migrateTaskTargetToMorphRelationsCommand: MigrateTaskTargetToMorphRelationsCommand, protected readonly identifyWebhookMetadataCommand: IdentifyWebhookMetadataCommand, @@ -62,6 +64,7 @@ export class UpgradeCommand extends UpgradeCommandRunner { const commands_1170: VersionCommands = [ this.migrateAttachmentToMorphRelationsCommand, + this.migrateFavoritesToNavigationMenuItemsCommand, this.migrateNoteTargetToMorphRelationsCommand, this.migrateTaskTargetToMorphRelationsCommand, this.identifyWebhookMetadataCommand, diff --git a/packages/twenty-server/src/engine/metadata-modules/object-metadata/object-metadata.service.ts b/packages/twenty-server/src/engine/metadata-modules/object-metadata/object-metadata.service.ts index 80caddf96e..2e50c5e1cc 100644 --- a/packages/twenty-server/src/engine/metadata-modules/object-metadata/object-metadata.service.ts +++ b/packages/twenty-server/src/engine/metadata-modules/object-metadata/object-metadata.service.ts @@ -4,9 +4,12 @@ import { InjectRepository } from '@nestjs/typeorm'; import { TypeOrmQueryService } from '@ptc-org/nestjs-query-typeorm'; import { fromArrayToUniqueKeyRecord, isDefined } from 'twenty-shared/utils'; import { FindManyOptions, FindOneOptions, Repository } from 'typeorm'; +import { v4 as uuidv4 } from 'uuid'; import { ApplicationService } from 'src/engine/core-modules/application/services/application.service'; import { type FlatApplication } from 'src/engine/core-modules/application/types/flat-application.type'; +import { FeatureFlagKey } from 'src/engine/core-modules/feature-flag/enums/feature-flag-key.enum'; +import { FeatureFlagService } from 'src/engine/core-modules/feature-flag/services/feature-flag.service'; import { createEmptyFlatEntityMaps } from 'src/engine/metadata-modules/flat-entity/constant/create-empty-flat-entity-maps.constant'; import { WorkspaceManyOrAllFlatEntityMapsCacheService } from 'src/engine/metadata-modules/flat-entity/services/workspace-many-or-all-flat-entity-maps-cache.service'; import { AllFlatEntityMaps } from 'src/engine/metadata-modules/flat-entity/types/all-flat-entity-maps.type'; @@ -16,6 +19,7 @@ import { findFlatEntityByIdInFlatEntityMaps } from 'src/engine/metadata-modules/ import { findManyFlatEntityByIdInFlatEntityMapsOrThrow } from 'src/engine/metadata-modules/flat-entity/utils/find-many-flat-entity-by-id-in-flat-entity-maps-or-throw.util'; import { FlatFieldMetadata } from 'src/engine/metadata-modules/flat-field-metadata/types/flat-field-metadata.type'; import { FlatIndexMetadata } from 'src/engine/metadata-modules/flat-index-metadata/types/flat-index-metadata.type'; +import { FlatNavigationMenuItem } from 'src/engine/metadata-modules/flat-navigation-menu-item/types/flat-navigation-menu-item.type'; import { FlatObjectMetadata } from 'src/engine/metadata-modules/flat-object-metadata/types/flat-object-metadata.type'; import { fromCreateObjectInputToFlatObjectMetadataAndFlatFieldMetadatasToCreate } from 'src/engine/metadata-modules/flat-object-metadata/utils/from-create-object-input-to-flat-object-metadata-and-flat-field-metadatas-to-create.util'; import { fromDeleteObjectInputToFlatFieldMetadatasToDelete } from 'src/engine/metadata-modules/flat-object-metadata/utils/from-delete-object-input-to-flat-field-metadatas-to-delete.util'; @@ -51,6 +55,7 @@ export class ObjectMetadataService extends TypeOrmQueryService { + const { flatNavigationMenuItemMaps } = + await this.workspaceCacheService.getOrRecompute(workspaceId, [ + 'flatNavigationMenuItemMaps', + ]); + + const workspaceLevelItems = Object.values( + flatNavigationMenuItemMaps.byUniversalIdentifier, + ).filter( + (item): item is FlatNavigationMenuItem => + isDefined(item) && !isDefined(item.userWorkspaceId), + ); + const nextPosition = + workspaceLevelItems.length > 0 + ? Math.max(...workspaceLevelItems.map((item) => item.position)) + 1 + : 0; + + const newId = uuidv4(); + const now = new Date().toISOString(); + + return { + id: newId, + universalIdentifier: newId, + userWorkspaceId: null, + targetRecordId: null, + targetObjectMetadataId: null, + viewId: view.id, + folderId: null, + name: null, + position: nextPosition, + workspaceId, + applicationId: workspaceCustomApplicationId, + createdAt: now, + updatedAt: now, + }; + } + private async createWorkspaceFavoriteForNewObjectDefaultView({ view, workspaceId, diff --git a/packages/twenty-server/src/engine/workspace-manager/twenty-standard-application/services/twenty-standard-application.service.ts b/packages/twenty-server/src/engine/workspace-manager/twenty-standard-application/services/twenty-standard-application.service.ts index 9b7badfcbc..08fa5e3420 100644 --- a/packages/twenty-server/src/engine/workspace-manager/twenty-standard-application/services/twenty-standard-application.service.ts +++ b/packages/twenty-server/src/engine/workspace-manager/twenty-standard-application/services/twenty-standard-application.service.ts @@ -1,7 +1,7 @@ import { Injectable } from '@nestjs/common'; -import { isDefined } from 'twenty-shared/utils'; import { STANDARD_OBJECTS } from 'twenty-shared/metadata'; +import { isDefined } from 'twenty-shared/utils'; import { ApplicationService } from 'src/engine/core-modules/application/services/application.service'; import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service'; @@ -10,7 +10,6 @@ import { MetadataFlatEntity } from 'src/engine/metadata-modules/flat-entity/type import { findFlatEntityByUniversalIdentifier } from 'src/engine/metadata-modules/flat-entity/utils/find-flat-entity-by-universal-identifier.util'; import { getMetadataFlatEntityMapsKey } from 'src/engine/metadata-modules/flat-entity/utils/get-metadata-flat-entity-maps-key.util'; import { getSubFlatEntityMapsByApplicationIdOrThrow } from 'src/engine/metadata-modules/flat-entity/utils/get-sub-flat-entity-maps-by-application-id-or-throw.util'; -import { fromCreateNavigationMenuItemInputToFlatNavigationMenuItemToCreate } from 'src/engine/metadata-modules/flat-navigation-menu-item/utils/from-create-navigation-menu-item-input-to-flat-navigation-menu-item-to-create.util'; import { FlatView } from 'src/engine/metadata-modules/flat-view/types/flat-view.type'; import { GlobalWorkspaceOrmManager } from 'src/engine/twenty-orm/global-workspace-datasource/global-workspace-orm.manager'; import { buildSystemAuthContext } from 'src/engine/twenty-orm/utils/build-system-auth-context.util'; @@ -60,82 +59,6 @@ export class TwentyStandardApplicationService { }, authContext); } - private async createManyNavigationMenuItem({ - flatViews, - workspaceId, - }: { - flatViews: FlatView[]; - workspaceId: string; - }) { - const { workspaceCustomFlatApplication } = - await this.applicationService.findWorkspaceTwentyStandardAndCustomApplicationOrThrow( - { workspaceId }, - ); - - const { flatNavigationMenuItemMaps: existingFlatNavigationMenuItemMaps } = - await this.workspaceManyOrAllFlatEntityMapsCacheService.getOrRecomputeManyOrAllFlatEntityMaps( - { - workspaceId, - flatMapsKeys: ['flatNavigationMenuItemMaps'], - }, - ); - - const existingWorkspaceItems = Object.values( - existingFlatNavigationMenuItemMaps.byUniversalIdentifier, - ).filter( - (item) => - isDefined(item) && - item.workspaceId === workspaceId && - item.userWorkspaceId === null, - ); - - const maxPosition = existingWorkspaceItems.reduce( - (max, item) => Math.max(max, item?.position ?? 0), - 0, - ); - - const flatNavigationMenuItemsToCreate = flatViews.map((flatView, index) => { - return fromCreateNavigationMenuItemInputToFlatNavigationMenuItemToCreate({ - createNavigationMenuItemInput: { - viewId: flatView.id, - userWorkspaceId: undefined, - position: maxPosition + index + 1, - }, - workspaceId, - applicationId: workspaceCustomFlatApplication.id, - flatNavigationMenuItemMaps: existingFlatNavigationMenuItemMaps, - }); - }); - - if (flatNavigationMenuItemsToCreate.length === 0) { - return; - } - - const validateAndBuildResult = - await this.workspaceMigrationValidateBuildAndRunService.validateBuildAndRunWorkspaceMigration( - { - allFlatEntityOperationByMetadataName: { - navigationMenuItem: { - flatEntityToCreate: flatNavigationMenuItemsToCreate, - flatEntityToDelete: [], - flatEntityToUpdate: [], - }, - }, - workspaceId, - isSystemBuild: true, - applicationUniversalIdentifier: - workspaceCustomFlatApplication.universalIdentifier, - }, - ); - - if (isDefined(validateAndBuildResult)) { - throw new WorkspaceMigrationBuilderException( - validateAndBuildResult, - 'Multiple validation errors occurred while creating workspace navigation menu items', - ); - } - } - async synchronizeTwentyStandardApplicationOrThrow({ workspaceId, }: {