Messages Message Folder Association (#17398)

This PR adds Message folder association for message channel messages,
Currently under testing phase, not ready yet.

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
neo773
2026-02-19 03:43:47 +05:30
committed by GitHub
parent 549c7a613b
commit 04502e5abb
32 changed files with 1760 additions and 884 deletions
+2
View File
@@ -28,6 +28,8 @@ npx jest path/to/test.test.ts --config=packages/PROJECT/jest.config.mjs
npx nx test twenty-front # Frontend unit tests
npx nx test twenty-server # Backend unit tests
npx nx run twenty-server:test:integration:with-db-reset # Integration tests with DB reset
# To run an indivual test or a pattern of tests, use the following command:
cd packages/{workspace} && npx jest "pattern or filename"
# Storybook
npx nx storybook:build twenty-front
@@ -0,0 +1,188 @@
import { InjectRepository } from '@nestjs/typeorm';
import { Command } from 'nest-commander';
import { STANDARD_OBJECTS } from 'twenty-shared/metadata';
import { isDefined } from 'twenty-shared/utils';
import { Repository } from 'typeorm';
import { ActiveOrSuspendedWorkspacesMigrationCommandRunner } from 'src/database/commands/command-runners/active-or-suspended-workspaces-migration.command-runner';
import { RunOnWorkspaceArgs } from 'src/database/commands/command-runners/workspaces-migration.command-runner';
import { ApplicationService } from 'src/engine/core-modules/application/services/application.service';
import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity';
import { DataSourceService } from 'src/engine/metadata-modules/data-source/data-source.service';
import { type SyncableFlatEntity } from 'src/engine/metadata-modules/flat-entity/types/flat-entity-from.type';
import { type FlatEntityMaps } from 'src/engine/metadata-modules/flat-entity/types/flat-entity-maps.type';
import { addFlatEntityToFlatEntityMapsOrThrow } from 'src/engine/metadata-modules/flat-entity/utils/add-flat-entity-to-flat-entity-maps-or-throw.util';
import { findFlatEntityByUniversalIdentifier } from 'src/engine/metadata-modules/flat-entity/utils/find-flat-entity-by-universal-identifier.util';
import { getSubFlatEntityMapsByApplicationIdsOrThrow } from 'src/engine/metadata-modules/flat-entity/utils/get-sub-flat-entity-maps-by-application-ids-or-throw.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 { GlobalWorkspaceOrmManager } from 'src/engine/twenty-orm/global-workspace-datasource/global-workspace-orm.manager';
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';
@Command({
name: 'upgrade:1-19:backfill-message-channel-message-association-message-folder',
description:
'Backfill messageChannelMessageAssociationMessageFolder standard object and its relation fields',
})
export class BackfillMessageChannelMessageAssociationMessageFolderCommand extends ActiveOrSuspendedWorkspacesMigrationCommandRunner {
constructor(
@InjectRepository(WorkspaceEntity)
protected readonly workspaceRepository: Repository<WorkspaceEntity>,
protected readonly twentyORMGlobalManager: GlobalWorkspaceOrmManager,
protected readonly dataSourceService: DataSourceService,
private readonly applicationService: ApplicationService,
private readonly workspaceCacheService: WorkspaceCacheService,
private readonly workspaceMigrationValidateBuildAndRunService: WorkspaceMigrationValidateBuildAndRunService,
) {
super(workspaceRepository, twentyORMGlobalManager, dataSourceService);
}
private addNewEntitiesToFlatEntityMaps<T extends SyncableFlatEntity>({
fromMaps,
standardBuilderMaps,
}: {
fromMaps: FlatEntityMaps<T>;
standardBuilderMaps: FlatEntityMaps<T>;
}): FlatEntityMaps<T> {
let toMaps = fromMaps;
for (const [universalIdentifier, entity] of Object.entries(
standardBuilderMaps.byUniversalIdentifier,
)) {
if (
!isDefined(entity) ||
isDefined(fromMaps.byUniversalIdentifier[universalIdentifier])
) {
continue;
}
toMaps = addFlatEntityToFlatEntityMapsOrThrow({
flatEntity: entity,
flatEntityMaps: toMaps,
});
}
return toMaps;
}
override async runOnWorkspace({
workspaceId,
options,
}: RunOnWorkspaceArgs): Promise<void> {
const isDryRun = options.dryRun ?? false;
this.logger.log(
`${isDryRun ? '[DRY RUN] ' : ''}Starting backfill of messageChannelMessageAssociationMessageFolder for workspace ${workspaceId}`,
);
const { flatObjectMetadataMaps, flatFieldMetadataMaps, featureFlagsMap } =
await this.workspaceCacheService.getOrRecompute(workspaceId, [
'flatObjectMetadataMaps',
'flatFieldMetadataMaps',
'featureFlagsMap',
]);
const existingObject = findFlatEntityByUniversalIdentifier({
flatEntityMaps: flatObjectMetadataMaps,
universalIdentifier:
STANDARD_OBJECTS.messageChannelMessageAssociationMessageFolder
.universalIdentifier,
});
if (existingObject) {
this.logger.log(
`messageChannelMessageAssociationMessageFolder object already exists for workspace ${workspaceId}, skipping`,
);
return;
}
if (isDryRun) {
this.logger.log(
`[DRY RUN] Would create messageChannelMessageAssociationMessageFolder object and relation fields for workspace ${workspaceId}`,
);
return;
}
const { twentyStandardFlatApplication } =
await this.applicationService.findWorkspaceTwentyStandardAndCustomApplicationOrThrow(
{ workspaceId },
);
const fromFlatObjectMetadataMaps =
getSubFlatEntityMapsByApplicationIdsOrThrow<FlatObjectMetadata>({
applicationIds: [twentyStandardFlatApplication.id],
flatEntityMaps: flatObjectMetadataMaps,
});
const fromFlatFieldMetadataMaps =
getSubFlatEntityMapsByApplicationIdsOrThrow<FlatFieldMetadata>({
applicationIds: [twentyStandardFlatApplication.id],
flatEntityMaps: flatFieldMetadataMaps,
});
const {
allFlatEntityMaps: standardAllFlatEntityMaps,
idByUniversalIdentifierByMetadataName,
} = computeTwentyStandardApplicationAllFlatEntityMaps({
now: new Date().toISOString(),
workspaceId,
twentyStandardApplicationId: twentyStandardFlatApplication.id,
});
const toFlatObjectMetadataMaps =
this.addNewEntitiesToFlatEntityMaps<FlatObjectMetadata>({
fromMaps: fromFlatObjectMetadataMaps,
standardBuilderMaps: standardAllFlatEntityMaps.flatObjectMetadataMaps,
});
const toFlatFieldMetadataMaps =
this.addNewEntitiesToFlatEntityMaps<FlatFieldMetadata>({
fromMaps: fromFlatFieldMetadataMaps,
standardBuilderMaps: standardAllFlatEntityMaps.flatFieldMetadataMaps,
});
const validateAndBuildResult =
await this.workspaceMigrationValidateBuildAndRunService.validateBuildAndRunWorkspaceMigrationFromTo(
{
buildOptions: {
isSystemBuild: true,
},
fromToAllFlatEntityMaps: {
flatObjectMetadataMaps: {
from: fromFlatObjectMetadataMaps,
to: toFlatObjectMetadataMaps,
},
flatFieldMetadataMaps: {
from: fromFlatFieldMetadataMaps,
to: toFlatFieldMetadataMaps,
},
},
workspaceId,
additionalCacheDataMaps: {
featureFlagsMap,
},
applicationUniversalIdentifier:
twentyStandardFlatApplication.universalIdentifier,
idByUniversalIdentifierByMetadataName,
},
);
if (isDefined(validateAndBuildResult)) {
this.logger.error(
`Failed to create messageChannelMessageAssociationMessageFolder:\n${JSON.stringify(validateAndBuildResult, null, 2)}`,
);
throw new Error(
`Failed to create messageChannelMessageAssociationMessageFolder for workspace ${workspaceId}`,
);
}
this.logger.log(
`Successfully created messageChannelMessageAssociationMessageFolder for workspace ${workspaceId}`,
);
}
}
@@ -0,0 +1,22 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { BackfillMessageChannelMessageAssociationMessageFolderCommand } from 'src/database/commands/upgrade-version-command/1-19/1-19-backfill-message-channel-message-association-message-folder.command';
import { ApplicationModule } from 'src/engine/core-modules/application/application.module';
import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity';
import { DataSourceModule } from 'src/engine/metadata-modules/data-source/data-source.module';
import { WorkspaceCacheModule } from 'src/engine/workspace-cache/workspace-cache.module';
import { WorkspaceMigrationModule } from 'src/engine/workspace-manager/workspace-migration/workspace-migration.module';
@Module({
imports: [
TypeOrmModule.forFeature([WorkspaceEntity]),
DataSourceModule,
WorkspaceCacheModule,
ApplicationModule,
WorkspaceMigrationModule,
],
providers: [BackfillMessageChannelMessageAssociationMessageFolderCommand],
exports: [BackfillMessageChannelMessageAssociationMessageFolderCommand],
})
export class V1_19_UpgradeVersionCommandModule {}
@@ -3,6 +3,7 @@ import { TypeOrmModule } from '@nestjs/typeorm';
import { V1_17_UpgradeVersionCommandModule } from 'src/database/commands/upgrade-version-command/1-17/1-17-upgrade-version-command.module';
import { V1_18_UpgradeVersionCommandModule } from 'src/database/commands/upgrade-version-command/1-18/1-18-upgrade-version-command.module';
import { V1_19_UpgradeVersionCommandModule } from 'src/database/commands/upgrade-version-command/1-19/1-19-upgrade-version-command.module';
import { UpgradeCommand } from 'src/database/commands/upgrade-version-command/upgrade.command';
import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity';
import { DataSourceModule } from 'src/engine/metadata-modules/data-source/data-source.module';
@@ -12,6 +13,7 @@ import { DataSourceModule } from 'src/engine/metadata-modules/data-source/data-s
TypeOrmModule.forFeature([WorkspaceEntity]),
V1_17_UpgradeVersionCommandModule,
V1_18_UpgradeVersionCommandModule,
V1_19_UpgradeVersionCommandModule,
DataSourceModule,
],
providers: [UpgradeCommand],
@@ -27,6 +27,7 @@ import { MigrateFavoritesToNavigationMenuItemsCommand } from 'src/database/comma
import { MigratePersonAvatarFilesCommand } from 'src/database/commands/upgrade-version-command/1-18/1-18-migrate-person-avatar-files.command';
import { MigrateWorkflowSendEmailAttachmentsCommand } from 'src/database/commands/upgrade-version-command/1-18/1-18-migrate-workflow-send-email-attachments.command';
import { MigrateWorkspacePicturesCommand } from 'src/database/commands/upgrade-version-command/1-18/1-18-migrate-workspace-pictures.command';
import { BackfillMessageChannelMessageAssociationMessageFolderCommand } from 'src/database/commands/upgrade-version-command/1-19/1-19-backfill-message-channel-message-association-message-folder.command';
import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service';
import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity';
import { DataSourceService } from 'src/engine/metadata-modules/data-source/data-source.service';
@@ -67,6 +68,9 @@ export class UpgradeCommand extends UpgradeCommandRunner {
protected readonly backfillStandardViewsAndFieldMetadataCommand: BackfillStandardViewsAndFieldMetadataCommand,
protected readonly migrateWorkspacePicturesCommand: MigrateWorkspacePicturesCommand,
protected readonly migrateWorkflowSendEmailAttachmentsCommand: MigrateWorkflowSendEmailAttachmentsCommand,
// 1.19 Commands
protected readonly backfillMessageChannelMessageAssociationMessageFolderCommand: BackfillMessageChannelMessageAssociationMessageFolderCommand,
) {
super(
workspaceRepository,
@@ -103,10 +107,15 @@ export class UpgradeCommand extends UpgradeCommandRunner {
this.backfillStandardViewsAndFieldMetadataCommand,
];
const commands_1190: VersionCommands = [
this.backfillMessageChannelMessageAssociationMessageFolderCommand,
];
this.allCommands = {
'1.16.0': commands_1160,
'1.17.0': commands_1170,
'1.18.0': commands_1180,
'1.19.0': commands_1190,
};
}
@@ -65,7 +65,7 @@ import { WorkspaceDataSourceModule } from 'src/engine/workspace-datasource/works
import { WorkspaceManagerModule } from 'src/engine/workspace-manager/workspace-manager.module';
import { CalendarChannelSyncStatusService } from 'src/modules/calendar/common/services/calendar-channel-sync-status.service';
import { ConnectedAccountModule } from 'src/modules/connected-account/connected-account.module';
import { MessageChannelSyncStatusService } from 'src/modules/messaging/common/services/message-channel-sync-status.service';
import { MessagingCommonModule } from 'src/modules/messaging/common/messaging-common.module';
import { MessagingFolderSyncManagerModule } from 'src/modules/messaging/message-folder-manager/messaging-folder-sync-manager.module';
import { AuthResolver } from './auth.resolver';
@@ -101,6 +101,7 @@ import { JwtAuthStrategy } from './strategies/jwt.auth.strategy';
OnboardingModule,
WorkspaceDataSourceModule,
ConnectedAccountModule,
MessagingCommonModule,
MessagingFolderSyncManagerModule,
WorkspaceSSOModule,
FeatureFlagModule,
@@ -145,7 +146,6 @@ import { JwtAuthStrategy } from './strategies/jwt.auth.strategy';
// So far, it's not possible to have controllers in business modules
// which forces us to have these services in the auth module
// TODO: Move these calendar, message, and connected account services to the business modules once possible
MessageChannelSyncStatusService,
CalendarChannelSyncStatusService,
CreateMessageChannelService,
CreateCalendarChannelService,
@@ -16,6 +16,7 @@ import { buildConnectedAccountStandardFlatFieldMetadatas } from 'src/engine/work
import { buildDashboardStandardFlatFieldMetadatas } from 'src/engine/workspace-manager/twenty-standard-application/utils/field-metadata/compute-dashboard-standard-flat-field-metadata.util';
import { buildFavoriteFolderStandardFlatFieldMetadatas } from 'src/engine/workspace-manager/twenty-standard-application/utils/field-metadata/compute-favorite-folder-standard-flat-field-metadata.util';
import { buildFavoriteStandardFlatFieldMetadatas } from 'src/engine/workspace-manager/twenty-standard-application/utils/field-metadata/compute-favorite-standard-flat-field-metadata.util';
import { buildMessageChannelMessageAssociationMessageFolderStandardFlatFieldMetadatas } from 'src/engine/workspace-manager/twenty-standard-application/utils/field-metadata/compute-message-channel-message-association-message-folder-standard-flat-field-metadata.util';
import { buildMessageChannelMessageAssociationStandardFlatFieldMetadatas } from 'src/engine/workspace-manager/twenty-standard-application/utils/field-metadata/compute-message-channel-message-association-standard-flat-field-metadata.util';
import { buildMessageChannelStandardFlatFieldMetadatas } from 'src/engine/workspace-manager/twenty-standard-application/utils/field-metadata/compute-message-channel-standard-flat-field-metadata.util';
import { buildMessageFolderStandardFlatFieldMetadatas } from 'src/engine/workspace-manager/twenty-standard-application/utils/field-metadata/compute-message-folder-standard-flat-field-metadata.util';
@@ -58,6 +59,8 @@ const STANDARD_FLAT_FIELD_METADATA_BUILDERS_BY_OBJECT_NAME = {
messageChannel: buildMessageChannelStandardFlatFieldMetadatas,
messageChannelMessageAssociation:
buildMessageChannelMessageAssociationStandardFlatFieldMetadatas,
messageChannelMessageAssociationMessageFolder:
buildMessageChannelMessageAssociationMessageFolderStandardFlatFieldMetadatas,
messageFolder: buildMessageFolderStandardFlatFieldMetadatas,
messageParticipant: buildMessageParticipantStandardFlatFieldMetadatas,
messageThread: buildMessageThreadStandardFlatFieldMetadatas,
@@ -0,0 +1,159 @@
import {
DateDisplayFormat,
FieldMetadataType,
RelationOnDeleteAction,
RelationType,
} from 'twenty-shared/types';
import { type FlatFieldMetadata } from 'src/engine/metadata-modules/flat-field-metadata/types/flat-field-metadata.type';
import { type AllStandardObjectFieldName } from 'src/engine/workspace-manager/twenty-standard-application/types/all-standard-object-field-name.type';
import {
type CreateStandardFieldArgs,
createStandardFieldFlatMetadata,
} from 'src/engine/workspace-manager/twenty-standard-application/utils/field-metadata/create-standard-field-flat-metadata.util';
import { createStandardRelationFieldFlatMetadata } from 'src/engine/workspace-manager/twenty-standard-application/utils/field-metadata/create-standard-relation-field-flat-metadata.util';
export const buildMessageChannelMessageAssociationMessageFolderStandardFlatFieldMetadatas =
({
now,
objectName,
workspaceId,
standardObjectMetadataRelatedEntityIds,
dependencyFlatEntityMaps,
twentyStandardApplicationId,
}: Omit<
CreateStandardFieldArgs<
'messageChannelMessageAssociationMessageFolder',
FieldMetadataType
>,
'context'
>): Record<
AllStandardObjectFieldName<'messageChannelMessageAssociationMessageFolder'>,
FlatFieldMetadata
> => ({
id: createStandardFieldFlatMetadata({
objectName,
workspaceId,
context: {
fieldName: 'id',
type: FieldMetadataType.UUID,
label: 'Id',
description: 'Id',
icon: 'Icon123',
isSystem: true,
isNullable: false,
isUIReadOnly: true,
defaultValue: 'uuid',
},
standardObjectMetadataRelatedEntityIds,
dependencyFlatEntityMaps,
twentyStandardApplicationId,
now,
}),
createdAt: createStandardFieldFlatMetadata({
objectName,
workspaceId,
context: {
fieldName: 'createdAt',
type: FieldMetadataType.DATE_TIME,
label: 'Creation date',
description: 'Creation date',
icon: 'IconCalendar',
isNullable: false,
isUIReadOnly: true,
defaultValue: 'now',
settings: { displayFormat: DateDisplayFormat.RELATIVE },
},
standardObjectMetadataRelatedEntityIds,
dependencyFlatEntityMaps,
twentyStandardApplicationId,
now,
}),
updatedAt: createStandardFieldFlatMetadata({
objectName,
workspaceId,
context: {
fieldName: 'updatedAt',
type: FieldMetadataType.DATE_TIME,
label: 'Last update',
description: 'Last time the record was changed',
icon: 'IconCalendarClock',
isNullable: false,
isUIReadOnly: true,
defaultValue: 'now',
settings: { displayFormat: DateDisplayFormat.RELATIVE },
},
standardObjectMetadataRelatedEntityIds,
dependencyFlatEntityMaps,
twentyStandardApplicationId,
now,
}),
deletedAt: createStandardFieldFlatMetadata({
objectName,
workspaceId,
context: {
fieldName: 'deletedAt',
type: FieldMetadataType.DATE_TIME,
label: 'Deleted at',
description: 'Date when the record was deleted',
icon: 'IconCalendarMinus',
isNullable: true,
isUIReadOnly: true,
settings: { displayFormat: DateDisplayFormat.RELATIVE },
},
standardObjectMetadataRelatedEntityIds,
dependencyFlatEntityMaps,
twentyStandardApplicationId,
now,
}),
messageChannelMessageAssociation: createStandardRelationFieldFlatMetadata({
objectName,
workspaceId,
context: {
type: FieldMetadataType.RELATION,
morphId: null,
fieldName: 'messageChannelMessageAssociation',
label: 'Message Channel Message Association',
description: 'Message Channel Message Association',
icon: 'IconMessage',
isNullable: false,
isUIReadOnly: true,
targetObjectName: 'messageChannelMessageAssociation',
targetFieldName: 'messageFolders',
settings: {
relationType: RelationType.MANY_TO_ONE,
onDelete: RelationOnDeleteAction.CASCADE,
joinColumnName: 'messageChannelMessageAssociationId',
},
},
standardObjectMetadataRelatedEntityIds,
dependencyFlatEntityMaps,
twentyStandardApplicationId,
now,
}),
messageFolder: createStandardRelationFieldFlatMetadata({
objectName,
workspaceId,
context: {
type: FieldMetadataType.RELATION,
morphId: null,
fieldName: 'messageFolder',
label: 'Message Folder',
description: 'Message Folder',
icon: 'IconFolder',
isNullable: false,
isUIReadOnly: true,
targetObjectName: 'messageFolder',
targetFieldName: 'messageChannelMessageAssociationMessageFolders',
settings: {
relationType: RelationType.MANY_TO_ONE,
onDelete: RelationOnDeleteAction.CASCADE,
joinColumnName: 'messageFolderId',
},
},
standardObjectMetadataRelatedEntityIds,
dependencyFlatEntityMaps,
twentyStandardApplicationId,
now,
}),
});
@@ -248,4 +248,27 @@ export const buildMessageChannelMessageAssociationStandardFlatFieldMetadatas =
twentyStandardApplicationId,
now,
}),
messageFolders: createStandardRelationFieldFlatMetadata({
objectName,
workspaceId,
context: {
type: FieldMetadataType.RELATION,
morphId: null,
fieldName: 'messageFolders',
label: 'Message Folders',
description: 'Message Folders (supports multiple folders/labels)',
icon: 'IconFolders',
isNullable: true,
isUIReadOnly: true,
targetObjectName: 'messageChannelMessageAssociationMessageFolder',
targetFieldName: 'messageChannelMessageAssociation',
settings: {
relationType: RelationType.ONE_TO_MANY,
},
},
standardObjectMetadataRelatedEntityIds,
dependencyFlatEntityMaps,
twentyStandardApplicationId,
now,
}),
});
@@ -255,4 +255,29 @@ export const buildMessageFolderStandardFlatFieldMetadatas = ({
twentyStandardApplicationId,
now,
}),
messageChannelMessageAssociationMessageFolders:
createStandardRelationFieldFlatMetadata({
objectName,
workspaceId,
context: {
type: FieldMetadataType.RELATION,
morphId: null,
fieldName: 'messageChannelMessageAssociationMessageFolders',
label: 'Message Association Folders',
description:
'Message Association Folders (supports multiple folders/labels)',
icon: 'IconFolders',
isNullable: true,
isUIReadOnly: true,
targetObjectName: 'messageChannelMessageAssociationMessageFolder',
targetFieldName: 'messageFolder',
settings: {
relationType: RelationType.ONE_TO_MANY,
},
},
standardObjectMetadataRelatedEntityIds,
dependencyFlatEntityMaps,
twentyStandardApplicationId,
now,
}),
});
@@ -12,6 +12,7 @@ import { buildCompanyStandardFlatIndexMetadatas } from 'src/engine/workspace-man
import { buildConnectedAccountStandardFlatIndexMetadatas } from 'src/engine/workspace-manager/twenty-standard-application/utils/index/compute-connected-account-standard-flat-index-metadata.util';
import { buildDashboardStandardFlatIndexMetadatas } from 'src/engine/workspace-manager/twenty-standard-application/utils/index/compute-dashboard-standard-flat-index-metadata.util';
import { buildFavoriteStandardFlatIndexMetadatas } from 'src/engine/workspace-manager/twenty-standard-application/utils/index/compute-favorite-standard-flat-index-metadata.util';
import { buildMessageChannelMessageAssociationMessageFolderStandardFlatIndexMetadatas } from 'src/engine/workspace-manager/twenty-standard-application/utils/index/compute-message-channel-message-association-message-folder-standard-flat-index-metadata.util';
import { buildMessageChannelMessageAssociationStandardFlatIndexMetadatas } from 'src/engine/workspace-manager/twenty-standard-application/utils/index/compute-message-channel-message-association-standard-flat-index-metadata.util';
import { buildMessageChannelStandardFlatIndexMetadatas } from 'src/engine/workspace-manager/twenty-standard-application/utils/index/compute-message-channel-standard-flat-index-metadata.util';
import { buildMessageFolderStandardFlatIndexMetadatas } from 'src/engine/workspace-manager/twenty-standard-application/utils/index/compute-message-folder-standard-flat-index-metadata.util';
@@ -51,6 +52,8 @@ const STANDARD_FLAT_INDEX_METADATA_BUILDERS_BY_OBJECT_NAME = {
messageChannel: buildMessageChannelStandardFlatIndexMetadatas,
messageChannelMessageAssociation:
buildMessageChannelMessageAssociationStandardFlatIndexMetadatas,
messageChannelMessageAssociationMessageFolder:
buildMessageChannelMessageAssociationMessageFolderStandardFlatIndexMetadatas,
messageFolder: buildMessageFolderStandardFlatIndexMetadatas,
messageParticipant: buildMessageParticipantStandardFlatIndexMetadatas,
note: buildNoteStandardFlatIndexMetadatas,
@@ -0,0 +1,66 @@
import { type FlatIndexMetadata } from 'src/engine/metadata-modules/flat-index-metadata/types/flat-index-metadata.type';
import { type AllStandardObjectIndexName } from 'src/engine/workspace-manager/twenty-standard-application/types/all-standard-object-index-name.type';
import {
type CreateStandardIndexArgs,
createStandardIndexFlatMetadata,
} from 'src/engine/workspace-manager/twenty-standard-application/utils/index/create-standard-index-flat-metadata.util';
export const buildMessageChannelMessageAssociationMessageFolderStandardFlatIndexMetadatas =
({
now,
objectName,
workspaceId,
standardObjectMetadataRelatedEntityIds,
dependencyFlatEntityMaps,
twentyStandardApplicationId,
}: Omit<
CreateStandardIndexArgs<'messageChannelMessageAssociationMessageFolder'>,
'context'
>): Record<
AllStandardObjectIndexName<'messageChannelMessageAssociationMessageFolder'>,
FlatIndexMetadata
> => ({
messageChannelMessageAssociationIdIndex: createStandardIndexFlatMetadata({
objectName,
workspaceId,
context: {
indexName: 'messageChannelMessageAssociationIdIndex',
relatedFieldNames: ['messageChannelMessageAssociation'],
},
standardObjectMetadataRelatedEntityIds,
dependencyFlatEntityMaps,
twentyStandardApplicationId,
now,
}),
messageFolderIdIndex: createStandardIndexFlatMetadata({
objectName,
workspaceId,
context: {
indexName: 'messageFolderIdIndex',
relatedFieldNames: ['messageFolder'],
},
standardObjectMetadataRelatedEntityIds,
dependencyFlatEntityMaps,
twentyStandardApplicationId,
now,
}),
messageChannelMessageAssociationIdMessageFolderIdUniqueIndex:
createStandardIndexFlatMetadata({
objectName,
workspaceId,
context: {
indexName:
'messageChannelMessageAssociationIdMessageFolderIdUniqueIndex',
relatedFieldNames: [
'messageChannelMessageAssociation',
'messageFolder',
],
isUnique: true,
indexWhereClause: '"deletedAt" IS NULL',
},
standardObjectMetadataRelatedEntityIds,
dependencyFlatEntityMaps,
twentyStandardApplicationId,
now,
}),
});
@@ -416,6 +416,39 @@ export const STANDARD_FLAT_OBJECT_METADATA_BUILDERS_BY_OBJECT_NAME = {
twentyStandardApplicationId,
now,
}),
messageChannelMessageAssociationMessageFolder: ({
now,
workspaceId,
standardObjectMetadataRelatedEntityIds,
twentyStandardApplicationId,
dependencyFlatEntityMaps,
}: Omit<
CreateStandardObjectArgs<'messageChannelMessageAssociationMessageFolder'>,
'context' | 'objectName'
>) =>
createStandardObjectFlatMetadata({
objectName: 'messageChannelMessageAssociationMessageFolder',
dependencyFlatEntityMaps,
context: {
universalIdentifier:
STANDARD_OBJECTS.messageChannelMessageAssociationMessageFolder
.universalIdentifier,
nameSingular: 'messageChannelMessageAssociationMessageFolder',
namePlural: 'messageChannelMessageAssociationMessageFolders',
labelSingular: 'Message Channel Message Association Message Folder',
labelPlural: 'Message Channel Message Association Message Folders',
description:
'Join table linking message channel message associations to message folders',
icon: 'IconFolder',
isSystem: true,
isAuditLogged: false,
labelIdentifierFieldMetadataName: 'id',
},
workspaceId,
standardObjectMetadataRelatedEntityIds,
twentyStandardApplicationId,
now,
}),
messageParticipant: ({
now,
workspaceId,
@@ -0,0 +1,11 @@
import { BaseWorkspaceEntity } from 'src/engine/twenty-orm/base.workspace-entity';
import { type EntityRelation } from 'src/engine/workspace-manager/workspace-migration/types/entity-relation.interface';
import { type MessageChannelMessageAssociationWorkspaceEntity } from 'src/modules/messaging/common/standard-objects/message-channel-message-association.workspace-entity';
import { type MessageFolderWorkspaceEntity } from 'src/modules/messaging/common/standard-objects/message-folder.workspace-entity';
export class MessageChannelMessageAssociationMessageFolderWorkspaceEntity extends BaseWorkspaceEntity {
messageChannelMessageAssociation: EntityRelation<MessageChannelMessageAssociationWorkspaceEntity>;
messageChannelMessageAssociationId: string;
messageFolder: EntityRelation<MessageFolderWorkspaceEntity>;
messageFolderId: string;
}
@@ -1,6 +1,7 @@
import { BaseWorkspaceEntity } from 'src/engine/twenty-orm/base.workspace-entity';
import { type EntityRelation } from 'src/engine/workspace-manager/workspace-migration/types/entity-relation.interface';
import { type MessageDirection } from 'src/modules/messaging/common/enums/message-direction.enum';
import { type MessageChannelMessageAssociationMessageFolderWorkspaceEntity } from 'src/modules/messaging/common/standard-objects/message-channel-message-association-message-folder.workspace-entity';
import { type MessageChannelWorkspaceEntity } from 'src/modules/messaging/common/standard-objects/message-channel.workspace-entity';
import { type MessageWorkspaceEntity } from 'src/modules/messaging/common/standard-objects/message.workspace-entity';
@@ -12,4 +13,7 @@ export class MessageChannelMessageAssociationWorkspaceEntity extends BaseWorkspa
messageChannelId: string;
message: EntityRelation<MessageWorkspaceEntity> | null;
messageId: string;
messageFolders: EntityRelation<
MessageChannelMessageAssociationMessageFolderWorkspaceEntity[]
>;
}
@@ -2,6 +2,7 @@ import { registerEnumType } from '@nestjs/graphql';
import { BaseWorkspaceEntity } from 'src/engine/twenty-orm/base.workspace-entity';
import { type EntityRelation } from 'src/engine/workspace-manager/workspace-migration/types/entity-relation.interface';
import { type MessageChannelMessageAssociationMessageFolderWorkspaceEntity } from 'src/modules/messaging/common/standard-objects/message-channel-message-association-message-folder.workspace-entity';
import { type MessageChannelWorkspaceEntity } from 'src/modules/messaging/common/standard-objects/message-channel.workspace-entity';
export enum MessageFolderPendingSyncAction {
@@ -23,4 +24,7 @@ export class MessageFolderWorkspaceEntity extends BaseWorkspaceEntity {
externalId: string | null;
pendingSyncAction: MessageFolderPendingSyncAction;
messageChannelId: string;
messageChannelMessageAssociationMessageFolders: EntityRelation<
MessageChannelMessageAssociationMessageFolderWorkspaceEntity[]
>;
}
@@ -84,6 +84,7 @@ export const parseAndFormatGmailMessage = (
participants,
text: sanitizeString(textWithoutReplyQuotations),
attachments,
messageFolderExternalIds: labelIds,
labelIds,
};
};
@@ -44,13 +44,11 @@ export class ImapGetMessagesService {
const client = await this.imapClientProvider.getClient(connectedAccount);
try {
const messages = await this.fetchFromAllFolders(
return await this.fetchFromAllFolders(
messagesByFolder,
client,
connectedAccount,
);
return messages;
} finally {
await this.imapClientProvider.closeClient(client);
}
@@ -112,11 +110,16 @@ export class ImapGetMessagesService {
);
const startTime = Date.now();
const results = await this.messageParser.parseMessagesFromFolder(
messageUids,
folderPath,
client,
);
const { messages: results, uidValidity } =
await this.messageParser.parseMessagesFromFolder(
messageUids,
folderPath,
client,
);
const folderExternalId = uidValidity
? `${folderPath}:${uidValidity}`
: folderPath;
const messages: MessageWithParticipants[] = [];
@@ -141,6 +144,7 @@ export class ImapGetMessagesService {
result.parsed,
result.uid,
folderPath,
folderExternalId,
connectedAccount,
),
);
@@ -157,6 +161,7 @@ export class ImapGetMessagesService {
parsed: ParsedMail,
uid: number,
folderPath: string,
folderExternalId: string,
connectedAccount: Pick<
ConnectedAccountWorkspaceEntity,
'handle' | 'handleAliases'
@@ -179,6 +184,7 @@ export class ImapGetMessagesService {
direction: computeMessageDirection(senderAddress, connectedAccount),
attachments: this.extractAttachments(parsed),
participants: this.extractParticipants(parsed),
messageFolderExternalIds: [folderExternalId],
};
}
@@ -9,6 +9,11 @@ export type MessageParseResult = {
error?: Error;
};
export type FolderParseResult = {
messages: MessageParseResult[];
uidValidity: bigint | null;
};
@Injectable()
export class ImapMessageParserService {
private readonly logger = new Logger(ImapMessageParserService.name);
@@ -17,14 +22,19 @@ export class ImapMessageParserService {
messageUids: number[],
folderPath: string,
client: ImapFlow,
): Promise<MessageParseResult[]> {
): Promise<FolderParseResult> {
if (!messageUids.length) {
return [];
return { messages: [], uidValidity: null };
}
const lock = await client.getMailboxLock(folderPath);
try {
const uidValidity =
client.mailbox && typeof client.mailbox !== 'boolean'
? client.mailbox.uidValidity
: null;
const uidSet = messageUids.join(',');
const startTime = Date.now();
@@ -52,13 +62,16 @@ export class ImapMessageParserService {
`Fetched and parsed ${results.length} messages from ${folderPath} in ${Date.now() - startTime}ms`,
);
return results;
return { messages: results, uidValidity };
} catch (error) {
this.logger.error(
`Failed to parse messages from folder ${folderPath}: ${error.message}`,
);
return this.createErrorResults(messageUids, error as Error);
return {
messages: this.createErrorResults(messageUids, error as Error),
uidValidity: null,
};
} finally {
lock.release();
}
@@ -109,6 +109,9 @@ describe('Microsoft get messages service', () => {
},
],
attachments: [],
messageFolderExternalIds: responseExample1.body.parentFolderId
? [responseExample1.body.parentFolderId]
: [],
});
const responseExample2 =
@@ -152,6 +155,9 @@ describe('Microsoft get messages service', () => {
},
],
attachments: [],
messageFolderExternalIds: responseExample2.body.parentFolderId
? [responseExample2.body.parentFolderId]
: [],
});
});
@@ -191,6 +197,9 @@ describe('Microsoft get messages service', () => {
},
],
attachments: [],
messageFolderExternalIds: responseExample.body.parentFolderId
? [responseExample.body.parentFolderId]
: [],
});
});
});
@@ -1,8 +1,8 @@
import { Injectable, Logger } from '@nestjs/common';
import { type EmailAddress } from 'addressparser';
import { isDefined } from 'twenty-shared/utils';
import { MessageParticipantRole } from 'twenty-shared/types';
import { isDefined } from 'twenty-shared/utils';
import { type ConnectedAccountWorkspaceEntity } from 'src/modules/connected-account/standard-objects/connected-account.workspace-entity';
import { MessageDirection } from 'src/modules/messaging/common/enums/message-direction.enum';
@@ -151,6 +151,9 @@ export class MicrosoftGetMessagesService {
: MessageDirection.INCOMING,
participants,
attachments: [],
messageFolderExternalIds: response.parentFolderId
? [response.parentFolderId]
: [],
};
});
@@ -42,6 +42,7 @@ import { MessagingDeleteGroupEmailMessagesService } from 'src/modules/messaging/
import { MessagingGetMessageListService } from 'src/modules/messaging/message-import-manager/services/messaging-get-message-list.service';
import { MessagingGetMessagesService } from 'src/modules/messaging/message-import-manager/services/messaging-get-messages.service';
import { MessageImportExceptionHandlerService } from 'src/modules/messaging/message-import-manager/services/messaging-import-exception-handler.service';
import { MessagingMessageFolderAssociationService } from 'src/modules/messaging/message-import-manager/services/messaging-message-folder-association.service';
import { MessagingMessageListFetchService } from 'src/modules/messaging/message-import-manager/services/messaging-message-list-fetch.service';
import { MessagingMessageService } from 'src/modules/messaging/message-import-manager/services/messaging-message.service';
import { MessagingMessagesImportService } from 'src/modules/messaging/message-import-manager/services/messaging-messages-import.service';
@@ -93,6 +94,7 @@ import { MessagingMonitoringModule } from 'src/modules/messaging/monitoring/mess
MessagingMessageImportManagerMessageChannelListener,
MessagingCleanCacheJob,
MessagingMessageService,
MessagingMessageFolderAssociationService,
MessagingMessageListFetchService,
MessagingMessagesImportService,
MessagingSaveMessagesAndEnqueueContactCreationService,
@@ -1,12 +1,17 @@
import { Injectable, Logger } from '@nestjs/common';
import chunk from 'lodash.chunk';
import { isDefined } from 'twenty-shared/utils';
import { In } from 'typeorm';
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';
import { type MessageChannelMessageAssociationMessageFolderWorkspaceEntity } from 'src/modules/messaging/common/standard-objects/message-channel-message-association-message-folder.workspace-entity';
import { type MessageChannelMessageAssociationWorkspaceEntity } from 'src/modules/messaging/common/standard-objects/message-channel-message-association.workspace-entity';
import { type MessageChannelWorkspaceEntity } from 'src/modules/messaging/common/standard-objects/message-channel.workspace-entity';
import { type MessageFolderWorkspaceEntity } from 'src/modules/messaging/common/standard-objects/message-folder.workspace-entity';
import { MessagingMessageCleanerService } from 'src/modules/messaging/message-cleaner/services/messaging-message-cleaner.service';
import { MessagingGetMessageListService } from 'src/modules/messaging/message-import-manager/services/messaging-get-message-list.service';
const BATCH_SIZE = 200;
@Injectable()
export class MessagingDeleteFolderMessagesService {
@@ -16,7 +21,7 @@ export class MessagingDeleteFolderMessagesService {
constructor(
private readonly messagingMessageCleanerService: MessagingMessageCleanerService,
private readonly messagingGetMessageListService: MessagingGetMessageListService,
private readonly globalWorkspaceOrmManager: GlobalWorkspaceOrmManager,
) {}
async deleteFolderMessages(
@@ -28,45 +33,81 @@ export class MessagingDeleteFolderMessagesService {
`WorkspaceId: ${workspaceId}, MessageChannelId: ${messageChannel.id}, FolderId: ${messageFolder.id} - Deleting messages from folder: ${messageFolder.name}`,
);
const messageLists =
await this.messagingGetMessageListService.getMessageLists(
messageChannel,
[messageFolder],
);
const authContext = buildSystemAuthContext(workspaceId);
let totalDeletedCount = 0;
for (const messageList of messageLists) {
const { messageExternalIds } = messageList;
await this.globalWorkspaceOrmManager.executeInWorkspaceContext(async () => {
const messageFolderAssociationRepository =
await this.globalWorkspaceOrmManager.getRepository<MessageChannelMessageAssociationMessageFolderWorkspaceEntity>(
workspaceId,
'messageChannelMessageAssociationMessageFolder',
);
if (messageExternalIds.length === 0) {
continue;
}
const messageChannelMessageAssociationRepository =
await this.globalWorkspaceOrmManager.getRepository<MessageChannelMessageAssociationWorkspaceEntity>(
workspaceId,
'messageChannelMessageAssociation',
);
const messageExternalIdsChunks = chunk(messageExternalIds, 200);
let hasMoreData = true;
for (const messageExternalIdsChunk of messageExternalIdsChunks) {
const validExternalIds = messageExternalIdsChunk.filter(isDefined);
while (hasMoreData) {
const folderAssociations =
await messageFolderAssociationRepository.find({
where: {
messageFolderId: messageFolder.id,
},
take: BATCH_SIZE,
});
if (validExternalIds.length === 0) {
if (folderAssociations.length === 0) {
hasMoreData = false;
continue;
}
await this.messagingMessageCleanerService.deleteMessagesChannelMessageAssociationsAndRelatedOrphans(
{
workspaceId,
messageExternalIds: validExternalIds,
messageChannelId: messageChannel.id,
},
const folderAssociationIds = folderAssociations.map(
(folderAssociation) => folderAssociation.id,
);
totalDeletedCount += validExternalIds.length;
this.logger.debug(
`WorkspaceId: ${workspaceId}, MessageChannelId: ${messageChannel.id}, FolderId: ${messageFolder.id} - Processed ${validExternalIds.length} message deletions`,
const messageChannelMessageAssociationIds = folderAssociations.map(
(folderAssociation) =>
folderAssociation.messageChannelMessageAssociationId,
);
const associations =
await messageChannelMessageAssociationRepository.find({
where: {
id: In(messageChannelMessageAssociationIds),
messageChannelId: messageChannel.id,
},
});
const messageExternalIds = associations
.map((association) => association.messageExternalId)
.filter(isDefined);
this.logger.log(
`WorkspaceId: ${workspaceId}, MessageChannelId: ${messageChannel.id}, FolderId: ${messageFolder.id} - Deleting ${messageExternalIds.length} messages`,
);
if (messageExternalIds.length > 0) {
await this.messagingMessageCleanerService.deleteMessagesChannelMessageAssociationsAndRelatedOrphans(
{
workspaceId,
messageExternalIds,
messageChannelId: messageChannel.id,
},
);
totalDeletedCount += messageExternalIds.length;
}
await messageFolderAssociationRepository.delete({
id: In(folderAssociationIds),
});
}
}
}, authContext);
this.logger.log(
`WorkspaceId: ${workspaceId}, MessageChannelId: ${messageChannel.id}, FolderId: ${messageFolder.id} - Completed deleting ${totalDeletedCount} messages from folder: ${messageFolder.name}`,
@@ -2,7 +2,7 @@ import { Injectable } from '@nestjs/common';
import { ConnectedAccountProvider } from 'twenty-shared/types';
import { MessageFolder } from 'src/modules/messaging/message-folder-manager/interfaces/message-folder-driver.interface';
import { type MessageFolder } from 'src/modules/messaging/message-folder-manager/interfaces/message-folder-driver.interface';
import { type MessageChannelWorkspaceEntity } from 'src/modules/messaging/common/standard-objects/message-channel.workspace-entity';
import {
@@ -0,0 +1,85 @@
import { Injectable } from '@nestjs/common';
import { In } from 'typeorm';
import { type WorkspaceEntityManager } from 'src/engine/twenty-orm/entity-manager/workspace-entity-manager';
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';
import { type MessageChannelMessageAssociationMessageFolderWorkspaceEntity } from 'src/modules/messaging/common/standard-objects/message-channel-message-association-message-folder.workspace-entity';
export type MessageChannelMessageAssociationFolderAssociation = {
messageChannelMessageAssociationId: string;
messageFolderIds: string[];
};
@Injectable()
export class MessagingMessageFolderAssociationService {
constructor(
private readonly globalWorkspaceOrmManager: GlobalWorkspaceOrmManager,
) {}
async saveMessageFolderAssociations(
associations: MessageChannelMessageAssociationFolderAssociation[],
workspaceId: string,
transactionManager?: WorkspaceEntityManager,
): Promise<void> {
if (associations.length === 0) {
return;
}
const authContext = buildSystemAuthContext(workspaceId);
await this.globalWorkspaceOrmManager.executeInWorkspaceContext(async () => {
const repository =
await this.globalWorkspaceOrmManager.getRepository<MessageChannelMessageAssociationMessageFolderWorkspaceEntity>(
workspaceId,
'messageChannelMessageAssociationMessageFolder',
);
const records = associations.flatMap((association) =>
association.messageFolderIds.map((folderId) => ({
messageChannelMessageAssociationId:
association.messageChannelMessageAssociationId,
messageFolderId: folderId,
})),
);
if (records.length === 0) {
return;
}
const associationIds = [
...new Set(
records.map((record) => record.messageChannelMessageAssociationId),
),
];
const existingRecords = await repository.find(
{
where: {
messageChannelMessageAssociationId: In(associationIds),
},
},
transactionManager,
);
const existingKeys = new Set(
existingRecords.map(
(record) =>
`${record.messageChannelMessageAssociationId}:${record.messageFolderId}`,
),
);
const recordsToInsert = records.filter(
(record) =>
!existingKeys.has(
`${record.messageChannelMessageAssociationId}:${record.messageFolderId}`,
),
);
if (recordsToInsert.length > 0) {
await repository.insert(recordsToInsert, transactionManager);
}
}, authContext);
}
}
@@ -28,6 +28,7 @@ type MessageAccumulator = {
threadToCreate?: Pick<MessageThreadWorkspaceEntity, 'id'>;
messageChannelMessageAssociationToCreate?: Pick<
MessageChannelMessageAssociationWorkspaceEntity,
| 'id'
| 'messageChannelId'
| 'messageId'
| 'messageExternalId'
@@ -51,6 +52,10 @@ export class MessagingMessageService {
): Promise<{
createdMessages: Partial<MessageWorkspaceEntity>[];
messageExternalIdsAndIdsMap: Map<string, string>;
messageExternalIdToMessageChannelMessageAssociationIdMap: Map<
string,
string
>;
}> {
const authContext = buildSystemAuthContext(workspaceId);
@@ -176,15 +181,16 @@ export class MessagingMessageService {
)
) {
messageAccumulator.messageChannelMessageAssociationToCreate = {
id: v4(),
messageChannelId,
messageId: newOrExistingMessageId,
messageExternalId: message.externalId,
messageThreadExternalId: message.messageThreadExternalId,
direction: message.direction,
};
messageAccumulatorMap.set(message.externalId, messageAccumulator);
}
messageAccumulatorMap.set(message.externalId, messageAccumulator);
}
const messageThreadsToCreate = Array.from(
@@ -219,6 +225,8 @@ export class MessagingMessageService {
);
const messageExternalIdsAndIdsMap = new Map<string, string>();
const messageExternalIdToMessageChannelMessageAssociationIdMap =
new Map<string, string>();
for (const [
externalId,
@@ -237,11 +245,25 @@ export class MessagingMessageService {
accumulator.existingMessageInDB.id,
);
}
const createdAssociationId =
accumulator.messageChannelMessageAssociationToCreate?.id;
const existingAssociationId =
accumulator.existingMessageChannelMessageAssociationInDB?.id;
const associationId = createdAssociationId ?? existingAssociationId;
if (isDefined(associationId)) {
messageExternalIdToMessageChannelMessageAssociationIdMap.set(
externalId,
associationId,
);
}
}
return {
createdMessages: messagesToCreate,
messageExternalIdsAndIdsMap,
messageExternalIdToMessageChannelMessageAssociationIdMap,
};
},
authContext,
@@ -124,6 +124,25 @@ export class MessagingMessagesImportService {
messageChannel,
);
// Map external folder IDs to internal folder IDs
const messageFolders = messageChannel.messageFolders ?? [];
const foldersWithExternalId = messageFolders.filter(
(folder): folder is typeof folder & { externalId: string } =>
isDefined(folder.externalId),
);
const folderExternalToInternalMap = new Map<string, string>(
foldersWithExternalId.map((folder) => [folder.externalId, folder.id]),
);
for (const message of allMessages) {
const externalFolderIds = message.messageFolderExternalIds ?? [];
message.messageFolderIds = externalFolderIds
.map((externalId) => folderExternalToInternalMap.get(externalId))
.filter(isDefined);
}
const blocklist = await this.blocklistRepository.getByWorkspaceMemberId(
connectedAccountWithFreshTokens.accountOwnerId,
workspaceId,
@@ -15,6 +15,7 @@ import {
MessageChannelContactAutoCreationPolicy,
type MessageChannelWorkspaceEntity,
} from 'src/modules/messaging/common/standard-objects/message-channel.workspace-entity';
import { MessagingMessageFolderAssociationService } from 'src/modules/messaging/message-import-manager/services/messaging-message-folder-association.service';
import { MessagingMessageService } from 'src/modules/messaging/message-import-manager/services/messaging-message.service';
import { MessagingSaveMessagesAndEnqueueContactCreationService } from 'src/modules/messaging/message-import-manager/services/messaging-save-messages-and-enqueue-contact-creation.service';
import { type MessageWithParticipants } from 'src/modules/messaging/message-import-manager/types/message';
@@ -151,6 +152,14 @@ describe('MessagingSaveMessagesAndEnqueueContactCreationService', () => {
saveMessageParticipants: jest.fn().mockResolvedValue(undefined),
},
},
{
provide: MessagingMessageFolderAssociationService,
useValue: {
saveMessageFolderAssociations: jest
.fn()
.mockResolvedValue(undefined),
},
},
{
provide: GlobalWorkspaceOrmManager,
useValue: {
@@ -1,6 +1,7 @@
import { Injectable } from '@nestjs/common';
import { FieldActorSource, MessageParticipantRole } from 'twenty-shared/types';
import { isDefined } from 'twenty-shared/utils';
import { InjectMessageQueue } from 'src/engine/core-modules/message-queue/decorators/message-queue.decorator';
import { MessageQueue } from 'src/engine/core-modules/message-queue/message-queue.constants';
@@ -21,6 +22,10 @@ import {
type Participant,
type ParticipantWithMessageId,
} from 'src/modules/messaging/message-import-manager/drivers/gmail/types/gmail-message.type';
import {
type MessageChannelMessageAssociationFolderAssociation,
MessagingMessageFolderAssociationService,
} from 'src/modules/messaging/message-import-manager/services/messaging-message-folder-association.service';
import { MessagingMessageService } from 'src/modules/messaging/message-import-manager/services/messaging-message.service';
import { type MessageWithParticipants } from 'src/modules/messaging/message-import-manager/types/message';
import { MessagingMessageParticipantService } from 'src/modules/messaging/message-participant-manager/services/messaging-message-participant.service';
@@ -33,6 +38,7 @@ export class MessagingSaveMessagesAndEnqueueContactCreationService {
private readonly messageQueueService: MessageQueueService,
private readonly messageService: MessagingMessageService,
private readonly messageParticipantService: MessagingMessageParticipantService,
private readonly messageFolderAssociationService: MessagingMessageFolderAssociationService,
private readonly globalWorkspaceOrmManager: GlobalWorkspaceOrmManager,
) {}
@@ -53,13 +59,15 @@ export class MessagingSaveMessagesAndEnqueueContactCreationService {
return workspaceDataSource?.transaction(
async (transactionManager: WorkspaceEntityManager) => {
const { messageExternalIdsAndIdsMap } =
await this.messageService.saveMessagesWithinTransaction(
messagesToSave,
messageChannel.id,
transactionManager,
workspaceId,
);
const {
messageExternalIdsAndIdsMap,
messageExternalIdToMessageChannelMessageAssociationIdMap,
} = await this.messageService.saveMessagesWithinTransaction(
messagesToSave,
messageChannel.id,
transactionManager,
workspaceId,
);
const participantsWithMessageId: (ParticipantWithMessageId & {
shouldCreateContact: boolean;
@@ -112,6 +120,37 @@ export class MessagingSaveMessagesAndEnqueueContactCreationService {
transactionManager,
);
const folderAssociations: MessageChannelMessageAssociationFolderAssociation[] =
messagesToSave.flatMap((message) => {
const messageFolderIds = message.messageFolderIds ?? [];
if (messageFolderIds.length === 0) {
return [];
}
const associationId =
messageExternalIdToMessageChannelMessageAssociationIdMap.get(
message.externalId,
);
if (!isDefined(associationId)) {
return [];
}
return [
{
messageChannelMessageAssociationId: associationId,
messageFolderIds,
},
];
});
await this.messageFolderAssociationService.saveMessageFolderAssociations(
folderAssociations,
workspaceId,
transactionManager,
);
return participantsWithMessageId;
},
);
@@ -11,6 +11,7 @@ export type Message = Omit<
| 'messageParticipants'
| 'messageThread'
| 'messageThreadId'
| 'messageFolders'
| 'id'
> & {
attachments: {
@@ -19,6 +20,8 @@ export type Message = Omit<
externalId: string;
messageThreadExternalId: string;
direction: MessageDirection;
messageFolderIds?: string[];
messageFolderExternalIds?: string[];
labelIds?: string[];
};
@@ -780,6 +780,9 @@ export const STANDARD_OBJECTS = {
direction: {
universalIdentifier: '75c9b0f7-9e76-44d4-a2f9-47051e61eec7',
},
messageFolders: {
universalIdentifier: '20202020-c3d4-e5f6-a7b8-901234567890',
},
},
indexes: {
messageChannelIdIndex: {
@@ -793,6 +796,40 @@ export const STANDARD_OBJECTS = {
},
},
},
messageChannelMessageAssociationMessageFolder: {
universalIdentifier: '20202020-a1b0-40b0-8ab0-5b6c7d8e9f0a',
fields: {
id: {
universalIdentifier: '20202020-a1b2-40b1-8ab1-6b7c8d9e0f1a',
},
createdAt: {
universalIdentifier: '20202020-a1b3-40b2-9bb2-7c8d9e0f1a2b',
},
updatedAt: {
universalIdentifier: '20202020-a1b4-40b3-8cb3-8d9e0f1a2b3c',
},
deletedAt: {
universalIdentifier: '20202020-a1b5-40b4-9db4-9e0f1a2b3c4d',
},
messageChannelMessageAssociation: {
universalIdentifier: '20202020-d4e5-f6a7-b8c9-012345678901',
},
messageFolder: {
universalIdentifier: '20202020-e5f6-a7b8-c9d0-123456789012',
},
},
indexes: {
messageChannelMessageAssociationIdIndex: {
universalIdentifier: '2b38d3e7-5779-4c1f-4e0f-78c9d70d1de9',
},
messageFolderIdIndex: {
universalIdentifier: '3c49e4f8-6880-4d2a-5f1a-89d0e81e2ef0',
},
messageChannelMessageAssociationIdMessageFolderIdUniqueIndex: {
universalIdentifier: '4d50f5a9-7991-4e3b-6a2b-90e1f92f3f01',
},
},
},
messageChannel: {
universalIdentifier: '20202020-fe8c-40bc-a681-b80b771449b7',
fields: {
@@ -906,6 +943,9 @@ export const STANDARD_OBJECTS = {
pendingSyncAction: {
universalIdentifier: '20202020-4f97-4c79-9517-16387fe237f7',
},
messageChannelMessageAssociationMessageFolders: {
universalIdentifier: '20202020-f6a7-b8c9-d0e1-234567890123',
},
},
indexes: {
messageChannelIdIndex: {