diff --git a/packages/twenty-front/src/generated-metadata/graphql.ts b/packages/twenty-front/src/generated-metadata/graphql.ts index d263746e76..d9a0383d62 100644 --- a/packages/twenty-front/src/generated-metadata/graphql.ts +++ b/packages/twenty-front/src/generated-metadata/graphql.ts @@ -1296,8 +1296,7 @@ export enum FeatureFlagKey { IS_RECORD_PAGE_LAYOUT_ENABLED = 'IS_RECORD_PAGE_LAYOUT_ENABLED', IS_STRIPE_INTEGRATION_ENABLED = 'IS_STRIPE_INTEGRATION_ENABLED', IS_UNIQUE_INDEXES_ENABLED = 'IS_UNIQUE_INDEXES_ENABLED', - IS_WORKFLOW_RUN_STOPPAGE_ENABLED = 'IS_WORKFLOW_RUN_STOPPAGE_ENABLED', - IS_WORKSPACE_MIGRATION_V2_ENABLED = 'IS_WORKSPACE_MIGRATION_V2_ENABLED' + IS_WORKFLOW_RUN_STOPPAGE_ENABLED = 'IS_WORKFLOW_RUN_STOPPAGE_ENABLED' } export type Field = { diff --git a/packages/twenty-front/src/generated/graphql.ts b/packages/twenty-front/src/generated/graphql.ts index abb0ab33bc..1d076b5b19 100644 --- a/packages/twenty-front/src/generated/graphql.ts +++ b/packages/twenty-front/src/generated/graphql.ts @@ -1279,8 +1279,7 @@ export enum FeatureFlagKey { IS_RECORD_PAGE_LAYOUT_ENABLED = 'IS_RECORD_PAGE_LAYOUT_ENABLED', IS_STRIPE_INTEGRATION_ENABLED = 'IS_STRIPE_INTEGRATION_ENABLED', IS_UNIQUE_INDEXES_ENABLED = 'IS_UNIQUE_INDEXES_ENABLED', - IS_WORKFLOW_RUN_STOPPAGE_ENABLED = 'IS_WORKFLOW_RUN_STOPPAGE_ENABLED', - IS_WORKSPACE_MIGRATION_V2_ENABLED = 'IS_WORKSPACE_MIGRATION_V2_ENABLED' + IS_WORKFLOW_RUN_STOPPAGE_ENABLED = 'IS_WORKFLOW_RUN_STOPPAGE_ENABLED' } export type Field = { diff --git a/packages/twenty-server/src/engine/core-modules/feature-flag/enums/feature-flag-key.enum.ts b/packages/twenty-server/src/engine/core-modules/feature-flag/enums/feature-flag-key.enum.ts index e174371735..366f7ad3bb 100644 --- a/packages/twenty-server/src/engine/core-modules/feature-flag/enums/feature-flag-key.enum.ts +++ b/packages/twenty-server/src/engine/core-modules/feature-flag/enums/feature-flag-key.enum.ts @@ -7,7 +7,6 @@ export enum FeatureFlagKey { IS_AI_ENABLED = 'IS_AI_ENABLED', IS_APPLICATION_ENABLED = 'IS_APPLICATION_ENABLED', IS_IMAP_SMTP_CALDAV_ENABLED = 'IS_IMAP_SMTP_CALDAV_ENABLED', - IS_WORKSPACE_MIGRATION_V2_ENABLED = 'IS_WORKSPACE_MIGRATION_V2_ENABLED', IS_PAGE_LAYOUT_ENABLED = 'IS_PAGE_LAYOUT_ENABLED', IS_RECORD_PAGE_LAYOUT_ENABLED = 'IS_RECORD_PAGE_LAYOUT_ENABLED', IS_PUBLIC_DOMAIN_ENABLED = 'IS_PUBLIC_DOMAIN_ENABLED', diff --git a/packages/twenty-server/src/engine/metadata-modules/view-permissions/view-permissions.module.ts b/packages/twenty-server/src/engine/metadata-modules/view-permissions/view-permissions.module.ts index b016adaddd..99ddf1fdae 100644 --- a/packages/twenty-server/src/engine/metadata-modules/view-permissions/view-permissions.module.ts +++ b/packages/twenty-server/src/engine/metadata-modules/view-permissions/view-permissions.module.ts @@ -1,7 +1,9 @@ import { Module } from '@nestjs/common'; import { TypeOrmModule } from '@nestjs/typeorm'; +import { ApplicationModule } from 'src/engine/core-modules/application/application.module'; import { I18nModule } from 'src/engine/core-modules/i18n/i18n.module'; +import { WorkspaceManyOrAllFlatEntityMapsCacheModule } from 'src/engine/metadata-modules/flat-entity/services/workspace-many-or-all-flat-entity-maps-cache.module'; import { PermissionsModule } from 'src/engine/metadata-modules/permissions/permissions.module'; import { ViewFieldEntity } from 'src/engine/metadata-modules/view-field/entities/view-field.entity'; import { ViewFilterGroupEntity } from 'src/engine/metadata-modules/view-filter-group/entities/view-filter-group.entity'; @@ -37,6 +39,7 @@ import { ViewSortEntity } from 'src/engine/metadata-modules/view-sort/entities/v import { ViewEntity } from 'src/engine/metadata-modules/view/entities/view.entity'; import { ViewService } from 'src/engine/metadata-modules/view/services/view.service'; import { WorkspaceCacheStorageModule } from 'src/engine/workspace-cache-storage/workspace-cache-storage.module'; +import { WorkspaceMigrationV2Module } from 'src/engine/workspace-manager/workspace-migration-v2/workspace-migration-v2.module'; @Module({ imports: [ @@ -48,9 +51,12 @@ import { WorkspaceCacheStorageModule } from 'src/engine/workspace-cache-storage/ ViewGroupEntity, ViewSortEntity, ]), + ApplicationModule, I18nModule, PermissionsModule, WorkspaceCacheStorageModule, + WorkspaceManyOrAllFlatEntityMapsCacheModule, + WorkspaceMigrationV2Module, ], providers: [ ViewService, diff --git a/packages/twenty-server/src/engine/metadata-modules/view/controllers/view.controller.ts b/packages/twenty-server/src/engine/metadata-modules/view/controllers/view.controller.ts index 062c737783..4142df18f9 100644 --- a/packages/twenty-server/src/engine/metadata-modules/view/controllers/view.controller.ts +++ b/packages/twenty-server/src/engine/metadata-modules/view/controllers/view.controller.ts @@ -14,8 +14,6 @@ import { import { type APP_LOCALES } from 'twenty-shared/translations'; import { isDefined } from 'twenty-shared/utils'; -import { FeatureFlagKey } from 'src/engine/core-modules/feature-flag/enums/feature-flag-key.enum'; -import { FeatureFlagService } from 'src/engine/core-modules/feature-flag/services/feature-flag.service'; import { I18nService } from 'src/engine/core-modules/i18n/i18n.service'; import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity'; import { AuthUserWorkspaceId } from 'src/engine/decorators/auth/auth-user-workspace-id.decorator'; @@ -40,7 +38,6 @@ import { ViewExceptionMessageKey, } from 'src/engine/metadata-modules/view/exceptions/view.exception'; import { ViewRestApiExceptionFilter } from 'src/engine/metadata-modules/view/filters/view-rest-api-exception.filter'; -import { ViewV2Service } from 'src/engine/metadata-modules/view/services/view-v2.service'; import { ViewService } from 'src/engine/metadata-modules/view/services/view.service'; @Controller('rest/metadata/views') @@ -49,8 +46,6 @@ import { ViewService } from 'src/engine/metadata-modules/view/services/view.serv export class ViewController { constructor( private readonly viewService: ViewService, - private readonly viewV2Service: ViewV2Service, - private readonly featureFlagService: FeatureFlagService, private readonly flatEntityMapsCacheService: WorkspaceManyOrAllFlatEntityMapsCacheService, private readonly i18nService: I18nService, ) {} @@ -114,25 +109,10 @@ export class ViewController { @AuthWorkspace() workspace: WorkspaceEntity, @RequestLocale() locale?: keyof typeof APP_LOCALES, ): Promise { - const isWorkspaceMigrationV2Enabled = - await this.featureFlagService.isFeatureEnabled( - FeatureFlagKey.IS_WORKSPACE_MIGRATION_V2_ENABLED, - workspace.id, - ); - - let view: ViewDTO; - - if (isWorkspaceMigrationV2Enabled) { - view = await this.viewV2Service.createOne({ - createViewInput: input, - workspaceId: workspace.id, - }); - } else { - view = await this.viewService.create({ - ...input, - workspaceId: workspace.id, - }); - } + const view = await this.viewService.createOne({ + createViewInput: input, + workspaceId: workspace.id, + }); const processedViews = await this.processViewsWithTemplates( [view], @@ -152,31 +132,14 @@ export class ViewController { @AuthWorkspace() workspace: WorkspaceEntity, @AuthUserWorkspaceId() userWorkspaceId: string | undefined, ): Promise { - const isWorkspaceMigrationV2Enabled = - await this.featureFlagService.isFeatureEnabled( - FeatureFlagKey.IS_WORKSPACE_MIGRATION_V2_ENABLED, - workspace.id, - ); - - let updatedView: ViewDTO; - - if (isWorkspaceMigrationV2Enabled) { - updatedView = await this.viewV2Service.updateOne({ - updateViewInput: { - ...input, - id, - }, - workspaceId: workspace.id, - userWorkspaceId, - }); - } else { - updatedView = await this.viewService.update( + const updatedView = await this.viewService.updateOne({ + updateViewInput: { + ...input, id, - workspace.id, - input, - userWorkspaceId, - ); - } + }, + workspaceId: workspace.id, + userWorkspaceId, + }); const processedViews = await this.processViewsWithTemplates( [updatedView], @@ -193,22 +156,10 @@ export class ViewController { @Param('id') id: string, @AuthWorkspace() workspace: WorkspaceEntity, ): Promise<{ success: boolean }> { - const isWorkspaceMigrationV2Enabled = - await this.featureFlagService.isFeatureEnabled( - FeatureFlagKey.IS_WORKSPACE_MIGRATION_V2_ENABLED, - workspace.id, - ); - - let deletedView: ViewDTO | null; - - if (isWorkspaceMigrationV2Enabled) { - deletedView = await this.viewV2Service.deleteOne({ - deleteViewInput: { id }, - workspaceId: workspace.id, - }); - } else { - deletedView = await this.viewService.delete(id, workspace.id); - } + const deletedView = await this.viewService.deleteOne({ + deleteViewInput: { id }, + workspaceId: workspace.id, + }); return { success: isDefined(deletedView) }; } diff --git a/packages/twenty-server/src/engine/metadata-modules/view/resolvers/view.resolver.ts b/packages/twenty-server/src/engine/metadata-modules/view/resolvers/view.resolver.ts index 57fca08119..6e21bb572e 100644 --- a/packages/twenty-server/src/engine/metadata-modules/view/resolvers/view.resolver.ts +++ b/packages/twenty-server/src/engine/metadata-modules/view/resolvers/view.resolver.ts @@ -12,8 +12,6 @@ import { import { isArray } from '@sniptt/guards'; import { isDefined } from 'twenty-shared/utils'; -import { FeatureFlagKey } from 'src/engine/core-modules/feature-flag/enums/feature-flag-key.enum'; -import { FeatureFlagService } from 'src/engine/core-modules/feature-flag/services/feature-flag.service'; import { I18nService } from 'src/engine/core-modules/i18n/i18n.service'; import { type I18nContext } from 'src/engine/core-modules/i18n/types/i18n-context.type'; import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity'; @@ -43,7 +41,6 @@ import { UpdateViewInput } from 'src/engine/metadata-modules/view/dtos/inputs/up import { ViewDTO } from 'src/engine/metadata-modules/view/dtos/view.dto'; import { ViewEntity } from 'src/engine/metadata-modules/view/entities/view.entity'; import { ViewVisibility } from 'src/engine/metadata-modules/view/enums/view-visibility.enum'; -import { ViewV2Service } from 'src/engine/metadata-modules/view/services/view-v2.service'; import { ViewService } from 'src/engine/metadata-modules/view/services/view.service'; import { ViewGraphqlApiExceptionFilter } from 'src/engine/metadata-modules/view/utils/view-graphql-api-exception.filter'; @@ -58,8 +55,6 @@ export class ViewResolver { private readonly viewSortService: ViewSortService, private readonly viewGroupService: ViewGroupService, private readonly i18nService: I18nService, - private readonly featureFlagService: FeatureFlagService, - private readonly viewV2Service: ViewV2Service, private readonly viewFieldV2Service: ViewFieldV2Service, ) {} @@ -155,27 +150,11 @@ export class ViewResolver { input.visibility = visibility; - const isWorkspaceMigrationV2Enabled = - await this.featureFlagService.isFeatureEnabled( - FeatureFlagKey.IS_WORKSPACE_MIGRATION_V2_ENABLED, - workspace.id, - ); - - if (isWorkspaceMigrationV2Enabled) { - return await this.viewV2Service.createOne({ - createViewInput: input, - workspaceId: workspace.id, - createdByUserWorkspaceId: userWorkspaceId ?? '', - }); - } - - const createdView = await this.viewService.create({ - ...input, + return await this.viewService.createOne({ + createViewInput: input, workspaceId: workspace.id, createdByUserWorkspaceId: userWorkspaceId ?? '', }); - - return createdView; } @Mutation(() => ViewDTO) @@ -186,21 +165,11 @@ export class ViewResolver { @AuthWorkspace() workspace: WorkspaceEntity, @AuthUserWorkspaceId() userWorkspaceId: string | undefined, ): Promise { - const isWorkspaceMigrationV2Enabled = - await this.featureFlagService.isFeatureEnabled( - FeatureFlagKey.IS_WORKSPACE_MIGRATION_V2_ENABLED, - workspace.id, - ); - - if (isWorkspaceMigrationV2Enabled) { - return await this.viewV2Service.updateOne({ - updateViewInput: { ...input, id }, - workspaceId: workspace.id, - userWorkspaceId, - }); - } - - return this.viewService.update(id, workspace.id, input, userWorkspaceId); + return await this.viewService.updateOne({ + updateViewInput: { ...input, id }, + workspaceId: workspace.id, + userWorkspaceId, + }); } @Mutation(() => Boolean) @@ -209,22 +178,10 @@ export class ViewResolver { @Args('id', { type: () => String }) id: string, @AuthWorkspace() workspace: WorkspaceEntity, ): Promise { - const isWorkspaceMigrationV2Enabled = - await this.featureFlagService.isFeatureEnabled( - FeatureFlagKey.IS_WORKSPACE_MIGRATION_V2_ENABLED, - workspace.id, - ); - - if (isWorkspaceMigrationV2Enabled) { - const deletedView = await this.viewV2Service.deleteOne({ - deleteViewInput: { id }, - workspaceId: workspace.id, - }); - - return isDefined(deletedView); - } - - const deletedView = await this.viewService.delete(id, workspace.id); + const deletedView = await this.viewService.deleteOne({ + deleteViewInput: { id }, + workspaceId: workspace.id, + }); return isDefined(deletedView); } @@ -235,22 +192,10 @@ export class ViewResolver { @Args('id', { type: () => String }) id: string, @AuthWorkspace() workspace: WorkspaceEntity, ): Promise { - const isWorkspaceMigrationV2Enabled = - await this.featureFlagService.isFeatureEnabled( - FeatureFlagKey.IS_WORKSPACE_MIGRATION_V2_ENABLED, - workspace.id, - ); - - if (isWorkspaceMigrationV2Enabled) { - const deletedView = await this.viewV2Service.destroyOne({ - destroyViewInput: { id }, - workspaceId: workspace.id, - }); - - return isDefined(deletedView); - } - - const deletedView = await this.viewService.destroy(id, workspace.id); + const deletedView = await this.viewService.destroyOne({ + destroyViewInput: { id }, + workspaceId: workspace.id, + }); return isDefined(deletedView); } diff --git a/packages/twenty-server/src/engine/metadata-modules/view/services/tests/view.service.spec.ts b/packages/twenty-server/src/engine/metadata-modules/view/services/tests/view.service.spec.ts deleted file mode 100644 index 0c19794f0a..0000000000 --- a/packages/twenty-server/src/engine/metadata-modules/view/services/tests/view.service.spec.ts +++ /dev/null @@ -1,597 +0,0 @@ -import { Test, type TestingModule } from '@nestjs/testing'; -import { getRepositoryToken } from '@nestjs/typeorm'; - -import { type Repository } from 'typeorm'; - -import { I18nService } from 'src/engine/core-modules/i18n/i18n.service'; -import { ViewEntity } from 'src/engine/metadata-modules/view/entities/view.entity'; -import { ViewOpenRecordIn } from 'src/engine/metadata-modules/view/enums/view-open-record-in'; -import { ViewType } from 'src/engine/metadata-modules/view/enums/view-type.enum'; -import { ViewVisibility } from 'src/engine/metadata-modules/view/enums/view-visibility.enum'; -import { - ViewException, - ViewExceptionCode, - ViewExceptionMessageKey, - generateViewExceptionMessage, - generateViewUserFriendlyExceptionMessage, -} from 'src/engine/metadata-modules/view/exceptions/view.exception'; -import { ViewService } from 'src/engine/metadata-modules/view/services/view.service'; -import { WorkspaceCacheStorageService } from 'src/engine/workspace-cache-storage/workspace-cache-storage.service'; - -describe('ViewService', () => { - let viewService: ViewService; - let viewRepository: Repository; - let i18nService: I18nService; - - const mockView = { - id: 'view-id', - name: 'Test View', - objectMetadataId: 'object-id', - workspaceId: 'workspace-id', - type: ViewType.TABLE, - icon: 'test-icon', - position: 0, - isCompact: false, - isCustom: true, - key: 'INDEX', - openRecordIn: ViewOpenRecordIn.SIDE_PANEL, - kanbanAggregateOperation: null, - kanbanAggregateOperationFieldMetadataId: null, - anyFieldFilterValue: null, - visibility: ViewVisibility.WORKSPACE, - createdByUserWorkspaceId: null, - createdAt: new Date(), - updatedAt: new Date(), - deletedAt: null, - } as ViewEntity; - - beforeEach(async () => { - const module: TestingModule = await Test.createTestingModule({ - providers: [ - ViewService, - { - provide: getRepositoryToken(ViewEntity), - useValue: { - find: jest.fn(), - findOne: jest.fn(), - create: jest.fn(), - save: jest.fn(), - softDelete: jest.fn(), - delete: jest.fn(), - }, - }, - { - provide: WorkspaceCacheStorageService, - useValue: { - flushGraphQLOperation: jest.fn(), - }, - }, - { - provide: I18nService, - useValue: { - translateMessage: jest.fn(), - }, - }, - ], - }).compile(); - - viewService = module.get(ViewService); - viewRepository = module.get>( - getRepositoryToken(ViewEntity), - ); - i18nService = module.get(I18nService); - }); - - it('should be defined', () => { - expect(viewService).toBeDefined(); - }); - - describe('findByWorkspaceId', () => { - it('should return workspace views and user-owned unlisted views', async () => { - const workspaceId = 'workspace-id'; - const userWorkspaceId = 'user-workspace-id'; - const workspaceView = { - ...mockView, - id: 'workspace-view', - visibility: ViewVisibility.WORKSPACE, - } as ViewEntity; - const userUnlistedView = { - ...mockView, - id: 'user-unlisted-view', - visibility: ViewVisibility.UNLISTED, - createdByUserWorkspaceId: userWorkspaceId, - } as ViewEntity; - const otherUserUnlistedView = { - ...mockView, - id: 'other-user-unlisted-view', - visibility: ViewVisibility.UNLISTED, - createdByUserWorkspaceId: 'other-user-workspace-id', - } as ViewEntity; - const allViews = [workspaceView, userUnlistedView, otherUserUnlistedView]; - - jest.spyOn(viewRepository, 'find').mockResolvedValue(allViews); - - const result = await viewService.findByWorkspaceId( - workspaceId, - userWorkspaceId, - ); - - expect(viewRepository.find).toHaveBeenCalledWith({ - where: { - workspaceId, - deletedAt: expect.anything(), - }, - order: { position: 'ASC' }, - relations: [ - 'workspace', - 'viewFields', - 'viewFilters', - 'viewSorts', - 'viewGroups', - 'viewFilterGroups', - ], - }); - expect(result).toEqual([workspaceView, userUnlistedView]); - expect(result).not.toContain(otherUserUnlistedView); - }); - - it('should return only workspace views when no userWorkspaceId provided', async () => { - const workspaceId = 'workspace-id'; - const workspaceView = { - ...mockView, - id: 'workspace-view', - visibility: ViewVisibility.WORKSPACE, - } as ViewEntity; - const unlistedView = { - ...mockView, - id: 'unlisted-view', - visibility: ViewVisibility.UNLISTED, - createdByUserWorkspaceId: 'some-user-workspace-id', - } as ViewEntity; - const allViews = [workspaceView, unlistedView]; - - jest.spyOn(viewRepository, 'find').mockResolvedValue(allViews); - - const result = await viewService.findByWorkspaceId(workspaceId); - - expect(result).toEqual([workspaceView]); - expect(result).not.toContain(unlistedView); - }); - }); - - describe('findByObjectMetadataId', () => { - it('should return workspace views and user-owned unlisted views for an object', async () => { - const workspaceId = 'workspace-id'; - const objectMetadataId = 'object-id'; - const userWorkspaceId = 'user-workspace-id'; - const workspaceView = { - ...mockView, - id: 'workspace-view', - visibility: ViewVisibility.WORKSPACE, - objectMetadataId, - } as ViewEntity; - const userUnlistedView = { - ...mockView, - id: 'user-unlisted-view', - visibility: ViewVisibility.UNLISTED, - createdByUserWorkspaceId: userWorkspaceId, - objectMetadataId, - } as ViewEntity; - const otherUserUnlistedView = { - ...mockView, - id: 'other-user-unlisted-view', - visibility: ViewVisibility.UNLISTED, - createdByUserWorkspaceId: 'other-user-workspace-id', - objectMetadataId, - } as ViewEntity; - const allViews = [workspaceView, userUnlistedView, otherUserUnlistedView]; - - jest.spyOn(viewRepository, 'find').mockResolvedValue(allViews); - - const result = await viewService.findByObjectMetadataId( - workspaceId, - objectMetadataId, - userWorkspaceId, - ); - - expect(viewRepository.find).toHaveBeenCalledWith({ - where: { - workspaceId, - objectMetadataId, - deletedAt: expect.anything(), - }, - order: { position: 'ASC' }, - relations: [ - 'workspace', - 'viewFields', - 'viewFilters', - 'viewSorts', - 'viewGroups', - 'viewFilterGroups', - ], - }); - expect(result).toEqual([workspaceView, userUnlistedView]); - expect(result).not.toContain(otherUserUnlistedView); - }); - - it('should return only workspace views when no userWorkspaceId provided', async () => { - const workspaceId = 'workspace-id'; - const objectMetadataId = 'object-id'; - const workspaceView = { - ...mockView, - id: 'workspace-view', - visibility: ViewVisibility.WORKSPACE, - objectMetadataId, - } as ViewEntity; - const unlistedView = { - ...mockView, - id: 'unlisted-view', - visibility: ViewVisibility.UNLISTED, - createdByUserWorkspaceId: 'some-user-workspace-id', - objectMetadataId, - } as ViewEntity; - const allViews = [workspaceView, unlistedView]; - - jest.spyOn(viewRepository, 'find').mockResolvedValue(allViews); - - const result = await viewService.findByObjectMetadataId( - workspaceId, - objectMetadataId, - ); - - expect(result).toEqual([workspaceView]); - expect(result).not.toContain(unlistedView); - }); - }); - - describe('findById', () => { - it('should return a view by id', async () => { - const id = 'view-id'; - const workspaceId = 'workspace-id'; - - jest.spyOn(viewRepository, 'findOne').mockResolvedValue(mockView); - - const result = await viewService.findById(id, workspaceId); - - expect(viewRepository.findOne).toHaveBeenCalledWith({ - where: { - id, - workspaceId, - deletedAt: expect.anything(), - }, - relations: [ - 'workspace', - 'viewFields', - 'viewFilters', - 'viewSorts', - 'viewGroups', - 'viewFilterGroups', - ], - }); - expect(result).toEqual(mockView); - }); - - it('should return null when view is not found', async () => { - const id = 'non-existent-id'; - const workspaceId = 'workspace-id'; - - jest.spyOn(viewRepository, 'findOne').mockResolvedValue(null); - - const result = await viewService.findById(id, workspaceId); - - expect(result).toBeNull(); - }); - }); - - describe('create', () => { - const validViewData = { - name: 'Test View', - workspaceId: 'workspace-id', - objectMetadataId: 'object-id', - type: ViewType.TABLE, - icon: 'test-icon', - }; - - it('should create a view successfully', async () => { - jest.spyOn(viewRepository, 'create').mockReturnValue(mockView); - jest.spyOn(viewRepository, 'save').mockResolvedValue(mockView); - - const result = await viewService.create(validViewData); - - expect(viewRepository.create).toHaveBeenCalledWith({ - ...validViewData, - isCustom: true, - }); - expect(viewRepository.save).toHaveBeenCalledWith(mockView); - expect(result).toEqual(mockView); - }); - - it('should throw exception when workspaceId is missing', async () => { - const invalidData = { ...validViewData, workspaceId: undefined }; - - await expect(viewService.create(invalidData)).rejects.toThrow( - new ViewException( - generateViewExceptionMessage( - ViewExceptionMessageKey.WORKSPACE_ID_REQUIRED, - ), - ViewExceptionCode.INVALID_VIEW_DATA, - { - userFriendlyMessage: generateViewUserFriendlyExceptionMessage( - ViewExceptionMessageKey.WORKSPACE_ID_REQUIRED, - ), - }, - ), - ); - }); - - it('should throw exception when objectMetadataId is missing', async () => { - const invalidData = { ...validViewData, objectMetadataId: undefined }; - - await expect(viewService.create(invalidData)).rejects.toThrow( - new ViewException( - generateViewExceptionMessage( - ViewExceptionMessageKey.OBJECT_METADATA_ID_REQUIRED, - ), - ViewExceptionCode.INVALID_VIEW_DATA, - { - userFriendlyMessage: generateViewUserFriendlyExceptionMessage( - ViewExceptionMessageKey.OBJECT_METADATA_ID_REQUIRED, - ), - }, - ), - ); - }); - }); - - describe('update', () => { - it('should update a view successfully', async () => { - const id = 'view-id'; - const workspaceId = 'workspace-id'; - const updateData = { name: 'Updated View' }; - const updatedView = { ...mockView, ...updateData }; - - jest.spyOn(viewService, 'findById').mockResolvedValue(mockView); - jest.spyOn(viewRepository, 'save').mockResolvedValue(updatedView); - - const result = await viewService.update(id, workspaceId, updateData); - - expect(viewService.findById).toHaveBeenCalledWith(id, workspaceId); - expect(viewRepository.save).toHaveBeenCalledWith({ - id, - ...updateData, - }); - expect(result).toEqual({ ...mockView, ...updatedView }); - }); - - it('should throw exception when view is not found', async () => { - const id = 'non-existent-id'; - const workspaceId = 'workspace-id'; - const updateData = { name: 'Updated View' }; - - jest.spyOn(viewService, 'findById').mockResolvedValue(null); - - await expect( - viewService.update(id, workspaceId, updateData), - ).rejects.toThrow( - new ViewException( - generateViewExceptionMessage( - ViewExceptionMessageKey.VIEW_NOT_FOUND, - id, - ), - ViewExceptionCode.VIEW_NOT_FOUND, - ), - ); - }); - - it('should re-allocate view to current user when changing from WORKSPACE to UNLISTED visibility', async () => { - const id = 'view-id'; - const workspaceId = 'workspace-id'; - const userWorkspaceId = 'current-user-workspace-id'; - const workspaceView = { - ...mockView, - visibility: ViewVisibility.WORKSPACE, - createdByUserWorkspaceId: null, - } as ViewEntity; - const updateData = { visibility: ViewVisibility.UNLISTED }; - const expectedSaveData = { - id, - visibility: ViewVisibility.UNLISTED, - createdByUserWorkspaceId: userWorkspaceId, - }; - const updatedView = { - ...workspaceView, - ...expectedSaveData, - }; - - jest.spyOn(viewService, 'findById').mockResolvedValue(workspaceView); - jest.spyOn(viewRepository, 'save').mockResolvedValue(updatedView); - - const result = await viewService.update( - id, - workspaceId, - updateData, - userWorkspaceId, - ); - - expect(viewService.findById).toHaveBeenCalledWith(id, workspaceId); - expect(viewRepository.save).toHaveBeenCalledWith(expectedSaveData); - expect(result.createdByUserWorkspaceId).toBe(userWorkspaceId); - }); - - it('should not change createdByUserWorkspaceId when visibility is not changing to UNLISTED', async () => { - const id = 'view-id'; - const workspaceId = 'workspace-id'; - const userWorkspaceId = 'current-user-workspace-id'; - const updateData = { name: 'Updated Name' }; - const updatedView = { ...mockView, ...updateData }; - - jest.spyOn(viewService, 'findById').mockResolvedValue(mockView); - jest.spyOn(viewRepository, 'save').mockResolvedValue(updatedView); - - await viewService.update(id, workspaceId, updateData, userWorkspaceId); - - expect(viewRepository.save).toHaveBeenCalledWith({ - id, - ...updateData, - }); - expect(viewRepository.save).not.toHaveBeenCalledWith( - expect.objectContaining({ - createdByUserWorkspaceId: userWorkspaceId, - }), - ); - }); - - it('should not change createdByUserWorkspaceId when view is already UNLISTED', async () => { - const id = 'view-id'; - const workspaceId = 'workspace-id'; - const userWorkspaceId = 'current-user-workspace-id'; - const originalOwner = 'original-owner-workspace-id'; - const unlistedView = { - ...mockView, - visibility: ViewVisibility.UNLISTED, - createdByUserWorkspaceId: originalOwner, - } as ViewEntity; - const updateData = { visibility: ViewVisibility.UNLISTED }; - const updatedView = { ...unlistedView, ...updateData }; - - jest.spyOn(viewService, 'findById').mockResolvedValue(unlistedView); - jest.spyOn(viewRepository, 'save').mockResolvedValue(updatedView); - - await viewService.update(id, workspaceId, updateData, userWorkspaceId); - - expect(viewRepository.save).toHaveBeenCalledWith({ - id, - ...updateData, - }); - expect(viewRepository.save).not.toHaveBeenCalledWith( - expect.objectContaining({ - createdByUserWorkspaceId: userWorkspaceId, - }), - ); - }); - }); - - describe('delete', () => { - it('should delete a view successfully', async () => { - const id = 'view-id'; - const workspaceId = 'workspace-id'; - - jest.spyOn(viewService, 'findById').mockResolvedValue(mockView); - jest.spyOn(viewRepository, 'softDelete').mockResolvedValue({} as any); - - const result = await viewService.delete(id, workspaceId); - - expect(viewService.findById).toHaveBeenCalledWith(id, workspaceId); - expect(viewRepository.softDelete).toHaveBeenCalledWith(id); - expect(result).toEqual(mockView); - }); - - it('should throw exception when view is not found', async () => { - const id = 'non-existent-id'; - const workspaceId = 'workspace-id'; - - jest.spyOn(viewService, 'findById').mockResolvedValue(null); - - await expect(viewService.delete(id, workspaceId)).rejects.toThrow( - new ViewException( - generateViewExceptionMessage( - ViewExceptionMessageKey.VIEW_NOT_FOUND, - id, - ), - ViewExceptionCode.VIEW_NOT_FOUND, - ), - ); - }); - }); - - describe('destroy', () => { - it('should destroy a view successfully', async () => { - const id = 'view-id'; - const workspaceId = 'workspace-id'; - - jest - .spyOn(viewService, 'findByIdIncludingDeleted') - .mockResolvedValue(mockView); - jest.spyOn(viewRepository, 'delete').mockResolvedValue({} as any); - - const result = await viewService.destroy(id, workspaceId); - - expect(viewService.findByIdIncludingDeleted).toHaveBeenCalledWith( - id, - workspaceId, - ); - expect(viewRepository.delete).toHaveBeenCalledWith(id); - expect(result).toEqual(true); - }); - }); - - describe('processViewNameWithTemplate', () => { - it('should replace template with objectLabelPlural', () => { - const viewName = 'All {objectLabelPlural}'; - const objectLabelPlural = 'Companies'; - - jest.spyOn(i18nService, 'translateMessage').mockImplementation((args) => { - return args.messageId; - }); - - const result = viewService.processViewNameWithTemplate( - viewName, - false, - objectLabelPlural, - 'en', - ); - - expect(result).toBe('All Companies'); - }); - - it('should return translated value when translation exists', () => { - const viewName = 'All {objectLabelPlural}'; - const objectLabelPlural = 'Companies'; - const translatedTemplate = 'Toutes les Companies'; - - jest - .spyOn(i18nService, 'translateMessage') - .mockReturnValue(translatedTemplate); - - const result = viewService.processViewNameWithTemplate( - viewName, - false, - objectLabelPlural, - 'fr-FR', - ); - - expect(result).toBe(translatedTemplate); - }); - - it('should not translate custom views', () => { - const viewName = 'My Custom View'; - - const result = viewService.processViewNameWithTemplate( - viewName, - true, - undefined, - 'en', - ); - - expect(i18nService.translateMessage).not.toHaveBeenCalled(); - expect(result).toBe(viewName); - }); - - it('should return original name when no objectLabelPlural provided for template', () => { - const viewName = 'All {objectLabelPlural}'; - - jest.spyOn(i18nService, 'translateMessage').mockImplementation((args) => { - return args.messageId; - }); - - const result = viewService.processViewNameWithTemplate( - viewName, - false, - undefined, - 'en', - ); - - expect(result).toBe(viewName); - }); - }); -}); diff --git a/packages/twenty-server/src/engine/metadata-modules/view/services/view-v2.service.ts b/packages/twenty-server/src/engine/metadata-modules/view/services/view-v2.service.ts deleted file mode 100644 index 8e8f8776bf..0000000000 --- a/packages/twenty-server/src/engine/metadata-modules/view/services/view-v2.service.ts +++ /dev/null @@ -1,315 +0,0 @@ -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 { computeFlatEntityMapsFromTo } from 'src/engine/metadata-modules/flat-entity/utils/compute-flat-entity-maps-from-to.util'; -import { findFlatEntityByIdInFlatEntityMapsOrThrow } from 'src/engine/metadata-modules/flat-entity/utils/find-flat-entity-by-id-in-flat-entity-maps-or-throw.util'; -import { fromCreateViewInputToFlatViewToCreate } from 'src/engine/metadata-modules/flat-view/utils/from-create-view-input-to-flat-view-to-create.util'; -import { fromDeleteViewInputToFlatViewOrThrow } from 'src/engine/metadata-modules/flat-view/utils/from-delete-view-input-to-flat-view-or-throw.util'; -import { fromDestroyViewInputToFlatViewOrThrow } from 'src/engine/metadata-modules/flat-view/utils/from-destroy-view-input-to-flat-view-or-throw.util'; -import { fromUpdateViewInputToFlatViewToUpdateOrThrow } from 'src/engine/metadata-modules/flat-view/utils/from-update-view-input-to-flat-view-to-update-or-throw.util'; -import { CreateViewInput } from 'src/engine/metadata-modules/view/dtos/inputs/create-view.input'; -import { DeleteViewInput } from 'src/engine/metadata-modules/view/dtos/inputs/delete-view.input'; -import { DestroyViewInput } from 'src/engine/metadata-modules/view/dtos/inputs/destroy-view.input'; -import { UpdateViewInput } from 'src/engine/metadata-modules/view/dtos/inputs/update-view.input'; -import { ViewDTO } from 'src/engine/metadata-modules/view/dtos/view.dto'; -import { WorkspaceMigrationBuilderExceptionV2 } from 'src/engine/workspace-manager/workspace-migration-v2/exceptions/workspace-migration-builder-exception-v2'; -import { WorkspaceMigrationValidateBuildAndRunService } from 'src/engine/workspace-manager/workspace-migration-v2/services/workspace-migration-validate-build-and-run-service'; - -@Injectable() -export class ViewV2Service { - constructor( - private readonly workspaceMigrationValidateBuildAndRunService: WorkspaceMigrationValidateBuildAndRunService, - private readonly flatEntityMapsCacheService: WorkspaceManyOrAllFlatEntityMapsCacheService, - private readonly applicationService: ApplicationService, - ) {} - - async createOne({ - createViewInput, - workspaceId, - createdByUserWorkspaceId, - }: { - createViewInput: CreateViewInput; - workspaceId: string; - createdByUserWorkspaceId?: string; - }): Promise { - const { workspaceCustomFlatApplication } = - await this.applicationService.findWorkspaceTwentyStandardAndCustomApplicationOrThrow( - { - workspaceId, - }, - ); - - const { - flatObjectMetadataMaps, - flatViewMaps: existingFlatViewMaps, - flatFieldMetadataMaps: existingFlatFieldMetadataMaps, - } = await this.flatEntityMapsCacheService.getOrRecomputeManyOrAllFlatEntityMaps( - { - workspaceId, - flatMapsKeys: [ - 'flatObjectMetadataMaps', - 'flatViewMaps', - 'flatFieldMetadataMaps', - ], - }, - ); - - const flatViewFromCreateInput = fromCreateViewInputToFlatViewToCreate({ - createViewInput, - workspaceId, - createdByUserWorkspaceId, - workspaceCustomApplicationId: workspaceCustomFlatApplication.id, - }); - - const validateAndBuildResult = - await this.workspaceMigrationValidateBuildAndRunService.validateBuildAndRunWorkspaceMigration( - { - fromToAllFlatEntityMaps: { - flatViewMaps: computeFlatEntityMapsFromTo({ - flatEntityMaps: existingFlatViewMaps, - flatEntityToCreate: [flatViewFromCreateInput], - flatEntityToDelete: [], - flatEntityToUpdate: [], - }), - }, - dependencyAllFlatEntityMaps: { - flatObjectMetadataMaps: flatObjectMetadataMaps, - flatFieldMetadataMaps: existingFlatFieldMetadataMaps, - }, - buildOptions: { - isSystemBuild: false, - }, - workspaceId, - }, - ); - - if (isDefined(validateAndBuildResult)) { - throw new WorkspaceMigrationBuilderExceptionV2( - validateAndBuildResult, - 'Multiple validation errors occurred while creating view', - ); - } - - const { flatViewMaps: recomputedExistingFlatViewMaps } = - await this.flatEntityMapsCacheService.getOrRecomputeManyOrAllFlatEntityMaps( - { - workspaceId, - flatMapsKeys: ['flatViewMaps'], - }, - ); - - return findFlatEntityByIdInFlatEntityMapsOrThrow({ - flatEntityId: flatViewFromCreateInput.id, - flatEntityMaps: recomputedExistingFlatViewMaps, - }); - } - - async updateOne({ - updateViewInput, - workspaceId, - userWorkspaceId, - }: { - updateViewInput: UpdateViewInput; - workspaceId: string; - userWorkspaceId?: string; - }): Promise { - const { - flatViewMaps: existingFlatViewMaps, - flatFieldMetadataMaps: existingFlatFieldMetadataMaps, - } = - await this.flatEntityMapsCacheService.getOrRecomputeManyOrAllFlatEntityMaps( - { - workspaceId, - flatMapsKeys: ['flatViewMaps', 'flatFieldMetadataMaps'], - }, - ); - - const flatViewFromUpdateInput = - fromUpdateViewInputToFlatViewToUpdateOrThrow({ - updateViewInput, - flatViewMaps: existingFlatViewMaps, - }); - - const existingFlatView = existingFlatViewMaps.byId[updateViewInput.id]; - - // If changing visibility from WORKSPACE to UNLISTED, ensure createdByUserWorkspaceId is set - // This prevents the view from disappearing for the user making the change - if ( - isDefined(existingFlatView) && - isDefined(updateViewInput.visibility) && - updateViewInput.visibility === 'UNLISTED' && - existingFlatView.visibility === 'WORKSPACE' && - isDefined(userWorkspaceId) - ) { - // Re-allocate the view to the current user - flatViewFromUpdateInput.createdByUserWorkspaceId = userWorkspaceId; - } - - const validateAndBuildResult = - await this.workspaceMigrationValidateBuildAndRunService.validateBuildAndRunWorkspaceMigration( - { - fromToAllFlatEntityMaps: { - flatViewMaps: computeFlatEntityMapsFromTo({ - flatEntityMaps: existingFlatViewMaps, - flatEntityToCreate: [], - flatEntityToDelete: [], - flatEntityToUpdate: [flatViewFromUpdateInput], - }), - }, - dependencyAllFlatEntityMaps: { - flatFieldMetadataMaps: existingFlatFieldMetadataMaps, - }, - buildOptions: { - isSystemBuild: false, - }, - workspaceId, - }, - ); - - if (isDefined(validateAndBuildResult)) { - throw new WorkspaceMigrationBuilderExceptionV2( - validateAndBuildResult, - 'Multiple validation errors occurred while updating view', - ); - } - - const { flatViewMaps: recomputedExistingFlatViewMaps } = - await this.flatEntityMapsCacheService.getOrRecomputeManyOrAllFlatEntityMaps( - { - workspaceId, - flatMapsKeys: ['flatViewMaps'], - }, - ); - - return findFlatEntityByIdInFlatEntityMapsOrThrow({ - flatEntityId: updateViewInput.id, - flatEntityMaps: recomputedExistingFlatViewMaps, - }); - } - - async deleteOne({ - deleteViewInput, - workspaceId, - }: { - deleteViewInput: DeleteViewInput; - workspaceId: string; - }): Promise { - const { - flatViewMaps: existingFlatViewMaps, - flatFieldMetadataMaps: existingFlatFieldMetadataMaps, - } = - await this.flatEntityMapsCacheService.getOrRecomputeManyOrAllFlatEntityMaps( - { - workspaceId, - flatMapsKeys: ['flatViewMaps', 'flatFieldMetadataMaps'], - }, - ); - - const optimisticallyUpdatedFlatViewWithDeletedAt = - fromDeleteViewInputToFlatViewOrThrow({ - deleteViewInput, - flatViewMaps: existingFlatViewMaps, - }); - - const validateAndBuildResult = - await this.workspaceMigrationValidateBuildAndRunService.validateBuildAndRunWorkspaceMigration( - { - fromToAllFlatEntityMaps: { - flatViewMaps: computeFlatEntityMapsFromTo({ - flatEntityMaps: existingFlatViewMaps, - flatEntityToCreate: [], - flatEntityToDelete: [], - flatEntityToUpdate: [optimisticallyUpdatedFlatViewWithDeletedAt], - }), - }, - dependencyAllFlatEntityMaps: { - flatFieldMetadataMaps: existingFlatFieldMetadataMaps, - }, - buildOptions: { - isSystemBuild: false, - }, - workspaceId, - }, - ); - - if (isDefined(validateAndBuildResult)) { - throw new WorkspaceMigrationBuilderExceptionV2( - validateAndBuildResult, - 'Multiple validation errors occurred while deleting view', - ); - } - - const { flatViewMaps: recomputedExistingFlatViewMaps } = - await this.flatEntityMapsCacheService.getOrRecomputeManyOrAllFlatEntityMaps( - { - workspaceId, - flatMapsKeys: ['flatViewMaps'], - }, - ); - - return findFlatEntityByIdInFlatEntityMapsOrThrow({ - flatEntityId: deleteViewInput.id, - flatEntityMaps: recomputedExistingFlatViewMaps, - }); - } - - async destroyOne({ - destroyViewInput, - workspaceId, - }: { - destroyViewInput: DestroyViewInput; - workspaceId: string; - }): Promise { - const { - flatViewMaps: existingFlatViewMaps, - flatFieldMetadataMaps: existingFlatFieldMetadataMaps, - } = - await this.flatEntityMapsCacheService.getOrRecomputeManyOrAllFlatEntityMaps( - { - workspaceId, - flatMapsKeys: ['flatViewMaps', 'flatFieldMetadataMaps'], - }, - ); - - const flatViewFromDestroyInput = fromDestroyViewInputToFlatViewOrThrow({ - destroyViewInput, - flatViewMaps: existingFlatViewMaps, - }); - - const validateAndBuildResult = - await this.workspaceMigrationValidateBuildAndRunService.validateBuildAndRunWorkspaceMigration( - { - fromToAllFlatEntityMaps: { - flatViewMaps: computeFlatEntityMapsFromTo({ - flatEntityMaps: existingFlatViewMaps, - flatEntityToCreate: [], - flatEntityToDelete: [flatViewFromDestroyInput], - flatEntityToUpdate: [], - }), - }, - buildOptions: { - isSystemBuild: false, - inferDeletionFromMissingEntities: { - view: true, - }, - }, - dependencyAllFlatEntityMaps: { - flatFieldMetadataMaps: existingFlatFieldMetadataMaps, - }, - workspaceId, - }, - ); - - if (isDefined(validateAndBuildResult)) { - throw new WorkspaceMigrationBuilderExceptionV2( - validateAndBuildResult, - 'Multiple validation errors occurred while destroying view', - ); - } - - return flatViewFromDestroyInput; - } -} diff --git a/packages/twenty-server/src/engine/metadata-modules/view/services/view.service.ts b/packages/twenty-server/src/engine/metadata-modules/view/services/view.service.ts index eeba3f5cf2..1dcdc2987a 100644 --- a/packages/twenty-server/src/engine/metadata-modules/view/services/view.service.ts +++ b/packages/twenty-server/src/engine/metadata-modules/view/services/view.service.ts @@ -1,33 +1,366 @@ import { Injectable } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; -import { type APP_LOCALES, SOURCE_LOCALE } from 'twenty-shared/translations'; import { isDefined } from 'twenty-shared/utils'; +import { APP_LOCALES, SOURCE_LOCALE } from 'twenty-shared/translations'; import { IsNull, Repository } from 'typeorm'; +import { ApplicationService } from 'src/engine/core-modules/application/application.service'; import { I18nService } from 'src/engine/core-modules/i18n/i18n.service'; import { generateMessageId } from 'src/engine/core-modules/i18n/utils/generateMessageId'; -import { FIND_ALL_CORE_VIEWS_GRAPHQL_OPERATION } from 'src/engine/metadata-modules/view/constants/find-all-core-views-graphql-operation.constant'; +import { WorkspaceManyOrAllFlatEntityMapsCacheService } from 'src/engine/metadata-modules/flat-entity/services/workspace-many-or-all-flat-entity-maps-cache.service'; +import { computeFlatEntityMapsFromTo } from 'src/engine/metadata-modules/flat-entity/utils/compute-flat-entity-maps-from-to.util'; +import { findFlatEntityByIdInFlatEntityMapsOrThrow } from 'src/engine/metadata-modules/flat-entity/utils/find-flat-entity-by-id-in-flat-entity-maps-or-throw.util'; +import { fromCreateViewInputToFlatViewToCreate } from 'src/engine/metadata-modules/flat-view/utils/from-create-view-input-to-flat-view-to-create.util'; +import { fromDeleteViewInputToFlatViewOrThrow } from 'src/engine/metadata-modules/flat-view/utils/from-delete-view-input-to-flat-view-or-throw.util'; +import { fromDestroyViewInputToFlatViewOrThrow } from 'src/engine/metadata-modules/flat-view/utils/from-destroy-view-input-to-flat-view-or-throw.util'; +import { fromUpdateViewInputToFlatViewToUpdateOrThrow } from 'src/engine/metadata-modules/flat-view/utils/from-update-view-input-to-flat-view-to-update-or-throw.util'; +import { CreateViewInput } from 'src/engine/metadata-modules/view/dtos/inputs/create-view.input'; +import { DeleteViewInput } from 'src/engine/metadata-modules/view/dtos/inputs/delete-view.input'; +import { DestroyViewInput } from 'src/engine/metadata-modules/view/dtos/inputs/destroy-view.input'; +import { UpdateViewInput } from 'src/engine/metadata-modules/view/dtos/inputs/update-view.input'; +import { ViewDTO } from 'src/engine/metadata-modules/view/dtos/view.dto'; import { ViewEntity } from 'src/engine/metadata-modules/view/entities/view.entity'; import { ViewVisibility } from 'src/engine/metadata-modules/view/enums/view-visibility.enum'; -import { - ViewException, - ViewExceptionCode, - ViewExceptionMessageKey, - generateViewExceptionMessage, - generateViewUserFriendlyExceptionMessage, -} from 'src/engine/metadata-modules/view/exceptions/view.exception'; -import { WorkspaceCacheStorageService } from 'src/engine/workspace-cache-storage/workspace-cache-storage.service'; +import { WorkspaceMigrationBuilderExceptionV2 } from 'src/engine/workspace-manager/workspace-migration-v2/exceptions/workspace-migration-builder-exception-v2'; +import { WorkspaceMigrationValidateBuildAndRunService } from 'src/engine/workspace-manager/workspace-migration-v2/services/workspace-migration-validate-build-and-run-service'; @Injectable() export class ViewService { constructor( @InjectRepository(ViewEntity) private readonly viewRepository: Repository, + private readonly workspaceMigrationValidateBuildAndRunService: WorkspaceMigrationValidateBuildAndRunService, + private readonly flatEntityMapsCacheService: WorkspaceManyOrAllFlatEntityMapsCacheService, + private readonly applicationService: ApplicationService, private readonly i18nService: I18nService, - private readonly workspaceCacheStorageService: WorkspaceCacheStorageService, ) {} + async createOne({ + createViewInput, + workspaceId, + createdByUserWorkspaceId, + }: { + createViewInput: CreateViewInput; + workspaceId: string; + createdByUserWorkspaceId?: string; + }): Promise { + const { workspaceCustomFlatApplication } = + await this.applicationService.findWorkspaceTwentyStandardAndCustomApplicationOrThrow( + { + workspaceId, + }, + ); + + const { + flatObjectMetadataMaps, + flatViewMaps: existingFlatViewMaps, + flatFieldMetadataMaps: existingFlatFieldMetadataMaps, + } = await this.flatEntityMapsCacheService.getOrRecomputeManyOrAllFlatEntityMaps( + { + workspaceId, + flatMapsKeys: [ + 'flatObjectMetadataMaps', + 'flatViewMaps', + 'flatFieldMetadataMaps', + ], + }, + ); + + const flatViewFromCreateInput = fromCreateViewInputToFlatViewToCreate({ + createViewInput, + workspaceId, + createdByUserWorkspaceId, + workspaceCustomApplicationId: workspaceCustomFlatApplication.id, + }); + + const validateAndBuildResult = + await this.workspaceMigrationValidateBuildAndRunService.validateBuildAndRunWorkspaceMigration( + { + fromToAllFlatEntityMaps: { + flatViewMaps: computeFlatEntityMapsFromTo({ + flatEntityMaps: existingFlatViewMaps, + flatEntityToCreate: [flatViewFromCreateInput], + flatEntityToDelete: [], + flatEntityToUpdate: [], + }), + }, + dependencyAllFlatEntityMaps: { + flatObjectMetadataMaps: flatObjectMetadataMaps, + flatFieldMetadataMaps: existingFlatFieldMetadataMaps, + }, + buildOptions: { + isSystemBuild: false, + }, + workspaceId, + }, + ); + + if (isDefined(validateAndBuildResult)) { + throw new WorkspaceMigrationBuilderExceptionV2( + validateAndBuildResult, + 'Multiple validation errors occurred while creating view', + ); + } + + const { flatViewMaps: recomputedExistingFlatViewMaps } = + await this.flatEntityMapsCacheService.getOrRecomputeManyOrAllFlatEntityMaps( + { + workspaceId, + flatMapsKeys: ['flatViewMaps'], + }, + ); + + return findFlatEntityByIdInFlatEntityMapsOrThrow({ + flatEntityId: flatViewFromCreateInput.id, + flatEntityMaps: recomputedExistingFlatViewMaps, + }); + } + + async updateOne({ + updateViewInput, + workspaceId, + userWorkspaceId, + }: { + updateViewInput: UpdateViewInput; + workspaceId: string; + userWorkspaceId?: string; + }): Promise { + const { + flatViewMaps: existingFlatViewMaps, + flatFieldMetadataMaps: existingFlatFieldMetadataMaps, + } = + await this.flatEntityMapsCacheService.getOrRecomputeManyOrAllFlatEntityMaps( + { + workspaceId, + flatMapsKeys: ['flatViewMaps', 'flatFieldMetadataMaps'], + }, + ); + + const flatViewFromUpdateInput = + fromUpdateViewInputToFlatViewToUpdateOrThrow({ + updateViewInput, + flatViewMaps: existingFlatViewMaps, + }); + + const existingFlatView = existingFlatViewMaps.byId[updateViewInput.id]; + + // If changing visibility from WORKSPACE to UNLISTED, ensure createdByUserWorkspaceId is set + // This prevents the view from disappearing for the user making the change + if ( + isDefined(existingFlatView) && + isDefined(updateViewInput.visibility) && + updateViewInput.visibility === 'UNLISTED' && + existingFlatView.visibility === 'WORKSPACE' && + isDefined(userWorkspaceId) + ) { + // Re-allocate the view to the current user + flatViewFromUpdateInput.createdByUserWorkspaceId = userWorkspaceId; + } + + const validateAndBuildResult = + await this.workspaceMigrationValidateBuildAndRunService.validateBuildAndRunWorkspaceMigration( + { + fromToAllFlatEntityMaps: { + flatViewMaps: computeFlatEntityMapsFromTo({ + flatEntityMaps: existingFlatViewMaps, + flatEntityToCreate: [], + flatEntityToDelete: [], + flatEntityToUpdate: [flatViewFromUpdateInput], + }), + }, + dependencyAllFlatEntityMaps: { + flatFieldMetadataMaps: existingFlatFieldMetadataMaps, + }, + buildOptions: { + isSystemBuild: false, + }, + workspaceId, + }, + ); + + if (isDefined(validateAndBuildResult)) { + throw new WorkspaceMigrationBuilderExceptionV2( + validateAndBuildResult, + 'Multiple validation errors occurred while updating view', + ); + } + + const { flatViewMaps: recomputedExistingFlatViewMaps } = + await this.flatEntityMapsCacheService.getOrRecomputeManyOrAllFlatEntityMaps( + { + workspaceId, + flatMapsKeys: ['flatViewMaps'], + }, + ); + + return findFlatEntityByIdInFlatEntityMapsOrThrow({ + flatEntityId: updateViewInput.id, + flatEntityMaps: recomputedExistingFlatViewMaps, + }); + } + + async deleteOne({ + deleteViewInput, + workspaceId, + }: { + deleteViewInput: DeleteViewInput; + workspaceId: string; + }): Promise { + const { + flatViewMaps: existingFlatViewMaps, + flatFieldMetadataMaps: existingFlatFieldMetadataMaps, + } = + await this.flatEntityMapsCacheService.getOrRecomputeManyOrAllFlatEntityMaps( + { + workspaceId, + flatMapsKeys: ['flatViewMaps', 'flatFieldMetadataMaps'], + }, + ); + + const optimisticallyUpdatedFlatViewWithDeletedAt = + fromDeleteViewInputToFlatViewOrThrow({ + deleteViewInput, + flatViewMaps: existingFlatViewMaps, + }); + + const validateAndBuildResult = + await this.workspaceMigrationValidateBuildAndRunService.validateBuildAndRunWorkspaceMigration( + { + fromToAllFlatEntityMaps: { + flatViewMaps: computeFlatEntityMapsFromTo({ + flatEntityMaps: existingFlatViewMaps, + flatEntityToCreate: [], + flatEntityToDelete: [], + flatEntityToUpdate: [optimisticallyUpdatedFlatViewWithDeletedAt], + }), + }, + dependencyAllFlatEntityMaps: { + flatFieldMetadataMaps: existingFlatFieldMetadataMaps, + }, + buildOptions: { + isSystemBuild: false, + }, + workspaceId, + }, + ); + + if (isDefined(validateAndBuildResult)) { + throw new WorkspaceMigrationBuilderExceptionV2( + validateAndBuildResult, + 'Multiple validation errors occurred while deleting view', + ); + } + + const { flatViewMaps: recomputedExistingFlatViewMaps } = + await this.flatEntityMapsCacheService.getOrRecomputeManyOrAllFlatEntityMaps( + { + workspaceId, + flatMapsKeys: ['flatViewMaps'], + }, + ); + + return findFlatEntityByIdInFlatEntityMapsOrThrow({ + flatEntityId: deleteViewInput.id, + flatEntityMaps: recomputedExistingFlatViewMaps, + }); + } + + async destroyOne({ + destroyViewInput, + workspaceId, + }: { + destroyViewInput: DestroyViewInput; + workspaceId: string; + }): Promise { + const { + flatViewMaps: existingFlatViewMaps, + flatFieldMetadataMaps: existingFlatFieldMetadataMaps, + } = + await this.flatEntityMapsCacheService.getOrRecomputeManyOrAllFlatEntityMaps( + { + workspaceId, + flatMapsKeys: ['flatViewMaps', 'flatFieldMetadataMaps'], + }, + ); + + const flatViewFromDestroyInput = fromDestroyViewInputToFlatViewOrThrow({ + destroyViewInput, + flatViewMaps: existingFlatViewMaps, + }); + + const validateAndBuildResult = + await this.workspaceMigrationValidateBuildAndRunService.validateBuildAndRunWorkspaceMigration( + { + fromToAllFlatEntityMaps: { + flatViewMaps: computeFlatEntityMapsFromTo({ + flatEntityMaps: existingFlatViewMaps, + flatEntityToCreate: [], + flatEntityToDelete: [flatViewFromDestroyInput], + flatEntityToUpdate: [], + }), + }, + buildOptions: { + isSystemBuild: false, + inferDeletionFromMissingEntities: { + view: true, + }, + }, + dependencyAllFlatEntityMaps: { + flatFieldMetadataMaps: existingFlatFieldMetadataMaps, + }, + workspaceId, + }, + ); + + if (isDefined(validateAndBuildResult)) { + throw new WorkspaceMigrationBuilderExceptionV2( + validateAndBuildResult, + 'Multiple validation errors occurred while destroying view', + ); + } + + return flatViewFromDestroyInput; + } + + processViewNameWithTemplate( + viewName: string, + isCustom: boolean, + objectLabelPlural?: string, + locale?: keyof typeof APP_LOCALES, + ): string { + if (viewName.includes('{objectLabelPlural}') && objectLabelPlural) { + const messageId = generateMessageId(viewName); + const translatedTemplate = this.i18nService.translateMessage({ + messageId, + values: { + objectLabelPlural, + }, + locale: locale ?? SOURCE_LOCALE, + }); + + if (translatedTemplate !== messageId) { + return translatedTemplate; + } + + return viewName.replace('{objectLabelPlural}', objectLabelPlural); + } + + if (!isCustom) { + const messageId = generateMessageId(viewName); + const translatedMessage = this.i18nService.translateMessage({ + messageId, + locale: locale ?? SOURCE_LOCALE, + }); + + if (translatedMessage !== messageId) { + return translatedMessage; + } + } + + return viewName; + } + async findByWorkspaceId( workspaceId: string, userWorkspaceId?: string, @@ -147,186 +480,4 @@ export class ViewService { return view || null; } - - async create(viewData: Partial): Promise { - if (!isDefined(viewData.workspaceId)) { - throw new ViewException( - generateViewExceptionMessage( - ViewExceptionMessageKey.WORKSPACE_ID_REQUIRED, - ), - ViewExceptionCode.INVALID_VIEW_DATA, - { - userFriendlyMessage: generateViewUserFriendlyExceptionMessage( - ViewExceptionMessageKey.WORKSPACE_ID_REQUIRED, - ), - }, - ); - } - - if (!isDefined(viewData.objectMetadataId)) { - throw new ViewException( - generateViewExceptionMessage( - ViewExceptionMessageKey.OBJECT_METADATA_ID_REQUIRED, - ), - ViewExceptionCode.INVALID_VIEW_DATA, - { - userFriendlyMessage: generateViewUserFriendlyExceptionMessage( - ViewExceptionMessageKey.OBJECT_METADATA_ID_REQUIRED, - ), - }, - ); - } - - const view = this.viewRepository.create({ - ...viewData, - isCustom: true, - }); - - const savedView = await this.viewRepository.save(view); - - await this.flushGraphQLCache(viewData.workspaceId); - - return savedView; - } - - async update( - id: string, - workspaceId: string, - updateData: Partial, - userWorkspaceId?: string, - ): Promise { - const existingView = await this.findById(id, workspaceId); - - if (!isDefined(existingView)) { - throw new ViewException( - generateViewExceptionMessage( - ViewExceptionMessageKey.VIEW_NOT_FOUND, - id, - ), - ViewExceptionCode.VIEW_NOT_FOUND, - ); - } - - // If changing visibility from WORKSPACE to UNLISTED, ensure createdByUserWorkspaceId is set - // This prevents the view from disappearing for the user making the change - const dataToUpdate = { ...updateData }; - - if ( - isDefined(updateData.visibility) && - updateData.visibility === ViewVisibility.UNLISTED && - existingView.visibility === ViewVisibility.WORKSPACE && - isDefined(userWorkspaceId) - ) { - // Re-allocate the view to the current user if it has no owner or a different owner - dataToUpdate.createdByUserWorkspaceId = userWorkspaceId; - } - - const updatedView = await this.viewRepository.save({ - id, - ...dataToUpdate, - }); - - await this.flushGraphQLCache(workspaceId); - - return { ...existingView, ...updatedView }; - } - - async delete(id: string, workspaceId: string): Promise { - const view = await this.findById(id, workspaceId); - - if (!isDefined(view)) { - throw new ViewException( - generateViewExceptionMessage( - ViewExceptionMessageKey.VIEW_NOT_FOUND, - id, - ), - ViewExceptionCode.VIEW_NOT_FOUND, - ); - } - - await this.viewRepository.softDelete(id); - - await this.flushGraphQLCache(workspaceId); - - return view; - } - - async destroy(id: string, workspaceId: string): Promise { - const view = await this.findByIdIncludingDeleted(id, workspaceId); - - if (!isDefined(view)) { - throw new ViewException( - generateViewExceptionMessage( - ViewExceptionMessageKey.VIEW_NOT_FOUND, - id, - ), - ViewExceptionCode.VIEW_NOT_FOUND, - ); - } - - await this.viewRepository.delete(id); - await this.flushGraphQLCache(workspaceId); - - return true; - } - - processViewNameWithTemplate( - viewName: string, - isCustom: boolean, - objectLabelPlural?: string, - locale?: keyof typeof APP_LOCALES, - ): string { - if (viewName.includes('{objectLabelPlural}') && objectLabelPlural) { - const messageId = generateMessageId(viewName); - const translatedTemplate = this.i18nService.translateMessage({ - messageId, - values: { - objectLabelPlural, - }, - locale: locale ?? SOURCE_LOCALE, - }); - - if (translatedTemplate !== messageId) { - return translatedTemplate; - } - - return viewName.replace('{objectLabelPlural}', objectLabelPlural); - } - - if (!isCustom) { - const messageId = generateMessageId(viewName); - const translatedMessage = this.i18nService.translateMessage({ - messageId, - locale: locale ?? SOURCE_LOCALE, - }); - - if (translatedMessage !== messageId) { - return translatedMessage; - } - } - - return viewName; - } - - canUserUpdateView( - view: ViewEntity, - userWorkspaceId: string | undefined, - userHasViewsPermission: boolean, - ): boolean { - if (userHasViewsPermission) { - return true; - } - - return ( - view.visibility === ViewVisibility.UNLISTED && - view.createdByUserWorkspaceId === userWorkspaceId - ); - } - - async flushGraphQLCache(workspaceId: string): Promise { - await this.workspaceCacheStorageService.flushGraphQLOperation({ - operationName: FIND_ALL_CORE_VIEWS_GRAPHQL_OPERATION, - workspaceId, - }); - } } diff --git a/packages/twenty-server/src/engine/metadata-modules/view/view.module.ts b/packages/twenty-server/src/engine/metadata-modules/view/view.module.ts index 87836574b5..87fc538750 100644 --- a/packages/twenty-server/src/engine/metadata-modules/view/view.module.ts +++ b/packages/twenty-server/src/engine/metadata-modules/view/view.module.ts @@ -2,7 +2,6 @@ import { Module } from '@nestjs/common'; import { TypeOrmModule } from '@nestjs/typeorm'; import { ApplicationModule } from 'src/engine/core-modules/application/application.module'; -import { FeatureFlagModule } from 'src/engine/core-modules/feature-flag/feature-flag.module'; import { I18nModule } from 'src/engine/core-modules/i18n/i18n.module'; import { WorkspaceManyOrAllFlatEntityMapsCacheModule } from 'src/engine/metadata-modules/flat-entity/services/workspace-many-or-all-flat-entity-maps-cache.module'; import { FlatViewModule } from 'src/engine/metadata-modules/flat-view/flat-view.module'; @@ -17,7 +16,6 @@ import { ViewSortModule } from 'src/engine/metadata-modules/view-sort/view-sort. import { ViewController } from 'src/engine/metadata-modules/view/controllers/view.controller'; import { ViewEntity } from 'src/engine/metadata-modules/view/entities/view.entity'; import { ViewResolver } from 'src/engine/metadata-modules/view/resolvers/view.resolver'; -import { ViewV2Service } from 'src/engine/metadata-modules/view/services/view-v2.service'; import { ViewService } from 'src/engine/metadata-modules/view/services/view.service'; import { WorkspaceCacheStorageModule } from 'src/engine/workspace-cache-storage/workspace-cache-storage.module'; import { WorkspaceMigrationV2Module } from 'src/engine/workspace-manager/workspace-migration-v2/workspace-migration-v2.module'; @@ -33,7 +31,6 @@ import { WorkspaceMigrationV2Module } from 'src/engine/workspace-manager/workspa ViewSortModule, I18nModule, ApplicationModule, - FeatureFlagModule, PermissionsModule, UserRoleModule, WorkspaceCacheStorageModule, @@ -42,7 +39,7 @@ import { WorkspaceMigrationV2Module } from 'src/engine/workspace-manager/workspa WorkspaceManyOrAllFlatEntityMapsCacheModule, ], controllers: [ViewController], - providers: [ViewService, ViewResolver, ViewV2Service], - exports: [ViewService, ViewV2Service, TypeOrmModule.forFeature([ViewEntity])], + providers: [ViewService, ViewResolver], + exports: [ViewService, TypeOrmModule.forFeature([ViewEntity])], }) export class ViewModule {} diff --git a/packages/twenty-server/src/engine/twenty-orm/entity-manager/workspace-entity-manager.spec.ts b/packages/twenty-server/src/engine/twenty-orm/entity-manager/workspace-entity-manager.spec.ts index 2a663bccf1..0e97a85775 100644 --- a/packages/twenty-server/src/engine/twenty-orm/entity-manager/workspace-entity-manager.spec.ts +++ b/packages/twenty-server/src/engine/twenty-orm/entity-manager/workspace-entity-manager.spec.ts @@ -200,7 +200,6 @@ describe('WorkspaceEntityManager', () => { IS_APPLICATION_ENABLED: false, IS_IMAP_SMTP_CALDAV_ENABLED: false, IS_MORPH_RELATION_ENABLED: false, - IS_WORKSPACE_MIGRATION_V2_ENABLED: false, IS_PAGE_LAYOUT_ENABLED: false, IS_RECORD_PAGE_LAYOUT_ENABLED: false, IS_PUBLIC_DOMAIN_ENABLED: false, @@ -226,7 +225,6 @@ describe('WorkspaceEntityManager', () => { IS_AI_ENABLED: false, IS_APPLICATION_ENABLED: false, IS_IMAP_SMTP_CALDAV_ENABLED: false, - IS_WORKSPACE_MIGRATION_V2_ENABLED: false, IS_PAGE_LAYOUT_ENABLED: false, IS_RECORD_PAGE_LAYOUT_ENABLED: false, IS_PUBLIC_DOMAIN_ENABLED: false, diff --git a/packages/twenty-server/src/engine/workspace-manager/dev-seeder/core/utils/seed-feature-flags.util.ts b/packages/twenty-server/src/engine/workspace-manager/dev-seeder/core/utils/seed-feature-flags.util.ts index 33e19438d0..8afbd6dc48 100644 --- a/packages/twenty-server/src/engine/workspace-manager/dev-seeder/core/utils/seed-feature-flags.util.ts +++ b/packages/twenty-server/src/engine/workspace-manager/dev-seeder/core/utils/seed-feature-flags.util.ts @@ -1,7 +1,6 @@ import { type QueryRunner } from 'typeorm'; import { FeatureFlagKey } from 'src/engine/core-modules/feature-flag/enums/feature-flag-key.enum'; -import { SEED_APPLE_WORKSPACE_ID } from 'src/engine/workspace-manager/dev-seeder/core/constants/seeder-workspaces.constant'; const tableName = 'featureFlag'; @@ -57,11 +56,6 @@ export const seedFeatureFlags = async ({ workspaceId: workspaceId, value: true, }, - { - key: FeatureFlagKey.IS_WORKSPACE_MIGRATION_V2_ENABLED, - workspaceId: workspaceId, - value: workspaceId === SEED_APPLE_WORKSPACE_ID, - }, { key: FeatureFlagKey.IS_PAGE_LAYOUT_ENABLED, workspaceId: workspaceId, diff --git a/packages/twenty-server/src/engine/workspace-manager/workspace-sync-metadata/constants/default-feature-flags.ts b/packages/twenty-server/src/engine/workspace-manager/workspace-sync-metadata/constants/default-feature-flags.ts index b3b022fe39..57e35bf669 100644 --- a/packages/twenty-server/src/engine/workspace-manager/workspace-sync-metadata/constants/default-feature-flags.ts +++ b/packages/twenty-server/src/engine/workspace-manager/workspace-sync-metadata/constants/default-feature-flags.ts @@ -1,5 +1,3 @@ -import { FeatureFlagKey } from 'src/engine/core-modules/feature-flag/enums/feature-flag-key.enum'; +import { type FeatureFlagKey } from 'src/engine/core-modules/feature-flag/enums/feature-flag-key.enum'; -export const DEFAULT_FEATURE_FLAGS = [ - FeatureFlagKey.IS_WORKSPACE_MIGRATION_V2_ENABLED, -] as const satisfies FeatureFlagKey[]; +export const DEFAULT_FEATURE_FLAGS = [] as const satisfies FeatureFlagKey[];