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
@@ -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,
);