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,382 @@
import { InjectRepository } from '@nestjs/typeorm';
import { isNonEmptyString } from '@sniptt/guards';
import { Command } from 'nest-commander';
import { FeatureFlagKey } from 'twenty-shared/types';
import { Repository } from 'typeorm';
import { ActiveOrSuspendedWorkspacesMigrationCommandRunner } from 'src/database/commands/command-runners/active-or-suspended-workspaces-migration.command-runner';
import { type RunOnWorkspaceArgs } from 'src/database/commands/command-runners/workspaces-migration.command-runner';
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 { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity';
import { CalendarChannelEntity } from 'src/engine/metadata-modules/calendar-channel/entities/calendar-channel.entity';
import { ConnectedAccountEntity } from 'src/engine/metadata-modules/connected-account/entities/connected-account.entity';
import { DataSourceService } from 'src/engine/metadata-modules/data-source/data-source.service';
import { MessageChannelEntity } from 'src/engine/metadata-modules/message-channel/entities/message-channel.entity';
import { MessageFolderEntity } from 'src/engine/metadata-modules/message-folder/entities/message-folder.entity';
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';
import { type ConnectedAccountWorkspaceEntity } from 'src/modules/connected-account/standard-objects/connected-account.workspace-entity';
import { type MessageChannelWorkspaceEntity } from 'src/modules/messaging/common/standard-objects/message-channel.workspace-entity';
import { type MessageFolderWorkspaceEntity } from 'src/modules/messaging/common/standard-objects/message-folder.workspace-entity';
import { type WorkspaceMemberWorkspaceEntity } from 'src/modules/workspace-member/standard-objects/workspace-member.workspace-entity';
@Command({
name: 'upgrade:1-20:migrate-messaging-infrastructure-to-metadata',
description:
'Backfill connectedAccount, messageChannel, calendarChannel, and messageFolder to core metadata schema',
})
export class MigrateMessagingInfrastructureToMetadataCommand extends ActiveOrSuspendedWorkspacesMigrationCommandRunner {
constructor(
@InjectRepository(WorkspaceEntity)
protected readonly workspaceRepository: Repository<WorkspaceEntity>,
protected readonly twentyORMGlobalManager: GlobalWorkspaceOrmManager,
protected readonly dataSourceService: DataSourceService,
@InjectRepository(ConnectedAccountEntity)
private readonly connectedAccountRepository: Repository<ConnectedAccountEntity>,
@InjectRepository(MessageChannelEntity)
private readonly messageChannelRepository: Repository<MessageChannelEntity>,
@InjectRepository(CalendarChannelEntity)
private readonly calendarChannelRepository: Repository<CalendarChannelEntity>,
@InjectRepository(MessageFolderEntity)
private readonly messageFolderRepository: Repository<MessageFolderEntity>,
@InjectRepository(UserWorkspaceEntity)
private readonly userWorkspaceRepository: Repository<UserWorkspaceEntity>,
private readonly featureFlagService: FeatureFlagService,
) {
super(workspaceRepository, twentyORMGlobalManager, dataSourceService);
}
override async runOnWorkspace({
workspaceId,
options,
}: RunOnWorkspaceArgs): Promise<void> {
const isDryRun = options.dryRun ?? false;
const isAlreadyMigrated = await this.featureFlagService.isFeatureEnabled(
FeatureFlagKey.IS_CONNECTED_ACCOUNT_MIGRATED,
workspaceId,
);
if (isAlreadyMigrated) {
this.logger.log(
`IS_CONNECTED_ACCOUNT_MIGRATED already enabled for workspace ${workspaceId}, skipping`,
);
return;
}
const connectedAccountWorkspaceRepository =
await this.twentyORMGlobalManager.getRepository<ConnectedAccountWorkspaceEntity>(
workspaceId,
'connectedAccount',
);
const messageChannelWorkspaceRepository =
await this.twentyORMGlobalManager.getRepository<MessageChannelWorkspaceEntity>(
workspaceId,
'messageChannel',
);
const calendarChannelWorkspaceRepository =
await this.twentyORMGlobalManager.getRepository<CalendarChannelWorkspaceEntity>(
workspaceId,
'calendarChannel',
);
const messageFolderWorkspaceRepository =
await this.twentyORMGlobalManager.getRepository<MessageFolderWorkspaceEntity>(
workspaceId,
'messageFolder',
);
const connectedAccounts = await connectedAccountWorkspaceRepository.find();
const messageChannels = await messageChannelWorkspaceRepository.find();
const calendarChannels = await calendarChannelWorkspaceRepository.find();
const messageFolders = await messageFolderWorkspaceRepository.find();
const workspaceMemberIdToUserWorkspaceIdMap =
await this.buildWorkspaceMemberIdToUserWorkspaceIdMap(workspaceId);
const connectedAccountsWithMissingHandle = connectedAccounts.filter(
(account) => !account.handle,
);
const connectedAccountsWithUnresolvedOwner = connectedAccounts.filter(
(account) =>
!workspaceMemberIdToUserWorkspaceIdMap.has(account.accountOwnerId),
);
const messageChannelsWithMissingHandle = messageChannels.filter(
(channel) => !channel.handle,
);
const calendarChannelsWithMissingHandle = calendarChannels.filter(
(channel) => !channel.handle,
);
if (isDryRun) {
this.logger.log(
`[DRY RUN] Workspace ${workspaceId}: ` +
`${connectedAccounts.length} connected accounts, ` +
`${messageChannels.length} message channels, ` +
`${calendarChannels.length} calendar channels, ` +
`${messageFolders.length} message folders`,
);
if (connectedAccountsWithMissingHandle.length > 0) {
this.logger.warn(
`[DRY RUN] ${connectedAccountsWithMissingHandle.length} connected accounts have empty handle`,
);
}
if (connectedAccountsWithUnresolvedOwner.length > 0) {
this.logger.warn(
`[DRY RUN] ${connectedAccountsWithUnresolvedOwner.length} connected accounts have unresolvable accountOwnerId (no matching userWorkspace)`,
);
}
if (messageChannelsWithMissingHandle.length > 0) {
this.logger.warn(
`[DRY RUN] ${messageChannelsWithMissingHandle.length} message channels have empty handle`,
);
}
if (calendarChannelsWithMissingHandle.length > 0) {
this.logger.warn(
`[DRY RUN] ${calendarChannelsWithMissingHandle.length} calendar channels have empty handle`,
);
}
return;
}
let migratedConnectedAccountIds = new Set(
connectedAccounts.map((account) => account.id),
);
let migratedMessageChannelIds = new Set(
messageChannels.map((channel) => channel.id),
);
if (connectedAccounts.length > 0) {
const coreConnectedAccounts = connectedAccounts
.filter((workspaceEntity) => {
const userWorkspaceId = workspaceMemberIdToUserWorkspaceIdMap.get(
workspaceEntity.accountOwnerId,
);
if (!userWorkspaceId) {
this.logger.warn(
`Skipping connected account ${workspaceEntity.id}: no userWorkspace found for workspaceMember ${workspaceEntity.accountOwnerId}`,
);
return false;
}
return true;
})
.map((workspaceEntity) => {
const handleAliases = isNonEmptyString(workspaceEntity.handleAliases)
? workspaceEntity.handleAliases
.split(',')
.map((alias) => alias.trim())
: null;
return {
id: workspaceEntity.id,
handle: workspaceEntity.handle ?? '',
provider: workspaceEntity.provider,
accessToken: workspaceEntity.accessToken,
refreshToken: workspaceEntity.refreshToken,
lastCredentialsRefreshedAt:
workspaceEntity.lastCredentialsRefreshedAt,
authFailedAt: workspaceEntity.authFailedAt,
handleAliases,
scopes: workspaceEntity.scopes,
connectionParameters:
workspaceEntity.connectionParameters as Record<
string,
unknown
> | null,
userWorkspaceId: workspaceMemberIdToUserWorkspaceIdMap.get(
workspaceEntity.accountOwnerId,
)!,
workspaceId,
createdAt: workspaceEntity.createdAt,
updatedAt: workspaceEntity.updatedAt,
};
});
if (coreConnectedAccounts.length > 0) {
await this.connectedAccountRepository.save(
coreConnectedAccounts as unknown as ConnectedAccountEntity[],
);
this.logger.log(
`Migrated ${coreConnectedAccounts.length} connected accounts for workspace ${workspaceId}`,
);
}
migratedConnectedAccountIds = new Set(
coreConnectedAccounts.map((account) => account.id),
);
}
if (messageChannels.length > 0) {
const coreMessageChannels = messageChannels
.filter((workspaceEntity) =>
migratedConnectedAccountIds.has(workspaceEntity.connectedAccountId),
)
.map((workspaceEntity) => ({
id: workspaceEntity.id,
visibility: workspaceEntity.visibility,
handle: workspaceEntity.handle ?? '',
type: workspaceEntity.type,
isContactAutoCreationEnabled:
workspaceEntity.isContactAutoCreationEnabled,
contactAutoCreationPolicy: workspaceEntity.contactAutoCreationPolicy,
messageFolderImportPolicy: workspaceEntity.messageFolderImportPolicy,
excludeNonProfessionalEmails:
workspaceEntity.excludeNonProfessionalEmails,
excludeGroupEmails: workspaceEntity.excludeGroupEmails,
pendingGroupEmailsAction: workspaceEntity.pendingGroupEmailsAction,
isSyncEnabled: workspaceEntity.isSyncEnabled,
syncCursor: workspaceEntity.syncCursor,
syncedAt: workspaceEntity.syncedAt
? new Date(workspaceEntity.syncedAt)
: null,
syncStatus: workspaceEntity.syncStatus ?? 'NOT_SYNCED',
syncStage: workspaceEntity.syncStage,
syncStageStartedAt: workspaceEntity.syncStageStartedAt
? new Date(workspaceEntity.syncStageStartedAt)
: null,
throttleFailureCount: workspaceEntity.throttleFailureCount,
throttleRetryAfter: workspaceEntity.throttleRetryAfter
? new Date(workspaceEntity.throttleRetryAfter)
: null,
connectedAccountId: workspaceEntity.connectedAccountId,
workspaceId,
createdAt: workspaceEntity.createdAt,
updatedAt: workspaceEntity.updatedAt,
}));
if (coreMessageChannels.length > 0) {
await this.messageChannelRepository.save(
coreMessageChannels as unknown as MessageChannelEntity[],
);
this.logger.log(
`Migrated ${coreMessageChannels.length} message channels for workspace ${workspaceId}`,
);
}
migratedMessageChannelIds = new Set(
coreMessageChannels.map((channel) => channel.id),
);
}
if (calendarChannels.length > 0) {
const coreCalendarChannels = calendarChannels
.filter((workspaceEntity) =>
migratedConnectedAccountIds.has(workspaceEntity.connectedAccountId),
)
.map((workspaceEntity) => ({
id: workspaceEntity.id,
handle: workspaceEntity.handle ?? '',
syncStatus: workspaceEntity.syncStatus ?? 'NOT_SYNCED',
syncStage: workspaceEntity.syncStage,
visibility: workspaceEntity.visibility,
isContactAutoCreationEnabled:
workspaceEntity.isContactAutoCreationEnabled,
contactAutoCreationPolicy: workspaceEntity.contactAutoCreationPolicy,
isSyncEnabled: workspaceEntity.isSyncEnabled,
syncCursor: workspaceEntity.syncCursor,
syncedAt: workspaceEntity.syncedAt
? new Date(workspaceEntity.syncedAt)
: null,
syncStageStartedAt: workspaceEntity.syncStageStartedAt
? new Date(workspaceEntity.syncStageStartedAt)
: null,
throttleFailureCount: workspaceEntity.throttleFailureCount,
connectedAccountId: workspaceEntity.connectedAccountId,
workspaceId,
createdAt: workspaceEntity.createdAt,
updatedAt: workspaceEntity.updatedAt,
}));
await this.calendarChannelRepository.save(
coreCalendarChannels as unknown as CalendarChannelEntity[],
);
this.logger.log(
`Migrated ${coreCalendarChannels.length} calendar channels for workspace ${workspaceId}`,
);
}
if (messageFolders.length > 0) {
const coreMessageFolders = messageFolders
.filter((workspaceEntity) =>
migratedMessageChannelIds.has(workspaceEntity.messageChannelId),
)
.map((workspaceEntity) => ({
id: workspaceEntity.id,
name: workspaceEntity.name,
syncCursor: workspaceEntity.syncCursor,
isSentFolder: workspaceEntity.isSentFolder,
isSynced: workspaceEntity.isSynced,
parentFolderId: isNonEmptyString(workspaceEntity.parentFolderId)
? workspaceEntity.parentFolderId
: null,
externalId: workspaceEntity.externalId,
pendingSyncAction: workspaceEntity.pendingSyncAction,
messageChannelId: workspaceEntity.messageChannelId,
workspaceId,
createdAt: workspaceEntity.createdAt,
updatedAt: workspaceEntity.updatedAt,
}));
await this.messageFolderRepository.save(
coreMessageFolders as unknown as MessageFolderEntity[],
);
this.logger.log(
`Migrated ${coreMessageFolders.length} message folders for workspace ${workspaceId}`,
);
}
await this.featureFlagService.enableFeatureFlags(
[FeatureFlagKey.IS_CONNECTED_ACCOUNT_MIGRATED],
workspaceId,
);
this.logger.log(
`Enabled IS_CONNECTED_ACCOUNT_MIGRATED for workspace ${workspaceId}`,
);
}
private async buildWorkspaceMemberIdToUserWorkspaceIdMap(
workspaceId: string,
): Promise<Map<string, string>> {
const workspaceMemberRepository =
await this.twentyORMGlobalManager.getRepository<WorkspaceMemberWorkspaceEntity>(
workspaceId,
'workspaceMember',
);
const workspaceMembers = await workspaceMemberRepository.find();
const userWorkspaces = await this.userWorkspaceRepository.find({
where: { workspaceId },
select: ['id', 'userId'],
});
const userWorkspaceIdByUserId = new Map(
userWorkspaces.map((userWorkspace) => [
userWorkspace.userId,
userWorkspace.id,
]),
);
return new Map(
workspaceMembers
.filter((member) => userWorkspaceIdByUserId.has(member.userId))
.map((member) => [
member.id,
userWorkspaceIdByUserId.get(member.userId)!,
]),
);
}
}
@@ -8,13 +8,19 @@ import { IdentifyObjectPermissionMetadataCommand } from 'src/database/commands/u
import { IdentifyPermissionFlagMetadataCommand } from 'src/database/commands/upgrade-version-command/1-20/1-20-identify-permission-flag-metadata.command';
import { MakeObjectPermissionUniversalIdentifierAndApplicationIdNotNullableMigrationCommand } from 'src/database/commands/upgrade-version-command/1-20/1-20-make-object-permission-universal-identifier-and-application-id-not-nullable-migration.command';
import { MakePermissionFlagUniversalIdentifierAndApplicationIdNotNullableMigrationCommand } from 'src/database/commands/upgrade-version-command/1-20/1-20-make-permission-flag-universal-identifier-and-application-id-not-nullable-migration.command';
import { MigrateMessagingInfrastructureToMetadataCommand } from 'src/database/commands/upgrade-version-command/1-20/1-20-migrate-messaging-infrastructure-to-metadata.command';
import { MigrateRichTextToTextCommand } from 'src/database/commands/upgrade-version-command/1-20/1-20-migrate-rich-text-to-text.command';
import { SeedCliApplicationRegistrationCommand } from 'src/database/commands/upgrade-version-command/1-20/1-20-seed-cli-application-registration.command';
import { ApplicationRegistrationModule } from 'src/engine/core-modules/application/application-registration/application-registration.module';
import { ApplicationModule } from 'src/engine/core-modules/application/application.module';
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 { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity';
import { CalendarChannelEntity } from 'src/engine/metadata-modules/calendar-channel/entities/calendar-channel.entity';
import { ConnectedAccountEntity } from 'src/engine/metadata-modules/connected-account/entities/connected-account.entity';
import { DataSourceModule } from 'src/engine/metadata-modules/data-source/data-source.module';
import { MessageChannelEntity } from 'src/engine/metadata-modules/message-channel/entities/message-channel.entity';
import { MessageFolderEntity } from 'src/engine/metadata-modules/message-folder/entities/message-folder.entity';
import { WorkspaceMetadataVersionModule } from 'src/engine/metadata-modules/workspace-metadata-version/workspace-metadata-version.module';
import { WorkspaceCacheStorageModule } from 'src/engine/workspace-cache-storage/workspace-cache-storage.module';
import { WorkspaceCacheModule } from 'src/engine/workspace-cache/workspace-cache.module';
@@ -23,7 +29,14 @@ import { WorkspaceMigrationModule } from 'src/engine/workspace-manager/workspace
@Module({
imports: [
TypeOrmModule.forFeature([WorkspaceEntity]),
TypeOrmModule.forFeature([
WorkspaceEntity,
ConnectedAccountEntity,
MessageChannelEntity,
CalendarChannelEntity,
MessageFolderEntity,
UserWorkspaceEntity,
]),
DataSourceModule,
WorkspaceCacheModule,
WorkspaceCacheStorageModule,
@@ -44,6 +57,7 @@ import { WorkspaceMigrationModule } from 'src/engine/workspace-manager/workspace
BackfillPageLayoutsCommand,
SeedCliApplicationRegistrationCommand,
MigrateRichTextToTextCommand,
MigrateMessagingInfrastructureToMetadataCommand,
],
exports: [
IdentifyPermissionFlagMetadataCommand,
@@ -55,6 +69,7 @@ import { WorkspaceMigrationModule } from 'src/engine/workspace-manager/workspace
BackfillPageLayoutsCommand,
SeedCliApplicationRegistrationCommand,
MigrateRichTextToTextCommand,
MigrateMessagingInfrastructureToMetadataCommand,
],
})
export class V1_20_UpgradeVersionCommandModule {}
@@ -40,6 +40,7 @@ import { IdentifyObjectPermissionMetadataCommand } from 'src/database/commands/u
import { IdentifyPermissionFlagMetadataCommand } from 'src/database/commands/upgrade-version-command/1-20/1-20-identify-permission-flag-metadata.command';
import { MakeObjectPermissionUniversalIdentifierAndApplicationIdNotNullableMigrationCommand } from 'src/database/commands/upgrade-version-command/1-20/1-20-make-object-permission-universal-identifier-and-application-id-not-nullable-migration.command';
import { MakePermissionFlagUniversalIdentifierAndApplicationIdNotNullableMigrationCommand } from 'src/database/commands/upgrade-version-command/1-20/1-20-make-permission-flag-universal-identifier-and-application-id-not-nullable-migration.command';
import { MigrateMessagingInfrastructureToMetadataCommand } from 'src/database/commands/upgrade-version-command/1-20/1-20-migrate-messaging-infrastructure-to-metadata.command';
import { MigrateRichTextToTextCommand } from 'src/database/commands/upgrade-version-command/1-20/1-20-migrate-rich-text-to-text.command';
import { SeedCliApplicationRegistrationCommand } from 'src/database/commands/upgrade-version-command/1-20/1-20-seed-cli-application-registration.command';
import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service';
@@ -101,6 +102,7 @@ export class UpgradeCommand extends UpgradeCommandRunner {
protected readonly backfillPageLayoutsCommand: BackfillPageLayoutsCommand,
protected readonly seedCliApplicationRegistrationCommand: SeedCliApplicationRegistrationCommand,
protected readonly migrateRichTextToTextCommand: MigrateRichTextToTextCommand,
protected readonly migrateMessagingInfrastructureToMetadataCommand: MigrateMessagingInfrastructureToMetadataCommand,
) {
super(
workspaceRepository,
@@ -159,6 +161,7 @@ export class UpgradeCommand extends UpgradeCommandRunner {
this.backfillCommandMenuItemsCommand,
this.backfillPageLayoutsCommand,
this.seedCliApplicationRegistrationCommand,
this.migrateMessagingInfrastructureToMetadataCommand,
];
this.allCommands = {
@@ -0,0 +1,139 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
export class AddMessagingInfrastructureMetadataEntities1773945207801
implements MigrationInterface
{
name = 'AddMessagingInfrastructureMetadataEntities1773945207801';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`CREATE TABLE "core"."connectedAccount" ("workspaceId" uuid NOT NULL, "id" uuid NOT NULL DEFAULT uuid_generate_v4(), "handle" character varying NOT NULL, "provider" character varying NOT NULL, "accessToken" character varying, "refreshToken" character varying, "lastCredentialsRefreshedAt" TIMESTAMP WITH TIME ZONE, "authFailedAt" TIMESTAMP WITH TIME ZONE, "handleAliases" character varying array, "scopes" character varying array, "connectionParameters" jsonb, "lastSignedInAt" TIMESTAMP WITH TIME ZONE, "oidcTokenClaims" jsonb, "userWorkspaceId" uuid NOT NULL, "createdAt" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(), "updatedAt" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(), CONSTRAINT "PK_8e7a0a0bbc2e06ac2acf89b7f3a" PRIMARY KEY ("id"))`,
);
await queryRunner.query(
`CREATE TYPE "core"."messageChannel_visibility_enum" AS ENUM('METADATA', 'SUBJECT', 'SHARE_EVERYTHING')`,
);
await queryRunner.query(
`CREATE TYPE "core"."messageChannel_type_enum" AS ENUM('EMAIL', 'SMS')`,
);
await queryRunner.query(
`CREATE TYPE "core"."messageChannel_contactautocreationpolicy_enum" AS ENUM('SENT_AND_RECEIVED', 'SENT', 'NONE')`,
);
await queryRunner.query(
`CREATE TYPE "core"."messageChannel_messagefolderimportpolicy_enum" AS ENUM('ALL_FOLDERS', 'SELECTED_FOLDERS')`,
);
await queryRunner.query(
`CREATE TYPE "core"."messageChannel_pendinggroupemailsaction_enum" AS ENUM('GROUP_EMAILS_DELETION', 'GROUP_EMAILS_IMPORT', 'NONE')`,
);
await queryRunner.query(
`CREATE TYPE "core"."messageChannel_syncstatus_enum" AS ENUM('NOT_SYNCED', 'ONGOING', 'ACTIVE', 'FAILED_INSUFFICIENT_PERMISSIONS', 'FAILED_UNKNOWN')`,
);
await queryRunner.query(
`CREATE TYPE "core"."messageChannel_syncstage_enum" AS ENUM('PENDING_CONFIGURATION', 'MESSAGE_LIST_FETCH_PENDING', 'MESSAGE_LIST_FETCH_SCHEDULED', 'MESSAGE_LIST_FETCH_ONGOING', 'MESSAGES_IMPORT_PENDING', 'MESSAGES_IMPORT_SCHEDULED', 'MESSAGES_IMPORT_ONGOING', 'FAILED')`,
);
await queryRunner.query(
`CREATE TABLE "core"."messageChannel" ("workspaceId" uuid NOT NULL, "id" uuid NOT NULL DEFAULT uuid_generate_v4(), "visibility" "core"."messageChannel_visibility_enum" NOT NULL, "handle" character varying NOT NULL, "type" "core"."messageChannel_type_enum" NOT NULL, "isContactAutoCreationEnabled" boolean NOT NULL, "contactAutoCreationPolicy" "core"."messageChannel_contactautocreationpolicy_enum" NOT NULL, "messageFolderImportPolicy" "core"."messageChannel_messagefolderimportpolicy_enum" NOT NULL, "excludeNonProfessionalEmails" boolean NOT NULL, "excludeGroupEmails" boolean NOT NULL, "pendingGroupEmailsAction" "core"."messageChannel_pendinggroupemailsaction_enum" NOT NULL, "isSyncEnabled" boolean NOT NULL, "syncCursor" character varying, "syncedAt" TIMESTAMP WITH TIME ZONE, "syncStatus" "core"."messageChannel_syncstatus_enum" NOT NULL DEFAULT 'NOT_SYNCED', "syncStage" "core"."messageChannel_syncstage_enum" NOT NULL, "syncStageStartedAt" TIMESTAMP WITH TIME ZONE, "throttleFailureCount" integer NOT NULL DEFAULT '0', "throttleRetryAfter" TIMESTAMP WITH TIME ZONE, "connectedAccountId" uuid NOT NULL, "createdAt" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(), "updatedAt" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(), CONSTRAINT "PK_438b9412475f39712ed065f77af" PRIMARY KEY ("id"))`,
);
await queryRunner.query(
`CREATE TYPE "core"."messageFolder_pendingsyncaction_enum" AS ENUM('FOLDER_DELETION', 'NONE')`,
);
await queryRunner.query(
`CREATE TABLE "core"."messageFolder" ("workspaceId" uuid NOT NULL, "id" uuid NOT NULL DEFAULT uuid_generate_v4(), "name" character varying, "syncCursor" character varying, "isSentFolder" boolean NOT NULL, "isSynced" boolean NOT NULL, "parentFolderId" uuid, "externalId" character varying, "pendingSyncAction" "core"."messageFolder_pendingsyncaction_enum" NOT NULL, "messageChannelId" uuid NOT NULL, "createdAt" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(), "updatedAt" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(), CONSTRAINT "PK_85cb5a339d9f7f1106dde9e4db8" PRIMARY KEY ("id"))`,
);
await queryRunner.query(
`CREATE TYPE "core"."calendarChannel_syncstatus_enum" AS ENUM('NOT_SYNCED', 'ONGOING', 'ACTIVE', 'FAILED_INSUFFICIENT_PERMISSIONS', 'FAILED_UNKNOWN')`,
);
await queryRunner.query(
`CREATE TYPE "core"."calendarChannel_syncstage_enum" AS ENUM('PENDING_CONFIGURATION', 'CALENDAR_EVENT_LIST_FETCH_PENDING', 'CALENDAR_EVENT_LIST_FETCH_SCHEDULED', 'CALENDAR_EVENT_LIST_FETCH_ONGOING', 'CALENDAR_EVENTS_IMPORT_PENDING', 'CALENDAR_EVENTS_IMPORT_SCHEDULED', 'CALENDAR_EVENTS_IMPORT_ONGOING', 'FAILED')`,
);
await queryRunner.query(
`CREATE TYPE "core"."calendarChannel_visibility_enum" AS ENUM('METADATA', 'SHARE_EVERYTHING')`,
);
await queryRunner.query(
`CREATE TYPE "core"."calendarChannel_contactautocreationpolicy_enum" AS ENUM('AS_PARTICIPANT_AND_ORGANIZER', 'AS_PARTICIPANT', 'AS_ORGANIZER', 'NONE')`,
);
await queryRunner.query(
`CREATE TABLE "core"."calendarChannel" ("workspaceId" uuid NOT NULL, "id" uuid NOT NULL DEFAULT uuid_generate_v4(), "handle" character varying NOT NULL, "syncStatus" "core"."calendarChannel_syncstatus_enum" NOT NULL DEFAULT 'NOT_SYNCED', "syncStage" "core"."calendarChannel_syncstage_enum" NOT NULL, "visibility" "core"."calendarChannel_visibility_enum" NOT NULL, "isContactAutoCreationEnabled" boolean NOT NULL, "contactAutoCreationPolicy" "core"."calendarChannel_contactautocreationpolicy_enum" NOT NULL, "isSyncEnabled" boolean NOT NULL, "syncCursor" character varying, "syncedAt" TIMESTAMP WITH TIME ZONE, "syncStageStartedAt" TIMESTAMP WITH TIME ZONE, "throttleFailureCount" integer NOT NULL DEFAULT '0', "connectedAccountId" uuid NOT NULL, "createdAt" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(), "updatedAt" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(), CONSTRAINT "PK_796d701c0c35518517d0f3e0e0b" PRIMARY KEY ("id"))`,
);
await queryRunner.query(
`ALTER TABLE "core"."connectedAccount" ADD CONSTRAINT "FK_1c7af038a011e99c27044793c6a" FOREIGN KEY ("workspaceId") REFERENCES "core"."workspace"("id") ON DELETE CASCADE ON UPDATE NO ACTION`,
);
await queryRunner.query(
`ALTER TABLE "core"."messageChannel" ADD CONSTRAINT "FK_22d9a21a23fdd99295dc0efc177" FOREIGN KEY ("workspaceId") REFERENCES "core"."workspace"("id") ON DELETE CASCADE ON UPDATE NO ACTION`,
);
await queryRunner.query(
`ALTER TABLE "core"."messageChannel" ADD CONSTRAINT "FK_2e966cbb240771c67630d52895c" FOREIGN KEY ("connectedAccountId") REFERENCES "core"."connectedAccount"("id") ON DELETE CASCADE ON UPDATE NO ACTION`,
);
await queryRunner.query(
`ALTER TABLE "core"."messageFolder" ADD CONSTRAINT "FK_e7fb85af997d06d8f7cc7512801" FOREIGN KEY ("workspaceId") REFERENCES "core"."workspace"("id") ON DELETE CASCADE ON UPDATE NO ACTION`,
);
await queryRunner.query(
`ALTER TABLE "core"."messageFolder" ADD CONSTRAINT "FK_4237a2fe8a6583354f807c2f8fe" FOREIGN KEY ("messageChannelId") REFERENCES "core"."messageChannel"("id") ON DELETE CASCADE ON UPDATE NO ACTION`,
);
await queryRunner.query(
`ALTER TABLE "core"."calendarChannel" ADD CONSTRAINT "FK_bb5ebadf91b73c8050fb0a092fa" FOREIGN KEY ("workspaceId") REFERENCES "core"."workspace"("id") ON DELETE CASCADE ON UPDATE NO ACTION`,
);
await queryRunner.query(
`ALTER TABLE "core"."calendarChannel" ADD CONSTRAINT "FK_c7bc368c97a18a072413d67cf45" FOREIGN KEY ("connectedAccountId") REFERENCES "core"."connectedAccount"("id") ON DELETE CASCADE ON UPDATE NO ACTION`,
);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`ALTER TABLE "core"."calendarChannel" DROP CONSTRAINT "FK_c7bc368c97a18a072413d67cf45"`,
);
await queryRunner.query(
`ALTER TABLE "core"."calendarChannel" DROP CONSTRAINT "FK_bb5ebadf91b73c8050fb0a092fa"`,
);
await queryRunner.query(
`ALTER TABLE "core"."messageFolder" DROP CONSTRAINT "FK_4237a2fe8a6583354f807c2f8fe"`,
);
await queryRunner.query(
`ALTER TABLE "core"."messageFolder" DROP CONSTRAINT "FK_e7fb85af997d06d8f7cc7512801"`,
);
await queryRunner.query(
`ALTER TABLE "core"."messageChannel" DROP CONSTRAINT "FK_2e966cbb240771c67630d52895c"`,
);
await queryRunner.query(
`ALTER TABLE "core"."messageChannel" DROP CONSTRAINT "FK_22d9a21a23fdd99295dc0efc177"`,
);
await queryRunner.query(
`ALTER TABLE "core"."connectedAccount" DROP CONSTRAINT "FK_1c7af038a011e99c27044793c6a"`,
);
await queryRunner.query(`DROP TABLE "core"."calendarChannel"`);
await queryRunner.query(
`DROP TYPE "core"."calendarChannel_contactautocreationpolicy_enum"`,
);
await queryRunner.query(
`DROP TYPE "core"."calendarChannel_visibility_enum"`,
);
await queryRunner.query(
`DROP TYPE "core"."calendarChannel_syncstage_enum"`,
);
await queryRunner.query(
`DROP TYPE "core"."calendarChannel_syncstatus_enum"`,
);
await queryRunner.query(`DROP TABLE "core"."messageFolder"`);
await queryRunner.query(
`DROP TYPE "core"."messageFolder_pendingsyncaction_enum"`,
);
await queryRunner.query(`DROP TABLE "core"."messageChannel"`);
await queryRunner.query(`DROP TYPE "core"."messageChannel_syncstage_enum"`);
await queryRunner.query(
`DROP TYPE "core"."messageChannel_syncstatus_enum"`,
);
await queryRunner.query(
`DROP TYPE "core"."messageChannel_pendinggroupemailsaction_enum"`,
);
await queryRunner.query(
`DROP TYPE "core"."messageChannel_messagefolderimportpolicy_enum"`,
);
await queryRunner.query(
`DROP TYPE "core"."messageChannel_contactautocreationpolicy_enum"`,
);
await queryRunner.query(`DROP TYPE "core"."messageChannel_type_enum"`);
await queryRunner.query(
`DROP TYPE "core"."messageChannel_visibility_enum"`,
);
await queryRunner.query(`DROP TABLE "core"."connectedAccount"`);
}
}
@@ -59,6 +59,9 @@ import { WorkspaceInvitationModule } from 'src/engine/core-modules/workspace-inv
import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity';
import { WorkspaceModule } from 'src/engine/core-modules/workspace/workspace.module';
import { DataSourceModule } from 'src/engine/metadata-modules/data-source/data-source.module';
import { CalendarChannelDataAccessModule } from 'src/engine/metadata-modules/calendar-channel/data-access/calendar-channel-data-access.module';
import { ConnectedAccountDataAccessModule } from 'src/engine/metadata-modules/connected-account/data-access/connected-account-data-access.module';
import { MessageChannelDataAccessModule } from 'src/engine/metadata-modules/message-channel/data-access/message-channel-data-access.module';
import { ObjectMetadataEntity } from 'src/engine/metadata-modules/object-metadata/object-metadata.entity';
import { PermissionsModule } from 'src/engine/metadata-modules/permissions/permissions.module';
import { UserRoleModule } from 'src/engine/metadata-modules/user-role/user-role.module';
@@ -104,6 +107,9 @@ import { JwtAuthStrategy } from './strategies/jwt.auth.strategy';
ConnectedAccountModule,
MessagingCommonModule,
MessagingFolderSyncManagerModule,
CalendarChannelDataAccessModule,
ConnectedAccountDataAccessModule,
MessageChannelDataAccessModule,
WorkspaceSSOModule,
FeatureFlagModule,
WorkspaceInvitationModule,
@@ -2,6 +2,7 @@ import { Injectable } from '@nestjs/common';
import { v4 } from 'uuid';
import { CalendarChannelDataAccessService } from 'src/engine/metadata-modules/calendar-channel/data-access/services/calendar-channel-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 { buildSystemAuthContext } from 'src/engine/twenty-orm/utils/build-system-auth-context.util';
@@ -9,7 +10,6 @@ import {
CalendarChannelSyncStage,
CalendarChannelSyncStatus,
CalendarChannelVisibility,
type CalendarChannelWorkspaceEntity,
} from 'src/modules/calendar/common/standard-objects/calendar-channel.workspace-entity';
export type CreateCalendarChannelInput = {
@@ -25,6 +25,7 @@ export type CreateCalendarChannelInput = {
export class CreateCalendarChannelService {
constructor(
private readonly globalWorkspaceOrmManager: GlobalWorkspaceOrmManager,
private readonly calendarChannelDataAccessService: CalendarChannelDataAccessService,
) {}
async createCalendarChannel(
@@ -43,15 +44,12 @@ export class CreateCalendarChannelService {
return this.globalWorkspaceOrmManager.executeInWorkspaceContext(
async () => {
const calendarChannelRepository =
await this.globalWorkspaceOrmManager.getRepository<CalendarChannelWorkspaceEntity>(
workspaceId,
'calendarChannel',
);
const newCalendarChannelId = v4();
const newCalendarChannel = await calendarChannelRepository.save(
await this.calendarChannelDataAccessService.save(
workspaceId,
{
id: v4(),
id: newCalendarChannelId,
connectedAccountId,
handle,
visibility:
@@ -63,11 +61,10 @@ export class CreateCalendarChannelService {
? CalendarChannelSyncStage.CALENDAR_EVENT_LIST_FETCH_PENDING
: CalendarChannelSyncStage.PENDING_CONFIGURATION,
},
{},
manager,
);
return newCalendarChannel.id;
return newCalendarChannelId;
},
authContext,
);
@@ -2,10 +2,10 @@ import { Injectable } from '@nestjs/common';
import { type ConnectedAccountProvider } from 'twenty-shared/types';
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 { buildSystemAuthContext } from 'src/engine/twenty-orm/utils/build-system-auth-context.util';
import { type ConnectedAccountWorkspaceEntity } from 'src/modules/connected-account/standard-objects/connected-account.workspace-entity';
export type CreateConnectedAccountInput = {
workspaceId: string;
@@ -23,6 +23,7 @@ export type CreateConnectedAccountInput = {
export class CreateConnectedAccountService {
constructor(
private readonly globalWorkspaceOrmManager: GlobalWorkspaceOrmManager,
private readonly connectedAccountDataAccessService: ConnectedAccountDataAccessService,
) {}
async createConnectedAccount(
@@ -43,13 +44,8 @@ export class CreateConnectedAccountService {
const authContext = buildSystemAuthContext(workspaceId);
await this.globalWorkspaceOrmManager.executeInWorkspaceContext(async () => {
const connectedAccountRepository =
await this.globalWorkspaceOrmManager.getRepository<ConnectedAccountWorkspaceEntity>(
workspaceId,
'connectedAccount',
);
await connectedAccountRepository.save(
await this.connectedAccountDataAccessService.save(
workspaceId,
{
id: connectedAccountId,
handle,
@@ -59,7 +55,6 @@ export class CreateConnectedAccountService {
accountOwnerId,
scopes,
},
{},
manager,
);
}, authContext);
@@ -3,6 +3,7 @@ import { Injectable } from '@nestjs/common';
import { isDefined } from 'twenty-shared/utils';
import { v4 } from 'uuid';
import { MessageChannelDataAccessService } from 'src/engine/metadata-modules/message-channel/data-access/services/message-channel-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 { buildSystemAuthContext } from 'src/engine/twenty-orm/utils/build-system-auth-context.util';
@@ -29,6 +30,7 @@ export type CreateMessageChannelInput = {
export class CreateMessageChannelService {
constructor(
private readonly globalWorkspaceOrmManager: GlobalWorkspaceOrmManager,
private readonly messageChannelDataAccessService: MessageChannelDataAccessService,
private readonly syncMessageFoldersService: SyncMessageFoldersService,
) {}
@@ -48,15 +50,10 @@ export class CreateMessageChannelService {
return this.globalWorkspaceOrmManager.executeInWorkspaceContext(
async () => {
const messageChannelRepository =
await this.globalWorkspaceOrmManager.getRepository<MessageChannelWorkspaceEntity>(
workspaceId,
'messageChannel',
);
const newMessageChannelId = v4();
await messageChannelRepository.insert(
await this.messageChannelDataAccessService.save(
workspaceId,
{
id: newMessageChannelId,
connectedAccountId,
@@ -76,17 +73,19 @@ export class CreateMessageChannelService {
manager,
);
const createdMessageChannel = await messageChannelRepository.findOne({
where: { id: newMessageChannelId },
relations: ['connectedAccount', 'messageFolders'],
});
const createdMessageChannel =
await this.messageChannelDataAccessService.findOne(workspaceId, {
where: { id: newMessageChannelId },
relations: ['connectedAccount', 'messageFolders'],
});
if (!isDefined(createdMessageChannel)) {
throw new Error('Message channel not found');
}
await this.syncMessageFoldersService.syncMessageFolders({
messageChannel: createdMessageChannel,
messageChannel:
createdMessageChannel as unknown as MessageChannelWorkspaceEntity,
workspaceId,
});
@@ -11,6 +11,9 @@ import { GoogleApisServiceAvailabilityService } from 'src/engine/core-modules/au
import { GoogleAPIsService } from 'src/engine/core-modules/auth/services/google-apis.service';
import { UpdateConnectedAccountOnReconnectService } from 'src/engine/core-modules/auth/services/update-connected-account-on-reconnect.service';
import { FeatureFlagService } from 'src/engine/core-modules/feature-flag/services/feature-flag.service';
import { CalendarChannelDataAccessService } from 'src/engine/metadata-modules/calendar-channel/data-access/services/calendar-channel-data-access.service';
import { ConnectedAccountDataAccessService } from 'src/engine/metadata-modules/connected-account/data-access/services/connected-account-data-access.service';
import { MessageChannelDataAccessService } from 'src/engine/metadata-modules/message-channel/data-access/services/message-channel-data-access.service';
import { MessageQueue } from 'src/engine/core-modules/message-queue/message-queue.constants';
import { getQueueToken } from 'src/engine/core-modules/message-queue/utils/get-queue-token.util';
import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service';
@@ -36,22 +39,16 @@ describe('GoogleAPIsService', () => {
let messagingChannelSyncStatusService: MessageChannelSyncStatusService;
let createMessageChannelService: CreateMessageChannelService;
const mockConnectedAccountRepository = {
const mockConnectedAccountDataAccessService = {
findOne: jest.fn(),
find: jest.fn(),
update: jest.fn(),
};
const mockCalendarChannelRepository = {
findOne: jest.fn(),
const mockMessageChannelDataAccessService = {
find: jest.fn(),
update: jest.fn(),
};
const mockMessageChannelRepository = {
findOne: jest.fn(),
const mockCalendarChannelDataAccessService = {
find: jest.fn(),
update: jest.fn(),
};
const mockWorkspaceMemberRepository = {
@@ -84,12 +81,6 @@ describe('GoogleAPIsService', () => {
getRepository: jest
.fn()
.mockImplementation((_workspaceId, entity) => {
if (entity === 'connectedAccount')
return mockConnectedAccountRepository;
if (entity === 'calendarChannel')
return mockCalendarChannelRepository;
if (entity === 'messageChannel')
return mockMessageChannelRepository;
if (entity === 'workspaceMember')
return mockWorkspaceMemberRepository;
@@ -188,6 +179,18 @@ describe('GoogleAPIsService', () => {
isFeatureEnabled: jest.fn().mockResolvedValue(false),
},
},
{
provide: ConnectedAccountDataAccessService,
useValue: mockConnectedAccountDataAccessService,
},
{
provide: MessageChannelDataAccessService,
useValue: mockMessageChannelDataAccessService,
},
{
provide: CalendarChannelDataAccessService,
useValue: mockCalendarChannelDataAccessService,
},
],
}).compile();
@@ -221,7 +224,7 @@ describe('GoogleAPIsService', () => {
provider: ConnectedAccountProvider.GOOGLE,
} as ConnectedAccountWorkspaceEntity;
mockConnectedAccountRepository.findOne.mockResolvedValue(
mockConnectedAccountDataAccessService.findOne.mockResolvedValue(
existingConnectedAccount,
);
@@ -237,11 +240,11 @@ describe('GoogleAPIsService', () => {
syncStage: CalendarChannelSyncStage.FAILED,
};
mockCalendarChannelRepository.find.mockResolvedValue([
mockCalendarChannelDataAccessService.find.mockResolvedValue([
failedCalendarChannel,
]);
mockMessageChannelRepository.find.mockResolvedValue([]);
mockMessageChannelDataAccessService.find.mockResolvedValue([]);
await service.refreshGoogleRefreshToken({
handle: 'test@example.com',
@@ -18,6 +18,9 @@ import { InjectMessageQueue } from 'src/engine/core-modules/message-queue/decora
import { MessageQueue } from 'src/engine/core-modules/message-queue/message-queue.constants';
import { MessageQueueService } from 'src/engine/core-modules/message-queue/services/message-queue.service';
import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service';
import { CalendarChannelDataAccessService } from 'src/engine/metadata-modules/calendar-channel/data-access/services/calendar-channel-data-access.service';
import { ConnectedAccountDataAccessService } from 'src/engine/metadata-modules/connected-account/data-access/services/connected-account-data-access.service';
import { MessageChannelDataAccessService } from 'src/engine/metadata-modules/message-channel/data-access/services/message-channel-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 { buildSystemAuthContext } from 'src/engine/twenty-orm/utils/build-system-auth-context.util';
@@ -29,16 +32,14 @@ import { CalendarChannelSyncStatusService } from 'src/modules/calendar/common/se
import {
CalendarChannelSyncStage,
type CalendarChannelVisibility,
type CalendarChannelWorkspaceEntity,
} from 'src/modules/calendar/common/standard-objects/calendar-channel.workspace-entity';
import { AccountsToReconnectService } from 'src/modules/connected-account/services/accounts-to-reconnect.service';
import { type ConnectedAccountWorkspaceEntity } from 'src/modules/connected-account/standard-objects/connected-account.workspace-entity';
import { MessageChannelSyncStatusService } from 'src/modules/messaging/common/services/message-channel-sync-status.service';
import {
MessageChannelSyncStage,
MessageChannelSyncStatus,
type MessageChannelVisibility,
type MessageChannelWorkspaceEntity,
} from 'src/modules/messaging/common/standard-objects/message-channel.workspace-entity';
import {
MessagingMessageListFetchJob,
@@ -65,6 +66,9 @@ export class GoogleAPIsService {
private readonly googleAPIScopesService: GoogleAPIScopesService,
private readonly googleApisServiceAvailabilityService: GoogleApisServiceAvailabilityService,
private readonly featureFlagService: FeatureFlagService,
private readonly connectedAccountDataAccessService: ConnectedAccountDataAccessService,
private readonly messageChannelDataAccessService: MessageChannelDataAccessService,
private readonly calendarChannelDataAccessService: CalendarChannelDataAccessService,
) {}
async refreshGoogleRefreshToken(input: {
@@ -128,31 +132,14 @@ export class GoogleAPIsService {
return this.globalWorkspaceOrmManager.executeInWorkspaceContext(
async () => {
const connectedAccountRepository =
await this.globalWorkspaceOrmManager.getRepository<ConnectedAccountWorkspaceEntity>(
workspaceId,
'connectedAccount',
);
const connectedAccount = await connectedAccountRepository.findOne({
where: { handle, accountOwnerId: workspaceMemberId },
});
const connectedAccount =
await this.connectedAccountDataAccessService.findOne(workspaceId, {
where: { handle, accountOwnerId: workspaceMemberId },
});
const existingAccountId = connectedAccount?.id;
const newOrExistingConnectedAccountId = existingAccountId ?? v4();
const calendarChannelRepository =
await this.globalWorkspaceOrmManager.getRepository<CalendarChannelWorkspaceEntity>(
workspaceId,
'calendarChannel',
);
const messageChannelRepository =
await this.globalWorkspaceOrmManager.getRepository<MessageChannelWorkspaceEntity>(
workspaceId,
'messageChannel',
);
const workspaceDataSource =
await this.globalWorkspaceOrmManager.getGlobalWorkspaceDataSource();
@@ -200,7 +187,6 @@ export class GoogleAPIsService {
accessToken: input.accessToken,
refreshToken: input.refreshToken,
scopes,
connectedAccount,
manager,
},
);
@@ -229,9 +215,10 @@ export class GoogleAPIsService {
);
if (isMessagingEnabled) {
const messageChannels = await messageChannelRepository.find({
where: { connectedAccountId: newOrExistingConnectedAccountId },
});
const messageChannels =
await this.messageChannelDataAccessService.find(workspaceId, {
connectedAccountId: newOrExistingConnectedAccountId,
});
if (!isMessagingAvailable && messageChannels.length > 0) {
await this.messagingChannelSyncStatusService.markAsFailed(
@@ -261,9 +248,12 @@ export class GoogleAPIsService {
}
if (isCalendarEnabled) {
const calendarChannels = await calendarChannelRepository.find({
where: { connectedAccountId: newOrExistingConnectedAccountId },
});
const calendarChannels =
await this.calendarChannelDataAccessService.find(workspaceId, {
where: {
connectedAccountId: newOrExistingConnectedAccountId,
},
});
if (!isCalendarAvailable && calendarChannels.length > 0) {
await this.calendarChannelSyncStatusService.markAsFailedInsufficientPermissionsAndFlushCalendarEventsToImport(
@@ -8,6 +8,9 @@ import { CreateConnectedAccountService } from 'src/engine/core-modules/auth/serv
import { CreateMessageChannelService } from 'src/engine/core-modules/auth/services/create-message-channel.service';
import { MicrosoftAPIsService } from 'src/engine/core-modules/auth/services/microsoft-apis.service';
import { UpdateConnectedAccountOnReconnectService } from 'src/engine/core-modules/auth/services/update-connected-account-on-reconnect.service';
import { CalendarChannelDataAccessService } from 'src/engine/metadata-modules/calendar-channel/data-access/services/calendar-channel-data-access.service';
import { ConnectedAccountDataAccessService } from 'src/engine/metadata-modules/connected-account/data-access/services/connected-account-data-access.service';
import { MessageChannelDataAccessService } from 'src/engine/metadata-modules/message-channel/data-access/services/message-channel-data-access.service';
import { MessageQueue } from 'src/engine/core-modules/message-queue/message-queue.constants';
import { getQueueToken } from 'src/engine/core-modules/message-queue/utils/get-queue-token.util';
import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service';
@@ -33,22 +36,16 @@ describe('MicrosoftAPIsService', () => {
let calendarChannelSyncStatusService: CalendarChannelSyncStatusService;
let createMessageChannelService: CreateMessageChannelService;
const mockConnectedAccountRepository = {
const mockConnectedAccountDataAccessService = {
findOne: jest.fn(),
find: jest.fn(),
update: jest.fn(),
};
const mockCalendarChannelRepository = {
findOne: jest.fn(),
const mockMessageChannelDataAccessService = {
find: jest.fn(),
update: jest.fn(),
};
const mockMessageChannelRepository = {
findOne: jest.fn(),
const mockCalendarChannelDataAccessService = {
find: jest.fn(),
update: jest.fn(),
};
const mockWorkspaceMemberRepository = {
@@ -81,12 +78,6 @@ describe('MicrosoftAPIsService', () => {
getRepository: jest
.fn()
.mockImplementation((_workspaceId, entity) => {
if (entity === 'connectedAccount')
return mockConnectedAccountRepository;
if (entity === 'calendarChannel')
return mockCalendarChannelRepository;
if (entity === 'messageChannel')
return mockMessageChannelRepository;
if (entity === 'workspaceMember')
return mockWorkspaceMemberRepository;
@@ -162,6 +153,18 @@ describe('MicrosoftAPIsService', () => {
provide: getQueueToken(MessageQueue.calendarQueue),
useValue: mockCalendarQueueService,
},
{
provide: ConnectedAccountDataAccessService,
useValue: mockConnectedAccountDataAccessService,
},
{
provide: MessageChannelDataAccessService,
useValue: mockMessageChannelDataAccessService,
},
{
provide: CalendarChannelDataAccessService,
useValue: mockCalendarChannelDataAccessService,
},
],
}).compile();
@@ -195,7 +198,7 @@ describe('MicrosoftAPIsService', () => {
provider: ConnectedAccountProvider.MICROSOFT,
} as ConnectedAccountWorkspaceEntity;
mockConnectedAccountRepository.findOne.mockResolvedValue(
mockConnectedAccountDataAccessService.findOne.mockResolvedValue(
existingConnectedAccount,
);
@@ -211,11 +214,11 @@ describe('MicrosoftAPIsService', () => {
syncStage: CalendarChannelSyncStage.FAILED,
};
mockCalendarChannelRepository.find.mockResolvedValue([
mockCalendarChannelDataAccessService.find.mockResolvedValue([
failedCalendarChannel,
]);
mockMessageChannelRepository.find.mockResolvedValue([
mockMessageChannelDataAccessService.find.mockResolvedValue([
{
id: 'message-channel-id',
connectedAccountId: 'existing-account-id',
@@ -12,6 +12,9 @@ import { InjectMessageQueue } from 'src/engine/core-modules/message-queue/decora
import { MessageQueue } from 'src/engine/core-modules/message-queue/message-queue.constants';
import { MessageQueueService } from 'src/engine/core-modules/message-queue/services/message-queue.service';
import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service';
import { CalendarChannelDataAccessService } from 'src/engine/metadata-modules/calendar-channel/data-access/services/calendar-channel-data-access.service';
import { ConnectedAccountDataAccessService } from 'src/engine/metadata-modules/connected-account/data-access/services/connected-account-data-access.service';
import { MessageChannelDataAccessService } from 'src/engine/metadata-modules/message-channel/data-access/services/message-channel-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 { buildSystemAuthContext } from 'src/engine/twenty-orm/utils/build-system-auth-context.util';
@@ -23,15 +26,13 @@ import { CalendarChannelSyncStatusService } from 'src/modules/calendar/common/se
import {
CalendarChannelSyncStage,
type CalendarChannelVisibility,
type CalendarChannelWorkspaceEntity,
} from 'src/modules/calendar/common/standard-objects/calendar-channel.workspace-entity';
import { AccountsToReconnectService } from 'src/modules/connected-account/services/accounts-to-reconnect.service';
import { type ConnectedAccountWorkspaceEntity } from 'src/modules/connected-account/standard-objects/connected-account.workspace-entity';
import { MessageChannelSyncStatusService } from 'src/modules/messaging/common/services/message-channel-sync-status.service';
import {
MessageChannelSyncStage,
type MessageChannelVisibility,
type MessageChannelWorkspaceEntity,
} from 'src/modules/messaging/common/standard-objects/message-channel.workspace-entity';
import {
MessagingMessageListFetchJob,
@@ -55,6 +56,9 @@ export class MicrosoftAPIsService {
private readonly createConnectedAccountService: CreateConnectedAccountService,
private readonly updateConnectedAccountOnReconnectService: UpdateConnectedAccountOnReconnectService,
private readonly twentyConfigService: TwentyConfigService,
private readonly connectedAccountDataAccessService: ConnectedAccountDataAccessService,
private readonly messageChannelDataAccessService: MessageChannelDataAccessService,
private readonly calendarChannelDataAccessService: CalendarChannelDataAccessService,
) {}
async refreshMicrosoftRefreshToken(input: {
@@ -82,31 +86,14 @@ export class MicrosoftAPIsService {
return this.globalWorkspaceOrmManager.executeInWorkspaceContext(
async () => {
const connectedAccountRepository =
await this.globalWorkspaceOrmManager.getRepository<ConnectedAccountWorkspaceEntity>(
workspaceId,
'connectedAccount',
);
const connectedAccount = await connectedAccountRepository.findOne({
where: { handle, accountOwnerId: workspaceMemberId },
});
const connectedAccount =
await this.connectedAccountDataAccessService.findOne(workspaceId, {
where: { handle, accountOwnerId: workspaceMemberId },
});
const existingAccountId = connectedAccount?.id;
const newOrExistingConnectedAccountId = existingAccountId ?? v4();
const calendarChannelRepository =
await this.globalWorkspaceOrmManager.getRepository<CalendarChannelWorkspaceEntity>(
workspaceId,
'calendarChannel',
);
const messageChannelRepository =
await this.globalWorkspaceOrmManager.getRepository<MessageChannelWorkspaceEntity>(
workspaceId,
'messageChannel',
);
const workspaceDataSource =
await this.globalWorkspaceOrmManager.getGlobalWorkspaceDataSource();
@@ -156,7 +143,6 @@ export class MicrosoftAPIsService {
accessToken: input.accessToken,
refreshToken: input.refreshToken,
scopes,
connectedAccount,
manager,
},
);
@@ -197,11 +183,10 @@ export class MicrosoftAPIsService {
if (
this.twentyConfigService.get('MESSAGING_PROVIDER_MICROSOFT_ENABLED')
) {
const messageChannels = await messageChannelRepository.find({
where: {
const messageChannels =
await this.messageChannelDataAccessService.find(workspaceId, {
connectedAccountId: newOrExistingConnectedAccountId,
},
});
});
for (const messageChannel of messageChannels) {
if (
@@ -222,11 +207,12 @@ export class MicrosoftAPIsService {
if (
this.twentyConfigService.get('CALENDAR_PROVIDER_MICROSOFT_ENABLED')
) {
const calendarChannels = await calendarChannelRepository.find({
where: {
connectedAccountId: newOrExistingConnectedAccountId,
},
});
const calendarChannels =
await this.calendarChannelDataAccessService.find(workspaceId, {
where: {
connectedAccountId: newOrExistingConnectedAccountId,
},
});
for (const calendarChannel of calendarChannels) {
if (
@@ -1,9 +1,9 @@
import { Injectable } from '@nestjs/common';
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 { buildSystemAuthContext } from 'src/engine/twenty-orm/utils/build-system-auth-context.util';
import { type ConnectedAccountWorkspaceEntity } from 'src/modules/connected-account/standard-objects/connected-account.workspace-entity';
export type UpdateConnectedAccountOnReconnectInput = {
workspaceId: string;
@@ -11,7 +11,6 @@ export type UpdateConnectedAccountOnReconnectInput = {
accessToken: string;
refreshToken: string;
scopes: string[];
connectedAccount: ConnectedAccountWorkspaceEntity;
manager: WorkspaceEntityManager;
};
@@ -19,6 +18,7 @@ export type UpdateConnectedAccountOnReconnectInput = {
export class UpdateConnectedAccountOnReconnectService {
constructor(
private readonly globalWorkspaceOrmManager: GlobalWorkspaceOrmManager,
private readonly connectedAccountDataAccessService: ConnectedAccountDataAccessService,
) {}
async updateConnectedAccountOnReconnect(
@@ -30,19 +30,13 @@ export class UpdateConnectedAccountOnReconnectService {
accessToken,
refreshToken,
scopes,
manager,
} = input;
const authContext = buildSystemAuthContext(workspaceId);
await this.globalWorkspaceOrmManager.executeInWorkspaceContext(async () => {
const connectedAccountRepository =
await this.globalWorkspaceOrmManager.getRepository<ConnectedAccountWorkspaceEntity>(
workspaceId,
'connectedAccount',
);
await connectedAccountRepository.update(
await this.connectedAccountDataAccessService.update(
workspaceId,
{
id: connectedAccountId,
},
@@ -52,7 +46,6 @@ export class UpdateConnectedAccountOnReconnectService {
scopes,
authFailedAt: null,
},
manager,
);
}, authContext);
}
@@ -3,6 +3,7 @@ import { Module } from '@nestjs/common';
import { FeatureFlagModule } from 'src/engine/core-modules/feature-flag/feature-flag.module';
import { ImapSmtpCaldavValidatorModule } from 'src/engine/core-modules/imap-smtp-caldav-connection/services/imap-smtp-caldav-connection-validator.module';
import { MessageQueueModule } from 'src/engine/core-modules/message-queue/message-queue.module';
import { ConnectedAccountDataAccessModule } from 'src/engine/metadata-modules/connected-account/data-access/connected-account-data-access.module';
import { PermissionsModule } from 'src/engine/metadata-modules/permissions/permissions.module';
import { TwentyORMModule } from 'src/engine/twenty-orm/twenty-orm.module';
import { ConnectedAccountModule } from 'src/modules/connected-account/connected-account.module';
@@ -21,6 +22,7 @@ import { ImapSmtpCaldavService } from './services/imap-smtp-caldav-connection.se
IMAPAPIsModule,
MessagingImportManagerModule,
MessageQueueModule,
ConnectedAccountDataAccessModule,
TwentyORMModule,
FeatureFlagModule,
ImapSmtpCaldavValidatorModule,
@@ -6,6 +6,7 @@ import { createTransport } from 'nodemailer';
import { ConnectedAccountProvider } from 'twenty-shared/types';
import { UserInputError } from 'src/engine/core-modules/graphql/utils/graphql-errors.util';
import { ConnectedAccountDataAccessService } from 'src/engine/metadata-modules/connected-account/data-access/services/connected-account-data-access.service';
import {
type AccountType,
type ConnectionParameters,
@@ -21,6 +22,7 @@ export class ImapSmtpCaldavService {
constructor(
private readonly globalWorkspaceOrmManager: GlobalWorkspaceOrmManager,
private readonly connectedAccountDataAccessService: ConnectedAccountDataAccessService,
) {}
async testImapConnection(
@@ -188,20 +190,15 @@ export class ImapSmtpCaldavService {
return this.globalWorkspaceOrmManager.executeInWorkspaceContext(
async () => {
const connectedAccountRepository =
await this.globalWorkspaceOrmManager.getRepository<ConnectedAccountWorkspaceEntity>(
workspaceId,
'connectedAccount',
);
const connectedAccount =
await this.connectedAccountDataAccessService.findOne(workspaceId, {
where: {
id: connectionId,
provider: ConnectedAccountProvider.IMAP_SMTP_CALDAV,
},
});
const connectedAccount = await connectedAccountRepository.findOne({
where: {
id: connectionId,
provider: ConnectedAccountProvider.IMAP_SMTP_CALDAV,
},
});
return connectedAccount;
return connectedAccount as ConnectedAccountWorkspaceEntity | null;
},
authContext,
);
@@ -15,6 +15,7 @@ import { HttpTool } from 'src/engine/core-modules/tool/tools/http-tool/http-tool
import { NavigateAppTool } from 'src/engine/core-modules/tool/tools/navigate-tool/navigate-app-tool';
import { SearchHelpCenterTool } from 'src/engine/core-modules/tool/tools/search-help-center-tool/search-help-center-tool';
import { WorkspaceManyOrAllFlatEntityMapsCacheModule } from 'src/engine/metadata-modules/flat-entity/services/workspace-many-or-all-flat-entity-maps-cache.module';
import { ConnectedAccountDataAccessModule } from 'src/engine/metadata-modules/connected-account/data-access/connected-account-data-access.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 { ViewModule } from 'src/engine/metadata-modules/view/view.module';
@@ -35,6 +36,7 @@ import { MessagingSendManagerModule } from 'src/modules/messaging/message-outbou
ViewModule,
NavigationMenuItemModule,
WorkspaceManyOrAllFlatEntityMapsCacheModule,
ConnectedAccountDataAccessModule,
],
providers: [
HttpTool,
@@ -20,11 +20,12 @@ import { EmailComposerResult } from 'src/engine/core-modules/tool/tools/email-to
import { EmailToolInput } from 'src/engine/core-modules/tool/tools/email-tool/types/email-tool-input.type';
import { parseCommaSeparatedEmails } from 'src/engine/core-modules/tool/tools/email-tool/utils/parse-comma-separated-emails.util';
import { ToolExecutionContext } from 'src/engine/core-modules/tool/types/tool.type';
import { ConnectedAccountDataAccessService } from 'src/engine/metadata-modules/connected-account/data-access/services/connected-account-data-access.service';
import { GlobalWorkspaceOrmManager } from 'src/engine/twenty-orm/global-workspace-datasource/global-workspace-orm.manager';
import { buildSystemAuthContext } from 'src/engine/twenty-orm/utils/build-system-auth-context.util';
import { type ConnectedAccountWorkspaceEntity } from 'src/modules/connected-account/standard-objects/connected-account.workspace-entity';
import { MessagingAccountAuthenticationService } from 'src/modules/messaging/message-import-manager/services/messaging-account-authentication.service';
import { type MessageAttachment } from 'src/modules/messaging/message-import-manager/types/message';
import { type ConnectedAccountWorkspaceEntity } from 'src/modules/connected-account/standard-objects/connected-account.workspace-entity';
import { parseEmailBody } from 'src/utils/parse-email-body';
import { streamToBuffer } from 'src/utils/stream-to-buffer';
@Injectable()
@@ -33,6 +34,7 @@ export class EmailComposerService {
constructor(
private readonly globalWorkspaceOrmManager: GlobalWorkspaceOrmManager,
private readonly connectedAccountDataAccessService: ConnectedAccountDataAccessService,
private readonly messagingAccountAuthenticationService: MessagingAccountAuthenticationService,
@InjectRepository(FileEntity)
private readonly fileRepository: Repository<FileEntity>,
@@ -54,20 +56,15 @@ export class EmailComposerService {
return this.globalWorkspaceOrmManager.executeInWorkspaceContext(
async () => {
const connectedAccountRepository =
await this.globalWorkspaceOrmManager.getRepository<ConnectedAccountWorkspaceEntity>(
workspaceId,
'connectedAccount',
);
const connectedAccount = await connectedAccountRepository.findOne({
where: { id: connectedAccountId },
relations: {
messageChannels: {
messageFolders: true,
const connectedAccount =
await this.connectedAccountDataAccessService.findOne(workspaceId, {
where: { id: connectedAccountId },
relations: {
messageChannels: {
messageFolders: true,
},
},
},
});
});
if (!isDefined(connectedAccount)) {
throw new EmailToolException(
@@ -76,7 +73,7 @@ export class EmailComposerService {
);
}
return connectedAccount;
return connectedAccount as unknown as ConnectedAccountWorkspaceEntity;
},
authContext,
);
@@ -89,12 +86,8 @@ export class EmailComposerService {
return this.globalWorkspaceOrmManager.executeInWorkspaceContext(
async () => {
const connectedAccountRepository =
await this.globalWorkspaceOrmManager.getRepository<ConnectedAccountWorkspaceEntity>(
workspaceId,
'connectedAccount',
);
const allAccounts = await connectedAccountRepository.find();
const allAccounts =
await this.connectedAccountDataAccessService.find(workspaceId);
if (!allAccounts || allAccounts.length === 0) {
throw new EmailToolException(
@@ -291,7 +284,7 @@ export class EmailComposerService {
...connectedAccount,
accessToken,
refreshToken,
};
} as unknown as ConnectedAccountWorkspaceEntity;
const attachments = await this.getAttachments(files || [], workspaceId);
@@ -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 {}
@@ -250,6 +250,7 @@ describe('WorkspaceEntityManager', () => {
IS_DATE_TIME_WHOLE_DAY_FILTER_ENABLED: false,
IS_DRAFT_EMAIL_ENABLED: false,
IS_RICH_TEXT_V1_MIGRATED: false,
IS_CONNECTED_ACCOUNT_MIGRATED: false,
},
userWorkspaceRoleMap: {},
eventEmitterService: {
@@ -1,5 +1,6 @@
import { Module } from '@nestjs/common';
import { CalendarChannelDataAccessModule } from 'src/engine/metadata-modules/calendar-channel/data-access/calendar-channel-data-access.module';
import { BlocklistItemDeleteCalendarEventsJob } from 'src/modules/calendar/blocklist-manager/jobs/blocklist-item-delete-calendar-events.job';
import { BlocklistReimportCalendarEventsJob } from 'src/modules/calendar/blocklist-manager/jobs/blocklist-reimport-calendar-events.job';
import { CalendarBlocklistListener } from 'src/modules/calendar/blocklist-manager/listeners/calendar-blocklist.listener';
@@ -7,7 +8,11 @@ import { CalendarEventCleanerModule } from 'src/modules/calendar/calendar-event-
import { CalendarCommonModule } from 'src/modules/calendar/common/calendar-common.module';
@Module({
imports: [CalendarEventCleanerModule, CalendarCommonModule],
imports: [
CalendarEventCleanerModule,
CalendarCommonModule,
CalendarChannelDataAccessModule,
],
providers: [
CalendarBlocklistListener,
BlocklistItemDeleteCalendarEventsJob,
@@ -7,13 +7,13 @@ import { type ObjectRecordCreateEvent } from 'twenty-shared/database-events';
import { Process } from 'src/engine/core-modules/message-queue/decorators/process.decorator';
import { Processor } from 'src/engine/core-modules/message-queue/decorators/processor.decorator';
import { MessageQueue } from 'src/engine/core-modules/message-queue/message-queue.constants';
import { CalendarChannelDataAccessService } from 'src/engine/metadata-modules/calendar-channel/data-access/services/calendar-channel-data-access.service';
import { GlobalWorkspaceOrmManager } from 'src/engine/twenty-orm/global-workspace-datasource/global-workspace-orm.manager';
import { buildSystemAuthContext } from 'src/engine/twenty-orm/utils/build-system-auth-context.util';
import { type WorkspaceEventBatch } from 'src/engine/workspace-event-emitter/types/workspace-event-batch.type';
import { type BlocklistWorkspaceEntity } from 'src/modules/blocklist/standard-objects/blocklist.workspace-entity';
import { CalendarEventCleanerService } from 'src/modules/calendar/calendar-event-cleaner/services/calendar-event-cleaner.service';
import { type CalendarChannelEventAssociationWorkspaceEntity } from 'src/modules/calendar/common/standard-objects/calendar-channel-event-association.workspace-entity';
import { type CalendarChannelWorkspaceEntity } from 'src/modules/calendar/common/standard-objects/calendar-channel.workspace-entity';
export type BlocklistItemDeleteCalendarEventsJobData = WorkspaceEventBatch<
ObjectRecordCreateEvent<BlocklistWorkspaceEntity>
@@ -26,6 +26,7 @@ export type BlocklistItemDeleteCalendarEventsJobData = WorkspaceEventBatch<
export class BlocklistItemDeleteCalendarEventsJob {
constructor(
private readonly globalWorkspaceOrmManager: GlobalWorkspaceOrmManager,
private readonly calendarChannelDataAccessService: CalendarChannelDataAccessService,
private readonly calendarEventCleanerService: CalendarEventCleanerService,
) {}
@@ -71,12 +72,6 @@ export class BlocklistItemDeleteCalendarEventsJob {
new Map<string, string[]>(),
);
const calendarChannelRepository =
await this.globalWorkspaceOrmManager.getRepository<CalendarChannelWorkspaceEntity>(
workspaceId,
'calendarChannel',
);
const calendarChannelEventAssociationRepository =
await this.globalWorkspaceOrmManager.getRepository<CalendarChannelEventAssociationWorkspaceEntity>(
workspaceId,
@@ -91,29 +86,35 @@ export class BlocklistItemDeleteCalendarEventsJob {
continue;
}
const calendarChannels = await calendarChannelRepository.find({
select: {
id: true,
handle: true,
connectedAccount: {
handleAliases: true,
const calendarChannels =
await this.calendarChannelDataAccessService.find(workspaceId, {
select: {
id: true,
handle: true,
connectedAccount: {
handleAliases: true,
},
},
},
where: {
connectedAccount: {
accountOwnerId: workspaceMemberId,
where: {
connectedAccount: {
accountOwnerId: workspaceMemberId,
},
},
},
relations: ['connectedAccount'],
});
relations: ['connectedAccount'],
});
for (const calendarChannel of calendarChannels) {
const calendarChannelHandles = [calendarChannel.handle];
if (calendarChannel.connectedAccount.handleAliases) {
calendarChannelHandles.push(
...calendarChannel.connectedAccount.handleAliases.split(','),
);
const rawAliases = calendarChannel.connectedAccount
.handleAliases as string | string[];
const aliasList = Array.isArray(rawAliases)
? rawAliases
: rawAliases.split(',').map((alias: string) => alias.trim());
calendarChannelHandles.push(...aliasList);
}
const handleConditions = handles.map((handle) => {
@@ -6,15 +6,13 @@ import { type ObjectRecordDeleteEvent } from 'twenty-shared/database-events';
import { Process } from 'src/engine/core-modules/message-queue/decorators/process.decorator';
import { Processor } from 'src/engine/core-modules/message-queue/decorators/processor.decorator';
import { MessageQueue } from 'src/engine/core-modules/message-queue/message-queue.constants';
import { CalendarChannelDataAccessService } from 'src/engine/metadata-modules/calendar-channel/data-access/services/calendar-channel-data-access.service';
import { GlobalWorkspaceOrmManager } from 'src/engine/twenty-orm/global-workspace-datasource/global-workspace-orm.manager';
import { buildSystemAuthContext } from 'src/engine/twenty-orm/utils/build-system-auth-context.util';
import { type WorkspaceEventBatch } from 'src/engine/workspace-event-emitter/types/workspace-event-batch.type';
import { type BlocklistWorkspaceEntity } from 'src/modules/blocklist/standard-objects/blocklist.workspace-entity';
import { CalendarChannelSyncStatusService } from 'src/modules/calendar/common/services/calendar-channel-sync-status.service';
import {
CalendarChannelSyncStage,
type CalendarChannelWorkspaceEntity,
} from 'src/modules/calendar/common/standard-objects/calendar-channel.workspace-entity';
import { CalendarChannelSyncStage } from 'src/modules/calendar/common/standard-objects/calendar-channel.workspace-entity';
export type BlocklistReimportCalendarEventsJobData = WorkspaceEventBatch<
ObjectRecordDeleteEvent<BlocklistWorkspaceEntity>
@@ -27,6 +25,7 @@ export type BlocklistReimportCalendarEventsJobData = WorkspaceEventBatch<
export class BlocklistReimportCalendarEventsJob {
constructor(
private readonly globalWorkspaceOrmManager: GlobalWorkspaceOrmManager,
private readonly calendarChannelDataAccessService: CalendarChannelDataAccessService,
private readonly calendarChannelSyncStatusService: CalendarChannelSyncStatusService,
) {}
@@ -37,27 +36,22 @@ export class BlocklistReimportCalendarEventsJob {
const authContext = buildSystemAuthContext(workspaceId);
await this.globalWorkspaceOrmManager.executeInWorkspaceContext(async () => {
const calendarChannelRepository =
await this.globalWorkspaceOrmManager.getRepository<CalendarChannelWorkspaceEntity>(
workspaceId,
'calendarChannel',
);
for (const eventPayload of data.events) {
const workspaceMemberId =
eventPayload.properties.before.workspaceMemberId;
const calendarChannels = await calendarChannelRepository.find({
select: ['id'],
where: {
connectedAccount: {
accountOwnerId: workspaceMemberId,
const calendarChannels =
await this.calendarChannelDataAccessService.find(workspaceId, {
select: ['id'],
where: {
connectedAccount: {
accountOwnerId: workspaceMemberId,
},
syncStage: Not(
CalendarChannelSyncStage.CALENDAR_EVENT_LIST_FETCH_PENDING,
),
},
syncStage: Not(
CalendarChannelSyncStage.CALENDAR_EVENT_LIST_FETCH_PENDING,
),
},
});
});
await this.calendarChannelSyncStatusService.resetAndMarkAsCalendarEventListFetchPending(
calendarChannels.map((calendarChannel) => calendarChannel.id),
@@ -6,6 +6,8 @@ import { FeatureFlagEntity } from 'src/engine/core-modules/feature-flag/feature-
import { MetricsModule } from 'src/engine/core-modules/metrics/metrics.module';
import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity';
import { DataSourceEntity } from 'src/engine/metadata-modules/data-source/data-source.entity';
import { CalendarChannelDataAccessModule } from 'src/engine/metadata-modules/calendar-channel/data-access/calendar-channel-data-access.module';
import { ConnectedAccountDataAccessModule } from 'src/engine/metadata-modules/connected-account/data-access/connected-account-data-access.module';
import { ObjectMetadataRepositoryModule } from 'src/engine/object-metadata-repository/object-metadata-repository.module';
import { WorkspaceDataSourceModule } from 'src/engine/workspace-datasource/workspace-datasource.module';
import { BlocklistWorkspaceEntity } from 'src/modules/blocklist/standard-objects/blocklist.workspace-entity';
@@ -57,6 +59,8 @@ import { RefreshTokensManagerModule } from 'src/modules/connected-account/refres
ConnectedAccountModule,
CalendarCommonModule,
MetricsModule,
CalendarChannelDataAccessModule,
ConnectedAccountDataAccessModule,
],
providers: [
CalendarAccountAuthenticationService,
@@ -5,16 +5,14 @@ import { Command, CommandRunner, Option } from 'nest-commander';
import { InjectMessageQueue } from 'src/engine/core-modules/message-queue/decorators/message-queue.decorator';
import { MessageQueue } from 'src/engine/core-modules/message-queue/message-queue.constants';
import { MessageQueueService } from 'src/engine/core-modules/message-queue/services/message-queue.service';
import { CalendarChannelDataAccessService } from 'src/engine/metadata-modules/calendar-channel/data-access/services/calendar-channel-data-access.service';
import { GlobalWorkspaceOrmManager } from 'src/engine/twenty-orm/global-workspace-datasource/global-workspace-orm.manager';
import { buildSystemAuthContext } from 'src/engine/twenty-orm/utils/build-system-auth-context.util';
import {
CalendarEventListFetchJob,
type CalendarEventListFetchJobData,
} from 'src/modules/calendar/calendar-event-import-manager/jobs/calendar-event-list-fetch.job';
import {
CalendarChannelSyncStage,
type CalendarChannelWorkspaceEntity,
} from 'src/modules/calendar/common/standard-objects/calendar-channel.workspace-entity';
import { CalendarChannelSyncStage } from 'src/modules/calendar/common/standard-objects/calendar-channel.workspace-entity';
type CalendarTriggerEventListFetchCommandOptions = {
workspaceId: string;
@@ -33,6 +31,7 @@ export class CalendarTriggerEventListFetchCommand extends CommandRunner {
constructor(
private readonly globalWorkspaceOrmManager: GlobalWorkspaceOrmManager,
private readonly calendarChannelDataAccessService: CalendarChannelDataAccessService,
@InjectMessageQueue(MessageQueue.calendarQueue)
private readonly messageQueueService: MessageQueueService,
) {
@@ -52,23 +51,17 @@ export class CalendarTriggerEventListFetchCommand extends CommandRunner {
const authContext = buildSystemAuthContext(workspaceId);
await this.globalWorkspaceOrmManager.executeInWorkspaceContext(async () => {
const calendarChannelRepository =
await this.globalWorkspaceOrmManager.getRepository<CalendarChannelWorkspaceEntity>(
workspaceId,
'calendarChannel',
);
const whereCondition: Record<string, unknown> = {
isSyncEnabled: true,
syncStage: CalendarChannelSyncStage.CALENDAR_EVENT_LIST_FETCH_PENDING,
};
if (calendarChannelId) {
whereCondition.id = calendarChannelId;
}
const calendarChannels =
await calendarChannelRepository.find(whereCondition);
const calendarChannels = await this.calendarChannelDataAccessService.find(
workspaceId,
{
where: {
isSyncEnabled: true,
syncStage:
CalendarChannelSyncStage.CALENDAR_EVENT_LIST_FETCH_PENDING,
...(calendarChannelId ? { id: calendarChannelId } : {}),
},
},
);
if (calendarChannels.length === 0) {
this.logger.warn(
@@ -83,11 +76,15 @@ export class CalendarTriggerEventListFetchCommand extends CommandRunner {
);
for (const calendarChannel of calendarChannels) {
await calendarChannelRepository.update(calendarChannel.id, {
syncStage:
CalendarChannelSyncStage.CALENDAR_EVENT_LIST_FETCH_SCHEDULED,
syncStageStartedAt: new Date().toISOString(),
});
await this.calendarChannelDataAccessService.update(
workspaceId,
{ id: calendarChannel.id },
{
syncStage:
CalendarChannelSyncStage.CALENDAR_EVENT_LIST_FETCH_SCHEDULED,
syncStageStartedAt: new Date().toISOString(),
},
);
await this.messageQueueService.add<CalendarEventListFetchJobData>(
CalendarEventListFetchJob.name,
@@ -3,6 +3,7 @@ import { Scope } from '@nestjs/common';
import { Process } from 'src/engine/core-modules/message-queue/decorators/process.decorator';
import { Processor } from 'src/engine/core-modules/message-queue/decorators/processor.decorator';
import { MessageQueue } from 'src/engine/core-modules/message-queue/message-queue.constants';
import { CalendarChannelDataAccessService } from 'src/engine/metadata-modules/calendar-channel/data-access/services/calendar-channel-data-access.service';
import { GlobalWorkspaceOrmManager } from 'src/engine/twenty-orm/global-workspace-datasource/global-workspace-orm.manager';
import { buildSystemAuthContext } from 'src/engine/twenty-orm/utils/build-system-auth-context.util';
import { CalendarFetchEventsService } from 'src/modules/calendar/calendar-event-import-manager/services/calendar-fetch-events.service';
@@ -11,6 +12,7 @@ import {
CalendarChannelSyncStage,
type CalendarChannelWorkspaceEntity,
} from 'src/modules/calendar/common/standard-objects/calendar-channel.workspace-entity';
import { type ConnectedAccountWorkspaceEntity } from 'src/modules/connected-account/standard-objects/connected-account.workspace-entity';
import { isThrottled } from 'src/modules/connected-account/utils/is-throttled';
export type CalendarEventListFetchJobData = {
@@ -25,6 +27,7 @@ export type CalendarEventListFetchJobData = {
export class CalendarEventListFetchJob {
constructor(
private readonly globalWorkspaceOrmManager: GlobalWorkspaceOrmManager,
private readonly calendarChannelDataAccessService: CalendarChannelDataAccessService,
private readonly calendarChannelSyncStatusService: CalendarChannelSyncStatusService,
private readonly calendarFetchEventsService: CalendarFetchEventsService,
) {}
@@ -36,19 +39,14 @@ export class CalendarEventListFetchJob {
const authContext = buildSystemAuthContext(workspaceId);
await this.globalWorkspaceOrmManager.executeInWorkspaceContext(async () => {
const calendarChannelRepository =
await this.globalWorkspaceOrmManager.getRepository<CalendarChannelWorkspaceEntity>(
workspaceId,
'calendarChannel',
);
const calendarChannel = await calendarChannelRepository.findOne({
where: {
id: calendarChannelId,
isSyncEnabled: true,
},
relations: ['connectedAccount'],
});
const calendarChannel =
await this.calendarChannelDataAccessService.findOne(workspaceId, {
where: {
id: calendarChannelId,
isSyncEnabled: true,
},
relations: ['connectedAccount'],
});
if (!calendarChannel) {
return;
@@ -61,11 +59,10 @@ export class CalendarEventListFetchJob {
return;
}
const syncStageStartedAt = calendarChannel.syncStageStartedAt;
if (
isThrottled(
calendarChannel.syncStageStartedAt,
calendarChannel.throttleFailureCount,
)
isThrottled(syncStageStartedAt, calendarChannel.throttleFailureCount)
) {
await this.calendarChannelSyncStatusService.markAsCalendarEventListFetchPending(
[calendarChannel.id],
@@ -77,8 +74,8 @@ export class CalendarEventListFetchJob {
}
await this.calendarFetchEventsService.fetchCalendarEvents(
calendarChannel,
calendarChannel.connectedAccount,
calendarChannel as unknown as CalendarChannelWorkspaceEntity,
calendarChannel.connectedAccount as unknown as ConnectedAccountWorkspaceEntity,
workspaceId,
);
}, authContext);
@@ -3,6 +3,7 @@ import { Scope } from '@nestjs/common';
import { Process } from 'src/engine/core-modules/message-queue/decorators/process.decorator';
import { Processor } from 'src/engine/core-modules/message-queue/decorators/processor.decorator';
import { MessageQueue } from 'src/engine/core-modules/message-queue/message-queue.constants';
import { CalendarChannelDataAccessService } from 'src/engine/metadata-modules/calendar-channel/data-access/services/calendar-channel-data-access.service';
import { GlobalWorkspaceOrmManager } from 'src/engine/twenty-orm/global-workspace-datasource/global-workspace-orm.manager';
import { buildSystemAuthContext } from 'src/engine/twenty-orm/utils/build-system-auth-context.util';
import { CalendarEventsImportService } from 'src/modules/calendar/calendar-event-import-manager/services/calendar-events-import.service';
@@ -11,6 +12,7 @@ import {
CalendarChannelSyncStage,
type CalendarChannelWorkspaceEntity,
} from 'src/modules/calendar/common/standard-objects/calendar-channel.workspace-entity';
import { type ConnectedAccountWorkspaceEntity } from 'src/modules/connected-account/standard-objects/connected-account.workspace-entity';
import { isThrottled } from 'src/modules/connected-account/utils/is-throttled';
export type CalendarEventsImportJobData = {
@@ -27,6 +29,7 @@ export class CalendarEventsImportJob {
private readonly calendarEventsImportService: CalendarEventsImportService,
private readonly calendarChannelSyncStatusService: CalendarChannelSyncStatusService,
private readonly globalWorkspaceOrmManager: GlobalWorkspaceOrmManager,
private readonly calendarChannelDataAccessService: CalendarChannelDataAccessService,
) {}
@Process(CalendarEventsImportJob.name)
@@ -36,18 +39,14 @@ export class CalendarEventsImportJob {
const authContext = buildSystemAuthContext(workspaceId);
await this.globalWorkspaceOrmManager.executeInWorkspaceContext(async () => {
const calendarChannelRepository =
await this.globalWorkspaceOrmManager.getRepository<CalendarChannelWorkspaceEntity>(
workspaceId,
'calendarChannel',
);
const calendarChannel = await calendarChannelRepository.findOne({
where: {
id: calendarChannelId,
isSyncEnabled: true,
},
relations: ['connectedAccount'],
});
const calendarChannel =
await this.calendarChannelDataAccessService.findOne(workspaceId, {
where: {
id: calendarChannelId,
isSyncEnabled: true,
},
relations: ['connectedAccount'],
});
if (!calendarChannel?.isSyncEnabled) {
return;
@@ -60,11 +59,10 @@ export class CalendarEventsImportJob {
return;
}
const syncStageStartedAt = calendarChannel.syncStageStartedAt;
if (
isThrottled(
calendarChannel.syncStageStartedAt,
calendarChannel.throttleFailureCount,
)
isThrottled(syncStageStartedAt, calendarChannel.throttleFailureCount)
) {
await this.calendarChannelSyncStatusService.markAsCalendarEventsImportPending(
[calendarChannel.id],
@@ -76,8 +74,8 @@ export class CalendarEventsImportJob {
}
await this.calendarEventsImportService.processCalendarEventsImport(
calendarChannel,
calendarChannel.connectedAccount,
calendarChannel as unknown as CalendarChannelWorkspaceEntity,
calendarChannel.connectedAccount as unknown as ConnectedAccountWorkspaceEntity,
workspaceId,
);
}, authContext);
@@ -5,14 +5,12 @@ import { In } from 'typeorm';
import { Process } from 'src/engine/core-modules/message-queue/decorators/process.decorator';
import { Processor } from 'src/engine/core-modules/message-queue/decorators/processor.decorator';
import { MessageQueue } from 'src/engine/core-modules/message-queue/message-queue.constants';
import { CalendarChannelDataAccessService } from 'src/engine/metadata-modules/calendar-channel/data-access/services/calendar-channel-data-access.service';
import { GlobalWorkspaceOrmManager } from 'src/engine/twenty-orm/global-workspace-datasource/global-workspace-orm.manager';
import { buildSystemAuthContext } from 'src/engine/twenty-orm/utils/build-system-auth-context.util';
import { isSyncStale } from 'src/modules/calendar/calendar-event-import-manager/utils/is-sync-stale.util';
import { CalendarChannelSyncStatusService } from 'src/modules/calendar/common/services/calendar-channel-sync-status.service';
import {
CalendarChannelSyncStage,
type CalendarChannelWorkspaceEntity,
} from 'src/modules/calendar/common/standard-objects/calendar-channel.workspace-entity';
import { CalendarChannelSyncStage } from 'src/modules/calendar/common/standard-objects/calendar-channel.workspace-entity';
export type CalendarOngoingStaleJobData = {
workspaceId: string;
@@ -26,6 +24,7 @@ export class CalendarOngoingStaleJob {
private readonly logger = new Logger(CalendarOngoingStaleJob.name);
constructor(
private readonly globalWorkspaceOrmManager: GlobalWorkspaceOrmManager,
private readonly calendarChannelDataAccessService: CalendarChannelDataAccessService,
private readonly calendarChannelSyncStatusService: CalendarChannelSyncStatusService,
) {}
@@ -36,25 +35,24 @@ export class CalendarOngoingStaleJob {
const authContext = buildSystemAuthContext(workspaceId);
await this.globalWorkspaceOrmManager.executeInWorkspaceContext(async () => {
const calendarChannelRepository =
await this.globalWorkspaceOrmManager.getRepository<CalendarChannelWorkspaceEntity>(
workspaceId,
'calendarChannel',
);
const calendarChannels = await calendarChannelRepository.find({
where: {
syncStage: In([
CalendarChannelSyncStage.CALENDAR_EVENTS_IMPORT_ONGOING,
CalendarChannelSyncStage.CALENDAR_EVENT_LIST_FETCH_ONGOING,
CalendarChannelSyncStage.CALENDAR_EVENTS_IMPORT_SCHEDULED,
CalendarChannelSyncStage.CALENDAR_EVENT_LIST_FETCH_SCHEDULED,
]),
const calendarChannels = await this.calendarChannelDataAccessService.find(
workspaceId,
{
where: {
syncStage: In([
CalendarChannelSyncStage.CALENDAR_EVENTS_IMPORT_ONGOING,
CalendarChannelSyncStage.CALENDAR_EVENT_LIST_FETCH_ONGOING,
CalendarChannelSyncStage.CALENDAR_EVENTS_IMPORT_SCHEDULED,
CalendarChannelSyncStage.CALENDAR_EVENT_LIST_FETCH_SCHEDULED,
]),
},
},
});
);
for (const calendarChannel of calendarChannels) {
if (isSyncStale(calendarChannel.syncStageStartedAt)) {
const syncStageStartedAt = calendarChannel.syncStageStartedAt;
if (isSyncStale(syncStageStartedAt)) {
await this.calendarChannelSyncStatusService.resetSyncStageStartedAt(
[calendarChannel.id],
workspaceId,
@@ -3,12 +3,12 @@ import { Scope } from '@nestjs/common';
import { Process } from 'src/engine/core-modules/message-queue/decorators/process.decorator';
import { Processor } from 'src/engine/core-modules/message-queue/decorators/processor.decorator';
import { MessageQueue } from 'src/engine/core-modules/message-queue/message-queue.constants';
import { CalendarChannelDataAccessService } from 'src/engine/metadata-modules/calendar-channel/data-access/services/calendar-channel-data-access.service';
import { GlobalWorkspaceOrmManager } from 'src/engine/twenty-orm/global-workspace-datasource/global-workspace-orm.manager';
import { buildSystemAuthContext } from 'src/engine/twenty-orm/utils/build-system-auth-context.util';
import {
CalendarChannelSyncStage,
CalendarChannelSyncStatus,
CalendarChannelWorkspaceEntity,
} from 'src/modules/calendar/common/standard-objects/calendar-channel.workspace-entity';
export type CalendarRelaunchFailedCalendarChannelJobData = {
@@ -23,6 +23,7 @@ export type CalendarRelaunchFailedCalendarChannelJobData = {
export class CalendarRelaunchFailedCalendarChannelJob {
constructor(
private readonly globalWorkspaceOrmManager: GlobalWorkspaceOrmManager,
private readonly calendarChannelDataAccessService: CalendarChannelDataAccessService,
) {}
@Process(CalendarRelaunchFailedCalendarChannelJob.name)
@@ -32,23 +33,12 @@ export class CalendarRelaunchFailedCalendarChannelJob {
const authContext = buildSystemAuthContext(workspaceId);
await this.globalWorkspaceOrmManager.executeInWorkspaceContext(async () => {
const calendarChannelRepository =
await this.globalWorkspaceOrmManager.getRepository<CalendarChannelWorkspaceEntity>(
workspaceId,
'calendarChannel',
{ shouldBypassPermissionChecks: true },
);
const calendarChannel = await calendarChannelRepository.findOne({
where: {
id: calendarChannelId,
},
relations: {
connectedAccount: {
accountOwner: true,
const calendarChannel =
await this.calendarChannelDataAccessService.findOne(workspaceId, {
where: {
id: calendarChannelId,
},
},
});
});
if (
!calendarChannel ||
@@ -58,10 +48,14 @@ export class CalendarRelaunchFailedCalendarChannelJob {
return;
}
await calendarChannelRepository.update(calendarChannelId, {
syncStage: CalendarChannelSyncStage.CALENDAR_EVENT_LIST_FETCH_PENDING,
syncStatus: CalendarChannelSyncStatus.ACTIVE,
});
await this.calendarChannelDataAccessService.update(
workspaceId,
{ id: calendarChannelId },
{
syncStage: CalendarChannelSyncStage.CALENDAR_EVENT_LIST_FETCH_PENDING,
syncStatus: CalendarChannelSyncStatus.ACTIVE,
},
);
}, authContext);
}
}
@@ -1,12 +1,11 @@
import { Injectable, Logger } from '@nestjs/common';
import { ExceptionHandlerService } from 'src/engine/core-modules/exception-handler/exception-handler.service';
import { CalendarChannelDataAccessService } from 'src/engine/metadata-modules/calendar-channel/data-access/services/calendar-channel-data-access.service';
import {
type TwentyORMException,
TwentyORMExceptionCode,
} from 'src/engine/twenty-orm/exceptions/twenty-orm.exception';
import { GlobalWorkspaceOrmManager } from 'src/engine/twenty-orm/global-workspace-datasource/global-workspace-orm.manager';
import { buildSystemAuthContext } from 'src/engine/twenty-orm/utils/build-system-auth-context.util';
import { CALENDAR_THROTTLE_MAX_ATTEMPTS } from 'src/modules/calendar/calendar-event-import-manager/constants/calendar-throttle-max-attempts';
import {
type CalendarEventImportDriverException,
@@ -29,7 +28,7 @@ export class CalendarEventImportErrorHandlerService {
CalendarEventImportErrorHandlerService.name,
);
constructor(
private readonly globalWorkspaceOrmManager: GlobalWorkspaceOrmManager,
private readonly calendarChannelDataAccessService: CalendarChannelDataAccessService,
private readonly calendarChannelSyncStatusService: CalendarChannelSyncStatusService,
private readonly exceptionHandlerService: ExceptionHandlerService,
) {}
@@ -133,25 +132,14 @@ export class CalendarEventImportErrorHandlerService {
throw calendarEventImportException;
}
const authContext = buildSystemAuthContext(workspaceId);
await this.globalWorkspaceOrmManager.executeInWorkspaceContext(async () => {
const calendarChannelRepository =
await this.globalWorkspaceOrmManager.getRepository<CalendarChannelWorkspaceEntity>(
workspaceId,
'calendarChannel',
);
await calendarChannelRepository.increment(
{
id: calendarChannel.id,
},
'throttleFailureCount',
1,
undefined,
['throttleFailureCount', 'id'],
);
}, authContext);
await this.calendarChannelDataAccessService.increment(
workspaceId,
{
id: calendarChannel.id,
},
'throttleFailureCount',
1,
);
switch (syncStep) {
case CalendarEventImportSyncStep.CALENDAR_EVENT_LIST_FETCH:
@@ -5,6 +5,7 @@ import { isDefined } from 'twenty-shared/utils';
import { InjectCacheStorage } from 'src/engine/core-modules/cache-storage/decorators/cache-storage.decorator';
import { CacheStorageService } from 'src/engine/core-modules/cache-storage/services/cache-storage.service';
import { CacheStorageNamespace } from 'src/engine/core-modules/cache-storage/types/cache-storage-namespace.enum';
import { CalendarChannelDataAccessService } from 'src/engine/metadata-modules/calendar-channel/data-access/services/calendar-channel-data-access.service';
import { GlobalWorkspaceOrmManager } from 'src/engine/twenty-orm/global-workspace-datasource/global-workspace-orm.manager';
import { buildSystemAuthContext } from 'src/engine/twenty-orm/utils/build-system-auth-context.util';
import {
@@ -29,6 +30,7 @@ export class CalendarFetchEventsService {
@InjectCacheStorage(CacheStorageNamespace.ModuleCalendar)
private readonly cacheStorage: CacheStorageService,
private readonly globalWorkspaceOrmManager: GlobalWorkspaceOrmManager,
private readonly calendarChannelDataAccessService: CalendarChannelDataAccessService,
private readonly calendarChannelSyncStatusService: CalendarChannelSyncStatusService,
private readonly getCalendarEventsService: CalendarGetCalendarEventsService,
private readonly calendarEventImportErrorHandlerService: CalendarEventImportErrorHandlerService,
@@ -90,14 +92,9 @@ export class CalendarFetchEventsService {
const calendarEventIds = getCalendarEventsResponse.calendarEventIds;
const nextSyncCursor = getCalendarEventsResponse.nextSyncCursor;
const calendarChannelRepository =
await this.globalWorkspaceOrmManager.getRepository<CalendarChannelWorkspaceEntity>(
workspaceId,
'calendarChannel',
);
if (!calendarEvents || calendarEvents?.length === 0) {
await calendarChannelRepository.update(
await this.calendarChannelDataAccessService.update(
workspaceId,
{
id: calendarChannel.id,
},
@@ -112,7 +109,8 @@ export class CalendarFetchEventsService {
);
}
await calendarChannelRepository.update(
await this.calendarChannelDataAccessService.update(
workspaceId,
{
id: calendarChannel.id,
},
@@ -3,6 +3,8 @@ import { TypeOrmModule } from '@nestjs/typeorm';
import { FeatureFlagEntity } from 'src/engine/core-modules/feature-flag/feature-flag.entity';
import { MetricsModule } from 'src/engine/core-modules/metrics/metrics.module';
import { CalendarChannelDataAccessModule } from 'src/engine/metadata-modules/calendar-channel/data-access/calendar-channel-data-access.module';
import { ConnectedAccountDataAccessModule } from 'src/engine/metadata-modules/connected-account/data-access/connected-account-data-access.module';
import { WorkspaceDataSourceModule } from 'src/engine/workspace-datasource/workspace-datasource.module';
import { CalendarChannelSyncStatusService } from 'src/modules/calendar/common/services/calendar-channel-sync-status.service';
import { ConnectedAccountModule } from 'src/modules/connected-account/connected-account.module';
@@ -12,6 +14,8 @@ import { ConnectedAccountModule } from 'src/modules/connected-account/connected-
WorkspaceDataSourceModule,
TypeOrmModule.forFeature([FeatureFlagEntity]),
ConnectedAccountModule,
CalendarChannelDataAccessModule,
ConnectedAccountDataAccessModule,
MetricsModule,
],
providers: [CalendarChannelSyncStatusService],
@@ -2,6 +2,7 @@ import { Test, type TestingModule } from '@nestjs/testing';
import { FIELD_RESTRICTED_ADDITIONAL_PERMISSIONS_REQUIRED } from 'twenty-shared/constants';
import { ConnectedAccountDataAccessService } from 'src/engine/metadata-modules/connected-account/data-access/services/connected-account-data-access.service';
import { GlobalWorkspaceOrmManager } from 'src/engine/twenty-orm/global-workspace-datasource/global-workspace-orm.manager';
import { CalendarChannelVisibility } from 'src/modules/calendar/common/standard-objects/calendar-channel.workspace-entity';
import { type CalendarEventWorkspaceEntity } from 'src/modules/calendar/common/standard-objects/calendar-event.workspace-entity';
@@ -44,22 +45,19 @@ describe('ApplyCalendarEventsVisibilityRestrictionsService', () => {
find: jest.fn(),
};
const mockConnectedAccountRepository = {
find: jest.fn(),
};
const mockWorkspaceMemberRepository = {
findOneByOrFail: jest.fn(),
};
const mockConnectedAccountDataAccessService = {
find: jest.fn(),
};
const mockGlobalWorkspaceOrmManager = {
getRepository: jest.fn().mockImplementation((workspaceId, name) => {
if (name === 'calendarChannelEventAssociation') {
return mockCalendarEventAssociationRepository;
}
if (name === 'connectedAccount') {
return mockConnectedAccountRepository;
}
if (name === 'workspaceMember') {
return mockWorkspaceMemberRepository;
}
@@ -77,6 +75,10 @@ describe('ApplyCalendarEventsVisibilityRestrictionsService', () => {
provide: GlobalWorkspaceOrmManager,
useValue: mockGlobalWorkspaceOrmManager,
},
{
provide: ConnectedAccountDataAccessService,
useValue: mockConnectedAccountDataAccessService,
},
],
}).compile();
@@ -121,7 +123,7 @@ describe('ApplyCalendarEventsVisibilityRestrictionsService', () => {
item.description !== FIELD_RESTRICTED_ADDITIONAL_PERMISSIONS_REQUIRED,
),
).toBe(true);
expect(mockConnectedAccountRepository.find).not.toHaveBeenCalled();
expect(mockConnectedAccountDataAccessService.find).not.toHaveBeenCalled();
});
it('should return calendar event with obfuscated title and description if the visibility is METADATA', async () => {
@@ -143,7 +145,7 @@ describe('ApplyCalendarEventsVisibilityRestrictionsService', () => {
id: 'workspace-member-id',
});
mockConnectedAccountRepository.find.mockResolvedValue([]);
mockConnectedAccountDataAccessService.find.mockResolvedValue([]);
const result = await service.applyCalendarEventsVisibilityRestrictions(
calendarEvents,
@@ -179,7 +181,7 @@ describe('ApplyCalendarEventsVisibilityRestrictionsService', () => {
id: 'workspace-member-account-owner-id',
});
mockConnectedAccountRepository.find.mockResolvedValue([{ id: '1' }]);
mockConnectedAccountDataAccessService.find.mockResolvedValue([{ id: '1' }]);
const result = await service.applyCalendarEventsVisibilityRestrictions(
calendarEvents,
@@ -215,7 +217,7 @@ describe('ApplyCalendarEventsVisibilityRestrictionsService', () => {
id: 'workspace-member-not-account-owner-id',
});
mockConnectedAccountRepository.find.mockResolvedValue([]);
mockConnectedAccountDataAccessService.find.mockResolvedValue([]);
const result = await service.applyCalendarEventsVisibilityRestrictions(
calendarEvents,
@@ -261,7 +263,7 @@ describe('ApplyCalendarEventsVisibilityRestrictionsService', () => {
},
]);
mockConnectedAccountRepository.find
mockConnectedAccountDataAccessService.find
.mockResolvedValueOnce([]) // request for calendar event 3
.mockResolvedValueOnce([{ id: '1' }]); // request for calendar event 2
@@ -317,7 +319,7 @@ describe('ApplyCalendarEventsVisibilityRestrictionsService', () => {
},
]);
mockConnectedAccountRepository.find
mockConnectedAccountDataAccessService.find
.mockResolvedValueOnce([]) // request for calendar event 3
.mockResolvedValueOnce([{ id: '1' }]); // request for calendar event 2
@@ -5,18 +5,19 @@ import { FIELD_RESTRICTED_ADDITIONAL_PERMISSIONS_REQUIRED } from 'twenty-shared/
import { isDefined } from 'twenty-shared/utils';
import { In } from 'typeorm';
import { ConnectedAccountDataAccessService } from 'src/engine/metadata-modules/connected-account/data-access/services/connected-account-data-access.service';
import { GlobalWorkspaceOrmManager } from 'src/engine/twenty-orm/global-workspace-datasource/global-workspace-orm.manager';
import { buildSystemAuthContext } from 'src/engine/twenty-orm/utils/build-system-auth-context.util';
import { type CalendarChannelEventAssociationWorkspaceEntity } from 'src/modules/calendar/common/standard-objects/calendar-channel-event-association.workspace-entity';
import { CalendarChannelVisibility } from 'src/modules/calendar/common/standard-objects/calendar-channel.workspace-entity';
import { type CalendarEventWorkspaceEntity } from 'src/modules/calendar/common/standard-objects/calendar-event.workspace-entity';
import { type ConnectedAccountWorkspaceEntity } from 'src/modules/connected-account/standard-objects/connected-account.workspace-entity';
import { WorkspaceMemberWorkspaceEntity } from 'src/modules/workspace-member/standard-objects/workspace-member.workspace-entity';
@Injectable()
export class ApplyCalendarEventsVisibilityRestrictionsService {
constructor(
private readonly globalWorkspaceOrmManager: GlobalWorkspaceOrmManager,
private readonly connectedAccountDataAccessService: ConnectedAccountDataAccessService,
) {}
public async applyCalendarEventsVisibilityRestrictions(
@@ -42,12 +43,6 @@ export class ApplyCalendarEventsVisibilityRestrictionsService {
relations: ['calendarChannel'],
});
const connectedAccountRepository =
await this.globalWorkspaceOrmManager.getRepository<ConnectedAccountWorkspaceEntity>(
workspaceId,
'connectedAccount',
);
const workspaceMemberRepository =
await this.globalWorkspaceOrmManager.getRepository<WorkspaceMemberWorkspaceEntity>(
workspaceId,
@@ -84,15 +79,13 @@ export class ApplyCalendarEventsVisibilityRestrictionsService {
userId: userId,
});
const connectedAccounts = await connectedAccountRepository.find({
select: ['id'],
where: {
const connectedAccounts =
await this.connectedAccountDataAccessService.find(workspaceId, {
calendarChannels: {
id: In(calendarChannels.map((channel) => channel.id)),
},
accountOwnerId: workspaceMember.id,
},
});
});
if (connectedAccounts.length > 0) {
continue;
@@ -1,10 +1,12 @@
import { Module } from '@nestjs/common';
import { ConnectedAccountDataAccessModule } from 'src/engine/metadata-modules/connected-account/data-access/connected-account-data-access.module';
import { CalendarEventFindManyPostQueryHook } from 'src/modules/calendar/common/query-hooks/calendar-event/calendar-event-find-many.post-query.hook';
import { CalendarEventFindOnePostQueryHook } from 'src/modules/calendar/common/query-hooks/calendar-event/calendar-event-find-one.post-query.hook';
import { ApplyCalendarEventsVisibilityRestrictionsService } from 'src/modules/calendar/common/query-hooks/calendar-event/services/apply-calendar-events-visibility-restrictions.service';
@Module({
imports: [ConnectedAccountDataAccessModule],
providers: [
ApplyCalendarEventsVisibilityRestrictionsService,
CalendarEventFindOnePostQueryHook,
@@ -1,22 +1,23 @@
import { Injectable } from '@nestjs/common';
import { Any } from 'typeorm';
import { Any, In } from 'typeorm';
import { InjectCacheStorage } from 'src/engine/core-modules/cache-storage/decorators/cache-storage.decorator';
import { CacheStorageService } from 'src/engine/core-modules/cache-storage/services/cache-storage.service';
import { CacheStorageNamespace } from 'src/engine/core-modules/cache-storage/types/cache-storage-namespace.enum';
import { MetricsService } from 'src/engine/core-modules/metrics/metrics.service';
import { MetricsKeys } from 'src/engine/core-modules/metrics/types/metrics-keys.type';
import { CalendarChannelDataAccessService } from 'src/engine/metadata-modules/calendar-channel/data-access/services/calendar-channel-data-access.service';
import { ConnectedAccountDataAccessService } from 'src/engine/metadata-modules/connected-account/data-access/services/connected-account-data-access.service';
import { GlobalWorkspaceOrmManager } from 'src/engine/twenty-orm/global-workspace-datasource/global-workspace-orm.manager';
import { buildSystemAuthContext } from 'src/engine/twenty-orm/utils/build-system-auth-context.util';
import {
CalendarChannelSyncStage,
CalendarChannelSyncStatus,
type CalendarChannelWorkspaceEntity,
} from 'src/modules/calendar/common/standard-objects/calendar-channel.workspace-entity';
import { AccountsToReconnectService } from 'src/modules/connected-account/services/accounts-to-reconnect.service';
import { type ConnectedAccountWorkspaceEntity } from 'src/modules/connected-account/standard-objects/connected-account.workspace-entity';
import { AccountsToReconnectKeys } from 'src/modules/connected-account/types/accounts-to-reconnect-key-value.type';
import { WorkspaceMemberWorkspaceEntity } from 'src/modules/workspace-member/standard-objects/workspace-member.workspace-entity';
@Injectable()
export class CalendarChannelSyncStatusService {
@@ -24,6 +25,8 @@ export class CalendarChannelSyncStatusService {
private readonly globalWorkspaceOrmManager: GlobalWorkspaceOrmManager,
@InjectCacheStorage(CacheStorageNamespace.ModuleCalendar)
private readonly cacheStorage: CacheStorageService,
private readonly calendarChannelDataAccessService: CalendarChannelDataAccessService,
private readonly connectedAccountDataAccessService: ConnectedAccountDataAccessService,
private readonly accountsToReconnectService: AccountsToReconnectService,
private readonly metricsService: MetricsService,
) {}
@@ -40,16 +43,14 @@ export class CalendarChannelSyncStatusService {
const authContext = buildSystemAuthContext(workspaceId);
await this.globalWorkspaceOrmManager.executeInWorkspaceContext(async () => {
const calendarChannelRepository =
await this.globalWorkspaceOrmManager.getRepository<CalendarChannelWorkspaceEntity>(
workspaceId,
'calendarChannel',
);
await calendarChannelRepository.update(calendarChannelIds, {
syncStage: CalendarChannelSyncStage.CALENDAR_EVENT_LIST_FETCH_PENDING,
...(!preserveSyncStageStartedAt ? { syncStageStartedAt: null } : {}),
});
await this.calendarChannelDataAccessService.update(
workspaceId,
{ id: In(calendarChannelIds) },
{
syncStage: CalendarChannelSyncStage.CALENDAR_EVENT_LIST_FETCH_PENDING,
...(!preserveSyncStageStartedAt ? { syncStageStartedAt: null } : {}),
},
);
}, authContext);
}
@@ -64,17 +65,15 @@ export class CalendarChannelSyncStatusService {
const authContext = buildSystemAuthContext(workspaceId);
await this.globalWorkspaceOrmManager.executeInWorkspaceContext(async () => {
const calendarChannelRepository =
await this.globalWorkspaceOrmManager.getRepository<CalendarChannelWorkspaceEntity>(
workspaceId,
'calendarChannel',
);
await calendarChannelRepository.update(calendarChannelIds, {
syncStage: CalendarChannelSyncStage.CALENDAR_EVENT_LIST_FETCH_ONGOING,
syncStatus: CalendarChannelSyncStatus.ONGOING,
syncStageStartedAt: new Date().toISOString(),
});
await this.calendarChannelDataAccessService.update(
workspaceId,
{ id: In(calendarChannelIds) },
{
syncStage: CalendarChannelSyncStage.CALENDAR_EVENT_LIST_FETCH_ONGOING,
syncStatus: CalendarChannelSyncStatus.ONGOING,
syncStageStartedAt: new Date().toISOString(),
},
);
}, authContext);
}
@@ -95,17 +94,15 @@ export class CalendarChannelSyncStatusService {
const authContext = buildSystemAuthContext(workspaceId);
await this.globalWorkspaceOrmManager.executeInWorkspaceContext(async () => {
const calendarChannelRepository =
await this.globalWorkspaceOrmManager.getRepository<CalendarChannelWorkspaceEntity>(
workspaceId,
'calendarChannel',
);
await calendarChannelRepository.update(calendarChannelIds, {
syncCursor: '',
syncStageStartedAt: null,
throttleFailureCount: 0,
});
await this.calendarChannelDataAccessService.update(
workspaceId,
{ id: In(calendarChannelIds) },
{
syncCursor: '',
syncStageStartedAt: null,
throttleFailureCount: 0,
},
);
}, authContext);
await this.markAsCalendarEventListFetchPending(
@@ -125,15 +122,13 @@ export class CalendarChannelSyncStatusService {
const authContext = buildSystemAuthContext(workspaceId);
await this.globalWorkspaceOrmManager.executeInWorkspaceContext(async () => {
const calendarChannelRepository =
await this.globalWorkspaceOrmManager.getRepository<CalendarChannelWorkspaceEntity>(
workspaceId,
'calendarChannel',
);
await calendarChannelRepository.update(calendarChannelIds, {
syncStageStartedAt: null,
});
await this.calendarChannelDataAccessService.update(
workspaceId,
{ id: In(calendarChannelIds) },
{
syncStageStartedAt: null,
},
);
}, authContext);
}
@@ -149,16 +144,14 @@ export class CalendarChannelSyncStatusService {
const authContext = buildSystemAuthContext(workspaceId);
await this.globalWorkspaceOrmManager.executeInWorkspaceContext(async () => {
const calendarChannelRepository =
await this.globalWorkspaceOrmManager.getRepository<CalendarChannelWorkspaceEntity>(
workspaceId,
'calendarChannel',
);
await calendarChannelRepository.update(calendarChannelIds, {
syncStage: CalendarChannelSyncStage.CALENDAR_EVENTS_IMPORT_PENDING,
...(!preserveSyncStageStartedAt ? { syncStageStartedAt: null } : {}),
});
await this.calendarChannelDataAccessService.update(
workspaceId,
{ id: In(calendarChannelIds) },
{
syncStage: CalendarChannelSyncStage.CALENDAR_EVENTS_IMPORT_PENDING,
...(!preserveSyncStageStartedAt ? { syncStageStartedAt: null } : {}),
},
);
}, authContext);
}
@@ -173,17 +166,15 @@ export class CalendarChannelSyncStatusService {
const authContext = buildSystemAuthContext(workspaceId);
await this.globalWorkspaceOrmManager.executeInWorkspaceContext(async () => {
const calendarChannelRepository =
await this.globalWorkspaceOrmManager.getRepository<CalendarChannelWorkspaceEntity>(
workspaceId,
'calendarChannel',
);
await calendarChannelRepository.update(calendarChannelIds, {
syncStage: CalendarChannelSyncStage.CALENDAR_EVENTS_IMPORT_ONGOING,
syncStatus: CalendarChannelSyncStatus.ONGOING,
syncStageStartedAt: new Date().toISOString(),
});
await this.calendarChannelDataAccessService.update(
workspaceId,
{ id: In(calendarChannelIds) },
{
syncStage: CalendarChannelSyncStage.CALENDAR_EVENTS_IMPORT_ONGOING,
syncStatus: CalendarChannelSyncStatus.ONGOING,
syncStageStartedAt: new Date().toISOString(),
},
);
}, authContext);
}
@@ -198,19 +189,17 @@ export class CalendarChannelSyncStatusService {
const authContext = buildSystemAuthContext(workspaceId);
await this.globalWorkspaceOrmManager.executeInWorkspaceContext(async () => {
const calendarChannelRepository =
await this.globalWorkspaceOrmManager.getRepository<CalendarChannelWorkspaceEntity>(
workspaceId,
'calendarChannel',
);
await calendarChannelRepository.update(calendarChannelIds, {
syncStage: CalendarChannelSyncStage.CALENDAR_EVENT_LIST_FETCH_PENDING,
syncStatus: CalendarChannelSyncStatus.ACTIVE,
throttleFailureCount: 0,
syncStageStartedAt: null,
syncedAt: new Date().toISOString(),
});
await this.calendarChannelDataAccessService.update(
workspaceId,
{ id: In(calendarChannelIds) },
{
syncStage: CalendarChannelSyncStage.CALENDAR_EVENT_LIST_FETCH_PENDING,
syncStatus: CalendarChannelSyncStatus.ACTIVE,
throttleFailureCount: 0,
syncStageStartedAt: null,
syncedAt: new Date().toISOString(),
},
);
}, authContext);
await this.markAsCalendarEventListFetchPending(
@@ -241,16 +230,14 @@ export class CalendarChannelSyncStatusService {
const authContext = buildSystemAuthContext(workspaceId);
await this.globalWorkspaceOrmManager.executeInWorkspaceContext(async () => {
const calendarChannelRepository =
await this.globalWorkspaceOrmManager.getRepository<CalendarChannelWorkspaceEntity>(
workspaceId,
'calendarChannel',
);
await calendarChannelRepository.update(calendarChannelIds, {
syncStatus: CalendarChannelSyncStatus.FAILED_UNKNOWN,
syncStage: CalendarChannelSyncStage.FAILED,
});
await this.calendarChannelDataAccessService.update(
workspaceId,
{ id: In(calendarChannelIds) },
{
syncStatus: CalendarChannelSyncStatus.FAILED_UNKNOWN,
syncStage: CalendarChannelSyncStage.FAILED,
},
);
}, authContext);
await this.metricsService.batchIncrementCounter({
@@ -276,33 +263,29 @@ export class CalendarChannelSyncStatusService {
const authContext = buildSystemAuthContext(workspaceId);
await this.globalWorkspaceOrmManager.executeInWorkspaceContext(async () => {
const calendarChannelRepository =
await this.globalWorkspaceOrmManager.getRepository<CalendarChannelWorkspaceEntity>(
workspaceId,
'calendarChannel',
);
await this.calendarChannelDataAccessService.update(
workspaceId,
{ id: In(calendarChannelIds) },
{
syncStatus: CalendarChannelSyncStatus.FAILED_INSUFFICIENT_PERMISSIONS,
syncStage: CalendarChannelSyncStage.FAILED,
},
);
await calendarChannelRepository.update(calendarChannelIds, {
syncStatus: CalendarChannelSyncStatus.FAILED_INSUFFICIENT_PERMISSIONS,
syncStage: CalendarChannelSyncStage.FAILED,
});
const connectedAccountRepository =
await this.globalWorkspaceOrmManager.getRepository<ConnectedAccountWorkspaceEntity>(
workspaceId,
'connectedAccount',
);
const calendarChannels = await calendarChannelRepository.find({
select: ['id', 'connectedAccountId'],
where: { id: Any(calendarChannelIds) },
});
const calendarChannels = await this.calendarChannelDataAccessService.find(
workspaceId,
{
select: ['id', 'connectedAccountId'],
where: { id: Any(calendarChannelIds) },
},
);
const connectedAccountIds = calendarChannels.map(
(calendarChannel) => calendarChannel.connectedAccountId,
);
await connectedAccountRepository.update(
await this.connectedAccountDataAccessService.update(
workspaceId,
{ id: Any(connectedAccountIds) },
{
authFailedAt: new Date(),
@@ -329,26 +312,43 @@ export class CalendarChannelSyncStatusService {
return;
}
const calendarChannelRepository =
await this.globalWorkspaceOrmManager.getRepository<CalendarChannelWorkspaceEntity>(
workspaceId,
'calendarChannel',
);
const calendarChannels = await calendarChannelRepository.find({
where: {
id: Any(calendarChannelIds),
},
relations: {
connectedAccount: {
accountOwner: true,
const calendarChannels = await this.calendarChannelDataAccessService.find(
workspaceId,
{
select: ['id', 'connectedAccountId'],
where: {
id: Any(calendarChannelIds),
},
},
});
);
const workspaceMemberRepository =
await this.globalWorkspaceOrmManager.getRepository<WorkspaceMemberWorkspaceEntity>(
workspaceId,
'workspaceMember',
{ shouldBypassPermissionChecks: true },
);
for (const calendarChannel of calendarChannels) {
const userId = calendarChannel.connectedAccount.accountOwner.userId;
const connectedAccountId = calendarChannel.connectedAccount.id;
const connectedAccount =
await this.connectedAccountDataAccessService.findOne(workspaceId, {
where: { id: calendarChannel.connectedAccountId },
});
if (!connectedAccount) {
continue;
}
const workspaceMember = await workspaceMemberRepository.findOne({
where: { id: connectedAccount.accountOwnerId },
});
if (!workspaceMember) {
continue;
}
const userId = workspaceMember.userId;
const connectedAccountId = connectedAccount.id;
await this.accountsToReconnectService.addAccountToReconnectByKey(
AccountsToReconnectKeys.ACCOUNTS_TO_RECONNECT_INSUFFICIENT_PERMISSIONS,
@@ -1,6 +1,12 @@
import { registerEnumType } from '@nestjs/graphql';
import { FieldMetadataType } from 'twenty-shared/types';
import {
CalendarChannelContactAutoCreationPolicy,
CalendarChannelSyncStage,
CalendarChannelSyncStatus,
CalendarChannelVisibility,
FieldMetadataType,
} from 'twenty-shared/types';
import { BaseWorkspaceEntity } from 'src/engine/twenty-orm/base.workspace-entity';
import { type FieldTypeAndNameMetadata } from 'src/engine/workspace-manager/utils/get-ts-vector-column-expression.util';
@@ -8,36 +14,12 @@ import { type EntityRelation } from 'src/engine/workspace-manager/workspace-migr
import { type CalendarChannelEventAssociationWorkspaceEntity } from 'src/modules/calendar/common/standard-objects/calendar-channel-event-association.workspace-entity';
import { type ConnectedAccountWorkspaceEntity } from 'src/modules/connected-account/standard-objects/connected-account.workspace-entity';
export enum CalendarChannelVisibility {
METADATA = 'METADATA',
SHARE_EVERYTHING = 'SHARE_EVERYTHING',
}
export enum CalendarChannelSyncStatus {
NOT_SYNCED = 'NOT_SYNCED',
ONGOING = 'ONGOING',
ACTIVE = 'ACTIVE',
FAILED_INSUFFICIENT_PERMISSIONS = 'FAILED_INSUFFICIENT_PERMISSIONS',
FAILED_UNKNOWN = 'FAILED_UNKNOWN',
}
export enum CalendarChannelSyncStage {
PENDING_CONFIGURATION = 'PENDING_CONFIGURATION',
CALENDAR_EVENT_LIST_FETCH_PENDING = 'CALENDAR_EVENT_LIST_FETCH_PENDING',
CALENDAR_EVENT_LIST_FETCH_SCHEDULED = 'CALENDAR_EVENT_LIST_FETCH_SCHEDULED',
CALENDAR_EVENT_LIST_FETCH_ONGOING = 'CALENDAR_EVENT_LIST_FETCH_ONGOING',
CALENDAR_EVENTS_IMPORT_PENDING = 'CALENDAR_EVENTS_IMPORT_PENDING',
CALENDAR_EVENTS_IMPORT_SCHEDULED = 'CALENDAR_EVENTS_IMPORT_SCHEDULED',
CALENDAR_EVENTS_IMPORT_ONGOING = 'CALENDAR_EVENTS_IMPORT_ONGOING',
FAILED = 'FAILED',
}
export enum CalendarChannelContactAutoCreationPolicy {
AS_PARTICIPANT_AND_ORGANIZER = 'AS_PARTICIPANT_AND_ORGANIZER',
AS_PARTICIPANT = 'AS_PARTICIPANT',
AS_ORGANIZER = 'AS_ORGANIZER',
NONE = 'NONE',
}
export {
CalendarChannelContactAutoCreationPolicy,
CalendarChannelSyncStage,
CalendarChannelSyncStatus,
CalendarChannelVisibility,
};
registerEnumType(CalendarChannelVisibility, {
name: 'CalendarChannelVisibility',
@@ -1,5 +1,7 @@
import { Module } from '@nestjs/common';
import { CalendarChannelDataAccessModule } from 'src/engine/metadata-modules/calendar-channel/data-access/calendar-channel-data-access.module';
import { MessageChannelDataAccessModule } from 'src/engine/metadata-modules/message-channel/data-access/message-channel-data-access.module';
import { PermissionsModule } from 'src/engine/metadata-modules/permissions/permissions.module';
import { WorkspaceDataSourceModule } from 'src/engine/workspace-datasource/workspace-datasource.module';
import { ChannelSyncResolver } from 'src/modules/connected-account/channel-sync/channel-sync.resolver';
@@ -8,6 +10,8 @@ import { MessagingCommonModule } from 'src/modules/messaging/common/messaging-co
@Module({
imports: [
CalendarChannelDataAccessModule,
MessageChannelDataAccessModule,
PermissionsModule,
WorkspaceDataSourceModule,
MessagingCommonModule,
@@ -3,6 +3,8 @@ import { Injectable } from '@nestjs/common';
import { InjectMessageQueue } from 'src/engine/core-modules/message-queue/decorators/message-queue.decorator';
import { MessageQueue } from 'src/engine/core-modules/message-queue/message-queue.constants';
import { MessageQueueService } from 'src/engine/core-modules/message-queue/services/message-queue.service';
import { CalendarChannelDataAccessService } from 'src/engine/metadata-modules/calendar-channel/data-access/services/calendar-channel-data-access.service';
import { MessageChannelDataAccessService } from 'src/engine/metadata-modules/message-channel/data-access/services/message-channel-data-access.service';
import { GlobalWorkspaceOrmManager } from 'src/engine/twenty-orm/global-workspace-datasource/global-workspace-orm.manager';
import { buildSystemAuthContext } from 'src/engine/twenty-orm/utils/build-system-auth-context.util';
import {
@@ -12,13 +14,9 @@ import {
import {
CalendarChannelSyncStage,
CalendarChannelSyncStatus,
type CalendarChannelWorkspaceEntity,
} from 'src/modules/calendar/common/standard-objects/calendar-channel.workspace-entity';
import { MessageChannelSyncStatusService } from 'src/modules/messaging/common/services/message-channel-sync-status.service';
import {
MessageChannelSyncStage,
type MessageChannelWorkspaceEntity,
} from 'src/modules/messaging/common/standard-objects/message-channel.workspace-entity';
import { MessageChannelSyncStage } from 'src/modules/messaging/common/standard-objects/message-channel.workspace-entity';
import {
MessagingMessageListFetchJob,
type MessagingMessageListFetchJobData,
@@ -37,7 +35,9 @@ export class ChannelSyncService {
private readonly messageQueueService: MessageQueueService,
@InjectMessageQueue(MessageQueue.calendarQueue)
private readonly calendarQueueService: MessageQueueService,
private readonly messageChannelDataAccessService: MessageChannelDataAccessService,
private readonly messageChannelSyncStatusService: MessageChannelSyncStatusService,
private readonly calendarChannelDataAccessService: CalendarChannelDataAccessService,
) {}
async startChannelSync(input: StartChannelSyncInput): Promise<void> {
@@ -54,18 +54,13 @@ export class ChannelSyncService {
const authContext = buildSystemAuthContext(workspaceId);
await this.globalWorkspaceOrmManager.executeInWorkspaceContext(async () => {
const messageChannelRepository =
await this.globalWorkspaceOrmManager.getRepository<MessageChannelWorkspaceEntity>(
workspaceId,
'messageChannel',
);
const messageChannels = await messageChannelRepository.find({
where: {
const messageChannels = await this.messageChannelDataAccessService.find(
workspaceId,
{
connectedAccountId,
syncStage: MessageChannelSyncStage.PENDING_CONFIGURATION,
},
});
);
for (const messageChannel of messageChannels) {
await this.messageChannelSyncStatusService.markAsMessagesListFetchScheduled(
@@ -91,25 +86,26 @@ export class ChannelSyncService {
const authContext = buildSystemAuthContext(workspaceId);
await this.globalWorkspaceOrmManager.executeInWorkspaceContext(async () => {
const calendarChannelRepository =
await this.globalWorkspaceOrmManager.getRepository<CalendarChannelWorkspaceEntity>(
workspaceId,
'calendarChannel',
);
const calendarChannels = await calendarChannelRepository.find({
where: {
connectedAccountId,
syncStage: CalendarChannelSyncStage.PENDING_CONFIGURATION,
const calendarChannels = await this.calendarChannelDataAccessService.find(
workspaceId,
{
where: {
connectedAccountId,
syncStage: CalendarChannelSyncStage.PENDING_CONFIGURATION,
},
},
});
);
for (const calendarChannel of calendarChannels) {
await calendarChannelRepository.update(calendarChannel.id, {
syncStage:
CalendarChannelSyncStage.CALENDAR_EVENT_LIST_FETCH_SCHEDULED,
syncStatus: CalendarChannelSyncStatus.ONGOING,
});
await this.calendarChannelDataAccessService.update(
workspaceId,
{ id: calendarChannel.id },
{
syncStage:
CalendarChannelSyncStage.CALENDAR_EVENT_LIST_FETCH_SCHEDULED,
syncStatus: CalendarChannelSyncStatus.ONGOING,
},
);
await this.calendarQueueService.add<CalendarEventListFetchJobData>(
CalendarEventListFetchJob.name,
@@ -1,13 +1,14 @@
import { Module } from '@nestjs/common';
import { UserVarsModule } from 'src/engine/core-modules/user/user-vars/user-vars.module';
import { ConnectedAccountDataAccessModule } from 'src/engine/metadata-modules/connected-account/data-access/connected-account-data-access.module';
import { DeleteWorkspaceMemberConnectedAccountsCleanupJob } from 'src/modules/connected-account/jobs/delete-workspace-member-connected-accounts.job';
import { ConnectedAccountWorkspaceMemberListener } from 'src/modules/connected-account/listeners/connected-account-workspace-member.listener';
import { ConnectedAccountListener } from 'src/modules/connected-account/listeners/connected-account.listener';
import { AccountsToReconnectService } from 'src/modules/connected-account/services/accounts-to-reconnect.service';
@Module({
imports: [UserVarsModule],
imports: [ConnectedAccountDataAccessModule, UserVarsModule],
providers: [
AccountsToReconnectService,
ConnectedAccountListener,
@@ -1,5 +1,6 @@
import { Module } from '@nestjs/common';
import { ConnectedAccountDataAccessModule } from 'src/engine/metadata-modules/connected-account/data-access/connected-account-data-access.module';
import { GmailEmailAliasErrorHandlerService } from 'src/modules/connected-account/email-alias-manager/drivers/google/services/google-email-alias-error-handler.service';
import { GoogleEmailAliasManagerService } from 'src/modules/connected-account/email-alias-manager/drivers/google/services/google-email-alias-manager.service';
import { MicrosoftEmailAliasManagerService } from 'src/modules/connected-account/email-alias-manager/drivers/microsoft/services/microsoft-email-alias-manager.service';
@@ -7,7 +8,7 @@ import { EmailAliasManagerService } from 'src/modules/connected-account/email-al
import { OAuth2ClientManagerModule } from 'src/modules/connected-account/oauth2-client-manager/oauth2-client-manager.module';
@Module({
imports: [OAuth2ClientManagerModule],
imports: [OAuth2ClientManagerModule, ConnectedAccountDataAccessModule],
providers: [
EmailAliasManagerService,
GoogleEmailAliasManagerService,
@@ -1,8 +1,8 @@
import { Test, type TestingModule } from '@nestjs/testing';
import { ConnectedAccountProvider } from 'twenty-shared/types';
import { type Repository } from 'typeorm';
import { ConnectedAccountDataAccessService } from 'src/engine/metadata-modules/connected-account/data-access/services/connected-account-data-access.service';
import { GlobalWorkspaceOrmManager } from 'src/engine/twenty-orm/global-workspace-datasource/global-workspace-orm.manager';
import { GoogleEmailAliasManagerService } from 'src/modules/connected-account/email-alias-manager/drivers/google/services/google-email-alias-manager.service';
import { microsoftGraphMeResponseWithProxyAddresses } from 'src/modules/connected-account/email-alias-manager/drivers/microsoft/mocks/microsoft-api-examples';
@@ -15,30 +15,27 @@ import { EmailAliasManagerService } from './email-alias-manager.service';
describe('Email Alias Manager Service', () => {
let emailAliasManagerService: EmailAliasManagerService;
let microsoftEmailAliasManagerService: MicrosoftEmailAliasManagerService;
let connectedAccountRepository: Partial<
Repository<ConnectedAccountWorkspaceEntity>
>;
const mockConnectedAccountDataAccessService = {
// @ts-expect-error legacy noImplicitAny
update: jest.fn().mockResolvedValue((arg) => arg),
};
beforeEach(async () => {
connectedAccountRepository = {
// @ts-expect-error legacy noImplicitAny
update: jest.fn().mockResolvedValue((arg) => arg),
};
const module: TestingModule = await Test.createTestingModule({
providers: [
{
provide: GlobalWorkspaceOrmManager,
useValue: {
getRepository: jest
.fn()
.mockResolvedValue(connectedAccountRepository),
executeInWorkspaceContext: jest
.fn()
.mockImplementation((fn: () => any, _authContext?: any) => fn()),
},
},
EmailAliasManagerService,
{
provide: ConnectedAccountDataAccessService,
useValue: mockConnectedAccountDataAccessService,
},
{
provide: GoogleEmailAliasManagerService,
useValue: {},
@@ -96,7 +93,8 @@ describe('Email Alias Manager Service', () => {
microsoftEmailAliasManagerService.getHandleAliases,
).toHaveBeenCalledWith(mockConnectedAccount);
expect(connectedAccountRepository.update).toHaveBeenCalledWith(
expect(mockConnectedAccountDataAccessService.update).toHaveBeenCalledWith(
'test-workspace-id',
{ id: mockConnectedAccount.id },
{
handleAliases: expectedAliases,
@@ -3,6 +3,7 @@ import { Injectable } from '@nestjs/common';
import { ConnectedAccountProvider } from 'twenty-shared/types';
import { assertUnreachable } from 'twenty-shared/utils';
import { ConnectedAccountDataAccessService } from 'src/engine/metadata-modules/connected-account/data-access/services/connected-account-data-access.service';
import { GlobalWorkspaceOrmManager } from 'src/engine/twenty-orm/global-workspace-datasource/global-workspace-orm.manager';
import { buildSystemAuthContext } from 'src/engine/twenty-orm/utils/build-system-auth-context.util';
import { GoogleEmailAliasManagerService } from 'src/modules/connected-account/email-alias-manager/drivers/google/services/google-email-alias-manager.service';
@@ -15,6 +16,7 @@ export class EmailAliasManagerService {
private readonly googleEmailAliasManagerService: GoogleEmailAliasManagerService,
private readonly microsoftEmailAliasManagerService: MicrosoftEmailAliasManagerService,
private readonly globalWorkspaceOrmManager: GlobalWorkspaceOrmManager,
private readonly connectedAccountDataAccessService: ConnectedAccountDataAccessService,
) {}
public async refreshHandleAliases(
@@ -50,13 +52,8 @@ export class EmailAliasManagerService {
const authContext = buildSystemAuthContext(workspaceId);
await this.globalWorkspaceOrmManager.executeInWorkspaceContext(async () => {
const connectedAccountRepository =
await this.globalWorkspaceOrmManager.getRepository<ConnectedAccountWorkspaceEntity>(
workspaceId,
'connectedAccount',
);
await connectedAccountRepository.update(
await this.connectedAccountDataAccessService.update(
workspaceId,
{ id: connectedAccount.id },
{
handleAliases: handleAliases.join(','), // TODO: modify handleAliases to be of fieldmetadatatype array
@@ -5,6 +5,9 @@ import { AuthModule } from 'src/engine/core-modules/auth/auth.module';
import { FeatureFlagModule } from 'src/engine/core-modules/feature-flag/feature-flag.module';
import { MessageQueueModule } from 'src/engine/core-modules/message-queue/message-queue.module';
import { TwentyConfigModule } from 'src/engine/core-modules/twenty-config/twenty-config.module';
import { CalendarChannelDataAccessModule } from 'src/engine/metadata-modules/calendar-channel/data-access/calendar-channel-data-access.module';
import { ConnectedAccountDataAccessModule } from 'src/engine/metadata-modules/connected-account/data-access/connected-account-data-access.module';
import { MessageChannelDataAccessModule } from 'src/engine/metadata-modules/message-channel/data-access/message-channel-data-access.module';
import { ObjectMetadataEntity } from 'src/engine/metadata-modules/object-metadata/object-metadata.entity';
import { TwentyORMModule } from 'src/engine/twenty-orm/twenty-orm.module';
import { WorkspaceEventEmitterModule } from 'src/engine/workspace-event-emitter/workspace-event-emitter.module';
@@ -19,6 +22,9 @@ import { ImapSmtpCalDavAPIService } from 'src/modules/connected-account/services
TwentyORMModule,
FeatureFlagModule,
AuthModule,
CalendarChannelDataAccessModule,
ConnectedAccountDataAccessModule,
MessageChannelDataAccessModule,
],
providers: [ImapSmtpCalDavAPIService],
exports: [ImapSmtpCalDavAPIService],
@@ -1,9 +1,9 @@
import { Process } from 'src/engine/core-modules/message-queue/decorators/process.decorator';
import { Processor } from 'src/engine/core-modules/message-queue/decorators/processor.decorator';
import { MessageQueue } from 'src/engine/core-modules/message-queue/message-queue.constants';
import { ConnectedAccountDataAccessService } from 'src/engine/metadata-modules/connected-account/data-access/services/connected-account-data-access.service';
import { GlobalWorkspaceOrmManager } from 'src/engine/twenty-orm/global-workspace-datasource/global-workspace-orm.manager';
import { buildSystemAuthContext } from 'src/engine/twenty-orm/utils/build-system-auth-context.util';
import { type ConnectedAccountWorkspaceEntity } from 'src/modules/connected-account/standard-objects/connected-account.workspace-entity';
export type DeleteWorkspaceMemberConnectedAccountsCleanupJobData = {
workspaceId: string;
@@ -14,6 +14,7 @@ export type DeleteWorkspaceMemberConnectedAccountsCleanupJobData = {
export class DeleteWorkspaceMemberConnectedAccountsCleanupJob {
constructor(
private readonly globalWorkspaceOrmManager: GlobalWorkspaceOrmManager,
private readonly connectedAccountDataAccessService: ConnectedAccountDataAccessService,
) {}
@Process(DeleteWorkspaceMemberConnectedAccountsCleanupJob.name)
@@ -25,13 +26,7 @@ export class DeleteWorkspaceMemberConnectedAccountsCleanupJob {
const authContext = buildSystemAuthContext(workspaceId);
await this.globalWorkspaceOrmManager.executeInWorkspaceContext(async () => {
const connectedAccountRepository =
await this.globalWorkspaceOrmManager.getRepository<ConnectedAccountWorkspaceEntity>(
workspaceId,
'connectedAccount',
);
await connectedAccountRepository.delete({
await this.connectedAccountDataAccessService.delete(workspaceId, {
accountOwnerId: workspaceMemberId,
});
}, authContext);
@@ -10,16 +10,16 @@ import { type WorkspaceAuthContext } from 'src/engine/core-modules/auth/types/wo
import { WorkspaceNotFoundDefaultError } from 'src/engine/core-modules/workspace/workspace.exception';
import { WorkspaceManyOrAllFlatEntityMapsCacheService } from 'src/engine/metadata-modules/flat-entity/services/workspace-many-or-all-flat-entity-maps-cache.service';
import { findFlatEntityByUniversalIdentifierOrThrow } from 'src/engine/metadata-modules/flat-entity/utils/find-flat-entity-by-universal-identifier-or-throw.util';
import { MessageChannelDataAccessService } from 'src/engine/metadata-modules/message-channel/data-access/services/message-channel-data-access.service';
import { GlobalWorkspaceOrmManager } from 'src/engine/twenty-orm/global-workspace-datasource/global-workspace-orm.manager';
import { WorkspaceEventEmitter } from 'src/engine/workspace-event-emitter/workspace-event-emitter';
import { type MessageChannelWorkspaceEntity } from 'src/modules/messaging/common/standard-objects/message-channel.workspace-entity';
@WorkspaceQueryHook(`connectedAccount.destroyOne`)
export class ConnectedAccountDeleteOnePreQueryHook
implements WorkspacePreQueryHookInstance
{
constructor(
private readonly globalWorkspaceOrmManager: GlobalWorkspaceOrmManager,
private readonly messageChannelDataAccessService: MessageChannelDataAccessService,
private readonly workspaceEventEmitter: WorkspaceEventEmitter,
private readonly workspaceManyOrAllFlatEntityMapsCacheService: WorkspaceManyOrAllFlatEntityMapsCacheService,
) {}
@@ -38,13 +38,7 @@ export class ConnectedAccountDeleteOnePreQueryHook
const messageChannels =
await this.globalWorkspaceOrmManager.executeInWorkspaceContext(
async () => {
const messageChannelRepository =
await this.globalWorkspaceOrmManager.getRepository<MessageChannelWorkspaceEntity>(
workspace.id,
'messageChannel',
);
return messageChannelRepository.findBy({
return this.messageChannelDataAccessService.find(workspace.id, {
connectedAccountId,
});
},
@@ -3,6 +3,7 @@ import { Module } from '@nestjs/common';
import { NestjsQueryTypeOrmModule } from '@ptc-org/nestjs-query-typeorm';
import { WorkspaceManyOrAllFlatEntityMapsCacheModule } from 'src/engine/metadata-modules/flat-entity/services/workspace-many-or-all-flat-entity-maps-cache.module';
import { MessageChannelDataAccessModule } from 'src/engine/metadata-modules/message-channel/data-access/message-channel-data-access.module';
import { ObjectMetadataEntity } from 'src/engine/metadata-modules/object-metadata/object-metadata.entity';
import { ConnectedAccountDeleteOnePreQueryHook } from 'src/modules/connected-account/query-hooks/connected-account-delete-one.pre-query.hook';
@@ -10,6 +11,7 @@ import { ConnectedAccountDeleteOnePreQueryHook } from 'src/modules/connected-acc
imports: [
NestjsQueryTypeOrmModule.forFeature([ObjectMetadataEntity]),
WorkspaceManyOrAllFlatEntityMapsCacheModule,
MessageChannelDataAccessModule,
],
providers: [ConnectedAccountDeleteOnePreQueryHook],
})
@@ -1,6 +1,7 @@
import { Module } from '@nestjs/common';
import { JwtModule } from 'src/engine/core-modules/jwt/jwt.module';
import { ConnectedAccountDataAccessModule } from 'src/engine/metadata-modules/connected-account/data-access/connected-account-data-access.module';
import { GoogleAPIRefreshAccessTokenModule } from 'src/modules/connected-account/refresh-tokens-manager/drivers/google/google-api-refresh-access-token.module';
import { MicrosoftAPIRefreshAccessTokenModule } from 'src/modules/connected-account/refresh-tokens-manager/drivers/microsoft/microsoft-api-refresh-access-token.module';
import { ConnectedAccountRefreshTokensService } from 'src/modules/connected-account/refresh-tokens-manager/services/connected-account-refresh-tokens.service';
@@ -8,6 +9,7 @@ import { ConnectedAccountRefreshTokensService } from 'src/modules/connected-acco
@Module({
imports: [
JwtModule,
ConnectedAccountDataAccessModule,
GoogleAPIRefreshAccessTokenModule,
MicrosoftAPIRefreshAccessTokenModule,
],
@@ -2,6 +2,7 @@ import { Test, type TestingModule } from '@nestjs/testing';
import { ConnectedAccountProvider } from 'twenty-shared/types';
import { ConnectedAccountDataAccessService } from 'src/engine/metadata-modules/connected-account/data-access/services/connected-account-data-access.service';
import { GlobalWorkspaceOrmManager } from 'src/engine/twenty-orm/global-workspace-datasource/global-workspace-orm.manager';
import { GoogleAPIRefreshAccessTokenService } from 'src/modules/connected-account/refresh-tokens-manager/drivers/google/services/google-api-refresh-tokens.service';
import { MicrosoftAPIRefreshAccessTokenService } from 'src/modules/connected-account/refresh-tokens-manager/drivers/microsoft/services/microsoft-api-refresh-tokens.service';
@@ -17,7 +18,7 @@ describe('ConnectedAccountRefreshTokensService', () => {
let service: ConnectedAccountRefreshTokensService;
let googleAPIRefreshAccessTokenService: GoogleAPIRefreshAccessTokenService;
let microsoftAPIRefreshAccessTokenService: MicrosoftAPIRefreshAccessTokenService;
let globalWorkspaceOrmManager: GlobalWorkspaceOrmManager;
let connectedAccountDataAccessService: ConnectedAccountDataAccessService;
const mockWorkspaceId = 'workspace-123';
const mockConnectedAccountId = 'account-456';
@@ -44,13 +45,18 @@ describe('ConnectedAccountRefreshTokensService', () => {
{
provide: GlobalWorkspaceOrmManager,
useValue: {
getRepository: jest.fn(),
executeInWorkspaceContext: jest
.fn()
.mockImplementation((fn: () => any, _authContext?: any) => fn()),
},
},
{
provide: ConnectedAccountDataAccessService,
useValue: {
update: jest.fn(),
},
},
],
}).compile();
@@ -65,9 +71,10 @@ describe('ConnectedAccountRefreshTokensService', () => {
module.get<MicrosoftAPIRefreshAccessTokenService>(
MicrosoftAPIRefreshAccessTokenService,
);
globalWorkspaceOrmManager = module.get<GlobalWorkspaceOrmManager>(
GlobalWorkspaceOrmManager,
);
connectedAccountDataAccessService =
module.get<ConnectedAccountDataAccessService>(
ConnectedAccountDataAccessService,
);
});
afterEach(() => {
@@ -96,7 +103,7 @@ describe('ConnectedAccountRefreshTokensService', () => {
expect(
microsoftAPIRefreshAccessTokenService.refreshTokens,
).not.toHaveBeenCalled();
expect(globalWorkspaceOrmManager.getRepository).not.toHaveBeenCalled();
expect(connectedAccountDataAccessService.update).not.toHaveBeenCalled();
});
it('should refresh and save new Microsoft token when expired (lastCredentialsRefreshedAt is old)', async () => {
@@ -108,7 +115,6 @@ describe('ConnectedAccountRefreshTokensService', () => {
lastCredentialsRefreshedAt: new Date(Date.now() - 2 * 60 * 60 * 1000), // 2 hours ago
} as ConnectedAccountWorkspaceEntity;
const mockRepository = { update: jest.fn() };
const newTokens = {
accessToken: mockNewAccessToken,
refreshToken: mockRefreshToken,
@@ -117,9 +123,6 @@ describe('ConnectedAccountRefreshTokensService', () => {
jest
.spyOn(microsoftAPIRefreshAccessTokenService, 'refreshTokens')
.mockResolvedValue(newTokens);
jest
.spyOn(globalWorkspaceOrmManager, 'getRepository')
.mockResolvedValue(mockRepository as any);
const result = await service.refreshAndSaveTokens(
connectedAccount,
@@ -130,7 +133,8 @@ describe('ConnectedAccountRefreshTokensService', () => {
expect(
microsoftAPIRefreshAccessTokenService.refreshTokens,
).toHaveBeenCalledWith(mockRefreshToken);
expect(mockRepository.update).toHaveBeenCalledWith(
expect(connectedAccountDataAccessService.update).toHaveBeenCalledWith(
mockWorkspaceId,
{ id: mockConnectedAccountId },
expect.objectContaining({
...newTokens,
@@ -148,7 +152,6 @@ describe('ConnectedAccountRefreshTokensService', () => {
lastCredentialsRefreshedAt: new Date(Date.now() - 2 * 60 * 60 * 1000), // 2 hours ago
} as ConnectedAccountWorkspaceEntity;
const mockRepository = { update: jest.fn() };
const newTokens = {
accessToken: mockNewAccessToken,
refreshToken: mockRefreshToken,
@@ -157,9 +160,6 @@ describe('ConnectedAccountRefreshTokensService', () => {
jest
.spyOn(googleAPIRefreshAccessTokenService, 'refreshTokens')
.mockResolvedValue(newTokens);
jest
.spyOn(globalWorkspaceOrmManager, 'getRepository')
.mockResolvedValue(mockRepository as any);
const result = await service.refreshAndSaveTokens(
connectedAccount,
@@ -170,7 +170,8 @@ describe('ConnectedAccountRefreshTokensService', () => {
expect(
googleAPIRefreshAccessTokenService.refreshTokens,
).toHaveBeenCalledWith(mockRefreshToken);
expect(mockRepository.update).toHaveBeenCalledWith(
expect(connectedAccountDataAccessService.update).toHaveBeenCalledWith(
mockWorkspaceId,
{ id: mockConnectedAccountId },
expect.objectContaining({
...newTokens,
@@ -188,7 +189,6 @@ describe('ConnectedAccountRefreshTokensService', () => {
lastCredentialsRefreshedAt: null,
} as ConnectedAccountWorkspaceEntity;
const mockRepository = { update: jest.fn() };
const newTokens = {
accessToken: mockNewAccessToken,
refreshToken: mockRefreshToken,
@@ -197,9 +197,6 @@ describe('ConnectedAccountRefreshTokensService', () => {
jest
.spyOn(microsoftAPIRefreshAccessTokenService, 'refreshTokens')
.mockResolvedValue(newTokens);
jest
.spyOn(globalWorkspaceOrmManager, 'getRepository')
.mockResolvedValue(mockRepository as any);
const result = await service.refreshAndSaveTokens(
connectedAccount,
@@ -210,7 +207,8 @@ describe('ConnectedAccountRefreshTokensService', () => {
expect(
microsoftAPIRefreshAccessTokenService.refreshTokens,
).toHaveBeenCalledWith(mockRefreshToken);
expect(mockRepository.update).toHaveBeenCalledWith(
expect(connectedAccountDataAccessService.update).toHaveBeenCalledWith(
mockWorkspaceId,
{ id: mockConnectedAccountId },
expect.objectContaining({
...newTokens,
@@ -3,6 +3,7 @@ import { Injectable, Logger } from '@nestjs/common';
import { ConnectedAccountProvider } from 'twenty-shared/types';
import { assertUnreachable, isDefined } from 'twenty-shared/utils';
import { ConnectedAccountDataAccessService } from 'src/engine/metadata-modules/connected-account/data-access/services/connected-account-data-access.service';
import { GlobalWorkspaceOrmManager } from 'src/engine/twenty-orm/global-workspace-datasource/global-workspace-orm.manager';
import { buildSystemAuthContext } from 'src/engine/twenty-orm/utils/build-system-auth-context.util';
import { GoogleAPIRefreshAccessTokenService } from 'src/modules/connected-account/refresh-tokens-manager/drivers/google/services/google-api-refresh-tokens.service';
@@ -30,6 +31,7 @@ export class ConnectedAccountRefreshTokensService {
private readonly googleAPIRefreshAccessTokenService: GoogleAPIRefreshAccessTokenService,
private readonly microsoftAPIRefreshAccessTokenService: MicrosoftAPIRefreshAccessTokenService,
private readonly globalWorkspaceOrmManager: GlobalWorkspaceOrmManager,
private readonly connectedAccountDataAccessService: ConnectedAccountDataAccessService,
) {}
async refreshAndSaveTokens(
@@ -78,13 +80,8 @@ export class ConnectedAccountRefreshTokensService {
const authContext = buildSystemAuthContext(workspaceId);
await this.globalWorkspaceOrmManager.executeInWorkspaceContext(async () => {
const connectedAccountRepository =
await this.globalWorkspaceOrmManager.getRepository<ConnectedAccountWorkspaceEntity>(
workspaceId,
'connectedAccount',
);
await connectedAccountRepository.update(
await this.connectedAccountDataAccessService.update(
workspaceId,
{ id: connectedAccount.id },
{
...connectedAccountTokens,
@@ -5,6 +5,9 @@ import { ConnectedAccountProvider } from 'twenty-shared/types';
import { CreateCalendarChannelService } from 'src/engine/core-modules/auth/services/create-calendar-channel.service';
import { CreateMessageChannelService } from 'src/engine/core-modules/auth/services/create-message-channel.service';
import { type EmailAccountConnectionParameters } from 'src/engine/core-modules/imap-smtp-caldav-connection/dtos/imap-smtp-caldav-connection.dto';
import { CalendarChannelDataAccessService } from 'src/engine/metadata-modules/calendar-channel/data-access/services/calendar-channel-data-access.service';
import { ConnectedAccountDataAccessService } from 'src/engine/metadata-modules/connected-account/data-access/services/connected-account-data-access.service';
import { MessageChannelDataAccessService } from 'src/engine/metadata-modules/message-channel/data-access/services/message-channel-data-access.service';
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';
import { ImapSmtpCalDavAPIService } from 'src/modules/connected-account/services/imap-smtp-caldav-apis.service';
@@ -18,19 +21,17 @@ jest.mock('uuid', () => ({
describe('ImapSmtpCalDavAPIService', () => {
let service: ImapSmtpCalDavAPIService;
const mockConnectedAccountRepository = {
const mockConnectedAccountDataAccessService = {
findOne: jest.fn(),
save: jest.fn(),
};
const mockMessageChannelRepository = {
const mockMessageChannelDataAccessService = {
findOne: jest.fn(),
save: jest.fn(),
};
const mockCalendarChannelRepository = {
const mockCalendarChannelDataAccessService = {
findOne: jest.fn(),
save: jest.fn(),
};
const mockWorkspaceDataSource = {
@@ -52,18 +53,6 @@ describe('ImapSmtpCalDavAPIService', () => {
{
provide: GlobalWorkspaceOrmManager,
useValue: {
getRepository: jest
.fn()
.mockImplementation((_workspaceId, entity) => {
if (entity === 'connectedAccount')
return mockConnectedAccountRepository;
if (entity === 'messageChannel')
return mockMessageChannelRepository;
if (entity === 'calendarChannel')
return mockCalendarChannelRepository;
return {};
}),
getGlobalWorkspaceDataSource: jest
.fn()
.mockResolvedValue(mockWorkspaceDataSource),
@@ -81,6 +70,18 @@ describe('ImapSmtpCalDavAPIService', () => {
provide: CreateCalendarChannelService,
useValue: mockCreateCalendarChannelService,
},
{
provide: ConnectedAccountDataAccessService,
useValue: mockConnectedAccountDataAccessService,
},
{
provide: MessageChannelDataAccessService,
useValue: mockMessageChannelDataAccessService,
},
{
provide: CalendarChannelDataAccessService,
useValue: mockCalendarChannelDataAccessService,
},
],
}).compile();
@@ -112,13 +113,14 @@ describe('ImapSmtpCalDavAPIService', () => {
};
it('should create new account with message channel when account does not exist and IMAP is configured', async () => {
mockConnectedAccountRepository.findOne.mockResolvedValue(null);
mockMessageChannelRepository.findOne.mockResolvedValue(null);
mockCalendarChannelRepository.findOne.mockResolvedValue(null);
mockConnectedAccountDataAccessService.findOne.mockResolvedValue(null);
mockMessageChannelDataAccessService.findOne.mockResolvedValue(null);
mockCalendarChannelDataAccessService.findOne.mockResolvedValue(null);
await service.processAccount(baseInput);
expect(mockConnectedAccountRepository.save).toHaveBeenCalledWith(
expect(mockConnectedAccountDataAccessService.save).toHaveBeenCalledWith(
'workspace-id',
{
id: 'mocked-uuid',
handle: 'test@example.com',
@@ -127,7 +129,6 @@ describe('ImapSmtpCalDavAPIService', () => {
accountOwnerId: 'workspace-member-id',
},
{},
{},
);
expect(
@@ -162,11 +163,13 @@ describe('ImapSmtpCalDavAPIService', () => {
connectedAccountId: 'existing-account-id',
} as CalendarChannelWorkspaceEntity;
mockConnectedAccountRepository.findOne.mockResolvedValue(existingAccount);
mockMessageChannelRepository.findOne.mockResolvedValue(
mockConnectedAccountDataAccessService.findOne.mockResolvedValue(
existingAccount,
);
mockMessageChannelDataAccessService.findOne.mockResolvedValue(
existingMessageChannel,
);
mockCalendarChannelRepository.findOne.mockResolvedValue(
mockCalendarChannelDataAccessService.findOne.mockResolvedValue(
existingCalendarChannel,
);
@@ -177,7 +180,8 @@ describe('ImapSmtpCalDavAPIService', () => {
await service.processAccount(inputWithConnectedAccountId);
expect(mockConnectedAccountRepository.save).toHaveBeenCalledWith(
expect(mockConnectedAccountDataAccessService.save).toHaveBeenCalledWith(
'workspace-id',
{
id: 'existing-account-id',
handle: 'test@example.com',
@@ -186,7 +190,6 @@ describe('ImapSmtpCalDavAPIService', () => {
accountOwnerId: 'workspace-member-id',
},
{},
{},
);
expect(
@@ -210,9 +213,9 @@ describe('ImapSmtpCalDavAPIService', () => {
} as EmailAccountConnectionParameters,
};
mockConnectedAccountRepository.findOne.mockResolvedValue(null);
mockMessageChannelRepository.findOne.mockResolvedValue(null);
mockCalendarChannelRepository.findOne.mockResolvedValue(null);
mockConnectedAccountDataAccessService.findOne.mockResolvedValue(null);
mockMessageChannelDataAccessService.findOne.mockResolvedValue(null);
mockCalendarChannelDataAccessService.findOne.mockResolvedValue(null);
await service.processAccount(imapOnlyInput);
@@ -238,9 +241,9 @@ describe('ImapSmtpCalDavAPIService', () => {
} as EmailAccountConnectionParameters,
};
mockConnectedAccountRepository.findOne.mockResolvedValue(null);
mockMessageChannelRepository.findOne.mockResolvedValue(null);
mockCalendarChannelRepository.findOne.mockResolvedValue(null);
mockConnectedAccountDataAccessService.findOne.mockResolvedValue(null);
mockMessageChannelDataAccessService.findOne.mockResolvedValue(null);
mockCalendarChannelDataAccessService.findOne.mockResolvedValue(null);
await service.processAccount(caldavOnlyInput);
@@ -272,9 +275,9 @@ describe('ImapSmtpCalDavAPIService', () => {
} as EmailAccountConnectionParameters,
};
mockConnectedAccountRepository.findOne.mockResolvedValue(null);
mockMessageChannelRepository.findOne.mockResolvedValue(null);
mockCalendarChannelRepository.findOne.mockResolvedValue(null);
mockConnectedAccountDataAccessService.findOne.mockResolvedValue(null);
mockMessageChannelDataAccessService.findOne.mockResolvedValue(null);
mockCalendarChannelDataAccessService.findOne.mockResolvedValue(null);
await service.processAccount(imapSmtpInput);
@@ -313,9 +316,9 @@ describe('ImapSmtpCalDavAPIService', () => {
} as EmailAccountConnectionParameters,
};
mockConnectedAccountRepository.findOne.mockResolvedValue(null);
mockMessageChannelRepository.findOne.mockResolvedValue(null);
mockCalendarChannelRepository.findOne.mockResolvedValue(null);
mockConnectedAccountDataAccessService.findOne.mockResolvedValue(null);
mockMessageChannelDataAccessService.findOne.mockResolvedValue(null);
mockCalendarChannelDataAccessService.findOne.mockResolvedValue(null);
await service.processAccount(fullConfigInput);
@@ -335,23 +338,26 @@ describe('ImapSmtpCalDavAPIService', () => {
provider: ConnectedAccountProvider.IMAP_SMTP_CALDAV,
} as ConnectedAccountWorkspaceEntity;
mockConnectedAccountRepository.findOne.mockResolvedValueOnce(
mockConnectedAccountDataAccessService.findOne.mockResolvedValueOnce(
existingAccount,
);
mockMessageChannelRepository.findOne.mockResolvedValue(null);
mockCalendarChannelRepository.findOne.mockResolvedValue(null);
mockMessageChannelDataAccessService.findOne.mockResolvedValue(null);
mockCalendarChannelDataAccessService.findOne.mockResolvedValue(null);
await service.processAccount(baseInput);
expect(mockConnectedAccountRepository.findOne).toHaveBeenCalledWith({
expect(
mockConnectedAccountDataAccessService.findOne,
).toHaveBeenCalledWith('workspace-id', {
where: {
handle: 'test@example.com',
accountOwnerId: 'workspace-member-id',
},
});
expect(mockConnectedAccountRepository.save).toHaveBeenCalledWith(
expect(mockConnectedAccountDataAccessService.save).toHaveBeenCalledWith(
'workspace-id',
{
id: 'existing-account-id',
handle: 'test@example.com',
@@ -360,7 +366,6 @@ describe('ImapSmtpCalDavAPIService', () => {
accountOwnerId: 'workspace-member-id',
},
{},
{},
);
});
@@ -378,9 +383,9 @@ describe('ImapSmtpCalDavAPIService', () => {
} as EmailAccountConnectionParameters,
};
mockConnectedAccountRepository.findOne.mockResolvedValue(null);
mockMessageChannelRepository.findOne.mockResolvedValue(null);
mockCalendarChannelRepository.findOne.mockResolvedValue(null);
mockConnectedAccountDataAccessService.findOne.mockResolvedValue(null);
mockMessageChannelDataAccessService.findOne.mockResolvedValue(null);
mockCalendarChannelDataAccessService.findOne.mockResolvedValue(null);
await service.processAccount(smtpOnlyInput);
@@ -393,9 +398,9 @@ describe('ImapSmtpCalDavAPIService', () => {
});
it('should handle transaction correctly', async () => {
mockConnectedAccountRepository.findOne.mockResolvedValue(null);
mockMessageChannelRepository.findOne.mockResolvedValue(null);
mockCalendarChannelRepository.findOne.mockResolvedValue(null);
mockConnectedAccountDataAccessService.findOne.mockResolvedValue(null);
mockMessageChannelDataAccessService.findOne.mockResolvedValue(null);
mockCalendarChannelDataAccessService.findOne.mockResolvedValue(null);
await service.processAccount(baseInput);
@@ -7,17 +7,21 @@ import { v4 } from 'uuid';
import { CreateCalendarChannelService } from 'src/engine/core-modules/auth/services/create-calendar-channel.service';
import { CreateMessageChannelService } from 'src/engine/core-modules/auth/services/create-message-channel.service';
import { type EmailAccountConnectionParameters } from 'src/engine/core-modules/imap-smtp-caldav-connection/dtos/imap-smtp-caldav-connection.dto';
import { CalendarChannelDataAccessService } from 'src/engine/metadata-modules/calendar-channel/data-access/services/calendar-channel-data-access.service';
import { ConnectedAccountDataAccessService } from 'src/engine/metadata-modules/connected-account/data-access/services/connected-account-data-access.service';
import { MessageChannelDataAccessService } from 'src/engine/metadata-modules/message-channel/data-access/services/message-channel-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 { buildSystemAuthContext } from 'src/engine/twenty-orm/utils/build-system-auth-context.util';
import { type CalendarChannelWorkspaceEntity } from 'src/modules/calendar/common/standard-objects/calendar-channel.workspace-entity';
import { type ConnectedAccountWorkspaceEntity } from 'src/modules/connected-account/standard-objects/connected-account.workspace-entity';
import { type MessageChannelWorkspaceEntity } from 'src/modules/messaging/common/standard-objects/message-channel.workspace-entity';
@Injectable()
export class ImapSmtpCalDavAPIService {
constructor(
private readonly globalWorkspaceOrmManager: GlobalWorkspaceOrmManager,
private readonly calendarChannelDataAccessService: CalendarChannelDataAccessService,
private readonly connectedAccountDataAccessService: ConnectedAccountDataAccessService,
private readonly messageChannelDataAccessService: MessageChannelDataAccessService,
private readonly createMessageChannelService: CreateMessageChannelService,
private readonly createCalendarChannelService: CreateCalendarChannelService,
) {}
@@ -30,15 +34,12 @@ export class ImapSmtpCalDavAPIService {
return this.globalWorkspaceOrmManager.executeInWorkspaceContext(
async () => {
const connectedAccountRepository =
await this.globalWorkspaceOrmManager.getRepository<ConnectedAccountWorkspaceEntity>(
workspaceId,
'connectedAccount',
);
const connectedAccount =
await this.connectedAccountDataAccessService.findOne(workspaceId, {
where: { id, provider: ConnectedAccountProvider.IMAP_SMTP_CALDAV },
});
return connectedAccountRepository.findOne({
where: { id, provider: ConnectedAccountProvider.IMAP_SMTP_CALDAV },
});
return connectedAccount as ConnectedAccountWorkspaceEntity | null;
},
authContext,
);
@@ -58,29 +59,11 @@ export class ImapSmtpCalDavAPIService {
return this.globalWorkspaceOrmManager.executeInWorkspaceContext(
async () => {
const connectedAccountRepository =
await this.globalWorkspaceOrmManager.getRepository<ConnectedAccountWorkspaceEntity>(
workspaceId,
'connectedAccount',
);
const messageChannelRepository =
await this.globalWorkspaceOrmManager.getRepository<MessageChannelWorkspaceEntity>(
workspaceId,
'messageChannel',
);
const calendarChannelRepository =
await this.globalWorkspaceOrmManager.getRepository<CalendarChannelWorkspaceEntity>(
workspaceId,
'calendarChannel',
);
const existingAccount = connectedAccountId
? await connectedAccountRepository.findOne({
? await this.connectedAccountDataAccessService.findOne(workspaceId, {
where: { id: connectedAccountId },
})
: await connectedAccountRepository.findOne({
: await this.connectedAccountDataAccessService.findOne(workspaceId, {
where: { handle, accountOwnerId: workspaceMemberId },
});
@@ -91,13 +74,13 @@ export class ImapSmtpCalDavAPIService {
await this.globalWorkspaceOrmManager.getGlobalWorkspaceDataSource();
const existingMessageChannel = existingAccount
? await messageChannelRepository.findOne({
? await this.messageChannelDataAccessService.findOne(workspaceId, {
where: { connectedAccountId: existingAccount.id },
})
: null;
const existingCalendarChannel = existingAccount
? await calendarChannelRepository.findOne({
? await this.calendarChannelDataAccessService.findOne(workspaceId, {
where: { connectedAccountId: existingAccount.id },
})
: null;
@@ -112,7 +95,8 @@ export class ImapSmtpCalDavAPIService {
await workspaceDataSource.transaction(
async (manager: WorkspaceEntityManager) => {
await connectedAccountRepository.save(
await this.connectedAccountDataAccessService.save(
workspaceId,
{
id: newOrExistingAccountId,
handle,
@@ -120,7 +104,6 @@ export class ImapSmtpCalDavAPIService {
connectionParameters: input.connectionParameters,
accountOwnerId: workspaceMemberId,
},
{},
manager,
);
@@ -1,19 +1,30 @@
import { Scope } from '@nestjs/common';
import { type ObjectRecordCreateEvent } from 'twenty-shared/database-events';
import { MessageParticipantRole } from 'twenty-shared/types';
import { isDefined } from 'twenty-shared/utils';
import { And, Any, ILike, In, IsNull, Not, Or } from 'typeorm';
import { type ObjectRecordCreateEvent } from 'twenty-shared/database-events';
import {
And,
Any,
type FindManyOptions,
ILike,
In,
IsNull,
Not,
Or,
} from 'typeorm';
import { type MessageChannelWorkspaceEntity } from 'src/modules/messaging/common/standard-objects/message-channel.workspace-entity';
import { Process } from 'src/engine/core-modules/message-queue/decorators/process.decorator';
import { Processor } from 'src/engine/core-modules/message-queue/decorators/processor.decorator';
import { MessageQueue } from 'src/engine/core-modules/message-queue/message-queue.constants';
import { MessageChannelDataAccessService } from 'src/engine/metadata-modules/message-channel/data-access/services/message-channel-data-access.service';
import { GlobalWorkspaceOrmManager } from 'src/engine/twenty-orm/global-workspace-datasource/global-workspace-orm.manager';
import { buildSystemAuthContext } from 'src/engine/twenty-orm/utils/build-system-auth-context.util';
import { type WorkspaceEventBatch } from 'src/engine/workspace-event-emitter/types/workspace-event-batch.type';
import { type BlocklistWorkspaceEntity } from 'src/modules/blocklist/standard-objects/blocklist.workspace-entity';
import { type MessageChannelMessageAssociationWorkspaceEntity } from 'src/modules/messaging/common/standard-objects/message-channel-message-association.workspace-entity';
import { type MessageChannelWorkspaceEntity } from 'src/modules/messaging/common/standard-objects/message-channel.workspace-entity';
import { MessagingMessageCleanerService } from 'src/modules/messaging/message-cleaner/services/messaging-message-cleaner.service';
export type BlocklistItemDeleteMessagesJobData = WorkspaceEventBatch<
@@ -28,6 +39,7 @@ export class BlocklistItemDeleteMessagesJob {
constructor(
private readonly threadCleanerService: MessagingMessageCleanerService,
private readonly globalWorkspaceOrmManager: GlobalWorkspaceOrmManager,
private readonly messageChannelDataAccessService: MessageChannelDataAccessService,
) {}
@Process(BlocklistItemDeleteMessagesJob.name)
@@ -72,12 +84,6 @@ export class BlocklistItemDeleteMessagesJob {
new Map<string, string[]>(),
);
const messageChannelRepository =
await this.globalWorkspaceOrmManager.getRepository<MessageChannelWorkspaceEntity>(
workspaceId,
'messageChannel',
);
const messageChannelMessageAssociationRepository =
await this.globalWorkspaceOrmManager.getRepository<MessageChannelMessageAssociationWorkspaceEntity>(
workspaceId,
@@ -97,30 +103,35 @@ export class BlocklistItemDeleteMessagesJob {
MessageParticipantRole.TO,
] as const;
const messageChannels = await messageChannelRepository.find({
select: {
id: true,
handle: true,
connectedAccount: {
handleAliases: true,
const messageChannels =
await this.messageChannelDataAccessService.findMany(workspaceId, {
select: {
id: true,
handle: true,
connectedAccount: {
handleAliases: true,
},
},
},
where: {
connectedAccount: {
accountOwnerId: workspaceMemberId,
deletedAt: IsNull(),
where: {
connectedAccount: {
accountOwnerId: workspaceMemberId,
deletedAt: IsNull(),
},
},
},
relations: ['connectedAccount'],
});
relations: ['connectedAccount'],
} as FindManyOptions<MessageChannelWorkspaceEntity>);
for (const messageChannel of messageChannels) {
const messageChannelHandles = [messageChannel.handle];
if (isDefined(messageChannel.connectedAccount.handleAliases)) {
messageChannelHandles.push(
...messageChannel.connectedAccount.handleAliases.split(','),
);
const handleAliases = messageChannel.connectedAccount?.handleAliases;
if (isDefined(handleAliases)) {
const aliasList: string[] = Array.isArray(handleAliases)
? handleAliases
: (handleAliases as string).split(',');
messageChannelHandles.push(...aliasList);
}
const handleConditions = handles.map((handle) => {

Some files were not shown because too many files have changed in this diff Show More