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:
+77
-4
@@ -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
-78
@@ -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,
|
||||
}: {
|
||||
|
||||
Reference in New Issue
Block a user