feat: migrate ConnectedAccount infrastructure entities to metadata schema (#18784)
## Summary - Migrates 4 entities (`connectedAccount`, `messageChannel`, `calendarChannel`, `messageFolder`) from per-workspace schemas to the shared `core` metadata schema - Introduces a `IS_CONNECTED_ACCOUNT_MIGRATED` feature flag to control the migration: when enabled, reads come from core metadata and all writes are dual-written to both workspace and core - Extracts 12 enums from workspace entity files to `twenty-shared` for reuse across frontend and backend - Creates new TypeORM entities, metadata services, GraphQL resolvers/DTOs, and exception interceptors per entity - Each entity owns its own data access module (`ConnectedAccountDataAccessModule`, `MessageChannelDataAccessModule`, `CalendarChannelDataAccessModule`, `MessageFolderDataAccessModule`) — no umbrella infrastructure module - Adds a 1.20 upgrade command that backfills data from workspace schemas to core (preserving UUIDs) and enables the feature flag - Replaces direct repository access with data access service calls across ~50 files in messaging, calendar, and connected-account modules - Adds `lastSignedInAt` and `oidcTokenClaims` fields to the new `ConnectedAccountEntity` - Drops unused `lastSyncHistoryId` field from the migrated connected account entity ## Test plan - [x] Lint passes (`npx nx lint:diff-with-main twenty-server`) - [x] Typecheck passes (`npx nx typecheck twenty-server`) - [x] All unit tests pass (477 suites, 4267 tests, 0 failures) - [ ] Manual test: verify messaging sync works with feature flag disabled (existing behavior) - [ ] Manual test: run upgrade command on a workspace, verify data backfilled to core tables - [ ] Manual test: verify messaging/calendar sync works with feature flag enabled (dual-write path) - [ ] Manual test: verify GraphQL metadata resolvers return correct data when flag enabled
This commit is contained in:
+18
@@ -0,0 +1,18 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
|
||||
import { FeatureFlagModule } from 'src/engine/core-modules/feature-flag/feature-flag.module';
|
||||
import { ConnectedAccountDataAccessModule } from 'src/engine/metadata-modules/connected-account/data-access/connected-account-data-access.module';
|
||||
import { MessageChannelDataAccessService } from 'src/engine/metadata-modules/message-channel/data-access/services/message-channel-data-access.service';
|
||||
import { MessageChannelEntity } from 'src/engine/metadata-modules/message-channel/entities/message-channel.entity';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
TypeOrmModule.forFeature([MessageChannelEntity]),
|
||||
FeatureFlagModule,
|
||||
ConnectedAccountDataAccessModule,
|
||||
],
|
||||
providers: [MessageChannelDataAccessService],
|
||||
exports: [MessageChannelDataAccessService],
|
||||
})
|
||||
export class MessageChannelDataAccessModule {}
|
||||
+280
@@ -0,0 +1,280 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
|
||||
import { FeatureFlagKey } from 'twenty-shared/types';
|
||||
import {
|
||||
type FindManyOptions,
|
||||
type FindOneOptions,
|
||||
type FindOptionsWhere,
|
||||
Repository,
|
||||
} from 'typeorm';
|
||||
|
||||
import { FeatureFlagService } from 'src/engine/core-modules/feature-flag/services/feature-flag.service';
|
||||
import { ConnectedAccountDataAccessService } from 'src/engine/metadata-modules/connected-account/data-access/services/connected-account-data-access.service';
|
||||
import { MessageChannelEntity } from 'src/engine/metadata-modules/message-channel/entities/message-channel.entity';
|
||||
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 { type MessageChannelWorkspaceEntity } from 'src/modules/messaging/common/standard-objects/message-channel.workspace-entity';
|
||||
|
||||
@Injectable()
|
||||
export class MessageChannelDataAccessService {
|
||||
private readonly logger = new Logger(MessageChannelDataAccessService.name);
|
||||
|
||||
constructor(
|
||||
@InjectRepository(MessageChannelEntity)
|
||||
private readonly coreRepository: Repository<MessageChannelEntity>,
|
||||
private readonly featureFlagService: FeatureFlagService,
|
||||
private readonly globalWorkspaceOrmManager: GlobalWorkspaceOrmManager,
|
||||
private readonly connectedAccountDataAccessService: ConnectedAccountDataAccessService,
|
||||
) {}
|
||||
|
||||
private async isMigrated(workspaceId: string): Promise<boolean> {
|
||||
return this.featureFlagService.isFeatureEnabled(
|
||||
FeatureFlagKey.IS_CONNECTED_ACCOUNT_MIGRATED,
|
||||
workspaceId,
|
||||
);
|
||||
}
|
||||
|
||||
private async toCoreWhere(
|
||||
workspaceId: string,
|
||||
where: Record<string, unknown>,
|
||||
): Promise<Record<string, unknown>> {
|
||||
const coreWhere: Record<string, unknown> = { ...where, workspaceId };
|
||||
|
||||
if (
|
||||
coreWhere.connectedAccount &&
|
||||
typeof coreWhere.connectedAccount === 'object'
|
||||
) {
|
||||
const connectedAccountWhere = {
|
||||
...(coreWhere.connectedAccount as Record<string, unknown>),
|
||||
};
|
||||
|
||||
if ('accountOwnerId' in connectedAccountWhere) {
|
||||
const { accountOwnerId, ...restConnectedAccount } =
|
||||
connectedAccountWhere;
|
||||
|
||||
const resolvedConnectedAccounts =
|
||||
await this.connectedAccountDataAccessService.find(workspaceId, {
|
||||
accountOwnerId,
|
||||
} as never);
|
||||
|
||||
if (resolvedConnectedAccounts.length > 0) {
|
||||
coreWhere.connectedAccountId = resolvedConnectedAccounts[0].id;
|
||||
} else {
|
||||
coreWhere.connectedAccountId = '00000000-0000-0000-0000-000000000000';
|
||||
}
|
||||
|
||||
if (Object.keys(restConnectedAccount).length > 0) {
|
||||
coreWhere.connectedAccount = restConnectedAccount;
|
||||
} else {
|
||||
delete coreWhere.connectedAccount;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return coreWhere;
|
||||
}
|
||||
|
||||
async getWorkspaceRepository(workspaceId: string) {
|
||||
return this.globalWorkspaceOrmManager.getRepository<MessageChannelWorkspaceEntity>(
|
||||
workspaceId,
|
||||
'messageChannel',
|
||||
);
|
||||
}
|
||||
|
||||
async findOne(
|
||||
workspaceId: string,
|
||||
options: FindOneOptions<MessageChannelWorkspaceEntity>,
|
||||
): Promise<MessageChannelWorkspaceEntity | null> {
|
||||
if (await this.isMigrated(workspaceId)) {
|
||||
const where = options.where as Record<string, unknown>;
|
||||
const coreWhere = Array.isArray(where)
|
||||
? await Promise.all(
|
||||
where.map((whereItem: Record<string, unknown>) =>
|
||||
this.toCoreWhere(workspaceId, whereItem),
|
||||
),
|
||||
)
|
||||
: await this.toCoreWhere(workspaceId, where);
|
||||
|
||||
return this.coreRepository.findOne({
|
||||
...options,
|
||||
where: coreWhere,
|
||||
} as FindOneOptions<MessageChannelEntity>) as unknown as Promise<MessageChannelWorkspaceEntity | null>;
|
||||
}
|
||||
|
||||
const workspaceRepository = await this.getWorkspaceRepository(workspaceId);
|
||||
|
||||
return workspaceRepository.findOne(options);
|
||||
}
|
||||
|
||||
async find(
|
||||
workspaceId: string,
|
||||
where?: FindOptionsWhere<MessageChannelWorkspaceEntity>,
|
||||
): Promise<MessageChannelWorkspaceEntity[]> {
|
||||
if (await this.isMigrated(workspaceId)) {
|
||||
return this.coreRepository.find({
|
||||
where: {
|
||||
...(where as Record<string, unknown>),
|
||||
workspaceId,
|
||||
} as FindOptionsWhere<MessageChannelEntity>,
|
||||
}) as unknown as Promise<MessageChannelWorkspaceEntity[]>;
|
||||
}
|
||||
|
||||
const workspaceRepository = await this.getWorkspaceRepository(workspaceId);
|
||||
|
||||
return workspaceRepository.find({ where });
|
||||
}
|
||||
|
||||
async findMany(
|
||||
workspaceId: string,
|
||||
options: FindManyOptions<MessageChannelWorkspaceEntity>,
|
||||
): Promise<MessageChannelWorkspaceEntity[]> {
|
||||
if (await this.isMigrated(workspaceId)) {
|
||||
const baseWhere = options.where;
|
||||
|
||||
if (!baseWhere) {
|
||||
return this.coreRepository.find({
|
||||
...options,
|
||||
where: { workspaceId },
|
||||
} as FindManyOptions<MessageChannelEntity>) as unknown as Promise<
|
||||
MessageChannelWorkspaceEntity[]
|
||||
>;
|
||||
}
|
||||
|
||||
if (Array.isArray(baseWhere)) {
|
||||
const coreWhereArray = await Promise.all(
|
||||
baseWhere.map((whereItem) =>
|
||||
this.toCoreWhere(workspaceId, whereItem as Record<string, unknown>),
|
||||
),
|
||||
);
|
||||
|
||||
return this.coreRepository.find({
|
||||
...options,
|
||||
where: coreWhereArray,
|
||||
} as FindManyOptions<MessageChannelEntity>) as unknown as Promise<
|
||||
MessageChannelWorkspaceEntity[]
|
||||
>;
|
||||
}
|
||||
|
||||
const coreWhere = await this.toCoreWhere(
|
||||
workspaceId,
|
||||
baseWhere as Record<string, unknown>,
|
||||
);
|
||||
|
||||
return this.coreRepository.find({
|
||||
...options,
|
||||
where: coreWhere,
|
||||
} as FindManyOptions<MessageChannelEntity>) as unknown as Promise<
|
||||
MessageChannelWorkspaceEntity[]
|
||||
>;
|
||||
}
|
||||
|
||||
const workspaceRepository = await this.getWorkspaceRepository(workspaceId);
|
||||
|
||||
return workspaceRepository.find(options);
|
||||
}
|
||||
|
||||
async save(
|
||||
workspaceId: string,
|
||||
data: Partial<MessageChannelWorkspaceEntity>,
|
||||
manager?: WorkspaceEntityManager,
|
||||
): Promise<void> {
|
||||
const workspaceRepository = await this.getWorkspaceRepository(workspaceId);
|
||||
|
||||
await workspaceRepository.save(data, {}, manager);
|
||||
|
||||
if (await this.isMigrated(workspaceId)) {
|
||||
try {
|
||||
await this.coreRepository.save({
|
||||
...data,
|
||||
workspaceId,
|
||||
} as unknown as MessageChannelEntity);
|
||||
} catch (error) {
|
||||
this.logger.error(
|
||||
`Failed to dual-write messageChannel to core: ${error}`,
|
||||
);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async update(
|
||||
workspaceId: string,
|
||||
where: FindOptionsWhere<MessageChannelWorkspaceEntity>,
|
||||
data: Partial<MessageChannelWorkspaceEntity>,
|
||||
manager?: WorkspaceEntityManager,
|
||||
): Promise<void> {
|
||||
const workspaceRepository = await this.getWorkspaceRepository(workspaceId);
|
||||
|
||||
await workspaceRepository.update(where, data, manager);
|
||||
|
||||
if (await this.isMigrated(workspaceId)) {
|
||||
try {
|
||||
await this.coreRepository.update(
|
||||
{ ...where, workspaceId } as FindOptionsWhere<MessageChannelEntity>,
|
||||
data as never,
|
||||
);
|
||||
} catch (error) {
|
||||
this.logger.error(
|
||||
`Failed to dual-write messageChannel update to core: ${error}`,
|
||||
);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async increment(
|
||||
workspaceId: string,
|
||||
where: FindOptionsWhere<MessageChannelWorkspaceEntity>,
|
||||
propertyPath: string,
|
||||
value: number,
|
||||
): Promise<void> {
|
||||
const workspaceRepository = await this.getWorkspaceRepository(workspaceId);
|
||||
|
||||
await workspaceRepository.increment(where, propertyPath, value, undefined, [
|
||||
propertyPath,
|
||||
'id',
|
||||
]);
|
||||
|
||||
if (await this.isMigrated(workspaceId)) {
|
||||
try {
|
||||
await this.coreRepository.increment(
|
||||
{
|
||||
...where,
|
||||
workspaceId,
|
||||
} as FindOptionsWhere<MessageChannelEntity>,
|
||||
propertyPath,
|
||||
value,
|
||||
);
|
||||
} catch (error) {
|
||||
this.logger.error(
|
||||
`Failed to dual-write messageChannel increment to core: ${error}`,
|
||||
);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async delete(
|
||||
workspaceId: string,
|
||||
where: FindOptionsWhere<MessageChannelWorkspaceEntity>,
|
||||
): Promise<void> {
|
||||
const workspaceRepository = await this.getWorkspaceRepository(workspaceId);
|
||||
|
||||
await workspaceRepository.delete(where);
|
||||
|
||||
if (await this.isMigrated(workspaceId)) {
|
||||
try {
|
||||
await this.coreRepository.delete({
|
||||
...where,
|
||||
workspaceId,
|
||||
} as FindOptionsWhere<MessageChannelEntity>);
|
||||
} catch (error) {
|
||||
this.logger.error(
|
||||
`Failed to dual-write messageChannel delete to core: ${error}`,
|
||||
);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+88
@@ -0,0 +1,88 @@
|
||||
import { Field, InputType } from '@nestjs/graphql';
|
||||
|
||||
import {
|
||||
IsBoolean,
|
||||
IsEnum,
|
||||
IsNotEmpty,
|
||||
IsOptional,
|
||||
IsString,
|
||||
IsUUID,
|
||||
} from 'class-validator';
|
||||
import {
|
||||
MessageChannelContactAutoCreationPolicy,
|
||||
MessageChannelPendingGroupEmailsAction,
|
||||
MessageChannelSyncStage,
|
||||
MessageChannelType,
|
||||
MessageChannelVisibility,
|
||||
MessageFolderImportPolicy,
|
||||
} from 'twenty-shared/types';
|
||||
|
||||
import { UUIDScalarType } from 'src/engine/api/graphql/workspace-schema-builder/graphql-types/scalars';
|
||||
|
||||
@InputType()
|
||||
export class CreateMessageChannelInput {
|
||||
@IsUUID()
|
||||
@IsOptional()
|
||||
@Field(() => UUIDScalarType, { nullable: true })
|
||||
id?: string;
|
||||
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
@Field()
|
||||
handle: string;
|
||||
|
||||
@IsEnum(MessageChannelVisibility)
|
||||
@IsNotEmpty()
|
||||
@Field(() => MessageChannelVisibility)
|
||||
visibility: MessageChannelVisibility;
|
||||
|
||||
@IsEnum(MessageChannelType)
|
||||
@IsNotEmpty()
|
||||
@Field(() => MessageChannelType)
|
||||
type: MessageChannelType;
|
||||
|
||||
@IsEnum(MessageChannelSyncStage)
|
||||
@IsNotEmpty()
|
||||
@Field(() => MessageChannelSyncStage)
|
||||
syncStage: MessageChannelSyncStage;
|
||||
|
||||
@IsUUID()
|
||||
@IsNotEmpty()
|
||||
@Field(() => UUIDScalarType)
|
||||
connectedAccountId: string;
|
||||
|
||||
@IsBoolean()
|
||||
@IsNotEmpty()
|
||||
@Field()
|
||||
isContactAutoCreationEnabled: boolean;
|
||||
|
||||
@IsEnum(MessageChannelContactAutoCreationPolicy)
|
||||
@IsNotEmpty()
|
||||
@Field(() => MessageChannelContactAutoCreationPolicy)
|
||||
contactAutoCreationPolicy: MessageChannelContactAutoCreationPolicy;
|
||||
|
||||
@IsEnum(MessageFolderImportPolicy)
|
||||
@IsNotEmpty()
|
||||
@Field(() => MessageFolderImportPolicy)
|
||||
messageFolderImportPolicy: MessageFolderImportPolicy;
|
||||
|
||||
@IsBoolean()
|
||||
@IsNotEmpty()
|
||||
@Field()
|
||||
excludeNonProfessionalEmails: boolean;
|
||||
|
||||
@IsBoolean()
|
||||
@IsNotEmpty()
|
||||
@Field()
|
||||
excludeGroupEmails: boolean;
|
||||
|
||||
@IsEnum(MessageChannelPendingGroupEmailsAction)
|
||||
@IsNotEmpty()
|
||||
@Field(() => MessageChannelPendingGroupEmailsAction)
|
||||
pendingGroupEmailsAction: MessageChannelPendingGroupEmailsAction;
|
||||
|
||||
@IsBoolean()
|
||||
@IsNotEmpty()
|
||||
@Field()
|
||||
isSyncEnabled: boolean;
|
||||
}
|
||||
+127
@@ -0,0 +1,127 @@
|
||||
import { Field, HideField, ObjectType } from '@nestjs/graphql';
|
||||
|
||||
import {
|
||||
IsBoolean,
|
||||
IsDateString,
|
||||
IsEnum,
|
||||
IsInt,
|
||||
IsNotEmpty,
|
||||
IsOptional,
|
||||
IsString,
|
||||
IsUUID,
|
||||
} from 'class-validator';
|
||||
import {
|
||||
MessageChannelContactAutoCreationPolicy,
|
||||
MessageChannelPendingGroupEmailsAction,
|
||||
MessageChannelSyncStage,
|
||||
MessageChannelSyncStatus,
|
||||
MessageChannelType,
|
||||
MessageChannelVisibility,
|
||||
MessageFolderImportPolicy,
|
||||
} from 'twenty-shared/types';
|
||||
|
||||
import { UUIDScalarType } from 'src/engine/api/graphql/workspace-schema-builder/graphql-types/scalars';
|
||||
|
||||
@ObjectType('MessageChannel')
|
||||
export class MessageChannelDTO {
|
||||
@IsUUID()
|
||||
@IsNotEmpty()
|
||||
@Field(() => UUIDScalarType)
|
||||
id: string;
|
||||
|
||||
@IsEnum(MessageChannelVisibility)
|
||||
@IsNotEmpty()
|
||||
@Field(() => MessageChannelVisibility)
|
||||
visibility: MessageChannelVisibility;
|
||||
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
@Field()
|
||||
handle: string;
|
||||
|
||||
@IsEnum(MessageChannelType)
|
||||
@IsNotEmpty()
|
||||
@Field(() => MessageChannelType)
|
||||
type: MessageChannelType;
|
||||
|
||||
@IsBoolean()
|
||||
@Field()
|
||||
isContactAutoCreationEnabled: boolean;
|
||||
|
||||
@IsEnum(MessageChannelContactAutoCreationPolicy)
|
||||
@IsNotEmpty()
|
||||
@Field(() => MessageChannelContactAutoCreationPolicy)
|
||||
contactAutoCreationPolicy: MessageChannelContactAutoCreationPolicy;
|
||||
|
||||
@IsEnum(MessageFolderImportPolicy)
|
||||
@IsNotEmpty()
|
||||
@Field(() => MessageFolderImportPolicy)
|
||||
messageFolderImportPolicy: MessageFolderImportPolicy;
|
||||
|
||||
@IsBoolean()
|
||||
@Field()
|
||||
excludeNonProfessionalEmails: boolean;
|
||||
|
||||
@IsBoolean()
|
||||
@Field()
|
||||
excludeGroupEmails: boolean;
|
||||
|
||||
@IsEnum(MessageChannelPendingGroupEmailsAction)
|
||||
@IsNotEmpty()
|
||||
@Field(() => MessageChannelPendingGroupEmailsAction)
|
||||
pendingGroupEmailsAction: MessageChannelPendingGroupEmailsAction;
|
||||
|
||||
@IsBoolean()
|
||||
@Field()
|
||||
isSyncEnabled: boolean;
|
||||
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
@Field(() => String, { nullable: true })
|
||||
syncCursor: string | null;
|
||||
|
||||
@IsDateString()
|
||||
@IsOptional()
|
||||
@Field(() => Date, { nullable: true })
|
||||
syncedAt: Date | null;
|
||||
|
||||
@IsEnum(MessageChannelSyncStatus)
|
||||
@IsNotEmpty()
|
||||
@Field(() => MessageChannelSyncStatus)
|
||||
syncStatus: MessageChannelSyncStatus;
|
||||
|
||||
@IsEnum(MessageChannelSyncStage)
|
||||
@IsNotEmpty()
|
||||
@Field(() => MessageChannelSyncStage)
|
||||
syncStage: MessageChannelSyncStage;
|
||||
|
||||
@IsDateString()
|
||||
@IsOptional()
|
||||
@Field(() => Date, { nullable: true })
|
||||
syncStageStartedAt: Date | null;
|
||||
|
||||
@IsInt()
|
||||
@Field()
|
||||
throttleFailureCount: number;
|
||||
|
||||
@IsDateString()
|
||||
@IsOptional()
|
||||
@Field(() => Date, { nullable: true })
|
||||
throttleRetryAfter: Date | null;
|
||||
|
||||
@IsUUID()
|
||||
@IsNotEmpty()
|
||||
@Field(() => UUIDScalarType)
|
||||
connectedAccountId: string;
|
||||
|
||||
@HideField()
|
||||
workspaceId: string;
|
||||
|
||||
@IsDateString()
|
||||
@Field()
|
||||
createdAt: Date;
|
||||
|
||||
@IsDateString()
|
||||
@Field()
|
||||
updatedAt: Date;
|
||||
}
|
||||
+69
@@ -0,0 +1,69 @@
|
||||
import { Field, InputType } from '@nestjs/graphql';
|
||||
|
||||
import { Type } from 'class-transformer';
|
||||
import {
|
||||
IsBoolean,
|
||||
IsEnum,
|
||||
IsNotEmpty,
|
||||
IsOptional,
|
||||
IsUUID,
|
||||
ValidateNested,
|
||||
} from 'class-validator';
|
||||
import {
|
||||
MessageChannelContactAutoCreationPolicy,
|
||||
MessageChannelVisibility,
|
||||
MessageFolderImportPolicy,
|
||||
} from 'twenty-shared/types';
|
||||
|
||||
import { UUIDScalarType } from 'src/engine/api/graphql/workspace-schema-builder/graphql-types/scalars';
|
||||
|
||||
@InputType()
|
||||
export class UpdateMessageChannelInputUpdates {
|
||||
@IsOptional()
|
||||
@IsEnum(MessageChannelVisibility)
|
||||
@Field(() => MessageChannelVisibility, { nullable: true })
|
||||
visibility?: MessageChannelVisibility;
|
||||
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
@Field({ nullable: true })
|
||||
isContactAutoCreationEnabled?: boolean;
|
||||
|
||||
@IsOptional()
|
||||
@IsEnum(MessageChannelContactAutoCreationPolicy)
|
||||
@Field(() => MessageChannelContactAutoCreationPolicy, { nullable: true })
|
||||
contactAutoCreationPolicy?: MessageChannelContactAutoCreationPolicy;
|
||||
|
||||
@IsOptional()
|
||||
@IsEnum(MessageFolderImportPolicy)
|
||||
@Field(() => MessageFolderImportPolicy, { nullable: true })
|
||||
messageFolderImportPolicy?: MessageFolderImportPolicy;
|
||||
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
@Field({ nullable: true })
|
||||
isSyncEnabled?: boolean;
|
||||
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
@Field({ nullable: true })
|
||||
excludeNonProfessionalEmails?: boolean;
|
||||
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
@Field({ nullable: true })
|
||||
excludeGroupEmails?: boolean;
|
||||
}
|
||||
|
||||
@InputType()
|
||||
export class UpdateMessageChannelInput {
|
||||
@IsUUID()
|
||||
@IsNotEmpty()
|
||||
@Field(() => UUIDScalarType)
|
||||
id: string;
|
||||
|
||||
@Type(() => UpdateMessageChannelInputUpdates)
|
||||
@ValidateNested()
|
||||
@Field(() => UpdateMessageChannelInputUpdates)
|
||||
update: UpdateMessageChannelInputUpdates;
|
||||
}
|
||||
+134
@@ -0,0 +1,134 @@
|
||||
import {
|
||||
Column,
|
||||
CreateDateColumn,
|
||||
Entity,
|
||||
JoinColumn,
|
||||
ManyToOne,
|
||||
OneToMany,
|
||||
PrimaryGeneratedColumn,
|
||||
type Relation,
|
||||
UpdateDateColumn,
|
||||
} from 'typeorm';
|
||||
|
||||
import {
|
||||
MessageChannelContactAutoCreationPolicy,
|
||||
MessageChannelPendingGroupEmailsAction,
|
||||
MessageChannelSyncStage,
|
||||
MessageChannelSyncStatus,
|
||||
MessageChannelType,
|
||||
MessageChannelVisibility,
|
||||
MessageFolderImportPolicy,
|
||||
} from 'twenty-shared/types';
|
||||
|
||||
import { ConnectedAccountEntity } from 'src/engine/metadata-modules/connected-account/entities/connected-account.entity';
|
||||
import { type MessageFolderEntity } from 'src/engine/metadata-modules/message-folder/entities/message-folder.entity';
|
||||
import { WorkspaceRelatedEntity } from 'src/engine/workspace-manager/types/workspace-related-entity';
|
||||
|
||||
@Entity({ name: 'messageChannel', schema: 'core' })
|
||||
export class MessageChannelEntity extends WorkspaceRelatedEntity {
|
||||
@PrimaryGeneratedColumn('uuid')
|
||||
id: string;
|
||||
|
||||
@Column({
|
||||
type: 'enum',
|
||||
enum: MessageChannelVisibility,
|
||||
nullable: false,
|
||||
})
|
||||
visibility: MessageChannelVisibility;
|
||||
|
||||
@Column({ type: 'varchar', nullable: false })
|
||||
handle: string;
|
||||
|
||||
@Column({
|
||||
type: 'enum',
|
||||
enum: MessageChannelType,
|
||||
nullable: false,
|
||||
})
|
||||
type: MessageChannelType;
|
||||
|
||||
@Column({ type: 'boolean', nullable: false })
|
||||
isContactAutoCreationEnabled: boolean;
|
||||
|
||||
@Column({
|
||||
type: 'enum',
|
||||
enum: MessageChannelContactAutoCreationPolicy,
|
||||
nullable: false,
|
||||
})
|
||||
contactAutoCreationPolicy: MessageChannelContactAutoCreationPolicy;
|
||||
|
||||
@Column({
|
||||
type: 'enum',
|
||||
enum: MessageFolderImportPolicy,
|
||||
nullable: false,
|
||||
})
|
||||
messageFolderImportPolicy: MessageFolderImportPolicy;
|
||||
|
||||
@Column({ type: 'boolean', nullable: false })
|
||||
excludeNonProfessionalEmails: boolean;
|
||||
|
||||
@Column({ type: 'boolean', nullable: false })
|
||||
excludeGroupEmails: boolean;
|
||||
|
||||
@Column({
|
||||
type: 'enum',
|
||||
enum: MessageChannelPendingGroupEmailsAction,
|
||||
nullable: false,
|
||||
})
|
||||
pendingGroupEmailsAction: MessageChannelPendingGroupEmailsAction;
|
||||
|
||||
@Column({ type: 'boolean', nullable: false })
|
||||
isSyncEnabled: boolean;
|
||||
|
||||
@Column({ type: 'varchar', nullable: true })
|
||||
syncCursor: string | null;
|
||||
|
||||
@Column({ type: 'timestamptz', nullable: true })
|
||||
syncedAt: Date | null;
|
||||
|
||||
@Column({
|
||||
type: 'enum',
|
||||
enum: MessageChannelSyncStatus,
|
||||
nullable: false,
|
||||
default: MessageChannelSyncStatus.NOT_SYNCED,
|
||||
})
|
||||
syncStatus: MessageChannelSyncStatus;
|
||||
|
||||
@Column({
|
||||
type: 'enum',
|
||||
enum: MessageChannelSyncStage,
|
||||
nullable: false,
|
||||
})
|
||||
syncStage: MessageChannelSyncStage;
|
||||
|
||||
@Column({ type: 'timestamptz', nullable: true })
|
||||
syncStageStartedAt: Date | null;
|
||||
|
||||
@Column({ type: 'integer', nullable: false, default: 0 })
|
||||
throttleFailureCount: number;
|
||||
|
||||
@Column({ type: 'timestamptz', nullable: true })
|
||||
throttleRetryAfter: Date | null;
|
||||
|
||||
@Column({ type: 'uuid', nullable: false })
|
||||
connectedAccountId: string;
|
||||
|
||||
@ManyToOne(
|
||||
() => ConnectedAccountEntity,
|
||||
(connectedAccount) => connectedAccount.messageChannels,
|
||||
{ onDelete: 'CASCADE' },
|
||||
)
|
||||
@JoinColumn({ name: 'connectedAccountId' })
|
||||
connectedAccount: Relation<ConnectedAccountEntity>;
|
||||
|
||||
@OneToMany(
|
||||
'MessageFolderEntity',
|
||||
(messageFolder: MessageFolderEntity) => messageFolder.messageChannel,
|
||||
)
|
||||
messageFolders: Relation<MessageFolderEntity[]>;
|
||||
|
||||
@CreateDateColumn({ type: 'timestamptz' })
|
||||
createdAt: Date;
|
||||
|
||||
@UpdateDateColumn({ type: 'timestamptz' })
|
||||
updatedAt: Date;
|
||||
}
|
||||
+24
@@ -0,0 +1,24 @@
|
||||
import {
|
||||
type CallHandler,
|
||||
type ExecutionContext,
|
||||
Injectable,
|
||||
type NestInterceptor,
|
||||
} from '@nestjs/common';
|
||||
|
||||
import { type Observable, catchError } from 'rxjs';
|
||||
|
||||
import { messageChannelGraphqlApiExceptionHandler } from 'src/engine/metadata-modules/message-channel/utils/message-channel-graphql-api-exception-handler.util';
|
||||
|
||||
@Injectable()
|
||||
export class MessageChannelGraphqlApiExceptionInterceptor
|
||||
implements NestInterceptor
|
||||
{
|
||||
intercept(
|
||||
_context: ExecutionContext,
|
||||
next: CallHandler,
|
||||
): Observable<unknown> {
|
||||
return next
|
||||
.handle()
|
||||
.pipe(catchError(messageChannelGraphqlApiExceptionHandler));
|
||||
}
|
||||
}
|
||||
+26
@@ -0,0 +1,26 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
|
||||
import { AuthModule } from 'src/engine/core-modules/auth/auth.module';
|
||||
import { FeatureFlagModule } from 'src/engine/core-modules/feature-flag/feature-flag.module';
|
||||
import { MessageChannelEntity } from 'src/engine/metadata-modules/message-channel/entities/message-channel.entity';
|
||||
import { MessageChannelGraphqlApiExceptionInterceptor } from 'src/engine/metadata-modules/message-channel/interceptors/message-channel-graphql-api-exception.interceptor';
|
||||
import { MessageChannelMetadataService } from 'src/engine/metadata-modules/message-channel/message-channel-metadata.service';
|
||||
import { MessageChannelResolver } from 'src/engine/metadata-modules/message-channel/resolvers/message-channel.resolver';
|
||||
import { PermissionsModule } from 'src/engine/metadata-modules/permissions/permissions.module';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
TypeOrmModule.forFeature([MessageChannelEntity]),
|
||||
AuthModule,
|
||||
PermissionsModule,
|
||||
FeatureFlagModule,
|
||||
],
|
||||
providers: [
|
||||
MessageChannelMetadataService,
|
||||
MessageChannelResolver,
|
||||
MessageChannelGraphqlApiExceptionInterceptor,
|
||||
],
|
||||
exports: [MessageChannelMetadataService],
|
||||
})
|
||||
export class MessageChannelMetadataModule {}
|
||||
+79
@@ -0,0 +1,79 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
|
||||
import { Repository } from 'typeorm';
|
||||
|
||||
import {
|
||||
MessageChannelSyncStage,
|
||||
MessageChannelType,
|
||||
MessageChannelVisibility,
|
||||
} from 'twenty-shared/types';
|
||||
|
||||
import { MessageChannelDTO } from 'src/engine/metadata-modules/message-channel/dtos/message-channel.dto';
|
||||
import { MessageChannelEntity } from 'src/engine/metadata-modules/message-channel/entities/message-channel.entity';
|
||||
|
||||
@Injectable()
|
||||
export class MessageChannelMetadataService {
|
||||
constructor(
|
||||
@InjectRepository(MessageChannelEntity)
|
||||
private readonly repository: Repository<MessageChannelEntity>,
|
||||
) {}
|
||||
|
||||
async findAll(workspaceId: string): Promise<MessageChannelDTO[]> {
|
||||
return this.repository.find({ where: { workspaceId } });
|
||||
}
|
||||
|
||||
async findByConnectedAccountId(
|
||||
connectedAccountId: string,
|
||||
workspaceId: string,
|
||||
): Promise<MessageChannelDTO[]> {
|
||||
return this.repository.find({
|
||||
where: { connectedAccountId, workspaceId },
|
||||
});
|
||||
}
|
||||
|
||||
async findById(
|
||||
id: string,
|
||||
workspaceId: string,
|
||||
): Promise<MessageChannelDTO | null> {
|
||||
return this.repository.findOne({ where: { id, workspaceId } });
|
||||
}
|
||||
|
||||
async create(
|
||||
data: Partial<MessageChannelEntity> & {
|
||||
workspaceId: string;
|
||||
handle: string;
|
||||
connectedAccountId: string;
|
||||
visibility: MessageChannelVisibility;
|
||||
type: MessageChannelType;
|
||||
syncStage: MessageChannelSyncStage;
|
||||
},
|
||||
): Promise<MessageChannelDTO> {
|
||||
const entity = this.repository.create(data);
|
||||
|
||||
return this.repository.save(entity);
|
||||
}
|
||||
|
||||
async update(
|
||||
id: string,
|
||||
workspaceId: string,
|
||||
data: Partial<MessageChannelEntity>,
|
||||
): Promise<MessageChannelDTO> {
|
||||
await this.repository.update(
|
||||
{ id, workspaceId },
|
||||
data as Record<string, unknown>,
|
||||
);
|
||||
|
||||
return this.repository.findOneOrFail({ where: { id, workspaceId } });
|
||||
}
|
||||
|
||||
async delete(id: string, workspaceId: string): Promise<MessageChannelDTO> {
|
||||
const entity = await this.repository.findOneOrFail({
|
||||
where: { id, workspaceId },
|
||||
});
|
||||
|
||||
await this.repository.delete({ id, workspaceId });
|
||||
|
||||
return entity;
|
||||
}
|
||||
}
|
||||
+37
@@ -0,0 +1,37 @@
|
||||
import { type MessageDescriptor } from '@lingui/core';
|
||||
import { msg } from '@lingui/core/macro';
|
||||
import { assertUnreachable } from 'twenty-shared/utils';
|
||||
|
||||
import { CustomException } from 'src/utils/custom-exception';
|
||||
|
||||
export enum MessageChannelExceptionCode {
|
||||
MESSAGE_CHANNEL_NOT_FOUND = 'MESSAGE_CHANNEL_NOT_FOUND',
|
||||
INVALID_MESSAGE_CHANNEL_INPUT = 'INVALID_MESSAGE_CHANNEL_INPUT',
|
||||
}
|
||||
|
||||
const getMessageChannelExceptionUserFriendlyMessage = (
|
||||
code: MessageChannelExceptionCode,
|
||||
) => {
|
||||
switch (code) {
|
||||
case MessageChannelExceptionCode.MESSAGE_CHANNEL_NOT_FOUND:
|
||||
return msg`Message channel not found.`;
|
||||
case MessageChannelExceptionCode.INVALID_MESSAGE_CHANNEL_INPUT:
|
||||
return msg`Invalid message channel input.`;
|
||||
default:
|
||||
assertUnreachable(code);
|
||||
}
|
||||
};
|
||||
|
||||
export class MessageChannelException extends CustomException<MessageChannelExceptionCode> {
|
||||
constructor(
|
||||
message: string,
|
||||
code: MessageChannelExceptionCode,
|
||||
{ userFriendlyMessage }: { userFriendlyMessage?: MessageDescriptor } = {},
|
||||
) {
|
||||
super(message, code, {
|
||||
userFriendlyMessage:
|
||||
userFriendlyMessage ??
|
||||
getMessageChannelExceptionUserFriendlyMessage(code),
|
||||
});
|
||||
}
|
||||
}
|
||||
+98
@@ -0,0 +1,98 @@
|
||||
import { UseGuards, UseInterceptors } from '@nestjs/common';
|
||||
import { Args, Mutation, Query } from '@nestjs/graphql';
|
||||
|
||||
import { PermissionFlagType } from 'twenty-shared/constants';
|
||||
import { FeatureFlagKey } from 'twenty-shared/types';
|
||||
|
||||
import { UUIDScalarType } from 'src/engine/api/graphql/workspace-schema-builder/graphql-types/scalars';
|
||||
import { MetadataResolver } from 'src/engine/api/graphql/graphql-config/decorators/metadata-resolver.decorator';
|
||||
import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity';
|
||||
import { AuthWorkspace } from 'src/engine/decorators/auth/auth-workspace.decorator';
|
||||
import {
|
||||
FeatureFlagGuard,
|
||||
RequireFeatureFlag,
|
||||
} from 'src/engine/guards/feature-flag.guard';
|
||||
import { SettingsPermissionGuard } from 'src/engine/guards/settings-permission.guard';
|
||||
import { WorkspaceAuthGuard } from 'src/engine/guards/workspace-auth.guard';
|
||||
import { CreateMessageChannelInput } from 'src/engine/metadata-modules/message-channel/dtos/create-message-channel.input';
|
||||
import { MessageChannelDTO } from 'src/engine/metadata-modules/message-channel/dtos/message-channel.dto';
|
||||
import { UpdateMessageChannelInput } from 'src/engine/metadata-modules/message-channel/dtos/update-message-channel.input';
|
||||
import { MessageChannelGraphqlApiExceptionInterceptor } from 'src/engine/metadata-modules/message-channel/interceptors/message-channel-graphql-api-exception.interceptor';
|
||||
import { MessageChannelMetadataService } from 'src/engine/metadata-modules/message-channel/message-channel-metadata.service';
|
||||
|
||||
@UseGuards(WorkspaceAuthGuard, FeatureFlagGuard)
|
||||
@UseInterceptors(MessageChannelGraphqlApiExceptionInterceptor)
|
||||
@MetadataResolver(() => MessageChannelDTO)
|
||||
export class MessageChannelResolver {
|
||||
constructor(
|
||||
private readonly messageChannelMetadataService: MessageChannelMetadataService,
|
||||
) {}
|
||||
|
||||
@Query(() => [MessageChannelDTO])
|
||||
@UseGuards(SettingsPermissionGuard(PermissionFlagType.CONNECTED_ACCOUNTS))
|
||||
@RequireFeatureFlag(FeatureFlagKey.IS_CONNECTED_ACCOUNT_MIGRATED)
|
||||
async messageChannels(
|
||||
@AuthWorkspace() workspace: WorkspaceEntity,
|
||||
@Args('connectedAccountId', {
|
||||
type: () => UUIDScalarType,
|
||||
nullable: true,
|
||||
})
|
||||
connectedAccountId?: string,
|
||||
): Promise<MessageChannelDTO[]> {
|
||||
if (connectedAccountId) {
|
||||
return this.messageChannelMetadataService.findByConnectedAccountId(
|
||||
connectedAccountId,
|
||||
workspace.id,
|
||||
);
|
||||
}
|
||||
|
||||
return this.messageChannelMetadataService.findAll(workspace.id);
|
||||
}
|
||||
|
||||
@Query(() => MessageChannelDTO, { nullable: true })
|
||||
@UseGuards(SettingsPermissionGuard(PermissionFlagType.CONNECTED_ACCOUNTS))
|
||||
@RequireFeatureFlag(FeatureFlagKey.IS_CONNECTED_ACCOUNT_MIGRATED)
|
||||
async messageChannel(
|
||||
@Args('id', { type: () => UUIDScalarType }) id: string,
|
||||
@AuthWorkspace() workspace: WorkspaceEntity,
|
||||
): Promise<MessageChannelDTO | null> {
|
||||
return this.messageChannelMetadataService.findById(id, workspace.id);
|
||||
}
|
||||
|
||||
@Mutation(() => MessageChannelDTO)
|
||||
@UseGuards(SettingsPermissionGuard(PermissionFlagType.CONNECTED_ACCOUNTS))
|
||||
@RequireFeatureFlag(FeatureFlagKey.IS_CONNECTED_ACCOUNT_MIGRATED)
|
||||
async createMessageChannel(
|
||||
@Args('input') input: CreateMessageChannelInput,
|
||||
@AuthWorkspace() workspace: WorkspaceEntity,
|
||||
): Promise<MessageChannelDTO> {
|
||||
return this.messageChannelMetadataService.create({
|
||||
...input,
|
||||
workspaceId: workspace.id,
|
||||
});
|
||||
}
|
||||
|
||||
@Mutation(() => MessageChannelDTO)
|
||||
@UseGuards(SettingsPermissionGuard(PermissionFlagType.CONNECTED_ACCOUNTS))
|
||||
@RequireFeatureFlag(FeatureFlagKey.IS_CONNECTED_ACCOUNT_MIGRATED)
|
||||
async updateMessageChannel(
|
||||
@Args('input') input: UpdateMessageChannelInput,
|
||||
@AuthWorkspace() workspace: WorkspaceEntity,
|
||||
): Promise<MessageChannelDTO> {
|
||||
return this.messageChannelMetadataService.update(
|
||||
input.id,
|
||||
workspace.id,
|
||||
input.update,
|
||||
);
|
||||
}
|
||||
|
||||
@Mutation(() => MessageChannelDTO)
|
||||
@UseGuards(SettingsPermissionGuard(PermissionFlagType.CONNECTED_ACCOUNTS))
|
||||
@RequireFeatureFlag(FeatureFlagKey.IS_CONNECTED_ACCOUNT_MIGRATED)
|
||||
async deleteMessageChannel(
|
||||
@Args('id', { type: () => UUIDScalarType }) id: string,
|
||||
@AuthWorkspace() workspace: WorkspaceEntity,
|
||||
): Promise<MessageChannelDTO> {
|
||||
return this.messageChannelMetadataService.delete(id, workspace.id);
|
||||
}
|
||||
}
|
||||
+26
@@ -0,0 +1,26 @@
|
||||
import { assertUnreachable } from 'twenty-shared/utils';
|
||||
|
||||
import {
|
||||
NotFoundError,
|
||||
UserInputError,
|
||||
} from 'src/engine/core-modules/graphql/utils/graphql-errors.util';
|
||||
import {
|
||||
MessageChannelException,
|
||||
MessageChannelExceptionCode,
|
||||
} from 'src/engine/metadata-modules/message-channel/message-channel.exception';
|
||||
|
||||
export const messageChannelGraphqlApiExceptionHandler = (error: Error) => {
|
||||
if (error instanceof MessageChannelException) {
|
||||
switch (error.code) {
|
||||
case MessageChannelExceptionCode.MESSAGE_CHANNEL_NOT_FOUND:
|
||||
throw new NotFoundError(error);
|
||||
case MessageChannelExceptionCode.INVALID_MESSAGE_CHANNEL_INPUT:
|
||||
throw new UserInputError(error);
|
||||
default: {
|
||||
return assertUnreachable(error.code);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
throw error;
|
||||
};
|
||||
Reference in New Issue
Block a user