From 7af98a9c155f0f042e669590f4a49bc588f6d766 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rapha=C3=ABl=20Bosi?= <71827178+bosiraphael@users.noreply.github.com> Date: Wed, 7 Jan 2026 18:14:24 +0100 Subject: [PATCH] Update updatedat field on dashboards after edition (#16964) Closes https://github.com/twentyhq/core-team-issues/issues/1896 --- .../dashboard/dashboard.module.ts | 12 + .../services/dashboard-sync.service.ts | 256 +++++++++++ .../page-layout-tab/page-layout-tab.module.ts | 2 + .../services/page-layout-tab.service.ts | 80 +++- .../page-layout-widget.module.ts | 2 + .../services/page-layout-widget.service.ts | 80 +++- .../page-layout/page-layout.module.ts | 6 +- .../services/page-layout-update.service.ts | 10 + .../services/page-layout.service.ts | 57 ++- .../dashboard-create-one.pre-query.hook.ts | 8 +- ...hboard-updated-at-sync.integration-spec.ts | 428 ++++++++++++++++++ 11 files changed, 872 insertions(+), 69 deletions(-) create mode 100644 packages/twenty-server/src/engine/metadata-modules/dashboard/dashboard.module.ts create mode 100644 packages/twenty-server/src/engine/metadata-modules/dashboard/services/dashboard-sync.service.ts create mode 100644 packages/twenty-server/test/integration/metadata/suites/dashboard/dashboard-updated-at-sync.integration-spec.ts diff --git a/packages/twenty-server/src/engine/metadata-modules/dashboard/dashboard.module.ts b/packages/twenty-server/src/engine/metadata-modules/dashboard/dashboard.module.ts new file mode 100644 index 0000000000..b4fdb9ada4 --- /dev/null +++ b/packages/twenty-server/src/engine/metadata-modules/dashboard/dashboard.module.ts @@ -0,0 +1,12 @@ +import { Module } from '@nestjs/common'; + +import { DashboardSyncService } from 'src/engine/metadata-modules/dashboard/services/dashboard-sync.service'; +import { WorkspaceManyOrAllFlatEntityMapsCacheModule } from 'src/engine/metadata-modules/flat-entity/services/workspace-many-or-all-flat-entity-maps-cache.module'; +import { TwentyORMModule } from 'src/engine/twenty-orm/twenty-orm.module'; + +@Module({ + imports: [TwentyORMModule, WorkspaceManyOrAllFlatEntityMapsCacheModule], + providers: [DashboardSyncService], + exports: [DashboardSyncService], +}) +export class DashboardModule {} diff --git a/packages/twenty-server/src/engine/metadata-modules/dashboard/services/dashboard-sync.service.ts b/packages/twenty-server/src/engine/metadata-modules/dashboard/services/dashboard-sync.service.ts new file mode 100644 index 0000000000..1fb986b394 --- /dev/null +++ b/packages/twenty-server/src/engine/metadata-modules/dashboard/services/dashboard-sync.service.ts @@ -0,0 +1,256 @@ +import { Injectable, Logger } from '@nestjs/common'; + +import { isDefined } from 'twenty-shared/utils'; + +import { WorkspaceManyOrAllFlatEntityMapsCacheService } from 'src/engine/metadata-modules/flat-entity/services/workspace-many-or-all-flat-entity-maps-cache.service'; +import { PageLayoutType } from 'src/engine/metadata-modules/page-layout/enums/page-layout-type.enum'; +import { GlobalWorkspaceOrmManager } from 'src/engine/twenty-orm/global-workspace-datasource/global-workspace-orm.manager'; +import { buildSystemAuthContext } from 'src/engine/twenty-orm/utils/build-system-auth-context.util'; + +@Injectable() +export class DashboardSyncService { + private readonly logger = new Logger(DashboardSyncService.name); + + constructor( + private readonly globalWorkspaceOrmManager: GlobalWorkspaceOrmManager, + private readonly workspaceManyOrAllFlatEntityMapsCacheService: WorkspaceManyOrAllFlatEntityMapsCacheService, + ) {} + + private async isPageLayoutOfTypeDashboard({ + pageLayoutId, + workspaceId, + }: { + pageLayoutId: string; + workspaceId: string; + }): Promise { + const { flatPageLayoutMaps } = + await this.workspaceManyOrAllFlatEntityMapsCacheService.getOrRecomputeManyOrAllFlatEntityMaps( + { + workspaceId, + flatMapsKeys: ['flatPageLayoutMaps'], + }, + ); + + const pageLayout = flatPageLayoutMaps.byId[pageLayoutId]; + + return ( + isDefined(pageLayout) && pageLayout.type === PageLayoutType.DASHBOARD + ); + } + + async updateLinkedDashboardsUpdatedAtByPageLayoutId({ + pageLayoutId, + workspaceId, + updatedAt, + }: { + pageLayoutId: string; + workspaceId: string; + updatedAt: Date; + }): Promise { + const isDashboard = await this.isPageLayoutOfTypeDashboard({ + pageLayoutId, + workspaceId, + }); + + if (!isDashboard) { + return; + } + + const authContext = buildSystemAuthContext(workspaceId); + + try { + await this.globalWorkspaceOrmManager.executeInWorkspaceContext( + authContext, + async () => { + const dashboardRepository = + await this.globalWorkspaceOrmManager.getRepository( + workspaceId, + 'dashboard', + { shouldBypassPermissionChecks: true }, + ); + + await dashboardRepository.update({ pageLayoutId }, { updatedAt }); + }, + ); + } catch (error) { + this.logger.error( + `Failed to update dashboard updatedAt for page layout ${pageLayoutId}: ${error}`, + ); + } + } + + async softDeleteLinkedDashboardsByPageLayoutId({ + pageLayoutId, + workspaceId, + deletedAt, + }: { + pageLayoutId: string; + workspaceId: string; + deletedAt: Date; + }): Promise { + const isDashboard = await this.isPageLayoutOfTypeDashboard({ + pageLayoutId, + workspaceId, + }); + + if (!isDashboard) { + return; + } + + const authContext = buildSystemAuthContext(workspaceId); + + try { + await this.globalWorkspaceOrmManager.executeInWorkspaceContext( + authContext, + async () => { + const dashboardRepository = + await this.globalWorkspaceOrmManager.getRepository( + workspaceId, + 'dashboard', + { shouldBypassPermissionChecks: true }, + ); + + await dashboardRepository.update({ pageLayoutId }, { deletedAt }); + }, + ); + } catch (error) { + this.logger.error( + `Failed to soft delete dashboards for page layout ${pageLayoutId}: ${error}`, + ); + } + } + + async restoreLinkedDashboardsByPageLayoutId({ + pageLayoutId, + workspaceId, + }: { + pageLayoutId: string; + workspaceId: string; + }): Promise { + const isDashboard = await this.isPageLayoutOfTypeDashboard({ + pageLayoutId, + workspaceId, + }); + + if (!isDashboard) { + return; + } + + const authContext = buildSystemAuthContext(workspaceId); + + try { + await this.globalWorkspaceOrmManager.executeInWorkspaceContext( + authContext, + async () => { + const dashboardRepository = + await this.globalWorkspaceOrmManager.getRepository( + workspaceId, + 'dashboard', + { shouldBypassPermissionChecks: true }, + ); + + await dashboardRepository.update( + { pageLayoutId }, + { deletedAt: null }, + ); + }, + ); + } catch (error) { + this.logger.error( + `Failed to restore dashboards for page layout ${pageLayoutId}: ${error}`, + ); + } + } + + async updateLinkedDashboardsUpdatedAtByTabId({ + tabId, + workspaceId, + updatedAt, + }: { + tabId: string; + workspaceId: string; + updatedAt: Date; + }): Promise { + const { flatPageLayoutTabMaps, flatPageLayoutMaps } = + await this.workspaceManyOrAllFlatEntityMapsCacheService.getOrRecomputeManyOrAllFlatEntityMaps( + { + workspaceId, + flatMapsKeys: ['flatPageLayoutTabMaps', 'flatPageLayoutMaps'], + }, + ); + + const tab = flatPageLayoutTabMaps.byId[tabId]; + + if (!isDefined(tab)) { + return; + } + + const pageLayout = flatPageLayoutMaps.byId[tab.pageLayoutId]; + + if ( + !isDefined(pageLayout) || + pageLayout.type !== PageLayoutType.DASHBOARD + ) { + return; + } + + await this.updateLinkedDashboardsUpdatedAtByPageLayoutId({ + pageLayoutId: tab.pageLayoutId, + workspaceId, + updatedAt, + }); + } + + async updateLinkedDashboardsUpdatedAtByWidgetId({ + widgetId, + workspaceId, + updatedAt, + }: { + widgetId: string; + workspaceId: string; + updatedAt: Date; + }): Promise { + const { + flatPageLayoutWidgetMaps, + flatPageLayoutTabMaps, + flatPageLayoutMaps, + } = + await this.workspaceManyOrAllFlatEntityMapsCacheService.getOrRecomputeManyOrAllFlatEntityMaps( + { + workspaceId, + flatMapsKeys: [ + 'flatPageLayoutWidgetMaps', + 'flatPageLayoutTabMaps', + 'flatPageLayoutMaps', + ], + }, + ); + + const widget = flatPageLayoutWidgetMaps.byId[widgetId]; + + if (!isDefined(widget)) { + return; + } + + const tab = flatPageLayoutTabMaps.byId[widget.pageLayoutTabId]; + + if (!isDefined(tab)) { + return; + } + + const pageLayout = flatPageLayoutMaps.byId[tab.pageLayoutId]; + + if ( + !isDefined(pageLayout) || + pageLayout.type !== PageLayoutType.DASHBOARD + ) { + return; + } + + await this.updateLinkedDashboardsUpdatedAtByPageLayoutId({ + pageLayoutId: tab.pageLayoutId, + workspaceId, + updatedAt, + }); + } +} diff --git a/packages/twenty-server/src/engine/metadata-modules/page-layout-tab/page-layout-tab.module.ts b/packages/twenty-server/src/engine/metadata-modules/page-layout-tab/page-layout-tab.module.ts index 1dcff8dab0..3ee9ce5897 100644 --- a/packages/twenty-server/src/engine/metadata-modules/page-layout-tab/page-layout-tab.module.ts +++ b/packages/twenty-server/src/engine/metadata-modules/page-layout-tab/page-layout-tab.module.ts @@ -4,6 +4,7 @@ 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 { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity'; +import { DashboardModule } from 'src/engine/metadata-modules/dashboard/dashboard.module'; import { WorkspaceManyOrAllFlatEntityMapsCacheModule } from 'src/engine/metadata-modules/flat-entity/services/workspace-many-or-all-flat-entity-maps-cache.module'; import { FlatPageLayoutTabModule } from 'src/engine/metadata-modules/flat-page-layout-tab/flat-page-layout-tab.module'; import { FlatPageLayoutWidgetModule } from 'src/engine/metadata-modules/flat-page-layout-widget/flat-page-layout-widget.module'; @@ -29,6 +30,7 @@ import { WorkspaceMigrationV2Module } from 'src/engine/workspace-manager/workspa FlatPageLayoutTabModule, FlatPageLayoutWidgetModule, ApplicationModule, + DashboardModule, ], controllers: [PageLayoutTabController], providers: [ diff --git a/packages/twenty-server/src/engine/metadata-modules/page-layout-tab/services/page-layout-tab.service.ts b/packages/twenty-server/src/engine/metadata-modules/page-layout-tab/services/page-layout-tab.service.ts index 0cabac9a3f..e5b33f630f 100644 --- a/packages/twenty-server/src/engine/metadata-modules/page-layout-tab/services/page-layout-tab.service.ts +++ b/packages/twenty-server/src/engine/metadata-modules/page-layout-tab/services/page-layout-tab.service.ts @@ -3,6 +3,7 @@ import { Injectable } from '@nestjs/common'; import { isDefined } from 'twenty-shared/utils'; import { ApplicationService } from 'src/engine/core-modules/application/application.service'; +import { DashboardSyncService } from 'src/engine/metadata-modules/dashboard/services/dashboard-sync.service'; import { WorkspaceManyOrAllFlatEntityMapsCacheService } from 'src/engine/metadata-modules/flat-entity/services/workspace-many-or-all-flat-entity-maps-cache.service'; import { findFlatEntityByIdInFlatEntityMapsOrThrow } from 'src/engine/metadata-modules/flat-entity/utils/find-flat-entity-by-id-in-flat-entity-maps-or-throw.util'; import { FlatPageLayoutTabMaps } from 'src/engine/metadata-modules/flat-page-layout-tab/types/flat-page-layout-tab-maps.type'; @@ -36,6 +37,7 @@ export class PageLayoutTabService { private readonly workspaceMigrationValidateBuildAndRunService: WorkspaceMigrationValidateBuildAndRunService, private readonly workspaceManyOrAllFlatEntityMapsCacheService: WorkspaceManyOrAllFlatEntityMapsCacheService, private readonly applicationService: ApplicationService, + private readonly dashboardSyncService: DashboardSyncService, ) {} async findByPageLayoutId( @@ -164,12 +166,18 @@ export class PageLayoutTabService { }, ); - return fromFlatPageLayoutTabToPageLayoutTabDto( - findFlatEntityByIdInFlatEntityMapsOrThrow({ - flatEntityId: flatPageLayoutTabToCreate.id, - flatEntityMaps: recomputedFlatPageLayoutTabMaps, - }), - ); + const createdTab = findFlatEntityByIdInFlatEntityMapsOrThrow({ + flatEntityId: flatPageLayoutTabToCreate.id, + flatEntityMaps: recomputedFlatPageLayoutTabMaps, + }); + + await this.dashboardSyncService.updateLinkedDashboardsUpdatedAtByTabId({ + tabId: flatPageLayoutTabToCreate.id, + workspaceId, + updatedAt: new Date(createdTab.updatedAt), + }); + + return fromFlatPageLayoutTabToPageLayoutTabDto(createdTab); } async update( @@ -226,12 +234,18 @@ export class PageLayoutTabService { }, ); - return fromFlatPageLayoutTabToPageLayoutTabDto( - findFlatEntityByIdInFlatEntityMapsOrThrow({ - flatEntityId: id, - flatEntityMaps: recomputedFlatPageLayoutTabMaps, - }), - ); + const updatedTab = findFlatEntityByIdInFlatEntityMapsOrThrow({ + flatEntityId: id, + flatEntityMaps: recomputedFlatPageLayoutTabMaps, + }); + + await this.dashboardSyncService.updateLinkedDashboardsUpdatedAtByTabId({ + tabId: id, + workspaceId, + updatedAt: new Date(updatedTab.updatedAt), + }); + + return fromFlatPageLayoutTabToPageLayoutTabDto(updatedTab); } async delete( @@ -282,12 +296,18 @@ export class PageLayoutTabService { }, ); - return fromFlatPageLayoutTabToPageLayoutTabDto( - findFlatEntityByIdInFlatEntityMapsOrThrow({ - flatEntityId: id, - flatEntityMaps: recomputedFlatPageLayoutTabMaps, - }), - ); + const deletedTab = findFlatEntityByIdInFlatEntityMapsOrThrow({ + flatEntityId: id, + flatEntityMaps: recomputedFlatPageLayoutTabMaps, + }); + + await this.dashboardSyncService.updateLinkedDashboardsUpdatedAtByTabId({ + tabId: id, + workspaceId, + updatedAt: new Date(deletedTab.updatedAt), + }); + + return fromFlatPageLayoutTabToPageLayoutTabDto(deletedTab); } async destroy(id: string, workspaceId: string): Promise { @@ -327,6 +347,12 @@ export class PageLayoutTabService { ); } + await this.dashboardSyncService.updateLinkedDashboardsUpdatedAtByTabId({ + tabId: id, + workspaceId, + updatedAt: new Date(), + }); + return true; } @@ -378,11 +404,17 @@ export class PageLayoutTabService { }, ); - return fromFlatPageLayoutTabToPageLayoutTabDto( - findFlatEntityByIdInFlatEntityMapsOrThrow({ - flatEntityId: id, - flatEntityMaps: recomputedFlatPageLayoutTabMaps, - }), - ); + const restoredTab = findFlatEntityByIdInFlatEntityMapsOrThrow({ + flatEntityId: id, + flatEntityMaps: recomputedFlatPageLayoutTabMaps, + }); + + await this.dashboardSyncService.updateLinkedDashboardsUpdatedAtByTabId({ + tabId: id, + workspaceId, + updatedAt: new Date(restoredTab.updatedAt), + }); + + return fromFlatPageLayoutTabToPageLayoutTabDto(restoredTab); } } diff --git a/packages/twenty-server/src/engine/metadata-modules/page-layout-widget/page-layout-widget.module.ts b/packages/twenty-server/src/engine/metadata-modules/page-layout-widget/page-layout-widget.module.ts index d36bc3b1cc..4d83d48f5f 100644 --- a/packages/twenty-server/src/engine/metadata-modules/page-layout-widget/page-layout-widget.module.ts +++ b/packages/twenty-server/src/engine/metadata-modules/page-layout-widget/page-layout-widget.module.ts @@ -4,6 +4,7 @@ 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 { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity'; +import { DashboardModule } from 'src/engine/metadata-modules/dashboard/dashboard.module'; import { WorkspaceManyOrAllFlatEntityMapsCacheModule } from 'src/engine/metadata-modules/flat-entity/services/workspace-many-or-all-flat-entity-maps-cache.module'; import { FlatPageLayoutWidgetModule } from 'src/engine/metadata-modules/flat-page-layout-widget/flat-page-layout-widget.module'; import { PageLayoutWidgetController } from 'src/engine/metadata-modules/page-layout-widget/controllers/page-layout-widget.controller'; @@ -27,6 +28,7 @@ import { WorkspaceMigrationV2Module } from 'src/engine/workspace-manager/workspa WorkspaceManyOrAllFlatEntityMapsCacheModule, FlatPageLayoutWidgetModule, ApplicationModule, + DashboardModule, ], controllers: [PageLayoutWidgetController], providers: [ diff --git a/packages/twenty-server/src/engine/metadata-modules/page-layout-widget/services/page-layout-widget.service.ts b/packages/twenty-server/src/engine/metadata-modules/page-layout-widget/services/page-layout-widget.service.ts index 4184ed1e44..e51d49c342 100644 --- a/packages/twenty-server/src/engine/metadata-modules/page-layout-widget/services/page-layout-widget.service.ts +++ b/packages/twenty-server/src/engine/metadata-modules/page-layout-widget/services/page-layout-widget.service.ts @@ -3,6 +3,7 @@ import { Injectable } from '@nestjs/common'; import { isDefined } from 'twenty-shared/utils'; import { ApplicationService } from 'src/engine/core-modules/application/application.service'; +import { DashboardSyncService } from 'src/engine/metadata-modules/dashboard/services/dashboard-sync.service'; import { WorkspaceManyOrAllFlatEntityMapsCacheService } from 'src/engine/metadata-modules/flat-entity/services/workspace-many-or-all-flat-entity-maps-cache.service'; import { findFlatEntityByIdInFlatEntityMapsOrThrow } from 'src/engine/metadata-modules/flat-entity/utils/find-flat-entity-by-id-in-flat-entity-maps-or-throw.util'; import { FlatPageLayoutWidgetMaps } from 'src/engine/metadata-modules/flat-page-layout-widget/types/flat-page-layout-widget-maps.type'; @@ -41,6 +42,7 @@ export class PageLayoutWidgetService { private readonly workspaceMigrationValidateBuildAndRunService: WorkspaceMigrationValidateBuildAndRunService, private readonly workspaceManyOrAllFlatEntityMapsCacheService: WorkspaceManyOrAllFlatEntityMapsCacheService, private readonly applicationService: ApplicationService, + private readonly dashboardSyncService: DashboardSyncService, ) {} private async getFlatPageLayoutWidgetMaps( @@ -158,12 +160,18 @@ export class PageLayoutWidgetService { const recomputedMaps = await this.getFlatPageLayoutWidgetMaps(workspaceId); - return fromFlatPageLayoutWidgetToPageLayoutWidgetDto( - findFlatEntityByIdInFlatEntityMapsOrThrow({ - flatEntityId: flatPageLayoutWidgetToCreate.id, - flatEntityMaps: recomputedMaps, - }), - ); + const createdWidget = findFlatEntityByIdInFlatEntityMapsOrThrow({ + flatEntityId: flatPageLayoutWidgetToCreate.id, + flatEntityMaps: recomputedMaps, + }); + + await this.dashboardSyncService.updateLinkedDashboardsUpdatedAtByWidgetId({ + widgetId: flatPageLayoutWidgetToCreate.id, + workspaceId, + updatedAt: new Date(createdWidget.updatedAt), + }); + + return fromFlatPageLayoutWidgetToPageLayoutWidgetDto(createdWidget); } async update( @@ -211,12 +219,18 @@ export class PageLayoutWidgetService { const recomputedMaps = await this.getFlatPageLayoutWidgetMaps(workspaceId); - return fromFlatPageLayoutWidgetToPageLayoutWidgetDto( - findFlatEntityByIdInFlatEntityMapsOrThrow({ - flatEntityId: id, - flatEntityMaps: recomputedMaps, - }), - ); + const updatedWidget = findFlatEntityByIdInFlatEntityMapsOrThrow({ + flatEntityId: id, + flatEntityMaps: recomputedMaps, + }); + + await this.dashboardSyncService.updateLinkedDashboardsUpdatedAtByWidgetId({ + widgetId: id, + workspaceId, + updatedAt: new Date(updatedWidget.updatedAt), + }); + + return fromFlatPageLayoutWidgetToPageLayoutWidgetDto(updatedWidget); } private getExistingWidgetOrThrow( @@ -261,12 +275,18 @@ export class PageLayoutWidgetService { const recomputedMaps = await this.getFlatPageLayoutWidgetMaps(workspaceId); - return fromFlatPageLayoutWidgetToPageLayoutWidgetDto( - findFlatEntityByIdInFlatEntityMapsOrThrow({ - flatEntityId: id, - flatEntityMaps: recomputedMaps, - }), - ); + const deletedWidget = findFlatEntityByIdInFlatEntityMapsOrThrow({ + flatEntityId: id, + flatEntityMaps: recomputedMaps, + }); + + await this.dashboardSyncService.updateLinkedDashboardsUpdatedAtByWidgetId({ + widgetId: id, + workspaceId, + updatedAt: new Date(deletedWidget.updatedAt), + }); + + return fromFlatPageLayoutWidgetToPageLayoutWidgetDto(deletedWidget); } async destroy(id: string, workspaceId: string): Promise { @@ -290,6 +310,12 @@ export class PageLayoutWidgetService { 'Multiple validation errors occurred while destroying page layout widget', }); + await this.dashboardSyncService.updateLinkedDashboardsUpdatedAtByWidgetId({ + widgetId: id, + workspaceId, + updatedAt: new Date(), + }); + return true; } @@ -316,11 +342,17 @@ export class PageLayoutWidgetService { const recomputedMaps = await this.getFlatPageLayoutWidgetMaps(workspaceId); - return fromFlatPageLayoutWidgetToPageLayoutWidgetDto( - findFlatEntityByIdInFlatEntityMapsOrThrow({ - flatEntityId: id, - flatEntityMaps: recomputedMaps, - }), - ); + const restoredWidget = findFlatEntityByIdInFlatEntityMapsOrThrow({ + flatEntityId: id, + flatEntityMaps: recomputedMaps, + }); + + await this.dashboardSyncService.updateLinkedDashboardsUpdatedAtByWidgetId({ + widgetId: id, + workspaceId, + updatedAt: new Date(restoredWidget.updatedAt), + }); + + return fromFlatPageLayoutWidgetToPageLayoutWidgetDto(restoredWidget); } } diff --git a/packages/twenty-server/src/engine/metadata-modules/page-layout/page-layout.module.ts b/packages/twenty-server/src/engine/metadata-modules/page-layout/page-layout.module.ts index fb635922de..2ded2a5a18 100644 --- a/packages/twenty-server/src/engine/metadata-modules/page-layout/page-layout.module.ts +++ b/packages/twenty-server/src/engine/metadata-modules/page-layout/page-layout.module.ts @@ -5,18 +5,19 @@ import { ApplicationModule } from 'src/engine/core-modules/application/applicati import { FeatureFlagModule } from 'src/engine/core-modules/feature-flag/feature-flag.module'; import { I18nModule } from 'src/engine/core-modules/i18n/i18n.module'; import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity'; +import { DashboardModule } from 'src/engine/metadata-modules/dashboard/dashboard.module'; import { WorkspaceManyOrAllFlatEntityMapsCacheModule } from 'src/engine/metadata-modules/flat-entity/services/workspace-many-or-all-flat-entity-maps-cache.module'; import { FlatPageLayoutTabModule } from 'src/engine/metadata-modules/flat-page-layout-tab/flat-page-layout-tab.module'; import { FlatPageLayoutWidgetModule } from 'src/engine/metadata-modules/flat-page-layout-widget/flat-page-layout-widget.module'; import { FlatPageLayoutModule } from 'src/engine/metadata-modules/flat-page-layout/flat-page-layout.module'; +import { PageLayoutTabModule } from 'src/engine/metadata-modules/page-layout-tab/page-layout-tab.module'; +import { PageLayoutWidgetModule } from 'src/engine/metadata-modules/page-layout-widget/page-layout-widget.module'; import { PageLayoutController } from 'src/engine/metadata-modules/page-layout/controllers/page-layout.controller'; import { PageLayoutEntity } from 'src/engine/metadata-modules/page-layout/entities/page-layout.entity'; import { PageLayoutResolver } from 'src/engine/metadata-modules/page-layout/resolvers/page-layout.resolver'; import { PageLayoutDuplicationService } from 'src/engine/metadata-modules/page-layout/services/page-layout-duplication.service'; import { PageLayoutUpdateService } from 'src/engine/metadata-modules/page-layout/services/page-layout-update.service'; import { PageLayoutService } from 'src/engine/metadata-modules/page-layout/services/page-layout.service'; -import { PageLayoutTabModule } from 'src/engine/metadata-modules/page-layout-tab/page-layout-tab.module'; -import { PageLayoutWidgetModule } from 'src/engine/metadata-modules/page-layout-widget/page-layout-widget.module'; import { PermissionsModule } from 'src/engine/metadata-modules/permissions/permissions.module'; import { TwentyORMModule } from 'src/engine/twenty-orm/twenty-orm.module'; import { WorkspaceCacheStorageModule } from 'src/engine/workspace-cache-storage/workspace-cache-storage.module'; @@ -39,6 +40,7 @@ import { WorkspaceMigrationV2Module } from 'src/engine/workspace-manager/workspa ApplicationModule, PageLayoutTabModule, PageLayoutWidgetModule, + DashboardModule, ], controllers: [PageLayoutController], providers: [ diff --git a/packages/twenty-server/src/engine/metadata-modules/page-layout/services/page-layout-update.service.ts b/packages/twenty-server/src/engine/metadata-modules/page-layout/services/page-layout-update.service.ts index eee034a575..c98e4ed83b 100644 --- a/packages/twenty-server/src/engine/metadata-modules/page-layout/services/page-layout-update.service.ts +++ b/packages/twenty-server/src/engine/metadata-modules/page-layout/services/page-layout-update.service.ts @@ -4,6 +4,7 @@ import { computeDiffBetweenObjects, isDefined } from 'twenty-shared/utils'; import { v4 } from 'uuid'; import { ApplicationService } from 'src/engine/core-modules/application/application.service'; +import { DashboardSyncService } from 'src/engine/metadata-modules/dashboard/services/dashboard-sync.service'; import { WorkspaceManyOrAllFlatEntityMapsCacheService } from 'src/engine/metadata-modules/flat-entity/services/workspace-many-or-all-flat-entity-maps-cache.service'; import { findFlatEntityByIdInFlatEntityMapsOrThrow } from 'src/engine/metadata-modules/flat-entity/utils/find-flat-entity-by-id-in-flat-entity-maps-or-throw.util'; import { FLAT_PAGE_LAYOUT_TAB_EDITABLE_PROPERTIES } from 'src/engine/metadata-modules/flat-page-layout-tab/constants/flat-page-layout-tab-editable-properties.constant'; @@ -40,6 +41,7 @@ export class PageLayoutUpdateService { private readonly workspaceMigrationValidateBuildAndRunService: WorkspaceMigrationValidateBuildAndRunService, private readonly workspaceManyOrAllFlatEntityMapsCacheService: WorkspaceManyOrAllFlatEntityMapsCacheService, private readonly applicationService: ApplicationService, + private readonly dashboardSyncService: DashboardSyncService, ) {} async updatePageLayoutWithTabs({ @@ -164,6 +166,14 @@ export class PageLayoutUpdateService { flatEntityMaps: recomputedFlatPageLayoutMaps, }); + await this.dashboardSyncService.updateLinkedDashboardsUpdatedAtByPageLayoutId( + { + pageLayoutId: id, + workspaceId, + updatedAt: new Date(flatLayout.updatedAt), + }, + ); + return fromFlatPageLayoutWithTabsAndWidgetsToPageLayoutDto( reconstructFlatPageLayoutWithTabsAndWidgets({ layout: flatLayout, diff --git a/packages/twenty-server/src/engine/metadata-modules/page-layout/services/page-layout.service.ts b/packages/twenty-server/src/engine/metadata-modules/page-layout/services/page-layout.service.ts index 66a4766650..e9b4f2c6e7 100644 --- a/packages/twenty-server/src/engine/metadata-modules/page-layout/services/page-layout.service.ts +++ b/packages/twenty-server/src/engine/metadata-modules/page-layout/services/page-layout.service.ts @@ -4,6 +4,7 @@ import { isNonEmptyString } from '@sniptt/guards'; import { isDefined } from 'twenty-shared/utils'; import { ApplicationService } from 'src/engine/core-modules/application/application.service'; +import { DashboardSyncService } from 'src/engine/metadata-modules/dashboard/services/dashboard-sync.service'; import { WorkspaceManyOrAllFlatEntityMapsCacheService } from 'src/engine/metadata-modules/flat-entity/services/workspace-many-or-all-flat-entity-maps-cache.service'; import { findFlatEntityByIdInFlatEntityMapsOrThrow } from 'src/engine/metadata-modules/flat-entity/utils/find-flat-entity-by-id-in-flat-entity-maps-or-throw.util'; import { type FlatPageLayoutTabMaps } from 'src/engine/metadata-modules/flat-page-layout-tab/types/flat-page-layout-tab-maps.type'; @@ -44,6 +45,7 @@ export class PageLayoutService { private readonly workspaceMigrationValidateBuildAndRunService: WorkspaceMigrationValidateBuildAndRunService, private readonly workspaceManyOrAllFlatEntityMapsCacheService: WorkspaceManyOrAllFlatEntityMapsCacheService, private readonly applicationService: ApplicationService, + private readonly dashboardSyncService: DashboardSyncService, ) {} async findByWorkspaceId(workspaceId: string): Promise { @@ -262,12 +264,20 @@ export class PageLayoutService { }, ); - return fromFlatPageLayoutToPageLayoutDto( - findFlatEntityByIdInFlatEntityMapsOrThrow({ - flatEntityId: id, - flatEntityMaps: recomputedFlatPageLayoutMaps, - }), + const updatedLayout = findFlatEntityByIdInFlatEntityMapsOrThrow({ + flatEntityId: id, + flatEntityMaps: recomputedFlatPageLayoutMaps, + }); + + await this.dashboardSyncService.updateLinkedDashboardsUpdatedAtByPageLayoutId( + { + pageLayoutId: id, + workspaceId, + updatedAt: new Date(updatedLayout.updatedAt), + }, ); + + return fromFlatPageLayoutToPageLayoutDto(updatedLayout); } async delete( @@ -318,12 +328,20 @@ export class PageLayoutService { }, ); - return fromFlatPageLayoutToPageLayoutDto( - findFlatEntityByIdInFlatEntityMapsOrThrow({ - flatEntityId: id, - flatEntityMaps: recomputedFlatPageLayoutMaps, - }), - ); + const deletedLayout = findFlatEntityByIdInFlatEntityMapsOrThrow({ + flatEntityId: id, + flatEntityMaps: recomputedFlatPageLayoutMaps, + }); + + await this.dashboardSyncService.softDeleteLinkedDashboardsByPageLayoutId({ + pageLayoutId: id, + workspaceId, + deletedAt: isDefined(deletedLayout.deletedAt) + ? new Date(deletedLayout.deletedAt) + : new Date(), + }); + + return fromFlatPageLayoutToPageLayoutDto(deletedLayout); } async destroy( @@ -456,11 +474,16 @@ export class PageLayoutService { }, ); - return fromFlatPageLayoutToPageLayoutDto( - findFlatEntityByIdInFlatEntityMapsOrThrow({ - flatEntityId: id, - flatEntityMaps: recomputedFlatPageLayoutMaps, - }), - ); + const restoredLayout = findFlatEntityByIdInFlatEntityMapsOrThrow({ + flatEntityId: id, + flatEntityMaps: recomputedFlatPageLayoutMaps, + }); + + await this.dashboardSyncService.restoreLinkedDashboardsByPageLayoutId({ + pageLayoutId: id, + workspaceId, + }); + + return fromFlatPageLayoutToPageLayoutDto(restoredLayout); } } diff --git a/packages/twenty-server/src/modules/dashboard/query-hooks/dashboard-create-one.pre-query.hook.ts b/packages/twenty-server/src/modules/dashboard/query-hooks/dashboard-create-one.pre-query.hook.ts index b9669e7896..4daa08ac03 100644 --- a/packages/twenty-server/src/modules/dashboard/query-hooks/dashboard-create-one.pre-query.hook.ts +++ b/packages/twenty-server/src/modules/dashboard/query-hooks/dashboard-create-one.pre-query.hook.ts @@ -1,6 +1,6 @@ import { Injectable } from '@nestjs/common'; -import { assertIsDefinedOrThrow } from 'twenty-shared/utils'; +import { assertIsDefinedOrThrow, isDefined } from 'twenty-shared/utils'; import { type WorkspacePreQueryHookInstance } from 'src/engine/api/graphql/workspace-query-runner/workspace-query-hook/interfaces/workspace-query-hook.interface'; import { type CreateOneResolverArgs } from 'src/engine/api/graphql/workspace-resolver-builder/interfaces/workspace-resolvers-builder.interface'; @@ -8,8 +8,8 @@ import { type CreateOneResolverArgs } from 'src/engine/api/graphql/workspace-res import { WorkspaceQueryHook } from 'src/engine/api/graphql/workspace-query-runner/workspace-query-hook/decorators/workspace-query-hook.decorator'; import { type AuthContext } from 'src/engine/core-modules/auth/types/auth-context.type'; import { WorkspaceNotFoundDefaultError } from 'src/engine/core-modules/workspace/workspace.exception'; -import { PageLayoutType } from 'src/engine/metadata-modules/page-layout/enums/page-layout-type.enum'; import { PageLayoutTabService } from 'src/engine/metadata-modules/page-layout-tab/services/page-layout-tab.service'; +import { PageLayoutType } from 'src/engine/metadata-modules/page-layout/enums/page-layout-type.enum'; import { PageLayoutService } from 'src/engine/metadata-modules/page-layout/services/page-layout.service'; import { type DashboardWorkspaceEntity } from 'src/modules/dashboard/standard-objects/dashboard.workspace-entity'; @@ -32,6 +32,10 @@ export class DashboardCreateOnePreQueryHook assertIsDefinedOrThrow(workspace, WorkspaceNotFoundDefaultError); + if (isDefined(payload.data.pageLayoutId)) { + return payload; + } + const pageLayout = await this.pageLayoutService.create( { type: PageLayoutType.DASHBOARD, diff --git a/packages/twenty-server/test/integration/metadata/suites/dashboard/dashboard-updated-at-sync.integration-spec.ts b/packages/twenty-server/test/integration/metadata/suites/dashboard/dashboard-updated-at-sync.integration-spec.ts new file mode 100644 index 0000000000..238c22e1f3 --- /dev/null +++ b/packages/twenty-server/test/integration/metadata/suites/dashboard/dashboard-updated-at-sync.integration-spec.ts @@ -0,0 +1,428 @@ +import { TEST_IFRAME_CONFIG } from 'test/integration/constants/widget-configuration-test-data.constants'; +import { + createTestDashboardWithGraphQL, + destroyDashboardWithGraphQL, + findDashboardWithGraphQL, +} from 'test/integration/metadata/suites/dashboard/utils/dashboard-graphql.util'; +import { createOnePageLayoutTab } from 'test/integration/metadata/suites/page-layout-tab/utils/create-one-page-layout-tab.util'; +import { deleteOnePageLayoutTab } from 'test/integration/metadata/suites/page-layout-tab/utils/delete-one-page-layout-tab.util'; +import { destroyOnePageLayoutTab } from 'test/integration/metadata/suites/page-layout-tab/utils/destroy-one-page-layout-tab.util'; +import { restoreOnePageLayoutTab } from 'test/integration/metadata/suites/page-layout-tab/utils/restore-one-page-layout-tab.util'; +import { updateOnePageLayoutTab } from 'test/integration/metadata/suites/page-layout-tab/utils/update-one-page-layout-tab.util'; +import { createOnePageLayoutWidget } from 'test/integration/metadata/suites/page-layout-widget/utils/create-one-page-layout-widget.util'; +import { deleteOnePageLayoutWidget } from 'test/integration/metadata/suites/page-layout-widget/utils/delete-one-page-layout-widget.util'; +import { destroyOnePageLayoutWidget } from 'test/integration/metadata/suites/page-layout-widget/utils/destroy-one-page-layout-widget.util'; +import { restoreOnePageLayoutWidget } from 'test/integration/metadata/suites/page-layout-widget/utils/restore-one-page-layout-widget.util'; +import { updateOnePageLayoutWidget } from 'test/integration/metadata/suites/page-layout-widget/utils/update-one-page-layout-widget.util'; +import { createOnePageLayout } from 'test/integration/metadata/suites/page-layout/utils/create-one-page-layout.util'; +import { deleteOnePageLayout } from 'test/integration/metadata/suites/page-layout/utils/delete-one-page-layout.util'; +import { destroyOnePageLayout } from 'test/integration/metadata/suites/page-layout/utils/destroy-one-page-layout.util'; +import { restoreOnePageLayout } from 'test/integration/metadata/suites/page-layout/utils/restore-one-page-layout.util'; +import { updateOnePageLayout } from 'test/integration/metadata/suites/page-layout/utils/update-one-page-layout.util'; + +import { WidgetType } from 'src/engine/metadata-modules/page-layout-widget/enums/widget-type.enum'; +import { PageLayoutType } from 'src/engine/metadata-modules/page-layout/enums/page-layout-type.enum'; + +type TestContext = { + pageLayoutId: string; + tabId: string; + widgetId: string; + dashboardId: string; +}; + +const createTestContext = async (): Promise => { + const { data: pageLayoutData } = await createOnePageLayout({ + expectToFail: false, + input: { + name: 'Page Layout for Dashboard Sync Test', + type: PageLayoutType.DASHBOARD, + }, + }); + + const pageLayoutId = pageLayoutData.createPageLayout.id; + + const { data: tabData } = await createOnePageLayoutTab({ + expectToFail: false, + input: { + title: 'Tab for Dashboard Sync Test', + pageLayoutId, + }, + }); + + const tabId = tabData.createPageLayoutTab.id; + + const { data: widgetData } = await createOnePageLayoutWidget({ + expectToFail: false, + input: { + title: 'Widget for Dashboard Sync Test', + type: WidgetType.IFRAME, + pageLayoutTabId: tabId, + gridPosition: { + row: 0, + column: 0, + rowSpan: 1, + columnSpan: 1, + }, + configuration: TEST_IFRAME_CONFIG, + }, + }); + + const widgetId = widgetData.createPageLayoutWidget.id; + + const dashboard = await createTestDashboardWithGraphQL({ + title: 'Dashboard for Sync Test', + pageLayoutId, + }); + + return { + pageLayoutId, + tabId, + widgetId, + dashboardId: dashboard.id, + }; +}; + +const cleanupTestContext = async (context: TestContext): Promise => { + await destroyOnePageLayoutWidget({ + expectToFail: false, + input: { id: context.widgetId }, + }); + + await destroyOnePageLayoutTab({ + expectToFail: false, + input: { id: context.tabId }, + }); + + await destroyDashboardWithGraphQL(context.dashboardId); + + await destroyOnePageLayout({ + expectToFail: false, + input: { id: context.pageLayoutId }, + }); +}; + +const assertDashboardUpdatedAtIncreased = async ( + dashboardId: string, + operation: () => Promise, +): Promise => { + const dashboardBefore = await findDashboardWithGraphQL(dashboardId); + + expect(dashboardBefore).not.toBeNull(); + + const updatedAtBefore = new Date(dashboardBefore!.updatedAt); + + await operation(); + + const dashboardAfter = await findDashboardWithGraphQL(dashboardId); + + expect(dashboardAfter).not.toBeNull(); + + const updatedAtAfter = new Date(dashboardAfter!.updatedAt); + + const isIncreased = updatedAtAfter > updatedAtBefore; + + expect(isIncreased).toBe(true); +}; + +const assertDashboardSoftDeleted = async ( + dashboardId: string, + operation: () => Promise, +): Promise => { + const dashboardBefore = await findDashboardWithGraphQL(dashboardId); + + expect(dashboardBefore).not.toBeNull(); + + await operation(); + + const dashboardAfter = await findDashboardWithGraphQL(dashboardId); + + expect(dashboardAfter).toBeNull(); +}; + +const assertDashboardRestored = async ( + dashboardId: string, + operation: () => Promise, +): Promise => { + const dashboardBefore = await findDashboardWithGraphQL(dashboardId); + + expect(dashboardBefore).toBeNull(); + + await operation(); + + const dashboardAfter = await findDashboardWithGraphQL(dashboardId); + + expect(dashboardAfter).not.toBeNull(); + + const updatedAtAfter = new Date(dashboardAfter!.updatedAt); + + const now = new Date(); + const timeDiff = now.getTime() - updatedAtAfter.getTime(); + + expect(timeDiff).toBeLessThan(5000); +}; + +describe('Dashboard updatedAt should sync when linked page layout entities change', () => { + describe('Widget operations', () => { + let context: TestContext; + let additionalWidgetId: string | undefined; + + beforeEach(async () => { + context = await createTestContext(); + }); + + afterEach(async () => { + if (additionalWidgetId) { + await destroyOnePageLayoutWidget({ + expectToFail: false, + input: { id: additionalWidgetId }, + }); + additionalWidgetId = undefined; + } + + await cleanupTestContext(context); + }); + + it('should update dashboard updatedAt when widget is created', async () => { + await assertDashboardUpdatedAtIncreased(context.dashboardId, async () => { + const { data: widgetData } = await createOnePageLayoutWidget({ + expectToFail: false, + input: { + title: 'New Widget for Dashboard Sync Test', + type: WidgetType.IFRAME, + pageLayoutTabId: context.tabId, + gridPosition: { + row: 1, + column: 0, + rowSpan: 1, + columnSpan: 1, + }, + configuration: TEST_IFRAME_CONFIG, + }, + }); + + additionalWidgetId = widgetData.createPageLayoutWidget.id; + }); + }); + + it('should update dashboard updatedAt when widget is updated', async () => { + await assertDashboardUpdatedAtIncreased(context.dashboardId, async () => { + await updateOnePageLayoutWidget({ + expectToFail: false, + input: { id: context.widgetId, title: 'Updated Widget Title' }, + }); + }); + }); + + it('should update dashboard updatedAt when widget is soft deleted', async () => { + await assertDashboardUpdatedAtIncreased(context.dashboardId, async () => { + await deleteOnePageLayoutWidget({ + expectToFail: false, + input: { id: context.widgetId }, + }); + }); + }); + + it('should update dashboard updatedAt when widget is restored', async () => { + await deleteOnePageLayoutWidget({ + expectToFail: false, + input: { id: context.widgetId }, + }); + + await assertDashboardUpdatedAtIncreased(context.dashboardId, async () => { + await restoreOnePageLayoutWidget({ + expectToFail: false, + input: { id: context.widgetId }, + }); + }); + }); + }); + + describe('Tab operations', () => { + let context: TestContext; + let additionalTabId: string | undefined; + + beforeEach(async () => { + context = await createTestContext(); + }); + + afterEach(async () => { + if (additionalTabId) { + await destroyOnePageLayoutTab({ + expectToFail: false, + input: { id: additionalTabId }, + }); + additionalTabId = undefined; + } + await cleanupTestContext(context); + }); + + it('should update dashboard updatedAt when tab is created', async () => { + await assertDashboardUpdatedAtIncreased(context.dashboardId, async () => { + const { data: tabData } = await createOnePageLayoutTab({ + expectToFail: false, + input: { + title: 'New Tab for Dashboard Sync Test', + pageLayoutId: context.pageLayoutId, + }, + }); + + additionalTabId = tabData.createPageLayoutTab.id; + }); + }); + + it('should update dashboard updatedAt when tab is updated', async () => { + await assertDashboardUpdatedAtIncreased(context.dashboardId, async () => { + await updateOnePageLayoutTab({ + expectToFail: false, + input: { id: context.tabId, title: 'Updated Tab Title' }, + }); + }); + }); + + it('should update dashboard updatedAt when tab is soft deleted', async () => { + const { data: tabData } = await createOnePageLayoutTab({ + expectToFail: false, + input: { + title: 'Tab to Delete', + pageLayoutId: context.pageLayoutId, + }, + }); + + additionalTabId = tabData.createPageLayoutTab.id; + + await assertDashboardUpdatedAtIncreased(context.dashboardId, async () => { + await deleteOnePageLayoutTab({ + expectToFail: false, + input: { id: additionalTabId! }, + }); + }); + }); + + it('should update dashboard updatedAt when tab is restored', async () => { + const { data: tabData } = await createOnePageLayoutTab({ + expectToFail: false, + input: { + title: 'Tab to Restore', + pageLayoutId: context.pageLayoutId, + }, + }); + + additionalTabId = tabData.createPageLayoutTab.id; + + await deleteOnePageLayoutTab({ + expectToFail: false, + input: { id: additionalTabId }, + }); + + await assertDashboardUpdatedAtIncreased(context.dashboardId, async () => { + await restoreOnePageLayoutTab({ + expectToFail: false, + input: { id: additionalTabId! }, + }); + }); + }); + }); + + describe('Page layout operations', () => { + let context: TestContext; + + beforeEach(async () => { + context = await createTestContext(); + }); + + afterEach(async () => { + await cleanupTestContext(context); + }); + + it('should update dashboard updatedAt when page layout is updated', async () => { + await assertDashboardUpdatedAtIncreased(context.dashboardId, async () => { + await updateOnePageLayout({ + expectToFail: false, + input: { + id: context.pageLayoutId, + name: 'Updated Page Layout Name', + }, + }); + }); + }); + + it('should update dashboard updatedAt when page layout is soft deleted', async () => { + await assertDashboardSoftDeleted(context.dashboardId, async () => { + await deleteOnePageLayout({ + expectToFail: false, + input: { id: context.pageLayoutId }, + }); + }); + }); + + it('should update dashboard updatedAt when page layout is restored', async () => { + await deleteOnePageLayout({ + expectToFail: false, + input: { id: context.pageLayoutId }, + }); + + await assertDashboardRestored(context.dashboardId, async () => { + await restoreOnePageLayout({ + expectToFail: false, + input: { id: context.pageLayoutId }, + }); + }); + }); + }); + + describe('Non-dashboard page layout operations should not trigger sync', () => { + let nonDashboardPageLayoutId: string; + let dashboardContext: TestContext; + + beforeEach(async () => { + dashboardContext = await createTestContext(); + + const { data: pageLayoutData } = await createOnePageLayout({ + expectToFail: false, + input: { + name: 'Non-Dashboard Page Layout', + type: PageLayoutType.RECORD_INDEX, + }, + }); + + nonDashboardPageLayoutId = pageLayoutData.createPageLayout.id; + }); + + afterEach(async () => { + await destroyOnePageLayout({ + expectToFail: false, + input: { id: nonDashboardPageLayoutId }, + }); + + await cleanupTestContext(dashboardContext); + }); + + it('should not update existing dashboard updatedAt when non-dashboard page layout is updated', async () => { + const dashboardBefore = await findDashboardWithGraphQL( + dashboardContext.dashboardId, + ); + + expect(dashboardBefore).not.toBeNull(); + + const updatedAtBefore = new Date(dashboardBefore!.updatedAt); + + await updateOnePageLayout({ + expectToFail: false, + input: { + id: nonDashboardPageLayoutId, + name: 'Updated Non-Dashboard Page Layout', + }, + }); + + const dashboardAfter = await findDashboardWithGraphQL( + dashboardContext.dashboardId, + ); + + expect(dashboardAfter).not.toBeNull(); + + const updatedAtAfter = new Date(dashboardAfter!.updatedAt); + + expect(updatedAtAfter.getTime()).toBe(updatedAtBefore.getTime()); + }); + }); +});