Add command menu item entity (#17181)

This commit is contained in:
Abdul Rahman
2026-01-17 00:32:07 +05:30
committed by GitHub
parent b7fbe1c49e
commit 5a617e4718
66 changed files with 2960 additions and 15 deletions
@@ -0,0 +1,37 @@
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 CommandMenuItemExceptionCode {
COMMAND_MENU_ITEM_NOT_FOUND = 'COMMAND_MENU_ITEM_NOT_FOUND',
INVALID_COMMAND_MENU_ITEM_INPUT = 'INVALID_COMMAND_MENU_ITEM_INPUT',
}
const getCommandMenuItemExceptionUserFriendlyMessage = (
code: CommandMenuItemExceptionCode,
) => {
switch (code) {
case CommandMenuItemExceptionCode.COMMAND_MENU_ITEM_NOT_FOUND:
return msg`Command menu item not found.`;
case CommandMenuItemExceptionCode.INVALID_COMMAND_MENU_ITEM_INPUT:
return msg`Invalid command menu item input.`;
default:
assertUnreachable(code);
}
};
export class CommandMenuItemException extends CustomException<CommandMenuItemExceptionCode> {
constructor(
message: string,
code: CommandMenuItemExceptionCode,
{ userFriendlyMessage }: { userFriendlyMessage?: MessageDescriptor } = {},
) {
super(message, code, {
userFriendlyMessage:
userFriendlyMessage ??
getCommandMenuItemExceptionUserFriendlyMessage(code),
});
}
}
@@ -0,0 +1,29 @@
import { Module } from '@nestjs/common';
import { ApplicationModule } from 'src/engine/core-modules/application/application.module';
import { FeatureFlagModule } from 'src/engine/core-modules/feature-flag/feature-flag.module';
import { CommandMenuItemResolver } from 'src/engine/metadata-modules/command-menu-item/command-menu-item.resolver';
import { CommandMenuItemService } from 'src/engine/metadata-modules/command-menu-item/command-menu-item.service';
import { CommandMenuItemGraphqlApiExceptionInterceptor } from 'src/engine/metadata-modules/command-menu-item/interceptors/command-menu-item-graphql-api-exception.interceptor';
import { FlatCommandMenuItemModule } from 'src/engine/metadata-modules/flat-command-menu-item/flat-command-menu-item.module';
import { WorkspaceManyOrAllFlatEntityMapsCacheModule } from 'src/engine/metadata-modules/flat-entity/services/workspace-many-or-all-flat-entity-maps-cache.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,
FlatCommandMenuItemModule,
FeatureFlagModule,
],
providers: [
CommandMenuItemService,
CommandMenuItemResolver,
CommandMenuItemGraphqlApiExceptionInterceptor,
WorkspaceMigrationBuilderGraphqlApiExceptionInterceptor,
],
exports: [CommandMenuItemService],
})
export class CommandMenuItemModule {}
@@ -0,0 +1,80 @@
import { UseGuards, UseInterceptors } from '@nestjs/common';
import { Args, Mutation, Query, Resolver } from '@nestjs/graphql';
import { UUIDScalarType } from 'src/engine/api/graphql/workspace-schema-builder/graphql-types/scalars';
import { FeatureFlagKey } from 'src/engine/core-modules/feature-flag/enums/feature-flag-key.enum';
import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity';
import { AuthWorkspace } from 'src/engine/decorators/auth/auth-workspace.decorator';
import {
FeatureFlagGuard,
RequireFeatureFlag,
} from 'src/engine/guards/feature-flag.guard';
import { NoPermissionGuard } from 'src/engine/guards/no-permission.guard';
import { WorkspaceAuthGuard } from 'src/engine/guards/workspace-auth.guard';
import { CommandMenuItemService } from 'src/engine/metadata-modules/command-menu-item/command-menu-item.service';
import { CommandMenuItemDTO } from 'src/engine/metadata-modules/command-menu-item/dtos/command-menu-item.dto';
import { CreateCommandMenuItemInput } from 'src/engine/metadata-modules/command-menu-item/dtos/create-command-menu-item.input';
import { UpdateCommandMenuItemInput } from 'src/engine/metadata-modules/command-menu-item/dtos/update-command-menu-item.input';
import { CommandMenuItemGraphqlApiExceptionInterceptor } from 'src/engine/metadata-modules/command-menu-item/interceptors/command-menu-item-graphql-api-exception.interceptor';
import { WorkspaceMigrationBuilderGraphqlApiExceptionInterceptor } from 'src/engine/workspace-manager/workspace-migration/interceptors/workspace-migration-builder-graphql-api-exception.interceptor';
@UseGuards(WorkspaceAuthGuard, FeatureFlagGuard)
@UseInterceptors(
WorkspaceMigrationBuilderGraphqlApiExceptionInterceptor,
CommandMenuItemGraphqlApiExceptionInterceptor,
)
@Resolver(() => CommandMenuItemDTO)
export class CommandMenuItemResolver {
constructor(
private readonly commandMenuItemService: CommandMenuItemService,
) {}
@Query(() => [CommandMenuItemDTO])
@UseGuards(NoPermissionGuard)
@RequireFeatureFlag(FeatureFlagKey.IS_COMMAND_MENU_ITEM_ENABLED)
async commandMenuItems(
@AuthWorkspace() workspace: WorkspaceEntity,
): Promise<CommandMenuItemDTO[]> {
return await this.commandMenuItemService.findAll(workspace.id);
}
@Query(() => CommandMenuItemDTO, { nullable: true })
@UseGuards(NoPermissionGuard)
@RequireFeatureFlag(FeatureFlagKey.IS_COMMAND_MENU_ITEM_ENABLED)
async commandMenuItem(
@Args('id', { type: () => UUIDScalarType }) id: string,
@AuthWorkspace() workspace: WorkspaceEntity,
): Promise<CommandMenuItemDTO | null> {
return await this.commandMenuItemService.findById(id, workspace.id);
}
@Mutation(() => CommandMenuItemDTO)
@UseGuards(NoPermissionGuard)
@RequireFeatureFlag(FeatureFlagKey.IS_COMMAND_MENU_ITEM_ENABLED)
async createCommandMenuItem(
@Args('input') input: CreateCommandMenuItemInput,
@AuthWorkspace() workspace: WorkspaceEntity,
): Promise<CommandMenuItemDTO> {
return await this.commandMenuItemService.create(input, workspace.id);
}
@Mutation(() => CommandMenuItemDTO)
@UseGuards(NoPermissionGuard)
@RequireFeatureFlag(FeatureFlagKey.IS_COMMAND_MENU_ITEM_ENABLED)
async updateCommandMenuItem(
@Args('input') input: UpdateCommandMenuItemInput,
@AuthWorkspace() workspace: WorkspaceEntity,
): Promise<CommandMenuItemDTO> {
return await this.commandMenuItemService.update(input, workspace.id);
}
@Mutation(() => CommandMenuItemDTO)
@UseGuards(NoPermissionGuard)
@RequireFeatureFlag(FeatureFlagKey.IS_COMMAND_MENU_ITEM_ENABLED)
async deleteCommandMenuItem(
@Args('id', { type: () => UUIDScalarType }) id: string,
@AuthWorkspace() workspace: WorkspaceEntity,
): Promise<CommandMenuItemDTO> {
return await this.commandMenuItemService.delete(id, workspace.id);
}
}
@@ -0,0 +1,279 @@
import { Injectable } from '@nestjs/common';
import { isDefined } from 'twenty-shared/utils';
import { ApplicationService } from 'src/engine/core-modules/application/application.service';
import {
CommandMenuItemException,
CommandMenuItemExceptionCode,
} from 'src/engine/metadata-modules/command-menu-item/command-menu-item.exception';
import { type CommandMenuItemDTO } from 'src/engine/metadata-modules/command-menu-item/dtos/command-menu-item.dto';
import { type CreateCommandMenuItemInput } from 'src/engine/metadata-modules/command-menu-item/dtos/create-command-menu-item.input';
import { type UpdateCommandMenuItemInput } from 'src/engine/metadata-modules/command-menu-item/dtos/update-command-menu-item.input';
import { type FlatCommandMenuItem } from 'src/engine/metadata-modules/flat-command-menu-item/types/flat-command-menu-item.type';
import { fromCreateCommandMenuItemInputToFlatCommandMenuItemToCreate } from 'src/engine/metadata-modules/flat-command-menu-item/utils/from-create-command-menu-item-input-to-flat-command-menu-item-to-create.util';
import { fromDeleteCommandMenuItemInputToFlatCommandMenuItemOrThrow } from 'src/engine/metadata-modules/flat-command-menu-item/utils/from-delete-command-menu-item-input-to-flat-command-menu-item-or-throw.util';
import { fromFlatCommandMenuItemToCommandMenuItemDto } from 'src/engine/metadata-modules/flat-command-menu-item/utils/from-flat-command-menu-item-to-command-menu-item-dto.util';
import { fromUpdateCommandMenuItemInputToFlatCommandMenuItemToUpdateOrThrow } from 'src/engine/metadata-modules/flat-command-menu-item/utils/from-update-command-menu-item-input-to-flat-command-menu-item-to-update-or-throw.util';
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 { 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 CommandMenuItemService {
constructor(
private readonly workspaceMigrationValidateBuildAndRunService: WorkspaceMigrationValidateBuildAndRunService,
private readonly workspaceManyOrAllFlatEntityMapsCacheService: WorkspaceManyOrAllFlatEntityMapsCacheService,
private readonly applicationService: ApplicationService,
) {}
async findAll(workspaceId: string): Promise<CommandMenuItemDTO[]> {
const { flatCommandMenuItemMaps } =
await this.workspaceManyOrAllFlatEntityMapsCacheService.getOrRecomputeManyOrAllFlatEntityMaps(
{
workspaceId,
flatMapsKeys: ['flatCommandMenuItemMaps'],
},
);
return Object.values(flatCommandMenuItemMaps.byId)
.filter(isDefined)
.sort((a, b) => a.label.localeCompare(b.label))
.map(fromFlatCommandMenuItemToCommandMenuItemDto);
}
async findById(
id: string,
workspaceId: string,
): Promise<CommandMenuItemDTO | null> {
const { flatCommandMenuItemMaps } =
await this.workspaceManyOrAllFlatEntityMapsCacheService.getOrRecomputeManyOrAllFlatEntityMaps(
{
workspaceId,
flatMapsKeys: ['flatCommandMenuItemMaps'],
},
);
const flatCommandMenuItem = findFlatEntityByIdInFlatEntityMaps({
flatEntityId: id,
flatEntityMaps: flatCommandMenuItemMaps,
});
if (!isDefined(flatCommandMenuItem)) {
return null;
}
return fromFlatCommandMenuItemToCommandMenuItemDto(flatCommandMenuItem);
}
async findByIdOrThrow(
id: string,
workspaceId: string,
): Promise<CommandMenuItemDTO> {
const commandMenuItem = await this.findById(id, workspaceId);
if (!isDefined(commandMenuItem)) {
throw new CommandMenuItemException(
'Command menu item not found',
CommandMenuItemExceptionCode.COMMAND_MENU_ITEM_NOT_FOUND,
);
}
return commandMenuItem;
}
async create(
input: CreateCommandMenuItemInput,
workspaceId: string,
): Promise<CommandMenuItemDTO> {
const { workspaceCustomFlatApplication } =
await this.applicationService.findWorkspaceTwentyStandardAndCustomApplicationOrThrow(
{ workspaceId },
);
const flatCommandMenuItemToCreate =
fromCreateCommandMenuItemInputToFlatCommandMenuItemToCreate({
createCommandMenuItemInput: input,
workspaceId,
applicationId: workspaceCustomFlatApplication.id,
});
const validateAndBuildResult =
await this.workspaceMigrationValidateBuildAndRunService.validateBuildAndRunWorkspaceMigration(
{
allFlatEntityOperationByMetadataName: {
commandMenuItem: {
flatEntityToCreate: [flatCommandMenuItemToCreate],
flatEntityToDelete: [],
flatEntityToUpdate: [],
},
},
workspaceId,
isSystemBuild: false,
},
);
if (isDefined(validateAndBuildResult)) {
throw new WorkspaceMigrationBuilderException(
validateAndBuildResult,
'Multiple validation errors occurred while creating command menu item',
);
}
const { flatCommandMenuItemMaps: recomputedFlatCommandMenuItemMaps } =
await this.workspaceManyOrAllFlatEntityMapsCacheService.getOrRecomputeManyOrAllFlatEntityMaps(
{
workspaceId,
flatMapsKeys: ['flatCommandMenuItemMaps'],
},
);
return fromFlatCommandMenuItemToCommandMenuItemDto(
findFlatEntityByIdInFlatEntityMapsOrThrow({
flatEntityId: flatCommandMenuItemToCreate.id,
flatEntityMaps: recomputedFlatCommandMenuItemMaps,
}),
);
}
async update(
input: UpdateCommandMenuItemInput,
workspaceId: string,
): Promise<CommandMenuItemDTO> {
const { flatCommandMenuItemMaps: existingFlatCommandMenuItemMaps } =
await this.workspaceManyOrAllFlatEntityMapsCacheService.getOrRecomputeManyOrAllFlatEntityMaps(
{
workspaceId,
flatMapsKeys: ['flatCommandMenuItemMaps'],
},
);
const flatCommandMenuItemToUpdate =
fromUpdateCommandMenuItemInputToFlatCommandMenuItemToUpdateOrThrow({
flatCommandMenuItemMaps: existingFlatCommandMenuItemMaps,
updateCommandMenuItemInput: input,
});
const validateAndBuildResult =
await this.workspaceMigrationValidateBuildAndRunService.validateBuildAndRunWorkspaceMigration(
{
allFlatEntityOperationByMetadataName: {
commandMenuItem: {
flatEntityToCreate: [],
flatEntityToDelete: [],
flatEntityToUpdate: [flatCommandMenuItemToUpdate],
},
},
workspaceId,
isSystemBuild: false,
},
);
if (isDefined(validateAndBuildResult)) {
throw new WorkspaceMigrationBuilderException(
validateAndBuildResult,
'Multiple validation errors occurred while updating command menu item',
);
}
const { flatCommandMenuItemMaps: recomputedFlatCommandMenuItemMaps } =
await this.workspaceManyOrAllFlatEntityMapsCacheService.getOrRecomputeManyOrAllFlatEntityMaps(
{
workspaceId,
flatMapsKeys: ['flatCommandMenuItemMaps'],
},
);
return fromFlatCommandMenuItemToCommandMenuItemDto(
findFlatEntityByIdInFlatEntityMapsOrThrow({
flatEntityId: input.id,
flatEntityMaps: recomputedFlatCommandMenuItemMaps,
}),
);
}
async delete(id: string, workspaceId: string): Promise<CommandMenuItemDTO> {
const { flatCommandMenuItemMaps: existingFlatCommandMenuItemMaps } =
await this.workspaceManyOrAllFlatEntityMapsCacheService.getOrRecomputeManyOrAllFlatEntityMaps(
{
workspaceId,
flatMapsKeys: ['flatCommandMenuItemMaps'],
},
);
const flatCommandMenuItemToDelete =
fromDeleteCommandMenuItemInputToFlatCommandMenuItemOrThrow({
flatCommandMenuItemMaps: existingFlatCommandMenuItemMaps,
commandMenuItemId: id,
});
const validateAndBuildResult =
await this.workspaceMigrationValidateBuildAndRunService.validateBuildAndRunWorkspaceMigration(
{
allFlatEntityOperationByMetadataName: {
commandMenuItem: {
flatEntityToCreate: [],
flatEntityToDelete: [flatCommandMenuItemToDelete],
flatEntityToUpdate: [],
},
},
workspaceId,
isSystemBuild: false,
},
);
if (isDefined(validateAndBuildResult)) {
throw new WorkspaceMigrationBuilderException(
validateAndBuildResult,
'Multiple validation errors occurred while deleting command menu item',
);
}
return fromFlatCommandMenuItemToCommandMenuItemDto(
flatCommandMenuItemToDelete,
);
}
async findAllFlatCommandMenuItems(
workspaceId: string,
): Promise<FlatCommandMenuItem[]> {
const { flatCommandMenuItemMaps } =
await this.workspaceManyOrAllFlatEntityMapsCacheService.getOrRecomputeManyOrAllFlatEntityMaps(
{
workspaceId,
flatMapsKeys: ['flatCommandMenuItemMaps'],
},
);
return Object.values(flatCommandMenuItemMaps.byId)
.filter(isDefined)
.sort((a, b) => a.label.localeCompare(b.label));
}
async findByWorkflowVersionId(
workflowVersionId: string,
workspaceId: string,
): Promise<CommandMenuItemDTO | null> {
const { flatCommandMenuItemMaps } =
await this.workspaceManyOrAllFlatEntityMapsCacheService.getOrRecomputeManyOrAllFlatEntityMaps(
{
workspaceId,
flatMapsKeys: ['flatCommandMenuItemMaps'],
},
);
const flatCommandMenuItem = Object.values(
flatCommandMenuItemMaps.byId,
).find(
(item) => isDefined(item) && item.workflowVersionId === workflowVersionId,
);
if (!isDefined(flatCommandMenuItem)) {
return null;
}
return fromFlatCommandMenuItemToCommandMenuItemDto(flatCommandMenuItem);
}
}
@@ -0,0 +1,73 @@
import {
Field,
HideField,
ObjectType,
registerEnumType,
} from '@nestjs/graphql';
import {
IsBoolean,
IsDateString,
IsEnum,
IsNotEmpty,
IsOptional,
IsString,
IsUUID,
} from 'class-validator';
import { UUIDScalarType } from 'src/engine/api/graphql/workspace-schema-builder/graphql-types/scalars';
import { CommandMenuItemAvailabilityType } from 'src/engine/metadata-modules/command-menu-item/entities/command-menu-item.entity';
registerEnumType(CommandMenuItemAvailabilityType, {
name: 'CommandMenuItemAvailabilityType',
});
@ObjectType('CommandMenuItem')
export class CommandMenuItemDTO {
@IsUUID()
@IsNotEmpty()
@Field(() => UUIDScalarType)
id: string;
@IsUUID()
@IsNotEmpty()
@Field(() => UUIDScalarType)
workflowVersionId: string;
@IsString()
@IsNotEmpty()
@Field()
label: string;
@IsString()
@IsOptional()
@Field({ nullable: true })
icon?: string;
@IsBoolean()
@Field()
isPinned: boolean;
@IsEnum(CommandMenuItemAvailabilityType)
@Field(() => CommandMenuItemAvailabilityType)
availabilityType: CommandMenuItemAvailabilityType;
@IsUUID()
@IsOptional()
@Field(() => UUIDScalarType, { nullable: true })
availabilityObjectMetadataId?: string;
@HideField()
workspaceId: string;
@Field(() => UUIDScalarType, { nullable: true })
applicationId?: string;
@IsDateString()
@Field()
createdAt: Date;
@IsDateString()
@Field()
updatedAt: Date;
}
@@ -0,0 +1,46 @@
import { Field, InputType } from '@nestjs/graphql';
import {
IsBoolean,
IsEnum,
IsNotEmpty,
IsOptional,
IsString,
IsUUID,
} from 'class-validator';
import { UUIDScalarType } from 'src/engine/api/graphql/workspace-schema-builder/graphql-types/scalars';
import { CommandMenuItemAvailabilityType } from 'src/engine/metadata-modules/command-menu-item/entities/command-menu-item.entity';
@InputType()
export class CreateCommandMenuItemInput {
@IsUUID()
@IsNotEmpty()
@Field(() => UUIDScalarType)
workflowVersionId: string;
@IsString()
@IsNotEmpty()
@Field()
label: string;
@IsString()
@IsOptional()
@Field({ nullable: true })
icon?: string;
@IsBoolean()
@IsOptional()
@Field({ nullable: true })
isPinned?: boolean;
@IsEnum(CommandMenuItemAvailabilityType)
@IsOptional()
@Field(() => CommandMenuItemAvailabilityType, { nullable: true })
availabilityType?: CommandMenuItemAvailabilityType;
@IsUUID()
@IsOptional()
@Field(() => UUIDScalarType, { nullable: true })
availabilityObjectMetadataId?: string;
}
@@ -0,0 +1,46 @@
import { Field, InputType } from '@nestjs/graphql';
import {
IsBoolean,
IsEnum,
IsNotEmpty,
IsOptional,
IsString,
IsUUID,
} from 'class-validator';
import { UUIDScalarType } from 'src/engine/api/graphql/workspace-schema-builder/graphql-types/scalars';
import { CommandMenuItemAvailabilityType } from 'src/engine/metadata-modules/command-menu-item/entities/command-menu-item.entity';
@InputType()
export class UpdateCommandMenuItemInput {
@IsUUID()
@IsNotEmpty()
@Field(() => UUIDScalarType)
id: string;
@IsString()
@IsOptional()
@Field({ nullable: true })
label?: string;
@IsString()
@IsOptional()
@Field({ nullable: true })
icon?: string;
@IsBoolean()
@IsOptional()
@Field({ nullable: true })
isPinned?: boolean;
@IsEnum(CommandMenuItemAvailabilityType)
@IsOptional()
@Field(() => CommandMenuItemAvailabilityType, { nullable: true })
availabilityType?: CommandMenuItemAvailabilityType;
@IsUUID()
@IsOptional()
@Field(() => UUIDScalarType, { nullable: true })
availabilityObjectMetadataId?: string;
}
@@ -0,0 +1,69 @@
import {
Column,
CreateDateColumn,
Entity,
Index,
JoinColumn,
ManyToOne,
PrimaryGeneratedColumn,
type Relation,
UpdateDateColumn,
} from 'typeorm';
import { ObjectMetadataEntity } from 'src/engine/metadata-modules/object-metadata/object-metadata.entity';
import { SyncableEntityRequired } from 'src/engine/workspace-manager/types/syncable-entity-required.interface';
export enum CommandMenuItemAvailabilityType {
GLOBAL = 'GLOBAL',
SINGLE_RECORD = 'SINGLE_RECORD',
BULK_RECORDS = 'BULK_RECORDS',
}
@Entity({ name: 'commandMenuItem', schema: 'core' })
@Index('IDX_COMMAND_MENU_ITEM_WORKFLOW_VERSION_ID_WORKSPACE_ID', [
'workflowVersionId',
'workspaceId',
])
export class CommandMenuItemEntity
extends SyncableEntityRequired
implements Required<CommandMenuItemEntity>
{
@PrimaryGeneratedColumn('uuid')
id: string;
@Column({ nullable: false, type: 'uuid' })
workflowVersionId: string;
@Column({ nullable: false })
label: string;
@Column({ nullable: true, type: 'varchar' })
icon: string | null;
@Column({ default: false })
isPinned: boolean;
@Column({
type: 'enum',
enum: CommandMenuItemAvailabilityType,
nullable: false,
default: CommandMenuItemAvailabilityType.GLOBAL,
})
availabilityType: CommandMenuItemAvailabilityType;
@Column({ nullable: true, type: 'uuid' })
availabilityObjectMetadataId: string | null;
@ManyToOne(() => ObjectMetadataEntity, {
onDelete: 'CASCADE',
nullable: true,
})
@JoinColumn({ name: 'availabilityObjectMetadataId' })
availabilityObjectMetadata: Relation<ObjectMetadataEntity> | null;
@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 { commandMenuItemGraphqlApiExceptionHandler } from 'src/engine/metadata-modules/command-menu-item/utils/command-menu-item-graphql-api-exception-handler.util';
@Injectable()
export class CommandMenuItemGraphqlApiExceptionInterceptor
implements NestInterceptor
{
intercept(
_context: ExecutionContext,
next: CallHandler,
): Observable<unknown> {
return next
.handle()
.pipe(catchError(commandMenuItemGraphqlApiExceptionHandler));
}
}
@@ -0,0 +1,26 @@
import { assertUnreachable } from 'twenty-shared/utils';
import {
NotFoundError,
UserInputError,
} from 'src/engine/core-modules/graphql/utils/graphql-errors.util';
import {
CommandMenuItemException,
CommandMenuItemExceptionCode,
} from 'src/engine/metadata-modules/command-menu-item/command-menu-item.exception';
export const commandMenuItemGraphqlApiExceptionHandler = (error: Error) => {
if (error instanceof CommandMenuItemException) {
switch (error.code) {
case CommandMenuItemExceptionCode.COMMAND_MENU_ITEM_NOT_FOUND:
throw new NotFoundError(error);
case CommandMenuItemExceptionCode.INVALID_COMMAND_MENU_ITEM_INPUT:
throw new UserInputError(error);
default: {
return assertUnreachable(error.code);
}
}
}
throw error;
};
@@ -0,0 +1,9 @@
import { type FlatCommandMenuItem } from 'src/engine/metadata-modules/flat-command-menu-item/types/flat-command-menu-item.type';
export const FLAT_COMMAND_MENU_ITEM_EDITABLE_PROPERTIES = [
'label',
'icon',
'isPinned',
'availabilityType',
'availabilityObjectMetadataId',
] as const satisfies (keyof FlatCommandMenuItem)[];
@@ -0,0 +1,16 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { CommandMenuItemEntity } from 'src/engine/metadata-modules/command-menu-item/entities/command-menu-item.entity';
import { WorkspaceFlatCommandMenuItemMapCacheService } from 'src/engine/metadata-modules/flat-command-menu-item/services/workspace-flat-command-menu-item-map-cache.service';
import { WorkspaceManyOrAllFlatEntityMapsCacheModule } from 'src/engine/metadata-modules/flat-entity/services/workspace-many-or-all-flat-entity-maps-cache.module';
@Module({
imports: [
TypeOrmModule.forFeature([CommandMenuItemEntity]),
WorkspaceManyOrAllFlatEntityMapsCacheModule,
],
providers: [WorkspaceFlatCommandMenuItemMapCacheService],
exports: [WorkspaceFlatCommandMenuItemMapCacheService],
})
export class FlatCommandMenuItemModule {}
@@ -0,0 +1,45 @@
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 { CommandMenuItemEntity } from 'src/engine/metadata-modules/command-menu-item/entities/command-menu-item.entity';
import { type FlatCommandMenuItemMaps } from 'src/engine/metadata-modules/flat-command-menu-item/types/flat-command-menu-item-maps.type';
import { fromCommandMenuItemEntityToFlatCommandMenuItem } from 'src/engine/metadata-modules/flat-command-menu-item/utils/from-command-menu-item-entity-to-flat-command-menu-item.util';
import { createEmptyFlatEntityMaps } from 'src/engine/metadata-modules/flat-entity/constant/create-empty-flat-entity-maps.constant';
import { WorkspaceCache } from 'src/engine/workspace-cache/decorators/workspace-cache.decorator';
import { addFlatEntityToFlatEntityMapsThroughMutationOrThrow } from 'src/engine/workspace-manager/workspace-migration/utils/add-flat-entity-to-flat-entity-maps-through-mutation-or-throw.util';
@Injectable()
@WorkspaceCache('flatCommandMenuItemMaps')
export class WorkspaceFlatCommandMenuItemMapCacheService extends WorkspaceCacheProvider<FlatCommandMenuItemMaps> {
constructor(
@InjectRepository(CommandMenuItemEntity)
private readonly commandMenuItemRepository: Repository<CommandMenuItemEntity>,
) {
super();
}
async computeForCache(workspaceId: string): Promise<FlatCommandMenuItemMaps> {
const commandMenuItems = await this.commandMenuItemRepository.find({
where: { workspaceId },
withDeleted: true,
});
const flatCommandMenuItemMaps = createEmptyFlatEntityMaps();
for (const commandMenuItemEntity of commandMenuItems) {
const flatCommandMenuItem =
fromCommandMenuItemEntityToFlatCommandMenuItem(commandMenuItemEntity);
addFlatEntityToFlatEntityMapsThroughMutationOrThrow({
flatEntity: flatCommandMenuItem,
flatEntityMapsToMutate: flatCommandMenuItemMaps,
});
}
return flatCommandMenuItemMaps;
}
}
@@ -0,0 +1,4 @@
import { type FlatCommandMenuItem } from 'src/engine/metadata-modules/flat-command-menu-item/types/flat-command-menu-item.type';
import { type FlatEntityMaps } from 'src/engine/metadata-modules/flat-entity/types/flat-entity-maps.type';
export type FlatCommandMenuItemMaps = FlatEntityMaps<FlatCommandMenuItem>;
@@ -0,0 +1,4 @@
import { type CommandMenuItemEntity } from 'src/engine/metadata-modules/command-menu-item/entities/command-menu-item.entity';
import { type FlatEntityFrom } from 'src/engine/metadata-modules/flat-entity/types/flat-entity.type';
export type FlatCommandMenuItem = FlatEntityFrom<CommandMenuItemEntity>;
@@ -0,0 +1,22 @@
import { type CommandMenuItemEntity } from 'src/engine/metadata-modules/command-menu-item/entities/command-menu-item.entity';
import { type FlatCommandMenuItem } from 'src/engine/metadata-modules/flat-command-menu-item/types/flat-command-menu-item.type';
export const fromCommandMenuItemEntityToFlatCommandMenuItem = (
commandMenuItemEntity: CommandMenuItemEntity,
): FlatCommandMenuItem => {
return {
id: commandMenuItemEntity.id,
workflowVersionId: commandMenuItemEntity.workflowVersionId,
label: commandMenuItemEntity.label,
icon: commandMenuItemEntity.icon,
isPinned: commandMenuItemEntity.isPinned,
availabilityType: commandMenuItemEntity.availabilityType,
availabilityObjectMetadataId:
commandMenuItemEntity.availabilityObjectMetadataId,
workspaceId: commandMenuItemEntity.workspaceId,
universalIdentifier: commandMenuItemEntity.universalIdentifier,
applicationId: commandMenuItemEntity.applicationId,
createdAt: commandMenuItemEntity.createdAt.toISOString(),
updatedAt: commandMenuItemEntity.updatedAt.toISOString(),
};
};
@@ -0,0 +1,36 @@
import { v4 as uuidv4 } from 'uuid';
import { type CreateCommandMenuItemInput } from 'src/engine/metadata-modules/command-menu-item/dtos/create-command-menu-item.input';
import { CommandMenuItemAvailabilityType } from 'src/engine/metadata-modules/command-menu-item/entities/command-menu-item.entity';
import { type FlatCommandMenuItem } from 'src/engine/metadata-modules/flat-command-menu-item/types/flat-command-menu-item.type';
export const fromCreateCommandMenuItemInputToFlatCommandMenuItemToCreate = ({
createCommandMenuItemInput,
workspaceId,
applicationId,
}: {
createCommandMenuItemInput: CreateCommandMenuItemInput;
workspaceId: string;
applicationId: string;
}): FlatCommandMenuItem => {
const id = uuidv4();
const now = new Date().toISOString();
return {
id,
universalIdentifier: id,
workflowVersionId: createCommandMenuItemInput.workflowVersionId,
label: createCommandMenuItemInput.label,
icon: createCommandMenuItemInput.icon ?? null,
isPinned: createCommandMenuItemInput.isPinned ?? false,
availabilityType:
createCommandMenuItemInput.availabilityType ??
CommandMenuItemAvailabilityType.GLOBAL,
availabilityObjectMetadataId:
createCommandMenuItemInput.availabilityObjectMetadataId ?? null,
workspaceId,
applicationId,
createdAt: now,
updatedAt: now,
};
};
@@ -0,0 +1,31 @@
import { isDefined } from 'twenty-shared/utils';
import {
CommandMenuItemException,
CommandMenuItemExceptionCode,
} from 'src/engine/metadata-modules/command-menu-item/command-menu-item.exception';
import { type FlatCommandMenuItemMaps } from 'src/engine/metadata-modules/flat-command-menu-item/types/flat-command-menu-item-maps.type';
import { type FlatCommandMenuItem } from 'src/engine/metadata-modules/flat-command-menu-item/types/flat-command-menu-item.type';
import { findFlatEntityByIdInFlatEntityMaps } from 'src/engine/metadata-modules/flat-entity/utils/find-flat-entity-by-id-in-flat-entity-maps.util';
export const fromDeleteCommandMenuItemInputToFlatCommandMenuItemOrThrow = ({
flatCommandMenuItemMaps,
commandMenuItemId,
}: {
flatCommandMenuItemMaps: FlatCommandMenuItemMaps;
commandMenuItemId: string;
}): FlatCommandMenuItem => {
const existingFlatCommandMenuItem = findFlatEntityByIdInFlatEntityMaps({
flatEntityId: commandMenuItemId,
flatEntityMaps: flatCommandMenuItemMaps,
});
if (!isDefined(existingFlatCommandMenuItem)) {
throw new CommandMenuItemException(
'Command menu item not found',
CommandMenuItemExceptionCode.COMMAND_MENU_ITEM_NOT_FOUND,
);
}
return existingFlatCommandMenuItem;
};
@@ -0,0 +1,19 @@
import { type CommandMenuItemDTO } from 'src/engine/metadata-modules/command-menu-item/dtos/command-menu-item.dto';
import { type FlatCommandMenuItem } from 'src/engine/metadata-modules/flat-command-menu-item/types/flat-command-menu-item.type';
export const fromFlatCommandMenuItemToCommandMenuItemDto = (
flatCommandMenuItem: FlatCommandMenuItem,
): CommandMenuItemDTO => ({
id: flatCommandMenuItem.id,
workflowVersionId: flatCommandMenuItem.workflowVersionId,
label: flatCommandMenuItem.label,
icon: flatCommandMenuItem.icon ?? undefined,
isPinned: flatCommandMenuItem.isPinned,
availabilityType: flatCommandMenuItem.availabilityType,
availabilityObjectMetadataId:
flatCommandMenuItem.availabilityObjectMetadataId ?? undefined,
workspaceId: flatCommandMenuItem.workspaceId,
applicationId: flatCommandMenuItem.applicationId ?? undefined,
createdAt: new Date(flatCommandMenuItem.createdAt),
updatedAt: new Date(flatCommandMenuItem.updatedAt),
});
@@ -0,0 +1,44 @@
import { isDefined } from 'twenty-shared/utils';
import {
CommandMenuItemException,
CommandMenuItemExceptionCode,
} from 'src/engine/metadata-modules/command-menu-item/command-menu-item.exception';
import { type UpdateCommandMenuItemInput } from 'src/engine/metadata-modules/command-menu-item/dtos/update-command-menu-item.input';
import { FLAT_COMMAND_MENU_ITEM_EDITABLE_PROPERTIES } from 'src/engine/metadata-modules/flat-command-menu-item/constants/flat-command-menu-item-editable-properties.constant';
import { type FlatCommandMenuItemMaps } from 'src/engine/metadata-modules/flat-command-menu-item/types/flat-command-menu-item-maps.type';
import { type FlatCommandMenuItem } from 'src/engine/metadata-modules/flat-command-menu-item/types/flat-command-menu-item.type';
import { findFlatEntityByIdInFlatEntityMaps } from 'src/engine/metadata-modules/flat-entity/utils/find-flat-entity-by-id-in-flat-entity-maps.util';
import { mergeUpdateInExistingRecord } from 'src/utils/merge-update-in-existing-record.util';
export const fromUpdateCommandMenuItemInputToFlatCommandMenuItemToUpdateOrThrow =
({
flatCommandMenuItemMaps,
updateCommandMenuItemInput,
}: {
flatCommandMenuItemMaps: FlatCommandMenuItemMaps;
updateCommandMenuItemInput: UpdateCommandMenuItemInput;
}): FlatCommandMenuItem => {
const existingFlatCommandMenuItem = findFlatEntityByIdInFlatEntityMaps({
flatEntityId: updateCommandMenuItemInput.id,
flatEntityMaps: flatCommandMenuItemMaps,
});
if (!isDefined(existingFlatCommandMenuItem)) {
throw new CommandMenuItemException(
'Command menu item not found',
CommandMenuItemExceptionCode.COMMAND_MENU_ITEM_NOT_FOUND,
);
}
const { id: _id, ...updates } = updateCommandMenuItemInput;
return {
...mergeUpdateInExistingRecord({
existing: existingFlatCommandMenuItem,
properties: [...FLAT_COMMAND_MENU_ITEM_EDITABLE_PROPERTIES],
update: updates,
}),
updatedAt: new Date().toISOString(),
};
};
@@ -14,6 +14,7 @@ import { FLAT_ROLE_TARGET_EDITABLE_PROPERTIES } from 'src/engine/metadata-module
import { FLAT_ROLE_EDITABLE_PROPERTIES } from 'src/engine/metadata-modules/flat-role/constants/flat-role-editable-properties.constant';
import { FLAT_ROW_LEVEL_PERMISSION_PREDICATE_GROUP_EDITABLE_PROPERTIES } from 'src/engine/metadata-modules/flat-row-level-permission-predicate-group/constants/flat-row-level-permission-predicate-group-editable-properties.constant';
import { FLAT_ROW_LEVEL_PERMISSION_PREDICATE_EDITABLE_PROPERTIES } from 'src/engine/metadata-modules/flat-row-level-permission-predicate/constants/flat-row-level-permission-predicate-editable-properties.constant';
import { FLAT_COMMAND_MENU_ITEM_EDITABLE_PROPERTIES } from 'src/engine/metadata-modules/flat-command-menu-item/constants/flat-command-menu-item-editable-properties.constant';
import { FLAT_SKILL_EDITABLE_PROPERTIES } from 'src/engine/metadata-modules/flat-skill/constants/flat-skill-editable-properties.constant';
import { FLAT_VIEW_FIELD_EDITABLE_PROPERTIES } from 'src/engine/metadata-modules/flat-view-field/constants/flat-view-field-editable-properties.constant';
import { FLAT_VIEW_FILTER_GROUP_EDITABLE_PROPERTIES } from 'src/engine/metadata-modules/flat-view-filter-group/constants/flat-view-filter-group-editable-properties.constant';
@@ -141,6 +142,10 @@ export const ALL_FLAT_ENTITY_PROPERTIES_TO_COMPARE_AND_STRINGIFY = {
propertiesToCompare: [...FLAT_SKILL_EDITABLE_PROPERTIES],
propertiesToStringify: [],
},
commandMenuItem: {
propertiesToCompare: [...FLAT_COMMAND_MENU_ITEM_EDITABLE_PROPERTIES],
propertiesToStringify: [],
},
rowLevelPermissionPredicate: {
propertiesToCompare: [
...FLAT_ROW_LEVEL_PERMISSION_PREDICATE_EDITABLE_PROPERTIES,
@@ -79,6 +79,18 @@ export const ALL_METADATA_RELATIONS = {
},
oneToMany: {},
},
commandMenuItem: {
manyToOne: {
workspace: null,
application: null,
availabilityObjectMetadata: {
metadataName: 'objectMetadata',
flatEntityForeignKeyAggregator: null,
foreignKey: 'availabilityObjectMetadataId',
},
},
oneToMany: {},
},
fieldMetadata: {
manyToOne: {
object: {
@@ -62,6 +62,9 @@ export const ALL_METADATA_REQUIRED_METADATA_FOR_VALIDATION = {
role: true,
},
skill: {},
commandMenuItem: {
objectMetadata: true,
},
pageLayout: {
objectMetadata: true,
},
@@ -1,13 +1,14 @@
import { type AgentEntity } from 'src/engine/metadata-modules/ai/ai-agent/entities/agent.entity';
import { type FlatFrontComponent } from 'src/engine/metadata-modules/flat-front-component/types/flat-front-component.type';
import { type FrontComponentEntity } from 'src/engine/metadata-modules/front-component/entities/front-component.entity';
import { type CommandMenuItemEntity } from 'src/engine/metadata-modules/command-menu-item/entities/command-menu-item.entity';
import { type CronTriggerEntity } from 'src/engine/metadata-modules/cron-trigger/entities/cron-trigger.entity';
import { type FlatCronTrigger } from 'src/engine/metadata-modules/cron-trigger/types/flat-cron-trigger.type';
import { type DatabaseEventTriggerEntity } from 'src/engine/metadata-modules/database-event-trigger/entities/database-event-trigger.entity';
import { type FlatDatabaseEventTrigger } from 'src/engine/metadata-modules/database-event-trigger/types/flat-database-event-trigger.type';
import { type FieldMetadataEntity } from 'src/engine/metadata-modules/field-metadata/field-metadata.entity';
import { type FlatAgent } from 'src/engine/metadata-modules/flat-agent/types/flat-agent.type';
import { type FlatCommandMenuItem } from 'src/engine/metadata-modules/flat-command-menu-item/types/flat-command-menu-item.type';
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 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';
@@ -21,6 +22,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 FrontComponentEntity } from 'src/engine/metadata-modules/front-component/entities/front-component.entity';
import { type IndexMetadataEntity } from 'src/engine/metadata-modules/index-metadata/index-metadata.entity';
import { type ObjectMetadataEntity } from 'src/engine/metadata-modules/object-metadata/object-metadata.entity';
import { type PageLayoutTabEntity } from 'src/engine/metadata-modules/page-layout-tab/entities/page-layout-tab.entity';
@@ -47,6 +49,11 @@ import {
type DeleteAgentAction,
type UpdateAgentAction,
} from 'src/engine/workspace-manager/workspace-migration/workspace-migration-builder/builders/agent/types/workspace-migration-agent-action-builder.service';
import {
type CreateCommandMenuItemAction,
type DeleteCommandMenuItemAction,
type UpdateCommandMenuItemAction,
} from 'src/engine/workspace-manager/workspace-migration/workspace-migration-builder/builders/command-menu-item/types/workspace-migration-command-menu-item-action.type';
import {
type CreateCronTriggerAction,
type DeleteCronTriggerAction,
@@ -62,6 +69,11 @@ import {
type DeleteFieldAction,
type UpdateFieldAction,
} from 'src/engine/workspace-manager/workspace-migration/workspace-migration-builder/builders/field/types/workspace-migration-field-action';
import {
type CreateFrontComponentAction,
type DeleteFrontComponentAction,
type UpdateFrontComponentAction,
} from 'src/engine/workspace-manager/workspace-migration/workspace-migration-builder/builders/front-component/types/workspace-migration-front-component-action.type';
import {
type CreateIndexAction,
type DeleteIndexAction,
@@ -147,11 +159,6 @@ import {
type DeleteViewAction,
type UpdateViewAction,
} from 'src/engine/workspace-manager/workspace-migration/workspace-migration-builder/builders/view/types/workspace-migration-view-action.type';
import {
type CreateFrontComponentAction,
type DeleteFrontComponentAction,
type UpdateFrontComponentAction,
} from 'src/engine/workspace-manager/workspace-migration/workspace-migration-builder/builders/front-component/types/workspace-migration-front-component-action.type';
export type AllFlatEntityTypesByMetadataName = {
fieldMetadata: {
@@ -316,6 +323,15 @@ export type AllFlatEntityTypesByMetadataName = {
flatEntity: FlatSkill;
entity: SkillEntity;
};
commandMenuItem: {
actions: {
create: CreateCommandMenuItemAction;
update: UpdateCommandMenuItemAction;
delete: DeleteCommandMenuItemAction;
};
flatEntity: FlatCommandMenuItem;
entity: CommandMenuItemEntity;
};
pageLayout: {
actions: {
create: CreatePageLayoutAction;
@@ -2,6 +2,12 @@
exports[`getMetadataRelatedMetadataNames should return related metadata names for agent 1`] = `[]`;
exports[`getMetadataRelatedMetadataNames should return related metadata names for commandMenuItem 1`] = `
[
"objectMetadata",
]
`;
exports[`getMetadataRelatedMetadataNames should return related metadata names for cronTrigger 1`] = `
[
"serverlessFunction",
@@ -3,6 +3,7 @@ import { Module } from '@nestjs/common';
import { AiAgentMonitorModule } from 'src/engine/metadata-modules/ai/ai-agent-monitor/ai-agent-monitor.module';
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 { 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';
@@ -29,6 +30,7 @@ import { WorkspaceMetadataVersionModule } from 'src/engine/metadata-modules/work
ServerlessFunctionModule,
ServerlessFunctionLayerModule,
SkillModule,
CommandMenuItemModule,
AiAgentModule,
AiAgentMonitorModule,
AiChatModule,
@@ -49,6 +51,7 @@ import { WorkspaceMetadataVersionModule } from 'src/engine/metadata-modules/work
SearchFieldMetadataModule,
ServerlessFunctionModule,
SkillModule,
CommandMenuItemModule,
AiAgentModule,
AiChatModule,
ViewModule,