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
@@ -7,6 +7,7 @@ import { FLAT_COMMAND_MENU_ITEM_EDITABLE_PROPERTIES } from 'src/engine/metadata-
import { type MetadataFlatEntity } from 'src/engine/metadata-modules/flat-entity/types/metadata-flat-entity.type';
import { FLAT_FIELD_METADATA_EDITABLE_PROPERTIES } from 'src/engine/metadata-modules/flat-field-metadata/constants/flat-field-metadata-editable-properties.constant';
import { FLAT_FRONT_COMPONENT_EDITABLE_PROPERTIES } from 'src/engine/metadata-modules/flat-front-component/constants/flat-front-component-editable-properties.constant';
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 { FLAT_OBJECT_METADATA_EDITABLE_PROPERTIES } from 'src/engine/metadata-modules/flat-object-metadata/constants/flat-object-metadata-editable-properties.constant';
import { FLAT_PAGE_LAYOUT_TAB_EDITABLE_PROPERTIES } from 'src/engine/metadata-modules/flat-page-layout-tab/constants/flat-page-layout-tab-editable-properties.constant';
import { FLAT_PAGE_LAYOUT_WIDGET_EDITABLE_PROPERTIES } from 'src/engine/metadata-modules/flat-page-layout-widget/constants/flat-page-layout-widget-editable-properties.constant';
@@ -148,6 +149,10 @@ export const ALL_FLAT_ENTITY_PROPERTIES_TO_COMPARE_AND_STRINGIFY = {
propertiesToCompare: [...FLAT_COMMAND_MENU_ITEM_EDITABLE_PROPERTIES],
propertiesToStringify: [],
},
navigationMenuItem: {
propertiesToCompare: [...FLAT_NAVIGATION_MENU_ITEM_EDITABLE_PROPERTIES],
propertiesToStringify: [],
},
rowLevelPermissionPredicate: {
propertiesToCompare: [
...FLAT_ROW_LEVEL_PERMISSION_PREDICATE_EDITABLE_PROPERTIES,
@@ -8,6 +8,7 @@ import { DatabaseEventTriggerEntity } from 'src/engine/metadata-modules/database
import { FieldMetadataEntity } from 'src/engine/metadata-modules/field-metadata/field-metadata.entity';
import { FrontComponentEntity } from 'src/engine/metadata-modules/front-component/entities/front-component.entity';
import { IndexMetadataEntity } from 'src/engine/metadata-modules/index-metadata/index-metadata.entity';
import { NavigationMenuItemEntity } from 'src/engine/metadata-modules/navigation-menu-item/entities/navigation-menu-item.entity';
import { ObjectMetadataEntity } from 'src/engine/metadata-modules/object-metadata/object-metadata.entity';
import { PageLayoutTabEntity } from 'src/engine/metadata-modules/page-layout-tab/entities/page-layout-tab.entity';
import { PageLayoutWidgetEntity } from 'src/engine/metadata-modules/page-layout-widget/entities/page-layout-widget.entity';
@@ -49,4 +50,5 @@ export const ALL_METADATA_ENTITY_BY_METADATA_NAME = {
role: RoleEntity,
agent: AgentEntity,
commandMenuItem: CommandMenuItemEntity,
navigationMenuItem: NavigationMenuItemEntity,
} as const satisfies Record<AllMetadataName, EntityTarget<ObjectLiteral>>;
@@ -91,6 +91,24 @@ export const ALL_METADATA_RELATIONS = {
},
oneToMany: {},
},
navigationMenuItem: {
manyToOne: {
workspace: null,
userWorkspace: null,
application: null,
targetObjectMetadata: {
metadataName: 'objectMetadata',
flatEntityForeignKeyAggregator: null,
foreignKey: 'targetObjectMetadataId',
},
folder: {
metadataName: 'navigationMenuItem',
flatEntityForeignKeyAggregator: null,
foreignKey: 'folderId',
},
},
oneToMany: {},
},
fieldMetadata: {
manyToOne: {
object: {
@@ -65,6 +65,10 @@ export const ALL_METADATA_REQUIRED_METADATA_FOR_VALIDATION = {
commandMenuItem: {
objectMetadata: true,
},
navigationMenuItem: {
objectMetadata: true,
view: true,
},
pageLayout: {
objectMetadata: true,
},
@@ -2,9 +2,12 @@ import { type AllFlatEntityTypesByMetadataName } from 'src/engine/metadata-modul
import { type FlatEntityMaps } from 'src/engine/metadata-modules/flat-entity/types/flat-entity-maps.type';
import { type MetadataFlatEntity } from 'src/engine/metadata-modules/flat-entity/types/metadata-flat-entity.type';
import { type MetadataToFlatEntityMapsKey } from 'src/engine/metadata-modules/flat-entity/types/metadata-to-flat-entity-maps-key';
import { type FlatNavigationMenuItemMaps } from 'src/engine/metadata-modules/flat-navigation-menu-item/types/flat-navigation-menu-item-maps.type';
export type AllFlatEntityMaps = {
[P in keyof AllFlatEntityTypesByMetadataName as MetadataToFlatEntityMapsKey<P>]: FlatEntityMaps<
MetadataFlatEntity<P>
>;
} & {
flatNavigationMenuItemMaps: FlatNavigationMenuItemMaps;
};
@@ -6,6 +6,7 @@ import { type MetadataEntity } from 'src/engine/metadata-modules/flat-entity/typ
import { type FlatFieldMetadata } from 'src/engine/metadata-modules/flat-field-metadata/types/flat-field-metadata.type';
import { type FlatFrontComponent } from 'src/engine/metadata-modules/flat-front-component/types/flat-front-component.type';
import { type FlatIndexMetadata } from 'src/engine/metadata-modules/flat-index-metadata/types/flat-index-metadata.type';
import { type FlatNavigationMenuItem } from 'src/engine/metadata-modules/flat-navigation-menu-item/types/flat-navigation-menu-item.type';
import { type FlatObjectMetadata } from 'src/engine/metadata-modules/flat-object-metadata/types/flat-object-metadata.type';
import { type FlatPageLayoutTab } from 'src/engine/metadata-modules/flat-page-layout-tab/types/flat-page-layout-tab.type';
import { type FlatPageLayoutWidget } from 'src/engine/metadata-modules/flat-page-layout-widget/types/flat-page-layout-widget.type';
@@ -18,6 +19,7 @@ import { type FlatViewFilterGroup } from 'src/engine/metadata-modules/flat-view-
import { type FlatViewFilter } from 'src/engine/metadata-modules/flat-view-filter/types/flat-view-filter.type';
import { type FlatViewGroup } from 'src/engine/metadata-modules/flat-view-group/types/flat-view-group.type';
import { type FlatView } from 'src/engine/metadata-modules/flat-view/types/flat-view.type';
import { type NavigationMenuItemEntity } from 'src/engine/metadata-modules/navigation-menu-item/entities/navigation-menu-item.entity';
import { type FlatRouteTrigger } from 'src/engine/metadata-modules/route-trigger/types/flat-route-trigger.type';
import { type FlatRowLevelPermissionPredicateGroup } from 'src/engine/metadata-modules/row-level-permission-predicate/types/flat-row-level-permission-predicate-group.type';
import { type FlatRowLevelPermissionPredicate } from 'src/engine/metadata-modules/row-level-permission-predicate/types/flat-row-level-permission-predicate.type';
@@ -57,6 +59,11 @@ import {
type DeleteIndexAction,
type UpdateIndexAction,
} from 'src/engine/workspace-manager/workspace-migration/workspace-migration-builder/builders/index/types/workspace-migration-index-action';
import {
type CreateNavigationMenuItemAction,
type DeleteNavigationMenuItemAction,
type UpdateNavigationMenuItemAction,
} from 'src/engine/workspace-manager/workspace-migration/workspace-migration-builder/builders/navigation-menu-item/types/workspace-migration-navigation-menu-item-action.type';
import {
type CreateObjectAction,
type DeleteObjectAction,
@@ -310,6 +317,15 @@ export type AllFlatEntityTypesByMetadataName = {
flatEntity: FlatCommandMenuItem;
entity: MetadataEntity<'commandMenuItem'>;
};
navigationMenuItem: {
actions: {
create: CreateNavigationMenuItemAction;
update: UpdateNavigationMenuItemAction;
delete: DeleteNavigationMenuItemAction;
};
flatEntity: FlatNavigationMenuItem;
entity: NavigationMenuItemEntity;
};
pageLayout: {
actions: {
create: CreatePageLayoutAction;
@@ -38,6 +38,13 @@ exports[`getMetadataRelatedMetadataNames should return related metadata names fo
]
`;
exports[`getMetadataRelatedMetadataNames should return related metadata names for navigationMenuItem 1`] = `
[
"objectMetadata",
"navigationMenuItem",
]
`;
exports[`getMetadataRelatedMetadataNames should return related metadata names for objectMetadata 1`] = `
[
"fieldMetadata",
@@ -1,9 +1,10 @@
// Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`sortMetadataNamesChildrenFirst should return metadata names sorted with children first (most manyToOne relations first) 1`] = `
[
"rowLevelPermissionPredicate",
"viewFilter",
"navigationMenuItem",
"pageLayoutWidget",
"viewField",
"commandMenuItem",
@@ -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;
}
};
@@ -4,6 +4,7 @@ import { AiAgentMonitorModule } from 'src/engine/metadata-modules/ai/ai-agent-mo
import { AiAgentModule } from 'src/engine/metadata-modules/ai/ai-agent/ai-agent.module';
import { AiChatModule } from 'src/engine/metadata-modules/ai/ai-chat/ai-chat.module';
import { CommandMenuItemModule } from 'src/engine/metadata-modules/command-menu-item/command-menu-item.module';
import { NavigationMenuItemModule } from 'src/engine/metadata-modules/navigation-menu-item/navigation-menu-item.module';
import { CronTriggerModule } from 'src/engine/metadata-modules/cron-trigger/cron-trigger.module';
import { DataSourceModule } from 'src/engine/metadata-modules/data-source/data-source.module';
import { DatabaseEventTriggerModule } from 'src/engine/metadata-modules/database-event-trigger/database-event-trigger.module';
@@ -31,6 +32,7 @@ import { WorkspaceMetadataVersionModule } from 'src/engine/metadata-modules/work
ServerlessFunctionLayerModule,
SkillModule,
CommandMenuItemModule,
NavigationMenuItemModule,
AiAgentModule,
AiAgentMonitorModule,
AiChatModule,
@@ -52,6 +54,7 @@ import { WorkspaceMetadataVersionModule } from 'src/engine/metadata-modules/work
ServerlessFunctionModule,
SkillModule,
CommandMenuItemModule,
NavigationMenuItemModule,
AiAgentModule,
AiChatModule,
ViewModule,
@@ -0,0 +1,44 @@
import { Field, InputType, Int } from '@nestjs/graphql';
import { IsInt, IsOptional, IsString, IsUUID, Min } from 'class-validator';
import { UUIDScalarType } from 'src/engine/api/graphql/workspace-schema-builder/graphql-types/scalars';
@InputType()
export class CreateNavigationMenuItemInput {
@IsUUID()
@IsOptional()
@Field(() => UUIDScalarType, { nullable: true })
userWorkspaceId?: string;
@IsUUID()
@IsOptional()
@Field(() => UUIDScalarType, { nullable: true })
targetRecordId?: string | null;
@IsUUID()
@IsOptional()
@Field(() => UUIDScalarType, { nullable: true })
targetObjectMetadataId?: string | null;
@IsUUID()
@IsOptional()
@Field(() => UUIDScalarType, { nullable: true })
viewId?: string | null;
@IsOptional()
@IsString()
@Field(() => String, { nullable: true })
name?: string | null;
@IsUUID()
@IsOptional()
@Field(() => UUIDScalarType, { nullable: true })
folderId?: string;
@IsInt()
@Min(0)
@IsOptional()
@Field(() => Int, { nullable: true })
position?: number;
}
@@ -0,0 +1,67 @@
import { Field, HideField, ObjectType } from '@nestjs/graphql';
import {
IsDateString,
IsNotEmpty,
IsNumber,
IsOptional,
IsUUID,
} from 'class-validator';
import { UUIDScalarType } from 'src/engine/api/graphql/workspace-schema-builder/graphql-types/scalars';
@ObjectType('NavigationMenuItem')
export class NavigationMenuItemDTO {
@IsUUID()
@IsNotEmpty()
@Field(() => UUIDScalarType)
id: string;
@IsUUID()
@IsOptional()
@Field(() => UUIDScalarType, { nullable: true })
userWorkspaceId?: string;
@IsUUID()
@IsOptional()
@Field(() => UUIDScalarType, { nullable: true })
targetRecordId?: string | null;
@IsUUID()
@IsOptional()
@Field(() => UUIDScalarType, { nullable: true })
targetObjectMetadataId?: string | null;
@IsUUID()
@IsOptional()
@Field(() => UUIDScalarType, { nullable: true })
viewId?: string | null;
@IsOptional()
@Field(() => String, { nullable: true })
name?: string | null;
@IsUUID()
@IsOptional()
@Field(() => UUIDScalarType, { nullable: true })
folderId?: string;
@IsNumber()
@IsNotEmpty()
@Field()
position: number;
@HideField()
workspaceId: string;
@Field(() => UUIDScalarType, { nullable: true })
applicationId?: string;
@IsDateString()
@Field()
createdAt: Date;
@IsDateString()
@Field()
updatedAt: Date;
}
@@ -0,0 +1,50 @@
import { Field, InputType, Int } from '@nestjs/graphql';
import { Type } from 'class-transformer';
import {
IsInt,
IsNotEmpty,
IsOptional,
IsString,
IsUUID,
Min,
ValidateNested,
} from 'class-validator';
import { UUIDScalarType } from 'src/engine/api/graphql/workspace-schema-builder/graphql-types/scalars';
@InputType()
export class UpdateNavigationMenuItemInput {
@IsUUID()
@IsOptional()
@Field(() => UUIDScalarType, { nullable: true })
folderId?: string | null;
@IsInt()
@Min(0)
@IsOptional()
@Field(() => Int, { nullable: true })
position?: number;
@IsOptional()
@IsString()
@Field(() => String, { nullable: true })
name?: string | null;
}
@InputType()
export class UpdateOneNavigationMenuItemInput {
@IsUUID()
@IsNotEmpty()
@Field(() => UUIDScalarType, {
description: 'The id of the record to update',
})
id!: string;
@Type(() => UpdateNavigationMenuItemInput)
@ValidateNested()
@Field(() => UpdateNavigationMenuItemInput, {
description: 'The record to update',
})
update!: UpdateNavigationMenuItemInput;
}
@@ -0,0 +1,94 @@
import {
Check,
Column,
CreateDateColumn,
Entity,
Index,
JoinColumn,
ManyToOne,
PrimaryGeneratedColumn,
type Relation,
UpdateDateColumn,
} from 'typeorm';
import { UserWorkspaceEntity } from 'src/engine/core-modules/user-workspace/user-workspace.entity';
import { ObjectMetadataEntity } from 'src/engine/metadata-modules/object-metadata/object-metadata.entity';
import { SyncableEntity } from 'src/engine/workspace-manager/types/syncable-entity.interface';
@Entity({ name: 'navigationMenuItem', schema: 'core' })
@Index('IDX_NAVIGATION_MENU_ITEM_USER_WORKSPACE_ID_WORKSPACE_ID', [
'userWorkspaceId',
'workspaceId',
])
@Index('IDX_NAVIGATION_MENU_ITEM_TARGET_RECORD_OBJ_METADATA_WS_ID', [
'targetRecordId',
'targetObjectMetadataId',
'workspaceId',
])
@Index('IDX_NAVIGATION_MENU_ITEM_FOLDER_ID_WORKSPACE_ID', [
'folderId',
'workspaceId',
])
@Index('IDX_NAVIGATION_MENU_ITEM_VIEW_ID_WORKSPACE_ID', [
'viewId',
'workspaceId',
])
@Check(
'CHK_navigation_menu_item_target_fields',
'("targetRecordId" IS NULL AND "targetObjectMetadataId" IS NULL) OR ("targetRecordId" IS NOT NULL AND "targetObjectMetadataId" IS NOT NULL)',
)
export class NavigationMenuItemEntity
extends SyncableEntity
implements Required<NavigationMenuItemEntity>
{
@PrimaryGeneratedColumn('uuid')
id: string;
@ManyToOne(() => UserWorkspaceEntity, {
onDelete: 'CASCADE',
nullable: true,
})
@JoinColumn({ name: 'userWorkspaceId' })
userWorkspace: Relation<UserWorkspaceEntity> | null;
@Column({ nullable: true, type: 'uuid' })
userWorkspaceId: string | null;
@Column({ nullable: true, type: 'uuid' })
targetRecordId: string | null;
@Column({ nullable: true, type: 'uuid' })
targetObjectMetadataId: string | null;
@Column({ nullable: true, type: 'uuid' })
viewId: string | null;
@ManyToOne(() => ObjectMetadataEntity, {
onDelete: 'CASCADE',
nullable: true,
})
@JoinColumn({ name: 'targetObjectMetadataId' })
targetObjectMetadata: Relation<ObjectMetadataEntity> | null;
@Column({ nullable: true, type: 'text' })
name: string | null;
@ManyToOne(() => NavigationMenuItemEntity, {
onDelete: 'CASCADE',
nullable: true,
})
@JoinColumn({ name: 'folderId' })
folder: Relation<NavigationMenuItemEntity> | null;
@Column({ nullable: true, type: 'uuid' })
folderId: string | null;
@Column({ nullable: false })
position: number;
@CreateDateColumn({ type: 'timestamptz' })
createdAt: Date;
@UpdateDateColumn({ type: 'timestamptz' })
updatedAt: Date;
}
@@ -0,0 +1,24 @@
import {
type CallHandler,
type ExecutionContext,
Injectable,
type NestInterceptor,
} from '@nestjs/common';
import { type Observable, catchError } from 'rxjs';
import { navigationMenuItemGraphqlApiExceptionHandler } from 'src/engine/metadata-modules/navigation-menu-item/utils/navigation-menu-item-graphql-api-exception-handler.util';
@Injectable()
export class NavigationMenuItemGraphqlApiExceptionInterceptor
implements NestInterceptor
{
intercept(
_context: ExecutionContext,
next: CallHandler,
): Observable<unknown> {
return next
.handle()
.pipe(catchError(navigationMenuItemGraphqlApiExceptionHandler));
}
}
@@ -0,0 +1,43 @@
import { type MessageDescriptor } from '@lingui/core';
import { msg } from '@lingui/core/macro';
import { assertUnreachable } from 'twenty-shared/utils';
import { CustomException } from 'src/utils/custom-exception';
export enum NavigationMenuItemExceptionCode {
NAVIGATION_MENU_ITEM_NOT_FOUND = 'NAVIGATION_MENU_ITEM_NOT_FOUND',
INVALID_NAVIGATION_MENU_ITEM_INPUT = 'INVALID_NAVIGATION_MENU_ITEM_INPUT',
CIRCULAR_DEPENDENCY = 'CIRCULAR_DEPENDENCY',
MAX_DEPTH_EXCEEDED = 'MAX_DEPTH_EXCEEDED',
}
const getNavigationMenuItemExceptionUserFriendlyMessage = (
code: NavigationMenuItemExceptionCode,
) => {
switch (code) {
case NavigationMenuItemExceptionCode.NAVIGATION_MENU_ITEM_NOT_FOUND:
return msg`Navigation menu item not found.`;
case NavigationMenuItemExceptionCode.INVALID_NAVIGATION_MENU_ITEM_INPUT:
return msg`Invalid navigation menu item input.`;
case NavigationMenuItemExceptionCode.CIRCULAR_DEPENDENCY:
return msg`Circular dependency detected in navigation menu item hierarchy.`;
case NavigationMenuItemExceptionCode.MAX_DEPTH_EXCEEDED:
return msg`Navigation menu item hierarchy exceeds maximum depth.`;
default:
assertUnreachable(code);
}
};
export class NavigationMenuItemException extends CustomException<NavigationMenuItemExceptionCode> {
constructor(
message: string,
code: NavigationMenuItemExceptionCode,
{ userFriendlyMessage }: { userFriendlyMessage?: MessageDescriptor } = {},
) {
super(message, code, {
userFriendlyMessage:
userFriendlyMessage ??
getNavigationMenuItemExceptionUserFriendlyMessage(code),
});
}
}
@@ -0,0 +1,31 @@
import { Module } from '@nestjs/common';
import { ApplicationModule } from 'src/engine/core-modules/application/application.module';
import { WorkspaceManyOrAllFlatEntityMapsCacheModule } from 'src/engine/metadata-modules/flat-entity/services/workspace-many-or-all-flat-entity-maps-cache.module';
import { FlatNavigationMenuItemModule } from 'src/engine/metadata-modules/flat-navigation-menu-item/flat-navigation-menu-item.module';
import { NavigationMenuItemGraphqlApiExceptionInterceptor } from 'src/engine/metadata-modules/navigation-menu-item/interceptors/navigation-menu-item-graphql-api-exception.interceptor';
import { NavigationMenuItemResolver } from 'src/engine/metadata-modules/navigation-menu-item/navigation-menu-item.resolver';
import { NavigationMenuItemService } from 'src/engine/metadata-modules/navigation-menu-item/navigation-menu-item.service';
import { NavigationMenuItemAccessService } from 'src/engine/metadata-modules/navigation-menu-item/services/navigation-menu-item-access.service';
import { PermissionsModule } from 'src/engine/metadata-modules/permissions/permissions.module';
import { WorkspaceMigrationBuilderGraphqlApiExceptionInterceptor } from 'src/engine/workspace-manager/workspace-migration/interceptors/workspace-migration-builder-graphql-api-exception.interceptor';
import { WorkspaceMigrationModule } from 'src/engine/workspace-manager/workspace-migration/workspace-migration.module';
@Module({
imports: [
WorkspaceManyOrAllFlatEntityMapsCacheModule,
WorkspaceMigrationModule,
ApplicationModule,
FlatNavigationMenuItemModule,
PermissionsModule,
],
providers: [
NavigationMenuItemService,
NavigationMenuItemAccessService,
NavigationMenuItemResolver,
NavigationMenuItemGraphqlApiExceptionInterceptor,
WorkspaceMigrationBuilderGraphqlApiExceptionInterceptor,
],
exports: [NavigationMenuItemService],
})
export class NavigationMenuItemModule {}
@@ -0,0 +1,108 @@
import { UseGuards, UseInterceptors } from '@nestjs/common';
import { Args, Context, Mutation, Query, Resolver } from '@nestjs/graphql';
import { UUIDScalarType } from 'src/engine/api/graphql/workspace-schema-builder/graphql-types/scalars';
import { ApiKeyEntity } from 'src/engine/core-modules/api-key/api-key.entity';
import { ApplicationEntity } from 'src/engine/core-modules/application/application.entity';
import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity';
import { AuthApiKey } from 'src/engine/decorators/auth/auth-api-key.decorator';
import { AuthUserWorkspaceId } from 'src/engine/decorators/auth/auth-user-workspace-id.decorator';
import { AuthWorkspace } from 'src/engine/decorators/auth/auth-workspace.decorator';
import { NoPermissionGuard } from 'src/engine/guards/no-permission.guard';
import { WorkspaceAuthGuard } from 'src/engine/guards/workspace-auth.guard';
import { CreateNavigationMenuItemInput } from 'src/engine/metadata-modules/navigation-menu-item/dtos/create-navigation-menu-item.input';
import { NavigationMenuItemDTO } from 'src/engine/metadata-modules/navigation-menu-item/dtos/navigation-menu-item.dto';
import { UpdateOneNavigationMenuItemInput } from 'src/engine/metadata-modules/navigation-menu-item/dtos/update-navigation-menu-item.input';
import { NavigationMenuItemGraphqlApiExceptionInterceptor } from 'src/engine/metadata-modules/navigation-menu-item/interceptors/navigation-menu-item-graphql-api-exception.interceptor';
import { NavigationMenuItemService } from 'src/engine/metadata-modules/navigation-menu-item/navigation-menu-item.service';
import { WorkspaceMigrationBuilderGraphqlApiExceptionInterceptor } from 'src/engine/workspace-manager/workspace-migration/interceptors/workspace-migration-builder-graphql-api-exception.interceptor';
@UseGuards(WorkspaceAuthGuard)
@UseInterceptors(
WorkspaceMigrationBuilderGraphqlApiExceptionInterceptor,
NavigationMenuItemGraphqlApiExceptionInterceptor,
)
@Resolver(() => NavigationMenuItemDTO)
export class NavigationMenuItemResolver {
constructor(
private readonly navigationMenuItemService: NavigationMenuItemService,
) {}
@Query(() => [NavigationMenuItemDTO])
@UseGuards(NoPermissionGuard)
async navigationMenuItems(
@AuthWorkspace() workspace: WorkspaceEntity,
@AuthUserWorkspaceId() userWorkspaceId: string | undefined,
): Promise<NavigationMenuItemDTO[]> {
return await this.navigationMenuItemService.findAll({
workspaceId: workspace.id,
userWorkspaceId,
});
}
@Query(() => NavigationMenuItemDTO, { nullable: true })
@UseGuards(NoPermissionGuard)
async navigationMenuItem(
@Args('id', { type: () => UUIDScalarType }) id: string,
@AuthWorkspace() workspace: WorkspaceEntity,
): Promise<NavigationMenuItemDTO | null> {
return await this.navigationMenuItemService.findById({
id,
workspaceId: workspace.id,
});
}
@Mutation(() => NavigationMenuItemDTO)
@UseGuards(NoPermissionGuard)
async createNavigationMenuItem(
@Args('input') input: CreateNavigationMenuItemInput,
@AuthWorkspace() workspace: WorkspaceEntity,
@AuthUserWorkspaceId() userWorkspaceId: string | undefined,
@AuthApiKey() apiKey: ApiKeyEntity | undefined,
@Context() context: { req: { application?: ApplicationEntity } },
): Promise<NavigationMenuItemDTO> {
return await this.navigationMenuItemService.create({
input,
workspaceId: workspace.id,
authUserWorkspaceId: userWorkspaceId,
authApiKeyId: apiKey?.id,
authApplicationId: context.req.application?.id,
});
}
@Mutation(() => NavigationMenuItemDTO)
@UseGuards(NoPermissionGuard)
async updateNavigationMenuItem(
@Args('input') input: UpdateOneNavigationMenuItemInput,
@AuthWorkspace() workspace: WorkspaceEntity,
@AuthUserWorkspaceId() userWorkspaceId: string | undefined,
@AuthApiKey() apiKey: ApiKeyEntity | undefined,
@Context() context: { req: { application?: ApplicationEntity } },
): Promise<NavigationMenuItemDTO> {
return await this.navigationMenuItemService.update({
input: { ...input.update, id: input.id },
workspaceId: workspace.id,
authUserWorkspaceId: userWorkspaceId,
authApiKeyId: apiKey?.id,
authApplicationId: context.req.application?.id,
});
}
@Mutation(() => NavigationMenuItemDTO)
@UseGuards(NoPermissionGuard)
async deleteNavigationMenuItem(
@Args('id', { type: () => UUIDScalarType }) id: string,
@AuthWorkspace() workspace: WorkspaceEntity,
@AuthUserWorkspaceId() userWorkspaceId: string | undefined,
@AuthApiKey() apiKey: ApiKeyEntity | undefined,
@Context() context: { req: { application?: ApplicationEntity } },
): Promise<NavigationMenuItemDTO> {
return await this.navigationMenuItemService.delete({
id,
workspaceId: workspace.id,
authUserWorkspaceId: userWorkspaceId,
authApiKeyId: apiKey?.id,
authApplicationId: context.req.application?.id,
});
}
}
@@ -0,0 +1,337 @@
import { Injectable } from '@nestjs/common';
import { isDefined } from 'twenty-shared/utils';
import { ApplicationService } from 'src/engine/core-modules/application/application.service';
import { WorkspaceManyOrAllFlatEntityMapsCacheService } from 'src/engine/metadata-modules/flat-entity/services/workspace-many-or-all-flat-entity-maps-cache.service';
import { findFlatEntityByIdInFlatEntityMapsOrThrow } from 'src/engine/metadata-modules/flat-entity/utils/find-flat-entity-by-id-in-flat-entity-maps-or-throw.util';
import { findFlatEntityByIdInFlatEntityMaps } from 'src/engine/metadata-modules/flat-entity/utils/find-flat-entity-by-id-in-flat-entity-maps.util';
import { 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 { fromDeleteNavigationMenuItemInputToFlatNavigationMenuItemOrThrow } from 'src/engine/metadata-modules/flat-navigation-menu-item/utils/from-delete-navigation-menu-item-input-to-flat-navigation-menu-item-or-throw.util';
import { fromFlatNavigationMenuItemToNavigationMenuItemDto } from 'src/engine/metadata-modules/flat-navigation-menu-item/utils/from-flat-navigation-menu-item-to-navigation-menu-item-dto.util';
import { fromUpdateNavigationMenuItemInputToFlatNavigationMenuItemToUpdateOrThrow } from 'src/engine/metadata-modules/flat-navigation-menu-item/utils/from-update-navigation-menu-item-input-to-flat-navigation-menu-item-to-update-or-throw.util';
import { type CreateNavigationMenuItemInput } from 'src/engine/metadata-modules/navigation-menu-item/dtos/create-navigation-menu-item.input';
import { type NavigationMenuItemDTO } from 'src/engine/metadata-modules/navigation-menu-item/dtos/navigation-menu-item.dto';
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 { NavigationMenuItemAccessService } from 'src/engine/metadata-modules/navigation-menu-item/services/navigation-menu-item-access.service';
import { WorkspaceMigrationBuilderException } from 'src/engine/workspace-manager/workspace-migration/exceptions/workspace-migration-builder-exception';
import { WorkspaceMigrationValidateBuildAndRunService } from 'src/engine/workspace-manager/workspace-migration/services/workspace-migration-validate-build-and-run-service';
@Injectable()
export class NavigationMenuItemService {
constructor(
private readonly workspaceMigrationValidateBuildAndRunService: WorkspaceMigrationValidateBuildAndRunService,
private readonly workspaceManyOrAllFlatEntityMapsCacheService: WorkspaceManyOrAllFlatEntityMapsCacheService,
private readonly applicationService: ApplicationService,
private readonly navigationMenuItemAccessService: NavigationMenuItemAccessService,
) {}
async findAll({
workspaceId,
userWorkspaceId,
}: {
workspaceId: string;
userWorkspaceId?: string;
}): Promise<NavigationMenuItemDTO[]> {
const { flatNavigationMenuItemMaps } =
await this.workspaceManyOrAllFlatEntityMapsCacheService.getOrRecomputeManyOrAllFlatEntityMaps(
{
workspaceId,
flatMapsKeys: ['flatNavigationMenuItemMaps'],
},
);
return Object.values(flatNavigationMenuItemMaps.byId)
.filter(
(item): item is NonNullable<typeof item> =>
isDefined(item) &&
(!isDefined(item.userWorkspaceId) ||
item.userWorkspaceId === userWorkspaceId),
)
.sort((a, b) => a.position - b.position)
.map(fromFlatNavigationMenuItemToNavigationMenuItemDto);
}
async findById({
id,
workspaceId,
}: {
id: string;
workspaceId: string;
}): Promise<NavigationMenuItemDTO | null> {
const { flatNavigationMenuItemMaps } =
await this.workspaceManyOrAllFlatEntityMapsCacheService.getOrRecomputeManyOrAllFlatEntityMaps(
{
workspaceId,
flatMapsKeys: ['flatNavigationMenuItemMaps'],
},
);
const flatNavigationMenuItem = findFlatEntityByIdInFlatEntityMaps({
flatEntityId: id,
flatEntityMaps: flatNavigationMenuItemMaps,
});
if (!isDefined(flatNavigationMenuItem)) {
return null;
}
return fromFlatNavigationMenuItemToNavigationMenuItemDto(
flatNavigationMenuItem,
);
}
async findByIdOrThrow({
id,
workspaceId,
}: {
id: string;
workspaceId: string;
}): Promise<NavigationMenuItemDTO> {
const navigationMenuItem = await this.findById({ id, workspaceId });
if (!isDefined(navigationMenuItem)) {
throw new NavigationMenuItemException(
'Navigation menu item not found',
NavigationMenuItemExceptionCode.NAVIGATION_MENU_ITEM_NOT_FOUND,
);
}
return navigationMenuItem;
}
async create({
input,
workspaceId,
authUserWorkspaceId,
authApiKeyId,
authApplicationId,
}: {
input: CreateNavigationMenuItemInput;
workspaceId: string;
authUserWorkspaceId?: string;
authApiKeyId?: string;
authApplicationId?: string;
}): Promise<NavigationMenuItemDTO> {
await this.navigationMenuItemAccessService.canUserCreateNavigationMenuItem({
userWorkspaceId: authUserWorkspaceId,
workspaceId,
apiKeyId: authApiKeyId,
applicationId: authApplicationId,
inputUserWorkspaceId: input.userWorkspaceId,
});
const { workspaceCustomFlatApplication } =
await this.applicationService.findWorkspaceTwentyStandardAndCustomApplicationOrThrow(
{ workspaceId },
);
const { flatNavigationMenuItemMaps: existingFlatNavigationMenuItemMaps } =
await this.workspaceManyOrAllFlatEntityMapsCacheService.getOrRecomputeManyOrAllFlatEntityMaps(
{
workspaceId,
flatMapsKeys: ['flatNavigationMenuItemMaps'],
},
);
const normalizedInput: CreateNavigationMenuItemInput = {
...input,
userWorkspaceId:
isDefined(input.userWorkspaceId) && isDefined(authUserWorkspaceId)
? authUserWorkspaceId
: input.userWorkspaceId,
};
const flatNavigationMenuItemToCreate =
fromCreateNavigationMenuItemInputToFlatNavigationMenuItemToCreate({
createNavigationMenuItemInput: normalizedInput,
workspaceId,
applicationId: workspaceCustomFlatApplication.id,
flatNavigationMenuItemMaps: existingFlatNavigationMenuItemMaps,
});
const validateAndBuildResult =
await this.workspaceMigrationValidateBuildAndRunService.validateBuildAndRunWorkspaceMigration(
{
allFlatEntityOperationByMetadataName: {
navigationMenuItem: {
flatEntityToCreate: [flatNavigationMenuItemToCreate],
flatEntityToDelete: [],
flatEntityToUpdate: [],
},
},
workspaceId,
isSystemBuild: false,
},
);
if (isDefined(validateAndBuildResult)) {
throw new WorkspaceMigrationBuilderException(
validateAndBuildResult,
'Multiple validation errors occurred while creating navigation menu item',
);
}
const { flatNavigationMenuItemMaps: recomputedFlatNavigationMenuItemMaps } =
await this.workspaceManyOrAllFlatEntityMapsCacheService.getOrRecomputeManyOrAllFlatEntityMaps(
{
workspaceId,
flatMapsKeys: ['flatNavigationMenuItemMaps'],
},
);
return fromFlatNavigationMenuItemToNavigationMenuItemDto(
findFlatEntityByIdInFlatEntityMapsOrThrow({
flatEntityId: flatNavigationMenuItemToCreate.id,
flatEntityMaps: recomputedFlatNavigationMenuItemMaps,
}),
);
}
async update({
input,
workspaceId,
authUserWorkspaceId,
authApiKeyId,
authApplicationId,
}: {
input: UpdateNavigationMenuItemInput & { id: string };
workspaceId: string;
authUserWorkspaceId?: string;
authApiKeyId?: string;
authApplicationId?: string;
}): Promise<NavigationMenuItemDTO> {
const { flatNavigationMenuItemMaps: existingFlatNavigationMenuItemMaps } =
await this.workspaceManyOrAllFlatEntityMapsCacheService.getOrRecomputeManyOrAllFlatEntityMaps(
{
workspaceId,
flatMapsKeys: ['flatNavigationMenuItemMaps'],
},
);
const existingNavigationMenuItem = findFlatEntityByIdInFlatEntityMaps({
flatEntityId: input.id,
flatEntityMaps: existingFlatNavigationMenuItemMaps,
});
if (isDefined(existingNavigationMenuItem)) {
await this.navigationMenuItemAccessService.canUserUpdateNavigationMenuItem(
{
userWorkspaceId: authUserWorkspaceId,
workspaceId,
apiKeyId: authApiKeyId,
applicationId: authApplicationId,
existingUserWorkspaceId: existingNavigationMenuItem.userWorkspaceId,
},
);
}
const flatNavigationMenuItemToUpdate =
fromUpdateNavigationMenuItemInputToFlatNavigationMenuItemToUpdateOrThrow({
flatNavigationMenuItemMaps: existingFlatNavigationMenuItemMaps,
updateNavigationMenuItemInput: input,
});
const validateAndBuildResult =
await this.workspaceMigrationValidateBuildAndRunService.validateBuildAndRunWorkspaceMigration(
{
allFlatEntityOperationByMetadataName: {
navigationMenuItem: {
flatEntityToCreate: [],
flatEntityToDelete: [],
flatEntityToUpdate: [flatNavigationMenuItemToUpdate],
},
},
workspaceId,
isSystemBuild: false,
},
);
if (isDefined(validateAndBuildResult)) {
throw new WorkspaceMigrationBuilderException(
validateAndBuildResult,
'Multiple validation errors occurred while updating navigation menu item',
);
}
const { flatNavigationMenuItemMaps: recomputedFlatNavigationMenuItemMaps } =
await this.workspaceManyOrAllFlatEntityMapsCacheService.getOrRecomputeManyOrAllFlatEntityMaps(
{
workspaceId,
flatMapsKeys: ['flatNavigationMenuItemMaps'],
},
);
return fromFlatNavigationMenuItemToNavigationMenuItemDto(
findFlatEntityByIdInFlatEntityMapsOrThrow({
flatEntityId: input.id,
flatEntityMaps: recomputedFlatNavigationMenuItemMaps,
}),
);
}
async delete({
id,
workspaceId,
authUserWorkspaceId,
authApiKeyId,
authApplicationId,
}: {
id: string;
workspaceId: string;
authUserWorkspaceId?: string;
authApiKeyId?: string;
authApplicationId?: string;
}): Promise<NavigationMenuItemDTO> {
const { flatNavigationMenuItemMaps: existingFlatNavigationMenuItemMaps } =
await this.workspaceManyOrAllFlatEntityMapsCacheService.getOrRecomputeManyOrAllFlatEntityMaps(
{
workspaceId,
flatMapsKeys: ['flatNavigationMenuItemMaps'],
},
);
const flatNavigationMenuItemToDelete =
fromDeleteNavigationMenuItemInputToFlatNavigationMenuItemOrThrow({
flatNavigationMenuItemMaps: existingFlatNavigationMenuItemMaps,
navigationMenuItemId: id,
});
await this.navigationMenuItemAccessService.canUserDeleteNavigationMenuItem({
userWorkspaceId: authUserWorkspaceId,
workspaceId,
apiKeyId: authApiKeyId,
applicationId: authApplicationId,
existingUserWorkspaceId: flatNavigationMenuItemToDelete.userWorkspaceId,
});
const validateAndBuildResult =
await this.workspaceMigrationValidateBuildAndRunService.validateBuildAndRunWorkspaceMigration(
{
allFlatEntityOperationByMetadataName: {
navigationMenuItem: {
flatEntityToCreate: [],
flatEntityToDelete: [flatNavigationMenuItemToDelete],
flatEntityToUpdate: [],
},
},
workspaceId,
isSystemBuild: false,
},
);
if (isDefined(validateAndBuildResult)) {
throw new WorkspaceMigrationBuilderException(
validateAndBuildResult,
'Multiple validation errors occurred while deleting navigation menu item',
);
}
return fromFlatNavigationMenuItemToNavigationMenuItemDto(
flatNavigationMenuItemToDelete,
);
}
}
@@ -0,0 +1,173 @@
import { Injectable } from '@nestjs/common';
import { msg } from '@lingui/core/macro';
import { PermissionFlagType } from 'twenty-shared/constants';
import { isDefined } from 'twenty-shared/utils';
import {
NavigationMenuItemException,
NavigationMenuItemExceptionCode,
} from 'src/engine/metadata-modules/navigation-menu-item/navigation-menu-item.exception';
import {
PermissionsException,
PermissionsExceptionCode,
PermissionsExceptionMessage,
} from 'src/engine/metadata-modules/permissions/permissions.exception';
import { PermissionsService } from 'src/engine/metadata-modules/permissions/permissions.service';
@Injectable()
export class NavigationMenuItemAccessService {
constructor(private readonly permissionsService: PermissionsService) {}
async canUserCreateNavigationMenuItem({
userWorkspaceId,
workspaceId,
apiKeyId,
applicationId,
inputUserWorkspaceId,
}: {
userWorkspaceId: string | undefined;
workspaceId: string;
apiKeyId: string | undefined;
applicationId: string | undefined;
inputUserWorkspaceId: string | undefined;
}): Promise<boolean> {
if (!isDefined(inputUserWorkspaceId)) {
const hasPermission =
await this.permissionsService.userHasWorkspaceSettingPermission({
userWorkspaceId,
workspaceId,
setting: PermissionFlagType.LAYOUTS,
apiKeyId,
applicationId,
});
if (!hasPermission) {
throw new PermissionsException(
PermissionsExceptionMessage.PERMISSION_DENIED,
PermissionsExceptionCode.PERMISSION_DENIED,
{
userFriendlyMessage: msg`You do not have permission to create workspace-level navigation menu items. Please contact your workspace administrator for access.`,
},
);
}
return true;
}
if (!isDefined(userWorkspaceId)) {
throw new NavigationMenuItemException(
'User-level navigation menu items can only be created by authenticated users',
NavigationMenuItemExceptionCode.INVALID_NAVIGATION_MENU_ITEM_INPUT,
);
}
return true;
}
async canUserUpdateNavigationMenuItem({
userWorkspaceId,
workspaceId,
apiKeyId,
applicationId,
existingUserWorkspaceId,
}: {
userWorkspaceId: string | undefined;
workspaceId: string;
apiKeyId: string | undefined;
applicationId: string | undefined;
existingUserWorkspaceId: string | null | undefined;
}): Promise<boolean> {
if (!isDefined(existingUserWorkspaceId)) {
const hasPermission =
await this.permissionsService.userHasWorkspaceSettingPermission({
userWorkspaceId,
workspaceId,
setting: PermissionFlagType.LAYOUTS,
apiKeyId,
applicationId,
});
if (!hasPermission) {
throw new PermissionsException(
PermissionsExceptionMessage.PERMISSION_DENIED,
PermissionsExceptionCode.PERMISSION_DENIED,
{
userFriendlyMessage: msg`You do not have permission to update workspace-level navigation menu items. Please contact your workspace administrator for access.`,
},
);
}
return true;
}
if (!isDefined(userWorkspaceId)) {
throw new NavigationMenuItemException(
'User-level navigation menu items can only be updated by authenticated users',
NavigationMenuItemExceptionCode.INVALID_NAVIGATION_MENU_ITEM_INPUT,
);
}
if (existingUserWorkspaceId !== userWorkspaceId) {
throw new NavigationMenuItemException(
'You can only update your own navigation menu items',
NavigationMenuItemExceptionCode.INVALID_NAVIGATION_MENU_ITEM_INPUT,
);
}
return true;
}
async canUserDeleteNavigationMenuItem({
userWorkspaceId,
workspaceId,
apiKeyId,
applicationId,
existingUserWorkspaceId,
}: {
userWorkspaceId: string | undefined;
workspaceId: string;
apiKeyId: string | undefined;
applicationId: string | undefined;
existingUserWorkspaceId: string | null | undefined;
}): Promise<boolean> {
if (!isDefined(existingUserWorkspaceId)) {
const hasPermission =
await this.permissionsService.userHasWorkspaceSettingPermission({
userWorkspaceId,
workspaceId,
setting: PermissionFlagType.LAYOUTS,
apiKeyId,
applicationId,
});
if (!hasPermission) {
throw new PermissionsException(
PermissionsExceptionMessage.PERMISSION_DENIED,
PermissionsExceptionCode.PERMISSION_DENIED,
{
userFriendlyMessage: msg`You do not have permission to delete workspace-level navigation menu items. Please contact your workspace administrator for access.`,
},
);
}
return true;
}
if (!isDefined(userWorkspaceId)) {
throw new NavigationMenuItemException(
'User-level navigation menu items can only be deleted by authenticated users',
NavigationMenuItemExceptionCode.INVALID_NAVIGATION_MENU_ITEM_INPUT,
);
}
if (existingUserWorkspaceId !== userWorkspaceId) {
throw new NavigationMenuItemException(
'You can only delete your own navigation menu items',
NavigationMenuItemExceptionCode.INVALID_NAVIGATION_MENU_ITEM_INPUT,
);
}
return true;
}
}
@@ -0,0 +1,28 @@
import { assertUnreachable } from 'twenty-shared/utils';
import {
NotFoundError,
UserInputError,
} from 'src/engine/core-modules/graphql/utils/graphql-errors.util';
import {
NavigationMenuItemException,
NavigationMenuItemExceptionCode,
} from 'src/engine/metadata-modules/navigation-menu-item/navigation-menu-item.exception';
export const navigationMenuItemGraphqlApiExceptionHandler = (error: Error) => {
if (error instanceof NavigationMenuItemException) {
switch (error.code) {
case NavigationMenuItemExceptionCode.NAVIGATION_MENU_ITEM_NOT_FOUND:
throw new NotFoundError(error);
case NavigationMenuItemExceptionCode.INVALID_NAVIGATION_MENU_ITEM_INPUT:
case NavigationMenuItemExceptionCode.CIRCULAR_DEPENDENCY:
case NavigationMenuItemExceptionCode.MAX_DEPTH_EXCEEDED:
throw new UserInputError(error);
default: {
return assertUnreachable(error.code);
}
}
}
throw error;
};