From 7cf778b5792a2fe3f9c15b2166126d96f989d987 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rapha=C3=ABl=20Bosi?= <71827178+bosiraphael@users.noreply.github.com> Date: Mon, 28 Jul 2025 14:41:27 +0200 Subject: [PATCH] Synchronization between Core Views and Workspace Views (#13461) Closes https://github.com/twentyhq/core-team-issues/issues/1248 - Create listeners on each CRUD operation for all view related objects and update the core views accordingly Some fields have to be parsed since we changed the data model a little bit when switching to core views. --- .../migrate-views-to-core.command.ts | 13 +- .../view/types/view-filter-value.type.ts | 12 + .../view/view-filter.entity.ts | 3 +- .../view/listeners/base-view-sync.listener.ts | 209 ++++++++++++++++++ .../view/listeners/view-field.listener.ts | 81 +++++++ .../listeners/view-filter-group.listener.ts | 86 +++++++ .../view/listeners/view-filter.listener.ts | 86 +++++++ .../view/listeners/view-group.listener.ts | 81 +++++++ .../view/listeners/view-sort.listener.ts | 81 +++++++ .../modules/view/listeners/view.listener.ts | 76 +++++++ .../view/services/view-field-sync.service.ts | 104 +++++++++ .../view-filter-group-sync.service.ts | 110 +++++++++ .../view/services/view-filter-sync.service.ts | 121 ++++++++++ .../view/services/view-group-sync.service.ts | 112 ++++++++++ .../view/services/view-sort-sync.service.ts | 120 ++++++++++ .../view/services/view-sync.service.ts | 121 ++++++++++ ...ew-filter-workspace-value-to-core-value.ts | 11 + .../src/modules/view/view.module.ts | 47 +++- .../src/modules/view/views.exception.ts | 2 + 19 files changed, 1461 insertions(+), 15 deletions(-) create mode 100644 packages/twenty-server/src/engine/metadata-modules/view/types/view-filter-value.type.ts create mode 100644 packages/twenty-server/src/modules/view/listeners/base-view-sync.listener.ts create mode 100644 packages/twenty-server/src/modules/view/listeners/view-field.listener.ts create mode 100644 packages/twenty-server/src/modules/view/listeners/view-filter-group.listener.ts create mode 100644 packages/twenty-server/src/modules/view/listeners/view-filter.listener.ts create mode 100644 packages/twenty-server/src/modules/view/listeners/view-group.listener.ts create mode 100644 packages/twenty-server/src/modules/view/listeners/view-sort.listener.ts create mode 100644 packages/twenty-server/src/modules/view/listeners/view.listener.ts create mode 100644 packages/twenty-server/src/modules/view/services/view-field-sync.service.ts create mode 100644 packages/twenty-server/src/modules/view/services/view-filter-group-sync.service.ts create mode 100644 packages/twenty-server/src/modules/view/services/view-filter-sync.service.ts create mode 100644 packages/twenty-server/src/modules/view/services/view-group-sync.service.ts create mode 100644 packages/twenty-server/src/modules/view/services/view-sort-sync.service.ts create mode 100644 packages/twenty-server/src/modules/view/services/view-sync.service.ts create mode 100644 packages/twenty-server/src/modules/view/utils/transform-view-filter-workspace-value-to-core-value.ts diff --git a/packages/twenty-server/src/database/commands/views-migration/migrate-views-to-core.command.ts b/packages/twenty-server/src/database/commands/views-migration/migrate-views-to-core.command.ts index ac4829243..b66392bb6 100644 --- a/packages/twenty-server/src/database/commands/views-migration/migrate-views-to-core.command.ts +++ b/packages/twenty-server/src/database/commands/views-migration/migrate-views-to-core.command.ts @@ -26,6 +26,7 @@ import { ViewFilterWorkspaceEntity } from 'src/modules/view/standard-objects/vie import { ViewGroupWorkspaceEntity } from 'src/modules/view/standard-objects/view-group.workspace-entity'; import { ViewSortWorkspaceEntity } from 'src/modules/view/standard-objects/view-sort.workspace-entity'; import { ViewWorkspaceEntity } from 'src/modules/view/standard-objects/view.workspace-entity'; +import { transformViewFilterWorkspaceValueToCoreValue } from 'src/modules/view/utils/transform-view-filter-workspace-value-to-core-value'; @Command({ name: 'migrate:views-to-core', @@ -298,22 +299,12 @@ export class MigrateViewsToCoreCommand extends ActiveOrSuspendedWorkspacesMigrat continue; } - let parsedValue: JSON; - - try { - parsedValue = JSON.parse(filter.value); - } catch { - throw new Error( - `Could not parse value to JSON for view filter ${filter.id} for workspace ${workspaceId}`, - ); - } - const coreViewFilter: Partial = { id: filter.id, fieldMetadataId: filter.fieldMetadataId, viewId: filter.viewId, operand: filter.operand, - value: parsedValue, + value: transformViewFilterWorkspaceValueToCoreValue(filter.value), viewFilterGroupId: filter.viewFilterGroupId, workspaceId, createdAt: new Date(filter.createdAt), diff --git a/packages/twenty-server/src/engine/metadata-modules/view/types/view-filter-value.type.ts b/packages/twenty-server/src/engine/metadata-modules/view/types/view-filter-value.type.ts new file mode 100644 index 000000000..04a1bbe7a --- /dev/null +++ b/packages/twenty-server/src/engine/metadata-modules/view/types/view-filter-value.type.ts @@ -0,0 +1,12 @@ +export type RelationFilterValue = { + isCurrentWorkspaceMemberSelected?: boolean; + selectedRecordIds: string[]; +}; + +export type ViewFilterValue = + | string + | string[] + | RelationFilterValue + | Record + | null + | undefined; diff --git a/packages/twenty-server/src/engine/metadata-modules/view/view-filter.entity.ts b/packages/twenty-server/src/engine/metadata-modules/view/view-filter.entity.ts index bef6cae54..1c36e4e52 100644 --- a/packages/twenty-server/src/engine/metadata-modules/view/view-filter.entity.ts +++ b/packages/twenty-server/src/engine/metadata-modules/view/view-filter.entity.ts @@ -14,6 +14,7 @@ import { import { UUIDScalarType } from 'src/engine/api/graphql/workspace-schema-builder/graphql-types/scalars'; import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity'; +import { ViewFilterValue } from 'src/engine/metadata-modules/view/types/view-filter-value.type'; import { View } from 'src/engine/metadata-modules/view/view.entity'; @Entity({ name: 'viewFilter', schema: 'core' }) @@ -31,7 +32,7 @@ export class ViewFilter { operand: string; @Column({ nullable: false, type: 'jsonb' }) - value: JSON; + value: ViewFilterValue; @Column({ nullable: true, type: 'uuid' }) viewFilterGroupId?: string | null; diff --git a/packages/twenty-server/src/modules/view/listeners/base-view-sync.listener.ts b/packages/twenty-server/src/modules/view/listeners/base-view-sync.listener.ts new file mode 100644 index 000000000..b18c50f1c --- /dev/null +++ b/packages/twenty-server/src/modules/view/listeners/base-view-sync.listener.ts @@ -0,0 +1,209 @@ +import { Inject, Injectable, Logger } from '@nestjs/common'; + +import { ObjectRecordCreateEvent } from 'src/engine/core-modules/event-emitter/types/object-record-create.event'; +import { ObjectRecordDeleteEvent } from 'src/engine/core-modules/event-emitter/types/object-record-delete.event'; +import { ObjectRecordDestroyEvent } from 'src/engine/core-modules/event-emitter/types/object-record-destroy.event'; +import { ObjectRecordDiff } from 'src/engine/core-modules/event-emitter/types/object-record-diff'; +import { ObjectRecordRestoreEvent } from 'src/engine/core-modules/event-emitter/types/object-record-restore.event'; +import { ObjectRecordUpdateEvent } from 'src/engine/core-modules/event-emitter/types/object-record-update.event'; +import { ExceptionHandlerService } from 'src/engine/core-modules/exception-handler/exception-handler.service'; +import { FeatureFlagService } from 'src/engine/core-modules/feature-flag/services/feature-flag.service'; +import { WorkspaceEventBatch } from 'src/engine/workspace-event-emitter/types/workspace-event.type'; +import { + ViewException, + ViewExceptionCode, +} from 'src/modules/view/views.exception'; + +type EntityWithId = { id: string }; + +type SyncOperations = { + create: (workspaceId: string, entity: T) => Promise; + update: ( + workspaceId: string, + entity: T, + diff?: Partial>, + ) => Promise; + delete: (workspaceId: string, entity: Pick) => Promise; + destroy: (workspaceId: string, entity: Pick) => Promise; + restore: (workspaceId: string, entity: Pick) => Promise; +}; + +@Injectable() +export abstract class BaseViewSyncListener { + @Inject(FeatureFlagService) + protected readonly featureFlagService: FeatureFlagService; + + @Inject(ExceptionHandlerService) + protected readonly exceptionHandlerService: ExceptionHandlerService; + + protected readonly logger: Logger; + + constructor( + protected readonly syncOperations: SyncOperations, + loggerName: string, + protected readonly entityTypeName: string, + ) { + this.logger = new Logger(loggerName); + } + + protected async handleCreated( + batchEvent: WorkspaceEventBatch>, + ): Promise { + const isEnabled = await this.isFeatureFlagEnabled(batchEvent.workspaceId); + + if (!isEnabled) { + return; + } + + for (const event of batchEvent.events) { + try { + await this.syncOperations.create( + batchEvent.workspaceId, + event.properties.after, + ); + } catch (error) { + this.captureException( + error, + batchEvent.workspaceId, + 'create', + event.properties.after.id, + ); + } + } + } + + protected async handleUpdated( + batchEvent: WorkspaceEventBatch>, + ): Promise { + const isEnabled = await this.isFeatureFlagEnabled(batchEvent.workspaceId); + + if (!isEnabled) { + return; + } + + for (const event of batchEvent.events) { + try { + await this.syncOperations.update( + batchEvent.workspaceId, + event.properties.after, + event.properties.diff, + ); + } catch (error) { + this.captureException( + error, + batchEvent.workspaceId, + 'update', + event.properties.after.id, + ); + } + } + } + + protected async handleDeleted( + batchEvent: WorkspaceEventBatch>, + ): Promise { + const isEnabled = await this.isFeatureFlagEnabled(batchEvent.workspaceId); + + if (!isEnabled) { + return; + } + + for (const event of batchEvent.events) { + try { + await this.syncOperations.delete( + batchEvent.workspaceId, + event.properties.before, + ); + } catch (error) { + this.captureException( + error, + batchEvent.workspaceId, + 'delete', + event.properties.before.id, + ); + } + } + } + + protected async handleDestroyed( + batchEvent: WorkspaceEventBatch>, + ): Promise { + const isEnabled = await this.isFeatureFlagEnabled(batchEvent.workspaceId); + + if (!isEnabled) { + return; + } + + for (const event of batchEvent.events) { + try { + await this.syncOperations.destroy( + batchEvent.workspaceId, + event.properties.before, + ); + } catch (error) { + this.captureException( + error, + batchEvent.workspaceId, + 'destroy', + event.properties.before.id, + ); + } + } + } + + protected async handleRestored( + batchEvent: WorkspaceEventBatch>, + ): Promise { + const isEnabled = await this.isFeatureFlagEnabled(batchEvent.workspaceId); + + if (!isEnabled) { + return; + } + + for (const event of batchEvent.events) { + try { + await this.syncOperations.restore( + batchEvent.workspaceId, + event.properties.after, + ); + } catch (error) { + this.captureException( + error, + batchEvent.workspaceId, + 'restore', + event.properties.after.id, + ); + } + } + } + + private async isFeatureFlagEnabled(workspaceId: string): Promise { + const featureFlags = + await this.featureFlagService.getWorkspaceFeatureFlagsMap(workspaceId); + + return featureFlags.IS_CORE_VIEW_SYNCING_ENABLED; + } + + private captureException( + error: Error, + workspaceId: string, + operation: string, + entityId: string, + ) { + const viewException = new ViewException( + `Failed to sync ${this.entityTypeName} ${entityId} to core: ${error.message}`, + ViewExceptionCode.CORE_VIEW_SYNC_ERROR, + ); + + this.exceptionHandlerService.captureExceptions([viewException], { + workspace: { + id: workspaceId, + }, + additionalData: { + entityId: entityId, + entityType: this.entityTypeName, + operation: operation, + }, + }); + } +} diff --git a/packages/twenty-server/src/modules/view/listeners/view-field.listener.ts b/packages/twenty-server/src/modules/view/listeners/view-field.listener.ts new file mode 100644 index 000000000..daf7e9127 --- /dev/null +++ b/packages/twenty-server/src/modules/view/listeners/view-field.listener.ts @@ -0,0 +1,81 @@ +import { Injectable } from '@nestjs/common'; + +import { OnDatabaseBatchEvent } from 'src/engine/api/graphql/graphql-query-runner/decorators/on-database-batch-event.decorator'; +import { DatabaseEventAction } from 'src/engine/api/graphql/graphql-query-runner/enums/database-event-action'; +import { ObjectRecordCreateEvent } from 'src/engine/core-modules/event-emitter/types/object-record-create.event'; +import { ObjectRecordDeleteEvent } from 'src/engine/core-modules/event-emitter/types/object-record-delete.event'; +import { ObjectRecordDestroyEvent } from 'src/engine/core-modules/event-emitter/types/object-record-destroy.event'; +import { ObjectRecordRestoreEvent } from 'src/engine/core-modules/event-emitter/types/object-record-restore.event'; +import { ObjectRecordUpdateEvent } from 'src/engine/core-modules/event-emitter/types/object-record-update.event'; +import { WorkspaceEventBatch } from 'src/engine/workspace-event-emitter/types/workspace-event.type'; +import { ViewFieldSyncService } from 'src/modules/view/services/view-field-sync.service'; +import { ViewFieldWorkspaceEntity } from 'src/modules/view/standard-objects/view-field.workspace-entity'; + +import { BaseViewSyncListener } from './base-view-sync.listener'; + +@Injectable() +export class ViewFieldListener extends BaseViewSyncListener { + constructor(viewFieldSyncService: ViewFieldSyncService) { + super( + { + create: + viewFieldSyncService.createCoreViewField.bind(viewFieldSyncService), + update: + viewFieldSyncService.updateCoreViewField.bind(viewFieldSyncService), + delete: + viewFieldSyncService.deleteCoreViewField.bind(viewFieldSyncService), + destroy: + viewFieldSyncService.destroyCoreViewField.bind(viewFieldSyncService), + restore: + viewFieldSyncService.restoreCoreViewField.bind(viewFieldSyncService), + }, + ViewFieldListener.name, + 'view field', + ); + } + + @OnDatabaseBatchEvent('viewField', DatabaseEventAction.CREATED) + async handleViewFieldCreated( + batchEvent: WorkspaceEventBatch< + ObjectRecordCreateEvent + >, + ) { + return this.handleCreated(batchEvent); + } + + @OnDatabaseBatchEvent('viewField', DatabaseEventAction.UPDATED) + async handleViewFieldUpdated( + batchEvent: WorkspaceEventBatch< + ObjectRecordUpdateEvent + >, + ) { + return this.handleUpdated(batchEvent); + } + + @OnDatabaseBatchEvent('viewField', DatabaseEventAction.DELETED) + async handleViewFieldDeleted( + batchEvent: WorkspaceEventBatch< + ObjectRecordDeleteEvent + >, + ) { + return this.handleDeleted(batchEvent); + } + + @OnDatabaseBatchEvent('viewField', DatabaseEventAction.DESTROYED) + async handleViewFieldDestroyed( + batchEvent: WorkspaceEventBatch< + ObjectRecordDestroyEvent + >, + ) { + return this.handleDestroyed(batchEvent); + } + + @OnDatabaseBatchEvent('viewField', DatabaseEventAction.RESTORED) + async handleViewFieldRestored( + batchEvent: WorkspaceEventBatch< + ObjectRecordRestoreEvent + >, + ) { + return this.handleRestored(batchEvent); + } +} diff --git a/packages/twenty-server/src/modules/view/listeners/view-filter-group.listener.ts b/packages/twenty-server/src/modules/view/listeners/view-filter-group.listener.ts new file mode 100644 index 000000000..50239a63c --- /dev/null +++ b/packages/twenty-server/src/modules/view/listeners/view-filter-group.listener.ts @@ -0,0 +1,86 @@ +import { Injectable } from '@nestjs/common'; + +import { OnDatabaseBatchEvent } from 'src/engine/api/graphql/graphql-query-runner/decorators/on-database-batch-event.decorator'; +import { DatabaseEventAction } from 'src/engine/api/graphql/graphql-query-runner/enums/database-event-action'; +import { ObjectRecordCreateEvent } from 'src/engine/core-modules/event-emitter/types/object-record-create.event'; +import { ObjectRecordDeleteEvent } from 'src/engine/core-modules/event-emitter/types/object-record-delete.event'; +import { ObjectRecordDestroyEvent } from 'src/engine/core-modules/event-emitter/types/object-record-destroy.event'; +import { ObjectRecordRestoreEvent } from 'src/engine/core-modules/event-emitter/types/object-record-restore.event'; +import { ObjectRecordUpdateEvent } from 'src/engine/core-modules/event-emitter/types/object-record-update.event'; +import { WorkspaceEventBatch } from 'src/engine/workspace-event-emitter/types/workspace-event.type'; +import { ViewFilterGroupSyncService } from 'src/modules/view/services/view-filter-group-sync.service'; +import { ViewFilterGroupWorkspaceEntity } from 'src/modules/view/standard-objects/view-filter-group.workspace-entity'; + +import { BaseViewSyncListener } from './base-view-sync.listener'; + +@Injectable() +export class ViewFilterGroupListener extends BaseViewSyncListener { + constructor(viewFilterGroupSyncService: ViewFilterGroupSyncService) { + super( + { + create: viewFilterGroupSyncService.createCoreViewFilterGroup.bind( + viewFilterGroupSyncService, + ), + update: viewFilterGroupSyncService.updateCoreViewFilterGroup.bind( + viewFilterGroupSyncService, + ), + delete: viewFilterGroupSyncService.deleteCoreViewFilterGroup.bind( + viewFilterGroupSyncService, + ), + destroy: viewFilterGroupSyncService.destroyCoreViewFilterGroup.bind( + viewFilterGroupSyncService, + ), + restore: viewFilterGroupSyncService.restoreCoreViewFilterGroup.bind( + viewFilterGroupSyncService, + ), + }, + ViewFilterGroupListener.name, + 'view filter group', + ); + } + + @OnDatabaseBatchEvent('viewFilterGroup', DatabaseEventAction.CREATED) + async handleViewFilterGroupCreated( + batchEvent: WorkspaceEventBatch< + ObjectRecordCreateEvent + >, + ) { + return this.handleCreated(batchEvent); + } + + @OnDatabaseBatchEvent('viewFilterGroup', DatabaseEventAction.UPDATED) + async handleViewFilterGroupUpdated( + batchEvent: WorkspaceEventBatch< + ObjectRecordUpdateEvent + >, + ) { + return this.handleUpdated(batchEvent); + } + + @OnDatabaseBatchEvent('viewFilterGroup', DatabaseEventAction.DELETED) + async handleViewFilterGroupDeleted( + batchEvent: WorkspaceEventBatch< + ObjectRecordDeleteEvent + >, + ) { + return this.handleDeleted(batchEvent); + } + + @OnDatabaseBatchEvent('viewFilterGroup', DatabaseEventAction.DESTROYED) + async handleViewFilterGroupDestroyed( + batchEvent: WorkspaceEventBatch< + ObjectRecordDestroyEvent + >, + ) { + return this.handleDestroyed(batchEvent); + } + + @OnDatabaseBatchEvent('viewFilterGroup', DatabaseEventAction.RESTORED) + async handleViewFilterGroupRestored( + batchEvent: WorkspaceEventBatch< + ObjectRecordRestoreEvent + >, + ) { + return this.handleRestored(batchEvent); + } +} diff --git a/packages/twenty-server/src/modules/view/listeners/view-filter.listener.ts b/packages/twenty-server/src/modules/view/listeners/view-filter.listener.ts new file mode 100644 index 000000000..edf282b1c --- /dev/null +++ b/packages/twenty-server/src/modules/view/listeners/view-filter.listener.ts @@ -0,0 +1,86 @@ +import { Injectable } from '@nestjs/common'; + +import { OnDatabaseBatchEvent } from 'src/engine/api/graphql/graphql-query-runner/decorators/on-database-batch-event.decorator'; +import { DatabaseEventAction } from 'src/engine/api/graphql/graphql-query-runner/enums/database-event-action'; +import { ObjectRecordCreateEvent } from 'src/engine/core-modules/event-emitter/types/object-record-create.event'; +import { ObjectRecordDeleteEvent } from 'src/engine/core-modules/event-emitter/types/object-record-delete.event'; +import { ObjectRecordDestroyEvent } from 'src/engine/core-modules/event-emitter/types/object-record-destroy.event'; +import { ObjectRecordRestoreEvent } from 'src/engine/core-modules/event-emitter/types/object-record-restore.event'; +import { ObjectRecordUpdateEvent } from 'src/engine/core-modules/event-emitter/types/object-record-update.event'; +import { WorkspaceEventBatch } from 'src/engine/workspace-event-emitter/types/workspace-event.type'; +import { ViewFilterSyncService } from 'src/modules/view/services/view-filter-sync.service'; +import { ViewFilterWorkspaceEntity } from 'src/modules/view/standard-objects/view-filter.workspace-entity'; + +import { BaseViewSyncListener } from './base-view-sync.listener'; + +@Injectable() +export class ViewFilterListener extends BaseViewSyncListener { + constructor(viewFilterSyncService: ViewFilterSyncService) { + super( + { + create: viewFilterSyncService.createCoreViewFilter.bind( + viewFilterSyncService, + ), + update: viewFilterSyncService.updateCoreViewFilter.bind( + viewFilterSyncService, + ), + delete: viewFilterSyncService.deleteCoreViewFilter.bind( + viewFilterSyncService, + ), + destroy: viewFilterSyncService.destroyCoreViewFilter.bind( + viewFilterSyncService, + ), + restore: viewFilterSyncService.restoreCoreViewFilter.bind( + viewFilterSyncService, + ), + }, + ViewFilterListener.name, + 'view filter', + ); + } + + @OnDatabaseBatchEvent('viewFilter', DatabaseEventAction.CREATED) + async handleViewFilterCreated( + batchEvent: WorkspaceEventBatch< + ObjectRecordCreateEvent + >, + ) { + return this.handleCreated(batchEvent); + } + + @OnDatabaseBatchEvent('viewFilter', DatabaseEventAction.UPDATED) + async handleViewFilterUpdated( + batchEvent: WorkspaceEventBatch< + ObjectRecordUpdateEvent + >, + ) { + return this.handleUpdated(batchEvent); + } + + @OnDatabaseBatchEvent('viewFilter', DatabaseEventAction.DELETED) + async handleViewFilterDeleted( + batchEvent: WorkspaceEventBatch< + ObjectRecordDeleteEvent + >, + ) { + return this.handleDeleted(batchEvent); + } + + @OnDatabaseBatchEvent('viewFilter', DatabaseEventAction.DESTROYED) + async handleViewFilterDestroyed( + batchEvent: WorkspaceEventBatch< + ObjectRecordDestroyEvent + >, + ) { + return this.handleDestroyed(batchEvent); + } + + @OnDatabaseBatchEvent('viewFilter', DatabaseEventAction.RESTORED) + async handleViewFilterRestored( + batchEvent: WorkspaceEventBatch< + ObjectRecordRestoreEvent + >, + ) { + return this.handleRestored(batchEvent); + } +} diff --git a/packages/twenty-server/src/modules/view/listeners/view-group.listener.ts b/packages/twenty-server/src/modules/view/listeners/view-group.listener.ts new file mode 100644 index 000000000..8b46b84dd --- /dev/null +++ b/packages/twenty-server/src/modules/view/listeners/view-group.listener.ts @@ -0,0 +1,81 @@ +import { Injectable } from '@nestjs/common'; + +import { OnDatabaseBatchEvent } from 'src/engine/api/graphql/graphql-query-runner/decorators/on-database-batch-event.decorator'; +import { DatabaseEventAction } from 'src/engine/api/graphql/graphql-query-runner/enums/database-event-action'; +import { ObjectRecordCreateEvent } from 'src/engine/core-modules/event-emitter/types/object-record-create.event'; +import { ObjectRecordDeleteEvent } from 'src/engine/core-modules/event-emitter/types/object-record-delete.event'; +import { ObjectRecordDestroyEvent } from 'src/engine/core-modules/event-emitter/types/object-record-destroy.event'; +import { ObjectRecordRestoreEvent } from 'src/engine/core-modules/event-emitter/types/object-record-restore.event'; +import { ObjectRecordUpdateEvent } from 'src/engine/core-modules/event-emitter/types/object-record-update.event'; +import { WorkspaceEventBatch } from 'src/engine/workspace-event-emitter/types/workspace-event.type'; +import { ViewGroupSyncService } from 'src/modules/view/services/view-group-sync.service'; +import { ViewGroupWorkspaceEntity } from 'src/modules/view/standard-objects/view-group.workspace-entity'; + +import { BaseViewSyncListener } from './base-view-sync.listener'; + +@Injectable() +export class ViewGroupListener extends BaseViewSyncListener { + constructor(viewGroupSyncService: ViewGroupSyncService) { + super( + { + create: + viewGroupSyncService.createCoreViewGroup.bind(viewGroupSyncService), + update: + viewGroupSyncService.updateCoreViewGroup.bind(viewGroupSyncService), + delete: + viewGroupSyncService.deleteCoreViewGroup.bind(viewGroupSyncService), + destroy: + viewGroupSyncService.destroyCoreViewGroup.bind(viewGroupSyncService), + restore: + viewGroupSyncService.restoreCoreViewGroup.bind(viewGroupSyncService), + }, + ViewGroupListener.name, + 'view group', + ); + } + + @OnDatabaseBatchEvent('viewGroup', DatabaseEventAction.CREATED) + async handleViewGroupCreated( + batchEvent: WorkspaceEventBatch< + ObjectRecordCreateEvent + >, + ) { + return this.handleCreated(batchEvent); + } + + @OnDatabaseBatchEvent('viewGroup', DatabaseEventAction.UPDATED) + async handleViewGroupUpdated( + batchEvent: WorkspaceEventBatch< + ObjectRecordUpdateEvent + >, + ) { + return this.handleUpdated(batchEvent); + } + + @OnDatabaseBatchEvent('viewGroup', DatabaseEventAction.DELETED) + async handleViewGroupDeleted( + batchEvent: WorkspaceEventBatch< + ObjectRecordDeleteEvent + >, + ) { + return this.handleDeleted(batchEvent); + } + + @OnDatabaseBatchEvent('viewGroup', DatabaseEventAction.DESTROYED) + async handleViewGroupDestroyed( + batchEvent: WorkspaceEventBatch< + ObjectRecordDestroyEvent + >, + ) { + return this.handleDestroyed(batchEvent); + } + + @OnDatabaseBatchEvent('viewGroup', DatabaseEventAction.RESTORED) + async handleViewGroupRestored( + batchEvent: WorkspaceEventBatch< + ObjectRecordRestoreEvent + >, + ) { + return this.handleRestored(batchEvent); + } +} diff --git a/packages/twenty-server/src/modules/view/listeners/view-sort.listener.ts b/packages/twenty-server/src/modules/view/listeners/view-sort.listener.ts new file mode 100644 index 000000000..ae9b4f141 --- /dev/null +++ b/packages/twenty-server/src/modules/view/listeners/view-sort.listener.ts @@ -0,0 +1,81 @@ +import { Injectable } from '@nestjs/common'; + +import { OnDatabaseBatchEvent } from 'src/engine/api/graphql/graphql-query-runner/decorators/on-database-batch-event.decorator'; +import { DatabaseEventAction } from 'src/engine/api/graphql/graphql-query-runner/enums/database-event-action'; +import { ObjectRecordCreateEvent } from 'src/engine/core-modules/event-emitter/types/object-record-create.event'; +import { ObjectRecordDeleteEvent } from 'src/engine/core-modules/event-emitter/types/object-record-delete.event'; +import { ObjectRecordDestroyEvent } from 'src/engine/core-modules/event-emitter/types/object-record-destroy.event'; +import { ObjectRecordRestoreEvent } from 'src/engine/core-modules/event-emitter/types/object-record-restore.event'; +import { ObjectRecordUpdateEvent } from 'src/engine/core-modules/event-emitter/types/object-record-update.event'; +import { WorkspaceEventBatch } from 'src/engine/workspace-event-emitter/types/workspace-event.type'; +import { ViewSortSyncService } from 'src/modules/view/services/view-sort-sync.service'; +import { ViewSortWorkspaceEntity } from 'src/modules/view/standard-objects/view-sort.workspace-entity'; + +import { BaseViewSyncListener } from './base-view-sync.listener'; + +@Injectable() +export class ViewSortListener extends BaseViewSyncListener { + constructor(viewSortSyncService: ViewSortSyncService) { + super( + { + create: + viewSortSyncService.createCoreViewSort.bind(viewSortSyncService), + update: + viewSortSyncService.updateCoreViewSort.bind(viewSortSyncService), + delete: + viewSortSyncService.deleteCoreViewSort.bind(viewSortSyncService), + destroy: + viewSortSyncService.destroyCoreViewSort.bind(viewSortSyncService), + restore: + viewSortSyncService.restoreCoreViewSort.bind(viewSortSyncService), + }, + ViewSortListener.name, + 'view sort', + ); + } + + @OnDatabaseBatchEvent('viewSort', DatabaseEventAction.CREATED) + async handleViewSortCreated( + batchEvent: WorkspaceEventBatch< + ObjectRecordCreateEvent + >, + ) { + return this.handleCreated(batchEvent); + } + + @OnDatabaseBatchEvent('viewSort', DatabaseEventAction.UPDATED) + async handleViewSortUpdated( + batchEvent: WorkspaceEventBatch< + ObjectRecordUpdateEvent + >, + ) { + return this.handleUpdated(batchEvent); + } + + @OnDatabaseBatchEvent('viewSort', DatabaseEventAction.DELETED) + async handleViewSortDeleted( + batchEvent: WorkspaceEventBatch< + ObjectRecordDeleteEvent + >, + ) { + return this.handleDeleted(batchEvent); + } + + @OnDatabaseBatchEvent('viewSort', DatabaseEventAction.DESTROYED) + async handleViewSortDestroyed( + batchEvent: WorkspaceEventBatch< + ObjectRecordDestroyEvent + >, + ) { + return this.handleDestroyed(batchEvent); + } + + @OnDatabaseBatchEvent('viewSort', DatabaseEventAction.RESTORED) + async handleViewSortRestored( + batchEvent: WorkspaceEventBatch< + ObjectRecordRestoreEvent + >, + ) { + return this.handleRestored(batchEvent); + } +} diff --git a/packages/twenty-server/src/modules/view/listeners/view.listener.ts b/packages/twenty-server/src/modules/view/listeners/view.listener.ts new file mode 100644 index 000000000..4c5675694 --- /dev/null +++ b/packages/twenty-server/src/modules/view/listeners/view.listener.ts @@ -0,0 +1,76 @@ +import { Injectable } from '@nestjs/common'; + +import { OnDatabaseBatchEvent } from 'src/engine/api/graphql/graphql-query-runner/decorators/on-database-batch-event.decorator'; +import { DatabaseEventAction } from 'src/engine/api/graphql/graphql-query-runner/enums/database-event-action'; +import { ObjectRecordCreateEvent } from 'src/engine/core-modules/event-emitter/types/object-record-create.event'; +import { ObjectRecordDeleteEvent } from 'src/engine/core-modules/event-emitter/types/object-record-delete.event'; +import { ObjectRecordDestroyEvent } from 'src/engine/core-modules/event-emitter/types/object-record-destroy.event'; +import { ObjectRecordRestoreEvent } from 'src/engine/core-modules/event-emitter/types/object-record-restore.event'; +import { ObjectRecordUpdateEvent } from 'src/engine/core-modules/event-emitter/types/object-record-update.event'; +import { WorkspaceEventBatch } from 'src/engine/workspace-event-emitter/types/workspace-event.type'; +import { ViewSyncService } from 'src/modules/view/services/view-sync.service'; +import { ViewWorkspaceEntity } from 'src/modules/view/standard-objects/view.workspace-entity'; + +import { BaseViewSyncListener } from './base-view-sync.listener'; + +@Injectable() +export class ViewListener extends BaseViewSyncListener { + constructor(viewSyncService: ViewSyncService) { + super( + { + create: viewSyncService.createCoreView.bind(viewSyncService), + update: viewSyncService.updateCoreView.bind(viewSyncService), + delete: viewSyncService.deleteCoreView.bind(viewSyncService), + destroy: viewSyncService.destroyCoreView.bind(viewSyncService), + restore: viewSyncService.restoreCoreView.bind(viewSyncService), + }, + ViewListener.name, + 'view', + ); + } + + @OnDatabaseBatchEvent('view', DatabaseEventAction.CREATED) + async handleViewCreated( + batchEvent: WorkspaceEventBatch< + ObjectRecordCreateEvent + >, + ) { + return this.handleCreated(batchEvent); + } + + @OnDatabaseBatchEvent('view', DatabaseEventAction.UPDATED) + async handleViewUpdated( + batchEvent: WorkspaceEventBatch< + ObjectRecordUpdateEvent + >, + ) { + return this.handleUpdated(batchEvent); + } + + @OnDatabaseBatchEvent('view', DatabaseEventAction.DELETED) + async handleViewDeleted( + batchEvent: WorkspaceEventBatch< + ObjectRecordDeleteEvent + >, + ) { + return this.handleDeleted(batchEvent); + } + + @OnDatabaseBatchEvent('view', DatabaseEventAction.DESTROYED) + async handleViewDestroyed( + batchEvent: WorkspaceEventBatch< + ObjectRecordDestroyEvent + >, + ) { + return this.handleDestroyed(batchEvent); + } + + @OnDatabaseBatchEvent('view', DatabaseEventAction.RESTORED) + async handleViewRestored( + batchEvent: WorkspaceEventBatch< + ObjectRecordRestoreEvent + >, + ) { + return this.handleRestored(batchEvent); + } +} diff --git a/packages/twenty-server/src/modules/view/services/view-field-sync.service.ts b/packages/twenty-server/src/modules/view/services/view-field-sync.service.ts new file mode 100644 index 000000000..97d41ee78 --- /dev/null +++ b/packages/twenty-server/src/modules/view/services/view-field-sync.service.ts @@ -0,0 +1,104 @@ +import { Injectable } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; + +import { isDefined } from 'twenty-shared/utils'; +import { Repository } from 'typeorm'; + +import { ObjectRecordDiff } from 'src/engine/core-modules/event-emitter/types/object-record-diff'; +import { ViewField } from 'src/engine/metadata-modules/view/view-field.entity'; +import { ViewFieldWorkspaceEntity } from 'src/modules/view/standard-objects/view-field.workspace-entity'; + +@Injectable() +export class ViewFieldSyncService { + constructor( + @InjectRepository(ViewField, 'core') + private readonly coreViewFieldRepository: Repository, + ) {} + + private parseUpdateDataFromDiff( + diff: Partial>, + ): Partial { + const updateData: Record = {}; + + for (const key of Object.keys(diff)) { + const diffValue = diff[key as keyof ViewFieldWorkspaceEntity]; + + if (isDefined(diffValue)) { + updateData[key] = diffValue.after; + } + } + + return updateData as Partial; + } + + public async createCoreViewField( + workspaceId: string, + workspaceViewField: ViewFieldWorkspaceEntity, + ): Promise { + const coreViewField: Partial = { + id: workspaceViewField.id, + fieldMetadataId: workspaceViewField.fieldMetadataId, + viewId: workspaceViewField.viewId, + position: workspaceViewField.position, + isVisible: workspaceViewField.isVisible, + size: workspaceViewField.size, + workspaceId, + createdAt: new Date(workspaceViewField.createdAt), + updatedAt: new Date(workspaceViewField.updatedAt), + deletedAt: workspaceViewField.deletedAt + ? new Date(workspaceViewField.deletedAt) + : null, + }; + + await this.coreViewFieldRepository.save(coreViewField); + } + + public async updateCoreViewField( + workspaceId: string, + workspaceViewField: Pick, + diff?: Partial>, + ): Promise { + if (!diff || Object.keys(diff).length === 0) { + return; + } + + const updateData = this.parseUpdateDataFromDiff(diff); + + if (Object.keys(updateData).length > 0) { + await this.coreViewFieldRepository.update( + { id: workspaceViewField.id, workspaceId }, + updateData, + ); + } + } + + public async deleteCoreViewField( + workspaceId: string, + workspaceViewField: Pick, + ): Promise { + await this.coreViewFieldRepository.softDelete({ + id: workspaceViewField.id, + workspaceId, + }); + } + + public async destroyCoreViewField( + workspaceId: string, + workspaceViewField: Pick, + ): Promise { + await this.coreViewFieldRepository.delete({ + id: workspaceViewField.id, + workspaceId, + }); + } + + public async restoreCoreViewField( + workspaceId: string, + workspaceViewField: Pick, + ): Promise { + await this.coreViewFieldRepository.restore({ + id: workspaceViewField.id, + workspaceId, + }); + } +} diff --git a/packages/twenty-server/src/modules/view/services/view-filter-group-sync.service.ts b/packages/twenty-server/src/modules/view/services/view-filter-group-sync.service.ts new file mode 100644 index 000000000..b0eb640ca --- /dev/null +++ b/packages/twenty-server/src/modules/view/services/view-filter-group-sync.service.ts @@ -0,0 +1,110 @@ +import { Injectable } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; + +import { isDefined } from 'twenty-shared/utils'; +import { Repository } from 'typeorm'; + +import { ObjectRecordDiff } from 'src/engine/core-modules/event-emitter/types/object-record-diff'; +import { ViewFilterGroupLogicalOperator } from 'src/engine/metadata-modules/view/enums/view-filter-group-logical-operator'; +import { ViewFilterGroup } from 'src/engine/metadata-modules/view/view-filter-group.entity'; +import { ViewFilterGroupWorkspaceEntity } from 'src/modules/view/standard-objects/view-filter-group.workspace-entity'; + +@Injectable() +export class ViewFilterGroupSyncService { + constructor( + @InjectRepository(ViewFilterGroup, 'core') + private readonly coreViewFilterGroupRepository: Repository, + ) {} + + private parseUpdateDataFromDiff( + diff: Partial>, + ): Partial { + const updateData: Record = {}; + + for (const key of Object.keys(diff)) { + const diffValue = diff[key as keyof ViewFilterGroupWorkspaceEntity]; + + if (isDefined(diffValue)) { + if (key === 'logicalOperator') { + updateData[key] = diffValue.after as ViewFilterGroupLogicalOperator; + } else { + updateData[key] = diffValue.after; + } + } + } + + return updateData as Partial; + } + + public async createCoreViewFilterGroup( + workspaceId: string, + workspaceViewFilterGroup: ViewFilterGroupWorkspaceEntity, + ): Promise { + const coreViewFilterGroup: Partial = { + id: workspaceViewFilterGroup.id, + viewId: workspaceViewFilterGroup.viewId, + logicalOperator: + workspaceViewFilterGroup.logicalOperator as ViewFilterGroupLogicalOperator, + parentViewFilterGroupId: workspaceViewFilterGroup.parentViewFilterGroupId, + positionInViewFilterGroup: + workspaceViewFilterGroup.positionInViewFilterGroup, + workspaceId, + createdAt: new Date(workspaceViewFilterGroup.createdAt), + updatedAt: new Date(workspaceViewFilterGroup.updatedAt), + deletedAt: workspaceViewFilterGroup.deletedAt + ? new Date(workspaceViewFilterGroup.deletedAt) + : null, + }; + + await this.coreViewFilterGroupRepository.save(coreViewFilterGroup); + } + + public async updateCoreViewFilterGroup( + workspaceId: string, + workspaceViewFilterGroup: Pick, + diff?: Partial>, + ): Promise { + if (!diff || Object.keys(diff).length === 0) { + return; + } + + const updateData = this.parseUpdateDataFromDiff(diff); + + if (Object.keys(updateData).length > 0) { + await this.coreViewFilterGroupRepository.update( + { id: workspaceViewFilterGroup.id, workspaceId }, + updateData, + ); + } + } + + public async deleteCoreViewFilterGroup( + workspaceId: string, + workspaceViewFilterGroup: Pick, + ): Promise { + await this.coreViewFilterGroupRepository.softDelete({ + id: workspaceViewFilterGroup.id, + workspaceId, + }); + } + + public async destroyCoreViewFilterGroup( + workspaceId: string, + workspaceViewFilterGroup: Pick, + ): Promise { + await this.coreViewFilterGroupRepository.delete({ + id: workspaceViewFilterGroup.id, + workspaceId, + }); + } + + public async restoreCoreViewFilterGroup( + workspaceId: string, + workspaceViewFilterGroup: ViewFilterGroupWorkspaceEntity, + ): Promise { + await this.coreViewFilterGroupRepository.restore({ + id: workspaceViewFilterGroup.id, + workspaceId, + }); + } +} diff --git a/packages/twenty-server/src/modules/view/services/view-filter-sync.service.ts b/packages/twenty-server/src/modules/view/services/view-filter-sync.service.ts new file mode 100644 index 000000000..c2f409a65 --- /dev/null +++ b/packages/twenty-server/src/modules/view/services/view-filter-sync.service.ts @@ -0,0 +1,121 @@ +import { Injectable } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; + +import { isDefined } from 'twenty-shared/utils'; +import { Repository } from 'typeorm'; + +import { ObjectRecordDiff } from 'src/engine/core-modules/event-emitter/types/object-record-diff'; +import { ViewFilter } from 'src/engine/metadata-modules/view/view-filter.entity'; +import { ViewFilterWorkspaceEntity } from 'src/modules/view/standard-objects/view-filter.workspace-entity'; +import { transformViewFilterWorkspaceValueToCoreValue } from 'src/modules/view/utils/transform-view-filter-workspace-value-to-core-value'; + +@Injectable() +export class ViewFilterSyncService { + constructor( + @InjectRepository(ViewFilter, 'core') + private readonly coreViewFilterRepository: Repository, + ) {} + + private parseUpdateDataFromDiff( + diff: Partial>, + ): Partial { + const updateData: Record = {}; + + for (const key of Object.keys(diff)) { + const diffValue = diff[key as keyof ViewFilterWorkspaceEntity]; + + if (isDefined(diffValue)) { + if (key === 'value' && typeof diffValue.after === 'string') { + updateData[key] = transformViewFilterWorkspaceValueToCoreValue( + diffValue.after, + ); + } else { + updateData[key] = diffValue.after; + } + } + } + + return updateData as Partial; + } + + public async createCoreViewFilter( + workspaceId: string, + workspaceViewFilter: ViewFilterWorkspaceEntity, + ): Promise { + if (!workspaceViewFilter.viewId) { + return; + } + + const coreViewFilter: Partial = { + id: workspaceViewFilter.id, + fieldMetadataId: workspaceViewFilter.fieldMetadataId, + viewId: workspaceViewFilter.viewId, + operand: workspaceViewFilter.operand, + value: transformViewFilterWorkspaceValueToCoreValue( + workspaceViewFilter.value, + ), + viewFilterGroupId: workspaceViewFilter.viewFilterGroupId, + workspaceId, + createdAt: new Date(workspaceViewFilter.createdAt), + updatedAt: new Date(workspaceViewFilter.updatedAt), + deletedAt: workspaceViewFilter.deletedAt + ? new Date(workspaceViewFilter.deletedAt) + : null, + }; + + await this.coreViewFilterRepository.save(coreViewFilter); + } + + public async updateCoreViewFilter( + workspaceId: string, + workspaceViewFilter: ViewFilterWorkspaceEntity, + diff?: Partial>, + ): Promise { + if (!workspaceViewFilter.viewId) { + return; + } + + if (!diff || Object.keys(diff).length === 0) { + return; + } + + const updateData = this.parseUpdateDataFromDiff(diff); + + if (Object.keys(updateData).length > 0) { + await this.coreViewFilterRepository.update( + { id: workspaceViewFilter.id, workspaceId }, + updateData, + ); + } + } + + public async deleteCoreViewFilter( + workspaceId: string, + workspaceViewFilter: Pick, + ): Promise { + await this.coreViewFilterRepository.softDelete({ + id: workspaceViewFilter.id, + workspaceId, + }); + } + + public async destroyCoreViewFilter( + workspaceId: string, + workspaceViewFilter: Pick, + ): Promise { + await this.coreViewFilterRepository.delete({ + id: workspaceViewFilter.id, + workspaceId, + }); + } + + public async restoreCoreViewFilter( + workspaceId: string, + workspaceViewFilter: Pick, + ): Promise { + await this.coreViewFilterRepository.restore({ + id: workspaceViewFilter.id, + workspaceId, + }); + } +} diff --git a/packages/twenty-server/src/modules/view/services/view-group-sync.service.ts b/packages/twenty-server/src/modules/view/services/view-group-sync.service.ts new file mode 100644 index 000000000..fbc888d65 --- /dev/null +++ b/packages/twenty-server/src/modules/view/services/view-group-sync.service.ts @@ -0,0 +1,112 @@ +import { Injectable } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; + +import { isDefined } from 'twenty-shared/utils'; +import { Repository } from 'typeorm'; + +import { ObjectRecordDiff } from 'src/engine/core-modules/event-emitter/types/object-record-diff'; +import { ViewGroup } from 'src/engine/metadata-modules/view/view-group.entity'; +import { ViewGroupWorkspaceEntity } from 'src/modules/view/standard-objects/view-group.workspace-entity'; + +@Injectable() +export class ViewGroupSyncService { + constructor( + @InjectRepository(ViewGroup, 'core') + private readonly coreViewGroupRepository: Repository, + ) {} + + private parseUpdateDataFromDiff( + diff: Partial>, + ): Partial { + const updateData: Record = {}; + + for (const key of Object.keys(diff)) { + const diffValue = diff[key as keyof ViewGroupWorkspaceEntity]; + + if (isDefined(diffValue)) { + updateData[key] = diffValue.after; + } + } + + return updateData as Partial; + } + + public async createCoreViewGroup( + workspaceId: string, + workspaceViewGroup: ViewGroupWorkspaceEntity, + ): Promise { + if (!workspaceViewGroup.viewId) { + return; + } + + const coreViewGroup: Partial = { + id: workspaceViewGroup.id, + fieldMetadataId: workspaceViewGroup.fieldMetadataId, + viewId: workspaceViewGroup.viewId, + fieldValue: workspaceViewGroup.fieldValue, + isVisible: workspaceViewGroup.isVisible, + position: workspaceViewGroup.position, + workspaceId, + createdAt: new Date(workspaceViewGroup.createdAt), + updatedAt: new Date(workspaceViewGroup.updatedAt), + deletedAt: workspaceViewGroup.deletedAt + ? new Date(workspaceViewGroup.deletedAt) + : null, + }; + + await this.coreViewGroupRepository.save(coreViewGroup); + } + + public async updateCoreViewGroup( + workspaceId: string, + workspaceViewGroup: ViewGroupWorkspaceEntity, + diff?: Partial>, + ): Promise { + if (!workspaceViewGroup.viewId) { + return; + } + + if (!diff || Object.keys(diff).length === 0) { + return; + } + + const updateData = this.parseUpdateDataFromDiff(diff); + + if (Object.keys(updateData).length > 0) { + await this.coreViewGroupRepository.update( + { id: workspaceViewGroup.id, workspaceId }, + updateData, + ); + } + } + + public async deleteCoreViewGroup( + workspaceId: string, + workspaceViewGroup: Pick, + ): Promise { + await this.coreViewGroupRepository.softDelete({ + id: workspaceViewGroup.id, + workspaceId, + }); + } + + public async destroyCoreViewGroup( + workspaceId: string, + workspaceViewGroup: Pick, + ): Promise { + await this.coreViewGroupRepository.delete({ + id: workspaceViewGroup.id, + workspaceId, + }); + } + + public async restoreCoreViewGroup( + workspaceId: string, + workspaceViewGroup: Pick, + ): Promise { + await this.coreViewGroupRepository.restore({ + id: workspaceViewGroup.id, + workspaceId, + }); + } +} diff --git a/packages/twenty-server/src/modules/view/services/view-sort-sync.service.ts b/packages/twenty-server/src/modules/view/services/view-sort-sync.service.ts new file mode 100644 index 000000000..d582d8a86 --- /dev/null +++ b/packages/twenty-server/src/modules/view/services/view-sort-sync.service.ts @@ -0,0 +1,120 @@ +import { Injectable } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; + +import { isDefined } from 'twenty-shared/utils'; +import { Repository } from 'typeorm'; + +import { ObjectRecordDiff } from 'src/engine/core-modules/event-emitter/types/object-record-diff'; +import { ViewSortDirection } from 'src/engine/metadata-modules/view/enums/view-sort-direction'; +import { ViewSort } from 'src/engine/metadata-modules/view/view-sort.entity'; +import { ViewSortWorkspaceEntity } from 'src/modules/view/standard-objects/view-sort.workspace-entity'; + +@Injectable() +export class ViewSortSyncService { + constructor( + @InjectRepository(ViewSort, 'core') + private readonly coreViewSortRepository: Repository, + ) {} + + private parseUpdateDataFromDiff( + diff: Partial>, + ): Partial { + const updateData: Record = {}; + + for (const key of Object.keys(diff)) { + const diffValue = diff[key as keyof ViewSortWorkspaceEntity]; + + if (isDefined(diffValue)) { + if (key === 'direction') { + updateData[key] = ( + diffValue.after as string + ).toUpperCase() as ViewSortDirection; + } else { + updateData[key] = diffValue.after; + } + } + } + + return updateData as Partial; + } + + public async createCoreViewSort( + workspaceId: string, + workspaceViewSort: ViewSortWorkspaceEntity, + ): Promise { + if (!workspaceViewSort.viewId) { + return; + } + + const direction = + workspaceViewSort.direction.toUpperCase() as ViewSortDirection; + + const coreViewSort: Partial = { + id: workspaceViewSort.id, + fieldMetadataId: workspaceViewSort.fieldMetadataId, + viewId: workspaceViewSort.viewId, + direction: direction, + workspaceId, + createdAt: new Date(workspaceViewSort.createdAt), + updatedAt: new Date(workspaceViewSort.updatedAt), + deletedAt: workspaceViewSort.deletedAt + ? new Date(workspaceViewSort.deletedAt) + : null, + }; + + await this.coreViewSortRepository.save(coreViewSort); + } + + public async updateCoreViewSort( + workspaceId: string, + workspaceViewSort: ViewSortWorkspaceEntity, + diff?: Partial>, + ): Promise { + if (!workspaceViewSort.viewId) { + return; + } + + if (!diff || Object.keys(diff).length === 0) { + return; + } + + const updateData = this.parseUpdateDataFromDiff(diff); + + if (Object.keys(updateData).length > 0) { + await this.coreViewSortRepository.update( + { id: workspaceViewSort.id, workspaceId }, + updateData, + ); + } + } + + public async deleteCoreViewSort( + workspaceId: string, + workspaceViewSort: Pick, + ): Promise { + await this.coreViewSortRepository.softDelete({ + id: workspaceViewSort.id, + workspaceId, + }); + } + + public async destroyCoreViewSort( + workspaceId: string, + workspaceViewSort: Pick, + ): Promise { + await this.coreViewSortRepository.delete({ + id: workspaceViewSort.id, + workspaceId, + }); + } + + public async restoreCoreViewSort( + workspaceId: string, + workspaceViewSort: Pick, + ): Promise { + await this.coreViewSortRepository.restore({ + id: workspaceViewSort.id, + workspaceId, + }); + } +} diff --git a/packages/twenty-server/src/modules/view/services/view-sync.service.ts b/packages/twenty-server/src/modules/view/services/view-sync.service.ts new file mode 100644 index 000000000..976977a34 --- /dev/null +++ b/packages/twenty-server/src/modules/view/services/view-sync.service.ts @@ -0,0 +1,121 @@ +import { Injectable } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; + +import { isDefined } from 'twenty-shared/utils'; +import { Repository } from 'typeorm'; + +import { ObjectRecordDiff } from 'src/engine/core-modules/event-emitter/types/object-record-diff'; +import { ViewOpenRecordIn } from 'src/engine/metadata-modules/view/enums/view-open-record-in'; +import { View } from 'src/engine/metadata-modules/view/view.entity'; +import { ViewWorkspaceEntity } from 'src/modules/view/standard-objects/view.workspace-entity'; + +@Injectable() +export class ViewSyncService { + constructor( + @InjectRepository(View, 'core') + private readonly coreViewRepository: Repository, + ) {} + + private parseUpdateDataFromDiff( + diff: Partial>, + ): Partial { + const updateData: Record = {}; + + for (const key of Object.keys(diff)) { + const diffValue = diff[key as keyof ViewWorkspaceEntity]; + + if (isDefined(diffValue)) { + if (key === 'openRecordIn') { + updateData[key] = + diffValue.after === 'SIDE_PANEL' + ? ViewOpenRecordIn.SIDE_PANEL + : ViewOpenRecordIn.RECORD_PAGE; + } else { + updateData[key] = diffValue.after; + } + } + } + + return updateData as Partial; + } + + public async createCoreView( + workspaceId: string, + workspaceView: ViewWorkspaceEntity, + ): Promise { + const coreView: Partial = { + id: workspaceView.id, + name: workspaceView.name, + objectMetadataId: workspaceView.objectMetadataId, + type: workspaceView.type, + key: workspaceView.key, + icon: workspaceView.icon, + position: workspaceView.position, + isCompact: workspaceView.isCompact, + openRecordIn: + workspaceView.openRecordIn === 'SIDE_PANEL' + ? ViewOpenRecordIn.SIDE_PANEL + : ViewOpenRecordIn.RECORD_PAGE, + kanbanAggregateOperation: workspaceView.kanbanAggregateOperation, + kanbanAggregateOperationFieldMetadataId: + workspaceView.kanbanAggregateOperationFieldMetadataId, + workspaceId, + createdAt: new Date(workspaceView.createdAt), + updatedAt: new Date(workspaceView.updatedAt), + deletedAt: workspaceView.deletedAt + ? new Date(workspaceView.deletedAt) + : null, + }; + + await this.coreViewRepository.save(coreView); + } + + public async updateCoreView( + workspaceId: string, + workspaceView: ViewWorkspaceEntity, + diff?: Partial>, + ): Promise { + if (!diff || Object.keys(diff).length === 0) { + return; + } + + const updateData = this.parseUpdateDataFromDiff(diff); + + if (Object.keys(updateData).length > 0) { + await this.coreViewRepository.update( + { id: workspaceView.id, workspaceId }, + updateData, + ); + } + } + + public async deleteCoreView( + workspaceId: string, + workspaceView: ViewWorkspaceEntity, + ): Promise { + await this.coreViewRepository.softDelete({ + id: workspaceView.id, + workspaceId, + }); + } + + public async destroyCoreView( + workspaceId: string, + workspaceView: ViewWorkspaceEntity, + ): Promise { + await this.coreViewRepository.delete({ + id: workspaceView.id, + workspaceId, + }); + } + + public async restoreCoreView( + workspaceId: string, + workspaceView: ViewWorkspaceEntity, + ): Promise { + await this.coreViewRepository.restore({ + id: workspaceView.id, + workspaceId, + }); + } +} diff --git a/packages/twenty-server/src/modules/view/utils/transform-view-filter-workspace-value-to-core-value.ts b/packages/twenty-server/src/modules/view/utils/transform-view-filter-workspace-value-to-core-value.ts new file mode 100644 index 000000000..4735665c6 --- /dev/null +++ b/packages/twenty-server/src/modules/view/utils/transform-view-filter-workspace-value-to-core-value.ts @@ -0,0 +1,11 @@ +import { ViewFilterValue } from 'src/engine/metadata-modules/view/types/view-filter-value.type'; + +export const transformViewFilterWorkspaceValueToCoreValue = ( + value: string, +): ViewFilterValue => { + try { + return JSON.parse(value); + } catch { + return value; + } +}; diff --git a/packages/twenty-server/src/modules/view/view.module.ts b/packages/twenty-server/src/modules/view/view.module.ts index e40ac5d50..e40c21b17 100644 --- a/packages/twenty-server/src/modules/view/view.module.ts +++ b/packages/twenty-server/src/modules/view/view.module.ts @@ -1,11 +1,52 @@ import { Module } from '@nestjs/common'; +import { TypeOrmModule } from '@nestjs/typeorm'; +import { FeatureFlagModule } from 'src/engine/core-modules/feature-flag/feature-flag.module'; +import { ViewField } from 'src/engine/metadata-modules/view/view-field.entity'; +import { ViewFilterGroup } from 'src/engine/metadata-modules/view/view-filter-group.entity'; +import { ViewFilter } from 'src/engine/metadata-modules/view/view-filter.entity'; +import { ViewGroup } from 'src/engine/metadata-modules/view/view-group.entity'; +import { ViewSort } from 'src/engine/metadata-modules/view/view-sort.entity'; +import { View } from 'src/engine/metadata-modules/view/view.entity'; +import { ViewFieldListener } from 'src/modules/view/listeners/view-field.listener'; +import { ViewFilterGroupListener } from 'src/modules/view/listeners/view-filter-group.listener'; +import { ViewFilterListener } from 'src/modules/view/listeners/view-filter.listener'; +import { ViewGroupListener } from 'src/modules/view/listeners/view-group.listener'; +import { ViewSortListener } from 'src/modules/view/listeners/view-sort.listener'; +import { ViewListener } from 'src/modules/view/listeners/view.listener'; +import { ViewDeleteOnePreQueryHook } from 'src/modules/view/pre-hooks/view-delete-one.pre-query.hook'; +import { ViewFieldSyncService } from 'src/modules/view/services/view-field-sync.service'; +import { ViewFilterGroupSyncService } from 'src/modules/view/services/view-filter-group-sync.service'; +import { ViewFilterSyncService } from 'src/modules/view/services/view-filter-sync.service'; +import { ViewGroupSyncService } from 'src/modules/view/services/view-group-sync.service'; +import { ViewSortSyncService } from 'src/modules/view/services/view-sort-sync.service'; +import { ViewSyncService } from 'src/modules/view/services/view-sync.service'; import { ViewService } from 'src/modules/view/services/view.service'; -import { ViewDeleteOnePreQueryHook } from './pre-hooks/view-delete-one.pre-query.hook'; @Module({ - imports: [], - providers: [ViewService, ViewDeleteOnePreQueryHook], + imports: [ + TypeOrmModule.forFeature( + [View, ViewField, ViewFilter, ViewFilterGroup, ViewGroup, ViewSort], + 'core', + ), + FeatureFlagModule, + ], + providers: [ + ViewService, + ViewDeleteOnePreQueryHook, + ViewSyncService, + ViewFieldSyncService, + ViewFilterSyncService, + ViewFilterGroupSyncService, + ViewGroupSyncService, + ViewSortSyncService, + ViewListener, + ViewFieldListener, + ViewFilterListener, + ViewFilterGroupListener, + ViewGroupListener, + ViewSortListener, + ], exports: [ViewService], }) export class ViewModule {} diff --git a/packages/twenty-server/src/modules/view/views.exception.ts b/packages/twenty-server/src/modules/view/views.exception.ts index 46cb651ed..f71211ef4 100644 --- a/packages/twenty-server/src/modules/view/views.exception.ts +++ b/packages/twenty-server/src/modules/view/views.exception.ts @@ -10,10 +10,12 @@ export enum ViewExceptionCode { VIEW_NOT_FOUND = 'VIEW_NOT_FOUND', CANNOT_DELETE_INDEX_VIEW = 'CANNOT_DELETE_INDEX_VIEW', METHOD_NOT_IMPLEMENTED = 'METHOD_NOT_IMPLEMENTED', + CORE_VIEW_SYNC_ERROR = 'CORE_VIEW_SYNC_ERROR', } export enum ViewExceptionMessage { VIEW_NOT_FOUND = 'View not found', CANNOT_DELETE_INDEX_VIEW = 'Cannot delete index view', METHOD_NOT_IMPLEMENTED = 'Method not implemented', + CORE_VIEW_SYNC_ERROR = 'Failed to sync view data to core', }