Feat/email composer improvements (#23188)

- Move composer to dedicated page
- Add test email option
- Auto saved as draft can be revisited from `objects/messageCampaigns`
later
- Campaign stats component



https://github.com/user-attachments/assets/9e523116-e79b-496d-9c9d-3887e0c9213f



<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/23188?utm_source=github"
target="_blank" rel="noopener noreferrer"
data-no-image-dialog="true"><picture><source
media="(prefers-color-scheme: dark)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"><source
media="(prefers-color-scheme: light)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-light.svg"><img
alt="Review in cubic"
src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"></picture></a>
<!-- End of auto-generated description by cubic. -->

---------

Co-authored-by: Félix Malfait <felix.malfait@gmail.com>
This commit is contained in:
neo773
2026-07-28 16:43:00 +05:30
committed by GitHub
parent 30bbf4149a
commit 1e58c3073c
119 changed files with 5345 additions and 2606 deletions
@@ -0,0 +1,52 @@
import { type QueryRunner } from 'typeorm';
import { RegisteredInstanceCommand } from 'src/engine/core-modules/upgrade/decorators/registered-instance-command.decorator';
import { type FastInstanceCommand } from 'src/engine/core-modules/upgrade/interfaces/fast-instance-command.interface';
@RegisteredInstanceCommand('2.25.0', 1785229950000)
export class AddMessageCampaignWidgetTypeFastInstanceCommand
implements FastInstanceCommand
{
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`ALTER TYPE "core"."pageLayoutWidget_type_enum" RENAME TO "pageLayoutWidget_type_enum_old"`,
);
await queryRunner.query(
`CREATE TYPE "core"."pageLayoutWidget_type_enum" AS ENUM('VIEW', 'IFRAME', 'FIELD', 'FIELDS', 'GRAPH', 'STANDALONE_RICH_TEXT', 'TIMELINE', 'TASKS', 'NOTES', 'FILES', 'EMAILS', 'CALENDAR', 'FIELD_RICH_TEXT', 'WORKFLOW', 'WORKFLOW_VERSION', 'WORKFLOW_RUN', 'FRONT_COMPONENT', 'RECORD_TABLE', 'EMAIL_THREAD', 'MESSAGE_CAMPAIGN_BODY', 'MESSAGE_CAMPAIGN_DETAILS')`,
);
await queryRunner.query(
`ALTER TABLE "core"."pageLayoutWidget" ALTER COLUMN "type" DROP DEFAULT`,
);
await queryRunner.query(
`ALTER TABLE "core"."pageLayoutWidget" ALTER COLUMN "type" TYPE "core"."pageLayoutWidget_type_enum" USING "type"::"text"::"core"."pageLayoutWidget_type_enum"`,
);
await queryRunner.query(
`ALTER TABLE "core"."pageLayoutWidget" ALTER COLUMN "type" SET DEFAULT 'VIEW'`,
);
await queryRunner.query(
`DROP TYPE "core"."pageLayoutWidget_type_enum_old"`,
);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`DELETE FROM "core"."pageLayoutWidget" WHERE "type" IN ('MESSAGE_CAMPAIGN_BODY', 'MESSAGE_CAMPAIGN_DETAILS')`,
);
await queryRunner.query(
`CREATE TYPE "core"."pageLayoutWidget_type_enum_old" AS ENUM('VIEW', 'IFRAME', 'FIELD', 'FIELDS', 'GRAPH', 'STANDALONE_RICH_TEXT', 'TIMELINE', 'TASKS', 'NOTES', 'FILES', 'EMAILS', 'CALENDAR', 'FIELD_RICH_TEXT', 'WORKFLOW', 'WORKFLOW_VERSION', 'WORKFLOW_RUN', 'FRONT_COMPONENT', 'RECORD_TABLE', 'EMAIL_THREAD')`,
);
await queryRunner.query(
`ALTER TABLE "core"."pageLayoutWidget" ALTER COLUMN "type" DROP DEFAULT`,
);
await queryRunner.query(
`ALTER TABLE "core"."pageLayoutWidget" ALTER COLUMN "type" TYPE "core"."pageLayoutWidget_type_enum_old" USING "type"::"text"::"core"."pageLayoutWidget_type_enum_old"`,
);
await queryRunner.query(
`ALTER TABLE "core"."pageLayoutWidget" ALTER COLUMN "type" SET DEFAULT 'VIEW'`,
);
await queryRunner.query(`DROP TYPE "core"."pageLayoutWidget_type_enum"`);
await queryRunner.query(
`ALTER TYPE "core"."pageLayoutWidget_type_enum_old" RENAME TO "pageLayoutWidget_type_enum"`,
);
}
}
@@ -3,17 +3,29 @@ import { TypeOrmModule } from '@nestjs/typeorm';
import { WorkspaceIteratorModule } from 'src/database/commands/command-runners/workspace-iterator.module';
import { BackfillMessageListMembersJunctionTargetCommand } from 'src/database/commands/upgrade-version-command/2-25/2-25-workspace-command-1784567000000-backfill-message-list-members-junction-target.command';
import { AddMessageCampaignComposerTabCommand } from 'src/database/commands/upgrade-version-command/2-25/2-25-workspace-command-1785229940000-add-message-campaign-composer-tab.command';
import { ConfigureMessageCampaignCommandMenuCommand } from 'src/database/commands/upgrade-version-command/2-25/2-25-workspace-command-1785229960000-configure-message-campaign-command-menu.command';
import { AddMessageCampaignNameFieldCommand } from 'src/database/commands/upgrade-version-command/2-25/2-25-workspace-command-1785229970000-add-message-campaign-name-field.command';
import { ApplicationModule } from 'src/engine/core-modules/application/application.module';
import { FieldMetadataEntity } from 'src/engine/metadata-modules/field-metadata/field-metadata.entity';
import { WorkspaceCacheModule } from 'src/engine/workspace-cache/workspace-cache.module';
import { WorkspaceMigrationRunnerModule } from 'src/engine/workspace-manager/workspace-migration/workspace-migration-runner/workspace-migration-runner.module';
import { WorkspaceMigrationModule } from 'src/engine/workspace-manager/workspace-migration/workspace-migration.module';
@Module({
imports: [
TypeOrmModule.forFeature([FieldMetadataEntity]),
ApplicationModule,
WorkspaceCacheModule,
WorkspaceMigrationModule,
WorkspaceMigrationRunnerModule,
WorkspaceIteratorModule,
],
providers: [BackfillMessageListMembersJunctionTargetCommand],
providers: [
BackfillMessageListMembersJunctionTargetCommand,
AddMessageCampaignComposerTabCommand,
ConfigureMessageCampaignCommandMenuCommand,
AddMessageCampaignNameFieldCommand,
],
})
export class V2_25_UpgradeVersionCommandModule {}
@@ -0,0 +1,326 @@
import { Command } from 'nest-commander';
import {
STANDARD_OBJECTS,
STANDARD_PAGE_LAYOUT_UNIVERSAL_IDENTIFIERS,
} from 'twenty-shared/metadata';
import { isDefined } from 'twenty-shared/utils';
import { ProvisionedWorkspaceCommandRunner } from 'src/database/commands/command-runners/provisioned-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 { getStandardFlatEntitiesToCreateOrThrow } from 'src/database/commands/upgrade-version-command/2-10/utils/get-standard-flat-entities-to-create-or-throw.util';
import { ApplicationService } from 'src/engine/core-modules/application/application.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 FlatViewFieldGroup } from 'src/engine/metadata-modules/flat-view-field-group/types/flat-view-field-group.type';
import { type FlatViewField } from 'src/engine/metadata-modules/flat-view-field/types/flat-view-field.type';
import { type FlatView } from 'src/engine/metadata-modules/flat-view/types/flat-view.type';
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';
const MESSAGE_CAMPAIGN_RECORD_PAGE_FIELDS_VIEW_UNIVERSAL_IDENTIFIER =
STANDARD_OBJECTS.messageCampaign.views.messageCampaignRecordPageFields
.universalIdentifier;
const MESSAGE_CAMPAIGN_RECORD_PAGE_FIELDS_VIEW_FIELD_UNIVERSAL_IDENTIFIERS =
Object.values(
STANDARD_OBJECTS.messageCampaign.views.messageCampaignRecordPageFields
.viewFields,
).map((viewField) => viewField.universalIdentifier);
const MESSAGE_CAMPAIGN_RECORD_PAGE_FIELDS_VIEW_FIELD_GROUP_UNIVERSAL_IDENTIFIERS =
Object.values(
STANDARD_OBJECTS.messageCampaign.views.messageCampaignRecordPageFields
.viewFieldGroups,
).map((viewFieldGroup) => viewFieldGroup.universalIdentifier);
const MESSAGE_CAMPAIGN_PAGE_LAYOUT_UNIVERSAL_IDENTIFIER =
STANDARD_PAGE_LAYOUT_UNIVERSAL_IDENTIFIERS.messageCampaignRecordPage
.universalIdentifier;
const HOME_TAB_UNIVERSAL_IDENTIFIER =
STANDARD_PAGE_LAYOUT_UNIVERSAL_IDENTIFIERS.messageCampaignRecordPage.tabs.home
.universalIdentifier;
const COMPOSER_TAB_UNIVERSAL_IDENTIFIER =
STANDARD_PAGE_LAYOUT_UNIVERSAL_IDENTIFIERS.messageCampaignRecordPage.tabs
.composer.universalIdentifier;
const COMPOSER_WIDGET_UNIVERSAL_IDENTIFIER =
STANDARD_PAGE_LAYOUT_UNIVERSAL_IDENTIFIERS.messageCampaignRecordPage.tabs
.composer.widgets.messageCampaign.universalIdentifier;
const HOME_FIELDS_WIDGET_UNIVERSAL_IDENTIFIER =
STANDARD_PAGE_LAYOUT_UNIVERSAL_IDENTIFIERS.messageCampaignRecordPage.tabs.home
.widgets.fields.universalIdentifier;
const HOME_DETAILS_WIDGET_UNIVERSAL_IDENTIFIER =
STANDARD_PAGE_LAYOUT_UNIVERSAL_IDENTIFIERS.messageCampaignRecordPage.tabs.home
.widgets.details.universalIdentifier;
const HOME_LIST_WIDGET_UNIVERSAL_IDENTIFIER =
STANDARD_PAGE_LAYOUT_UNIVERSAL_IDENTIFIERS.messageCampaignRecordPage.tabs.home
.widgets.list.universalIdentifier;
const HOME_RECIPIENTS_WIDGET_UNIVERSAL_IDENTIFIER =
STANDARD_PAGE_LAYOUT_UNIVERSAL_IDENTIFIERS.messageCampaignRecordPage.tabs.home
.widgets.recipients.universalIdentifier;
const HOME_OBSOLETE_MESSAGES_WIDGET_UNIVERSAL_IDENTIFIER =
STANDARD_PAGE_LAYOUT_UNIVERSAL_IDENTIFIERS.messageCampaignRecordPage.tabs.home
.widgets.messages.universalIdentifier;
@RegisteredWorkspaceCommand('2.25.0', 1785229940000)
@Command({
name: 'upgrade:2-25:add-message-campaign-composer-tab',
description:
'Aligns the message campaign record page with the Note layout: a home Fields tab (left column) plus an Email tab holding the composer editor',
})
export class AddMessageCampaignComposerTabCommand extends ProvisionedWorkspaceCommandRunner {
constructor(
protected readonly workspaceIteratorService: WorkspaceIteratorService,
private readonly applicationService: ApplicationService,
private readonly workspaceCacheService: WorkspaceCacheService,
private readonly workspaceMigrationValidateBuildAndRunService: WorkspaceMigrationValidateBuildAndRunService,
) {
super(workspaceIteratorService);
}
override async runOnWorkspace({
workspaceId,
options,
}: RunOnWorkspaceArgs): Promise<void> {
const isDryRun = options.dryRun ?? false;
const { twentyStandardFlatApplication } =
await this.applicationService.findWorkspaceTwentyStandardAndCustomApplicationOrThrow(
{ workspaceId },
);
const {
flatPageLayoutMaps,
flatPageLayoutTabMaps,
flatPageLayoutWidgetMaps,
flatViewMaps,
flatViewFieldMaps,
flatViewFieldGroupMaps,
} = await this.workspaceCacheService.getOrRecompute(workspaceId, [
'flatPageLayoutMaps',
'flatPageLayoutTabMaps',
'flatPageLayoutWidgetMaps',
'flatViewMaps',
'flatViewFieldMaps',
'flatViewFieldGroupMaps',
]);
const existingPageLayout =
flatPageLayoutMaps.byUniversalIdentifier[
MESSAGE_CAMPAIGN_PAGE_LAYOUT_UNIVERSAL_IDENTIFIER
];
if (!isDefined(existingPageLayout)) {
this.logger.log(
`Message campaign page layout does not exist for workspace ${workspaceId}, skipping`,
);
return;
}
const { allFlatEntityMaps: standardAllFlatEntityMaps } =
computeTwentyStandardApplicationAllFlatEntityMaps({
now: new Date().toISOString(),
workspaceId,
twentyStandardApplicationId: twentyStandardFlatApplication.id,
});
const pageLayoutTabsToCreate =
getStandardFlatEntitiesToCreateOrThrow<FlatPageLayoutTab>({
standardFlatEntityMaps: standardAllFlatEntityMaps.flatPageLayoutTabMaps,
existingFlatEntityMaps: flatPageLayoutTabMaps,
universalIdentifiers: [
HOME_TAB_UNIVERSAL_IDENTIFIER,
COMPOSER_TAB_UNIVERSAL_IDENTIFIER,
],
});
const pageLayoutWidgetsToCreate =
getStandardFlatEntitiesToCreateOrThrow<FlatPageLayoutWidget>({
standardFlatEntityMaps:
standardAllFlatEntityMaps.flatPageLayoutWidgetMaps,
existingFlatEntityMaps: flatPageLayoutWidgetMaps,
universalIdentifiers: [
HOME_DETAILS_WIDGET_UNIVERSAL_IDENTIFIER,
COMPOSER_WIDGET_UNIVERSAL_IDENTIFIER,
HOME_LIST_WIDGET_UNIVERSAL_IDENTIFIER,
HOME_RECIPIENTS_WIDGET_UNIVERSAL_IDENTIFIER,
HOME_FIELDS_WIDGET_UNIVERSAL_IDENTIFIER,
],
});
const viewsToCreate = getStandardFlatEntitiesToCreateOrThrow<FlatView>({
standardFlatEntityMaps: standardAllFlatEntityMaps.flatViewMaps,
existingFlatEntityMaps: flatViewMaps,
universalIdentifiers: [
MESSAGE_CAMPAIGN_RECORD_PAGE_FIELDS_VIEW_UNIVERSAL_IDENTIFIER,
],
});
const viewFieldsToCreate =
getStandardFlatEntitiesToCreateOrThrow<FlatViewField>({
standardFlatEntityMaps: standardAllFlatEntityMaps.flatViewFieldMaps,
existingFlatEntityMaps: flatViewFieldMaps,
universalIdentifiers:
MESSAGE_CAMPAIGN_RECORD_PAGE_FIELDS_VIEW_FIELD_UNIVERSAL_IDENTIFIERS,
});
const viewFieldGroupsToCreate =
getStandardFlatEntitiesToCreateOrThrow<FlatViewFieldGroup>({
standardFlatEntityMaps:
standardAllFlatEntityMaps.flatViewFieldGroupMaps,
existingFlatEntityMaps: flatViewFieldGroupMaps,
universalIdentifiers:
MESSAGE_CAMPAIGN_RECORD_PAGE_FIELDS_VIEW_FIELD_GROUP_UNIVERSAL_IDENTIFIERS,
});
const existingComposerTab =
flatPageLayoutTabMaps.byUniversalIdentifier[
COMPOSER_TAB_UNIVERSAL_IDENTIFIER
];
const standardComposerTab =
standardAllFlatEntityMaps.flatPageLayoutTabMaps.byUniversalIdentifier[
COMPOSER_TAB_UNIVERSAL_IDENTIFIER
];
const pageLayoutTabsToUpdate =
isDefined(existingComposerTab) &&
isDefined(standardComposerTab) &&
existingComposerTab.layoutMode !== standardComposerTab.layoutMode
? [
{
...existingComposerTab,
layoutMode: standardComposerTab.layoutMode,
title: standardComposerTab.title,
},
]
: [];
const pageLayoutWidgetsToUpdate = [
HOME_DETAILS_WIDGET_UNIVERSAL_IDENTIFIER,
HOME_FIELDS_WIDGET_UNIVERSAL_IDENTIFIER,
HOME_LIST_WIDGET_UNIVERSAL_IDENTIFIER,
HOME_RECIPIENTS_WIDGET_UNIVERSAL_IDENTIFIER,
]
.map((universalIdentifier) => {
const existingWidget =
flatPageLayoutWidgetMaps.byUniversalIdentifier[universalIdentifier];
const standardWidget =
standardAllFlatEntityMaps.flatPageLayoutWidgetMaps
.byUniversalIdentifier[universalIdentifier];
if (
!isDefined(existingWidget) ||
!isDefined(standardWidget) ||
existingWidget.conditionalAvailabilityExpression ===
standardWidget.conditionalAvailabilityExpression
) {
return null;
}
return {
...existingWidget,
conditionalAvailabilityExpression:
standardWidget.conditionalAvailabilityExpression,
};
})
.filter((widget): widget is FlatPageLayoutWidget => isDefined(widget));
const obsoleteMessagesWidget =
flatPageLayoutWidgetMaps.byUniversalIdentifier[
HOME_OBSOLETE_MESSAGES_WIDGET_UNIVERSAL_IDENTIFIER
];
const pageLayoutWidgetsToDelete = isDefined(obsoleteMessagesWidget)
? [obsoleteMessagesWidget]
: [];
const totalOperationCount =
pageLayoutTabsToCreate.length +
pageLayoutWidgetsToCreate.length +
pageLayoutTabsToUpdate.length +
pageLayoutWidgetsToUpdate.length +
pageLayoutWidgetsToDelete.length +
viewsToCreate.length +
viewFieldsToCreate.length +
viewFieldGroupsToCreate.length;
if (totalOperationCount === 0) {
this.logger.log(
`Message campaign record page already has the home and email tabs for workspace ${workspaceId}, skipping`,
);
return;
}
this.logger.log(
`${isDryRun ? '[DRY RUN] ' : ''}Applying ${totalOperationCount} message campaign record page operation(s) for workspace ${workspaceId}`,
);
if (isDryRun) {
return;
}
const validateAndBuildResult =
await this.workspaceMigrationValidateBuildAndRunService.validateBuildAndRunLegacyWorkspaceMigration(
{
isSystemBuild: true,
applicationUniversalIdentifier:
twentyStandardFlatApplication.universalIdentifier,
workspaceId,
allFlatEntityOperationByMetadataName: {
pageLayoutTab: {
flatEntityToCreate: pageLayoutTabsToCreate,
flatEntityToDelete: [],
flatEntityToUpdate: pageLayoutTabsToUpdate,
},
pageLayoutWidget: {
flatEntityToCreate: pageLayoutWidgetsToCreate,
flatEntityToDelete: pageLayoutWidgetsToDelete,
flatEntityToUpdate: pageLayoutWidgetsToUpdate,
},
view: {
flatEntityToCreate: viewsToCreate,
flatEntityToDelete: [],
flatEntityToUpdate: [],
},
viewField: {
flatEntityToCreate: viewFieldsToCreate,
flatEntityToDelete: [],
flatEntityToUpdate: [],
},
viewFieldGroup: {
flatEntityToCreate: viewFieldGroupsToCreate,
flatEntityToDelete: [],
flatEntityToUpdate: [],
},
},
},
);
if (validateAndBuildResult.status === 'fail') {
throw new Error(
`Failed to align the message campaign record page for workspace ${workspaceId}: ${JSON.stringify(
validateAndBuildResult,
null,
2,
)}`,
);
}
this.logger.log(
`Aligned the message campaign record page for workspace ${workspaceId}`,
);
}
}
@@ -0,0 +1,179 @@
import { Command } from 'nest-commander';
import { STANDARD_PAGE_LAYOUT_UNIVERSAL_IDENTIFIERS } from 'twenty-shared/metadata';
import { isDefined } from 'twenty-shared/utils';
import { ProvisionedWorkspaceCommandRunner } from 'src/database/commands/command-runners/provisioned-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 { RegisteredWorkspaceCommand } from 'src/engine/core-modules/upgrade/decorators/registered-workspace-command.decorator';
import { type FlatCommandMenuItem } from 'src/engine/metadata-modules/flat-command-menu-item/types/flat-command-menu-item.type';
import { WorkspaceCacheService } from 'src/engine/workspace-cache/services/workspace-cache.service';
import { STANDARD_COMMAND_MENU_ITEMS } from 'src/engine/workspace-manager/twenty-standard-application/constants/standard-command-menu-item.constant';
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';
const SEND_COMMAND_MENU_ITEM_UNIVERSAL_IDENTIFIERS = [
STANDARD_COMMAND_MENU_ITEMS.sendMessageCampaign.universalIdentifier,
STANDARD_COMMAND_MENU_ITEMS.sendMessageCampaignTest.universalIdentifier,
];
const MESSAGE_CAMPAIGN_PAGE_LAYOUT_UNIVERSAL_IDENTIFIER =
STANDARD_PAGE_LAYOUT_UNIVERSAL_IDENTIFIERS.messageCampaignRecordPage
.universalIdentifier;
const REALIGNED_COMMAND_MENU_ITEM_UNIVERSAL_IDENTIFIERS = [
STANDARD_COMMAND_MENU_ITEMS.navigateToNextRecord.universalIdentifier,
STANDARD_COMMAND_MENU_ITEMS.navigateToPreviousRecord.universalIdentifier,
STANDARD_COMMAND_MENU_ITEMS.addToFavorites.universalIdentifier,
STANDARD_COMMAND_MENU_ITEMS.removeFromFavorites.universalIdentifier,
];
@RegisteredWorkspaceCommand('2.25.0', 1785229960000)
@Command({
name: 'upgrade:2-25:configure-message-campaign-command-menu',
description:
'Adds the Send Campaign and Send Test Email record actions and hides the favorite/record-navigation actions on message campaign record pages in existing workspaces',
})
export class ConfigureMessageCampaignCommandMenuCommand extends ProvisionedWorkspaceCommandRunner {
constructor(
protected readonly workspaceIteratorService: WorkspaceIteratorService,
private readonly applicationService: ApplicationService,
private readonly workspaceMigrationValidateBuildAndRunService: WorkspaceMigrationValidateBuildAndRunService,
private readonly workspaceCacheService: WorkspaceCacheService,
) {
super(workspaceIteratorService);
}
override async runOnWorkspace({
workspaceId,
options,
}: RunOnWorkspaceArgs): Promise<void> {
const isDryRun = options.dryRun ?? false;
const { twentyStandardFlatApplication } =
await this.applicationService.findWorkspaceTwentyStandardAndCustomApplicationOrThrow(
{ workspaceId },
);
const {
flatCommandMenuItemMaps: existingFlatCommandMenuItemMaps,
flatPageLayoutMaps,
} = await this.workspaceCacheService.getOrRecompute(workspaceId, [
'flatCommandMenuItemMaps',
'flatPageLayoutMaps',
]);
const existingPageLayout =
flatPageLayoutMaps.byUniversalIdentifier[
MESSAGE_CAMPAIGN_PAGE_LAYOUT_UNIVERSAL_IDENTIFIER
];
if (!isDefined(existingPageLayout)) {
this.logger.log(
`Message campaign page layout does not exist for workspace ${workspaceId}, skipping`,
);
return;
}
const { allFlatEntityMaps: standardAllFlatEntityMaps } =
computeTwentyStandardApplicationAllFlatEntityMaps({
now: new Date().toISOString(),
workspaceId,
twentyStandardApplicationId: twentyStandardFlatApplication.id,
});
const itemsToCreate = SEND_COMMAND_MENU_ITEM_UNIVERSAL_IDENTIFIERS.filter(
(universalIdentifier) =>
!isDefined(
existingFlatCommandMenuItemMaps.byUniversalIdentifier[
universalIdentifier
],
),
)
.map(
(universalIdentifier) =>
standardAllFlatEntityMaps.flatCommandMenuItemMaps
.byUniversalIdentifier[universalIdentifier],
)
.filter((item): item is FlatCommandMenuItem => isDefined(item));
const itemsToUpdate = REALIGNED_COMMAND_MENU_ITEM_UNIVERSAL_IDENTIFIERS.map(
(universalIdentifier) => {
const existingItem =
existingFlatCommandMenuItemMaps.byUniversalIdentifier[
universalIdentifier
];
const standardItem =
standardAllFlatEntityMaps.flatCommandMenuItemMaps
.byUniversalIdentifier[universalIdentifier];
if (
!isDefined(existingItem) ||
!isDefined(standardItem) ||
existingItem.conditionalAvailabilityExpression ===
standardItem.conditionalAvailabilityExpression
) {
return null;
}
return {
...existingItem,
conditionalAvailabilityExpression:
standardItem.conditionalAvailabilityExpression,
};
},
).filter((item): item is FlatCommandMenuItem => isDefined(item));
const totalOperationCount = itemsToCreate.length + itemsToUpdate.length;
if (totalOperationCount === 0) {
this.logger.log(
`Message campaign command menu already configured for workspace ${workspaceId}, skipping`,
);
return;
}
this.logger.log(
`${isDryRun ? '[DRY RUN] ' : ''}Configuring message campaign command menu (${itemsToCreate.length} to add, ${itemsToUpdate.length} to realign) for workspace ${workspaceId}`,
);
if (isDryRun) {
return;
}
const validateAndBuildResult =
await this.workspaceMigrationValidateBuildAndRunService.validateBuildAndRunLegacyWorkspaceMigration(
{
isSystemBuild: true,
allFlatEntityOperationByMetadataName: {
commandMenuItem: {
flatEntityToCreate: itemsToCreate,
flatEntityToDelete: [],
flatEntityToUpdate: itemsToUpdate,
},
},
workspaceId,
applicationUniversalIdentifier:
twentyStandardFlatApplication.universalIdentifier,
},
);
if (validateAndBuildResult.status === 'fail') {
throw new Error(
`Failed to configure message campaign command menu for workspace ${workspaceId}: ${JSON.stringify(
validateAndBuildResult,
null,
2,
)}`,
);
}
this.logger.log(
`Configured message campaign command menu for workspace ${workspaceId}`,
);
}
}
@@ -0,0 +1,208 @@
import { Command } from 'nest-commander';
import { STANDARD_OBJECTS } from 'twenty-shared/metadata';
import { isDefined } from 'twenty-shared/utils';
import { ProvisionedWorkspaceCommandRunner } from 'src/database/commands/command-runners/provisioned-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 { RegisteredWorkspaceCommand } from 'src/engine/core-modules/upgrade/decorators/registered-workspace-command.decorator';
import { findFlatEntityByUniversalIdentifier } from 'src/engine/metadata-modules/flat-entity/utils/find-flat-entity-by-universal-identifier.util';
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 FlatViewField } from 'src/engine/metadata-modules/flat-view-field/types/flat-view-field.type';
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';
const CAMPAIGN = STANDARD_OBJECTS.messageCampaign;
const NAME_FIELD_UNIVERSAL_IDENTIFIER =
CAMPAIGN.fields.name.universalIdentifier;
const SUBJECT_FIELD_UNIVERSAL_IDENTIFIER =
CAMPAIGN.fields.subject.universalIdentifier;
const NAME_VIEW_FIELD_UNIVERSAL_IDENTIFIER =
CAMPAIGN.views.allMessageCampaigns.viewFields.name.universalIdentifier;
@RegisteredWorkspaceCommand('2.25.0', 1785229970000)
@Command({
name: 'upgrade:2-25:add-message-campaign-name-field',
description:
'Add the internal name field to messageCampaign, surface it in the all campaigns view and make it the label identifier (was subject) on existing workspaces',
})
export class AddMessageCampaignNameFieldCommand extends ProvisionedWorkspaceCommandRunner {
constructor(
protected readonly workspaceIteratorService: WorkspaceIteratorService,
private readonly applicationService: ApplicationService,
private readonly workspaceCacheService: WorkspaceCacheService,
private readonly workspaceMigrationValidateBuildAndRunService: WorkspaceMigrationValidateBuildAndRunService,
) {
super(workspaceIteratorService);
}
override async runOnWorkspace({
workspaceId,
options,
}: RunOnWorkspaceArgs): Promise<void> {
const isDryRun = options.dryRun ?? false;
const { flatObjectMetadataMaps, flatFieldMetadataMaps, flatViewFieldMaps } =
await this.workspaceCacheService.getOrRecompute(workspaceId, [
'flatObjectMetadataMaps',
'flatFieldMetadataMaps',
'flatViewFieldMaps',
]);
const campaignObjectMetadata =
findFlatEntityByUniversalIdentifier<FlatObjectMetadata>({
flatEntityMaps: flatObjectMetadataMaps,
universalIdentifier: CAMPAIGN.universalIdentifier,
});
if (!isDefined(campaignObjectMetadata)) {
this.logger.log(
`messageCampaign object does not exist for workspace ${workspaceId}, skipping`,
);
return;
}
const { twentyStandardFlatApplication } =
await this.applicationService.findWorkspaceTwentyStandardAndCustomApplicationOrThrow(
{ workspaceId },
);
const { allFlatEntityMaps: standardAllFlatEntityMaps } =
computeTwentyStandardApplicationAllFlatEntityMaps({
now: new Date().toISOString(),
workspaceId,
twentyStandardApplicationId: twentyStandardFlatApplication.id,
});
const fieldsToCreate: FlatFieldMetadata[] = [];
if (
!isDefined(
flatFieldMetadataMaps.byUniversalIdentifier[
NAME_FIELD_UNIVERSAL_IDENTIFIER
],
)
) {
const standardNameField =
findFlatEntityByUniversalIdentifier<FlatFieldMetadata>({
flatEntityMaps: standardAllFlatEntityMaps.flatFieldMetadataMaps,
universalIdentifier: NAME_FIELD_UNIVERSAL_IDENTIFIER,
});
if (!isDefined(standardNameField)) {
throw new Error(
'Standard application is missing the messageCampaign name field',
);
}
fieldsToCreate.push(standardNameField);
}
const viewFieldsToCreate: FlatViewField[] = [];
if (
!isDefined(
flatViewFieldMaps.byUniversalIdentifier[
NAME_VIEW_FIELD_UNIVERSAL_IDENTIFIER
],
)
) {
const standardNameViewField =
findFlatEntityByUniversalIdentifier<FlatViewField>({
flatEntityMaps: standardAllFlatEntityMaps.flatViewFieldMaps,
universalIdentifier: NAME_VIEW_FIELD_UNIVERSAL_IDENTIFIER,
});
if (!isDefined(standardNameViewField)) {
throw new Error(
'Standard application is missing the messageCampaign name view column',
);
}
viewFieldsToCreate.push(standardNameViewField);
}
// Move the label identifier from subject to name, but never clobber a
// user customization pointing at another field.
const currentLabelIdentifier =
campaignObjectMetadata.labelIdentifierFieldMetadataUniversalIdentifier;
const flatObjectMetadataToUpdate: FlatObjectMetadata[] =
!isDefined(currentLabelIdentifier) ||
currentLabelIdentifier === SUBJECT_FIELD_UNIVERSAL_IDENTIFIER
? [
{
...campaignObjectMetadata,
labelIdentifierFieldMetadataUniversalIdentifier:
NAME_FIELD_UNIVERSAL_IDENTIFIER,
},
]
: [];
const totalOperationCount =
fieldsToCreate.length +
viewFieldsToCreate.length +
flatObjectMetadataToUpdate.length;
if (totalOperationCount === 0) {
this.logger.log(
`messageCampaign name field already configured for workspace ${workspaceId}, skipping`,
);
return;
}
if (isDryRun) {
this.logger.log(
`[DRY RUN] Workspace ${workspaceId}: ${fieldsToCreate.length} field(s), ${viewFieldsToCreate.length} view column(s), ${flatObjectMetadataToUpdate.length} label identifier update(s)`,
);
return;
}
const result =
await this.workspaceMigrationValidateBuildAndRunService.validateBuildAndRunWorkspaceMigration(
{
isSystemBuild: true,
workspaceId,
applicationUniversalIdentifier:
twentyStandardFlatApplication.universalIdentifier,
allFlatEntityOperationByMetadataName: {
fieldMetadata: {
flatEntityToCreate: fieldsToCreate,
flatEntityToDelete: [],
flatEntityToUpdate: [],
},
viewField: {
flatEntityToCreate: viewFieldsToCreate,
flatEntityToDelete: [],
flatEntityToUpdate: [],
},
objectMetadata: {
flatEntityToCreate: [],
flatEntityToDelete: [],
flatEntityToUpdate: flatObjectMetadataToUpdate,
},
},
},
);
if (result.status === 'fail') {
this.logger.error(
`Failed to add the messageCampaign name field:\n${JSON.stringify(result, null, 2)}`,
);
throw new Error(
`Failed to add the messageCampaign name field for workspace ${workspaceId}`,
);
}
this.logger.log(
`Added the messageCampaign name field for workspace ${workspaceId}`,
);
}
}
@@ -119,6 +119,7 @@ import { WidenViewCalendarIntegrityConstraintFastInstanceCommand } from './2-23/
import { AddApplicationIdToKeyValuePairFastInstanceCommand } from './2-23/2-23-instance-command-fast-1784659343818-add-application-id-to-key-value-pair';
import { AddLogoFileIdToApplicationRegistration2_23FastInstanceCommand } from './2-23/2-23-instance-command-fast-1784823473532-add-logo-file-id-to-application-registration';
import { AddMessageChannelDisplayNameFastInstanceCommand } from './2-25/2-25-instance-command-fast-1784904030252-add-message-channel-display-name';
import { AddMessageCampaignWidgetTypeFastInstanceCommand } from './2-25/2-25-instance-command-fast-1785229950000-add-message-campaign-widget-type';
import { BackfillCreatedWorkspaceActivationStatusSlowInstanceCommand } from './2-23/2-23-instance-command-slow-1784286705000-backfill-created-workspace-activation-status';
import { UnlistUnclaimedNpmApplicationRegistrationsSlowInstanceCommand } from './2-23/2-23-instance-command-slow-1784322591746-unlist-unclaimed-npm-application-registrations';
import { AddStatusesToBillingSubscriptionIndexSlowInstanceCommand } from './2-23/2-23-instance-command-slow-1784650048045-add-statuses-to-billing-subscription-index';
@@ -251,6 +252,7 @@ export const INSTANCE_COMMANDS = [
AddSdkClientCoreChecksumToApplicationFastInstanceCommand,
AddLogoFileIdToApplicationRegistration2_23FastInstanceCommand,
AddMessageChannelDisplayNameFastInstanceCommand,
AddMessageCampaignWidgetTypeFastInstanceCommand,
AddStatusesToBillingSubscriptionIndexSlowInstanceCommand,
AddOnConnectLogicFunctionToConnectionProviderFastInstanceCommand,
RepairKeyValuePairApplicationIdFastInstanceCommand,