Batch create, update, and delete navigation menu items (#18882)

Co-authored-by: Charles Bochet <charles@twenty.com>
This commit is contained in:
Abdul Rahman
2026-03-25 01:54:47 +05:30
committed by GitHub
parent 0af7760441
commit 37640521d5
48 changed files with 2175 additions and 565 deletions
@@ -65,6 +65,26 @@ export class NavigationMenuItemResolver {
});
}
@Mutation(() => [NavigationMenuItemDTO])
@UseGuards(NoPermissionGuard)
async createManyNavigationMenuItems(
@Args('inputs', { type: () => [CreateNavigationMenuItemInput] })
inputs: CreateNavigationMenuItemInput[],
@AuthWorkspace() workspace: WorkspaceEntity,
@AuthUserWorkspaceId({ allowUndefined: true })
userWorkspaceId: string | undefined,
@AuthApiKey() apiKey: ApiKeyEntity | undefined,
@Context() context: { req: { application?: ApplicationEntity } },
): Promise<NavigationMenuItemDTO[]> {
return await this.navigationMenuItemService.createMany({
inputs,
workspaceId: workspace.id,
authUserWorkspaceId: userWorkspaceId,
authApiKeyId: apiKey?.id,
authApplicationId: context.req.application?.id,
});
}
@Mutation(() => NavigationMenuItemDTO)
@UseGuards(NoPermissionGuard)
async createNavigationMenuItem(
@@ -84,6 +104,26 @@ export class NavigationMenuItemResolver {
});
}
@Mutation(() => [NavigationMenuItemDTO])
@UseGuards(NoPermissionGuard)
async updateManyNavigationMenuItems(
@Args('inputs', { type: () => [UpdateOneNavigationMenuItemInput] })
inputs: UpdateOneNavigationMenuItemInput[],
@AuthWorkspace() workspace: WorkspaceEntity,
@AuthUserWorkspaceId({ allowUndefined: true })
userWorkspaceId: string | undefined,
@AuthApiKey() apiKey: ApiKeyEntity | undefined,
@Context() context: { req: { application?: ApplicationEntity } },
): Promise<NavigationMenuItemDTO[]> {
return await this.navigationMenuItemService.updateMany({
inputs,
workspaceId: workspace.id,
authUserWorkspaceId: userWorkspaceId,
authApiKeyId: apiKey?.id,
authApplicationId: context.req.application?.id,
});
}
@Mutation(() => NavigationMenuItemDTO)
@UseGuards(NoPermissionGuard)
async updateNavigationMenuItem(
@@ -103,6 +143,25 @@ export class NavigationMenuItemResolver {
});
}
@Mutation(() => [NavigationMenuItemDTO])
@UseGuards(NoPermissionGuard)
async deleteManyNavigationMenuItems(
@Args('ids', { type: () => [UUIDScalarType] }) ids: string[],
@AuthWorkspace() workspace: WorkspaceEntity,
@AuthUserWorkspaceId({ allowUndefined: true })
userWorkspaceId: string | undefined,
@AuthApiKey() apiKey: ApiKeyEntity | undefined,
@Context() context: { req: { application?: ApplicationEntity } },
): Promise<NavigationMenuItemDTO[]> {
return await this.navigationMenuItemService.deleteMany({
ids,
workspaceId: workspace.id,
authUserWorkspaceId: userWorkspaceId,
authApiKeyId: apiKey?.id,
authApplicationId: context.req.application?.id,
});
}
@Mutation(() => NavigationMenuItemDTO)
@UseGuards(NoPermissionGuard)
async deleteNavigationMenuItem(
@@ -7,6 +7,8 @@ import { type WorkspaceAuthContext } from 'src/engine/core-modules/auth/types/wo
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 { type FlatNavigationMenuItem } from 'src/engine/metadata-modules/flat-navigation-menu-item/types/flat-navigation-menu-item.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 { 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';
@@ -14,7 +16,10 @@ import { fromUpdateNavigationMenuItemInputToFlatNavigationMenuItemToUpdateOrThro
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 { RecordIdentifierDTO } from 'src/engine/metadata-modules/navigation-menu-item/dtos/record-identifier.dto';
import { type UpdateNavigationMenuItemInput } from 'src/engine/metadata-modules/navigation-menu-item/dtos/update-navigation-menu-item.input';
import {
type UpdateNavigationMenuItemInput,
type UpdateOneNavigationMenuItemInput,
} from 'src/engine/metadata-modules/navigation-menu-item/dtos/update-navigation-menu-item.input';
import { NavigationMenuItemType } from 'src/engine/metadata-modules/navigation-menu-item/enums/navigation-menu-item-type.enum';
import {
NavigationMenuItemException,
@@ -123,13 +128,55 @@ export class NavigationMenuItemService {
authApiKeyId?: string;
authApplicationId?: string;
}): Promise<NavigationMenuItemDTO> {
await this.navigationMenuItemAccessService.canUserCreateNavigationMenuItem({
userWorkspaceId: authUserWorkspaceId,
const createdItems = await this.createMany({
inputs: [input],
workspaceId,
apiKeyId: authApiKeyId,
applicationId: authApplicationId,
inputUserWorkspaceId: input.userWorkspaceId,
authUserWorkspaceId,
authApiKeyId,
authApplicationId,
});
const created = createdItems[0];
if (!isDefined(created)) {
throw new NavigationMenuItemException(
'Failed to create navigation menu item',
NavigationMenuItemExceptionCode.INVALID_NAVIGATION_MENU_ITEM_INPUT,
);
}
return created;
}
async createMany({
inputs,
workspaceId,
authUserWorkspaceId,
authApiKeyId,
authApplicationId,
}: {
inputs: CreateNavigationMenuItemInput[];
workspaceId: string;
authUserWorkspaceId?: string;
authApiKeyId?: string;
authApplicationId?: string;
}): Promise<NavigationMenuItemDTO[]> {
if (inputs.length === 0) {
return [];
}
for (const input of inputs) {
await this.navigationMenuItemAccessService.canUserCreateNavigationMenuItem(
{
userWorkspaceId: authUserWorkspaceId,
workspaceId,
apiKeyId: authApiKeyId,
applicationId: authApplicationId,
inputUserWorkspaceId: input.userWorkspaceId,
},
);
}
const { workspaceCustomFlatApplication } =
await this.applicationService.findWorkspaceTwentyStandardAndCustomApplicationOrThrow(
{ workspaceId },
@@ -150,30 +197,57 @@ export class NavigationMenuItemService {
},
);
const normalizedInput: CreateNavigationMenuItemInput = {
...input,
userWorkspaceId:
isDefined(input.userWorkspaceId) && isDefined(authUserWorkspaceId)
? authUserWorkspaceId
: input.userWorkspaceId,
};
const optimisticFlatNavigationMenuItemMaps = structuredClone(
existingFlatNavigationMenuItemMaps,
);
const flatNavigationMenuItemToCreate =
fromCreateNavigationMenuItemInputToFlatNavigationMenuItemToCreate({
createNavigationMenuItemInput: normalizedInput,
workspaceId,
flatApplication: workspaceCustomFlatApplication,
flatNavigationMenuItemMaps: existingFlatNavigationMenuItemMaps,
flatObjectMetadataMaps,
flatViewMaps,
const foldersFirst = [...inputs].sort((a, b) => {
const aIsFolder = a.type === NavigationMenuItemType.FOLDER ? 0 : 1;
const bIsFolder = b.type === NavigationMenuItemType.FOLDER ? 0 : 1;
return aIsFolder - bIsFolder;
});
const flatEntityByInput = new Map<
CreateNavigationMenuItemInput,
FlatNavigationMenuItem
>();
const flatNavigationMenuItemsToCreate = foldersFirst.map((input) => {
const normalizedInput: CreateNavigationMenuItemInput = {
...input,
userWorkspaceId:
isDefined(input.userWorkspaceId) && isDefined(authUserWorkspaceId)
? authUserWorkspaceId
: input.userWorkspaceId,
};
const flatNavigationMenuItemToCreate =
fromCreateNavigationMenuItemInputToFlatNavigationMenuItemToCreate({
createNavigationMenuItemInput: normalizedInput,
workspaceId,
flatApplication: workspaceCustomFlatApplication,
flatNavigationMenuItemMaps: optimisticFlatNavigationMenuItemMaps,
flatObjectMetadataMaps,
flatViewMaps,
});
addFlatNavigationMenuItemToMapsAndUpdateIndex({
flatNavigationMenuItem: flatNavigationMenuItemToCreate,
flatNavigationMenuItemMaps: optimisticFlatNavigationMenuItemMaps,
});
flatEntityByInput.set(input, flatNavigationMenuItemToCreate);
return flatNavigationMenuItemToCreate;
});
const validateAndBuildResult =
await this.workspaceMigrationValidateBuildAndRunService.validateBuildAndRunWorkspaceMigration(
{
allFlatEntityOperationByMetadataName: {
navigationMenuItem: {
flatEntityToCreate: [flatNavigationMenuItemToCreate],
flatEntityToCreate: flatNavigationMenuItemsToCreate,
flatEntityToDelete: [],
flatEntityToUpdate: [],
},
@@ -188,7 +262,7 @@ export class NavigationMenuItemService {
if (validateAndBuildResult.status === 'fail') {
throw new WorkspaceMigrationBuilderException(
validateAndBuildResult,
'Multiple validation errors occurred while creating navigation menu item',
'Multiple validation errors occurred while creating navigation menu items',
);
}
@@ -200,11 +274,13 @@ export class NavigationMenuItemService {
},
);
return fromFlatNavigationMenuItemToNavigationMenuItemDto(
findFlatEntityByIdInFlatEntityMapsOrThrow({
flatEntityId: flatNavigationMenuItemToCreate.id,
flatEntityMaps: recomputedFlatNavigationMenuItemMaps,
}),
return inputs.map((input) =>
fromFlatNavigationMenuItemToNavigationMenuItemDto(
findFlatEntityByIdInFlatEntityMapsOrThrow({
flatEntityId: flatEntityByInput.get(input)!.id,
flatEntityMaps: recomputedFlatNavigationMenuItemMaps,
}),
),
);
}
@@ -221,6 +297,45 @@ export class NavigationMenuItemService {
authApiKeyId?: string;
authApplicationId?: string;
}): Promise<NavigationMenuItemDTO> {
const { id, ...update } = input;
const updatedItems = await this.updateMany({
inputs: [{ id, update }],
workspaceId,
authUserWorkspaceId,
authApiKeyId,
authApplicationId,
});
const updated = updatedItems[0];
if (!isDefined(updated)) {
throw new NavigationMenuItemException(
'Failed to update navigation menu item',
NavigationMenuItemExceptionCode.INVALID_NAVIGATION_MENU_ITEM_INPUT,
);
}
return updated;
}
async updateMany({
inputs,
workspaceId,
authUserWorkspaceId,
authApiKeyId,
authApplicationId,
}: {
inputs: UpdateOneNavigationMenuItemInput[];
workspaceId: string;
authUserWorkspaceId?: string;
authApiKeyId?: string;
authApplicationId?: string;
}): Promise<NavigationMenuItemDTO[]> {
if (inputs.length === 0) {
return [];
}
const { workspaceCustomFlatApplication } =
await this.applicationService.findWorkspaceTwentyStandardAndCustomApplicationOrThrow(
{ workspaceId },
@@ -234,29 +349,41 @@ export class NavigationMenuItemService {
},
);
const existingNavigationMenuItem = findFlatEntityByIdInFlatEntityMaps({
flatEntityId: input.id,
flatEntityMaps: existingFlatNavigationMenuItemMaps,
});
const flatNavigationMenuItemsToUpdate: FlatNavigationMenuItem[] = [];
if (isDefined(existingNavigationMenuItem)) {
await this.navigationMenuItemAccessService.canUserUpdateNavigationMenuItem(
{
userWorkspaceId: authUserWorkspaceId,
workspaceId,
apiKeyId: authApiKeyId,
applicationId: authApplicationId,
existingUserWorkspaceId: existingNavigationMenuItem.userWorkspaceId,
},
for (const { id, update } of inputs) {
const updateInput: UpdateNavigationMenuItemInput & { id: string } = {
...update,
id,
};
const existingNavigationMenuItem = findFlatEntityByIdInFlatEntityMaps({
flatEntityId: id,
flatEntityMaps: existingFlatNavigationMenuItemMaps,
});
if (isDefined(existingNavigationMenuItem)) {
await this.navigationMenuItemAccessService.canUserUpdateNavigationMenuItem(
{
userWorkspaceId: authUserWorkspaceId,
workspaceId,
apiKeyId: authApiKeyId,
applicationId: authApplicationId,
existingUserWorkspaceId: existingNavigationMenuItem.userWorkspaceId,
},
);
}
flatNavigationMenuItemsToUpdate.push(
fromUpdateNavigationMenuItemInputToFlatNavigationMenuItemToUpdateOrThrow(
{
flatNavigationMenuItemMaps: existingFlatNavigationMenuItemMaps,
updateNavigationMenuItemInput: updateInput,
},
),
);
}
const flatNavigationMenuItemToUpdate =
fromUpdateNavigationMenuItemInputToFlatNavigationMenuItemToUpdateOrThrow({
flatNavigationMenuItemMaps: existingFlatNavigationMenuItemMaps,
updateNavigationMenuItemInput: input,
});
const validateAndBuildResult =
await this.workspaceMigrationValidateBuildAndRunService.validateBuildAndRunWorkspaceMigration(
{
@@ -264,7 +391,7 @@ export class NavigationMenuItemService {
navigationMenuItem: {
flatEntityToCreate: [],
flatEntityToDelete: [],
flatEntityToUpdate: [flatNavigationMenuItemToUpdate],
flatEntityToUpdate: flatNavigationMenuItemsToUpdate,
},
},
workspaceId,
@@ -277,7 +404,7 @@ export class NavigationMenuItemService {
if (validateAndBuildResult.status === 'fail') {
throw new WorkspaceMigrationBuilderException(
validateAndBuildResult,
'Multiple validation errors occurred while updating navigation menu item',
'Multiple validation errors occurred while updating navigation menu items',
);
}
@@ -289,11 +416,13 @@ export class NavigationMenuItemService {
},
);
return fromFlatNavigationMenuItemToNavigationMenuItemDto(
findFlatEntityByIdInFlatEntityMapsOrThrow({
flatEntityId: input.id,
flatEntityMaps: recomputedFlatNavigationMenuItemMaps,
}),
return inputs.map(({ id }) =>
fromFlatNavigationMenuItemToNavigationMenuItemDto(
findFlatEntityByIdInFlatEntityMapsOrThrow({
flatEntityId: id,
flatEntityMaps: recomputedFlatNavigationMenuItemMaps,
}),
),
);
}
@@ -310,6 +439,53 @@ export class NavigationMenuItemService {
authApiKeyId?: string;
authApplicationId?: string;
}): Promise<NavigationMenuItemDTO> {
const deletedItems = await this.deleteMany({
ids: [id],
workspaceId,
authUserWorkspaceId,
authApiKeyId,
authApplicationId,
});
const deleted = deletedItems[0];
if (!isDefined(deleted)) {
throw new NavigationMenuItemException(
'Failed to delete navigation menu item',
NavigationMenuItemExceptionCode.NAVIGATION_MENU_ITEM_NOT_FOUND,
);
}
return deleted;
}
async deleteMany({
ids,
workspaceId,
authUserWorkspaceId,
authApiKeyId,
authApplicationId,
}: {
ids: string[];
workspaceId: string;
authUserWorkspaceId?: string;
authApiKeyId?: string;
authApplicationId?: string;
}): Promise<NavigationMenuItemDTO[]> {
if (ids.length === 0) {
return [];
}
const uniqueOrderedIds: string[] = [];
const seenId = new Set<string>();
for (const id of ids) {
if (!seenId.has(id)) {
seenId.add(id);
uniqueOrderedIds.push(id);
}
}
const { workspaceCustomFlatApplication } =
await this.applicationService.findWorkspaceTwentyStandardAndCustomApplicationOrThrow(
{ workspaceId },
@@ -323,30 +499,55 @@ export class NavigationMenuItemService {
},
);
const flatNavigationMenuItemToDelete =
fromDeleteNavigationMenuItemInputToFlatNavigationMenuItemOrThrow({
flatNavigationMenuItemMaps: existingFlatNavigationMenuItemMaps,
navigationMenuItemId: id,
});
const flatEntitiesToDeleteOrdered: FlatNavigationMenuItem[] = [];
const seenDeleteId = new Set<string>();
const deletedNavigationMenuItemDtoByRequestedId = new Map<
string,
NavigationMenuItemDTO
>();
await this.navigationMenuItemAccessService.canUserDeleteNavigationMenuItem({
userWorkspaceId: authUserWorkspaceId,
workspaceId,
apiKeyId: authApiKeyId,
applicationId: authApplicationId,
existingUserWorkspaceId: flatNavigationMenuItemToDelete.userWorkspaceId,
});
for (const requestedId of uniqueOrderedIds) {
const flatNavigationMenuItemRoot =
fromDeleteNavigationMenuItemInputToFlatNavigationMenuItemOrThrow({
flatNavigationMenuItemMaps: existingFlatNavigationMenuItemMaps,
navigationMenuItemId: requestedId,
});
const flatEntitiesToDelete = [flatNavigationMenuItemToDelete];
await this.navigationMenuItemAccessService.canUserDeleteNavigationMenuItem(
{
userWorkspaceId: authUserWorkspaceId,
workspaceId,
apiKeyId: authApiKeyId,
applicationId: authApplicationId,
existingUserWorkspaceId: flatNavigationMenuItemRoot.userWorkspaceId,
},
);
if (flatNavigationMenuItemToDelete.type === NavigationMenuItemType.FOLDER) {
const userWorkspaceIdKey =
flatNavigationMenuItemToDelete.userWorkspaceId ?? 'null';
const folderChildren =
existingFlatNavigationMenuItemMaps.byUserWorkspaceIdAndFolderId[
userWorkspaceIdKey
]?.[id] ?? [];
flatEntitiesToDelete.unshift(...folderChildren);
deletedNavigationMenuItemDtoByRequestedId.set(
requestedId,
fromFlatNavigationMenuItemToNavigationMenuItemDto(
flatNavigationMenuItemRoot,
),
);
const flatEntitiesForRoot = [flatNavigationMenuItemRoot];
if (flatNavigationMenuItemRoot.type === NavigationMenuItemType.FOLDER) {
const userWorkspaceIdKey =
flatNavigationMenuItemRoot.userWorkspaceId ?? 'null';
const folderChildren =
existingFlatNavigationMenuItemMaps.byUserWorkspaceIdAndFolderId[
userWorkspaceIdKey
]?.[requestedId] ?? [];
flatEntitiesForRoot.unshift(...folderChildren);
}
for (const flatEntity of flatEntitiesForRoot) {
if (!seenDeleteId.has(flatEntity.id)) {
seenDeleteId.add(flatEntity.id);
flatEntitiesToDeleteOrdered.push(flatEntity);
}
}
}
const validateAndBuildResult =
@@ -355,7 +556,7 @@ export class NavigationMenuItemService {
allFlatEntityOperationByMetadataName: {
navigationMenuItem: {
flatEntityToCreate: [],
flatEntityToDelete: flatEntitiesToDelete,
flatEntityToDelete: flatEntitiesToDeleteOrdered,
flatEntityToUpdate: [],
},
},
@@ -369,13 +570,22 @@ export class NavigationMenuItemService {
if (validateAndBuildResult.status === 'fail') {
throw new WorkspaceMigrationBuilderException(
validateAndBuildResult,
'Multiple validation errors occurred while deleting navigation menu item',
'Multiple validation errors occurred while deleting navigation menu items',
);
}
return fromFlatNavigationMenuItemToNavigationMenuItemDto(
flatNavigationMenuItemToDelete,
);
return uniqueOrderedIds.map((requestedId) => {
const dto = deletedNavigationMenuItemDtoByRequestedId.get(requestedId);
if (!isDefined(dto)) {
throw new NavigationMenuItemException(
'Failed to resolve deleted navigation menu item in batch',
NavigationMenuItemExceptionCode.NAVIGATION_MENU_ITEM_NOT_FOUND,
);
}
return dto;
});
}
async findTargetRecord({