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:
Charles Bochet
2026-03-20 00:34:58 +01:00
committed by GitHub
parent cd594ce8bd
commit cee4cf6452
149 changed files with 7338 additions and 1699 deletions
@@ -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 { CalendarChannelMetadataService } from 'src/engine/metadata-modules/calendar-channel/calendar-channel-metadata.service';
import { CalendarChannelEntity } from 'src/engine/metadata-modules/calendar-channel/entities/calendar-channel.entity';
import { CalendarChannelGraphqlApiExceptionInterceptor } from 'src/engine/metadata-modules/calendar-channel/interceptors/calendar-channel-graphql-api-exception.interceptor';
import { CalendarChannelResolver } from 'src/engine/metadata-modules/calendar-channel/resolvers/calendar-channel.resolver';
import { PermissionsModule } from 'src/engine/metadata-modules/permissions/permissions.module';
@Module({
imports: [
TypeOrmModule.forFeature([CalendarChannelEntity]),
AuthModule,
PermissionsModule,
FeatureFlagModule,
],
providers: [
CalendarChannelMetadataService,
CalendarChannelResolver,
CalendarChannelGraphqlApiExceptionInterceptor,
],
exports: [CalendarChannelMetadataService],
})
export class CalendarChannelMetadataModule {}
@@ -0,0 +1,77 @@
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import {
CalendarChannelSyncStage,
CalendarChannelVisibility,
} from 'twenty-shared/types';
import { CalendarChannelDTO } from 'src/engine/metadata-modules/calendar-channel/dtos/calendar-channel.dto';
import { CalendarChannelEntity } from 'src/engine/metadata-modules/calendar-channel/entities/calendar-channel.entity';
@Injectable()
export class CalendarChannelMetadataService {
constructor(
@InjectRepository(CalendarChannelEntity)
private readonly repository: Repository<CalendarChannelEntity>,
) {}
async findAll(workspaceId: string): Promise<CalendarChannelDTO[]> {
return this.repository.find({ where: { workspaceId } });
}
async findByConnectedAccountId(
connectedAccountId: string,
workspaceId: string,
): Promise<CalendarChannelDTO[]> {
return this.repository.find({
where: { connectedAccountId, workspaceId },
});
}
async findById(
id: string,
workspaceId: string,
): Promise<CalendarChannelDTO | null> {
return this.repository.findOne({ where: { id, workspaceId } });
}
async create(
data: Partial<CalendarChannelEntity> & {
workspaceId: string;
handle: string;
connectedAccountId: string;
visibility: CalendarChannelVisibility;
syncStage: CalendarChannelSyncStage;
},
): Promise<CalendarChannelDTO> {
const entity = this.repository.create(data);
return this.repository.save(entity);
}
async update(
id: string,
workspaceId: string,
data: Partial<CalendarChannelEntity>,
): Promise<CalendarChannelDTO> {
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<CalendarChannelDTO> {
const entity = await this.repository.findOneOrFail({
where: { id, workspaceId },
});
await this.repository.delete({ id, workspaceId });
return entity;
}
}
@@ -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 CalendarChannelExceptionCode {
CALENDAR_CHANNEL_NOT_FOUND = 'CALENDAR_CHANNEL_NOT_FOUND',
INVALID_CALENDAR_CHANNEL_INPUT = 'INVALID_CALENDAR_CHANNEL_INPUT',
}
const getCalendarChannelExceptionUserFriendlyMessage = (
code: CalendarChannelExceptionCode,
) => {
switch (code) {
case CalendarChannelExceptionCode.CALENDAR_CHANNEL_NOT_FOUND:
return msg`Calendar channel not found.`;
case CalendarChannelExceptionCode.INVALID_CALENDAR_CHANNEL_INPUT:
return msg`Invalid calendar channel input.`;
default:
assertUnreachable(code);
}
};
export class CalendarChannelException extends CustomException<CalendarChannelExceptionCode> {
constructor(
message: string,
code: CalendarChannelExceptionCode,
{ userFriendlyMessage }: { userFriendlyMessage?: MessageDescriptor } = {},
) {
super(message, code, {
userFriendlyMessage:
userFriendlyMessage ??
getCalendarChannelExceptionUserFriendlyMessage(code),
});
}
}
@@ -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 { CalendarChannelDataAccessService } from 'src/engine/metadata-modules/calendar-channel/data-access/services/calendar-channel-data-access.service';
import { CalendarChannelEntity } from 'src/engine/metadata-modules/calendar-channel/entities/calendar-channel.entity';
import { ConnectedAccountDataAccessModule } from 'src/engine/metadata-modules/connected-account/data-access/connected-account-data-access.module';
@Module({
imports: [
TypeOrmModule.forFeature([CalendarChannelEntity]),
FeatureFlagModule,
ConnectedAccountDataAccessModule,
],
providers: [CalendarChannelDataAccessService],
exports: [CalendarChannelDataAccessService],
})
export class CalendarChannelDataAccessModule {}
@@ -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 { CalendarChannelEntity } from 'src/engine/metadata-modules/calendar-channel/entities/calendar-channel.entity';
import { ConnectedAccountDataAccessService } from 'src/engine/metadata-modules/connected-account/data-access/services/connected-account-data-access.service';
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 CalendarChannelWorkspaceEntity } from 'src/modules/calendar/common/standard-objects/calendar-channel.workspace-entity';
@Injectable()
export class CalendarChannelDataAccessService {
private readonly logger = new Logger(CalendarChannelDataAccessService.name);
constructor(
@InjectRepository(CalendarChannelEntity)
private readonly coreRepository: Repository<CalendarChannelEntity>,
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<CalendarChannelWorkspaceEntity>(
workspaceId,
'calendarChannel',
);
}
async findOne(
workspaceId: string,
options: FindOneOptions<CalendarChannelWorkspaceEntity>,
): Promise<CalendarChannelWorkspaceEntity | 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<CalendarChannelEntity>) as unknown as Promise<CalendarChannelWorkspaceEntity | null>;
}
const workspaceRepository = await this.getWorkspaceRepository(workspaceId);
return workspaceRepository.findOne(options);
}
async find(
workspaceId: string,
whereOrOptions?:
| FindOptionsWhere<CalendarChannelWorkspaceEntity>
| FindManyOptions<CalendarChannelWorkspaceEntity>,
): Promise<CalendarChannelWorkspaceEntity[]> {
if (
whereOrOptions !== undefined &&
typeof whereOrOptions === 'object' &&
whereOrOptions !== null &&
!Array.isArray(whereOrOptions) &&
'where' in whereOrOptions
) {
const options =
whereOrOptions as FindManyOptions<CalendarChannelWorkspaceEntity>;
if (await this.isMigrated(workspaceId)) {
const { where } = options;
const coreWhere = Array.isArray(where)
? await Promise.all(
where.map((whereItem) =>
this.toCoreWhere(
workspaceId,
whereItem as Record<string, unknown>,
),
),
)
: await this.toCoreWhere(
workspaceId,
where as Record<string, unknown>,
);
return this.coreRepository.find({
...options,
where: coreWhere,
} as FindManyOptions<CalendarChannelEntity>) as unknown as Promise<
CalendarChannelWorkspaceEntity[]
>;
}
const workspaceRepository =
await this.getWorkspaceRepository(workspaceId);
return workspaceRepository.find(options);
}
const where = whereOrOptions as
| FindOptionsWhere<CalendarChannelWorkspaceEntity>
| undefined;
if (await this.isMigrated(workspaceId)) {
const coreWhere = where
? await this.toCoreWhere(workspaceId, where as Record<string, unknown>)
: { workspaceId };
return this.coreRepository.find({
where: coreWhere,
} as FindManyOptions<CalendarChannelEntity>) as unknown as Promise<
CalendarChannelWorkspaceEntity[]
>;
}
const workspaceRepository = await this.getWorkspaceRepository(workspaceId);
return workspaceRepository.find({ where });
}
async save(
workspaceId: string,
data: Partial<CalendarChannelWorkspaceEntity>,
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 CalendarChannelEntity);
} catch (error) {
this.logger.error(
`Failed to dual-write calendarChannel to core: ${error}`,
);
throw error;
}
}
}
async update(
workspaceId: string,
where: FindOptionsWhere<CalendarChannelWorkspaceEntity>,
data: Partial<CalendarChannelWorkspaceEntity>,
): Promise<void> {
const workspaceRepository = await this.getWorkspaceRepository(workspaceId);
await workspaceRepository.update(where, data);
if (await this.isMigrated(workspaceId)) {
try {
await this.coreRepository.update(
{ ...where, workspaceId } as FindOptionsWhere<CalendarChannelEntity>,
data as Record<string, unknown>,
);
} catch (error) {
this.logger.error(
`Failed to dual-write calendarChannel update to core: ${error}`,
);
throw error;
}
}
}
async increment(
workspaceId: string,
where: FindOptionsWhere<CalendarChannelWorkspaceEntity>,
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<CalendarChannelEntity>,
propertyPath,
value,
);
} catch (error) {
this.logger.error(
`Failed to dual-write calendarChannel increment to core: ${error}`,
);
throw error;
}
}
}
async delete(
workspaceId: string,
where: FindOptionsWhere<CalendarChannelWorkspaceEntity>,
): 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<CalendarChannelEntity>);
} catch (error) {
this.logger.error(
`Failed to dual-write calendarChannel delete to core: ${error}`,
);
throw error;
}
}
}
}
@@ -0,0 +1,96 @@
import { Field, HideField, ObjectType } from '@nestjs/graphql';
import {
IsBoolean,
IsDateString,
IsEnum,
IsInt,
IsNotEmpty,
IsOptional,
IsString,
IsUUID,
} from 'class-validator';
import {
CalendarChannelContactAutoCreationPolicy,
CalendarChannelSyncStage,
CalendarChannelSyncStatus,
CalendarChannelVisibility,
} from 'twenty-shared/types';
import { UUIDScalarType } from 'src/engine/api/graphql/workspace-schema-builder/graphql-types/scalars';
@ObjectType('CalendarChannel')
export class CalendarChannelDTO {
@IsUUID()
@IsNotEmpty()
@Field(() => UUIDScalarType)
id: string;
@IsString()
@IsNotEmpty()
@Field()
handle: string;
@IsEnum(CalendarChannelSyncStatus)
@IsNotEmpty()
@Field(() => CalendarChannelSyncStatus)
syncStatus: CalendarChannelSyncStatus;
@IsEnum(CalendarChannelSyncStage)
@IsNotEmpty()
@Field(() => CalendarChannelSyncStage)
syncStage: CalendarChannelSyncStage;
@IsEnum(CalendarChannelVisibility)
@IsNotEmpty()
@Field(() => CalendarChannelVisibility)
visibility: CalendarChannelVisibility;
@IsBoolean()
@Field()
isContactAutoCreationEnabled: boolean;
@IsEnum(CalendarChannelContactAutoCreationPolicy)
@IsNotEmpty()
@Field(() => CalendarChannelContactAutoCreationPolicy)
contactAutoCreationPolicy: CalendarChannelContactAutoCreationPolicy;
@IsBoolean()
@Field()
isSyncEnabled: boolean;
@IsString()
@IsOptional()
@Field(() => String, { nullable: true })
syncCursor: string | null;
@IsDateString()
@IsOptional()
@Field(() => Date, { nullable: true })
syncedAt: Date | null;
@IsDateString()
@IsOptional()
@Field(() => Date, { nullable: true })
syncStageStartedAt: Date | null;
@IsInt()
@Field()
throttleFailureCount: number;
@IsUUID()
@IsNotEmpty()
@Field(() => UUIDScalarType)
connectedAccountId: string;
@HideField()
workspaceId: string;
@IsDateString()
@Field()
createdAt: Date;
@IsDateString()
@Field()
updatedAt: Date;
}
@@ -0,0 +1,60 @@
import { Field, InputType } from '@nestjs/graphql';
import {
IsBoolean,
IsEnum,
IsNotEmpty,
IsOptional,
IsString,
IsUUID,
} from 'class-validator';
import {
CalendarChannelContactAutoCreationPolicy,
CalendarChannelSyncStage,
CalendarChannelVisibility,
} from 'twenty-shared/types';
import { UUIDScalarType } from 'src/engine/api/graphql/workspace-schema-builder/graphql-types/scalars';
@InputType()
export class CreateCalendarChannelInput {
@IsUUID()
@IsOptional()
@Field(() => UUIDScalarType, { nullable: true })
id?: string;
@IsString()
@IsNotEmpty()
@Field()
handle: string;
@IsEnum(CalendarChannelVisibility)
@IsNotEmpty()
@Field(() => CalendarChannelVisibility)
visibility: CalendarChannelVisibility;
@IsEnum(CalendarChannelSyncStage)
@IsNotEmpty()
@Field(() => CalendarChannelSyncStage)
syncStage: CalendarChannelSyncStage;
@IsUUID()
@IsNotEmpty()
@Field(() => UUIDScalarType)
connectedAccountId: string;
@IsBoolean()
@IsNotEmpty()
@Field()
isContactAutoCreationEnabled: boolean;
@IsEnum(CalendarChannelContactAutoCreationPolicy)
@IsNotEmpty()
@Field(() => CalendarChannelContactAutoCreationPolicy)
contactAutoCreationPolicy: CalendarChannelContactAutoCreationPolicy;
@IsBoolean()
@IsNotEmpty()
@Field()
isSyncEnabled: boolean;
}
@@ -0,0 +1,53 @@
import { Field, InputType } from '@nestjs/graphql';
import { Type } from 'class-transformer';
import {
IsBoolean,
IsEnum,
IsNotEmpty,
IsOptional,
IsUUID,
ValidateNested,
} from 'class-validator';
import {
CalendarChannelContactAutoCreationPolicy,
CalendarChannelVisibility,
} from 'twenty-shared/types';
import { UUIDScalarType } from 'src/engine/api/graphql/workspace-schema-builder/graphql-types/scalars';
@InputType()
export class UpdateCalendarChannelInputUpdates {
@IsOptional()
@IsEnum(CalendarChannelVisibility)
@Field(() => CalendarChannelVisibility, { nullable: true })
visibility?: CalendarChannelVisibility;
@IsOptional()
@IsBoolean()
@Field({ nullable: true })
isContactAutoCreationEnabled?: boolean;
@IsOptional()
@IsEnum(CalendarChannelContactAutoCreationPolicy)
@Field(() => CalendarChannelContactAutoCreationPolicy, { nullable: true })
contactAutoCreationPolicy?: CalendarChannelContactAutoCreationPolicy;
@IsOptional()
@IsBoolean()
@Field({ nullable: true })
isSyncEnabled?: boolean;
}
@InputType()
export class UpdateCalendarChannelInput {
@IsUUID()
@IsNotEmpty()
@Field(() => UUIDScalarType)
id: string;
@Type(() => UpdateCalendarChannelInputUpdates)
@ValidateNested()
@Field(() => UpdateCalendarChannelInputUpdates)
update: UpdateCalendarChannelInputUpdates;
}
@@ -0,0 +1,93 @@
import {
Column,
CreateDateColumn,
Entity,
JoinColumn,
ManyToOne,
PrimaryGeneratedColumn,
type Relation,
UpdateDateColumn,
} from 'typeorm';
import {
CalendarChannelContactAutoCreationPolicy,
CalendarChannelSyncStage,
CalendarChannelSyncStatus,
CalendarChannelVisibility,
} from 'twenty-shared/types';
import { ConnectedAccountEntity } from 'src/engine/metadata-modules/connected-account/entities/connected-account.entity';
import { WorkspaceRelatedEntity } from 'src/engine/workspace-manager/types/workspace-related-entity';
@Entity({ name: 'calendarChannel', schema: 'core' })
export class CalendarChannelEntity extends WorkspaceRelatedEntity {
@PrimaryGeneratedColumn('uuid')
id: string;
@Column({ type: 'varchar', nullable: false })
handle: string;
@Column({
type: 'enum',
enum: CalendarChannelSyncStatus,
nullable: false,
default: CalendarChannelSyncStatus.NOT_SYNCED,
})
syncStatus: CalendarChannelSyncStatus;
@Column({
type: 'enum',
enum: CalendarChannelSyncStage,
nullable: false,
})
syncStage: CalendarChannelSyncStage;
@Column({
type: 'enum',
enum: CalendarChannelVisibility,
nullable: false,
})
visibility: CalendarChannelVisibility;
@Column({ type: 'boolean', nullable: false })
isContactAutoCreationEnabled: boolean;
@Column({
type: 'enum',
enum: CalendarChannelContactAutoCreationPolicy,
nullable: false,
})
contactAutoCreationPolicy: CalendarChannelContactAutoCreationPolicy;
@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: 'timestamptz', nullable: true })
syncStageStartedAt: Date | null;
@Column({ type: 'integer', nullable: false, default: 0 })
throttleFailureCount: number;
@Column({ type: 'uuid', nullable: false })
connectedAccountId: string;
@ManyToOne(
() => ConnectedAccountEntity,
(connectedAccount) => connectedAccount.calendarChannels,
{ onDelete: 'CASCADE' },
)
@JoinColumn({ name: 'connectedAccountId' })
connectedAccount: Relation<ConnectedAccountEntity>;
@CreateDateColumn({ type: 'timestamptz' })
createdAt: Date;
@UpdateDateColumn({ type: 'timestamptz' })
updatedAt: Date;
}
@@ -0,0 +1,24 @@
import {
type CallHandler,
type ExecutionContext,
Injectable,
type NestInterceptor,
} from '@nestjs/common';
import { type Observable, catchError } from 'rxjs';
import { calendarChannelGraphqlApiExceptionHandler } from 'src/engine/metadata-modules/calendar-channel/utils/calendar-channel-graphql-api-exception-handler.util';
@Injectable()
export class CalendarChannelGraphqlApiExceptionInterceptor
implements NestInterceptor
{
intercept(
_context: ExecutionContext,
next: CallHandler,
): Observable<unknown> {
return next
.handle()
.pipe(catchError(calendarChannelGraphqlApiExceptionHandler));
}
}
@@ -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 { CalendarChannelMetadataService } from 'src/engine/metadata-modules/calendar-channel/calendar-channel-metadata.service';
import { CalendarChannelDTO } from 'src/engine/metadata-modules/calendar-channel/dtos/calendar-channel.dto';
import { CreateCalendarChannelInput } from 'src/engine/metadata-modules/calendar-channel/dtos/create-calendar-channel.input';
import { UpdateCalendarChannelInput } from 'src/engine/metadata-modules/calendar-channel/dtos/update-calendar-channel.input';
import { CalendarChannelGraphqlApiExceptionInterceptor } from 'src/engine/metadata-modules/calendar-channel/interceptors/calendar-channel-graphql-api-exception.interceptor';
@UseGuards(WorkspaceAuthGuard, FeatureFlagGuard)
@UseInterceptors(CalendarChannelGraphqlApiExceptionInterceptor)
@MetadataResolver(() => CalendarChannelDTO)
export class CalendarChannelResolver {
constructor(
private readonly calendarChannelMetadataService: CalendarChannelMetadataService,
) {}
@Query(() => [CalendarChannelDTO])
@UseGuards(SettingsPermissionGuard(PermissionFlagType.CONNECTED_ACCOUNTS))
@RequireFeatureFlag(FeatureFlagKey.IS_CONNECTED_ACCOUNT_MIGRATED)
async calendarChannels(
@AuthWorkspace() workspace: WorkspaceEntity,
@Args('connectedAccountId', {
type: () => UUIDScalarType,
nullable: true,
})
connectedAccountId?: string,
): Promise<CalendarChannelDTO[]> {
if (connectedAccountId) {
return this.calendarChannelMetadataService.findByConnectedAccountId(
connectedAccountId,
workspace.id,
);
}
return this.calendarChannelMetadataService.findAll(workspace.id);
}
@Query(() => CalendarChannelDTO, { nullable: true })
@UseGuards(SettingsPermissionGuard(PermissionFlagType.CONNECTED_ACCOUNTS))
@RequireFeatureFlag(FeatureFlagKey.IS_CONNECTED_ACCOUNT_MIGRATED)
async calendarChannel(
@Args('id', { type: () => UUIDScalarType }) id: string,
@AuthWorkspace() workspace: WorkspaceEntity,
): Promise<CalendarChannelDTO | null> {
return this.calendarChannelMetadataService.findById(id, workspace.id);
}
@Mutation(() => CalendarChannelDTO)
@UseGuards(SettingsPermissionGuard(PermissionFlagType.CONNECTED_ACCOUNTS))
@RequireFeatureFlag(FeatureFlagKey.IS_CONNECTED_ACCOUNT_MIGRATED)
async createCalendarChannel(
@Args('input') input: CreateCalendarChannelInput,
@AuthWorkspace() workspace: WorkspaceEntity,
): Promise<CalendarChannelDTO> {
return this.calendarChannelMetadataService.create({
...input,
workspaceId: workspace.id,
});
}
@Mutation(() => CalendarChannelDTO)
@UseGuards(SettingsPermissionGuard(PermissionFlagType.CONNECTED_ACCOUNTS))
@RequireFeatureFlag(FeatureFlagKey.IS_CONNECTED_ACCOUNT_MIGRATED)
async updateCalendarChannel(
@Args('input') input: UpdateCalendarChannelInput,
@AuthWorkspace() workspace: WorkspaceEntity,
): Promise<CalendarChannelDTO> {
return this.calendarChannelMetadataService.update(
input.id,
workspace.id,
input.update,
);
}
@Mutation(() => CalendarChannelDTO)
@UseGuards(SettingsPermissionGuard(PermissionFlagType.CONNECTED_ACCOUNTS))
@RequireFeatureFlag(FeatureFlagKey.IS_CONNECTED_ACCOUNT_MIGRATED)
async deleteCalendarChannel(
@Args('id', { type: () => UUIDScalarType }) id: string,
@AuthWorkspace() workspace: WorkspaceEntity,
): Promise<CalendarChannelDTO> {
return this.calendarChannelMetadataService.delete(id, workspace.id);
}
}
@@ -0,0 +1,26 @@
import { assertUnreachable } from 'twenty-shared/utils';
import {
NotFoundError,
UserInputError,
} from 'src/engine/core-modules/graphql/utils/graphql-errors.util';
import {
CalendarChannelException,
CalendarChannelExceptionCode,
} from 'src/engine/metadata-modules/calendar-channel/calendar-channel.exception';
export const calendarChannelGraphqlApiExceptionHandler = (error: Error) => {
if (error instanceof CalendarChannelException) {
switch (error.code) {
case CalendarChannelExceptionCode.CALENDAR_CHANNEL_NOT_FOUND:
throw new NotFoundError(error);
case CalendarChannelExceptionCode.INVALID_CALENDAR_CHANNEL_INPUT:
throw new UserInputError(error);
default: {
return assertUnreachable(error.code);
}
}
}
throw error;
};
@@ -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 { ConnectedAccountMetadataService } from 'src/engine/metadata-modules/connected-account/connected-account-metadata.service';
import { ConnectedAccountEntity } from 'src/engine/metadata-modules/connected-account/entities/connected-account.entity';
import { ConnectedAccountGraphqlApiExceptionInterceptor } from 'src/engine/metadata-modules/connected-account/interceptors/connected-account-graphql-api-exception.interceptor';
import { ConnectedAccountResolver } from 'src/engine/metadata-modules/connected-account/resolvers/connected-account.resolver';
import { PermissionsModule } from 'src/engine/metadata-modules/permissions/permissions.module';
@Module({
imports: [
TypeOrmModule.forFeature([ConnectedAccountEntity]),
AuthModule,
PermissionsModule,
FeatureFlagModule,
],
providers: [
ConnectedAccountMetadataService,
ConnectedAccountResolver,
ConnectedAccountGraphqlApiExceptionInterceptor,
],
exports: [ConnectedAccountMetadataService],
})
export class ConnectedAccountMetadataModule {}
@@ -0,0 +1,62 @@
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { ConnectedAccountDTO } from 'src/engine/metadata-modules/connected-account/dtos/connected-account.dto';
import { ConnectedAccountEntity } from 'src/engine/metadata-modules/connected-account/entities/connected-account.entity';
@Injectable()
export class ConnectedAccountMetadataService {
constructor(
@InjectRepository(ConnectedAccountEntity)
private readonly repository: Repository<ConnectedAccountEntity>,
) {}
async findAll(workspaceId: string): Promise<ConnectedAccountDTO[]> {
return this.repository.find({ where: { workspaceId } });
}
async findById(
id: string,
workspaceId: string,
): Promise<ConnectedAccountDTO | null> {
return this.repository.findOne({ where: { id, workspaceId } });
}
async create(
data: Partial<ConnectedAccountEntity> & {
workspaceId: string;
handle: string;
provider: string;
userWorkspaceId: string;
},
): Promise<ConnectedAccountDTO> {
const entity = this.repository.create(data);
return this.repository.save(entity);
}
async update(
id: string,
workspaceId: string,
data: Partial<ConnectedAccountEntity>,
): Promise<ConnectedAccountDTO> {
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<ConnectedAccountDTO> {
const entity = await this.repository.findOneOrFail({
where: { id, workspaceId },
});
await this.repository.delete({ id, workspaceId });
return entity;
}
}
@@ -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 ConnectedAccountExceptionCode {
CONNECTED_ACCOUNT_NOT_FOUND = 'CONNECTED_ACCOUNT_NOT_FOUND',
INVALID_CONNECTED_ACCOUNT_INPUT = 'INVALID_CONNECTED_ACCOUNT_INPUT',
}
const getConnectedAccountExceptionUserFriendlyMessage = (
code: ConnectedAccountExceptionCode,
) => {
switch (code) {
case ConnectedAccountExceptionCode.CONNECTED_ACCOUNT_NOT_FOUND:
return msg`Connected account not found.`;
case ConnectedAccountExceptionCode.INVALID_CONNECTED_ACCOUNT_INPUT:
return msg`Invalid connected account input.`;
default:
assertUnreachable(code);
}
};
export class ConnectedAccountException extends CustomException<ConnectedAccountExceptionCode> {
constructor(
message: string,
code: ConnectedAccountExceptionCode,
{ userFriendlyMessage }: { userFriendlyMessage?: MessageDescriptor } = {},
) {
super(message, code, {
userFriendlyMessage:
userFriendlyMessage ??
getConnectedAccountExceptionUserFriendlyMessage(code),
});
}
}
@@ -0,0 +1,17 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { FeatureFlagModule } from 'src/engine/core-modules/feature-flag/feature-flag.module';
import { UserWorkspaceEntity } from 'src/engine/core-modules/user-workspace/user-workspace.entity';
import { ConnectedAccountDataAccessService } from 'src/engine/metadata-modules/connected-account/data-access/services/connected-account-data-access.service';
import { ConnectedAccountEntity } from 'src/engine/metadata-modules/connected-account/entities/connected-account.entity';
@Module({
imports: [
TypeOrmModule.forFeature([ConnectedAccountEntity, UserWorkspaceEntity]),
FeatureFlagModule,
],
providers: [ConnectedAccountDataAccessService],
exports: [ConnectedAccountDataAccessService],
})
export class ConnectedAccountDataAccessModule {}
@@ -0,0 +1,346 @@
import { Injectable, Logger } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { isNonEmptyString } from '@sniptt/guards';
import { FeatureFlagKey } from 'twenty-shared/types';
import { isDefined } from 'twenty-shared/utils';
import {
type FindOneOptions,
type FindOptionsWhere,
In,
Repository,
} from 'typeorm';
import { FeatureFlagService } from 'src/engine/core-modules/feature-flag/services/feature-flag.service';
import { UserWorkspaceEntity } from 'src/engine/core-modules/user-workspace/user-workspace.entity';
import { ConnectedAccountEntity } from 'src/engine/metadata-modules/connected-account/entities/connected-account.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 ConnectedAccountWorkspaceEntity } from 'src/modules/connected-account/standard-objects/connected-account.workspace-entity';
import { type WorkspaceMemberWorkspaceEntity } from 'src/modules/workspace-member/standard-objects/workspace-member.workspace-entity';
@Injectable()
export class ConnectedAccountDataAccessService {
private readonly logger = new Logger(ConnectedAccountDataAccessService.name);
constructor(
@InjectRepository(ConnectedAccountEntity)
private readonly coreRepository: Repository<ConnectedAccountEntity>,
@InjectRepository(UserWorkspaceEntity)
private readonly userWorkspaceRepository: Repository<UserWorkspaceEntity>,
private readonly featureFlagService: FeatureFlagService,
private readonly globalWorkspaceOrmManager: GlobalWorkspaceOrmManager,
) {}
private async isMigrated(workspaceId: string): Promise<boolean> {
return this.featureFlagService.isFeatureEnabled(
FeatureFlagKey.IS_CONNECTED_ACCOUNT_MIGRATED,
workspaceId,
);
}
private async resolveUserWorkspaceId(
workspaceId: string,
workspaceMemberId: string,
): Promise<string | null> {
const workspaceMemberRepository =
await this.globalWorkspaceOrmManager.getRepository<WorkspaceMemberWorkspaceEntity>(
workspaceId,
'workspaceMember',
);
const workspaceMember = await workspaceMemberRepository.findOne({
where: { id: workspaceMemberId },
});
if (!workspaceMember) {
return null;
}
const userWorkspace = await this.userWorkspaceRepository.findOne({
where: { userId: workspaceMember.userId, workspaceId },
});
return userWorkspace?.id ?? null;
}
private async toCore(
workspaceId: string,
data: Partial<ConnectedAccountWorkspaceEntity>,
): Promise<Partial<ConnectedAccountEntity>> {
const {
handleAliases,
lastSyncHistoryId: _lastSyncHistoryId,
accountOwnerId,
...rest
} = data as Record<string, unknown>;
const coreData: Record<string, unknown> = { ...rest };
if (handleAliases !== undefined) {
coreData.handleAliases = isNonEmptyString(handleAliases)
? handleAliases.split(',').map((alias: string) => alias.trim())
: null;
}
if (accountOwnerId !== undefined) {
const userWorkspaceId = await this.resolveUserWorkspaceId(
workspaceId,
accountOwnerId as string,
);
if (!userWorkspaceId) {
this.logger.warn(
`Could not resolve userWorkspaceId for workspaceMember ${accountOwnerId}`,
);
}
coreData.userWorkspaceId = userWorkspaceId;
}
return coreData as Partial<ConnectedAccountEntity>;
}
private async toCoreWhere(
workspaceId: string,
where: Record<string, unknown>,
): Promise<FindOptionsWhere<ConnectedAccountEntity>> {
const { accountOwnerId, ...rest } = where;
const coreWhere: Record<string, unknown> = { ...rest, workspaceId };
if (accountOwnerId !== undefined) {
const userWorkspaceId = await this.resolveUserWorkspaceId(
workspaceId,
accountOwnerId as string,
);
if (userWorkspaceId) {
coreWhere.userWorkspaceId = userWorkspaceId;
} else {
this.logger.warn(
`toCoreWhere: could not resolve userWorkspaceId for workspaceMember ${accountOwnerId}, returning empty result`,
);
coreWhere.id = '00000000-0000-0000-0000-000000000000';
}
}
return coreWhere as FindOptionsWhere<ConnectedAccountEntity>;
}
private async fromCoreEntities(
workspaceId: string,
entities: ConnectedAccountEntity[],
): Promise<ConnectedAccountWorkspaceEntity[]> {
if (entities.length === 0) {
return [];
}
const userWorkspaceIds = entities
.map((entity) => entity.userWorkspaceId)
.filter(isDefined);
const userWorkspaces =
userWorkspaceIds.length > 0
? await this.userWorkspaceRepository.find({
where: { id: In(userWorkspaceIds) },
select: ['id', 'userId'],
})
: [];
const userIdByUserWorkspaceId = new Map(
userWorkspaces.map((userWorkspace) => [
userWorkspace.id,
userWorkspace.userId,
]),
);
const uniqueUserIds = [
...new Set(userWorkspaces.map((userWorkspace) => userWorkspace.userId)),
];
const workspaceMemberRepository =
await this.globalWorkspaceOrmManager.getRepository<WorkspaceMemberWorkspaceEntity>(
workspaceId,
'workspaceMember',
);
const workspaceMembers =
uniqueUserIds.length > 0
? await workspaceMemberRepository.find({
where: { userId: In(uniqueUserIds) },
})
: [];
const workspaceMemberIdByUserId = new Map(
workspaceMembers.map((workspaceMember) => [
workspaceMember.userId,
workspaceMember.id,
]),
);
return entities.map((entity) => {
const userId = entity.userWorkspaceId
? userIdByUserWorkspaceId.get(entity.userWorkspaceId)
: undefined;
const accountOwnerId = userId
? workspaceMemberIdByUserId.get(userId)
: undefined;
const handleAliases = Array.isArray(entity.handleAliases)
? entity.handleAliases.join(',')
: (entity.handleAliases ?? '');
return {
...entity,
handleAliases,
accountOwnerId: accountOwnerId ?? null,
} as unknown as ConnectedAccountWorkspaceEntity;
});
}
async getWorkspaceRepository(workspaceId: string) {
return this.globalWorkspaceOrmManager.getRepository<ConnectedAccountWorkspaceEntity>(
workspaceId,
'connectedAccount',
);
}
async findOne(
workspaceId: string,
options: FindOneOptions<ConnectedAccountWorkspaceEntity>,
): Promise<ConnectedAccountWorkspaceEntity | 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);
const coreResult = await this.coreRepository.findOne({
...options,
where: coreWhere,
} as FindOneOptions<ConnectedAccountEntity>);
if (!coreResult) {
return null;
}
const [transformed] = await this.fromCoreEntities(workspaceId, [
coreResult,
]);
return transformed ?? null;
}
const workspaceRepository = await this.getWorkspaceRepository(workspaceId);
return workspaceRepository.findOne(options);
}
async find(
workspaceId: string,
where?: FindOptionsWhere<ConnectedAccountWorkspaceEntity>,
): Promise<ConnectedAccountWorkspaceEntity[]> {
if (await this.isMigrated(workspaceId)) {
const coreWhere = where
? await this.toCoreWhere(workspaceId, where as Record<string, unknown>)
: { workspaceId };
const coreResults = await this.coreRepository.find({
where: coreWhere,
});
return this.fromCoreEntities(workspaceId, coreResults);
}
const workspaceRepository = await this.getWorkspaceRepository(workspaceId);
return workspaceRepository.find({ where });
}
async save(
workspaceId: string,
data: Partial<ConnectedAccountWorkspaceEntity>,
manager?: WorkspaceEntityManager,
): Promise<void> {
const workspaceRepository = await this.getWorkspaceRepository(workspaceId);
await workspaceRepository.save(data, {}, manager);
if (await this.isMigrated(workspaceId)) {
try {
const coreData = await this.toCore(workspaceId, data);
await this.coreRepository.save({
...coreData,
workspaceId,
} as ConnectedAccountEntity);
} catch (error) {
this.logger.error(
`Failed to dual-write connectedAccount to core: ${error}`,
);
throw error;
}
}
}
async update(
workspaceId: string,
where: FindOptionsWhere<ConnectedAccountWorkspaceEntity>,
data: Partial<ConnectedAccountWorkspaceEntity>,
): Promise<void> {
const workspaceRepository = await this.getWorkspaceRepository(workspaceId);
await workspaceRepository.update(where, data);
if (await this.isMigrated(workspaceId)) {
try {
const coreData = await this.toCore(workspaceId, data);
const coreWhere = await this.toCoreWhere(
workspaceId,
where as Record<string, unknown>,
);
await this.coreRepository.update(
coreWhere,
coreData as Record<string, unknown>,
);
} catch (error) {
this.logger.error(
`Failed to dual-write connectedAccount update to core: ${error}`,
);
throw error;
}
}
}
async delete(
workspaceId: string,
where: FindOptionsWhere<ConnectedAccountWorkspaceEntity>,
): Promise<void> {
const workspaceRepository = await this.getWorkspaceRepository(workspaceId);
await workspaceRepository.delete(where);
if (await this.isMigrated(workspaceId)) {
try {
const coreWhere = await this.toCoreWhere(
workspaceId,
where as Record<string, unknown>,
);
await this.coreRepository.delete(coreWhere);
} catch (error) {
this.logger.error(
`Failed to dual-write connectedAccount delete to core: ${error}`,
);
throw error;
}
}
}
}
@@ -0,0 +1,90 @@
import { Field, HideField, ObjectType } from '@nestjs/graphql';
import {
IsArray,
IsDateString,
IsNotEmpty,
IsOptional,
IsString,
IsUUID,
} from 'class-validator';
import GraphQLJSON from 'graphql-type-json';
import { UUIDScalarType } from 'src/engine/api/graphql/workspace-schema-builder/graphql-types/scalars';
@ObjectType('ConnectedAccountDTO')
export class ConnectedAccountDTO {
@IsUUID()
@IsNotEmpty()
@Field(() => UUIDScalarType)
id: string;
@IsString()
@IsNotEmpty()
@Field()
handle: string;
@IsString()
@IsNotEmpty()
@Field()
provider: string;
@IsString()
@IsOptional()
@Field(() => String, { nullable: true })
accessToken: string | null;
@IsString()
@IsOptional()
@Field(() => String, { nullable: true })
refreshToken: string | null;
@IsDateString()
@IsOptional()
@Field(() => Date, { nullable: true })
lastCredentialsRefreshedAt: Date | null;
@IsDateString()
@IsOptional()
@Field(() => Date, { nullable: true })
authFailedAt: Date | null;
@IsArray()
@IsOptional()
@Field(() => [String], { nullable: true })
handleAliases: string[] | null;
@IsArray()
@IsOptional()
@Field(() => [String], { nullable: true })
scopes: string[] | null;
@IsOptional()
@Field(() => GraphQLJSON, { nullable: true })
connectionParameters: Record<string, unknown> | null;
@IsDateString()
@IsOptional()
@Field(() => Date, { nullable: true })
lastSignedInAt: Date | null;
@IsOptional()
@Field(() => GraphQLJSON, { nullable: true })
oidcTokenClaims: Record<string, unknown> | null;
@IsUUID()
@IsNotEmpty()
@Field(() => UUIDScalarType)
userWorkspaceId: string;
@HideField()
workspaceId: string;
@IsDateString()
@Field()
createdAt: Date;
@IsDateString()
@Field()
updatedAt: Date;
}
@@ -0,0 +1,49 @@
import { Field, InputType } from '@nestjs/graphql';
import {
IsArray,
IsNotEmpty,
IsOptional,
IsString,
IsUUID,
} from 'class-validator';
import { UUIDScalarType } from 'src/engine/api/graphql/workspace-schema-builder/graphql-types/scalars';
@InputType()
export class CreateConnectedAccountInput {
@IsUUID()
@IsOptional()
@Field(() => UUIDScalarType, { nullable: true })
id?: string;
@IsString()
@IsNotEmpty()
@Field()
handle: string;
@IsString()
@IsNotEmpty()
@Field()
provider: string;
@IsString()
@IsOptional()
@Field({ nullable: true })
accessToken?: string;
@IsString()
@IsOptional()
@Field({ nullable: true })
refreshToken?: string;
@IsArray()
@IsOptional()
@Field(() => [String], { nullable: true })
scopes?: string[];
@IsUUID()
@IsNotEmpty()
@Field(() => UUIDScalarType)
userWorkspaceId: string;
}
@@ -0,0 +1,49 @@
import { Field, InputType } from '@nestjs/graphql';
import { Type } from 'class-transformer';
import {
IsArray,
IsNotEmpty,
IsOptional,
IsString,
IsUUID,
ValidateNested,
} from 'class-validator';
import { UUIDScalarType } from 'src/engine/api/graphql/workspace-schema-builder/graphql-types/scalars';
@InputType()
export class UpdateConnectedAccountInputUpdates {
@IsOptional()
@IsString()
@Field({ nullable: true })
accessToken?: string;
@IsOptional()
@IsString()
@Field({ nullable: true })
refreshToken?: string;
@IsOptional()
@IsArray()
@Field(() => [String], { nullable: true })
handleAliases?: string[];
@IsOptional()
@IsArray()
@Field(() => [String], { nullable: true })
scopes?: string[];
}
@InputType()
export class UpdateConnectedAccountInput {
@IsUUID()
@IsNotEmpty()
@Field(() => UUIDScalarType)
id: string;
@Type(() => UpdateConnectedAccountInputUpdates)
@ValidateNested()
@Field(() => UpdateConnectedAccountInputUpdates)
update: UpdateConnectedAccountInputUpdates;
}
@@ -0,0 +1,74 @@
import {
Column,
CreateDateColumn,
Entity,
OneToMany,
PrimaryGeneratedColumn,
type Relation,
UpdateDateColumn,
} from 'typeorm';
import { type CalendarChannelEntity } from 'src/engine/metadata-modules/calendar-channel/entities/calendar-channel.entity';
import { type MessageChannelEntity } from 'src/engine/metadata-modules/message-channel/entities/message-channel.entity';
import { WorkspaceRelatedEntity } from 'src/engine/workspace-manager/types/workspace-related-entity';
@Entity({ name: 'connectedAccount', schema: 'core' })
export class ConnectedAccountEntity extends WorkspaceRelatedEntity {
@PrimaryGeneratedColumn('uuid')
id: string;
@Column({ type: 'varchar', nullable: false })
handle: string;
@Column({ type: 'varchar', nullable: false })
provider: string;
@Column({ type: 'varchar', nullable: true })
accessToken: string | null;
@Column({ type: 'varchar', nullable: true })
refreshToken: string | null;
@Column({ type: 'timestamptz', nullable: true })
lastCredentialsRefreshedAt: Date | null;
@Column({ type: 'timestamptz', nullable: true })
authFailedAt: Date | null;
@Column({ type: 'varchar', array: true, nullable: true })
handleAliases: string[] | null;
@Column({ type: 'varchar', array: true, nullable: true })
scopes: string[] | null;
@Column({ type: 'jsonb', nullable: true })
connectionParameters: Record<string, unknown> | null;
@Column({ type: 'timestamptz', nullable: true })
lastSignedInAt: Date | null;
@Column({ type: 'jsonb', nullable: true })
oidcTokenClaims: Record<string, unknown> | null;
@Column({ type: 'uuid', nullable: false })
userWorkspaceId: string;
@OneToMany(
'MessageChannelEntity',
(messageChannel: MessageChannelEntity) => messageChannel.connectedAccount,
)
messageChannels: Relation<MessageChannelEntity[]>;
@OneToMany(
'CalendarChannelEntity',
(calendarChannel: CalendarChannelEntity) =>
calendarChannel.connectedAccount,
)
calendarChannels: Relation<CalendarChannelEntity[]>;
@CreateDateColumn({ type: 'timestamptz' })
createdAt: Date;
@UpdateDateColumn({ type: 'timestamptz' })
updatedAt: Date;
}
@@ -0,0 +1,24 @@
import {
type CallHandler,
type ExecutionContext,
Injectable,
type NestInterceptor,
} from '@nestjs/common';
import { type Observable, catchError } from 'rxjs';
import { connectedAccountGraphqlApiExceptionHandler } from 'src/engine/metadata-modules/connected-account/utils/connected-account-graphql-api-exception-handler.util';
@Injectable()
export class ConnectedAccountGraphqlApiExceptionInterceptor
implements NestInterceptor
{
intercept(
_context: ExecutionContext,
next: CallHandler,
): Observable<unknown> {
return next
.handle()
.pipe(catchError(connectedAccountGraphqlApiExceptionHandler));
}
}
@@ -0,0 +1,86 @@
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 { ConnectedAccountMetadataService } from 'src/engine/metadata-modules/connected-account/connected-account-metadata.service';
import { ConnectedAccountDTO } from 'src/engine/metadata-modules/connected-account/dtos/connected-account.dto';
import { CreateConnectedAccountInput } from 'src/engine/metadata-modules/connected-account/dtos/create-connected-account.input';
import { UpdateConnectedAccountInput } from 'src/engine/metadata-modules/connected-account/dtos/update-connected-account.input';
import { ConnectedAccountGraphqlApiExceptionInterceptor } from 'src/engine/metadata-modules/connected-account/interceptors/connected-account-graphql-api-exception.interceptor';
@UseGuards(WorkspaceAuthGuard, FeatureFlagGuard)
@UseInterceptors(ConnectedAccountGraphqlApiExceptionInterceptor)
@MetadataResolver(() => ConnectedAccountDTO)
export class ConnectedAccountResolver {
constructor(
private readonly connectedAccountMetadataService: ConnectedAccountMetadataService,
) {}
@Query(() => [ConnectedAccountDTO])
@UseGuards(SettingsPermissionGuard(PermissionFlagType.CONNECTED_ACCOUNTS))
@RequireFeatureFlag(FeatureFlagKey.IS_CONNECTED_ACCOUNT_MIGRATED)
async connectedAccounts(
@AuthWorkspace() workspace: WorkspaceEntity,
): Promise<ConnectedAccountDTO[]> {
return this.connectedAccountMetadataService.findAll(workspace.id);
}
@Query(() => ConnectedAccountDTO, { nullable: true })
@UseGuards(SettingsPermissionGuard(PermissionFlagType.CONNECTED_ACCOUNTS))
@RequireFeatureFlag(FeatureFlagKey.IS_CONNECTED_ACCOUNT_MIGRATED)
async connectedAccount(
@Args('id', { type: () => UUIDScalarType }) id: string,
@AuthWorkspace() workspace: WorkspaceEntity,
): Promise<ConnectedAccountDTO | null> {
return this.connectedAccountMetadataService.findById(id, workspace.id);
}
@Mutation(() => ConnectedAccountDTO)
@UseGuards(SettingsPermissionGuard(PermissionFlagType.CONNECTED_ACCOUNTS))
@RequireFeatureFlag(FeatureFlagKey.IS_CONNECTED_ACCOUNT_MIGRATED)
async createConnectedAccount(
@Args('input') input: CreateConnectedAccountInput,
@AuthWorkspace() workspace: WorkspaceEntity,
): Promise<ConnectedAccountDTO> {
return this.connectedAccountMetadataService.create({
...input,
workspaceId: workspace.id,
});
}
@Mutation(() => ConnectedAccountDTO)
@UseGuards(SettingsPermissionGuard(PermissionFlagType.CONNECTED_ACCOUNTS))
@RequireFeatureFlag(FeatureFlagKey.IS_CONNECTED_ACCOUNT_MIGRATED)
async updateConnectedAccount(
@Args('input') input: UpdateConnectedAccountInput,
@AuthWorkspace() workspace: WorkspaceEntity,
): Promise<ConnectedAccountDTO> {
return this.connectedAccountMetadataService.update(
input.id,
workspace.id,
input.update,
);
}
@Mutation(() => ConnectedAccountDTO)
@UseGuards(SettingsPermissionGuard(PermissionFlagType.CONNECTED_ACCOUNTS))
@RequireFeatureFlag(FeatureFlagKey.IS_CONNECTED_ACCOUNT_MIGRATED)
async deleteConnectedAccount(
@Args('id', { type: () => UUIDScalarType }) id: string,
@AuthWorkspace() workspace: WorkspaceEntity,
): Promise<ConnectedAccountDTO> {
return this.connectedAccountMetadataService.delete(id, workspace.id);
}
}
@@ -0,0 +1,26 @@
import { assertUnreachable } from 'twenty-shared/utils';
import {
NotFoundError,
UserInputError,
} from 'src/engine/core-modules/graphql/utils/graphql-errors.util';
import {
ConnectedAccountException,
ConnectedAccountExceptionCode,
} from 'src/engine/metadata-modules/connected-account/connected-account.exception';
export const connectedAccountGraphqlApiExceptionHandler = (error: Error) => {
if (error instanceof ConnectedAccountException) {
switch (error.code) {
case ConnectedAccountExceptionCode.CONNECTED_ACCOUNT_NOT_FOUND:
throw new NotFoundError(error);
case ConnectedAccountExceptionCode.INVALID_CONNECTED_ACCOUNT_INPUT:
throw new UserInputError(error);
default: {
return assertUnreachable(error.code);
}
}
}
throw error;
};
@@ -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 {}
@@ -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;
}
}
}
}
@@ -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;
}
@@ -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;
}
@@ -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;
}
@@ -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;
}
@@ -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));
}
}
@@ -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 {}
@@ -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;
}
}
@@ -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),
});
}
}
@@ -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);
}
}
@@ -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;
};
@@ -0,0 +1,13 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { FeatureFlagModule } from 'src/engine/core-modules/feature-flag/feature-flag.module';
import { MessageFolderDataAccessService } from 'src/engine/metadata-modules/message-folder/data-access/services/message-folder-data-access.service';
import { MessageFolderEntity } from 'src/engine/metadata-modules/message-folder/entities/message-folder.entity';
@Module({
imports: [TypeOrmModule.forFeature([MessageFolderEntity]), FeatureFlagModule],
providers: [MessageFolderDataAccessService],
exports: [MessageFolderDataAccessService],
})
export class MessageFolderDataAccessModule {}
@@ -0,0 +1,157 @@
import { Injectable, Logger } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { FeatureFlagKey } from 'twenty-shared/types';
import {
type FindOneOptions,
type FindOptionsWhere,
Repository,
} from 'typeorm';
import { FeatureFlagService } from 'src/engine/core-modules/feature-flag/services/feature-flag.service';
import { MessageFolderEntity } from 'src/engine/metadata-modules/message-folder/entities/message-folder.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 MessageFolderWorkspaceEntity } from 'src/modules/messaging/common/standard-objects/message-folder.workspace-entity';
@Injectable()
export class MessageFolderDataAccessService {
private readonly logger = new Logger(MessageFolderDataAccessService.name);
constructor(
@InjectRepository(MessageFolderEntity)
private readonly coreRepository: Repository<MessageFolderEntity>,
private readonly featureFlagService: FeatureFlagService,
private readonly globalWorkspaceOrmManager: GlobalWorkspaceOrmManager,
) {}
private async isMigrated(workspaceId: string): Promise<boolean> {
return this.featureFlagService.isFeatureEnabled(
FeatureFlagKey.IS_CONNECTED_ACCOUNT_MIGRATED,
workspaceId,
);
}
async getWorkspaceRepository(workspaceId: string) {
return this.globalWorkspaceOrmManager.getRepository<MessageFolderWorkspaceEntity>(
workspaceId,
'messageFolder',
);
}
async findOne(
workspaceId: string,
options: FindOneOptions<MessageFolderWorkspaceEntity>,
): Promise<MessageFolderWorkspaceEntity | null> {
if (await this.isMigrated(workspaceId)) {
const where = options.where as Record<string, unknown>;
const coreWhere = Array.isArray(where)
? where.map((whereItem) => ({
...(whereItem as Record<string, unknown>),
workspaceId,
}))
: {
...(where as Record<string, unknown>),
workspaceId,
};
return this.coreRepository.findOne({
...options,
where: coreWhere,
} as FindOneOptions<MessageFolderEntity>) as unknown as Promise<MessageFolderWorkspaceEntity | null>;
}
const workspaceRepository = await this.getWorkspaceRepository(workspaceId);
return workspaceRepository.findOne(options);
}
async find(
workspaceId: string,
where?: FindOptionsWhere<MessageFolderWorkspaceEntity>,
): Promise<MessageFolderWorkspaceEntity[]> {
if (await this.isMigrated(workspaceId)) {
return this.coreRepository.find({
where: {
...(where as Record<string, unknown>),
workspaceId,
} as FindOptionsWhere<MessageFolderEntity>,
}) as unknown as Promise<MessageFolderWorkspaceEntity[]>;
}
const workspaceRepository = await this.getWorkspaceRepository(workspaceId);
return workspaceRepository.find({ where });
}
async save(
workspaceId: string,
data: Partial<MessageFolderWorkspaceEntity>,
): Promise<void> {
const workspaceRepository = await this.getWorkspaceRepository(workspaceId);
await workspaceRepository.save(data);
if (await this.isMigrated(workspaceId)) {
try {
await this.coreRepository.save({
...data,
workspaceId,
} as unknown as MessageFolderEntity);
} catch (error) {
this.logger.error(
`Failed to dual-write messageFolder to core: ${error}`,
);
throw error;
}
}
}
async update(
workspaceId: string,
where: FindOptionsWhere<MessageFolderWorkspaceEntity>,
data: Partial<MessageFolderWorkspaceEntity>,
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<MessageFolderEntity>,
data as Record<string, unknown>,
);
} catch (error) {
this.logger.error(
`Failed to dual-write messageFolder update to core: ${error}`,
);
throw error;
}
}
}
async delete(
workspaceId: string,
where: FindOptionsWhere<MessageFolderWorkspaceEntity>,
): 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<MessageFolderEntity>);
} catch (error) {
this.logger.error(
`Failed to dual-write messageFolder delete to core: ${error}`,
);
throw error;
}
}
}
}
@@ -0,0 +1,56 @@
import { Field, InputType } from '@nestjs/graphql';
import {
IsBoolean,
IsEnum,
IsNotEmpty,
IsOptional,
IsString,
IsUUID,
} from 'class-validator';
import { MessageFolderPendingSyncAction } from 'twenty-shared/types';
import { UUIDScalarType } from 'src/engine/api/graphql/workspace-schema-builder/graphql-types/scalars';
@InputType()
export class CreateMessageFolderInput {
@IsUUID()
@IsOptional()
@Field(() => UUIDScalarType, { nullable: true })
id?: string;
@IsString()
@IsOptional()
@Field({ nullable: true })
name?: string;
@IsBoolean()
@IsNotEmpty()
@Field()
isSentFolder: boolean;
@IsBoolean()
@IsNotEmpty()
@Field()
isSynced: boolean;
@IsString()
@IsOptional()
@Field({ nullable: true })
externalId?: string;
@IsEnum(MessageFolderPendingSyncAction)
@IsNotEmpty()
@Field(() => MessageFolderPendingSyncAction)
pendingSyncAction: MessageFolderPendingSyncAction;
@IsUUID()
@IsNotEmpty()
@Field(() => UUIDScalarType)
messageChannelId: string;
@IsUUID()
@IsOptional()
@Field(() => UUIDScalarType, { nullable: true })
parentFolderId?: string;
}
@@ -0,0 +1,71 @@
import { Field, HideField, ObjectType } from '@nestjs/graphql';
import {
IsBoolean,
IsDateString,
IsEnum,
IsNotEmpty,
IsOptional,
IsString,
IsUUID,
} from 'class-validator';
import { MessageFolderPendingSyncAction } from 'twenty-shared/types';
import { UUIDScalarType } from 'src/engine/api/graphql/workspace-schema-builder/graphql-types/scalars';
@ObjectType('MessageFolder')
export class MessageFolderDTO {
@IsUUID()
@IsNotEmpty()
@Field(() => UUIDScalarType)
id: string;
@IsString()
@IsOptional()
@Field(() => String, { nullable: true })
name: string | null;
@IsString()
@IsOptional()
@Field(() => String, { nullable: true })
syncCursor: string | null;
@IsBoolean()
@Field()
isSentFolder: boolean;
@IsBoolean()
@Field()
isSynced: boolean;
@IsUUID()
@IsOptional()
@Field(() => UUIDScalarType, { nullable: true })
parentFolderId: string | null;
@IsString()
@IsOptional()
@Field(() => String, { nullable: true })
externalId: string | null;
@IsEnum(MessageFolderPendingSyncAction)
@IsNotEmpty()
@Field(() => MessageFolderPendingSyncAction)
pendingSyncAction: MessageFolderPendingSyncAction;
@IsUUID()
@IsNotEmpty()
@Field(() => UUIDScalarType)
messageChannelId: string;
@HideField()
workspaceId: string;
@IsDateString()
@Field()
createdAt: Date;
@IsDateString()
@Field()
updatedAt: Date;
}
@@ -0,0 +1,51 @@
import { Field, InputType } from '@nestjs/graphql';
import { Type } from 'class-transformer';
import {
IsBoolean,
IsEnum,
IsNotEmpty,
IsOptional,
IsString,
IsUUID,
ValidateNested,
} from 'class-validator';
import { MessageFolderPendingSyncAction } from 'twenty-shared/types';
import { UUIDScalarType } from 'src/engine/api/graphql/workspace-schema-builder/graphql-types/scalars';
@InputType()
export class UpdateMessageFolderInputUpdates {
@IsOptional()
@IsString()
@Field({ nullable: true })
name?: string;
@IsOptional()
@IsString()
@Field({ nullable: true })
syncCursor?: string;
@IsOptional()
@IsBoolean()
@Field({ nullable: true })
isSynced?: boolean;
@IsOptional()
@IsEnum(MessageFolderPendingSyncAction)
@Field(() => MessageFolderPendingSyncAction, { nullable: true })
pendingSyncAction?: MessageFolderPendingSyncAction;
}
@InputType()
export class UpdateMessageFolderInput {
@IsUUID()
@IsNotEmpty()
@Field(() => UUIDScalarType)
id: string;
@Type(() => UpdateMessageFolderInputUpdates)
@ValidateNested()
@Field(() => UpdateMessageFolderInputUpdates)
update: UpdateMessageFolderInputUpdates;
}
@@ -0,0 +1,63 @@
import {
Column,
CreateDateColumn,
Entity,
JoinColumn,
ManyToOne,
PrimaryGeneratedColumn,
type Relation,
UpdateDateColumn,
} from 'typeorm';
import { MessageFolderPendingSyncAction } from 'twenty-shared/types';
import { MessageChannelEntity } from 'src/engine/metadata-modules/message-channel/entities/message-channel.entity';
import { WorkspaceRelatedEntity } from 'src/engine/workspace-manager/types/workspace-related-entity';
@Entity({ name: 'messageFolder', schema: 'core' })
export class MessageFolderEntity extends WorkspaceRelatedEntity {
@PrimaryGeneratedColumn('uuid')
id: string;
@Column({ type: 'varchar', nullable: true })
name: string | null;
@Column({ type: 'varchar', nullable: true })
syncCursor: string | null;
@Column({ type: 'boolean', nullable: false })
isSentFolder: boolean;
@Column({ type: 'boolean', nullable: false })
isSynced: boolean;
@Column({ type: 'uuid', nullable: true })
parentFolderId: string | null;
@Column({ type: 'varchar', nullable: true })
externalId: string | null;
@Column({
type: 'enum',
enum: MessageFolderPendingSyncAction,
nullable: false,
})
pendingSyncAction: MessageFolderPendingSyncAction;
@Column({ type: 'uuid', nullable: false })
messageChannelId: string;
@ManyToOne(
() => MessageChannelEntity,
(messageChannel) => messageChannel.messageFolders,
{ onDelete: 'CASCADE' },
)
@JoinColumn({ name: 'messageChannelId' })
messageChannel: Relation<MessageChannelEntity>;
@CreateDateColumn({ type: 'timestamptz' })
createdAt: Date;
@UpdateDateColumn({ type: 'timestamptz' })
updatedAt: Date;
}
@@ -0,0 +1,24 @@
import {
type CallHandler,
type ExecutionContext,
Injectable,
type NestInterceptor,
} from '@nestjs/common';
import { type Observable, catchError } from 'rxjs';
import { messageFolderGraphqlApiExceptionHandler } from 'src/engine/metadata-modules/message-folder/utils/message-folder-graphql-api-exception-handler.util';
@Injectable()
export class MessageFolderGraphqlApiExceptionInterceptor
implements NestInterceptor
{
intercept(
_context: ExecutionContext,
next: CallHandler,
): Observable<unknown> {
return next
.handle()
.pipe(catchError(messageFolderGraphqlApiExceptionHandler));
}
}
@@ -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 { MessageFolderEntity } from 'src/engine/metadata-modules/message-folder/entities/message-folder.entity';
import { MessageFolderGraphqlApiExceptionInterceptor } from 'src/engine/metadata-modules/message-folder/interceptors/message-folder-graphql-api-exception.interceptor';
import { MessageFolderMetadataService } from 'src/engine/metadata-modules/message-folder/message-folder-metadata.service';
import { MessageFolderResolver } from 'src/engine/metadata-modules/message-folder/resolvers/message-folder.resolver';
import { PermissionsModule } from 'src/engine/metadata-modules/permissions/permissions.module';
@Module({
imports: [
TypeOrmModule.forFeature([MessageFolderEntity]),
AuthModule,
PermissionsModule,
FeatureFlagModule,
],
providers: [
MessageFolderMetadataService,
MessageFolderResolver,
MessageFolderGraphqlApiExceptionInterceptor,
],
exports: [MessageFolderMetadataService],
})
export class MessageFolderMetadataModule {}
@@ -0,0 +1,72 @@
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { MessageFolderPendingSyncAction } from 'twenty-shared/types';
import { MessageFolderDTO } from 'src/engine/metadata-modules/message-folder/dtos/message-folder.dto';
import { MessageFolderEntity } from 'src/engine/metadata-modules/message-folder/entities/message-folder.entity';
@Injectable()
export class MessageFolderMetadataService {
constructor(
@InjectRepository(MessageFolderEntity)
private readonly repository: Repository<MessageFolderEntity>,
) {}
async findAll(workspaceId: string): Promise<MessageFolderDTO[]> {
return this.repository.find({ where: { workspaceId } });
}
async findByMessageChannelId(
messageChannelId: string,
workspaceId: string,
): Promise<MessageFolderDTO[]> {
return this.repository.find({
where: { messageChannelId, workspaceId },
});
}
async findById(
id: string,
workspaceId: string,
): Promise<MessageFolderDTO | null> {
return this.repository.findOne({ where: { id, workspaceId } });
}
async create(
data: Partial<MessageFolderEntity> & {
workspaceId: string;
messageChannelId: string;
pendingSyncAction: MessageFolderPendingSyncAction;
},
): Promise<MessageFolderDTO> {
const entity = this.repository.create(data);
return this.repository.save(entity);
}
async update(
id: string,
workspaceId: string,
data: Partial<MessageFolderEntity>,
): Promise<MessageFolderDTO> {
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<MessageFolderDTO> {
const entity = await this.repository.findOneOrFail({
where: { id, workspaceId },
});
await this.repository.delete({ id, workspaceId });
return entity;
}
}
@@ -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 MessageFolderExceptionCode {
MESSAGE_FOLDER_NOT_FOUND = 'MESSAGE_FOLDER_NOT_FOUND',
INVALID_MESSAGE_FOLDER_INPUT = 'INVALID_MESSAGE_FOLDER_INPUT',
}
const getMessageFolderExceptionUserFriendlyMessage = (
code: MessageFolderExceptionCode,
) => {
switch (code) {
case MessageFolderExceptionCode.MESSAGE_FOLDER_NOT_FOUND:
return msg`Message folder not found.`;
case MessageFolderExceptionCode.INVALID_MESSAGE_FOLDER_INPUT:
return msg`Invalid message folder input.`;
default:
assertUnreachable(code);
}
};
export class MessageFolderException extends CustomException<MessageFolderExceptionCode> {
constructor(
message: string,
code: MessageFolderExceptionCode,
{ userFriendlyMessage }: { userFriendlyMessage?: MessageDescriptor } = {},
) {
super(message, code, {
userFriendlyMessage:
userFriendlyMessage ??
getMessageFolderExceptionUserFriendlyMessage(code),
});
}
}
@@ -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 { CreateMessageFolderInput } from 'src/engine/metadata-modules/message-folder/dtos/create-message-folder.input';
import { MessageFolderDTO } from 'src/engine/metadata-modules/message-folder/dtos/message-folder.dto';
import { UpdateMessageFolderInput } from 'src/engine/metadata-modules/message-folder/dtos/update-message-folder.input';
import { MessageFolderGraphqlApiExceptionInterceptor } from 'src/engine/metadata-modules/message-folder/interceptors/message-folder-graphql-api-exception.interceptor';
import { MessageFolderMetadataService } from 'src/engine/metadata-modules/message-folder/message-folder-metadata.service';
@UseGuards(WorkspaceAuthGuard, FeatureFlagGuard)
@UseInterceptors(MessageFolderGraphqlApiExceptionInterceptor)
@MetadataResolver(() => MessageFolderDTO)
export class MessageFolderResolver {
constructor(
private readonly messageFolderMetadataService: MessageFolderMetadataService,
) {}
@Query(() => [MessageFolderDTO])
@UseGuards(SettingsPermissionGuard(PermissionFlagType.CONNECTED_ACCOUNTS))
@RequireFeatureFlag(FeatureFlagKey.IS_CONNECTED_ACCOUNT_MIGRATED)
async messageFolders(
@AuthWorkspace() workspace: WorkspaceEntity,
@Args('messageChannelId', {
type: () => UUIDScalarType,
nullable: true,
})
messageChannelId?: string,
): Promise<MessageFolderDTO[]> {
if (messageChannelId) {
return this.messageFolderMetadataService.findByMessageChannelId(
messageChannelId,
workspace.id,
);
}
return this.messageFolderMetadataService.findAll(workspace.id);
}
@Query(() => MessageFolderDTO, { nullable: true })
@UseGuards(SettingsPermissionGuard(PermissionFlagType.CONNECTED_ACCOUNTS))
@RequireFeatureFlag(FeatureFlagKey.IS_CONNECTED_ACCOUNT_MIGRATED)
async messageFolder(
@Args('id', { type: () => UUIDScalarType }) id: string,
@AuthWorkspace() workspace: WorkspaceEntity,
): Promise<MessageFolderDTO | null> {
return this.messageFolderMetadataService.findById(id, workspace.id);
}
@Mutation(() => MessageFolderDTO)
@UseGuards(SettingsPermissionGuard(PermissionFlagType.CONNECTED_ACCOUNTS))
@RequireFeatureFlag(FeatureFlagKey.IS_CONNECTED_ACCOUNT_MIGRATED)
async createMessageFolder(
@Args('input') input: CreateMessageFolderInput,
@AuthWorkspace() workspace: WorkspaceEntity,
): Promise<MessageFolderDTO> {
return this.messageFolderMetadataService.create({
...input,
workspaceId: workspace.id,
});
}
@Mutation(() => MessageFolderDTO)
@UseGuards(SettingsPermissionGuard(PermissionFlagType.CONNECTED_ACCOUNTS))
@RequireFeatureFlag(FeatureFlagKey.IS_CONNECTED_ACCOUNT_MIGRATED)
async updateMessageFolder(
@Args('input') input: UpdateMessageFolderInput,
@AuthWorkspace() workspace: WorkspaceEntity,
): Promise<MessageFolderDTO> {
return this.messageFolderMetadataService.update(
input.id,
workspace.id,
input.update,
);
}
@Mutation(() => MessageFolderDTO)
@UseGuards(SettingsPermissionGuard(PermissionFlagType.CONNECTED_ACCOUNTS))
@RequireFeatureFlag(FeatureFlagKey.IS_CONNECTED_ACCOUNT_MIGRATED)
async deleteMessageFolder(
@Args('id', { type: () => UUIDScalarType }) id: string,
@AuthWorkspace() workspace: WorkspaceEntity,
): Promise<MessageFolderDTO> {
return this.messageFolderMetadataService.delete(id, workspace.id);
}
}
@@ -0,0 +1,26 @@
import { assertUnreachable } from 'twenty-shared/utils';
import {
NotFoundError,
UserInputError,
} from 'src/engine/core-modules/graphql/utils/graphql-errors.util';
import {
MessageFolderException,
MessageFolderExceptionCode,
} from 'src/engine/metadata-modules/message-folder/message-folder.exception';
export const messageFolderGraphqlApiExceptionHandler = (error: Error) => {
if (error instanceof MessageFolderException) {
switch (error.code) {
case MessageFolderExceptionCode.MESSAGE_FOLDER_NOT_FOUND:
throw new NotFoundError(error);
case MessageFolderExceptionCode.INVALID_MESSAGE_FOLDER_INPUT:
throw new UserInputError(error);
default: {
return assertUnreachable(error.code);
}
}
}
throw error;
};
@@ -5,6 +5,8 @@ import { AiAgentMonitorModule } from 'src/engine/metadata-modules/ai/ai-agent-mo
import { AiAgentModule } from 'src/engine/metadata-modules/ai/ai-agent/ai-agent.module';
import { AiChatModule } from 'src/engine/metadata-modules/ai/ai-chat/ai-chat.module';
import { AiGenerateTextModule } from 'src/engine/metadata-modules/ai/ai-generate-text/ai-generate-text.module';
import { CalendarChannelMetadataModule } from 'src/engine/metadata-modules/calendar-channel/calendar-channel-metadata.module';
import { ConnectedAccountMetadataModule } from 'src/engine/metadata-modules/connected-account/connected-account-metadata.module';
import { CommandMenuItemModule } from 'src/engine/metadata-modules/command-menu-item/command-menu-item.module';
import { DataSourceModule } from 'src/engine/metadata-modules/data-source/data-source.module';
import { FieldMetadataModule } from 'src/engine/metadata-modules/field-metadata/field-metadata.module';
@@ -12,6 +14,8 @@ import { FlatEntityMapsGraphqlApiExceptionFilter } from 'src/engine/metadata-mod
import { FrontComponentModule } from 'src/engine/metadata-modules/front-component/front-component.module';
import { LogicFunctionLayerModule } from 'src/engine/metadata-modules/logic-function-layer/logic-function-layer.module';
import { LogicFunctionModule } from 'src/engine/metadata-modules/logic-function/logic-function.module';
import { MessageChannelMetadataModule } from 'src/engine/metadata-modules/message-channel/message-channel-metadata.module';
import { MessageFolderMetadataModule } from 'src/engine/metadata-modules/message-folder/message-folder-metadata.module';
import { NavigationMenuItemModule } from 'src/engine/metadata-modules/navigation-menu-item/navigation-menu-item.module';
import { ObjectMetadataModule } from 'src/engine/metadata-modules/object-metadata/object-metadata.module';
import { PermissionsModule } from 'src/engine/metadata-modules/permissions/permissions.module';
@@ -47,6 +51,10 @@ import { WorkspaceMetadataVersionModule } from 'src/engine/metadata-modules/work
PermissionsModule,
RouteTriggerModule,
WebhookModule,
ConnectedAccountMetadataModule,
MessageChannelMetadataModule,
CalendarChannelMetadataModule,
MessageFolderMetadataModule,
],
providers: [
{
@@ -71,6 +79,10 @@ import { WorkspaceMetadataVersionModule } from 'src/engine/metadata-modules/work
RoleModule,
PermissionsModule,
WebhookModule,
ConnectedAccountMetadataModule,
MessageChannelMetadataModule,
CalendarChannelMetadataModule,
MessageFolderMetadataModule,
],
})
export class MetadataEngineModule {}