From aff577689f697dc2460ed647b331dcb0950736f4 Mon Sep 17 00:00:00 2001 From: Abdul Rahman <81605929+abdulrahmancodes@users.noreply.github.com> Date: Thu, 22 Jan 2026 18:18:51 +0530 Subject: [PATCH] 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) --- .../src/generated-metadata/graphql.ts | 63 +++ .../twenty-front/src/generated/graphql.ts | 15 + ...68807499350-addNavigationMenuItemEntity.ts | 77 ++++ ...rties-to-compare-and-stringify.constant.ts | 5 + ...tadata-entity-by-metadata-name.constant.ts | 2 + .../all-metadata-relations.constant.ts | 18 + ...quired-metadata-for-validation.constant.ts | 4 + .../types/all-flat-entity-maps.type.ts | 3 + .../all-flat-entity-types-by-metadata-name.ts | 16 + ...a-related-metadata-names.util.spec.ts.snap | 7 + ...ata-names-children-first.util.spec.ts.snap | 3 +- ...-menu-item-editable-properties.constant.ts | 7 + .../flat-navigation-menu-item.module.ts | 16 + ...-navigation-menu-item-map-cache.service.ts | 52 +++ .../flat-navigation-menu-item-maps.type.ts | 9 + .../types/flat-navigation-menu-item.type.ts | 4 + ...menu-item-to-maps-and-update-index.util.ts | 41 ++ ...tion-menu-item-from-maps-and-index.util.ts | 22 + ...lat-navigation-menu-item-to-create.util.ts | 59 +++ ...flat-navigation-menu-item-or-throw.util.ts | 32 ++ ...u-item-to-navigation-menu-item-dto.util.ts | 20 + ...ntity-to-flat-navigation-menu-item.util.ts | 22 + ...ation-menu-item-to-update-or-throw.util.ts | 46 +++ ...at-navigation-menu-item-from-index.util.ts | 51 +++ ...menu-item-in-maps-and-update-index.util.ts | 91 +++++ .../metadata-engine.module.ts | 3 + .../dtos/create-navigation-menu-item.input.ts | 44 ++ .../dtos/navigation-menu-item.dto.ts | 67 +++ .../dtos/update-navigation-menu-item.input.ts | 50 +++ .../entities/navigation-menu-item.entity.ts | 94 +++++ ...-item-graphql-api-exception.interceptor.ts | 24 ++ .../navigation-menu-item.exception.ts | 43 ++ .../navigation-menu-item.module.ts | 31 ++ .../navigation-menu-item.resolver.ts | 108 +++++ .../navigation-menu-item.service.ts | 337 +++++++++++++++ .../navigation-menu-item-access.service.ts | 173 ++++++++ ...item-graphql-api-exception-handler.util.ts | 28 ++ .../types/workspace-cache-key.type.ts | 1 + ...ce-migration-build-orchestrator.service.ts | 49 +++ ...ration-navigation-menu-item-action.type.ts | 12 + ...ation-menu-item-actions-builder.service.ts | 117 ++++++ ...-navigation-menu-item-validator.service.ts | 382 ++++++++++++++++++ ...ace-migration-builder-validators.module.ts | 7 +- .../workspace-migration-builder.module.ts | 3 + ...gation-menu-item-action-handler.service.ts | 40 ++ ...gation-menu-item-action-handler.service.ts | 46 +++ ...gation-menu-item-action-handler.service.ts | 39 ++ ...migration-runner-action-handlers.module.ts | 7 + ...ate-action-on-all-flat-entity-maps.util.ts | 10 + ...ete-action-on-all-flat-entity-maps.util.ts | 16 + ...ate-action-on-all-flat-entity-maps.util.ts | 22 + ...enu-item-creation.integration-spec.ts.snap | 209 ++++++++++ ...enu-item-deletion.integration-spec.ts.snap | 57 +++ ...rcular-dependency.integration-spec.ts.snap | 73 ++++ ...-menu-item-update.integration-spec.ts.snap | 109 +++++ ...ion-menu-item-creation.integration-spec.ts | 115 ++++++ ...ion-menu-item-deletion.integration-spec.ts | 62 +++ ...te-circular-dependency.integration-spec.ts | 99 +++++ ...ation-menu-item-update.integration-spec.ts | 165 ++++++++ ...ion-menu-item-creation.integration-spec.ts | 169 ++++++++ ...ion-menu-item-deletion.integration-spec.ts | 69 ++++ ...ation-menu-item-update.integration-spec.ts | 193 +++++++++ ...navigation-menu-item-query-factory.util.ts | 35 ++ .../utils/create-navigation-menu-item.util.ts | 44 ++ ...navigation-menu-item-query-factory.util.ts | 28 ++ .../utils/delete-navigation-menu-item.util.ts | 44 ++ ...avigation-menu-items-query-factory.util.ts | 26 ++ .../utils/find-navigation-menu-items.util.ts | 39 ++ ...navigation-menu-item-query-factory.util.ts | 35 ++ .../utils/update-navigation-menu-item.util.ts | 43 ++ .../metadata/all-metadata-name.constant.ts | 1 + 71 files changed, 4050 insertions(+), 3 deletions(-) create mode 100644 packages/twenty-server/src/database/typeorm/core/migrations/common/1768807499350-addNavigationMenuItemEntity.ts create mode 100644 packages/twenty-server/src/engine/metadata-modules/flat-navigation-menu-item/constants/flat-navigation-menu-item-editable-properties.constant.ts create mode 100644 packages/twenty-server/src/engine/metadata-modules/flat-navigation-menu-item/flat-navigation-menu-item.module.ts create mode 100644 packages/twenty-server/src/engine/metadata-modules/flat-navigation-menu-item/services/workspace-flat-navigation-menu-item-map-cache.service.ts create mode 100644 packages/twenty-server/src/engine/metadata-modules/flat-navigation-menu-item/types/flat-navigation-menu-item-maps.type.ts create mode 100644 packages/twenty-server/src/engine/metadata-modules/flat-navigation-menu-item/types/flat-navigation-menu-item.type.ts create mode 100644 packages/twenty-server/src/engine/metadata-modules/flat-navigation-menu-item/utils/add-flat-navigation-menu-item-to-maps-and-update-index.util.ts create mode 100644 packages/twenty-server/src/engine/metadata-modules/flat-navigation-menu-item/utils/delete-flat-navigation-menu-item-from-maps-and-index.util.ts create mode 100644 packages/twenty-server/src/engine/metadata-modules/flat-navigation-menu-item/utils/from-create-navigation-menu-item-input-to-flat-navigation-menu-item-to-create.util.ts create mode 100644 packages/twenty-server/src/engine/metadata-modules/flat-navigation-menu-item/utils/from-delete-navigation-menu-item-input-to-flat-navigation-menu-item-or-throw.util.ts create mode 100644 packages/twenty-server/src/engine/metadata-modules/flat-navigation-menu-item/utils/from-flat-navigation-menu-item-to-navigation-menu-item-dto.util.ts create mode 100644 packages/twenty-server/src/engine/metadata-modules/flat-navigation-menu-item/utils/from-navigation-menu-item-entity-to-flat-navigation-menu-item.util.ts create mode 100644 packages/twenty-server/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.ts create mode 100644 packages/twenty-server/src/engine/metadata-modules/flat-navigation-menu-item/utils/remove-flat-navigation-menu-item-from-index.util.ts create mode 100644 packages/twenty-server/src/engine/metadata-modules/flat-navigation-menu-item/utils/replace-flat-navigation-menu-item-in-maps-and-update-index.util.ts create mode 100644 packages/twenty-server/src/engine/metadata-modules/navigation-menu-item/dtos/create-navigation-menu-item.input.ts create mode 100644 packages/twenty-server/src/engine/metadata-modules/navigation-menu-item/dtos/navigation-menu-item.dto.ts create mode 100644 packages/twenty-server/src/engine/metadata-modules/navigation-menu-item/dtos/update-navigation-menu-item.input.ts create mode 100644 packages/twenty-server/src/engine/metadata-modules/navigation-menu-item/entities/navigation-menu-item.entity.ts create mode 100644 packages/twenty-server/src/engine/metadata-modules/navigation-menu-item/interceptors/navigation-menu-item-graphql-api-exception.interceptor.ts create mode 100644 packages/twenty-server/src/engine/metadata-modules/navigation-menu-item/navigation-menu-item.exception.ts create mode 100644 packages/twenty-server/src/engine/metadata-modules/navigation-menu-item/navigation-menu-item.module.ts create mode 100644 packages/twenty-server/src/engine/metadata-modules/navigation-menu-item/navigation-menu-item.resolver.ts create mode 100644 packages/twenty-server/src/engine/metadata-modules/navigation-menu-item/navigation-menu-item.service.ts create mode 100644 packages/twenty-server/src/engine/metadata-modules/navigation-menu-item/services/navigation-menu-item-access.service.ts create mode 100644 packages/twenty-server/src/engine/metadata-modules/navigation-menu-item/utils/navigation-menu-item-graphql-api-exception-handler.util.ts create mode 100644 packages/twenty-server/src/engine/workspace-manager/workspace-migration/workspace-migration-builder/builders/navigation-menu-item/types/workspace-migration-navigation-menu-item-action.type.ts create mode 100644 packages/twenty-server/src/engine/workspace-manager/workspace-migration/workspace-migration-builder/builders/navigation-menu-item/workspace-migration-navigation-menu-item-actions-builder.service.ts create mode 100644 packages/twenty-server/src/engine/workspace-manager/workspace-migration/workspace-migration-builder/validators/services/flat-navigation-menu-item-validator.service.ts create mode 100644 packages/twenty-server/src/engine/workspace-manager/workspace-migration/workspace-migration-runner/action-handlers/navigation-menu-item/services/create-navigation-menu-item-action-handler.service.ts create mode 100644 packages/twenty-server/src/engine/workspace-manager/workspace-migration/workspace-migration-runner/action-handlers/navigation-menu-item/services/delete-navigation-menu-item-action-handler.service.ts create mode 100644 packages/twenty-server/src/engine/workspace-manager/workspace-migration/workspace-migration-runner/action-handlers/navigation-menu-item/services/update-navigation-menu-item-action-handler.service.ts create mode 100644 packages/twenty-server/test/integration/metadata/suites/navigation-menu-item/__snapshots__/failing-navigation-menu-item-creation.integration-spec.ts.snap create mode 100644 packages/twenty-server/test/integration/metadata/suites/navigation-menu-item/__snapshots__/failing-navigation-menu-item-deletion.integration-spec.ts.snap create mode 100644 packages/twenty-server/test/integration/metadata/suites/navigation-menu-item/__snapshots__/failing-navigation-menu-item-update-circular-dependency.integration-spec.ts.snap create mode 100644 packages/twenty-server/test/integration/metadata/suites/navigation-menu-item/__snapshots__/failing-navigation-menu-item-update.integration-spec.ts.snap create mode 100644 packages/twenty-server/test/integration/metadata/suites/navigation-menu-item/failing-navigation-menu-item-creation.integration-spec.ts create mode 100644 packages/twenty-server/test/integration/metadata/suites/navigation-menu-item/failing-navigation-menu-item-deletion.integration-spec.ts create mode 100644 packages/twenty-server/test/integration/metadata/suites/navigation-menu-item/failing-navigation-menu-item-update-circular-dependency.integration-spec.ts create mode 100644 packages/twenty-server/test/integration/metadata/suites/navigation-menu-item/failing-navigation-menu-item-update.integration-spec.ts create mode 100644 packages/twenty-server/test/integration/metadata/suites/navigation-menu-item/successful-navigation-menu-item-creation.integration-spec.ts create mode 100644 packages/twenty-server/test/integration/metadata/suites/navigation-menu-item/successful-navigation-menu-item-deletion.integration-spec.ts create mode 100644 packages/twenty-server/test/integration/metadata/suites/navigation-menu-item/successful-navigation-menu-item-update.integration-spec.ts create mode 100644 packages/twenty-server/test/integration/metadata/suites/navigation-menu-item/utils/create-navigation-menu-item-query-factory.util.ts create mode 100644 packages/twenty-server/test/integration/metadata/suites/navigation-menu-item/utils/create-navigation-menu-item.util.ts create mode 100644 packages/twenty-server/test/integration/metadata/suites/navigation-menu-item/utils/delete-navigation-menu-item-query-factory.util.ts create mode 100644 packages/twenty-server/test/integration/metadata/suites/navigation-menu-item/utils/delete-navigation-menu-item.util.ts create mode 100644 packages/twenty-server/test/integration/metadata/suites/navigation-menu-item/utils/find-navigation-menu-items-query-factory.util.ts create mode 100644 packages/twenty-server/test/integration/metadata/suites/navigation-menu-item/utils/find-navigation-menu-items.util.ts create mode 100644 packages/twenty-server/test/integration/metadata/suites/navigation-menu-item/utils/update-navigation-menu-item-query-factory.util.ts create mode 100644 packages/twenty-server/test/integration/metadata/suites/navigation-menu-item/utils/update-navigation-menu-item.util.ts diff --git a/packages/twenty-front/src/generated-metadata/graphql.ts b/packages/twenty-front/src/generated-metadata/graphql.ts index 112b1f25559..e681ec07735 100644 --- a/packages/twenty-front/src/generated-metadata/graphql.ts +++ b/packages/twenty-front/src/generated-metadata/graphql.ts @@ -948,6 +948,16 @@ export type CreateFrontComponentInput = { name: Scalars['String']; }; +export type CreateNavigationMenuItemInput = { + folderId?: InputMaybe; + name?: InputMaybe; + position?: InputMaybe; + targetObjectMetadataId?: InputMaybe; + targetRecordId?: InputMaybe; + userWorkspaceId?: InputMaybe; + viewId?: InputMaybe; +}; + export type CreateObjectInput = { description?: InputMaybe; icon?: InputMaybe; @@ -2000,6 +2010,7 @@ export type Mutation = { createFrontComponent: FrontComponent; createManyCoreViewFields: Array; createManyCoreViewGroups: Array; + createNavigationMenuItem: NavigationMenuItem; createOIDCIdentityProvider: SetupSsoOutput; createObjectEvent: Analytics; createOneAgent: Agent; @@ -2039,6 +2050,7 @@ export type Mutation = { deleteFile: File; deleteFrontComponent: FrontComponent; deleteJobs: DeleteJobsResponse; + deleteNavigationMenuItem: NavigationMenuItem; deleteOneAgent: Agent; deleteOneCronTrigger: CronTrigger; deleteOneDatabaseEventTrigger: DatabaseEventTrigger; @@ -2127,6 +2139,7 @@ export type Mutation = { updateDatabaseConfigVariable: Scalars['Boolean']; updateFrontComponent: FrontComponent; updateLabPublicFeatureFlag: FeatureFlagDto; + updateNavigationMenuItem: NavigationMenuItem; updateOneAgent: Agent; updateOneApplicationVariable: Scalars['Boolean']; updateOneCronTrigger: CronTrigger; @@ -2309,6 +2322,11 @@ export type MutationCreateManyCoreViewGroupsArgs = { }; +export type MutationCreateNavigationMenuItemArgs = { + input: CreateNavigationMenuItemInput; +}; + + export type MutationCreateOidcIdentityProviderArgs = { input: SetupOidcSsoInput; }; @@ -2504,6 +2522,11 @@ export type MutationDeleteJobsArgs = { }; +export type MutationDeleteNavigationMenuItemArgs = { + id: Scalars['UUID']; +}; + + export type MutationDeleteOneAgentArgs = { input: AgentIdInput; }; @@ -2932,6 +2955,11 @@ export type MutationUpdateLabPublicFeatureFlagArgs = { }; +export type MutationUpdateNavigationMenuItemArgs = { + input: UpdateOneNavigationMenuItemInput; +}; + + export type MutationUpdateOneAgentArgs = { input: UpdateAgentInput; }; @@ -3158,6 +3186,21 @@ export type NativeModelCapabilities = { webSearch?: Maybe; }; +export type NavigationMenuItem = { + __typename?: 'NavigationMenuItem'; + applicationId?: Maybe; + createdAt: Scalars['DateTime']; + folderId?: Maybe; + id: Scalars['UUID']; + name?: Maybe; + position: Scalars['Float']; + targetObjectMetadataId?: Maybe; + targetRecordId?: Maybe; + updatedAt: Scalars['DateTime']; + userWorkspaceId?: Maybe; + viewId?: Maybe; +}; + export type NotesConfiguration = { __typename?: 'NotesConfiguration'; configurationType: WidgetConfigurationType; @@ -3621,6 +3664,8 @@ export type Query = { indexMetadatas: IndexConnection; lineChartData: LineChartDataOutput; listPlans: Array; + navigationMenuItem?: Maybe; + navigationMenuItems: Array; object: Object; objects: ObjectConnection; pieChartData: PieChartDataOutput; @@ -3953,6 +3998,11 @@ export type QueryLineChartDataArgs = { }; +export type QueryNavigationMenuItemArgs = { + id: Scalars['UUID']; +}; + + export type QueryObjectArgs = { id: Scalars['UUID']; }; @@ -4741,6 +4791,12 @@ export type UpdateLabPublicFeatureFlagInput = { value: Scalars['Boolean']; }; +export type UpdateNavigationMenuItemInput = { + folderId?: InputMaybe; + name?: InputMaybe; + position?: InputMaybe; +}; + export type UpdateObjectPayload = { description?: InputMaybe; icon?: InputMaybe; @@ -4762,6 +4818,13 @@ export type UpdateOneFieldMetadataInput = { update: UpdateFieldInput; }; +export type UpdateOneNavigationMenuItemInput = { + /** The id of the record to update */ + id: Scalars['UUID']; + /** The record to update */ + update: UpdateNavigationMenuItemInput; +}; + export type UpdateOneObjectInput = { /** The id of the object to update */ id: Scalars['UUID']; diff --git a/packages/twenty-front/src/generated/graphql.ts b/packages/twenty-front/src/generated/graphql.ts index 84792815208..a70013faaf5 100644 --- a/packages/twenty-front/src/generated/graphql.ts +++ b/packages/twenty-front/src/generated/graphql.ts @@ -3068,6 +3068,21 @@ export type NativeModelCapabilities = { webSearch?: Maybe; }; +export type NavigationMenuItem = { + __typename?: 'NavigationMenuItem'; + applicationId?: Maybe; + createdAt: Scalars['DateTime']; + folderId?: Maybe; + id: Scalars['UUID']; + name?: Maybe; + position: Scalars['Float']; + targetObjectMetadataId?: Maybe; + targetRecordId?: Maybe; + updatedAt: Scalars['DateTime']; + userWorkspaceId?: Maybe; + viewId?: Maybe; +}; + export type NotesConfiguration = { __typename?: 'NotesConfiguration'; configurationType: WidgetConfigurationType; diff --git a/packages/twenty-server/src/database/typeorm/core/migrations/common/1768807499350-addNavigationMenuItemEntity.ts b/packages/twenty-server/src/database/typeorm/core/migrations/common/1768807499350-addNavigationMenuItemEntity.ts new file mode 100644 index 00000000000..7afd7a3d140 --- /dev/null +++ b/packages/twenty-server/src/database/typeorm/core/migrations/common/1768807499350-addNavigationMenuItemEntity.ts @@ -0,0 +1,77 @@ +import { type MigrationInterface, type QueryRunner } from 'typeorm'; + +export class AddNavigationMenuItemEntity1768807499350 + implements MigrationInterface +{ + name = 'AddNavigationMenuItemEntity1768807499350'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `CREATE TABLE "core"."navigationMenuItem" ("workspaceId" uuid NOT NULL, "universalIdentifier" uuid NOT NULL, "applicationId" uuid NOT NULL, "id" uuid NOT NULL DEFAULT uuid_generate_v4(), "userWorkspaceId" uuid, "targetRecordId" uuid, "targetObjectMetadataId" uuid, "viewId" uuid, "name" text, "folderId" uuid, "position" integer NOT NULL, "createdAt" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(), "updatedAt" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(), CONSTRAINT "CHK_navigation_menu_item_target_fields" CHECK (("targetRecordId" IS NULL AND "targetObjectMetadataId" IS NULL) OR ("targetRecordId" IS NOT NULL AND "targetObjectMetadataId" IS NOT NULL)), CONSTRAINT "PK_d8689756f55769faea7dc0ae968" PRIMARY KEY ("id"))`, + ); + await queryRunner.query( + `CREATE UNIQUE INDEX "IDX_4d8beaebdfcd5d82ebe6e8b58f" ON "core"."navigationMenuItem" ("workspaceId", "universalIdentifier") `, + ); + await queryRunner.query( + `CREATE INDEX "IDX_NAVIGATION_MENU_ITEM_VIEW_ID_WORKSPACE_ID" ON "core"."navigationMenuItem" ("viewId", "workspaceId") `, + ); + await queryRunner.query( + `CREATE INDEX "IDX_NAVIGATION_MENU_ITEM_FOLDER_ID_WORKSPACE_ID" ON "core"."navigationMenuItem" ("folderId", "workspaceId") `, + ); + await queryRunner.query( + `CREATE INDEX "IDX_NAVIGATION_MENU_ITEM_TARGET_RECORD_OBJ_METADATA_WS_ID" ON "core"."navigationMenuItem" ("targetRecordId", "targetObjectMetadataId", "workspaceId") `, + ); + await queryRunner.query( + `CREATE INDEX "IDX_NAVIGATION_MENU_ITEM_USER_WORKSPACE_ID_WORKSPACE_ID" ON "core"."navigationMenuItem" ("userWorkspaceId", "workspaceId") `, + ); + await queryRunner.query( + `ALTER TABLE "core"."navigationMenuItem" ADD CONSTRAINT "FK_03c63a0b00ddc3ade21ed0b1a80" FOREIGN KEY ("workspaceId") REFERENCES "core"."workspace"("id") ON DELETE CASCADE ON UPDATE NO ACTION`, + ); + await queryRunner.query( + `ALTER TABLE "core"."navigationMenuItem" ADD CONSTRAINT "FK_6fd84a774fe4ea4daa9aeeee5ed" FOREIGN KEY ("applicationId") REFERENCES "core"."application"("id") ON DELETE CASCADE ON UPDATE NO ACTION`, + ); + await queryRunner.query( + `ALTER TABLE "core"."navigationMenuItem" ADD CONSTRAINT "FK_b2e02050a5faa58ed3e08624659" FOREIGN KEY ("userWorkspaceId") REFERENCES "core"."userWorkspace"("id") ON DELETE CASCADE ON UPDATE NO ACTION`, + ); + await queryRunner.query( + `ALTER TABLE "core"."navigationMenuItem" ADD CONSTRAINT "FK_62d47d14b50b67a03f832481de7" FOREIGN KEY ("targetObjectMetadataId") REFERENCES "core"."objectMetadata"("id") ON DELETE CASCADE ON UPDATE NO ACTION`, + ); + await queryRunner.query( + `ALTER TABLE "core"."navigationMenuItem" ADD CONSTRAINT "FK_175fc64110c36793eaf9765d1c6" FOREIGN KEY ("folderId") REFERENCES "core"."navigationMenuItem"("id") ON DELETE CASCADE ON UPDATE NO ACTION DEFERRABLE INITIALLY DEFERRED`, + ); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE "core"."navigationMenuItem" DROP CONSTRAINT "FK_175fc64110c36793eaf9765d1c6"`, + ); + await queryRunner.query( + `ALTER TABLE "core"."navigationMenuItem" DROP CONSTRAINT "FK_62d47d14b50b67a03f832481de7"`, + ); + await queryRunner.query( + `ALTER TABLE "core"."navigationMenuItem" DROP CONSTRAINT "FK_b2e02050a5faa58ed3e08624659"`, + ); + await queryRunner.query( + `ALTER TABLE "core"."navigationMenuItem" DROP CONSTRAINT "FK_6fd84a774fe4ea4daa9aeeee5ed"`, + ); + await queryRunner.query( + `ALTER TABLE "core"."navigationMenuItem" DROP CONSTRAINT "FK_03c63a0b00ddc3ade21ed0b1a80"`, + ); + await queryRunner.query( + `DROP INDEX "core"."IDX_NAVIGATION_MENU_ITEM_USER_WORKSPACE_ID_WORKSPACE_ID"`, + ); + await queryRunner.query( + `DROP INDEX "core"."IDX_NAVIGATION_MENU_ITEM_TARGET_RECORD_OBJ_METADATA_WS_ID"`, + ); + await queryRunner.query( + `DROP INDEX "core"."IDX_NAVIGATION_MENU_ITEM_FOLDER_ID_WORKSPACE_ID"`, + ); + await queryRunner.query( + `DROP INDEX "core"."IDX_NAVIGATION_MENU_ITEM_VIEW_ID_WORKSPACE_ID"`, + ); + await queryRunner.query( + `DROP INDEX "core"."IDX_4d8beaebdfcd5d82ebe6e8b58f"`, + ); + await queryRunner.query(`DROP TABLE "core"."navigationMenuItem"`); + } +} diff --git a/packages/twenty-server/src/engine/metadata-modules/flat-entity/constant/all-flat-entity-properties-to-compare-and-stringify.constant.ts b/packages/twenty-server/src/engine/metadata-modules/flat-entity/constant/all-flat-entity-properties-to-compare-and-stringify.constant.ts index 6975cbd0ac1..1ce439bece7 100644 --- a/packages/twenty-server/src/engine/metadata-modules/flat-entity/constant/all-flat-entity-properties-to-compare-and-stringify.constant.ts +++ b/packages/twenty-server/src/engine/metadata-modules/flat-entity/constant/all-flat-entity-properties-to-compare-and-stringify.constant.ts @@ -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, diff --git a/packages/twenty-server/src/engine/metadata-modules/flat-entity/constant/all-metadata-entity-by-metadata-name.constant.ts b/packages/twenty-server/src/engine/metadata-modules/flat-entity/constant/all-metadata-entity-by-metadata-name.constant.ts index 12462cc1c4a..9f92b394799 100644 --- a/packages/twenty-server/src/engine/metadata-modules/flat-entity/constant/all-metadata-entity-by-metadata-name.constant.ts +++ b/packages/twenty-server/src/engine/metadata-modules/flat-entity/constant/all-metadata-entity-by-metadata-name.constant.ts @@ -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>; diff --git a/packages/twenty-server/src/engine/metadata-modules/flat-entity/constant/all-metadata-relations.constant.ts b/packages/twenty-server/src/engine/metadata-modules/flat-entity/constant/all-metadata-relations.constant.ts index 99916d3e56f..cf5eb79456f 100644 --- a/packages/twenty-server/src/engine/metadata-modules/flat-entity/constant/all-metadata-relations.constant.ts +++ b/packages/twenty-server/src/engine/metadata-modules/flat-entity/constant/all-metadata-relations.constant.ts @@ -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: { diff --git a/packages/twenty-server/src/engine/metadata-modules/flat-entity/constant/all-metadata-required-metadata-for-validation.constant.ts b/packages/twenty-server/src/engine/metadata-modules/flat-entity/constant/all-metadata-required-metadata-for-validation.constant.ts index 3180525dd11..8e07122e704 100644 --- a/packages/twenty-server/src/engine/metadata-modules/flat-entity/constant/all-metadata-required-metadata-for-validation.constant.ts +++ b/packages/twenty-server/src/engine/metadata-modules/flat-entity/constant/all-metadata-required-metadata-for-validation.constant.ts @@ -65,6 +65,10 @@ export const ALL_METADATA_REQUIRED_METADATA_FOR_VALIDATION = { commandMenuItem: { objectMetadata: true, }, + navigationMenuItem: { + objectMetadata: true, + view: true, + }, pageLayout: { objectMetadata: true, }, diff --git a/packages/twenty-server/src/engine/metadata-modules/flat-entity/types/all-flat-entity-maps.type.ts b/packages/twenty-server/src/engine/metadata-modules/flat-entity/types/all-flat-entity-maps.type.ts index e13181413ed..1b431209e0c 100644 --- a/packages/twenty-server/src/engine/metadata-modules/flat-entity/types/all-flat-entity-maps.type.ts +++ b/packages/twenty-server/src/engine/metadata-modules/flat-entity/types/all-flat-entity-maps.type.ts @@ -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

]: FlatEntityMaps< MetadataFlatEntity

>; +} & { + flatNavigationMenuItemMaps: FlatNavigationMenuItemMaps; }; diff --git a/packages/twenty-server/src/engine/metadata-modules/flat-entity/types/all-flat-entity-types-by-metadata-name.ts b/packages/twenty-server/src/engine/metadata-modules/flat-entity/types/all-flat-entity-types-by-metadata-name.ts index bcb734ec250..3ce8da7588c 100644 --- a/packages/twenty-server/src/engine/metadata-modules/flat-entity/types/all-flat-entity-types-by-metadata-name.ts +++ b/packages/twenty-server/src/engine/metadata-modules/flat-entity/types/all-flat-entity-types-by-metadata-name.ts @@ -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; diff --git a/packages/twenty-server/src/engine/metadata-modules/flat-entity/utils/__tests__/__snapshots__/get-metadata-related-metadata-names.util.spec.ts.snap b/packages/twenty-server/src/engine/metadata-modules/flat-entity/utils/__tests__/__snapshots__/get-metadata-related-metadata-names.util.spec.ts.snap index eda7c7ff72a..3826ccdc2fa 100644 --- a/packages/twenty-server/src/engine/metadata-modules/flat-entity/utils/__tests__/__snapshots__/get-metadata-related-metadata-names.util.spec.ts.snap +++ b/packages/twenty-server/src/engine/metadata-modules/flat-entity/utils/__tests__/__snapshots__/get-metadata-related-metadata-names.util.spec.ts.snap @@ -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", diff --git a/packages/twenty-server/src/engine/metadata-modules/flat-entity/utils/__tests__/__snapshots__/sort-metadata-names-children-first.util.spec.ts.snap b/packages/twenty-server/src/engine/metadata-modules/flat-entity/utils/__tests__/__snapshots__/sort-metadata-names-children-first.util.spec.ts.snap index 82689758b53..d48a072acb1 100644 --- a/packages/twenty-server/src/engine/metadata-modules/flat-entity/utils/__tests__/__snapshots__/sort-metadata-names-children-first.util.spec.ts.snap +++ b/packages/twenty-server/src/engine/metadata-modules/flat-entity/utils/__tests__/__snapshots__/sort-metadata-names-children-first.util.spec.ts.snap @@ -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", diff --git a/packages/twenty-server/src/engine/metadata-modules/flat-navigation-menu-item/constants/flat-navigation-menu-item-editable-properties.constant.ts b/packages/twenty-server/src/engine/metadata-modules/flat-navigation-menu-item/constants/flat-navigation-menu-item-editable-properties.constant.ts new file mode 100644 index 00000000000..30182afaa03 --- /dev/null +++ b/packages/twenty-server/src/engine/metadata-modules/flat-navigation-menu-item/constants/flat-navigation-menu-item-editable-properties.constant.ts @@ -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)[]; diff --git a/packages/twenty-server/src/engine/metadata-modules/flat-navigation-menu-item/flat-navigation-menu-item.module.ts b/packages/twenty-server/src/engine/metadata-modules/flat-navigation-menu-item/flat-navigation-menu-item.module.ts new file mode 100644 index 00000000000..0c7450e14d4 --- /dev/null +++ b/packages/twenty-server/src/engine/metadata-modules/flat-navigation-menu-item/flat-navigation-menu-item.module.ts @@ -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 {} diff --git a/packages/twenty-server/src/engine/metadata-modules/flat-navigation-menu-item/services/workspace-flat-navigation-menu-item-map-cache.service.ts b/packages/twenty-server/src/engine/metadata-modules/flat-navigation-menu-item/services/workspace-flat-navigation-menu-item-map-cache.service.ts new file mode 100644 index 00000000000..4996f8b43c0 --- /dev/null +++ b/packages/twenty-server/src/engine/metadata-modules/flat-navigation-menu-item/services/workspace-flat-navigation-menu-item-map-cache.service.ts @@ -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 { + constructor( + @InjectRepository(NavigationMenuItemEntity) + private readonly navigationMenuItemRepository: Repository, + ) { + super(); + } + + async computeForCache( + workspaceId: string, + ): Promise { + 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; + } +} diff --git a/packages/twenty-server/src/engine/metadata-modules/flat-navigation-menu-item/types/flat-navigation-menu-item-maps.type.ts b/packages/twenty-server/src/engine/metadata-modules/flat-navigation-menu-item/types/flat-navigation-menu-item-maps.type.ts new file mode 100644 index 00000000000..cdd9c6116cc --- /dev/null +++ b/packages/twenty-server/src/engine/metadata-modules/flat-navigation-menu-item/types/flat-navigation-menu-item-maps.type.ts @@ -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 & { + byUserWorkspaceIdAndFolderId: Partial< + Record>> + >; + }; diff --git a/packages/twenty-server/src/engine/metadata-modules/flat-navigation-menu-item/types/flat-navigation-menu-item.type.ts b/packages/twenty-server/src/engine/metadata-modules/flat-navigation-menu-item/types/flat-navigation-menu-item.type.ts new file mode 100644 index 00000000000..3da677ed497 --- /dev/null +++ b/packages/twenty-server/src/engine/metadata-modules/flat-navigation-menu-item/types/flat-navigation-menu-item.type.ts @@ -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; diff --git a/packages/twenty-server/src/engine/metadata-modules/flat-navigation-menu-item/utils/add-flat-navigation-menu-item-to-maps-and-update-index.util.ts b/packages/twenty-server/src/engine/metadata-modules/flat-navigation-menu-item/utils/add-flat-navigation-menu-item-to-maps-and-update-index.util.ts new file mode 100644 index 00000000000..db59e2546df --- /dev/null +++ b/packages/twenty-server/src/engine/metadata-modules/flat-navigation-menu-item/utils/add-flat-navigation-menu-item-to-maps-and-update-index.util.ts @@ -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); +}; diff --git a/packages/twenty-server/src/engine/metadata-modules/flat-navigation-menu-item/utils/delete-flat-navigation-menu-item-from-maps-and-index.util.ts b/packages/twenty-server/src/engine/metadata-modules/flat-navigation-menu-item/utils/delete-flat-navigation-menu-item-from-maps-and-index.util.ts new file mode 100644 index 00000000000..54c6a104ce4 --- /dev/null +++ b/packages/twenty-server/src/engine/metadata-modules/flat-navigation-menu-item/utils/delete-flat-navigation-menu-item-from-maps-and-index.util.ts @@ -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, + }); +}; diff --git a/packages/twenty-server/src/engine/metadata-modules/flat-navigation-menu-item/utils/from-create-navigation-menu-item-input-to-flat-navigation-menu-item-to-create.util.ts b/packages/twenty-server/src/engine/metadata-modules/flat-navigation-menu-item/utils/from-create-navigation-menu-item-input-to-flat-navigation-menu-item-to-create.util.ts new file mode 100644 index 00000000000..8b3cc3ec122 --- /dev/null +++ b/packages/twenty-server/src/engine/metadata-modules/flat-navigation-menu-item/utils/from-create-navigation-menu-item-input-to-flat-navigation-menu-item-to-create.util.ts @@ -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, + }; + }; diff --git a/packages/twenty-server/src/engine/metadata-modules/flat-navigation-menu-item/utils/from-delete-navigation-menu-item-input-to-flat-navigation-menu-item-or-throw.util.ts b/packages/twenty-server/src/engine/metadata-modules/flat-navigation-menu-item/utils/from-delete-navigation-menu-item-input-to-flat-navigation-menu-item-or-throw.util.ts new file mode 100644 index 00000000000..64a238c7846 --- /dev/null +++ b/packages/twenty-server/src/engine/metadata-modules/flat-navigation-menu-item/utils/from-delete-navigation-menu-item-input-to-flat-navigation-menu-item-or-throw.util.ts @@ -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; + }; diff --git a/packages/twenty-server/src/engine/metadata-modules/flat-navigation-menu-item/utils/from-flat-navigation-menu-item-to-navigation-menu-item-dto.util.ts b/packages/twenty-server/src/engine/metadata-modules/flat-navigation-menu-item/utils/from-flat-navigation-menu-item-to-navigation-menu-item-dto.util.ts new file mode 100644 index 00000000000..35f009ca1e4 --- /dev/null +++ b/packages/twenty-server/src/engine/metadata-modules/flat-navigation-menu-item/utils/from-flat-navigation-menu-item-to-navigation-menu-item-dto.util.ts @@ -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), +}); diff --git a/packages/twenty-server/src/engine/metadata-modules/flat-navigation-menu-item/utils/from-navigation-menu-item-entity-to-flat-navigation-menu-item.util.ts b/packages/twenty-server/src/engine/metadata-modules/flat-navigation-menu-item/utils/from-navigation-menu-item-entity-to-flat-navigation-menu-item.util.ts new file mode 100644 index 00000000000..5a3f5f3a8d4 --- /dev/null +++ b/packages/twenty-server/src/engine/metadata-modules/flat-navigation-menu-item/utils/from-navigation-menu-item-entity-to-flat-navigation-menu-item.util.ts @@ -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(), + }; +}; diff --git a/packages/twenty-server/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.ts b/packages/twenty-server/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.ts new file mode 100644 index 00000000000..ceb549a9b43 --- /dev/null +++ b/packages/twenty-server/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.ts @@ -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(), + }; + }; diff --git a/packages/twenty-server/src/engine/metadata-modules/flat-navigation-menu-item/utils/remove-flat-navigation-menu-item-from-index.util.ts b/packages/twenty-server/src/engine/metadata-modules/flat-navigation-menu-item/utils/remove-flat-navigation-menu-item-from-index.util.ts new file mode 100644 index 00000000000..3f80ed0c7e5 --- /dev/null +++ b/packages/twenty-server/src/engine/metadata-modules/flat-navigation-menu-item/utils/remove-flat-navigation-menu-item-from-index.util.ts @@ -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 + ]; + } +}; diff --git a/packages/twenty-server/src/engine/metadata-modules/flat-navigation-menu-item/utils/replace-flat-navigation-menu-item-in-maps-and-update-index.util.ts b/packages/twenty-server/src/engine/metadata-modules/flat-navigation-menu-item/utils/replace-flat-navigation-menu-item-in-maps-and-update-index.util.ts new file mode 100644 index 00000000000..7b2b5a220e2 --- /dev/null +++ b/packages/twenty-server/src/engine/metadata-modules/flat-navigation-menu-item/utils/replace-flat-navigation-menu-item-in-maps-and-update-index.util.ts @@ -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; + } +}; diff --git a/packages/twenty-server/src/engine/metadata-modules/metadata-engine.module.ts b/packages/twenty-server/src/engine/metadata-modules/metadata-engine.module.ts index 2a7fd72fb79..b73f3ece38c 100644 --- a/packages/twenty-server/src/engine/metadata-modules/metadata-engine.module.ts +++ b/packages/twenty-server/src/engine/metadata-modules/metadata-engine.module.ts @@ -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, diff --git a/packages/twenty-server/src/engine/metadata-modules/navigation-menu-item/dtos/create-navigation-menu-item.input.ts b/packages/twenty-server/src/engine/metadata-modules/navigation-menu-item/dtos/create-navigation-menu-item.input.ts new file mode 100644 index 00000000000..cb6e105a43b --- /dev/null +++ b/packages/twenty-server/src/engine/metadata-modules/navigation-menu-item/dtos/create-navigation-menu-item.input.ts @@ -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; +} diff --git a/packages/twenty-server/src/engine/metadata-modules/navigation-menu-item/dtos/navigation-menu-item.dto.ts b/packages/twenty-server/src/engine/metadata-modules/navigation-menu-item/dtos/navigation-menu-item.dto.ts new file mode 100644 index 00000000000..a84d22b6eb8 --- /dev/null +++ b/packages/twenty-server/src/engine/metadata-modules/navigation-menu-item/dtos/navigation-menu-item.dto.ts @@ -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; +} diff --git a/packages/twenty-server/src/engine/metadata-modules/navigation-menu-item/dtos/update-navigation-menu-item.input.ts b/packages/twenty-server/src/engine/metadata-modules/navigation-menu-item/dtos/update-navigation-menu-item.input.ts new file mode 100644 index 00000000000..30d8ecda7fc --- /dev/null +++ b/packages/twenty-server/src/engine/metadata-modules/navigation-menu-item/dtos/update-navigation-menu-item.input.ts @@ -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; +} diff --git a/packages/twenty-server/src/engine/metadata-modules/navigation-menu-item/entities/navigation-menu-item.entity.ts b/packages/twenty-server/src/engine/metadata-modules/navigation-menu-item/entities/navigation-menu-item.entity.ts new file mode 100644 index 00000000000..aad2e448f16 --- /dev/null +++ b/packages/twenty-server/src/engine/metadata-modules/navigation-menu-item/entities/navigation-menu-item.entity.ts @@ -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 +{ + @PrimaryGeneratedColumn('uuid') + id: string; + + @ManyToOne(() => UserWorkspaceEntity, { + onDelete: 'CASCADE', + nullable: true, + }) + @JoinColumn({ name: 'userWorkspaceId' }) + userWorkspace: Relation | 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 | null; + + @Column({ nullable: true, type: 'text' }) + name: string | null; + + @ManyToOne(() => NavigationMenuItemEntity, { + onDelete: 'CASCADE', + nullable: true, + }) + @JoinColumn({ name: 'folderId' }) + folder: Relation | null; + + @Column({ nullable: true, type: 'uuid' }) + folderId: string | null; + + @Column({ nullable: false }) + position: number; + + @CreateDateColumn({ type: 'timestamptz' }) + createdAt: Date; + + @UpdateDateColumn({ type: 'timestamptz' }) + updatedAt: Date; +} diff --git a/packages/twenty-server/src/engine/metadata-modules/navigation-menu-item/interceptors/navigation-menu-item-graphql-api-exception.interceptor.ts b/packages/twenty-server/src/engine/metadata-modules/navigation-menu-item/interceptors/navigation-menu-item-graphql-api-exception.interceptor.ts new file mode 100644 index 00000000000..75f27d97b1b --- /dev/null +++ b/packages/twenty-server/src/engine/metadata-modules/navigation-menu-item/interceptors/navigation-menu-item-graphql-api-exception.interceptor.ts @@ -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 { + return next + .handle() + .pipe(catchError(navigationMenuItemGraphqlApiExceptionHandler)); + } +} diff --git a/packages/twenty-server/src/engine/metadata-modules/navigation-menu-item/navigation-menu-item.exception.ts b/packages/twenty-server/src/engine/metadata-modules/navigation-menu-item/navigation-menu-item.exception.ts new file mode 100644 index 00000000000..cdc57a4cf00 --- /dev/null +++ b/packages/twenty-server/src/engine/metadata-modules/navigation-menu-item/navigation-menu-item.exception.ts @@ -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 { + constructor( + message: string, + code: NavigationMenuItemExceptionCode, + { userFriendlyMessage }: { userFriendlyMessage?: MessageDescriptor } = {}, + ) { + super(message, code, { + userFriendlyMessage: + userFriendlyMessage ?? + getNavigationMenuItemExceptionUserFriendlyMessage(code), + }); + } +} diff --git a/packages/twenty-server/src/engine/metadata-modules/navigation-menu-item/navigation-menu-item.module.ts b/packages/twenty-server/src/engine/metadata-modules/navigation-menu-item/navigation-menu-item.module.ts new file mode 100644 index 00000000000..39e587035fc --- /dev/null +++ b/packages/twenty-server/src/engine/metadata-modules/navigation-menu-item/navigation-menu-item.module.ts @@ -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 {} diff --git a/packages/twenty-server/src/engine/metadata-modules/navigation-menu-item/navigation-menu-item.resolver.ts b/packages/twenty-server/src/engine/metadata-modules/navigation-menu-item/navigation-menu-item.resolver.ts new file mode 100644 index 00000000000..10cb848d274 --- /dev/null +++ b/packages/twenty-server/src/engine/metadata-modules/navigation-menu-item/navigation-menu-item.resolver.ts @@ -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 { + 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 { + 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 { + 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 { + 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 { + return await this.navigationMenuItemService.delete({ + id, + workspaceId: workspace.id, + authUserWorkspaceId: userWorkspaceId, + authApiKeyId: apiKey?.id, + authApplicationId: context.req.application?.id, + }); + } +} diff --git a/packages/twenty-server/src/engine/metadata-modules/navigation-menu-item/navigation-menu-item.service.ts b/packages/twenty-server/src/engine/metadata-modules/navigation-menu-item/navigation-menu-item.service.ts new file mode 100644 index 00000000000..ba400935bcb --- /dev/null +++ b/packages/twenty-server/src/engine/metadata-modules/navigation-menu-item/navigation-menu-item.service.ts @@ -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 { + const { flatNavigationMenuItemMaps } = + await this.workspaceManyOrAllFlatEntityMapsCacheService.getOrRecomputeManyOrAllFlatEntityMaps( + { + workspaceId, + flatMapsKeys: ['flatNavigationMenuItemMaps'], + }, + ); + + return Object.values(flatNavigationMenuItemMaps.byId) + .filter( + (item): item is NonNullable => + 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 { + 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 { + 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 { + 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 { + 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 { + 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, + ); + } +} diff --git a/packages/twenty-server/src/engine/metadata-modules/navigation-menu-item/services/navigation-menu-item-access.service.ts b/packages/twenty-server/src/engine/metadata-modules/navigation-menu-item/services/navigation-menu-item-access.service.ts new file mode 100644 index 00000000000..13677349dd7 --- /dev/null +++ b/packages/twenty-server/src/engine/metadata-modules/navigation-menu-item/services/navigation-menu-item-access.service.ts @@ -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 { + 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 { + 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 { + 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; + } +} diff --git a/packages/twenty-server/src/engine/metadata-modules/navigation-menu-item/utils/navigation-menu-item-graphql-api-exception-handler.util.ts b/packages/twenty-server/src/engine/metadata-modules/navigation-menu-item/utils/navigation-menu-item-graphql-api-exception-handler.util.ts new file mode 100644 index 00000000000..ae2364b4c6a --- /dev/null +++ b/packages/twenty-server/src/engine/metadata-modules/navigation-menu-item/utils/navigation-menu-item-graphql-api-exception-handler.util.ts @@ -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; +}; diff --git a/packages/twenty-server/src/engine/workspace-cache/types/workspace-cache-key.type.ts b/packages/twenty-server/src/engine/workspace-cache/types/workspace-cache-key.type.ts index b4d8b8c6bcb..a505f03caca 100644 --- a/packages/twenty-server/src/engine/workspace-cache/types/workspace-cache-key.type.ts +++ b/packages/twenty-server/src/engine/workspace-cache/types/workspace-cache-key.type.ts @@ -36,6 +36,7 @@ export const WORKSPACE_CACHE_KEYS_V2 = { flatAgentMaps: 'flat-maps:agent', flatSkillMaps: 'flat-maps:skill', flatCommandMenuItemMaps: 'flat-maps:command-menu-item', + flatNavigationMenuItemMaps: 'flat-maps:navigation-menu-item', flatRoleTargetByAgentIdMaps: 'flat-maps:flatRoleTargetByAgentId', flatPageLayoutMaps: 'flat-maps:page-layout', flatPageLayoutWidgetMaps: 'flat-maps:page-layout-widget', diff --git a/packages/twenty-server/src/engine/workspace-manager/workspace-migration/services/workspace-migration-build-orchestrator.service.ts b/packages/twenty-server/src/engine/workspace-manager/workspace-migration/services/workspace-migration-build-orchestrator.service.ts index d85dcb17bad..27bce3634d1 100644 --- a/packages/twenty-server/src/engine/workspace-manager/workspace-migration/services/workspace-migration-build-orchestrator.service.ts +++ b/packages/twenty-server/src/engine/workspace-manager/workspace-migration/services/workspace-migration-build-orchestrator.service.ts @@ -21,6 +21,7 @@ import { WorkspaceMigrationDatabaseEventTriggerActionsBuilderService } from 'src import { WorkspaceMigrationFieldActionsBuilderService } from 'src/engine/workspace-manager/workspace-migration/workspace-migration-builder/builders/field/workspace-migration-field-actions-builder.service'; import { WorkspaceMigrationFrontComponentActionsBuilderService } from 'src/engine/workspace-manager/workspace-migration/workspace-migration-builder/builders/front-component/workspace-migration-front-component-actions-builder.service'; import { WorkspaceMigrationIndexActionsBuilderService } from 'src/engine/workspace-manager/workspace-migration/workspace-migration-builder/builders/index/workspace-migration-index-actions-builder.service'; +import { WorkspaceMigrationNavigationMenuItemActionsBuilderService } from 'src/engine/workspace-manager/workspace-migration/workspace-migration-builder/builders/navigation-menu-item/workspace-migration-navigation-menu-item-actions-builder.service'; import { WorkspaceMigrationObjectActionsBuilderService } from 'src/engine/workspace-manager/workspace-migration/workspace-migration-builder/builders/object/workspace-migration-object-actions-builder.service'; import { WorkspaceMigrationPageLayoutTabActionsBuilderService } from 'src/engine/workspace-manager/workspace-migration/workspace-migration-builder/builders/page-layout-tab/workspace-migration-page-layout-tab-actions-builder.service'; import { WorkspaceMigrationPageLayoutWidgetActionsBuilderService } from 'src/engine/workspace-manager/workspace-migration/workspace-migration-builder/builders/page-layout-widget/workspace-migration-page-layout-widget-actions-builder.service'; @@ -58,6 +59,7 @@ export class WorkspaceMigrationBuildOrchestratorService { private readonly workspaceMigrationAgentActionsBuilderService: WorkspaceMigrationAgentActionsBuilderService, private readonly workspaceMigrationSkillActionsBuilderService: WorkspaceMigrationSkillActionsBuilderService, private readonly workspaceMigrationCommandMenuItemActionsBuilderService: WorkspaceMigrationCommandMenuItemActionsBuilderService, + private readonly workspaceMigrationNavigationMenuItemActionsBuilderService: WorkspaceMigrationNavigationMenuItemActionsBuilderService, private readonly workspaceMigrationPageLayoutActionsBuilderService: WorkspaceMigrationPageLayoutActionsBuilderService, private readonly workspaceMigrationPageLayoutWidgetActionsBuilderService: WorkspaceMigrationPageLayoutWidgetActionsBuilderService, private readonly workspaceMigrationPageLayoutTabActionsBuilderService: WorkspaceMigrationPageLayoutTabActionsBuilderService, @@ -161,6 +163,7 @@ export class WorkspaceMigrationBuildOrchestratorService { flatAgentMaps, flatSkillMaps, flatCommandMenuItemMaps, + flatNavigationMenuItemMaps, flatPageLayoutMaps, flatPageLayoutWidgetMaps, flatPageLayoutTabMaps, @@ -859,6 +862,46 @@ export class WorkspaceMigrationBuildOrchestratorService { } } + if (isDefined(flatNavigationMenuItemMaps)) { + const { + from: fromFlatNavigationMenuItemMaps, + to: toFlatNavigationMenuItemMaps, + } = flatNavigationMenuItemMaps; + + const navigationMenuItemResult = + await this.workspaceMigrationNavigationMenuItemActionsBuilderService.validateAndBuild( + { + additionalCacheDataMaps, + from: fromFlatNavigationMenuItemMaps, + to: toFlatNavigationMenuItemMaps, + buildOptions, + dependencyOptimisticFlatEntityMaps: { + flatObjectMetadataMaps: + optimisticAllFlatEntityMaps.flatObjectMetadataMaps, + flatViewMaps: optimisticAllFlatEntityMaps.flatViewMaps, + }, + workspaceId, + }, + ); + + this.mergeFlatEntityMapsAndRelatedFlatEntityMapsInAllFlatEntityMapsThroughMutation( + { + allFlatEntityMaps: optimisticAllFlatEntityMaps, + flatEntityMapsAndRelatedFlatEntityMaps: + navigationMenuItemResult.optimisticFlatEntityMapsAndRelatedFlatEntityMaps, + }, + ); + + if (navigationMenuItemResult.status === 'fail') { + orchestratorFailureReport.navigationMenuItem.push( + ...navigationMenuItemResult.errors, + ); + } else { + orchestratorActionsReport.navigationMenuItem = + navigationMenuItemResult.actions; + } + } + if (isDefined(flatPageLayoutMaps)) { const { from: fromFlatPageLayoutMaps, to: toFlatPageLayoutMaps } = flatPageLayoutMaps; @@ -1118,6 +1161,12 @@ export class WorkspaceMigrationBuildOrchestratorService { ...aggregatedOrchestratorActionsReport.commandMenuItem.update, /// + // Navigation Menu Items + ...aggregatedOrchestratorActionsReport.navigationMenuItem.delete, + ...aggregatedOrchestratorActionsReport.navigationMenuItem.create, + ...aggregatedOrchestratorActionsReport.navigationMenuItem.update, + /// + // Page layouts ...aggregatedOrchestratorActionsReport.pageLayout.delete, ...aggregatedOrchestratorActionsReport.pageLayout.create, diff --git a/packages/twenty-server/src/engine/workspace-manager/workspace-migration/workspace-migration-builder/builders/navigation-menu-item/types/workspace-migration-navigation-menu-item-action.type.ts b/packages/twenty-server/src/engine/workspace-manager/workspace-migration/workspace-migration-builder/builders/navigation-menu-item/types/workspace-migration-navigation-menu-item-action.type.ts new file mode 100644 index 00000000000..94b49c51e55 --- /dev/null +++ b/packages/twenty-server/src/engine/workspace-manager/workspace-migration/workspace-migration-builder/builders/navigation-menu-item/types/workspace-migration-navigation-menu-item-action.type.ts @@ -0,0 +1,12 @@ +import { type BaseCreateWorkspaceMigrationAction } from 'src/engine/workspace-manager/workspace-migration/workspace-migration-builder/types/base-create-workspace-migration-action.type'; +import { type BaseDeleteWorkspaceMigrationAction } from 'src/engine/workspace-manager/workspace-migration/workspace-migration-builder/types/base-delete-workspace-migration-action.type'; +import { type BaseUpdateWorkspaceMigrationAction } from 'src/engine/workspace-manager/workspace-migration/workspace-migration-builder/types/base-update-workspace-migration-action.type'; + +export type CreateNavigationMenuItemAction = + BaseCreateWorkspaceMigrationAction<'navigationMenuItem'>; + +export type UpdateNavigationMenuItemAction = + BaseUpdateWorkspaceMigrationAction<'navigationMenuItem'>; + +export type DeleteNavigationMenuItemAction = + BaseDeleteWorkspaceMigrationAction<'navigationMenuItem'>; diff --git a/packages/twenty-server/src/engine/workspace-manager/workspace-migration/workspace-migration-builder/builders/navigation-menu-item/workspace-migration-navigation-menu-item-actions-builder.service.ts b/packages/twenty-server/src/engine/workspace-manager/workspace-migration/workspace-migration-builder/builders/navigation-menu-item/workspace-migration-navigation-menu-item-actions-builder.service.ts new file mode 100644 index 00000000000..90677163408 --- /dev/null +++ b/packages/twenty-server/src/engine/workspace-manager/workspace-migration/workspace-migration-builder/builders/navigation-menu-item/workspace-migration-navigation-menu-item-actions-builder.service.ts @@ -0,0 +1,117 @@ +import { Injectable } from '@nestjs/common'; + +import { ALL_METADATA_NAME } from 'twenty-shared/metadata'; + +import { UpdateNavigationMenuItemAction } from 'src/engine/workspace-manager/workspace-migration/workspace-migration-builder/builders/navigation-menu-item/types/workspace-migration-navigation-menu-item-action.type'; +import { WorkspaceEntityMigrationBuilderService } from 'src/engine/workspace-manager/workspace-migration/workspace-migration-builder/services/workspace-entity-migration-builder.service'; +import { FlatEntityUpdateValidationArgs } from 'src/engine/workspace-manager/workspace-migration/workspace-migration-builder/types/flat-entity-update-validation-args.type'; +import { FlatEntityValidationArgs } from 'src/engine/workspace-manager/workspace-migration/workspace-migration-builder/types/flat-entity-validation-args.type'; +import { FlatEntityValidationReturnType } from 'src/engine/workspace-manager/workspace-migration/workspace-migration-builder/types/flat-entity-validation-result.type'; +import { FlatNavigationMenuItemValidatorService } from 'src/engine/workspace-manager/workspace-migration/workspace-migration-builder/validators/services/flat-navigation-menu-item-validator.service'; + +@Injectable() +export class WorkspaceMigrationNavigationMenuItemActionsBuilderService extends WorkspaceEntityMigrationBuilderService< + typeof ALL_METADATA_NAME.navigationMenuItem +> { + constructor( + private readonly flatNavigationMenuItemValidatorService: FlatNavigationMenuItemValidatorService, + ) { + super(ALL_METADATA_NAME.navigationMenuItem); + } + + protected validateFlatEntityCreation( + args: FlatEntityValidationArgs, + ): FlatEntityValidationReturnType< + typeof ALL_METADATA_NAME.navigationMenuItem, + 'create' + > { + const validationResult = + this.flatNavigationMenuItemValidatorService.validateFlatNavigationMenuItemCreation( + args, + ); + + if (validationResult.errors.length > 0) { + return { + status: 'fail', + ...validationResult, + }; + } + + const { flatEntityToValidate: flatNavigationMenuItemToValidate } = args; + + return { + status: 'success', + action: { + type: 'create', + metadataName: 'navigationMenuItem', + flatEntity: flatNavigationMenuItemToValidate, + }, + }; + } + + protected validateFlatEntityDeletion( + args: FlatEntityValidationArgs, + ): FlatEntityValidationReturnType< + typeof ALL_METADATA_NAME.navigationMenuItem, + 'delete' + > { + const validationResult = + this.flatNavigationMenuItemValidatorService.validateFlatNavigationMenuItemDeletion( + args, + ); + + if (validationResult.errors.length > 0) { + return { + status: 'fail', + ...validationResult, + }; + } + + const { flatEntityToValidate: flatNavigationMenuItemToValidate } = args; + + return { + status: 'success', + action: { + type: 'delete', + metadataName: 'navigationMenuItem', + universalIdentifier: + flatNavigationMenuItemToValidate.universalIdentifier, + }, + }; + } + + protected validateFlatEntityUpdate( + args: FlatEntityUpdateValidationArgs< + typeof ALL_METADATA_NAME.navigationMenuItem + >, + ): FlatEntityValidationReturnType< + typeof ALL_METADATA_NAME.navigationMenuItem, + 'update' + > { + const validationResult = + this.flatNavigationMenuItemValidatorService.validateFlatNavigationMenuItemUpdate( + args, + ); + + if (validationResult.errors.length > 0) { + return { + status: 'fail', + ...validationResult, + }; + } + + const { flatEntityId, flatEntityUpdates } = args; + + const updateNavigationMenuItemAction: UpdateNavigationMenuItemAction = { + type: 'update', + metadataName: 'navigationMenuItem', + entityId: flatEntityId, + updates: flatEntityUpdates, + }; + + return { + status: 'success', + action: updateNavigationMenuItemAction, + }; + } +} diff --git a/packages/twenty-server/src/engine/workspace-manager/workspace-migration/workspace-migration-builder/validators/services/flat-navigation-menu-item-validator.service.ts b/packages/twenty-server/src/engine/workspace-manager/workspace-migration/workspace-migration-builder/validators/services/flat-navigation-menu-item-validator.service.ts new file mode 100644 index 00000000000..96dc2329bc7 --- /dev/null +++ b/packages/twenty-server/src/engine/workspace-manager/workspace-migration/workspace-migration-builder/validators/services/flat-navigation-menu-item-validator.service.ts @@ -0,0 +1,382 @@ +import { Injectable } from '@nestjs/common'; + +import { msg, t } from '@lingui/core/macro'; +import { ALL_METADATA_NAME } from 'twenty-shared/metadata'; +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 { NavigationMenuItemExceptionCode } from 'src/engine/metadata-modules/navigation-menu-item/navigation-menu-item.exception'; +import { findFlatEntityPropertyUpdate } from 'src/engine/workspace-manager/workspace-migration/utils/find-flat-entity-property-update.util'; +import { validateFlatEntityCircularDependency } from 'src/engine/workspace-manager/workspace-migration/utils/validate-flat-entity-circular-dependency.util'; +import { + type FailedFlatEntityValidation, + type FlatEntityValidationError, +} from 'src/engine/workspace-manager/workspace-migration/workspace-migration-builder/builders/types/failed-flat-entity-validation.type'; +import { getEmptyFlatEntityValidationError } from 'src/engine/workspace-manager/workspace-migration/workspace-migration-builder/builders/utils/get-flat-entity-validation-error.util'; +import { type FlatEntityUpdateValidationArgs } from 'src/engine/workspace-manager/workspace-migration/workspace-migration-builder/types/flat-entity-update-validation-args.type'; +import { type FlatEntityValidationArgs } from 'src/engine/workspace-manager/workspace-migration/workspace-migration-builder/types/flat-entity-validation-args.type'; +import { fromFlatEntityPropertiesUpdatesToPartialFlatEntity } from 'src/engine/workspace-manager/workspace-migration/workspace-migration-runner/utils/from-flat-entity-properties-updates-to-partial-flat-entity'; + +const NAVIGATION_MENU_ITEM_MAX_DEPTH = 2; + +@Injectable() +export class FlatNavigationMenuItemValidatorService { + private validateNavigationMenuItemType({ + hasTargetRecordId, + hasTargetObjectMetadataId, + hasViewId, + name, + isUpdate = false, + }: { + hasTargetRecordId: boolean; + hasTargetObjectMetadataId: boolean; + hasViewId: boolean; + name: string | null | undefined; + isUpdate?: boolean; + }): FlatEntityValidationError[] { + const errors: FlatEntityValidationError[] = + []; + + if (hasTargetObjectMetadataId && !hasTargetRecordId) { + errors.push({ + code: NavigationMenuItemExceptionCode.INVALID_NAVIGATION_MENU_ITEM_INPUT, + message: t`targetRecordId is required when targetObjectMetadataId is provided`, + userFriendlyMessage: msg`targetRecordId is required when targetObjectMetadataId is provided`, + }); + } + + if (hasTargetRecordId && !hasTargetObjectMetadataId) { + errors.push({ + code: NavigationMenuItemExceptionCode.INVALID_NAVIGATION_MENU_ITEM_INPUT, + message: t`targetObjectMetadataId is required when targetRecordId is provided`, + userFriendlyMessage: msg`targetObjectMetadataId is required when targetRecordId is provided`, + }); + } + + if (hasViewId && (hasTargetRecordId || hasTargetObjectMetadataId)) { + errors.push({ + code: NavigationMenuItemExceptionCode.INVALID_NAVIGATION_MENU_ITEM_INPUT, + message: t`viewId cannot be provided together with targetRecordId or targetObjectMetadataId`, + userFriendlyMessage: msg`viewId cannot be provided together with targetRecordId or targetObjectMetadataId`, + }); + } + + const isFolder = + !hasTargetRecordId && !hasTargetObjectMetadataId && !hasViewId; + const isViewLink = hasViewId; + const isRecordLink = hasTargetRecordId && hasTargetObjectMetadataId; + const typeCount = + (isFolder ? 1 : 0) + (isViewLink ? 1 : 0) + (isRecordLink ? 1 : 0); + + if (typeCount === 0) { + errors.push({ + code: NavigationMenuItemExceptionCode.INVALID_NAVIGATION_MENU_ITEM_INPUT, + message: t`Navigation menu item must be either a folder (with name), a view link (with viewId), or a record link (with targetRecordId and targetObjectMetadataId)`, + userFriendlyMessage: msg`Navigation menu item must be either a folder (with name), a view link (with viewId), or a record link (with targetRecordId and targetObjectMetadataId)`, + }); + } + + if (typeCount > 1) { + errors.push({ + code: NavigationMenuItemExceptionCode.INVALID_NAVIGATION_MENU_ITEM_INPUT, + message: t`Navigation menu item cannot be multiple types simultaneously`, + userFriendlyMessage: msg`Navigation menu item cannot be multiple types simultaneously`, + }); + } + + if (isFolder && (!isDefined(name) || name.trim() === '')) { + errors.push({ + code: NavigationMenuItemExceptionCode.INVALID_NAVIGATION_MENU_ITEM_INPUT, + message: isUpdate + ? t`Folder name is required and cannot be empty` + : t`Folder name is required when creating a folder`, + userFriendlyMessage: isUpdate + ? msg`Folder name is required and cannot be empty` + : msg`Folder name is required when creating a folder`, + }); + } + + return errors; + } + + private getCircularDependencyValidationErrors({ + navigationMenuItemId, + folderId, + flatNavigationMenuItemMaps, + }: { + navigationMenuItemId: string; + folderId: string; + flatNavigationMenuItemMaps: FlatNavigationMenuItemMaps; + }): FlatEntityValidationError[] { + const circularDependencyResult = validateFlatEntityCircularDependency({ + flatEntityId: navigationMenuItemId, + flatEntityParentId: folderId, + maxDepth: NAVIGATION_MENU_ITEM_MAX_DEPTH, + parentIdKey: 'folderId', + flatEntityMaps: flatNavigationMenuItemMaps, + }); + + if (circularDependencyResult.status === 'success') { + return []; + } + + switch (circularDependencyResult.reason) { + case 'self_reference': + return [ + { + code: NavigationMenuItemExceptionCode.CIRCULAR_DEPENDENCY, + message: t`Navigation menu item cannot be its own parent`, + userFriendlyMessage: msg`Navigation menu item cannot be its own parent`, + }, + ]; + case 'circular_dependency': + return [ + { + code: NavigationMenuItemExceptionCode.CIRCULAR_DEPENDENCY, + message: t`Circular dependency detected in navigation menu item hierarchy`, + userFriendlyMessage: msg`Circular dependency detected in navigation menu item hierarchy`, + }, + ]; + case 'max_depth_exceeded': + return [ + { + code: NavigationMenuItemExceptionCode.MAX_DEPTH_EXCEEDED, + message: t`Navigation menu item hierarchy exceeds maximum depth of ${NAVIGATION_MENU_ITEM_MAX_DEPTH}`, + userFriendlyMessage: msg`Navigation menu item hierarchy exceeds maximum depth of ${NAVIGATION_MENU_ITEM_MAX_DEPTH}`, + }, + ]; + } + } + + public validateFlatNavigationMenuItemCreation({ + flatEntityToValidate: flatNavigationMenuItem, + optimisticFlatEntityMapsAndRelatedFlatEntityMaps: { + flatNavigationMenuItemMaps: optimisticFlatNavigationMenuItemMaps, + }, + remainingFlatEntityMapsToValidate, + }: FlatEntityValidationArgs< + typeof ALL_METADATA_NAME.navigationMenuItem + >): FailedFlatEntityValidation<'navigationMenuItem', 'create'> { + const validationResult = getEmptyFlatEntityValidationError({ + flatEntityMinimalInformation: { + id: flatNavigationMenuItem.id, + universalIdentifier: flatNavigationMenuItem.universalIdentifier, + }, + metadataName: 'navigationMenuItem', + type: 'create', + }); + + if ( + isDefined(flatNavigationMenuItem.position) && + (!Number.isInteger(flatNavigationMenuItem.position) || + flatNavigationMenuItem.position < 0) + ) { + validationResult.errors.push({ + code: NavigationMenuItemExceptionCode.INVALID_NAVIGATION_MENU_ITEM_INPUT, + message: t`Position must be a non-negative integer`, + userFriendlyMessage: msg`Position must be a non-negative integer`, + }); + } + + const typeValidationErrors = this.validateNavigationMenuItemType({ + hasTargetRecordId: isDefined(flatNavigationMenuItem.targetRecordId), + hasTargetObjectMetadataId: isDefined( + flatNavigationMenuItem.targetObjectMetadataId, + ), + hasViewId: isDefined(flatNavigationMenuItem.viewId), + name: flatNavigationMenuItem.name, + isUpdate: false, + }); + + validationResult.errors.push(...typeValidationErrors); + + if (isDefined(flatNavigationMenuItem.folderId)) { + const circularDependencyErrors = + this.getCircularDependencyValidationErrors({ + navigationMenuItemId: flatNavigationMenuItem.id, + folderId: flatNavigationMenuItem.folderId, + flatNavigationMenuItemMaps: optimisticFlatNavigationMenuItemMaps, + }); + + if (circularDependencyErrors.length > 0) { + validationResult.errors.push(...circularDependencyErrors); + } + + const referencedParentInOptimistic = findFlatEntityByIdInFlatEntityMaps({ + flatEntityId: flatNavigationMenuItem.folderId, + flatEntityMaps: optimisticFlatNavigationMenuItemMaps, + }); + + const referencedParentInRemaining = findFlatEntityByIdInFlatEntityMaps({ + flatEntityId: flatNavigationMenuItem.folderId, + flatEntityMaps: remainingFlatEntityMapsToValidate, + }); + + if ( + !isDefined(referencedParentInOptimistic) && + !isDefined(referencedParentInRemaining) + ) { + validationResult.errors.push({ + code: NavigationMenuItemExceptionCode.NAVIGATION_MENU_ITEM_NOT_FOUND, + message: t`Parent navigation menu item not found`, + userFriendlyMessage: msg`Parent navigation menu item not found`, + }); + } + } + + return validationResult; + } + + public validateFlatNavigationMenuItemDeletion({ + flatEntityToValidate, + optimisticFlatEntityMapsAndRelatedFlatEntityMaps: { + flatNavigationMenuItemMaps: optimisticFlatNavigationMenuItemMaps, + }, + }: FlatEntityValidationArgs< + typeof ALL_METADATA_NAME.navigationMenuItem + >): FailedFlatEntityValidation<'navigationMenuItem', 'delete'> { + const validationResult = getEmptyFlatEntityValidationError({ + flatEntityMinimalInformation: { + id: flatEntityToValidate.id, + universalIdentifier: flatEntityToValidate.universalIdentifier, + }, + metadataName: 'navigationMenuItem', + type: 'delete', + }); + + const existingNavigationMenuItem = findFlatEntityByIdInFlatEntityMaps({ + flatEntityId: flatEntityToValidate.id, + flatEntityMaps: optimisticFlatNavigationMenuItemMaps, + }); + + if (!isDefined(existingNavigationMenuItem)) { + validationResult.errors.push({ + code: NavigationMenuItemExceptionCode.NAVIGATION_MENU_ITEM_NOT_FOUND, + message: t`Navigation menu item not found`, + userFriendlyMessage: msg`Navigation menu item not found`, + }); + } + + return validationResult; + } + + public validateFlatNavigationMenuItemUpdate({ + flatEntityId, + flatEntityUpdates, + optimisticFlatEntityMapsAndRelatedFlatEntityMaps: { + flatNavigationMenuItemMaps: optimisticFlatNavigationMenuItemMaps, + }, + }: FlatEntityUpdateValidationArgs< + typeof ALL_METADATA_NAME.navigationMenuItem + >): FailedFlatEntityValidation<'navigationMenuItem', 'update'> { + const fromFlatNavigationMenuItem = findFlatEntityByIdInFlatEntityMaps({ + flatEntityId, + flatEntityMaps: optimisticFlatNavigationMenuItemMaps, + }); + + const validationResult = getEmptyFlatEntityValidationError({ + flatEntityMinimalInformation: { + id: flatEntityId, + universalIdentifier: fromFlatNavigationMenuItem?.universalIdentifier, + }, + metadataName: 'navigationMenuItem', + type: 'update', + }); + + if (!isDefined(fromFlatNavigationMenuItem)) { + validationResult.errors.push({ + code: NavigationMenuItemExceptionCode.NAVIGATION_MENU_ITEM_NOT_FOUND, + message: t`Navigation menu item not found`, + userFriendlyMessage: msg`Navigation menu item not found`, + }); + + return validationResult; + } + + const positionUpdate = findFlatEntityPropertyUpdate({ + flatEntityUpdates, + property: 'position', + }); + + if ( + isDefined(positionUpdate) && + (!Number.isInteger(positionUpdate.to) || positionUpdate.to < 0) + ) { + validationResult.errors.push({ + code: NavigationMenuItemExceptionCode.INVALID_NAVIGATION_MENU_ITEM_INPUT, + message: t`Position must be a non-negative integer`, + userFriendlyMessage: msg`Position must be a non-negative integer`, + }); + } + + const toFlatNavigationMenuItem = { + ...fromFlatNavigationMenuItem, + ...fromFlatEntityPropertiesUpdatesToPartialFlatEntity({ + updates: flatEntityUpdates, + }), + }; + + const nameUpdate = findFlatEntityPropertyUpdate({ + flatEntityUpdates, + property: 'name', + }); + + const typeValidationErrors = this.validateNavigationMenuItemType({ + hasTargetRecordId: isDefined(toFlatNavigationMenuItem.targetRecordId), + hasTargetObjectMetadataId: isDefined( + toFlatNavigationMenuItem.targetObjectMetadataId, + ), + hasViewId: isDefined(toFlatNavigationMenuItem.viewId), + name: isDefined(nameUpdate) + ? nameUpdate.to + : toFlatNavigationMenuItem.name, + isUpdate: true, + }); + + validationResult.errors.push(...typeValidationErrors); + + const folderIdUpdate = findFlatEntityPropertyUpdate({ + flatEntityUpdates, + property: 'folderId', + }); + + if (!isDefined(folderIdUpdate)) { + return validationResult; + } + + const newFolderId = folderIdUpdate.to; + + if (!isDefined(newFolderId)) { + return validationResult; + } + + const circularDependencyErrors = this.getCircularDependencyValidationErrors( + { + navigationMenuItemId: flatEntityId, + folderId: newFolderId, + flatNavigationMenuItemMaps: optimisticFlatNavigationMenuItemMaps, + }, + ); + + if (circularDependencyErrors.length > 0) { + validationResult.errors.push(...circularDependencyErrors); + } + + const referencedParentNavigationMenuItem = + findFlatEntityByIdInFlatEntityMaps({ + flatEntityId: newFolderId, + flatEntityMaps: optimisticFlatNavigationMenuItemMaps, + }); + + if (!isDefined(referencedParentNavigationMenuItem)) { + validationResult.errors.push({ + code: NavigationMenuItemExceptionCode.NAVIGATION_MENU_ITEM_NOT_FOUND, + message: t`Parent navigation menu item not found`, + userFriendlyMessage: msg`Parent navigation menu item not found`, + }); + } + + return validationResult; + } +} diff --git a/packages/twenty-server/src/engine/workspace-manager/workspace-migration/workspace-migration-builder/validators/workspace-migration-builder-validators.module.ts b/packages/twenty-server/src/engine/workspace-manager/workspace-migration/workspace-migration-builder/validators/workspace-migration-builder-validators.module.ts index 257791dbbe8..d8f3ff0fa68 100644 --- a/packages/twenty-server/src/engine/workspace-manager/workspace-migration/workspace-migration-builder/validators/workspace-migration-builder-validators.module.ts +++ b/packages/twenty-server/src/engine/workspace-manager/workspace-migration/workspace-migration-builder/validators/workspace-migration-builder-validators.module.ts @@ -4,10 +4,13 @@ import { FeatureFlagModule } from 'src/engine/core-modules/feature-flag/feature- import { FlatFieldMetadataTypeValidatorService } from 'src/engine/metadata-modules/flat-field-metadata/services/flat-field-metadata-type-validator.service'; import { FlatPageLayoutWidgetTypeValidatorService } from 'src/engine/metadata-modules/flat-page-layout-widget/services/flat-page-layout-widget-type-validator.service'; import { FlatAgentValidatorService } from 'src/engine/workspace-manager/workspace-migration/workspace-migration-builder/validators/services/flat-agent-validator.service'; +import { FlatCommandMenuItemValidatorService } from 'src/engine/workspace-manager/workspace-migration/workspace-migration-builder/validators/services/flat-command-menu-item-validator.service'; import { FlatCronTriggerValidatorService } from 'src/engine/workspace-manager/workspace-migration/workspace-migration-builder/validators/services/flat-cron-trigger-validator.service'; import { FlatDatabaseEventTriggerValidatorService } from 'src/engine/workspace-manager/workspace-migration/workspace-migration-builder/validators/services/flat-database-event-trigger-validator.service'; import { FlatFieldMetadataValidatorService } from 'src/engine/workspace-manager/workspace-migration/workspace-migration-builder/validators/services/flat-field-metadata-validator.service'; +import { FlatFrontComponentValidatorService } from 'src/engine/workspace-manager/workspace-migration/workspace-migration-builder/validators/services/flat-front-component-validator.service'; import { FlatIndexValidatorService } from 'src/engine/workspace-manager/workspace-migration/workspace-migration-builder/validators/services/flat-index-metadata-validator.service'; +import { FlatNavigationMenuItemValidatorService } from 'src/engine/workspace-manager/workspace-migration/workspace-migration-builder/validators/services/flat-navigation-menu-item-validator.service'; import { FlatObjectMetadataValidatorService } from 'src/engine/workspace-manager/workspace-migration/workspace-migration-builder/validators/services/flat-object-metadata-validator.service'; import { FlatPageLayoutTabValidatorService } from 'src/engine/workspace-manager/workspace-migration/workspace-migration-builder/validators/services/flat-page-layout-tab-validator.service'; import { FlatPageLayoutValidatorService } from 'src/engine/workspace-manager/workspace-migration/workspace-migration-builder/validators/services/flat-page-layout-validator.service'; @@ -18,9 +21,7 @@ import { FlatRouteTriggerValidatorService } from 'src/engine/workspace-manager/w import { FlatRowLevelPermissionPredicateGroupValidatorService } from 'src/engine/workspace-manager/workspace-migration/workspace-migration-builder/validators/services/flat-row-level-permission-predicate-group-validator.service'; import { FlatRowLevelPermissionPredicateValidatorService } from 'src/engine/workspace-manager/workspace-migration/workspace-migration-builder/validators/services/flat-row-level-permission-predicate-validator.service'; import { FlatServerlessFunctionValidatorService } from 'src/engine/workspace-manager/workspace-migration/workspace-migration-builder/validators/services/flat-serverless-function-validator.service'; -import { FlatCommandMenuItemValidatorService } from 'src/engine/workspace-manager/workspace-migration/workspace-migration-builder/validators/services/flat-command-menu-item-validator.service'; import { FlatSkillValidatorService } from 'src/engine/workspace-manager/workspace-migration/workspace-migration-builder/validators/services/flat-skill-validator.service'; -import { FlatFrontComponentValidatorService } from 'src/engine/workspace-manager/workspace-migration/workspace-migration-builder/validators/services/flat-front-component-validator.service'; import { FlatViewFieldValidatorService } from 'src/engine/workspace-manager/workspace-migration/workspace-migration-builder/validators/services/flat-view-field-validator.service'; import { FlatViewFilterGroupValidatorService } from 'src/engine/workspace-manager/workspace-migration/workspace-migration-builder/validators/services/flat-view-filter-group-validator.service'; import { FlatViewFilterValidatorService } from 'src/engine/workspace-manager/workspace-migration/workspace-migration-builder/validators/services/flat-view-filter-validator.service'; @@ -49,6 +50,7 @@ import { FlatViewValidatorService } from 'src/engine/workspace-manager/workspace FlatAgentValidatorService, FlatSkillValidatorService, FlatCommandMenuItemValidatorService, + FlatNavigationMenuItemValidatorService, FlatPageLayoutValidatorService, FlatPageLayoutWidgetValidatorService, FlatPageLayoutTabValidatorService, @@ -75,6 +77,7 @@ import { FlatViewValidatorService } from 'src/engine/workspace-manager/workspace FlatAgentValidatorService, FlatSkillValidatorService, FlatCommandMenuItemValidatorService, + FlatNavigationMenuItemValidatorService, FlatPageLayoutValidatorService, FlatPageLayoutWidgetValidatorService, FlatPageLayoutTabValidatorService, diff --git a/packages/twenty-server/src/engine/workspace-manager/workspace-migration/workspace-migration-builder/workspace-migration-builder.module.ts b/packages/twenty-server/src/engine/workspace-manager/workspace-migration/workspace-migration-builder/workspace-migration-builder.module.ts index d0935d59b10..7a4cfd4791b 100644 --- a/packages/twenty-server/src/engine/workspace-manager/workspace-migration/workspace-migration-builder/workspace-migration-builder.module.ts +++ b/packages/twenty-server/src/engine/workspace-manager/workspace-migration/workspace-migration-builder/workspace-migration-builder.module.ts @@ -4,6 +4,7 @@ import { FeatureFlagModule } from 'src/engine/core-modules/feature-flag/feature- import { FlatFieldMetadataTypeValidatorService } from 'src/engine/metadata-modules/flat-field-metadata/services/flat-field-metadata-type-validator.service'; import { WorkspaceMigrationAgentActionsBuilderService } from 'src/engine/workspace-manager/workspace-migration/workspace-migration-builder/builders/agent/workspace-migration-agent-actions-builder.service'; import { WorkspaceMigrationCommandMenuItemActionsBuilderService } from 'src/engine/workspace-manager/workspace-migration/workspace-migration-builder/builders/command-menu-item/workspace-migration-command-menu-item-actions-builder.service'; +import { WorkspaceMigrationNavigationMenuItemActionsBuilderService } from 'src/engine/workspace-manager/workspace-migration/workspace-migration-builder/builders/navigation-menu-item/workspace-migration-navigation-menu-item-actions-builder.service'; import { WorkspaceMigrationCronTriggerActionsBuilderService } from 'src/engine/workspace-manager/workspace-migration/workspace-migration-builder/builders/cron-trigger/workspace-migration-cron-trigger-action-builder.service'; import { WorkspaceMigrationDatabaseEventTriggerActionsBuilderService } from 'src/engine/workspace-manager/workspace-migration/workspace-migration-builder/builders/database-event-trigger/workspace-migration-database-event-trigger-actions-builder.service'; import { WorkspaceMigrationFieldActionsBuilderService } from 'src/engine/workspace-manager/workspace-migration/workspace-migration-builder/builders/field/workspace-migration-field-actions-builder.service'; @@ -48,6 +49,7 @@ import { WorkspaceMigrationBuilderValidatorsModule } from 'src/engine/workspace- WorkspaceMigrationAgentActionsBuilderService, WorkspaceMigrationSkillActionsBuilderService, WorkspaceMigrationCommandMenuItemActionsBuilderService, + WorkspaceMigrationNavigationMenuItemActionsBuilderService, WorkspaceMigrationPageLayoutActionsBuilderService, WorkspaceMigrationPageLayoutWidgetActionsBuilderService, WorkspaceMigrationPageLayoutTabActionsBuilderService, @@ -73,6 +75,7 @@ import { WorkspaceMigrationBuilderValidatorsModule } from 'src/engine/workspace- WorkspaceMigrationAgentActionsBuilderService, WorkspaceMigrationSkillActionsBuilderService, WorkspaceMigrationCommandMenuItemActionsBuilderService, + WorkspaceMigrationNavigationMenuItemActionsBuilderService, WorkspaceMigrationPageLayoutActionsBuilderService, WorkspaceMigrationPageLayoutWidgetActionsBuilderService, WorkspaceMigrationPageLayoutTabActionsBuilderService, diff --git a/packages/twenty-server/src/engine/workspace-manager/workspace-migration/workspace-migration-runner/action-handlers/navigation-menu-item/services/create-navigation-menu-item-action-handler.service.ts b/packages/twenty-server/src/engine/workspace-manager/workspace-migration/workspace-migration-runner/action-handlers/navigation-menu-item/services/create-navigation-menu-item-action-handler.service.ts new file mode 100644 index 00000000000..accaeaf990f --- /dev/null +++ b/packages/twenty-server/src/engine/workspace-manager/workspace-migration/workspace-migration-runner/action-handlers/navigation-menu-item/services/create-navigation-menu-item-action-handler.service.ts @@ -0,0 +1,40 @@ +import { Injectable } from '@nestjs/common'; + +import { WorkspaceMigrationRunnerActionHandler } from 'src/engine/workspace-manager/workspace-migration/workspace-migration-runner/interfaces/workspace-migration-runner-action-handler-service.interface'; + +import { NavigationMenuItemEntity } from 'src/engine/metadata-modules/navigation-menu-item/entities/navigation-menu-item.entity'; +import { CreateNavigationMenuItemAction } from 'src/engine/workspace-manager/workspace-migration/workspace-migration-builder/builders/navigation-menu-item/types/workspace-migration-navigation-menu-item-action.type'; +import { WorkspaceMigrationActionRunnerArgs } from 'src/engine/workspace-manager/workspace-migration/workspace-migration-runner/types/workspace-migration-action-runner-args.type'; + +@Injectable() +export class CreateNavigationMenuItemActionHandlerService extends WorkspaceMigrationRunnerActionHandler( + 'create', + 'navigationMenuItem', +) { + constructor() { + super(); + } + + async executeForMetadata( + context: WorkspaceMigrationActionRunnerArgs, + ): Promise { + const { action, queryRunner, workspaceId } = context; + const { flatEntity } = action; + + const navigationMenuItemRepository = + queryRunner.manager.getRepository( + NavigationMenuItemEntity, + ); + + await navigationMenuItemRepository.insert({ + ...flatEntity, + workspaceId, + }); + } + + async executeForWorkspaceSchema( + _context: WorkspaceMigrationActionRunnerArgs, + ): Promise { + return; + } +} diff --git a/packages/twenty-server/src/engine/workspace-manager/workspace-migration/workspace-migration-runner/action-handlers/navigation-menu-item/services/delete-navigation-menu-item-action-handler.service.ts b/packages/twenty-server/src/engine/workspace-manager/workspace-migration/workspace-migration-runner/action-handlers/navigation-menu-item/services/delete-navigation-menu-item-action-handler.service.ts new file mode 100644 index 00000000000..d6e3673a582 --- /dev/null +++ b/packages/twenty-server/src/engine/workspace-manager/workspace-migration/workspace-migration-runner/action-handlers/navigation-menu-item/services/delete-navigation-menu-item-action-handler.service.ts @@ -0,0 +1,46 @@ +import { Injectable } from '@nestjs/common'; + +import { WorkspaceMigrationRunnerActionHandler } from 'src/engine/workspace-manager/workspace-migration/workspace-migration-runner/interfaces/workspace-migration-runner-action-handler-service.interface'; + +import { findFlatEntityByUniversalIdentifierOrThrow } from 'src/engine/metadata-modules/flat-entity/utils/find-flat-entity-by-universal-identifier-or-throw.util'; +import { NavigationMenuItemEntity } from 'src/engine/metadata-modules/navigation-menu-item/entities/navigation-menu-item.entity'; +import { DeleteNavigationMenuItemAction } from 'src/engine/workspace-manager/workspace-migration/workspace-migration-builder/builders/navigation-menu-item/types/workspace-migration-navigation-menu-item-action.type'; +import { WorkspaceMigrationActionRunnerArgs } from 'src/engine/workspace-manager/workspace-migration/workspace-migration-runner/types/workspace-migration-action-runner-args.type'; + +@Injectable() +export class DeleteNavigationMenuItemActionHandlerService extends WorkspaceMigrationRunnerActionHandler( + 'delete', + 'navigationMenuItem', +) { + constructor() { + super(); + } + + async executeForMetadata( + context: WorkspaceMigrationActionRunnerArgs, + ): Promise { + const { action, queryRunner, workspaceId, allFlatEntityMaps } = context; + const { universalIdentifier } = action; + + const flatNavigationMenuItem = findFlatEntityByUniversalIdentifierOrThrow({ + flatEntityMaps: allFlatEntityMaps.flatNavigationMenuItemMaps, + universalIdentifier, + }); + + const navigationMenuItemRepository = + queryRunner.manager.getRepository( + NavigationMenuItemEntity, + ); + + await navigationMenuItemRepository.delete({ + id: flatNavigationMenuItem.id, + workspaceId, + }); + } + + async executeForWorkspaceSchema( + _context: WorkspaceMigrationActionRunnerArgs, + ): Promise { + return; + } +} diff --git a/packages/twenty-server/src/engine/workspace-manager/workspace-migration/workspace-migration-runner/action-handlers/navigation-menu-item/services/update-navigation-menu-item-action-handler.service.ts b/packages/twenty-server/src/engine/workspace-manager/workspace-migration/workspace-migration-runner/action-handlers/navigation-menu-item/services/update-navigation-menu-item-action-handler.service.ts new file mode 100644 index 00000000000..5ef8234b34d --- /dev/null +++ b/packages/twenty-server/src/engine/workspace-manager/workspace-migration/workspace-migration-runner/action-handlers/navigation-menu-item/services/update-navigation-menu-item-action-handler.service.ts @@ -0,0 +1,39 @@ +import { Injectable } from '@nestjs/common'; + +import { WorkspaceMigrationRunnerActionHandler } from 'src/engine/workspace-manager/workspace-migration/workspace-migration-runner/interfaces/workspace-migration-runner-action-handler-service.interface'; + +import { NavigationMenuItemEntity } from 'src/engine/metadata-modules/navigation-menu-item/entities/navigation-menu-item.entity'; +import { UpdateNavigationMenuItemAction } from 'src/engine/workspace-manager/workspace-migration/workspace-migration-builder/builders/navigation-menu-item/types/workspace-migration-navigation-menu-item-action.type'; +import { WorkspaceMigrationActionRunnerArgs } from 'src/engine/workspace-manager/workspace-migration/workspace-migration-runner/types/workspace-migration-action-runner-args.type'; +import { fromFlatEntityPropertiesUpdatesToPartialFlatEntity } from 'src/engine/workspace-manager/workspace-migration/workspace-migration-runner/utils/from-flat-entity-properties-updates-to-partial-flat-entity'; + +@Injectable() +export class UpdateNavigationMenuItemActionHandlerService extends WorkspaceMigrationRunnerActionHandler( + 'update', + 'navigationMenuItem', +) { + async executeForMetadata( + context: WorkspaceMigrationActionRunnerArgs, + ): Promise { + const { action, queryRunner, workspaceId } = context; + const { entityId, updates } = action; + + const navigationMenuItemRepository = + queryRunner.manager.getRepository( + NavigationMenuItemEntity, + ); + + await navigationMenuItemRepository.update( + { id: entityId, workspaceId }, + fromFlatEntityPropertiesUpdatesToPartialFlatEntity({ + updates, + }), + ); + } + + async executeForWorkspaceSchema( + _context: WorkspaceMigrationActionRunnerArgs, + ): Promise { + return; + } +} diff --git a/packages/twenty-server/src/engine/workspace-manager/workspace-migration/workspace-migration-runner/action-handlers/workspace-schema-migration-runner-action-handlers.module.ts b/packages/twenty-server/src/engine/workspace-manager/workspace-migration/workspace-migration-runner/action-handlers/workspace-schema-migration-runner-action-handlers.module.ts index 9cba7bd19c6..8dcf22b7f58 100644 --- a/packages/twenty-server/src/engine/workspace-manager/workspace-migration/workspace-migration-runner/action-handlers/workspace-schema-migration-runner-action-handlers.module.ts +++ b/packages/twenty-server/src/engine/workspace-manager/workspace-migration/workspace-migration-runner/action-handlers/workspace-schema-migration-runner-action-handlers.module.ts @@ -7,6 +7,9 @@ import { UpdateAgentActionHandlerService } from 'src/engine/workspace-manager/wo import { CreateCommandMenuItemActionHandlerService } from 'src/engine/workspace-manager/workspace-migration/workspace-migration-runner/action-handlers/command-menu-item/services/create-command-menu-item-action-handler.service'; import { DeleteCommandMenuItemActionHandlerService } from 'src/engine/workspace-manager/workspace-migration/workspace-migration-runner/action-handlers/command-menu-item/services/delete-command-menu-item-action-handler.service'; import { UpdateCommandMenuItemActionHandlerService } from 'src/engine/workspace-manager/workspace-migration/workspace-migration-runner/action-handlers/command-menu-item/services/update-command-menu-item-action-handler.service'; +import { CreateNavigationMenuItemActionHandlerService } from 'src/engine/workspace-manager/workspace-migration/workspace-migration-runner/action-handlers/navigation-menu-item/services/create-navigation-menu-item-action-handler.service'; +import { DeleteNavigationMenuItemActionHandlerService } from 'src/engine/workspace-manager/workspace-migration/workspace-migration-runner/action-handlers/navigation-menu-item/services/delete-navigation-menu-item-action-handler.service'; +import { UpdateNavigationMenuItemActionHandlerService } from 'src/engine/workspace-manager/workspace-migration/workspace-migration-runner/action-handlers/navigation-menu-item/services/update-navigation-menu-item-action-handler.service'; import { CreateCronTriggerActionHandlerService } from 'src/engine/workspace-manager/workspace-migration/workspace-migration-runner/action-handlers/cron-trigger/services/create-cron-trigger-action-handler.service'; import { DeleteCronTriggerActionHandlerService } from 'src/engine/workspace-manager/workspace-migration/workspace-migration-runner/action-handlers/cron-trigger/services/delete-cron-trigger-action-handler.service'; import { UpdateCronTriggerActionHandlerService } from 'src/engine/workspace-manager/workspace-migration/workspace-migration-runner/action-handlers/cron-trigger/services/update-cron-trigger-action-handler.service'; @@ -142,6 +145,10 @@ import { UpdateViewActionHandlerService } from 'src/engine/workspace-manager/wor UpdateCommandMenuItemActionHandlerService, DeleteCommandMenuItemActionHandlerService, + CreateNavigationMenuItemActionHandlerService, + UpdateNavigationMenuItemActionHandlerService, + DeleteNavigationMenuItemActionHandlerService, + CreatePageLayoutActionHandlerService, UpdatePageLayoutActionHandlerService, DeletePageLayoutActionHandlerService, diff --git a/packages/twenty-server/src/engine/workspace-manager/workspace-migration/workspace-migration-runner/utils/optimistically-apply-create-action-on-all-flat-entity-maps.util.ts b/packages/twenty-server/src/engine/workspace-manager/workspace-migration/workspace-migration-runner/utils/optimistically-apply-create-action-on-all-flat-entity-maps.util.ts index 47d10b28e2c..19271773ba8 100644 --- a/packages/twenty-server/src/engine/workspace-manager/workspace-migration/workspace-migration-runner/utils/optimistically-apply-create-action-on-all-flat-entity-maps.util.ts +++ b/packages/twenty-server/src/engine/workspace-manager/workspace-migration/workspace-migration-runner/utils/optimistically-apply-create-action-on-all-flat-entity-maps.util.ts @@ -4,6 +4,7 @@ import { assertUnreachable } from 'twenty-shared/utils'; import { type AllFlatEntityMaps } from 'src/engine/metadata-modules/flat-entity/types/all-flat-entity-maps.type'; import { type AllFlatEntityTypesByMetadataName } from 'src/engine/metadata-modules/flat-entity/types/all-flat-entity-types-by-metadata-name'; import { addFlatEntityToFlatEntityAndRelatedEntityMapsThroughMutationOrThrow } from 'src/engine/metadata-modules/flat-entity/utils/add-flat-entity-to-flat-entity-and-related-entity-maps-through-mutation-or-throw.util'; +import { addFlatNavigationMenuItemToMapsAndUpdateIndex } from 'src/engine/metadata-modules/flat-navigation-menu-item/utils/add-flat-navigation-menu-item-to-maps-and-update-index.util'; type CreateAction = AllFlatEntityTypesByMetadataName[TMetadataName]['actions']['create']; @@ -79,6 +80,15 @@ export const optimisticallyApplyCreateActionOnAllFlatEntityMaps = < return allFlatEntityMaps; } + case 'navigationMenuItem': { + addFlatNavigationMenuItemToMapsAndUpdateIndex({ + flatNavigationMenuItem: action.flatEntity, + flatNavigationMenuItemMaps: + allFlatEntityMaps.flatNavigationMenuItemMaps, + }); + + return allFlatEntityMaps; + } default: { assertUnreachable(action); } diff --git a/packages/twenty-server/src/engine/workspace-manager/workspace-migration/workspace-migration-runner/utils/optimistically-apply-delete-action-on-all-flat-entity-maps.util.ts b/packages/twenty-server/src/engine/workspace-manager/workspace-migration/workspace-migration-runner/utils/optimistically-apply-delete-action-on-all-flat-entity-maps.util.ts index efa8c9cd4ac..ec4a789cf4b 100644 --- a/packages/twenty-server/src/engine/workspace-manager/workspace-migration/workspace-migration-runner/utils/optimistically-apply-delete-action-on-all-flat-entity-maps.util.ts +++ b/packages/twenty-server/src/engine/workspace-manager/workspace-migration/workspace-migration-runner/utils/optimistically-apply-delete-action-on-all-flat-entity-maps.util.ts @@ -7,6 +7,7 @@ import { type MetadataFlatEntity } from 'src/engine/metadata-modules/flat-entity import { deleteFlatEntityFromFlatEntityAndRelatedEntityMapsThroughMutationOrThrow } from 'src/engine/metadata-modules/flat-entity/utils/delete-flat-entity-from-flat-entity-and-related-entity-maps-through-mutation-or-throw.util'; import { findFlatEntityByUniversalIdentifierOrThrow } from 'src/engine/metadata-modules/flat-entity/utils/find-flat-entity-by-universal-identifier-or-throw.util'; import { getMetadataFlatEntityMapsKey } from 'src/engine/metadata-modules/flat-entity/utils/get-metadata-flat-entity-maps-key.util'; +import { deleteFlatNavigationMenuItemFromMapsAndIndex } from 'src/engine/metadata-modules/flat-navigation-menu-item/utils/delete-flat-navigation-menu-item-from-maps-and-index.util'; type DeleteAction = AllFlatEntityTypesByMetadataName[TMetadataName]['actions']['delete']; @@ -64,6 +65,21 @@ export const optimisticallyApplyDeleteActionOnAllFlatEntityMaps = < return allFlatEntityMaps; } + case 'navigationMenuItem': { + const flatNavigationMenuItemToDelete = + findFlatEntityByUniversalIdentifierOrThrow({ + universalIdentifier: action.universalIdentifier, + flatEntityMaps: allFlatEntityMaps.flatNavigationMenuItemMaps, + }); + + deleteFlatNavigationMenuItemFromMapsAndIndex({ + flatNavigationMenuItem: flatNavigationMenuItemToDelete, + flatNavigationMenuItemMaps: + allFlatEntityMaps.flatNavigationMenuItemMaps, + }); + + return allFlatEntityMaps; + } default: { assertUnreachable(action); } diff --git a/packages/twenty-server/src/engine/workspace-manager/workspace-migration/workspace-migration-runner/utils/optimistically-apply-update-action-on-all-flat-entity-maps.util.ts b/packages/twenty-server/src/engine/workspace-manager/workspace-migration/workspace-migration-runner/utils/optimistically-apply-update-action-on-all-flat-entity-maps.util.ts index 0075036a5b7..f12fd02b3b0 100644 --- a/packages/twenty-server/src/engine/workspace-manager/workspace-migration/workspace-migration-runner/utils/optimistically-apply-update-action-on-all-flat-entity-maps.util.ts +++ b/packages/twenty-server/src/engine/workspace-manager/workspace-migration/workspace-migration-runner/utils/optimistically-apply-update-action-on-all-flat-entity-maps.util.ts @@ -8,6 +8,7 @@ import { addFlatEntityToFlatEntityAndRelatedEntityMapsThroughMutationOrThrow } f import { deleteFlatEntityFromFlatEntityAndRelatedEntityMapsThroughMutationOrThrow } from 'src/engine/metadata-modules/flat-entity/utils/delete-flat-entity-from-flat-entity-and-related-entity-maps-through-mutation-or-throw.util'; import { findFlatEntityByIdInFlatEntityMapsOrThrow } from 'src/engine/metadata-modules/flat-entity/utils/find-flat-entity-by-id-in-flat-entity-maps-or-throw.util'; import { getMetadataFlatEntityMapsKey } from 'src/engine/metadata-modules/flat-entity/utils/get-metadata-flat-entity-maps-key.util'; +import { replaceFlatNavigationMenuItemInMapsAndUpdateIndex } from 'src/engine/metadata-modules/flat-navigation-menu-item/utils/replace-flat-navigation-menu-item-in-maps-and-update-index.util'; import { replaceFlatEntityInFlatEntityMapsThroughMutationOrThrow } from 'src/engine/workspace-manager/workspace-migration/utils/replace-flat-entity-in-flat-entity-maps-through-mutation-or-throw.util'; import { fromFlatEntityPropertiesUpdatesToPartialFlatEntity } from 'src/engine/workspace-manager/workspace-migration/workspace-migration-runner/utils/from-flat-entity-properties-updates-to-partial-flat-entity'; @@ -92,6 +93,27 @@ export const optimisticallyApplyUpdateActionOnAllFlatEntityMaps = < return allFlatEntityMaps; } + case 'navigationMenuItem': { + const fromFlatNavigationMenuItem = + findFlatEntityByIdInFlatEntityMapsOrThrow({ + flatEntityId: action.entityId, + flatEntityMaps: allFlatEntityMaps.flatNavigationMenuItemMaps, + }); + + const toFlatNavigationMenuItem = { + ...fromFlatNavigationMenuItem, + ...fromFlatEntityPropertiesUpdatesToPartialFlatEntity(action), + }; + + replaceFlatNavigationMenuItemInMapsAndUpdateIndex({ + fromFlatNavigationMenuItem, + toFlatNavigationMenuItem, + flatNavigationMenuItemMaps: + allFlatEntityMaps.flatNavigationMenuItemMaps, + }); + + return allFlatEntityMaps; + } default: { assertUnreachable(action); } diff --git a/packages/twenty-server/test/integration/metadata/suites/navigation-menu-item/__snapshots__/failing-navigation-menu-item-creation.integration-spec.ts.snap b/packages/twenty-server/test/integration/metadata/suites/navigation-menu-item/__snapshots__/failing-navigation-menu-item-creation.integration-spec.ts.snap new file mode 100644 index 00000000000..8c0578e3917 --- /dev/null +++ b/packages/twenty-server/test/integration/metadata/suites/navigation-menu-item/__snapshots__/failing-navigation-menu-item-creation.integration-spec.ts.snap @@ -0,0 +1,209 @@ +// Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing + +exports[`NavigationMenuItem creation should fail when creating with empty targetObjectMetadataId 1`] = ` +{ + "extensions": { + "code": "GRAPHQL_VALIDATION_FAILED", + "http": { + "status": 400, + }, + "userFriendlyMessage": "An error occurred.", + "value": "", + }, + "message": "Invalid UUID", + "name": "ValidationError", +} +`; + +exports[`NavigationMenuItem creation should fail when creating with empty targetRecordId 1`] = ` +{ + "extensions": { + "code": "GRAPHQL_VALIDATION_FAILED", + "http": { + "status": 400, + }, + "userFriendlyMessage": "An error occurred.", + "value": "", + }, + "message": "Invalid UUID", + "name": "ValidationError", +} +`; + +exports[`NavigationMenuItem creation should fail when creating with invalid folderId (not a UUID) 1`] = ` +{ + "extensions": { + "code": "GRAPHQL_VALIDATION_FAILED", + "http": { + "status": 400, + }, + "userFriendlyMessage": "An error occurred.", + "value": "not-a-valid-uuid", + }, + "message": "Invalid UUID", + "name": "ValidationError", +} +`; + +exports[`NavigationMenuItem creation should fail when creating with invalid userWorkspaceId (not a UUID) 1`] = ` +{ + "extensions": { + "code": "GRAPHQL_VALIDATION_FAILED", + "http": { + "status": 400, + }, + "userFriendlyMessage": "An error occurred.", + "value": "not-a-valid-uuid", + }, + "message": "Invalid UUID", + "name": "ValidationError", +} +`; + +exports[`NavigationMenuItem creation should fail when creating with invalid targetObjectMetadataId (not a UUID) 1`] = ` +{ + "extensions": { + "code": "GRAPHQL_VALIDATION_FAILED", + "http": { + "status": 400, + }, + "userFriendlyMessage": "An error occurred.", + "value": "not-a-valid-uuid", + }, + "message": "Invalid UUID", + "name": "ValidationError", +} +`; + +exports[`NavigationMenuItem creation should fail when creating with invalid targetRecordId (not a UUID) 1`] = ` +{ + "extensions": { + "code": "GRAPHQL_VALIDATION_FAILED", + "http": { + "status": 400, + }, + "userFriendlyMessage": "An error occurred.", + "value": "not-a-valid-uuid", + }, + "message": "Invalid UUID", + "name": "ValidationError", +} +`; + +exports[`NavigationMenuItem creation should fail when creating with missing targetObjectMetadataId 1`] = ` +{ + "extensions": { + "code": "METADATA_VALIDATION_FAILED", + "errors": { + "navigationMenuItem": [ + { + "errors": [ + { + "code": "INVALID_NAVIGATION_MENU_ITEM_INPUT", + "message": "targetObjectMetadataId is required when targetRecordId is provided", + "userFriendlyMessage": "targetObjectMetadataId is required when targetRecordId is provided", + }, + { + "code": "INVALID_NAVIGATION_MENU_ITEM_INPUT", + "message": "Navigation menu item must be either a folder (with name), a view link (with viewId), or a record link (with targetRecordId and targetObjectMetadataId)", + "userFriendlyMessage": "Navigation menu item must be either a folder (with name), a view link (with viewId), or a record link (with targetRecordId and targetObjectMetadataId)", + }, + ], + "flatEntityMinimalInformation": { + "id": Any, + "universalIdentifier": Any, + }, + "metadataName": "navigationMenuItem", + "status": "fail", + "type": "create", + }, + ], + }, + "message": "Validation failed for 1 navigationMenuItem", + "summary": { + "navigationMenuItem": 1, + "totalErrors": 1, + }, + "userFriendlyMessage": "Metadata validation failed", + }, + "message": "Multiple validation errors occurred while creating navigation menu item", + "name": "GraphQLError", +} +`; + +exports[`NavigationMenuItem creation should fail when creating with missing targetRecordId 1`] = ` +{ + "extensions": { + "code": "METADATA_VALIDATION_FAILED", + "errors": { + "navigationMenuItem": [ + { + "errors": [ + { + "code": "INVALID_NAVIGATION_MENU_ITEM_INPUT", + "message": "targetRecordId is required when targetObjectMetadataId is provided", + "userFriendlyMessage": "targetRecordId is required when targetObjectMetadataId is provided", + }, + { + "code": "INVALID_NAVIGATION_MENU_ITEM_INPUT", + "message": "Navigation menu item must be either a folder (with name), a view link (with viewId), or a record link (with targetRecordId and targetObjectMetadataId)", + "userFriendlyMessage": "Navigation menu item must be either a folder (with name), a view link (with viewId), or a record link (with targetRecordId and targetObjectMetadataId)", + }, + ], + "flatEntityMinimalInformation": { + "id": Any, + "universalIdentifier": Any, + }, + "metadataName": "navigationMenuItem", + "status": "fail", + "type": "create", + }, + ], + }, + "message": "Validation failed for 1 navigationMenuItem", + "summary": { + "navigationMenuItem": 1, + "totalErrors": 1, + }, + "userFriendlyMessage": "Metadata validation failed", + }, + "message": "Multiple validation errors occurred while creating navigation menu item", + "name": "GraphQLError", +} +`; + +exports[`NavigationMenuItem creation should fail when creating with negative position 1`] = ` +{ + "extensions": { + "code": "METADATA_VALIDATION_FAILED", + "errors": { + "navigationMenuItem": [ + { + "errors": [ + { + "code": "INVALID_NAVIGATION_MENU_ITEM_INPUT", + "message": "Position must be a non-negative integer", + "userFriendlyMessage": "Position must be a non-negative integer", + }, + ], + "flatEntityMinimalInformation": { + "id": Any, + "universalIdentifier": Any, + }, + "metadataName": "navigationMenuItem", + "status": "fail", + "type": "create", + }, + ], + }, + "message": "Validation failed for 1 navigationMenuItem", + "summary": { + "navigationMenuItem": 1, + "totalErrors": 1, + }, + "userFriendlyMessage": "Metadata validation failed", + }, + "message": "Multiple validation errors occurred while creating navigation menu item", + "name": "GraphQLError", +} +`; diff --git a/packages/twenty-server/test/integration/metadata/suites/navigation-menu-item/__snapshots__/failing-navigation-menu-item-deletion.integration-spec.ts.snap b/packages/twenty-server/test/integration/metadata/suites/navigation-menu-item/__snapshots__/failing-navigation-menu-item-deletion.integration-spec.ts.snap new file mode 100644 index 00000000000..4c78a9c6737 --- /dev/null +++ b/packages/twenty-server/test/integration/metadata/suites/navigation-menu-item/__snapshots__/failing-navigation-menu-item-deletion.integration-spec.ts.snap @@ -0,0 +1,57 @@ +// Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing + +exports[`NavigationMenuItem deletion should fail when deleting a non-existent navigation menu item 1`] = ` +{ + "extensions": { + "code": "NOT_FOUND", + "subCode": "NAVIGATION_MENU_ITEM_NOT_FOUND", + "userFriendlyMessage": "Navigation menu item not found.", + }, + "message": "Navigation menu item not found", + "name": "NotFoundError", +} +`; + +exports[`NavigationMenuItem deletion should fail when deleting with empty id 1`] = ` +{ + "extensions": { + "code": "GRAPHQL_VALIDATION_FAILED", + "http": { + "status": 400, + }, + "userFriendlyMessage": "An error occurred.", + "value": "", + }, + "message": "Invalid UUID", + "name": "ValidationError", +} +`; + +exports[`NavigationMenuItem deletion should fail when deleting with invalid id (not a UUID) 1`] = ` +{ + "extensions": { + "code": "GRAPHQL_VALIDATION_FAILED", + "http": { + "status": 400, + }, + "userFriendlyMessage": "An error occurred.", + "value": "not-a-valid-uuid", + }, + "message": "Invalid UUID", + "name": "ValidationError", +} +`; + +exports[`NavigationMenuItem deletion should fail when deleting with missing id 1`] = ` +{ + "extensions": { + "code": "BAD_USER_INPUT", + "http": { + "status": 400, + }, + "userFriendlyMessage": "An error occurred.", + }, + "message": "Variable "$id" of required type "UUID!" was not provided.", + "name": "GraphQLError", +} +`; diff --git a/packages/twenty-server/test/integration/metadata/suites/navigation-menu-item/__snapshots__/failing-navigation-menu-item-update-circular-dependency.integration-spec.ts.snap b/packages/twenty-server/test/integration/metadata/suites/navigation-menu-item/__snapshots__/failing-navigation-menu-item-update-circular-dependency.integration-spec.ts.snap new file mode 100644 index 00000000000..7227274754d --- /dev/null +++ b/packages/twenty-server/test/integration/metadata/suites/navigation-menu-item/__snapshots__/failing-navigation-menu-item-update-circular-dependency.integration-spec.ts.snap @@ -0,0 +1,73 @@ +// Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing + +exports[`Navigation Menu Item update should fail with circular dependency when folderId equals id (self-reference) 1`] = ` +{ + "extensions": { + "code": "METADATA_VALIDATION_FAILED", + "errors": { + "navigationMenuItem": [ + { + "errors": [ + { + "code": "CIRCULAR_DEPENDENCY", + "message": "Navigation menu item cannot be its own parent", + "userFriendlyMessage": "Navigation menu item cannot be its own parent", + }, + ], + "flatEntityMinimalInformation": { + "id": Any, + "universalIdentifier": Any, + }, + "metadataName": "navigationMenuItem", + "status": "fail", + "type": "update", + }, + ], + }, + "message": "Validation failed for 1 navigationMenuItem", + "summary": { + "navigationMenuItem": 1, + "totalErrors": 1, + }, + "userFriendlyMessage": "Metadata validation failed", + }, + "message": "Multiple validation errors occurred while updating navigation menu item", + "name": "GraphQLError", +} +`; + +exports[`Navigation Menu Item update should fail with circular dependency when update creates a circular dependency chain 1`] = ` +{ + "extensions": { + "code": "METADATA_VALIDATION_FAILED", + "errors": { + "navigationMenuItem": [ + { + "errors": [ + { + "code": "CIRCULAR_DEPENDENCY", + "message": "Circular dependency detected in navigation menu item hierarchy", + "userFriendlyMessage": "Circular dependency detected in navigation menu item hierarchy", + }, + ], + "flatEntityMinimalInformation": { + "id": Any, + "universalIdentifier": Any, + }, + "metadataName": "navigationMenuItem", + "status": "fail", + "type": "update", + }, + ], + }, + "message": "Validation failed for 1 navigationMenuItem", + "summary": { + "navigationMenuItem": 1, + "totalErrors": 1, + }, + "userFriendlyMessage": "Metadata validation failed", + }, + "message": "Multiple validation errors occurred while updating navigation menu item", + "name": "GraphQLError", +} +`; diff --git a/packages/twenty-server/test/integration/metadata/suites/navigation-menu-item/__snapshots__/failing-navigation-menu-item-update.integration-spec.ts.snap b/packages/twenty-server/test/integration/metadata/suites/navigation-menu-item/__snapshots__/failing-navigation-menu-item-update.integration-spec.ts.snap new file mode 100644 index 00000000000..7932d993286 --- /dev/null +++ b/packages/twenty-server/test/integration/metadata/suites/navigation-menu-item/__snapshots__/failing-navigation-menu-item-update.integration-spec.ts.snap @@ -0,0 +1,109 @@ +// Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing + +exports[`NavigationMenuItem update should fail when updating a non-existent navigation menu item 1`] = ` +{ + "extensions": { + "code": "NOT_FOUND", + "subCode": "NAVIGATION_MENU_ITEM_NOT_FOUND", + "userFriendlyMessage": "Navigation menu item not found.", + }, + "message": "Navigation menu item not found", + "name": "NotFoundError", +} +`; + +exports[`NavigationMenuItem update should fail when updating with empty id 1`] = ` +{ + "extensions": { + "code": "GRAPHQL_VALIDATION_FAILED", + "http": { + "status": 400, + }, + "userFriendlyMessage": "An error occurred.", + "value": "", + }, + "message": "Invalid UUID", + "name": "ValidationError", +} +`; + +exports[`NavigationMenuItem update should fail when updating with invalid folderId (not a UUID) 1`] = ` +{ + "extensions": { + "code": "GRAPHQL_VALIDATION_FAILED", + "http": { + "status": 400, + }, + "userFriendlyMessage": "An error occurred.", + "value": "not-a-valid-uuid", + }, + "message": "Invalid UUID", + "name": "ValidationError", +} +`; + +exports[`NavigationMenuItem update should fail when updating with invalid id (not a UUID) 1`] = ` +{ + "extensions": { + "code": "GRAPHQL_VALIDATION_FAILED", + "http": { + "status": 400, + }, + "userFriendlyMessage": "An error occurred.", + "value": "not-a-valid-uuid", + }, + "message": "Invalid UUID", + "name": "ValidationError", +} +`; + +exports[`NavigationMenuItem update should fail when updating with missing id 1`] = ` +{ + "extensions": { + "code": "GRAPHQL_VALIDATION_FAILED", + "http": { + "status": 400, + }, + "userFriendlyMessage": "An error occurred.", + "value": "", + }, + "message": "Invalid UUID", + "name": "ValidationError", +} +`; + +exports[`NavigationMenuItem update should fail when updating with negative position 1`] = ` +{ + "extensions": { + "code": "METADATA_VALIDATION_FAILED", + "errors": { + "navigationMenuItem": [ + { + "errors": [ + { + "code": "INVALID_NAVIGATION_MENU_ITEM_INPUT", + "message": "Position must be a non-negative integer", + "userFriendlyMessage": "Position must be a non-negative integer", + }, + ], + "flatEntityMinimalInformation": { + "id": Any, + "universalIdentifier": Any, + }, + "metadataName": "navigationMenuItem", + "status": "fail", + "type": "update", + }, + ], + }, + "message": "Validation failed for 1 navigationMenuItem", + "summary": { + "navigationMenuItem": 1, + "totalErrors": 1, + }, + "userFriendlyMessage": "Metadata validation failed", + }, + "message": "Multiple validation errors occurred while updating navigation menu item", + "name": "GraphQLError", +} +`; diff --git a/packages/twenty-server/test/integration/metadata/suites/navigation-menu-item/failing-navigation-menu-item-creation.integration-spec.ts b/packages/twenty-server/test/integration/metadata/suites/navigation-menu-item/failing-navigation-menu-item-creation.integration-spec.ts new file mode 100644 index 00000000000..6f170f6622c --- /dev/null +++ b/packages/twenty-server/test/integration/metadata/suites/navigation-menu-item/failing-navigation-menu-item-creation.integration-spec.ts @@ -0,0 +1,115 @@ +import { faker } from '@faker-js/faker'; +import { expectOneNotInternalServerErrorSnapshot } from 'test/integration/graphql/utils/expect-one-not-internal-server-error-snapshot.util'; +import { createNavigationMenuItem } from 'test/integration/metadata/suites/navigation-menu-item/utils/create-navigation-menu-item.util'; +import { + eachTestingContextFilter, + type EachTestingContext, +} from 'twenty-shared/testing'; + +import { type CreateNavigationMenuItemInput } from 'src/engine/metadata-modules/navigation-menu-item/dtos/create-navigation-menu-item.input'; + +type TestContext = { + input: CreateNavigationMenuItemInput; +}; + +const failingNavigationMenuItemCreationTestCases: EachTestingContext[] = + [ + { + title: 'when creating with missing targetRecordId', + context: { + input: { + targetObjectMetadataId: faker.string.uuid(), + } as CreateNavigationMenuItemInput, + }, + }, + { + title: 'when creating with empty targetRecordId', + context: { + input: { + targetRecordId: '', + targetObjectMetadataId: faker.string.uuid(), + }, + }, + }, + { + title: 'when creating with invalid targetRecordId (not a UUID)', + context: { + input: { + targetRecordId: 'not-a-valid-uuid', + targetObjectMetadataId: faker.string.uuid(), + }, + }, + }, + { + title: 'when creating with missing targetObjectMetadataId', + context: { + input: { + targetRecordId: faker.string.uuid(), + } as CreateNavigationMenuItemInput, + }, + }, + { + title: 'when creating with empty targetObjectMetadataId', + context: { + input: { + targetRecordId: faker.string.uuid(), + targetObjectMetadataId: '', + }, + }, + }, + { + title: 'when creating with invalid targetObjectMetadataId (not a UUID)', + context: { + input: { + targetRecordId: faker.string.uuid(), + targetObjectMetadataId: 'not-a-valid-uuid', + }, + }, + }, + { + title: 'when creating with invalid userWorkspaceId (not a UUID)', + context: { + input: { + targetRecordId: faker.string.uuid(), + targetObjectMetadataId: faker.string.uuid(), + userWorkspaceId: 'not-a-valid-uuid', + }, + }, + }, + { + title: 'when creating with invalid folderId (not a UUID)', + context: { + input: { + targetRecordId: faker.string.uuid(), + targetObjectMetadataId: faker.string.uuid(), + folderId: 'not-a-valid-uuid', + }, + }, + }, + { + title: 'when creating with negative position', + context: { + input: { + targetRecordId: faker.string.uuid(), + targetObjectMetadataId: faker.string.uuid(), + position: -1, + }, + }, + }, + ]; + +describe('NavigationMenuItem creation should fail', () => { + it.each(eachTestingContextFilter(failingNavigationMenuItemCreationTestCases))( + '$title', + async ({ context }) => { + const { errors } = await createNavigationMenuItem({ + expectToFail: true, + input: context.input, + }); + + expectOneNotInternalServerErrorSnapshot({ + errors, + }); + }, + ); +}); diff --git a/packages/twenty-server/test/integration/metadata/suites/navigation-menu-item/failing-navigation-menu-item-deletion.integration-spec.ts b/packages/twenty-server/test/integration/metadata/suites/navigation-menu-item/failing-navigation-menu-item-deletion.integration-spec.ts new file mode 100644 index 00000000000..83e5bbf81da --- /dev/null +++ b/packages/twenty-server/test/integration/metadata/suites/navigation-menu-item/failing-navigation-menu-item-deletion.integration-spec.ts @@ -0,0 +1,62 @@ +import { faker } from '@faker-js/faker'; +import { expectOneNotInternalServerErrorSnapshot } from 'test/integration/graphql/utils/expect-one-not-internal-server-error-snapshot.util'; +import { type DeleteNavigationMenuItemFactoryInput } from 'test/integration/metadata/suites/navigation-menu-item/utils/delete-navigation-menu-item-query-factory.util'; +import { deleteNavigationMenuItem } from 'test/integration/metadata/suites/navigation-menu-item/utils/delete-navigation-menu-item.util'; +import { + eachTestingContextFilter, + type EachTestingContext, +} from 'twenty-shared/testing'; + +type TestContext = { + input: DeleteNavigationMenuItemFactoryInput; +}; + +const failingNavigationMenuItemDeletionTestCases: EachTestingContext[] = + [ + { + title: 'when deleting a non-existent navigation menu item', + context: { + input: { + id: faker.string.uuid(), + }, + }, + }, + { + title: 'when deleting with missing id', + context: { + input: {} as DeleteNavigationMenuItemFactoryInput, + }, + }, + { + title: 'when deleting with empty id', + context: { + input: { + id: '', + }, + }, + }, + { + title: 'when deleting with invalid id (not a UUID)', + context: { + input: { + id: 'not-a-valid-uuid', + }, + }, + }, + ]; + +describe('NavigationMenuItem deletion should fail', () => { + it.each(eachTestingContextFilter(failingNavigationMenuItemDeletionTestCases))( + '$title', + async ({ context }) => { + const { errors } = await deleteNavigationMenuItem({ + expectToFail: true, + input: context.input, + }); + + expectOneNotInternalServerErrorSnapshot({ + errors, + }); + }, + ); +}); diff --git a/packages/twenty-server/test/integration/metadata/suites/navigation-menu-item/failing-navigation-menu-item-update-circular-dependency.integration-spec.ts b/packages/twenty-server/test/integration/metadata/suites/navigation-menu-item/failing-navigation-menu-item-update-circular-dependency.integration-spec.ts new file mode 100644 index 00000000000..e7d2efb5154 --- /dev/null +++ b/packages/twenty-server/test/integration/metadata/suites/navigation-menu-item/failing-navigation-menu-item-update-circular-dependency.integration-spec.ts @@ -0,0 +1,99 @@ +import { expectOneNotInternalServerErrorSnapshot } from 'test/integration/graphql/utils/expect-one-not-internal-server-error-snapshot.util'; +import { createNavigationMenuItem } from 'test/integration/metadata/suites/navigation-menu-item/utils/create-navigation-menu-item.util'; +import { deleteNavigationMenuItem } from 'test/integration/metadata/suites/navigation-menu-item/utils/delete-navigation-menu-item.util'; +import { updateNavigationMenuItem } from 'test/integration/metadata/suites/navigation-menu-item/utils/update-navigation-menu-item.util'; +import { jestExpectToBeDefined } from 'test/utils/jest-expect-to-be-defined.util.test'; + +describe('Navigation Menu Item update should fail with circular dependency', () => { + let folderId: string; + let parentFolderId: string; + let childFolderId: string; + + beforeAll(async () => { + const { data: folderData } = await createNavigationMenuItem({ + expectToFail: false, + input: { + name: 'Standalone Folder', + }, + }); + + folderId = folderData?.createNavigationMenuItem?.id; + jestExpectToBeDefined(folderId); + + const { data: parentFolderData } = await createNavigationMenuItem({ + expectToFail: false, + input: { + name: 'Parent Folder', + }, + }); + + parentFolderId = parentFolderData?.createNavigationMenuItem?.id; + jestExpectToBeDefined(parentFolderId); + + const { data: childFolderData } = await createNavigationMenuItem({ + expectToFail: false, + input: { + name: 'Child Folder', + folderId: parentFolderId, + }, + }); + + childFolderId = childFolderData?.createNavigationMenuItem?.id; + jestExpectToBeDefined(childFolderId); + }); + + afterAll(async () => { + if (childFolderId) { + await deleteNavigationMenuItem({ + expectToFail: false, + input: { id: childFolderId }, + }); + } + + if (parentFolderId) { + await deleteNavigationMenuItem({ + expectToFail: false, + input: { id: parentFolderId }, + }); + } + + if (folderId) { + await deleteNavigationMenuItem({ + expectToFail: false, + input: { id: folderId }, + }); + } + }); + + it('when folderId equals id (self-reference)', async () => { + const { errors } = await updateNavigationMenuItem({ + expectToFail: true, + input: { + id: folderId, + update: { + folderId: folderId, + }, + }, + }); + + expectOneNotInternalServerErrorSnapshot({ + errors, + }); + }); + + it('when update creates a circular dependency chain', async () => { + const { errors } = await updateNavigationMenuItem({ + expectToFail: true, + input: { + id: parentFolderId, + update: { + folderId: childFolderId, + }, + }, + }); + + expectOneNotInternalServerErrorSnapshot({ + errors, + }); + }); +}); diff --git a/packages/twenty-server/test/integration/metadata/suites/navigation-menu-item/failing-navigation-menu-item-update.integration-spec.ts b/packages/twenty-server/test/integration/metadata/suites/navigation-menu-item/failing-navigation-menu-item-update.integration-spec.ts new file mode 100644 index 00000000000..f26b32e63e5 --- /dev/null +++ b/packages/twenty-server/test/integration/metadata/suites/navigation-menu-item/failing-navigation-menu-item-update.integration-spec.ts @@ -0,0 +1,165 @@ +import { faker } from '@faker-js/faker'; +import { expectOneNotInternalServerErrorSnapshot } from 'test/integration/graphql/utils/expect-one-not-internal-server-error-snapshot.util'; +import { createNavigationMenuItem } from 'test/integration/metadata/suites/navigation-menu-item/utils/create-navigation-menu-item.util'; +import { deleteNavigationMenuItem } from 'test/integration/metadata/suites/navigation-menu-item/utils/delete-navigation-menu-item.util'; +import { updateNavigationMenuItem } from 'test/integration/metadata/suites/navigation-menu-item/utils/update-navigation-menu-item.util'; +import { findManyObjectMetadata } from 'test/integration/metadata/suites/object-metadata/utils/find-many-object-metadata.util'; +import { jestExpectToBeDefined } from 'test/utils/jest-expect-to-be-defined.util.test'; +import { + eachTestingContextFilter, + type EachTestingContext, +} from 'twenty-shared/testing'; + +import { type UpdateOneNavigationMenuItemInput } from 'src/engine/metadata-modules/navigation-menu-item/dtos/update-navigation-menu-item.input'; + +type TestContext = { + input: (testSetup: TestSetup) => UpdateOneNavigationMenuItemInput; +}; + +type TestSetup = { + testNavigationMenuItemId: string; +}; + +describe('NavigationMenuItem update should fail', () => { + let testNavigationMenuItemId: string; + let personObjectMetadataId: string; + + beforeAll(async () => { + const { objects } = await findManyObjectMetadata({ + expectToFail: false, + input: { + filter: {}, + paging: { first: 1000 }, + }, + gqlFields: ` + id + nameSingular + `, + }); + + jestExpectToBeDefined(objects); + + const personObjectMetadata = objects.find( + (object: { nameSingular: string }) => object.nameSingular === 'person', + ); + + jestExpectToBeDefined(personObjectMetadata); + + personObjectMetadataId = personObjectMetadata.id; + }); + + beforeEach(async () => { + const targetRecordId = faker.string.uuid(); + + const { data } = await createNavigationMenuItem({ + expectToFail: false, + input: { + targetRecordId, + targetObjectMetadataId: personObjectMetadataId, + position: 1, + }, + }); + + testNavigationMenuItemId = data.createNavigationMenuItem.id; + }); + + afterEach(async () => { + if (testNavigationMenuItemId) { + await deleteNavigationMenuItem({ + expectToFail: false, + input: { id: testNavigationMenuItemId }, + }); + } + }); + + const failingNavigationMenuItemUpdateTestCases: EachTestingContext[] = + [ + { + title: 'when updating with missing id', + context: { + input: () => + ({ + id: '', + update: { + position: 10, + }, + }) as UpdateOneNavigationMenuItemInput, + }, + }, + { + title: 'when updating with empty id', + context: { + input: () => ({ + id: '', + update: { + position: 10, + }, + }), + }, + }, + { + title: 'when updating with invalid id (not a UUID)', + context: { + input: () => ({ + id: 'not-a-valid-uuid', + update: { + position: 10, + }, + }), + }, + }, + { + title: 'when updating a non-existent navigation menu item', + context: { + input: () => ({ + id: faker.string.uuid(), + update: { + position: 10, + }, + }), + }, + }, + { + title: 'when updating with invalid folderId (not a UUID)', + context: { + input: (testSetup) => ({ + id: testSetup.testNavigationMenuItemId, + update: { + folderId: 'not-a-valid-uuid', + }, + }), + }, + }, + { + title: 'when updating with negative position', + context: { + input: (testSetup) => ({ + id: testSetup.testNavigationMenuItemId, + update: { + position: -1, + }, + }), + }, + }, + ]; + + it.each(eachTestingContextFilter(failingNavigationMenuItemUpdateTestCases))( + '$title', + async ({ context }) => { + const testSetup: TestSetup = { + testNavigationMenuItemId, + }; + + const input = context.input(testSetup); + + const { errors } = await updateNavigationMenuItem({ + expectToFail: true, + input, + }); + + expectOneNotInternalServerErrorSnapshot({ + errors, + }); + }, + ); +}); diff --git a/packages/twenty-server/test/integration/metadata/suites/navigation-menu-item/successful-navigation-menu-item-creation.integration-spec.ts b/packages/twenty-server/test/integration/metadata/suites/navigation-menu-item/successful-navigation-menu-item-creation.integration-spec.ts new file mode 100644 index 00000000000..2f2fb539db9 --- /dev/null +++ b/packages/twenty-server/test/integration/metadata/suites/navigation-menu-item/successful-navigation-menu-item-creation.integration-spec.ts @@ -0,0 +1,169 @@ +import { faker } from '@faker-js/faker'; +import { getCurrentUser } from 'test/integration/graphql/utils/get-current-user.util'; +import { createNavigationMenuItem } from 'test/integration/metadata/suites/navigation-menu-item/utils/create-navigation-menu-item.util'; +import { deleteNavigationMenuItem } from 'test/integration/metadata/suites/navigation-menu-item/utils/delete-navigation-menu-item.util'; +import { findManyObjectMetadata } from 'test/integration/metadata/suites/object-metadata/utils/find-many-object-metadata.util'; +import { jestExpectToBeDefined } from 'test/utils/jest-expect-to-be-defined.util.test'; + +describe('NavigationMenuItem creation should succeed', () => { + let createdNavigationMenuItemId: string; + let companyObjectMetadataId: string; + let personObjectMetadataId: string; + let validUserWorkspaceId: string | null; + let createdFolderId: string | undefined; + + beforeAll(async () => { + const { objects } = await findManyObjectMetadata({ + expectToFail: false, + input: { + filter: {}, + paging: { first: 1000 }, + }, + gqlFields: ` + id + nameSingular + `, + }); + + jestExpectToBeDefined(objects); + + const companyObjectMetadata = objects.find( + (object: { nameSingular: string }) => object.nameSingular === 'company', + ); + const personObjectMetadata = objects.find( + (object: { nameSingular: string }) => object.nameSingular === 'person', + ); + + jestExpectToBeDefined(companyObjectMetadata); + jestExpectToBeDefined(personObjectMetadata); + + companyObjectMetadataId = companyObjectMetadata.id; + personObjectMetadataId = personObjectMetadata.id; + + const { data: currentUserData } = await getCurrentUser({ + accessToken: APPLE_JANE_ADMIN_ACCESS_TOKEN, + expectToFail: false, + }); + + jestExpectToBeDefined(currentUserData?.currentUser?.currentUserWorkspace); + + validUserWorkspaceId = + currentUserData?.currentUser?.currentUserWorkspace?.id ?? null; + }); + + afterEach(async () => { + if (createdNavigationMenuItemId) { + await deleteNavigationMenuItem({ + expectToFail: false, + input: { id: createdNavigationMenuItemId }, + }); + createdNavigationMenuItemId = undefined as unknown as string; + } + if (createdFolderId) { + await deleteNavigationMenuItem({ + expectToFail: false, + input: { id: createdFolderId }, + }); + createdFolderId = undefined; + } + }); + + it('should create a basic navigation menu item with minimal input', async () => { + const targetRecordId = faker.string.uuid(); + + const { data } = await createNavigationMenuItem({ + expectToFail: false, + input: { + targetRecordId, + targetObjectMetadataId: personObjectMetadataId, + }, + }); + + createdNavigationMenuItemId = data?.createNavigationMenuItem?.id; + + expect(data.createNavigationMenuItem).toMatchObject({ + id: expect.any(String), + targetRecordId, + targetObjectMetadataId: personObjectMetadataId, + userWorkspaceId: null, + folderId: null, + position: expect.any(Number), + }); + }); + + it('should create navigation menu item with all optional fields', async () => { + const targetRecordId = faker.string.uuid(); + const folderTargetRecordId = faker.string.uuid(); + + const { data: folderData } = await createNavigationMenuItem({ + expectToFail: false, + input: { + targetRecordId: folderTargetRecordId, + targetObjectMetadataId: companyObjectMetadataId, + userWorkspaceId: validUserWorkspaceId ?? undefined, + }, + }); + + const folderId = folderData?.createNavigationMenuItem?.id; + + jestExpectToBeDefined(folderId); + createdFolderId = folderId; + + const { data } = await createNavigationMenuItem({ + expectToFail: false, + input: { + targetRecordId, + targetObjectMetadataId: companyObjectMetadataId, + userWorkspaceId: validUserWorkspaceId ?? undefined, + folderId, + position: 5, + }, + }); + + createdNavigationMenuItemId = data?.createNavigationMenuItem?.id; + + expect(data.createNavigationMenuItem).toMatchObject({ + id: expect.any(String), + targetRecordId, + targetObjectMetadataId: companyObjectMetadataId, + userWorkspaceId: validUserWorkspaceId, + folderId, + position: 5, + }); + }); + + it('should auto-calculate position when not provided', async () => { + const targetRecordId1 = faker.string.uuid(); + const targetRecordId2 = faker.string.uuid(); + + const { data: data1 } = await createNavigationMenuItem({ + expectToFail: false, + input: { + targetRecordId: targetRecordId1, + targetObjectMetadataId: personObjectMetadataId, + }, + }); + + const firstItemId = data1.createNavigationMenuItem.id; + const firstPosition = data1.createNavigationMenuItem.position; + + const { data: data2 } = await createNavigationMenuItem({ + expectToFail: false, + input: { + targetRecordId: targetRecordId2, + targetObjectMetadataId: personObjectMetadataId, + }, + }); + + createdNavigationMenuItemId = data2.createNavigationMenuItem.id; + + expect(data2.createNavigationMenuItem.position).toBeGreaterThan( + firstPosition, + ); + + await deleteNavigationMenuItem({ + expectToFail: false, + input: { id: firstItemId }, + }); + }); +}); diff --git a/packages/twenty-server/test/integration/metadata/suites/navigation-menu-item/successful-navigation-menu-item-deletion.integration-spec.ts b/packages/twenty-server/test/integration/metadata/suites/navigation-menu-item/successful-navigation-menu-item-deletion.integration-spec.ts new file mode 100644 index 00000000000..04db4f1cde2 --- /dev/null +++ b/packages/twenty-server/test/integration/metadata/suites/navigation-menu-item/successful-navigation-menu-item-deletion.integration-spec.ts @@ -0,0 +1,69 @@ +import { faker } from '@faker-js/faker'; +import { createNavigationMenuItem } from 'test/integration/metadata/suites/navigation-menu-item/utils/create-navigation-menu-item.util'; +import { deleteNavigationMenuItem } from 'test/integration/metadata/suites/navigation-menu-item/utils/delete-navigation-menu-item.util'; +import { findManyObjectMetadata } from 'test/integration/metadata/suites/object-metadata/utils/find-many-object-metadata.util'; +import { findNavigationMenuItems } from 'test/integration/metadata/suites/navigation-menu-item/utils/find-navigation-menu-items.util'; +import { jestExpectToBeDefined } from 'test/utils/jest-expect-to-be-defined.util.test'; + +describe('NavigationMenuItem deletion should succeed', () => { + let personObjectMetadataId: string; + + beforeAll(async () => { + const { objects } = await findManyObjectMetadata({ + expectToFail: false, + input: { + filter: {}, + paging: { first: 1000 }, + }, + gqlFields: ` + id + nameSingular + `, + }); + + jestExpectToBeDefined(objects); + + const personObjectMetadata = objects.find( + (object: { nameSingular: string }) => object.nameSingular === 'person', + ); + + jestExpectToBeDefined(personObjectMetadata); + + personObjectMetadataId = personObjectMetadata.id; + }); + + it('should delete an existing navigation menu item', async () => { + const targetRecordId = faker.string.uuid(); + + const { data: createData } = await createNavigationMenuItem({ + expectToFail: false, + input: { + targetRecordId, + targetObjectMetadataId: personObjectMetadataId, + }, + }); + + const createdId = createData.createNavigationMenuItem.id; + + const { data: deleteData } = await deleteNavigationMenuItem({ + expectToFail: false, + input: { id: createdId }, + }); + + expect(deleteData.deleteNavigationMenuItem).toMatchObject({ + id: createdId, + targetRecordId, + }); + + const { data: findData } = await findNavigationMenuItems({ + expectToFail: false, + input: undefined, + }); + + const deletedItem = findData.navigationMenuItems.find( + (item) => item.id === createdId, + ); + + expect(deletedItem).toBeUndefined(); + }); +}); diff --git a/packages/twenty-server/test/integration/metadata/suites/navigation-menu-item/successful-navigation-menu-item-update.integration-spec.ts b/packages/twenty-server/test/integration/metadata/suites/navigation-menu-item/successful-navigation-menu-item-update.integration-spec.ts new file mode 100644 index 00000000000..8930e22bb8a --- /dev/null +++ b/packages/twenty-server/test/integration/metadata/suites/navigation-menu-item/successful-navigation-menu-item-update.integration-spec.ts @@ -0,0 +1,193 @@ +import { faker } from '@faker-js/faker'; +import { createNavigationMenuItem } from 'test/integration/metadata/suites/navigation-menu-item/utils/create-navigation-menu-item.util'; +import { deleteNavigationMenuItem } from 'test/integration/metadata/suites/navigation-menu-item/utils/delete-navigation-menu-item.util'; +import { updateNavigationMenuItem } from 'test/integration/metadata/suites/navigation-menu-item/utils/update-navigation-menu-item.util'; +import { findManyObjectMetadata } from 'test/integration/metadata/suites/object-metadata/utils/find-many-object-metadata.util'; +import { jestExpectToBeDefined } from 'test/utils/jest-expect-to-be-defined.util.test'; + +describe('NavigationMenuItem update should succeed', () => { + let createdNavigationMenuItemId: string; + let personObjectMetadataId: string; + let createdFolderId: string | undefined; + + beforeAll(async () => { + const { objects } = await findManyObjectMetadata({ + expectToFail: false, + input: { + filter: {}, + paging: { first: 1000 }, + }, + gqlFields: ` + id + nameSingular + `, + }); + + jestExpectToBeDefined(objects); + + const personObjectMetadata = objects.find( + (object: { nameSingular: string }) => object.nameSingular === 'person', + ); + + jestExpectToBeDefined(personObjectMetadata); + + personObjectMetadataId = personObjectMetadata.id; + }); + + beforeEach(async () => { + const targetRecordId = faker.string.uuid(); + + const { data } = await createNavigationMenuItem({ + expectToFail: false, + input: { + targetRecordId, + targetObjectMetadataId: personObjectMetadataId, + position: 1, + }, + }); + + createdNavigationMenuItemId = data.createNavigationMenuItem.id; + }); + + afterEach(async () => { + if (createdNavigationMenuItemId) { + await deleteNavigationMenuItem({ + expectToFail: false, + input: { id: createdNavigationMenuItemId }, + }); + createdNavigationMenuItemId = undefined as unknown as string; + } + if (createdFolderId) { + await deleteNavigationMenuItem({ + expectToFail: false, + input: { id: createdFolderId }, + }); + createdFolderId = undefined; + } + }); + + it('should update the position', async () => { + const { data } = await updateNavigationMenuItem({ + expectToFail: false, + input: { + id: createdNavigationMenuItemId, + update: { + position: 10, + }, + }, + }); + + expect(data.updateNavigationMenuItem).toMatchObject({ + id: createdNavigationMenuItemId, + position: 10, + }); + }); + + it('should update the folderId', async () => { + const folderTargetRecordId = faker.string.uuid(); + + const { data: folderData } = await createNavigationMenuItem({ + expectToFail: false, + input: { + targetRecordId: folderTargetRecordId, + targetObjectMetadataId: personObjectMetadataId, + }, + }); + + const folderId = folderData?.createNavigationMenuItem?.id; + + jestExpectToBeDefined(folderId); + createdFolderId = folderId; + + const { data } = await updateNavigationMenuItem({ + expectToFail: false, + input: { + id: createdNavigationMenuItemId, + update: { + folderId, + }, + }, + }); + + expect(data.updateNavigationMenuItem).toMatchObject({ + id: createdNavigationMenuItemId, + folderId, + }); + }); + + it('should update folderId to null', async () => { + const folderTargetRecordId = faker.string.uuid(); + + const { data: folderData } = await createNavigationMenuItem({ + expectToFail: false, + input: { + targetRecordId: folderTargetRecordId, + targetObjectMetadataId: personObjectMetadataId, + }, + }); + + const folderId = folderData?.createNavigationMenuItem?.id; + + jestExpectToBeDefined(folderId); + createdFolderId = folderId; + + await updateNavigationMenuItem({ + expectToFail: false, + input: { + id: createdNavigationMenuItemId, + update: { + folderId, + }, + }, + }); + + const { data } = await updateNavigationMenuItem({ + expectToFail: false, + input: { + id: createdNavigationMenuItemId, + update: { + folderId: null, + }, + }, + }); + + expect(data.updateNavigationMenuItem).toMatchObject({ + id: createdNavigationMenuItemId, + folderId: null, + }); + }); + + it('should update multiple fields at once', async () => { + const folderTargetRecordId = faker.string.uuid(); + + const { data: folderData } = await createNavigationMenuItem({ + expectToFail: false, + input: { + targetRecordId: folderTargetRecordId, + targetObjectMetadataId: personObjectMetadataId, + }, + }); + + const folderId = folderData?.createNavigationMenuItem?.id; + + jestExpectToBeDefined(folderId); + createdFolderId = folderId; + + const { data } = await updateNavigationMenuItem({ + expectToFail: false, + input: { + id: createdNavigationMenuItemId, + update: { + folderId, + position: 99, + }, + }, + }); + + expect(data.updateNavigationMenuItem).toMatchObject({ + id: createdNavigationMenuItemId, + folderId, + position: 99, + }); + }); +}); diff --git a/packages/twenty-server/test/integration/metadata/suites/navigation-menu-item/utils/create-navigation-menu-item-query-factory.util.ts b/packages/twenty-server/test/integration/metadata/suites/navigation-menu-item/utils/create-navigation-menu-item-query-factory.util.ts new file mode 100644 index 00000000000..93b4699bc15 --- /dev/null +++ b/packages/twenty-server/test/integration/metadata/suites/navigation-menu-item/utils/create-navigation-menu-item-query-factory.util.ts @@ -0,0 +1,35 @@ +import gql from 'graphql-tag'; +import { type PerformMetadataQueryParams } from 'test/integration/metadata/types/perform-metadata-query.type'; + +import { type CreateNavigationMenuItemInput } from 'src/engine/metadata-modules/navigation-menu-item/dtos/create-navigation-menu-item.input'; + +export type CreateNavigationMenuItemFactoryInput = + CreateNavigationMenuItemInput; + +const DEFAULT_NAVIGATION_MENU_ITEM_GQL_FIELDS = ` + id + userWorkspaceId + targetRecordId + targetObjectMetadataId + folderId + position + applicationId + createdAt + updatedAt +`; + +export const createNavigationMenuItemQueryFactory = ({ + input, + gqlFields = DEFAULT_NAVIGATION_MENU_ITEM_GQL_FIELDS, +}: PerformMetadataQueryParams) => ({ + query: gql` + mutation CreateNavigationMenuItem($input: CreateNavigationMenuItemInput!) { + createNavigationMenuItem(input: $input) { + ${gqlFields} + } + } + `, + variables: { + input, + }, +}); diff --git a/packages/twenty-server/test/integration/metadata/suites/navigation-menu-item/utils/create-navigation-menu-item.util.ts b/packages/twenty-server/test/integration/metadata/suites/navigation-menu-item/utils/create-navigation-menu-item.util.ts new file mode 100644 index 00000000000..2eed60b9b2e --- /dev/null +++ b/packages/twenty-server/test/integration/metadata/suites/navigation-menu-item/utils/create-navigation-menu-item.util.ts @@ -0,0 +1,44 @@ +import { + type CreateNavigationMenuItemFactoryInput, + createNavigationMenuItemQueryFactory, +} from 'test/integration/metadata/suites/navigation-menu-item/utils/create-navigation-menu-item-query-factory.util'; +import { makeMetadataAPIRequest } from 'test/integration/metadata/suites/utils/make-metadata-api-request.util'; +import { type CommonResponseBody } from 'test/integration/metadata/types/common-response-body.type'; +import { type PerformMetadataQueryParams } from 'test/integration/metadata/types/perform-metadata-query.type'; +import { warnIfErrorButNotExpectedToFail } from 'test/integration/metadata/utils/warn-if-error-but-not-expected-to-fail.util'; +import { warnIfNoErrorButExpectedToFail } from 'test/integration/metadata/utils/warn-if-no-error-but-expected-to-fail.util'; + +import { type NavigationMenuItemDTO } from 'src/engine/metadata-modules/navigation-menu-item/dtos/navigation-menu-item.dto'; + +export const createNavigationMenuItem = async ({ + input, + gqlFields, + expectToFail = false, + token, +}: PerformMetadataQueryParams): CommonResponseBody<{ + createNavigationMenuItem: NavigationMenuItemDTO; +}> => { + const graphqlOperation = createNavigationMenuItemQueryFactory({ + input, + gqlFields, + }); + + const response = await makeMetadataAPIRequest(graphqlOperation, token); + + if (expectToFail === true) { + warnIfNoErrorButExpectedToFail({ + response, + errorMessage: + 'NavigationMenuItem creation should have failed but did not', + }); + } + + if (expectToFail === false) { + warnIfErrorButNotExpectedToFail({ + response, + errorMessage: 'NavigationMenuItem creation has failed but should not', + }); + } + + return { data: response.body.data, errors: response.body.errors }; +}; diff --git a/packages/twenty-server/test/integration/metadata/suites/navigation-menu-item/utils/delete-navigation-menu-item-query-factory.util.ts b/packages/twenty-server/test/integration/metadata/suites/navigation-menu-item/utils/delete-navigation-menu-item-query-factory.util.ts new file mode 100644 index 00000000000..f3e9d88d884 --- /dev/null +++ b/packages/twenty-server/test/integration/metadata/suites/navigation-menu-item/utils/delete-navigation-menu-item-query-factory.util.ts @@ -0,0 +1,28 @@ +import gql from 'graphql-tag'; +import { type PerformMetadataQueryParams } from 'test/integration/metadata/types/perform-metadata-query.type'; + +export type DeleteNavigationMenuItemFactoryInput = { + id: string; +}; + +const DEFAULT_NAVIGATION_MENU_ITEM_GQL_FIELDS = ` + id + targetRecordId + position +`; + +export const deleteNavigationMenuItemQueryFactory = ({ + input, + gqlFields = DEFAULT_NAVIGATION_MENU_ITEM_GQL_FIELDS, +}: PerformMetadataQueryParams) => ({ + query: gql` + mutation DeleteNavigationMenuItem($id: UUID!) { + deleteNavigationMenuItem(id: $id) { + ${gqlFields} + } + } + `, + variables: { + id: input.id, + }, +}); diff --git a/packages/twenty-server/test/integration/metadata/suites/navigation-menu-item/utils/delete-navigation-menu-item.util.ts b/packages/twenty-server/test/integration/metadata/suites/navigation-menu-item/utils/delete-navigation-menu-item.util.ts new file mode 100644 index 00000000000..11ae1614e23 --- /dev/null +++ b/packages/twenty-server/test/integration/metadata/suites/navigation-menu-item/utils/delete-navigation-menu-item.util.ts @@ -0,0 +1,44 @@ +import { + type DeleteNavigationMenuItemFactoryInput, + deleteNavigationMenuItemQueryFactory, +} from 'test/integration/metadata/suites/navigation-menu-item/utils/delete-navigation-menu-item-query-factory.util'; +import { makeMetadataAPIRequest } from 'test/integration/metadata/suites/utils/make-metadata-api-request.util'; +import { type CommonResponseBody } from 'test/integration/metadata/types/common-response-body.type'; +import { type PerformMetadataQueryParams } from 'test/integration/metadata/types/perform-metadata-query.type'; +import { warnIfErrorButNotExpectedToFail } from 'test/integration/metadata/utils/warn-if-error-but-not-expected-to-fail.util'; +import { warnIfNoErrorButExpectedToFail } from 'test/integration/metadata/utils/warn-if-no-error-but-expected-to-fail.util'; + +import { type NavigationMenuItemDTO } from 'src/engine/metadata-modules/navigation-menu-item/dtos/navigation-menu-item.dto'; + +export const deleteNavigationMenuItem = async ({ + input, + gqlFields, + expectToFail = false, + token, +}: PerformMetadataQueryParams): CommonResponseBody<{ + deleteNavigationMenuItem: NavigationMenuItemDTO; +}> => { + const graphqlOperation = deleteNavigationMenuItemQueryFactory({ + input, + gqlFields, + }); + + const response = await makeMetadataAPIRequest(graphqlOperation, token); + + if (expectToFail === true) { + warnIfNoErrorButExpectedToFail({ + response, + errorMessage: + 'NavigationMenuItem deletion should have failed but did not', + }); + } + + if (expectToFail === false) { + warnIfErrorButNotExpectedToFail({ + response, + errorMessage: 'NavigationMenuItem deletion has failed but should not', + }); + } + + return { data: response.body.data, errors: response.body.errors }; +}; diff --git a/packages/twenty-server/test/integration/metadata/suites/navigation-menu-item/utils/find-navigation-menu-items-query-factory.util.ts b/packages/twenty-server/test/integration/metadata/suites/navigation-menu-item/utils/find-navigation-menu-items-query-factory.util.ts new file mode 100644 index 00000000000..df8d260a16b --- /dev/null +++ b/packages/twenty-server/test/integration/metadata/suites/navigation-menu-item/utils/find-navigation-menu-items-query-factory.util.ts @@ -0,0 +1,26 @@ +import gql from 'graphql-tag'; +import { type PerformMetadataQueryParams } from 'test/integration/metadata/types/perform-metadata-query.type'; + +const DEFAULT_NAVIGATION_MENU_ITEM_GQL_FIELDS = ` + id + userWorkspaceId + targetRecordId + targetObjectMetadataId + folderId + position + applicationId + createdAt + updatedAt +`; + +export const findNavigationMenuItemsQueryFactory = ({ + gqlFields = DEFAULT_NAVIGATION_MENU_ITEM_GQL_FIELDS, +}: PerformMetadataQueryParams) => ({ + query: gql` + query NavigationMenuItems { + navigationMenuItems { + ${gqlFields} + } + } + `, +}); diff --git a/packages/twenty-server/test/integration/metadata/suites/navigation-menu-item/utils/find-navigation-menu-items.util.ts b/packages/twenty-server/test/integration/metadata/suites/navigation-menu-item/utils/find-navigation-menu-items.util.ts new file mode 100644 index 00000000000..1d1a137e435 --- /dev/null +++ b/packages/twenty-server/test/integration/metadata/suites/navigation-menu-item/utils/find-navigation-menu-items.util.ts @@ -0,0 +1,39 @@ +import { findNavigationMenuItemsQueryFactory } from 'test/integration/metadata/suites/navigation-menu-item/utils/find-navigation-menu-items-query-factory.util'; +import { makeMetadataAPIRequest } from 'test/integration/metadata/suites/utils/make-metadata-api-request.util'; +import { type CommonResponseBody } from 'test/integration/metadata/types/common-response-body.type'; +import { type PerformMetadataQueryParams } from 'test/integration/metadata/types/perform-metadata-query.type'; +import { warnIfErrorButNotExpectedToFail } from 'test/integration/metadata/utils/warn-if-error-but-not-expected-to-fail.util'; +import { warnIfNoErrorButExpectedToFail } from 'test/integration/metadata/utils/warn-if-no-error-but-expected-to-fail.util'; + +import { type NavigationMenuItemDTO } from 'src/engine/metadata-modules/navigation-menu-item/dtos/navigation-menu-item.dto'; + +export const findNavigationMenuItems = async ({ + gqlFields, + expectToFail = false, + token, +}: PerformMetadataQueryParams): CommonResponseBody<{ + navigationMenuItems: NavigationMenuItemDTO[]; +}> => { + const graphqlOperation = findNavigationMenuItemsQueryFactory({ + input: undefined, + gqlFields, + }); + + const response = await makeMetadataAPIRequest(graphqlOperation, token); + + if (expectToFail === true) { + warnIfNoErrorButExpectedToFail({ + response, + errorMessage: 'NavigationMenuItems query should have failed but did not', + }); + } + + if (expectToFail === false) { + warnIfErrorButNotExpectedToFail({ + response, + errorMessage: 'NavigationMenuItems query has failed but should not', + }); + } + + return { data: response.body.data, errors: response.body.errors }; +}; diff --git a/packages/twenty-server/test/integration/metadata/suites/navigation-menu-item/utils/update-navigation-menu-item-query-factory.util.ts b/packages/twenty-server/test/integration/metadata/suites/navigation-menu-item/utils/update-navigation-menu-item-query-factory.util.ts new file mode 100644 index 00000000000..d577707809d --- /dev/null +++ b/packages/twenty-server/test/integration/metadata/suites/navigation-menu-item/utils/update-navigation-menu-item-query-factory.util.ts @@ -0,0 +1,35 @@ +import gql from 'graphql-tag'; +import { type PerformMetadataQueryParams } from 'test/integration/metadata/types/perform-metadata-query.type'; + +import { type UpdateOneNavigationMenuItemInput } from 'src/engine/metadata-modules/navigation-menu-item/dtos/update-navigation-menu-item.input'; + +export type UpdateNavigationMenuItemFactoryInput = + UpdateOneNavigationMenuItemInput; + +const DEFAULT_NAVIGATION_MENU_ITEM_GQL_FIELDS = ` + id + userWorkspaceId + targetRecordId + targetObjectMetadataId + folderId + position + applicationId + createdAt + updatedAt +`; + +export const updateNavigationMenuItemQueryFactory = ({ + input, + gqlFields = DEFAULT_NAVIGATION_MENU_ITEM_GQL_FIELDS, +}: PerformMetadataQueryParams) => ({ + query: gql` + mutation UpdateNavigationMenuItem($input: UpdateOneNavigationMenuItemInput!) { + updateNavigationMenuItem(input: $input) { + ${gqlFields} + } + } + `, + variables: { + input, + }, +}); diff --git a/packages/twenty-server/test/integration/metadata/suites/navigation-menu-item/utils/update-navigation-menu-item.util.ts b/packages/twenty-server/test/integration/metadata/suites/navigation-menu-item/utils/update-navigation-menu-item.util.ts new file mode 100644 index 00000000000..29e36524338 --- /dev/null +++ b/packages/twenty-server/test/integration/metadata/suites/navigation-menu-item/utils/update-navigation-menu-item.util.ts @@ -0,0 +1,43 @@ +import { + type UpdateNavigationMenuItemFactoryInput, + updateNavigationMenuItemQueryFactory, +} from 'test/integration/metadata/suites/navigation-menu-item/utils/update-navigation-menu-item-query-factory.util'; +import { makeMetadataAPIRequest } from 'test/integration/metadata/suites/utils/make-metadata-api-request.util'; +import { type CommonResponseBody } from 'test/integration/metadata/types/common-response-body.type'; +import { type PerformMetadataQueryParams } from 'test/integration/metadata/types/perform-metadata-query.type'; +import { warnIfErrorButNotExpectedToFail } from 'test/integration/metadata/utils/warn-if-error-but-not-expected-to-fail.util'; +import { warnIfNoErrorButExpectedToFail } from 'test/integration/metadata/utils/warn-if-no-error-but-expected-to-fail.util'; + +import { type NavigationMenuItemDTO } from 'src/engine/metadata-modules/navigation-menu-item/dtos/navigation-menu-item.dto'; + +export const updateNavigationMenuItem = async ({ + input, + gqlFields, + expectToFail = false, + token, +}: PerformMetadataQueryParams): CommonResponseBody<{ + updateNavigationMenuItem: NavigationMenuItemDTO; +}> => { + const graphqlOperation = updateNavigationMenuItemQueryFactory({ + input, + gqlFields, + }); + + const response = await makeMetadataAPIRequest(graphqlOperation, token); + + if (expectToFail === true) { + warnIfNoErrorButExpectedToFail({ + response, + errorMessage: 'NavigationMenuItem update should have failed but did not', + }); + } + + if (expectToFail === false) { + warnIfErrorButNotExpectedToFail({ + response, + errorMessage: 'NavigationMenuItem update has failed but should not', + }); + } + + return { data: response.body.data, errors: response.body.errors }; +}; diff --git a/packages/twenty-shared/src/metadata/all-metadata-name.constant.ts b/packages/twenty-shared/src/metadata/all-metadata-name.constant.ts index 2227821d210..98315e4edea 100644 --- a/packages/twenty-shared/src/metadata/all-metadata-name.constant.ts +++ b/packages/twenty-shared/src/metadata/all-metadata-name.constant.ts @@ -21,5 +21,6 @@ export const ALL_METADATA_NAME = { pageLayoutWidget: 'pageLayoutWidget', pageLayoutTab: 'pageLayoutTab', commandMenuItem: 'commandMenuItem', + navigationMenuItem: 'navigationMenuItem', frontComponent: 'frontComponent', } as const;