Add syncable NavigationMenuItem entity to core schema (#17232)

Implements a new syncable `navigationMenuItem` entity in the core schema
to replace the workspace `favorite` entity.

## Next Steps
- Frontend integration ([separate
PR](https://github.com/twentyhq/twenty/pull/17268))
- Data migration (separate PR)
This commit is contained in:
Abdul Rahman
2026-01-22 18:18:51 +05:30
committed by GitHub
parent 2cda1f5f08
commit aff577689f
71 changed files with 4050 additions and 3 deletions
@@ -0,0 +1,7 @@
import { type FlatNavigationMenuItem } from 'src/engine/metadata-modules/flat-navigation-menu-item/types/flat-navigation-menu-item.type';
export const FLAT_NAVIGATION_MENU_ITEM_EDITABLE_PROPERTIES = [
'position',
'folderId',
'name',
] as const satisfies (keyof FlatNavigationMenuItem)[];
@@ -0,0 +1,16 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { WorkspaceManyOrAllFlatEntityMapsCacheModule } from 'src/engine/metadata-modules/flat-entity/services/workspace-many-or-all-flat-entity-maps-cache.module';
import { WorkspaceFlatNavigationMenuItemMapCacheService } from 'src/engine/metadata-modules/flat-navigation-menu-item/services/workspace-flat-navigation-menu-item-map-cache.service';
import { NavigationMenuItemEntity } from 'src/engine/metadata-modules/navigation-menu-item/entities/navigation-menu-item.entity';
@Module({
imports: [
TypeOrmModule.forFeature([NavigationMenuItemEntity]),
WorkspaceManyOrAllFlatEntityMapsCacheModule,
],
providers: [WorkspaceFlatNavigationMenuItemMapCacheService],
exports: [WorkspaceFlatNavigationMenuItemMapCacheService],
})
export class FlatNavigationMenuItemModule {}
@@ -0,0 +1,52 @@
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { WorkspaceCacheProvider } from 'src/engine/workspace-cache/interfaces/workspace-cache-provider.service';
import { createEmptyFlatEntityMaps } from 'src/engine/metadata-modules/flat-entity/constant/create-empty-flat-entity-maps.constant';
import { type FlatNavigationMenuItemMaps } from 'src/engine/metadata-modules/flat-navigation-menu-item/types/flat-navigation-menu-item-maps.type';
import { addFlatNavigationMenuItemToMapsAndUpdateIndex } from 'src/engine/metadata-modules/flat-navigation-menu-item/utils/add-flat-navigation-menu-item-to-maps-and-update-index.util';
import { fromNavigationMenuItemEntityToFlatNavigationMenuItem } from 'src/engine/metadata-modules/flat-navigation-menu-item/utils/from-navigation-menu-item-entity-to-flat-navigation-menu-item.util';
import { NavigationMenuItemEntity } from 'src/engine/metadata-modules/navigation-menu-item/entities/navigation-menu-item.entity';
import { WorkspaceCache } from 'src/engine/workspace-cache/decorators/workspace-cache.decorator';
@Injectable()
@WorkspaceCache('flatNavigationMenuItemMaps')
export class WorkspaceFlatNavigationMenuItemMapCacheService extends WorkspaceCacheProvider<FlatNavigationMenuItemMaps> {
constructor(
@InjectRepository(NavigationMenuItemEntity)
private readonly navigationMenuItemRepository: Repository<NavigationMenuItemEntity>,
) {
super();
}
async computeForCache(
workspaceId: string,
): Promise<FlatNavigationMenuItemMaps> {
const navigationMenuItems = await this.navigationMenuItemRepository.find({
where: { workspaceId },
withDeleted: true,
});
const flatNavigationMenuItemMaps = {
...createEmptyFlatEntityMaps(),
byUserWorkspaceIdAndFolderId: {},
};
for (const navigationMenuItemEntity of navigationMenuItems) {
const flatNavigationMenuItem =
fromNavigationMenuItemEntityToFlatNavigationMenuItem(
navigationMenuItemEntity,
);
addFlatNavigationMenuItemToMapsAndUpdateIndex({
flatNavigationMenuItem,
flatNavigationMenuItemMaps,
});
}
return flatNavigationMenuItemMaps;
}
}
@@ -0,0 +1,9 @@
import { type FlatEntityMaps } from 'src/engine/metadata-modules/flat-entity/types/flat-entity-maps.type';
import { type FlatNavigationMenuItem } from 'src/engine/metadata-modules/flat-navigation-menu-item/types/flat-navigation-menu-item.type';
export type FlatNavigationMenuItemMaps =
FlatEntityMaps<FlatNavigationMenuItem> & {
byUserWorkspaceIdAndFolderId: Partial<
Record<string, Partial<Record<string, FlatNavigationMenuItem[]>>>
>;
};
@@ -0,0 +1,4 @@
import { type FlatEntityFrom } from 'src/engine/metadata-modules/flat-entity/types/flat-entity.type';
import { type NavigationMenuItemEntity } from 'src/engine/metadata-modules/navigation-menu-item/entities/navigation-menu-item.entity';
export type FlatNavigationMenuItem = FlatEntityFrom<NavigationMenuItemEntity>;
@@ -0,0 +1,41 @@
import { type FlatNavigationMenuItemMaps } from 'src/engine/metadata-modules/flat-navigation-menu-item/types/flat-navigation-menu-item-maps.type';
import { type FlatNavigationMenuItem } from 'src/engine/metadata-modules/flat-navigation-menu-item/types/flat-navigation-menu-item.type';
import { addFlatEntityToFlatEntityMapsThroughMutationOrThrow } from 'src/engine/workspace-manager/workspace-migration/utils/add-flat-entity-to-flat-entity-maps-through-mutation-or-throw.util';
export const addFlatNavigationMenuItemToMapsAndUpdateIndex = ({
flatNavigationMenuItem,
flatNavigationMenuItemMaps,
}: {
flatNavigationMenuItem: FlatNavigationMenuItem;
flatNavigationMenuItemMaps: FlatNavigationMenuItemMaps;
}): void => {
addFlatEntityToFlatEntityMapsThroughMutationOrThrow({
flatEntity: flatNavigationMenuItem,
flatEntityMapsToMutate: flatNavigationMenuItemMaps,
});
const userWorkspaceIdKey = flatNavigationMenuItem.userWorkspaceId ?? 'null';
const folderIdKey = flatNavigationMenuItem.folderId ?? 'null';
if (
!flatNavigationMenuItemMaps.byUserWorkspaceIdAndFolderId[userWorkspaceIdKey]
) {
flatNavigationMenuItemMaps.byUserWorkspaceIdAndFolderId[
userWorkspaceIdKey
] = {};
}
if (
!flatNavigationMenuItemMaps.byUserWorkspaceIdAndFolderId[
userWorkspaceIdKey
][folderIdKey]
) {
flatNavigationMenuItemMaps.byUserWorkspaceIdAndFolderId[userWorkspaceIdKey][
folderIdKey
] = [];
}
flatNavigationMenuItemMaps.byUserWorkspaceIdAndFolderId[userWorkspaceIdKey][
folderIdKey
].push(flatNavigationMenuItem);
};
@@ -0,0 +1,22 @@
import { type FlatNavigationMenuItemMaps } from 'src/engine/metadata-modules/flat-navigation-menu-item/types/flat-navigation-menu-item-maps.type';
import { type FlatNavigationMenuItem } from 'src/engine/metadata-modules/flat-navigation-menu-item/types/flat-navigation-menu-item.type';
import { removeFlatNavigationMenuItemFromIndex } from 'src/engine/metadata-modules/flat-navigation-menu-item/utils/remove-flat-navigation-menu-item-from-index.util';
import { deleteFlatEntityFromFlatEntityMapsThroughMutationOrThrow } from 'src/engine/workspace-manager/workspace-migration/utils/delete-flat-entity-from-flat-entity-maps-through-mutation-or-throw.util';
export const deleteFlatNavigationMenuItemFromMapsAndIndex = ({
flatNavigationMenuItem,
flatNavigationMenuItemMaps,
}: {
flatNavigationMenuItem: FlatNavigationMenuItem;
flatNavigationMenuItemMaps: FlatNavigationMenuItemMaps;
}): void => {
removeFlatNavigationMenuItemFromIndex({
flatNavigationMenuItem,
flatNavigationMenuItemMaps,
});
deleteFlatEntityFromFlatEntityMapsThroughMutationOrThrow({
entityToDeleteId: flatNavigationMenuItem.id,
flatEntityMapsToMutate: flatNavigationMenuItemMaps,
});
};
@@ -0,0 +1,59 @@
import { isDefined } from 'twenty-shared/utils';
import { v4 as uuidv4 } from 'uuid';
import { type FlatNavigationMenuItemMaps } from 'src/engine/metadata-modules/flat-navigation-menu-item/types/flat-navigation-menu-item-maps.type';
import { type FlatNavigationMenuItem } from 'src/engine/metadata-modules/flat-navigation-menu-item/types/flat-navigation-menu-item.type';
import { type CreateNavigationMenuItemInput } from 'src/engine/metadata-modules/navigation-menu-item/dtos/create-navigation-menu-item.input';
export const fromCreateNavigationMenuItemInputToFlatNavigationMenuItemToCreate =
({
createNavigationMenuItemInput,
workspaceId,
applicationId,
flatNavigationMenuItemMaps,
}: {
createNavigationMenuItemInput: CreateNavigationMenuItemInput;
workspaceId: string;
applicationId: string;
flatNavigationMenuItemMaps: FlatNavigationMenuItemMaps;
}): FlatNavigationMenuItem => {
const id = uuidv4();
const now = new Date().toISOString();
let position = createNavigationMenuItemInput.position;
if (!isDefined(position)) {
const userWorkspaceIdKey =
createNavigationMenuItemInput.userWorkspaceId ?? 'null';
const folderIdKey = createNavigationMenuItemInput.folderId ?? 'null';
const existingItems =
flatNavigationMenuItemMaps.byUserWorkspaceIdAndFolderId[
userWorkspaceIdKey
]?.[folderIdKey] ?? [];
const maxPosition = existingItems.reduce(
(max, item) => Math.max(max, item.position),
0,
);
position = maxPosition + 1;
}
return {
id,
universalIdentifier: id,
userWorkspaceId: createNavigationMenuItemInput.userWorkspaceId ?? null,
targetRecordId: createNavigationMenuItemInput.targetRecordId ?? null,
targetObjectMetadataId:
createNavigationMenuItemInput.targetObjectMetadataId ?? null,
viewId: createNavigationMenuItemInput.viewId ?? null,
folderId: createNavigationMenuItemInput.folderId ?? null,
name: createNavigationMenuItemInput.name ?? null,
position,
workspaceId,
applicationId,
createdAt: now,
updatedAt: now,
};
};
@@ -0,0 +1,32 @@
import { isDefined } from 'twenty-shared/utils';
import { findFlatEntityByIdInFlatEntityMaps } from 'src/engine/metadata-modules/flat-entity/utils/find-flat-entity-by-id-in-flat-entity-maps.util';
import { type FlatNavigationMenuItemMaps } from 'src/engine/metadata-modules/flat-navigation-menu-item/types/flat-navigation-menu-item-maps.type';
import { type FlatNavigationMenuItem } from 'src/engine/metadata-modules/flat-navigation-menu-item/types/flat-navigation-menu-item.type';
import {
NavigationMenuItemException,
NavigationMenuItemExceptionCode,
} from 'src/engine/metadata-modules/navigation-menu-item/navigation-menu-item.exception';
export const fromDeleteNavigationMenuItemInputToFlatNavigationMenuItemOrThrow =
({
flatNavigationMenuItemMaps,
navigationMenuItemId,
}: {
flatNavigationMenuItemMaps: FlatNavigationMenuItemMaps;
navigationMenuItemId: string;
}): FlatNavigationMenuItem => {
const existingFlatNavigationMenuItem = findFlatEntityByIdInFlatEntityMaps({
flatEntityId: navigationMenuItemId,
flatEntityMaps: flatNavigationMenuItemMaps,
});
if (!isDefined(existingFlatNavigationMenuItem)) {
throw new NavigationMenuItemException(
'Navigation menu item not found',
NavigationMenuItemExceptionCode.NAVIGATION_MENU_ITEM_NOT_FOUND,
);
}
return existingFlatNavigationMenuItem;
};
@@ -0,0 +1,20 @@
import { type FlatNavigationMenuItem } from 'src/engine/metadata-modules/flat-navigation-menu-item/types/flat-navigation-menu-item.type';
import { type NavigationMenuItemDTO } from 'src/engine/metadata-modules/navigation-menu-item/dtos/navigation-menu-item.dto';
export const fromFlatNavigationMenuItemToNavigationMenuItemDto = (
flatNavigationMenuItem: FlatNavigationMenuItem,
): NavigationMenuItemDTO => ({
id: flatNavigationMenuItem.id,
userWorkspaceId: flatNavigationMenuItem.userWorkspaceId ?? undefined,
targetRecordId: flatNavigationMenuItem.targetRecordId ?? undefined,
targetObjectMetadataId:
flatNavigationMenuItem.targetObjectMetadataId ?? undefined,
viewId: flatNavigationMenuItem.viewId ?? undefined,
folderId: flatNavigationMenuItem.folderId ?? undefined,
name: flatNavigationMenuItem.name ?? undefined,
position: flatNavigationMenuItem.position,
workspaceId: flatNavigationMenuItem.workspaceId,
applicationId: flatNavigationMenuItem.applicationId ?? undefined,
createdAt: new Date(flatNavigationMenuItem.createdAt),
updatedAt: new Date(flatNavigationMenuItem.updatedAt),
});
@@ -0,0 +1,22 @@
import { type FlatNavigationMenuItem } from 'src/engine/metadata-modules/flat-navigation-menu-item/types/flat-navigation-menu-item.type';
import { type NavigationMenuItemEntity } from 'src/engine/metadata-modules/navigation-menu-item/entities/navigation-menu-item.entity';
export const fromNavigationMenuItemEntityToFlatNavigationMenuItem = (
navigationMenuItemEntity: NavigationMenuItemEntity,
): FlatNavigationMenuItem => {
return {
id: navigationMenuItemEntity.id,
userWorkspaceId: navigationMenuItemEntity.userWorkspaceId,
targetRecordId: navigationMenuItemEntity.targetRecordId,
targetObjectMetadataId: navigationMenuItemEntity.targetObjectMetadataId,
viewId: navigationMenuItemEntity.viewId,
folderId: navigationMenuItemEntity.folderId,
name: navigationMenuItemEntity.name,
position: navigationMenuItemEntity.position,
workspaceId: navigationMenuItemEntity.workspaceId,
universalIdentifier: navigationMenuItemEntity.universalIdentifier,
applicationId: navigationMenuItemEntity.applicationId,
createdAt: navigationMenuItemEntity.createdAt.toISOString(),
updatedAt: navigationMenuItemEntity.updatedAt.toISOString(),
};
};
@@ -0,0 +1,46 @@
import { isDefined } from 'twenty-shared/utils';
import { findFlatEntityByIdInFlatEntityMaps } from 'src/engine/metadata-modules/flat-entity/utils/find-flat-entity-by-id-in-flat-entity-maps.util';
import { FLAT_NAVIGATION_MENU_ITEM_EDITABLE_PROPERTIES } from 'src/engine/metadata-modules/flat-navigation-menu-item/constants/flat-navigation-menu-item-editable-properties.constant';
import { type FlatNavigationMenuItemMaps } from 'src/engine/metadata-modules/flat-navigation-menu-item/types/flat-navigation-menu-item-maps.type';
import { type FlatNavigationMenuItem } from 'src/engine/metadata-modules/flat-navigation-menu-item/types/flat-navigation-menu-item.type';
import { type UpdateNavigationMenuItemInput } from 'src/engine/metadata-modules/navigation-menu-item/dtos/update-navigation-menu-item.input';
import {
NavigationMenuItemException,
NavigationMenuItemExceptionCode,
} from 'src/engine/metadata-modules/navigation-menu-item/navigation-menu-item.exception';
import { mergeUpdateInExistingRecord } from 'src/utils/merge-update-in-existing-record.util';
export const fromUpdateNavigationMenuItemInputToFlatNavigationMenuItemToUpdateOrThrow =
({
flatNavigationMenuItemMaps,
updateNavigationMenuItemInput,
}: {
flatNavigationMenuItemMaps: FlatNavigationMenuItemMaps;
updateNavigationMenuItemInput: UpdateNavigationMenuItemInput & {
id: string;
};
}): FlatNavigationMenuItem => {
const existingFlatNavigationMenuItem = findFlatEntityByIdInFlatEntityMaps({
flatEntityId: updateNavigationMenuItemInput.id,
flatEntityMaps: flatNavigationMenuItemMaps,
});
if (!isDefined(existingFlatNavigationMenuItem)) {
throw new NavigationMenuItemException(
'Navigation menu item not found',
NavigationMenuItemExceptionCode.NAVIGATION_MENU_ITEM_NOT_FOUND,
);
}
const { id: _id, ...updates } = updateNavigationMenuItemInput;
return {
...mergeUpdateInExistingRecord({
existing: existingFlatNavigationMenuItem,
properties: [...FLAT_NAVIGATION_MENU_ITEM_EDITABLE_PROPERTIES],
update: updates,
}),
updatedAt: new Date().toISOString(),
};
};
@@ -0,0 +1,51 @@
import { type FlatNavigationMenuItemMaps } from 'src/engine/metadata-modules/flat-navigation-menu-item/types/flat-navigation-menu-item-maps.type';
import { type FlatNavigationMenuItem } from 'src/engine/metadata-modules/flat-navigation-menu-item/types/flat-navigation-menu-item.type';
export const removeFlatNavigationMenuItemFromIndex = ({
flatNavigationMenuItem,
flatNavigationMenuItemMaps,
}: {
flatNavigationMenuItem: FlatNavigationMenuItem;
flatNavigationMenuItemMaps: FlatNavigationMenuItemMaps;
}): void => {
const userWorkspaceIdKey = flatNavigationMenuItem.userWorkspaceId ?? 'null';
const folderIdKey = flatNavigationMenuItem.folderId ?? 'null';
const itemsArray =
flatNavigationMenuItemMaps.byUserWorkspaceIdAndFolderId[
userWorkspaceIdKey
]?.[folderIdKey];
if (!itemsArray) {
return;
}
const index = itemsArray.findIndex(
(item) => item.id === flatNavigationMenuItem.id,
);
if (index === -1) {
return;
}
itemsArray.splice(index, 1);
if (itemsArray.length > 0) {
return;
}
const userWorkspaceMap =
flatNavigationMenuItemMaps.byUserWorkspaceIdAndFolderId[userWorkspaceIdKey];
if (!userWorkspaceMap) {
return;
}
delete userWorkspaceMap[folderIdKey];
if (Object.keys(userWorkspaceMap).length === 0) {
delete flatNavigationMenuItemMaps.byUserWorkspaceIdAndFolderId[
userWorkspaceIdKey
];
}
};
@@ -0,0 +1,91 @@
import { isDefined } from 'twenty-shared/utils';
import { type FlatNavigationMenuItemMaps } from 'src/engine/metadata-modules/flat-navigation-menu-item/types/flat-navigation-menu-item-maps.type';
import { type FlatNavigationMenuItem } from 'src/engine/metadata-modules/flat-navigation-menu-item/types/flat-navigation-menu-item.type';
import { removeFlatNavigationMenuItemFromIndex } from 'src/engine/metadata-modules/flat-navigation-menu-item/utils/remove-flat-navigation-menu-item-from-index.util';
import { replaceFlatEntityInFlatEntityMapsThroughMutationOrThrow } from 'src/engine/workspace-manager/workspace-migration/utils/replace-flat-entity-in-flat-entity-maps-through-mutation-or-throw.util';
export const replaceFlatNavigationMenuItemInMapsAndUpdateIndex = ({
fromFlatNavigationMenuItem,
toFlatNavigationMenuItem,
flatNavigationMenuItemMaps,
}: {
fromFlatNavigationMenuItem: FlatNavigationMenuItem;
toFlatNavigationMenuItem: FlatNavigationMenuItem;
flatNavigationMenuItemMaps: FlatNavigationMenuItemMaps;
}): void => {
const oldUserWorkspaceIdKey =
fromFlatNavigationMenuItem.userWorkspaceId ?? 'null';
const oldFolderIdKey = fromFlatNavigationMenuItem.folderId ?? 'null';
const newUserWorkspaceIdKey =
toFlatNavigationMenuItem.userWorkspaceId ?? 'null';
const newFolderIdKey = toFlatNavigationMenuItem.folderId ?? 'null';
const groupChanged =
oldUserWorkspaceIdKey !== newUserWorkspaceIdKey ||
oldFolderIdKey !== newFolderIdKey;
if (groupChanged) {
removeFlatNavigationMenuItemFromIndex({
flatNavigationMenuItem: fromFlatNavigationMenuItem,
flatNavigationMenuItemMaps,
});
}
replaceFlatEntityInFlatEntityMapsThroughMutationOrThrow({
flatEntity: toFlatNavigationMenuItem,
flatEntityMapsToMutate: flatNavigationMenuItemMaps,
});
if (groupChanged) {
if (
!flatNavigationMenuItemMaps.byUserWorkspaceIdAndFolderId[
newUserWorkspaceIdKey
]
) {
flatNavigationMenuItemMaps.byUserWorkspaceIdAndFolderId[
newUserWorkspaceIdKey
] = {};
}
if (
!flatNavigationMenuItemMaps.byUserWorkspaceIdAndFolderId[
newUserWorkspaceIdKey
][newFolderIdKey]
) {
flatNavigationMenuItemMaps.byUserWorkspaceIdAndFolderId[
newUserWorkspaceIdKey
][newFolderIdKey] = [];
}
flatNavigationMenuItemMaps.byUserWorkspaceIdAndFolderId[
newUserWorkspaceIdKey
][newFolderIdKey].push(toFlatNavigationMenuItem);
} else {
const itemsArray =
flatNavigationMenuItemMaps.byUserWorkspaceIdAndFolderId[
newUserWorkspaceIdKey
]?.[newFolderIdKey];
if (!itemsArray) {
return;
}
const index = itemsArray.findIndex(
(item) => item.id === toFlatNavigationMenuItem.id,
);
if (index === -1) {
return;
}
const updatedItem =
flatNavigationMenuItemMaps.byId[toFlatNavigationMenuItem.id];
if (!isDefined(updatedItem)) {
return;
}
itemsArray[index] = updatedItem;
}
};