diff --git a/packages/twenty-server/src/database/commands/upgrade-version-command/1-23/1-23-upgrade-version-command.module.ts b/packages/twenty-server/src/database/commands/upgrade-version-command/1-23/1-23-upgrade-version-command.module.ts index 381b36934e..0666a2990f 100644 --- a/packages/twenty-server/src/database/commands/upgrade-version-command/1-23/1-23-upgrade-version-command.module.ts +++ b/packages/twenty-server/src/database/commands/upgrade-version-command/1-23/1-23-upgrade-version-command.module.ts @@ -1,7 +1,7 @@ import { Module } from '@nestjs/common'; import { WorkspaceIteratorModule } from 'src/database/commands/command-runners/workspace-iterator.module'; -import { BackfillPageLayoutsAndFieldsWidgetViewFieldsCommand } from 'src/database/commands/upgrade-version-command/1-23/1-23-workspace-command-1780000001000-backfill-page-layouts-and-fields-widget-view-fields.command'; +import { BackfillRecordPageLayoutsCommand } from 'src/database/commands/upgrade-version-command/1-23/1-23-workspace-command-1780000001500-backfill-record-page-layouts.command'; import { UpdateGlobalObjectContextCommandMenuItemsCommand } from 'src/database/commands/upgrade-version-command/1-23/1-23-workspace-command-1780000005000-update-global-object-context-command-menu-items.command'; import { ApplicationModule } from 'src/engine/core-modules/application/application.module'; import { FeatureFlagModule } from 'src/engine/core-modules/feature-flag/feature-flag.module'; @@ -17,7 +17,7 @@ import { WorkspaceMigrationModule } from 'src/engine/workspace-manager/workspace WorkspaceMigrationModule, ], providers: [ - BackfillPageLayoutsAndFieldsWidgetViewFieldsCommand, + BackfillRecordPageLayoutsCommand, UpdateGlobalObjectContextCommandMenuItemsCommand, ], }) diff --git a/packages/twenty-server/src/database/commands/upgrade-version-command/1-23/1-23-workspace-command-1780000001000-backfill-page-layouts-and-fields-widget-view-fields.command.ts b/packages/twenty-server/src/database/commands/upgrade-version-command/1-23/1-23-workspace-command-1780000001000-backfill-page-layouts-and-fields-widget-view-fields.command.ts deleted file mode 100644 index 76a1a35398..0000000000 --- a/packages/twenty-server/src/database/commands/upgrade-version-command/1-23/1-23-workspace-command-1780000001000-backfill-page-layouts-and-fields-widget-view-fields.command.ts +++ /dev/null @@ -1,833 +0,0 @@ -import { Command } from 'nest-commander'; -import { - CoreObjectNameSingular, - FeatureFlagKey, - FieldMetadataType, - PageLayoutTabLayoutMode, - ViewType, -} from 'twenty-shared/types'; -import { isDefined } from 'twenty-shared/utils'; -import { v4 } from 'uuid'; - -import { ActiveOrSuspendedWorkspaceCommandRunner } from 'src/database/commands/command-runners/active-or-suspended-workspace.command-runner'; -import { WorkspaceIteratorService } from 'src/database/commands/command-runners/workspace-iterator.service'; -import { type RunOnWorkspaceArgs } from 'src/database/commands/command-runners/workspace.command-runner'; -import { ApplicationService } from 'src/engine/core-modules/application/application.service'; -import { type FlatApplication } from 'src/engine/core-modules/application/types/flat-application.type'; -import { FeatureFlagService } from 'src/engine/core-modules/feature-flag/services/feature-flag.service'; -import { RegisteredWorkspaceCommand } from 'src/engine/core-modules/upgrade/decorators/registered-workspace-command.decorator'; -import { type FlatFieldMetadata } from 'src/engine/metadata-modules/flat-field-metadata/types/flat-field-metadata.type'; -import { type FlatObjectMetadata } from 'src/engine/metadata-modules/flat-object-metadata/types/flat-object-metadata.type'; -import { type FlatPageLayoutTab } from 'src/engine/metadata-modules/flat-page-layout-tab/types/flat-page-layout-tab.type'; -import { type FlatPageLayoutWidget } from 'src/engine/metadata-modules/flat-page-layout-widget/types/flat-page-layout-widget.type'; -import { type FlatPageLayout } from 'src/engine/metadata-modules/flat-page-layout/types/flat-page-layout.type'; -import { computeFlatDefaultRecordPageLayoutToCreate } from 'src/engine/metadata-modules/object-metadata/utils/compute-flat-default-record-page-layout-to-create.util'; -import { computeFlatRecordPageFieldsViewToCreate } from 'src/engine/metadata-modules/object-metadata/utils/compute-flat-record-page-fields-view-to-create.util'; -import { computeFlatViewFieldsToCreate } from 'src/engine/metadata-modules/object-metadata/utils/compute-flat-view-fields-to-create.util'; -import { FieldDisplayMode } from 'src/engine/metadata-modules/page-layout-widget/enums/field-display-mode.enum'; -import { WidgetConfigurationType } from 'src/engine/metadata-modules/page-layout-widget/enums/widget-configuration-type.type'; -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'; -import { WorkspaceCacheService } from 'src/engine/workspace-cache/services/workspace-cache.service'; -import { - GRID_POSITIONS, - VERTICAL_LIST_LAYOUT_POSITIONS, -} from 'src/engine/workspace-manager/twenty-standard-application/constants/standard-page-layout-tabs.template'; -import { computeTwentyStandardApplicationAllFlatEntityMaps } from 'src/engine/workspace-manager/twenty-standard-application/utils/twenty-standard-application-all-flat-entity-maps.constant'; -import { WorkspaceMigrationValidateBuildAndRunService } from 'src/engine/workspace-manager/workspace-migration/services/workspace-migration-validate-build-and-run-service'; -import { type UniversalFlatViewField } from 'src/engine/workspace-manager/workspace-migration/universal-flat-entity/types/universal-flat-view-field.type'; -import { type UniversalFlatView } from 'src/engine/workspace-manager/workspace-migration/universal-flat-entity/types/universal-flat-view.type'; - -const HOME_TAB_POSITION = 10; - -const isActivityTargetField = ( - fieldName: string, - objectNameSingular: string, -): boolean => - (objectNameSingular === CoreObjectNameSingular.Note && - fieldName === 'noteTargets') || - (objectNameSingular === CoreObjectNameSingular.Task && - fieldName === 'taskTargets'); - -const isJunctionRelationField = (field: FlatFieldMetadata): boolean => { - const settings = field.settings as - | { junctionTargetFieldId?: string } - | undefined; - - return ( - isDefined(settings?.junctionTargetFieldId) && - typeof settings?.junctionTargetFieldId === 'string' && - settings.junctionTargetFieldId.length > 0 - ); -}; - -const isRelationTargetAvailable = ( - targetObject: FlatObjectMetadata | undefined, -): boolean => { - if (!isDefined(targetObject)) { - return false; - } - - if (targetObject.isRemote) { - return false; - } - - if ( - targetObject.isSystem && - targetObject.nameSingular !== CoreObjectNameSingular.WorkspaceMember - ) { - return false; - } - - return true; -}; - -@RegisteredWorkspaceCommand('1.23.0', 1780000001000) -@Command({ - name: 'upgrade:1-23:backfill-page-layouts-and-fields-widget-view-fields', - description: - 'Backfill RECORD_PAGE page layouts, sync FIELDS_WIDGET view fields, create FIELD widgets, and enable IS_RECORD_PAGE_LAYOUT_EDITING_ENABLED', -}) -export class BackfillPageLayoutsAndFieldsWidgetViewFieldsCommand extends ActiveOrSuspendedWorkspaceCommandRunner { - constructor( - protected readonly workspaceIteratorService: WorkspaceIteratorService, - private readonly applicationService: ApplicationService, - private readonly workspaceMigrationValidateBuildAndRunService: WorkspaceMigrationValidateBuildAndRunService, - private readonly featureFlagService: FeatureFlagService, - private readonly workspaceCacheService: WorkspaceCacheService, - ) { - super(workspaceIteratorService); - } - - override async runOnWorkspace({ - workspaceId, - options, - }: RunOnWorkspaceArgs): Promise { - const isDryRun = options.dryRun ?? false; - - this.logger.log( - `${isDryRun ? '[DRY RUN] ' : ''}Starting backfill of page layouts and field widgets for workspace ${workspaceId}`, - ); - - const isAlreadyEnabled = await this.featureFlagService.isFeatureEnabled( - FeatureFlagKey.IS_RECORD_PAGE_LAYOUT_EDITING_ENABLED, - workspaceId, - ); - - if (isAlreadyEnabled) { - this.logger.log( - `IS_RECORD_PAGE_LAYOUT_EDITING_ENABLED already enabled for workspace ${workspaceId}, skipping`, - ); - - return; - } - - if (isDryRun) { - this.logger.log( - `[DRY RUN] Would create page layouts, sync view fields, create FIELD widgets and enable IS_RECORD_PAGE_LAYOUT_EDITING_ENABLED for workspace ${workspaceId}`, - ); - - return; - } - - const { twentyStandardFlatApplication } = - await this.applicationService.findWorkspaceTwentyStandardAndCustomApplicationOrThrow( - { workspaceId }, - ); - - await this.backfillStandardPageLayoutsAndSyncViews({ - workspaceId, - twentyStandardFlatApplication, - }); - - await this.backfillCustomObjectPageLayouts({ - workspaceId, - twentyStandardFlatApplication, - }); - - await this.backfillFieldWidgets({ - workspaceId, - twentyStandardFlatApplication, - }); - - await this.featureFlagService.enableFeatureFlags( - [FeatureFlagKey.IS_RECORD_PAGE_LAYOUT_EDITING_ENABLED], - workspaceId, - ); - - this.logger.log( - `Successfully backfilled page layouts and field widgets for workspace ${workspaceId}`, - ); - } - - private async backfillStandardPageLayoutsAndSyncViews({ - workspaceId, - twentyStandardFlatApplication, - }: { - workspaceId: string; - twentyStandardFlatApplication: FlatApplication; - }): Promise { - const { allFlatEntityMaps: standardAllFlatEntityMaps } = - computeTwentyStandardApplicationAllFlatEntityMaps({ - shouldIncludeRecordPageLayouts: true, - now: new Date().toISOString(), - workspaceId, - twentyStandardApplicationId: twentyStandardFlatApplication.id, - }); - - const recordPageLayoutUniversalIdentifiers = new Set(); - - const recordPageLayoutsFromStandard = Object.values( - standardAllFlatEntityMaps.flatPageLayoutMaps.byUniversalIdentifier, - ) - .filter(isDefined) - .filter((pageLayout) => { - if (pageLayout.type !== PageLayoutType.RECORD_PAGE) { - return false; - } - - recordPageLayoutUniversalIdentifiers.add( - pageLayout.universalIdentifier, - ); - - return true; - }); - - const tabUniversalIdentifiers = new Set(); - - const tabsFromStandard = Object.values( - standardAllFlatEntityMaps.flatPageLayoutTabMaps.byUniversalIdentifier, - ) - .filter(isDefined) - .filter((tab) => { - if ( - !recordPageLayoutUniversalIdentifiers.has( - tab.pageLayoutUniversalIdentifier, - ) - ) { - return false; - } - - tabUniversalIdentifiers.add(tab.universalIdentifier); - - return true; - }); - - const widgetsFromStandard = Object.values( - standardAllFlatEntityMaps.flatPageLayoutWidgetMaps.byUniversalIdentifier, - ) - .filter(isDefined) - .filter((widget) => - tabUniversalIdentifiers.has(widget.pageLayoutTabUniversalIdentifier), - ); - - const { - flatFieldMetadataMaps: existingFlatFieldMetadataMaps, - flatPageLayoutMaps: existingFlatPageLayoutMaps, - flatPageLayoutTabMaps: existingFlatPageLayoutTabMaps, - flatPageLayoutWidgetMaps: existingFlatPageLayoutWidgetMaps, - flatViewMaps: existingFlatViewMaps, - flatViewFieldMaps: existingFlatViewFieldMaps, - flatViewFieldGroupMaps: existingFlatViewFieldGroupMaps, - } = await this.workspaceCacheService.getOrRecompute(workspaceId, [ - 'flatFieldMetadataMaps', - 'flatPageLayoutMaps', - 'flatPageLayoutTabMaps', - 'flatPageLayoutWidgetMaps', - 'flatViewMaps', - 'flatViewFieldMaps', - 'flatViewFieldGroupMaps', - ]); - - // Filter out page layouts, tabs, and widgets that already exist in the workspace - const pageLayoutsToCreate = recordPageLayoutsFromStandard.filter( - (pageLayout) => - !isDefined( - existingFlatPageLayoutMaps.byUniversalIdentifier[ - pageLayout.universalIdentifier - ], - ), - ); - - const pageLayoutTabsToCreate = tabsFromStandard.filter( - (tab) => - !isDefined( - existingFlatPageLayoutTabMaps.byUniversalIdentifier[ - tab.universalIdentifier - ], - ), - ); - - const pageLayoutWidgetsToCreate = widgetsFromStandard.filter( - (widget) => - !isDefined( - existingFlatPageLayoutWidgetMaps.byUniversalIdentifier[ - widget.universalIdentifier - ], - ), - ); - - // Collect all standard FIELDS_WIDGET view universal identifiers - // This includes both new views to create AND existing views (created by 1-19) - const allStandardFieldsWidgetViewUniversalIds = new Set(); - - const viewsToCreate = Object.values( - standardAllFlatEntityMaps.flatViewMaps.byUniversalIdentifier, - ) - .filter(isDefined) - .filter((view) => { - if (view.type !== ViewType.FIELDS_WIDGET) { - return false; - } - - allStandardFieldsWidgetViewUniversalIds.add(view.universalIdentifier); - - if ( - isDefined( - existingFlatViewMaps.byUniversalIdentifier[ - view.universalIdentifier - ], - ) - ) { - return false; - } - - return true; - }); - - // Delete all existing viewFields for standard FIELDS_WIDGET views and recreate from current standard. - // This ensures positions, visibility, and groups match the current standard definition. - const viewFieldsToDelete = Object.values( - existingFlatViewFieldMaps.byUniversalIdentifier, - ) - .filter(isDefined) - .filter((viewField) => - allStandardFieldsWidgetViewUniversalIds.has( - viewField.viewUniversalIdentifier, - ), - ); - - const viewFieldsToCreate = Object.values( - standardAllFlatEntityMaps.flatViewFieldMaps.byUniversalIdentifier, - ) - .filter(isDefined) - .filter((viewField) => - allStandardFieldsWidgetViewUniversalIds.has( - viewField.viewUniversalIdentifier, - ), - ) - // Skip viewFields whose referenced fieldMetadata doesn't exist in the workspace yet - // (e.g. fields added to the standard definition but not yet synced to this workspace) - .filter((viewField) => - isDefined( - existingFlatFieldMetadataMaps.byUniversalIdentifier[ - viewField.fieldMetadataUniversalIdentifier - ], - ), - ); - - // Same delete-and-recreate approach for viewFieldGroups - const viewFieldGroupsToDelete = Object.values( - existingFlatViewFieldGroupMaps.byUniversalIdentifier, - ) - .filter(isDefined) - .filter((viewFieldGroup) => - allStandardFieldsWidgetViewUniversalIds.has( - viewFieldGroup.viewUniversalIdentifier, - ), - ); - - const viewFieldGroupsToCreate = Object.values( - standardAllFlatEntityMaps.flatViewFieldGroupMaps.byUniversalIdentifier, - ) - .filter(isDefined) - .filter((viewFieldGroup) => - allStandardFieldsWidgetViewUniversalIds.has( - viewFieldGroup.viewUniversalIdentifier, - ), - ); - - this.logger.log( - `Creating ${pageLayoutsToCreate.length} page layouts, ${viewsToCreate.length} views, deleting ${viewFieldsToDelete.length} and creating ${viewFieldsToCreate.length} view fields, deleting ${viewFieldGroupsToDelete.length} and creating ${viewFieldGroupsToCreate.length} view field groups for workspace ${workspaceId}`, - ); - - const validateAndBuildResult = - await this.workspaceMigrationValidateBuildAndRunService.validateBuildAndRunWorkspaceMigration( - { - allFlatEntityOperationByMetadataName: { - pageLayout: { - flatEntityToCreate: pageLayoutsToCreate, - flatEntityToDelete: [], - flatEntityToUpdate: [], - }, - pageLayoutTab: { - flatEntityToCreate: pageLayoutTabsToCreate, - flatEntityToDelete: [], - flatEntityToUpdate: [], - }, - pageLayoutWidget: { - flatEntityToCreate: pageLayoutWidgetsToCreate, - flatEntityToDelete: [], - flatEntityToUpdate: [], - }, - view: { - flatEntityToCreate: viewsToCreate, - flatEntityToDelete: [], - flatEntityToUpdate: [], - }, - viewField: { - flatEntityToCreate: viewFieldsToCreate, - flatEntityToDelete: viewFieldsToDelete, - flatEntityToUpdate: [], - }, - viewFieldGroup: { - flatEntityToCreate: viewFieldGroupsToCreate, - flatEntityToDelete: viewFieldGroupsToDelete, - flatEntityToUpdate: [], - }, - }, - workspaceId, - applicationUniversalIdentifier: - twentyStandardFlatApplication.universalIdentifier, - }, - ); - - if (validateAndBuildResult.status === 'fail') { - this.logger.error( - `Failed to create standard page layouts and sync view fields:\n${JSON.stringify(validateAndBuildResult, null, 2)}`, - ); - throw new Error( - `Failed to create standard page layouts and sync view fields for workspace ${workspaceId}`, - ); - } - } - - private async backfillCustomObjectPageLayouts({ - workspaceId, - twentyStandardFlatApplication, - }: { - workspaceId: string; - twentyStandardFlatApplication: FlatApplication; - }): Promise { - const { - flatObjectMetadataMaps, - flatFieldMetadataMaps, - flatPageLayoutMaps, - } = await this.workspaceCacheService.getOrRecompute(workspaceId, [ - 'flatObjectMetadataMaps', - 'flatFieldMetadataMaps', - 'flatPageLayoutMaps', - ]); - - const existingPageLayouts = Object.values( - flatPageLayoutMaps.byUniversalIdentifier, - ).filter(isDefined); - - const objectIdsWithRecordPageLayout = new Set( - existingPageLayouts - .filter( - (layout: FlatPageLayout) => - layout.type === PageLayoutType.RECORD_PAGE && - isDefined(layout.objectMetadataId), - ) - .map((layout: FlatPageLayout) => layout.objectMetadataId), - ); - - const customObjectsWithoutPageLayout = Object.values( - flatObjectMetadataMaps.byUniversalIdentifier, - ) - .filter(isDefined) - .filter( - (objectMetadata) => - objectMetadata.isCustom && - !objectMetadata.isRemote && - !objectIdsWithRecordPageLayout.has(objectMetadata.id), - ); - - if (customObjectsWithoutPageLayout.length === 0) { - this.logger.log( - `No custom objects without page layouts found for workspace ${workspaceId}`, - ); - - return; - } - - this.logger.log( - `Creating page layouts for ${customObjectsWithoutPageLayout.length} custom object(s) in workspace ${workspaceId}`, - ); - - const allCustomPageLayoutsToCreate: FlatPageLayout[] = []; - const allCustomPageLayoutTabsToCreate: FlatPageLayoutTab[] = []; - const allCustomPageLayoutWidgetsToCreate: FlatPageLayoutWidget[] = []; - const allCustomViewsToCreate: (UniversalFlatView & { id: string })[] = []; - const allCustomViewFieldsToCreate: UniversalFlatViewField[] = []; - - for (const customObject of customObjectsWithoutPageLayout) { - const flatRecordPageFieldsView = computeFlatRecordPageFieldsViewToCreate({ - objectMetadata: customObject, - flatApplication: twentyStandardFlatApplication, - }); - - const objectFieldMetadatas = Object.values( - flatFieldMetadataMaps.byUniversalIdentifier, - ) - .filter(isDefined) - .filter((field) => field.objectMetadataId === customObject.id); - - const viewFields = computeFlatViewFieldsToCreate({ - objectFlatFieldMetadatas: objectFieldMetadatas, - viewUniversalIdentifier: flatRecordPageFieldsView.universalIdentifier, - flatApplication: twentyStandardFlatApplication, - labelIdentifierFieldMetadataUniversalIdentifier: - customObject.labelIdentifierFieldMetadataUniversalIdentifier, - excludeLabelIdentifier: true, - }); - - const { pageLayouts, pageLayoutTabs, pageLayoutWidgets } = - computeFlatDefaultRecordPageLayoutToCreate({ - objectMetadata: customObject, - flatApplication: twentyStandardFlatApplication, - recordPageFieldsView: flatRecordPageFieldsView, - workspaceId, - }); - - allCustomPageLayoutsToCreate.push(...pageLayouts); - allCustomPageLayoutTabsToCreate.push(...pageLayoutTabs); - allCustomPageLayoutWidgetsToCreate.push(...pageLayoutWidgets); - allCustomViewsToCreate.push(flatRecordPageFieldsView); - allCustomViewFieldsToCreate.push(...viewFields); - } - - const customValidateAndBuildResult = - await this.workspaceMigrationValidateBuildAndRunService.validateBuildAndRunWorkspaceMigration( - { - allFlatEntityOperationByMetadataName: { - pageLayout: { - flatEntityToCreate: allCustomPageLayoutsToCreate, - flatEntityToDelete: [], - flatEntityToUpdate: [], - }, - pageLayoutTab: { - flatEntityToCreate: allCustomPageLayoutTabsToCreate, - flatEntityToDelete: [], - flatEntityToUpdate: [], - }, - pageLayoutWidget: { - flatEntityToCreate: allCustomPageLayoutWidgetsToCreate, - flatEntityToDelete: [], - flatEntityToUpdate: [], - }, - view: { - flatEntityToCreate: allCustomViewsToCreate, - flatEntityToDelete: [], - flatEntityToUpdate: [], - }, - viewField: { - flatEntityToCreate: allCustomViewFieldsToCreate, - flatEntityToDelete: [], - flatEntityToUpdate: [], - }, - }, - workspaceId, - applicationUniversalIdentifier: - twentyStandardFlatApplication.universalIdentifier, - }, - ); - - if (customValidateAndBuildResult.status === 'fail') { - this.logger.error( - `Failed to create custom object page layouts:\n${JSON.stringify(customValidateAndBuildResult, null, 2)}`, - ); - throw new Error( - `Failed to create custom object page layouts for workspace ${workspaceId}`, - ); - } - - this.logger.log( - `Successfully created page layouts for ${customObjectsWithoutPageLayout.length} custom object(s) in workspace ${workspaceId}`, - ); - } - - private async backfillFieldWidgets({ - workspaceId, - twentyStandardFlatApplication, - }: { - workspaceId: string; - twentyStandardFlatApplication: FlatApplication; - }): Promise { - const { - flatObjectMetadataMaps, - flatFieldMetadataMaps, - flatPageLayoutMaps, - flatPageLayoutTabMaps, - flatPageLayoutWidgetMaps, - } = await this.workspaceCacheService.getOrRecompute(workspaceId, [ - 'flatObjectMetadataMaps', - 'flatFieldMetadataMaps', - 'flatPageLayoutMaps', - 'flatPageLayoutTabMaps', - 'flatPageLayoutWidgetMaps', - ]); - - // Build a set of fieldMetadataIds that already have a FIELD widget - const existingFieldWidgetFieldIds = new Set(); - - for (const widget of Object.values( - flatPageLayoutWidgetMaps.byUniversalIdentifier, - )) { - if ( - isDefined(widget) && - widget.configuration?.configurationType === - WidgetConfigurationType.FIELD && - isDefined(widget.configuration.fieldMetadataId) - ) { - existingFieldWidgetFieldIds.add(widget.configuration.fieldMetadataId); - } - } - - // Build object ID -> objectMetadata map - const objectById = new Map(); - - for (const obj of Object.values( - flatObjectMetadataMaps.byUniversalIdentifier, - )) { - if (isDefined(obj)) { - objectById.set(obj.id, obj); - } - } - - // Build object ID -> RECORD_PAGE layout map - const recordPageLayoutByObjectId = new Map< - string, - { id: string; universalIdentifier: string } - >(); - - for (const layout of Object.values( - flatPageLayoutMaps.byUniversalIdentifier, - )) { - if ( - isDefined(layout) && - layout.type === PageLayoutType.RECORD_PAGE && - isDefined(layout.objectMetadataId) - ) { - recordPageLayoutByObjectId.set(layout.objectMetadataId, { - id: layout.id, - universalIdentifier: layout.universalIdentifier, - }); - } - } - - // Build pageLayoutId -> home tab map - const homeTabByPageLayoutId = new Map< - string, - { - id: string; - universalIdentifier: string; - widgetCount: number; - } - >(); - - for (const tab of Object.values( - flatPageLayoutTabMaps.byUniversalIdentifier, - )) { - if ( - isDefined(tab) && - tab.position === HOME_TAB_POSITION && - tab.layoutMode === PageLayoutTabLayoutMode.VERTICAL_LIST - ) { - homeTabByPageLayoutId.set(tab.pageLayoutId, { - id: tab.id, - universalIdentifier: tab.universalIdentifier, - widgetCount: tab.widgetIds?.length ?? 0, - }); - } - } - - // Group fields by objectMetadataId - const fieldsByObjectId = new Map(); - - // Map morphId -> all field IDs sharing that morphId (for dedup) - const fieldIdsByMorphId = new Map(); - - for (const field of Object.values( - flatFieldMetadataMaps.byUniversalIdentifier, - )) { - if (!isDefined(field) || !isDefined(field.objectMetadataId)) { - continue; - } - - const list = fieldsByObjectId.get(field.objectMetadataId) ?? []; - - list.push(field); - fieldsByObjectId.set(field.objectMetadataId, list); - - if ( - field.type === FieldMetadataType.MORPH_RELATION && - isDefined(field.morphId) - ) { - const morphFieldIds = fieldIdsByMorphId.get(field.morphId) ?? []; - - morphFieldIds.push(field.id); - fieldIdsByMorphId.set(field.morphId, morphFieldIds); - } - } - - const now = new Date().toISOString(); - - const widgetsToCreate: FlatPageLayoutWidget[] = []; - const processedMorphIds = new Set(); - - for (const object of objectById.values()) { - const pageLayout = recordPageLayoutByObjectId.get(object.id); - - if (!isDefined(pageLayout)) { - continue; - } - - const homeTab = homeTabByPageLayoutId.get(pageLayout.id); - - if (!isDefined(homeTab)) { - continue; - } - - const fields = fieldsByObjectId.get(object.id) ?? []; - let nextWidgetIndex = homeTab.widgetCount; - - for (const field of fields) { - if ( - field.type !== FieldMetadataType.RELATION && - field.type !== FieldMetadataType.MORPH_RELATION - ) { - continue; - } - - if (isActivityTargetField(field.name, object.nameSingular)) { - continue; - } - - if (isJunctionRelationField(field)) { - continue; - } - - if (field.type === FieldMetadataType.RELATION) { - const targetObject = isDefined(field.relationTargetObjectMetadataId) - ? objectById.get(field.relationTargetObjectMetadataId) - : undefined; - - if (!isRelationTargetAvailable(targetObject)) { - continue; - } - } - - // For morph relations, skip if any sibling field (same morphId) - // already has a widget or was already processed in this run - if ( - field.type === FieldMetadataType.MORPH_RELATION && - isDefined(field.morphId) - ) { - if (processedMorphIds.has(field.morphId)) { - continue; - } - - const siblingFieldIds = fieldIdsByMorphId.get(field.morphId) ?? []; - const alreadyHasWidget = siblingFieldIds.some((id) => - existingFieldWidgetFieldIds.has(id), - ); - - if (alreadyHasWidget) { - continue; - } - - processedMorphIds.add(field.morphId); - } - - if (existingFieldWidgetFieldIds.has(field.id)) { - continue; - } - - const widget: FlatPageLayoutWidget = { - id: v4(), - universalIdentifier: v4(), - applicationId: twentyStandardFlatApplication.id, - applicationUniversalIdentifier: - twentyStandardFlatApplication.universalIdentifier, - workspaceId, - pageLayoutTabId: homeTab.id, - pageLayoutTabUniversalIdentifier: homeTab.universalIdentifier, - title: field.label, - type: WidgetType.FIELD, - gridPosition: { ...GRID_POSITIONS.FULL_WIDTH }, - position: { - ...VERTICAL_LIST_LAYOUT_POSITIONS.SECOND, - index: nextWidgetIndex, - }, - configuration: { - configurationType: WidgetConfigurationType.FIELD, - fieldMetadataId: field.id, - fieldDisplayMode: FieldDisplayMode.CARD, - }, - universalConfiguration: { - configurationType: WidgetConfigurationType.FIELD, - fieldMetadataId: field.universalIdentifier, - fieldDisplayMode: FieldDisplayMode.CARD, - }, - objectMetadataId: object.id, - objectMetadataUniversalIdentifier: object.universalIdentifier, - isActive: true, - createdAt: now, - updatedAt: now, - deletedAt: null, - conditionalDisplay: null, - conditionalAvailabilityExpression: null, - overrides: null, - universalOverrides: null, - }; - - widgetsToCreate.push(widget); - existingFieldWidgetFieldIds.add(field.id); - nextWidgetIndex++; - } - } - - if (widgetsToCreate.length === 0) { - this.logger.log( - `All FIELD widgets already exist for workspace ${workspaceId}, skipping`, - ); - - return; - } - - this.logger.log( - `Found ${widgetsToCreate.length} FIELD widget(s) to create for workspace ${workspaceId}`, - ); - - const result = - await this.workspaceMigrationValidateBuildAndRunService.validateBuildAndRunWorkspaceMigration( - { - allFlatEntityOperationByMetadataName: { - pageLayoutWidget: { - flatEntityToCreate: widgetsToCreate, - flatEntityToDelete: [], - flatEntityToUpdate: [], - }, - }, - workspaceId, - applicationUniversalIdentifier: - twentyStandardFlatApplication.universalIdentifier, - }, - ); - - if (result.status === 'fail') { - this.logger.error( - `Failed to create FIELD widgets:\n${JSON.stringify(result, null, 2)}`, - ); - throw new Error( - `Failed to create FIELD widgets for workspace ${workspaceId}`, - ); - } - - this.logger.log( - `Successfully created ${widgetsToCreate.length} FIELD widget(s) for workspace ${workspaceId}`, - ); - } -} diff --git a/packages/twenty-server/src/database/commands/upgrade-version-command/1-23/1-23-workspace-command-1780000001500-backfill-record-page-layouts.command.ts b/packages/twenty-server/src/database/commands/upgrade-version-command/1-23/1-23-workspace-command-1780000001500-backfill-record-page-layouts.command.ts new file mode 100644 index 0000000000..057e25d5dc --- /dev/null +++ b/packages/twenty-server/src/database/commands/upgrade-version-command/1-23/1-23-workspace-command-1780000001500-backfill-record-page-layouts.command.ts @@ -0,0 +1,534 @@ +import { Command } from 'nest-commander'; +import { FeatureFlagKey, ViewType } from 'twenty-shared/types'; +import { isDefined } from 'twenty-shared/utils'; + +import { ActiveOrSuspendedWorkspaceCommandRunner } from 'src/database/commands/command-runners/active-or-suspended-workspace.command-runner'; +import { WorkspaceIteratorService } from 'src/database/commands/command-runners/workspace-iterator.service'; +import { type RunOnWorkspaceArgs } from 'src/database/commands/command-runners/workspace.command-runner'; +import { ApplicationService } from 'src/engine/core-modules/application/application.service'; +import { type FlatApplication } from 'src/engine/core-modules/application/types/flat-application.type'; +import { FeatureFlagService } from 'src/engine/core-modules/feature-flag/services/feature-flag.service'; +import { RegisteredWorkspaceCommand } from 'src/engine/core-modules/upgrade/decorators/registered-workspace-command.decorator'; +import { type FlatPageLayoutTab } from 'src/engine/metadata-modules/flat-page-layout-tab/types/flat-page-layout-tab.type'; +import { type FlatPageLayoutWidget } from 'src/engine/metadata-modules/flat-page-layout-widget/types/flat-page-layout-widget.type'; +import { type FlatPageLayout } from 'src/engine/metadata-modules/flat-page-layout/types/flat-page-layout.type'; +import { computeFlatDefaultRecordPageLayoutToCreate } from 'src/engine/metadata-modules/object-metadata/utils/compute-flat-default-record-page-layout-to-create.util'; +import { computeFlatRecordPageFieldsViewToCreate } from 'src/engine/metadata-modules/object-metadata/utils/compute-flat-record-page-fields-view-to-create.util'; +import { computeFlatViewFieldsToCreate } from 'src/engine/metadata-modules/object-metadata/utils/compute-flat-view-fields-to-create.util'; +import { PageLayoutType } from 'src/engine/metadata-modules/page-layout/enums/page-layout-type.enum'; +import { WorkspaceCacheService } from 'src/engine/workspace-cache/services/workspace-cache.service'; +import { computeTwentyStandardApplicationAllFlatEntityMaps } from 'src/engine/workspace-manager/twenty-standard-application/utils/twenty-standard-application-all-flat-entity-maps.constant'; +import { WorkspaceMigrationValidateBuildAndRunService } from 'src/engine/workspace-manager/workspace-migration/services/workspace-migration-validate-build-and-run-service'; +import { type UniversalFlatViewField } from 'src/engine/workspace-manager/workspace-migration/universal-flat-entity/types/universal-flat-view-field.type'; +import { type UniversalFlatView } from 'src/engine/workspace-manager/workspace-migration/universal-flat-entity/types/universal-flat-view.type'; + +@RegisteredWorkspaceCommand('1.23.0', 1780000001500) +@Command({ + name: 'upgrade:1-23:backfill-record-page-layouts', + description: + 'Delete and recreate all record page layouts from standard config, backfill custom objects, and enable IS_RECORD_PAGE_LAYOUT_EDITING_ENABLED', +}) +export class BackfillRecordPageLayoutsCommand extends ActiveOrSuspendedWorkspaceCommandRunner { + constructor( + protected readonly workspaceIteratorService: WorkspaceIteratorService, + private readonly applicationService: ApplicationService, + private readonly workspaceMigrationValidateBuildAndRunService: WorkspaceMigrationValidateBuildAndRunService, + private readonly featureFlagService: FeatureFlagService, + private readonly workspaceCacheService: WorkspaceCacheService, + ) { + super(workspaceIteratorService); + } + + override async runOnWorkspace({ + workspaceId, + options, + }: RunOnWorkspaceArgs): Promise { + const isDryRun = options.dryRun ?? false; + + const isAlreadyEnabled = await this.featureFlagService.isFeatureEnabled( + FeatureFlagKey.IS_RECORD_PAGE_LAYOUT_EDITING_ENABLED, + workspaceId, + ); + + if (isAlreadyEnabled) { + this.logger.log( + `IS_RECORD_PAGE_LAYOUT_EDITING_ENABLED already enabled for workspace ${workspaceId}, skipping`, + ); + + return; + } + + if (isDryRun) { + this.logger.log( + `[DRY RUN] Would recreate all record page layouts and enable feature flag for workspace ${workspaceId}`, + ); + + return; + } + + const { twentyStandardFlatApplication } = + await this.applicationService.findWorkspaceTwentyStandardAndCustomApplicationOrThrow( + { workspaceId }, + ); + + await this.deleteAllRecordPageLayoutEntities({ + workspaceId, + twentyStandardFlatApplication, + }); + + await this.createStandardRecordPageLayouts({ + workspaceId, + twentyStandardFlatApplication, + }); + + await this.createCustomObjectPageLayouts({ + workspaceId, + twentyStandardFlatApplication, + }); + + await this.featureFlagService.enableFeatureFlags( + [FeatureFlagKey.IS_RECORD_PAGE_LAYOUT_EDITING_ENABLED], + workspaceId, + ); + + await this.featureFlagService.enableFeatureFlags( + [FeatureFlagKey.IS_RECORD_PAGE_LAYOUT_GLOBAL_EDITION_ENABLED], + workspaceId, + ); + + this.logger.log( + `Successfully backfilled record page layouts for workspace ${workspaceId}`, + ); + } + + private async deleteAllRecordPageLayoutEntities({ + workspaceId, + twentyStandardFlatApplication, + }: { + workspaceId: string; + twentyStandardFlatApplication: FlatApplication; + }): Promise { + const { + flatPageLayoutMaps, + flatPageLayoutTabMaps, + flatPageLayoutWidgetMaps, + flatViewMaps, + flatViewFieldMaps, + flatViewFieldGroupMaps, + } = await this.workspaceCacheService.getOrRecompute(workspaceId, [ + 'flatPageLayoutMaps', + 'flatPageLayoutTabMaps', + 'flatPageLayoutWidgetMaps', + 'flatViewMaps', + 'flatViewFieldMaps', + 'flatViewFieldGroupMaps', + ]); + + const recordPageLayouts = Object.values( + flatPageLayoutMaps.byUniversalIdentifier, + ) + .filter(isDefined) + .filter((layout) => layout.type === PageLayoutType.RECORD_PAGE); + + const recordPageLayoutIds = new Set( + recordPageLayouts.map((layout) => layout.id), + ); + + const tabs = Object.values(flatPageLayoutTabMaps.byUniversalIdentifier) + .filter(isDefined) + .filter((tab) => recordPageLayoutIds.has(tab.pageLayoutId)); + + const tabIds = new Set(tabs.map((tab) => tab.id)); + + const widgets = Object.values( + flatPageLayoutWidgetMaps.byUniversalIdentifier, + ) + .filter(isDefined) + .filter((widget) => tabIds.has(widget.pageLayoutTabId)); + + const fieldsWidgetViews = Object.values(flatViewMaps.byUniversalIdentifier) + .filter(isDefined) + .filter((view) => view.type === ViewType.FIELDS_WIDGET); + + const fieldsWidgetViewUniversalIdentifiers = new Set( + fieldsWidgetViews.map((view) => view.universalIdentifier), + ); + + const viewFields = Object.values(flatViewFieldMaps.byUniversalIdentifier) + .filter(isDefined) + .filter((viewField) => + fieldsWidgetViewUniversalIdentifiers.has( + viewField.viewUniversalIdentifier, + ), + ); + + const viewFieldGroups = Object.values( + flatViewFieldGroupMaps.byUniversalIdentifier, + ) + .filter(isDefined) + .filter((viewFieldGroup) => + fieldsWidgetViewUniversalIdentifiers.has( + viewFieldGroup.viewUniversalIdentifier, + ), + ); + + if (recordPageLayouts.length === 0 && fieldsWidgetViews.length === 0) { + return; + } + + this.logger.log( + `Deleting ${recordPageLayouts.length} page layouts, ${tabs.length} tabs, ${widgets.length} widgets, ${fieldsWidgetViews.length} views, ${viewFields.length} view fields, ${viewFieldGroups.length} view field groups for workspace ${workspaceId}`, + ); + + const result = + await this.workspaceMigrationValidateBuildAndRunService.validateBuildAndRunWorkspaceMigration( + { + allFlatEntityOperationByMetadataName: { + viewField: { + flatEntityToCreate: [], + flatEntityToDelete: viewFields, + flatEntityToUpdate: [], + }, + viewFieldGroup: { + flatEntityToCreate: [], + flatEntityToDelete: viewFieldGroups, + flatEntityToUpdate: [], + }, + view: { + flatEntityToCreate: [], + flatEntityToDelete: fieldsWidgetViews, + flatEntityToUpdate: [], + }, + pageLayoutWidget: { + flatEntityToCreate: [], + flatEntityToDelete: widgets, + flatEntityToUpdate: [], + }, + pageLayoutTab: { + flatEntityToCreate: [], + flatEntityToDelete: tabs, + flatEntityToUpdate: [], + }, + pageLayout: { + flatEntityToCreate: [], + flatEntityToDelete: recordPageLayouts, + flatEntityToUpdate: [], + }, + }, + workspaceId, + applicationUniversalIdentifier: + twentyStandardFlatApplication.universalIdentifier, + }, + ); + + if (result.status === 'fail') { + this.logger.error( + `Failed to delete record page layout entities:\n${JSON.stringify(result, null, 2)}`, + ); + throw new Error( + `Failed to delete record page layout entities for workspace ${workspaceId}`, + ); + } + } + + private async createStandardRecordPageLayouts({ + workspaceId, + twentyStandardFlatApplication, + }: { + workspaceId: string; + twentyStandardFlatApplication: FlatApplication; + }): Promise { + const { allFlatEntityMaps: standardMaps } = + computeTwentyStandardApplicationAllFlatEntityMaps({ + shouldIncludeRecordPageLayouts: true, + now: new Date().toISOString(), + workspaceId, + twentyStandardApplicationId: twentyStandardFlatApplication.id, + }); + + const recordPageLayoutUniversalIdentifiers = new Set(); + + const pageLayouts = Object.values( + standardMaps.flatPageLayoutMaps.byUniversalIdentifier, + ) + .filter(isDefined) + .filter((pageLayout) => { + if (pageLayout.type !== PageLayoutType.RECORD_PAGE) { + return false; + } + + recordPageLayoutUniversalIdentifiers.add( + pageLayout.universalIdentifier, + ); + + return true; + }); + + const tabUniversalIdentifiers = new Set(); + + const tabs = Object.values( + standardMaps.flatPageLayoutTabMaps.byUniversalIdentifier, + ) + .filter(isDefined) + .filter((tab) => { + if ( + !recordPageLayoutUniversalIdentifiers.has( + tab.pageLayoutUniversalIdentifier, + ) + ) { + return false; + } + + tabUniversalIdentifiers.add(tab.universalIdentifier); + + return true; + }); + + const widgets = Object.values( + standardMaps.flatPageLayoutWidgetMaps.byUniversalIdentifier, + ) + .filter(isDefined) + .filter((widget) => + tabUniversalIdentifiers.has(widget.pageLayoutTabUniversalIdentifier), + ); + + const fieldsWidgetViewUniversalIdentifiers = new Set(); + + const views = Object.values(standardMaps.flatViewMaps.byUniversalIdentifier) + .filter(isDefined) + .filter((view) => { + if (view.type !== ViewType.FIELDS_WIDGET) { + return false; + } + + fieldsWidgetViewUniversalIdentifiers.add(view.universalIdentifier); + + return true; + }); + + const { flatFieldMetadataMaps } = + await this.workspaceCacheService.getOrRecompute(workspaceId, [ + 'flatFieldMetadataMaps', + ]); + + const viewFields = Object.values( + standardMaps.flatViewFieldMaps.byUniversalIdentifier, + ) + .filter(isDefined) + .filter((viewField) => + fieldsWidgetViewUniversalIdentifiers.has( + viewField.viewUniversalIdentifier, + ), + ) + .filter((viewField) => + isDefined( + flatFieldMetadataMaps.byUniversalIdentifier[ + viewField.fieldMetadataUniversalIdentifier + ], + ), + ); + + const viewFieldGroups = Object.values( + standardMaps.flatViewFieldGroupMaps.byUniversalIdentifier, + ) + .filter(isDefined) + .filter((viewFieldGroup) => + fieldsWidgetViewUniversalIdentifiers.has( + viewFieldGroup.viewUniversalIdentifier, + ), + ); + + this.logger.log( + `Creating ${pageLayouts.length} standard page layouts, ${views.length} views, ${viewFields.length} view fields for workspace ${workspaceId}`, + ); + + const result = + await this.workspaceMigrationValidateBuildAndRunService.validateBuildAndRunWorkspaceMigration( + { + allFlatEntityOperationByMetadataName: { + pageLayout: { + flatEntityToCreate: pageLayouts, + flatEntityToDelete: [], + flatEntityToUpdate: [], + }, + pageLayoutTab: { + flatEntityToCreate: tabs, + flatEntityToDelete: [], + flatEntityToUpdate: [], + }, + pageLayoutWidget: { + flatEntityToCreate: widgets, + flatEntityToDelete: [], + flatEntityToUpdate: [], + }, + view: { + flatEntityToCreate: views, + flatEntityToDelete: [], + flatEntityToUpdate: [], + }, + viewField: { + flatEntityToCreate: viewFields, + flatEntityToDelete: [], + flatEntityToUpdate: [], + }, + viewFieldGroup: { + flatEntityToCreate: viewFieldGroups, + flatEntityToDelete: [], + flatEntityToUpdate: [], + }, + }, + workspaceId, + applicationUniversalIdentifier: + twentyStandardFlatApplication.universalIdentifier, + }, + ); + + if (result.status === 'fail') { + this.logger.error( + `Failed to create standard record page layouts:\n${JSON.stringify(result, null, 2)}`, + ); + throw new Error( + `Failed to create standard record page layouts for workspace ${workspaceId}`, + ); + } + } + + private async createCustomObjectPageLayouts({ + workspaceId, + twentyStandardFlatApplication, + }: { + workspaceId: string; + twentyStandardFlatApplication: FlatApplication; + }): Promise { + const { + flatObjectMetadataMaps, + flatFieldMetadataMaps, + flatPageLayoutMaps, + } = await this.workspaceCacheService.getOrRecompute(workspaceId, [ + 'flatObjectMetadataMaps', + 'flatFieldMetadataMaps', + 'flatPageLayoutMaps', + ]); + + const objectIdsWithRecordPageLayout = new Set( + Object.values(flatPageLayoutMaps.byUniversalIdentifier) + .filter(isDefined) + .filter( + (layout) => + layout.type === PageLayoutType.RECORD_PAGE && + isDefined(layout.objectMetadataId), + ) + .map((layout) => layout.objectMetadataId), + ); + + const customObjectsWithoutPageLayout = Object.values( + flatObjectMetadataMaps.byUniversalIdentifier, + ) + .filter(isDefined) + .filter( + (objectMetadata) => + objectMetadata.isCustom && + !objectMetadata.isRemote && + !objectIdsWithRecordPageLayout.has(objectMetadata.id), + ); + + if (customObjectsWithoutPageLayout.length === 0) { + this.logger.log( + `No custom objects without page layouts found for workspace ${workspaceId}`, + ); + + return; + } + + this.logger.log( + `Creating page layouts for ${customObjectsWithoutPageLayout.length} custom object(s) in workspace ${workspaceId}`, + ); + + const allPageLayouts: FlatPageLayout[] = []; + const allTabs: FlatPageLayoutTab[] = []; + const allWidgets: FlatPageLayoutWidget[] = []; + const allViews: (UniversalFlatView & { id: string })[] = []; + const allViewFields: UniversalFlatViewField[] = []; + + for (const customObject of customObjectsWithoutPageLayout) { + const fieldsView = computeFlatRecordPageFieldsViewToCreate({ + objectMetadata: customObject, + flatApplication: twentyStandardFlatApplication, + }); + + const objectFieldMetadatas = Object.values( + flatFieldMetadataMaps.byUniversalIdentifier, + ) + .filter(isDefined) + .filter((field) => field.objectMetadataId === customObject.id); + + const viewFields = computeFlatViewFieldsToCreate({ + objectFlatFieldMetadatas: objectFieldMetadatas, + viewUniversalIdentifier: fieldsView.universalIdentifier, + flatApplication: twentyStandardFlatApplication, + labelIdentifierFieldMetadataUniversalIdentifier: + customObject.labelIdentifierFieldMetadataUniversalIdentifier, + excludeLabelIdentifier: true, + }); + + const { pageLayouts, pageLayoutTabs, pageLayoutWidgets } = + computeFlatDefaultRecordPageLayoutToCreate({ + objectMetadata: customObject, + flatApplication: twentyStandardFlatApplication, + recordPageFieldsView: fieldsView, + workspaceId, + }); + + allPageLayouts.push(...pageLayouts); + allTabs.push(...pageLayoutTabs); + allWidgets.push(...pageLayoutWidgets); + allViews.push(fieldsView); + allViewFields.push(...viewFields); + } + + const result = + await this.workspaceMigrationValidateBuildAndRunService.validateBuildAndRunWorkspaceMigration( + { + allFlatEntityOperationByMetadataName: { + pageLayout: { + flatEntityToCreate: allPageLayouts, + flatEntityToDelete: [], + flatEntityToUpdate: [], + }, + pageLayoutTab: { + flatEntityToCreate: allTabs, + flatEntityToDelete: [], + flatEntityToUpdate: [], + }, + pageLayoutWidget: { + flatEntityToCreate: allWidgets, + flatEntityToDelete: [], + flatEntityToUpdate: [], + }, + view: { + flatEntityToCreate: allViews, + flatEntityToDelete: [], + flatEntityToUpdate: [], + }, + viewField: { + flatEntityToCreate: allViewFields, + flatEntityToDelete: [], + flatEntityToUpdate: [], + }, + }, + workspaceId, + applicationUniversalIdentifier: + twentyStandardFlatApplication.universalIdentifier, + }, + ); + + if (result.status === 'fail') { + this.logger.error( + `Failed to create custom object page layouts:\n${JSON.stringify(result, null, 2)}`, + ); + throw new Error( + `Failed to create custom object page layouts for workspace ${workspaceId}`, + ); + } + } +}