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,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;
};