Migrate favorites to navigation menu items (1.17 upgrade) (#17477)

**What this PR does:**
- Migrates existing `Favorite` and `FavoriteFolder` entities to the new
`NavigationMenuItem` structure
- Preserves user-level vs workspace-level ownership
- Preserves folder structure, positions, and relationships
- Handles both view-based favorites (linked to views) and record-based
favorites (linked to records)
- Soft-deletes original favorites and folders after successful migration
- Enables the `IS_NAVIGATION_MENU_ITEM_ENABLED` feature flag
post-migration
- Skips migration if the feature flag is already enabled
- Idempotent: checks for existing navigation menu items to prevent
duplicates

**Migration flow:**
1. Migrate favorite folders first (creates folder mapping)
2. Migrate favorites
3. Soft-delete migrated favorites and folders
4. Enable feature flag

**What's next:**
- After all workspaces are migrated and the navigation menu item feature
is fully rolled out, we can:
- Remove the old `Favorite` and `FavoriteFolder` entities and related
code
- Remove the feature flag check and make navigation menu items the
default
  - Clean up any deprecated favorites-related code paths in the frontend

---------

Co-authored-by: Etienne <45695613+etiennejouan@users.noreply.github.com>
This commit is contained in:
Abdul Rahman
2026-02-06 19:12:20 +05:30
committed by GitHub
parent 7074d9ce1b
commit f6beb06364
6 changed files with 800 additions and 84 deletions
@@ -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<string, NavigationMenuItem[]>;
}>(
@@ -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<WorkspaceEntity>,
@InjectRepository(ObjectMetadataEntity)
private readonly objectMetadataRepository: Repository<ObjectMetadataEntity>,
@InjectRepository(FieldMetadataEntity)
private readonly fieldMetadataRepository: Repository<FieldMetadataEntity>,
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<void> {
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<string, Map<string, string>>;
flatNavigationMenuItemsToCreate: FlatNavigationMenuItem[];
}> {
const existingFlatNavigationMenuItemIds = Object.keys(
existingFlatNavigationMenuItemMaps.universalIdentifierById,
);
const favoriteFolderRepository =
await this.twentyORMGlobalManager.getRepository<FavoriteFolderWorkspaceEntity>(
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<string, Map<string, string>>(); // 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<FlatObjectMetadata>;
flatViewMaps: FlatEntityMaps<FlatView>;
folderIdMapping: Map<string, Map<string, string>>;
existingFlatNavigationMenuItemMaps: FlatNavigationMenuItemMaps;
flatFieldMetadataMaps: FlatEntityMaps<FlatFieldMetadata>;
}): Promise<{
migratedFavoriteIds: string[];
flatNavigationMenuItemsToCreate: FlatNavigationMenuItem[];
}> {
const favoriteRepository =
await this.twentyORMGlobalManager.getRepository<FavoriteWorkspaceEntity>(
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<FieldMetadataType.RELATION> =>
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<FlatView>;
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<string | null> {
if (!isDefined(workspaceMemberId)) {
return null;
}
const authContext = buildSystemAuthContext(workspaceId);
try {
const workspaceMember =
await this.twentyORMGlobalManager.executeInWorkspaceContext(
async () => {
const workspaceMemberRepository =
await this.twentyORMGlobalManager.getRepository<WorkspaceMemberWorkspaceEntity>(
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<FlatObjectMetadata>,
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<string, string | undefined>
)[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<void> {
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)}`,
);
}
}
}
@@ -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,
@@ -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,
@@ -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<ObjectMetadataEnt
private readonly workspaceCacheService: WorkspaceCacheService,
private readonly globalWorkspaceOrmManager: GlobalWorkspaceOrmManager,
private readonly applicationService: ApplicationService,
private readonly featureFlagService: FeatureFlagService,
) {
super(objectMetadataRepository);
}
@@ -409,6 +414,18 @@ export class ObjectMetadataService extends TypeOrmQueryService<ObjectMetadataEnt
flatObjectMetadataToCreate.labelIdentifierFieldMetadataId,
});
const isNavigationMenuItemEnabled =
existingFeatureFlagsMap[FeatureFlagKey.IS_NAVIGATION_MENU_ITEM_ENABLED] ??
false;
const flatNavigationMenuItemToCreate = isNavigationMenuItemEnabled
? await this.computeFlatNavigationMenuItemToCreate({
view: flatDefaultViewToCreate,
workspaceId,
workspaceCustomApplicationId: workspaceCustomFlatApplication.id,
})
: null;
const validateAndBuildResult =
await this.workspaceMigrationValidateBuildAndRunService.validateBuildAndRunWorkspaceMigration(
{
@@ -441,6 +458,15 @@ export class ObjectMetadataService extends TypeOrmQueryService<ObjectMetadataEnt
flatEntityToDelete: [],
flatEntityToUpdate: [],
},
...(isDefined(flatNavigationMenuItemToCreate)
? {
navigationMenuItem: {
flatEntityToCreate: [flatNavigationMenuItemToCreate],
flatEntityToDelete: [],
flatEntityToUpdate: [],
},
}
: {}),
},
workspaceId,
isSystemBuild: false,
@@ -476,10 +502,12 @@ export class ObjectMetadataService extends TypeOrmQueryService<ObjectMetadataEnt
);
}
await this.createWorkspaceFavoriteForNewObjectDefaultView({
view: flatDefaultViewToCreate,
workspaceId,
});
if (!isNavigationMenuItemEnabled) {
await this.createWorkspaceFavoriteForNewObjectDefaultView({
view: flatDefaultViewToCreate,
workspaceId,
});
}
return createdFlatObjectMetadata;
}
@@ -551,6 +579,51 @@ export class ObjectMetadataService extends TypeOrmQueryService<ObjectMetadataEnt
return defaultViewFields;
}
private async computeFlatNavigationMenuItemToCreate({
view,
workspaceId,
workspaceCustomApplicationId,
}: {
view: FlatView;
workspaceId: string;
workspaceCustomApplicationId: string;
}): Promise<FlatNavigationMenuItem> {
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,
@@ -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,
}: {