diff --git a/packages/twenty-server/src/engine/core-modules/auth/services/google-apis.service.spec.ts b/packages/twenty-server/src/engine/core-modules/auth/services/google-apis.service.spec.ts index 1a12b2a901..afa772f6ec 100644 --- a/packages/twenty-server/src/engine/core-modules/auth/services/google-apis.service.spec.ts +++ b/packages/twenty-server/src/engine/core-modules/auth/services/google-apis.service.spec.ts @@ -47,8 +47,13 @@ describe('GoogleAPIsService', () => { findOne: jest.fn(), }; + const mockTransactionEntityRepository = { + save: jest.fn(), + update: jest.fn(), + }; + const mockTransactionManager = { - getRepository: jest.fn().mockReturnValue({ save: jest.fn() }), + getRepository: jest.fn().mockReturnValue(mockTransactionEntityRepository), }; const mockMessageChannelRepository = { @@ -298,5 +303,65 @@ describe('GoogleAPIsService', () => { createMessageChannelService.createMessageChannel, ).toHaveBeenCalled(); }); + + it('should re-enable sync on existing channels when reconnecting an archived account', async () => { + mockTwentyConfigService.get.mockImplementation((key) => { + if (key === 'CALENDAR_PROVIDER_GOOGLE_ENABLED') return true; + if (key === 'MESSAGING_PROVIDER_GMAIL_ENABLED') return true; + + return false; + }); + + const archivedConnectedAccount = { + id: 'archived-account-id', + handle: 'test@example.com', + userWorkspaceId: 'user-workspace-id', + provider: ConnectedAccountProvider.GOOGLE, + archivedAt: new Date('2026-01-01'), + } as ConnectedAccountEntity; + + mockConnectedAccountRepository.findOne.mockResolvedValue( + archivedConnectedAccount, + ); + + mockWorkspaceMemberRepository.findOne.mockResolvedValue({ + id: 'workspace-member-id', + userId: 'user-id', + }); + + mockMessageChannelRepository.find.mockResolvedValue([ + { + id: 'message-channel-id', + connectedAccountId: 'archived-account-id', + }, + ]); + + mockCalendarChannelRepository.find.mockResolvedValue([ + { + id: 'calendar-channel-id', + connectedAccountId: 'archived-account-id', + }, + ]); + + await service.refreshGoogleRefreshToken({ + handle: 'test@example.com', + userId: 'user-id', + workspaceMemberId: 'workspace-member-id', + workspaceId: 'workspace-id', + accessToken: 'new-access-token' as PlaintextString, + refreshToken: 'new-refresh-token' as PlaintextString, + calendarVisibility: CalendarChannelVisibility.SHARE_EVERYTHING, + messageVisibility: MessageChannelVisibility.SHARE_EVERYTHING, + }); + + expect(mockTransactionEntityRepository.update).toHaveBeenCalledWith( + { + connectedAccountId: 'archived-account-id', + workspaceId: 'workspace-id', + }, + { isSyncEnabled: true }, + ); + expect(mockTransactionEntityRepository.update).toHaveBeenCalledTimes(2); + }); }); }); diff --git a/packages/twenty-server/src/engine/core-modules/auth/services/google-apis.service.ts b/packages/twenty-server/src/engine/core-modules/auth/services/google-apis.service.ts index 1f5e5e04e1..ed6c61326a 100644 --- a/packages/twenty-server/src/engine/core-modules/auth/services/google-apis.service.ts +++ b/packages/twenty-server/src/engine/core-modules/auth/services/google-apis.service.ts @@ -160,6 +160,7 @@ export class GoogleAPIsService { const existingAccountId = connectedAccount?.id; const newOrExistingConnectedAccountId = existingAccountId ?? v4(); + const wasArchived = isDefined(connectedAccount?.archivedAt); const existingMessageChannels = await this.messageChannelRepository.find({ @@ -239,6 +240,40 @@ export class GoogleAPIsService { transactionManager, }); } + + if ( + wasArchived && + isMessagingEnabled && + isMessagingAvailable && + existingMessageChannels.length > 0 + ) { + await transactionManager + .getRepository(MessageChannelEntity) + .update( + { + connectedAccountId: newOrExistingConnectedAccountId, + workspaceId, + }, + { isSyncEnabled: true }, + ); + } + + if ( + wasArchived && + isCalendarEnabled && + isCalendarAvailable && + existingCalendarChannels.length > 0 + ) { + await transactionManager + .getRepository(CalendarChannelEntity) + .update( + { + connectedAccountId: newOrExistingConnectedAccountId, + workspaceId, + }, + { isSyncEnabled: true }, + ); + } }, ); diff --git a/packages/twenty-server/src/engine/core-modules/auth/services/microsoft-apis.service.spec.ts b/packages/twenty-server/src/engine/core-modules/auth/services/microsoft-apis.service.spec.ts index 4b5771a846..7015692d02 100644 --- a/packages/twenty-server/src/engine/core-modules/auth/services/microsoft-apis.service.spec.ts +++ b/packages/twenty-server/src/engine/core-modules/auth/services/microsoft-apis.service.spec.ts @@ -44,8 +44,13 @@ describe('MicrosoftAPIsService', () => { findOne: jest.fn(), }; + const mockTransactionEntityRepository = { + save: jest.fn(), + update: jest.fn(), + }; + const mockTransactionManager = { - getRepository: jest.fn().mockReturnValue({ save: jest.fn() }), + getRepository: jest.fn().mockReturnValue(mockTransactionEntityRepository), }; const mockMessageChannelRepository = { @@ -277,5 +282,65 @@ describe('MicrosoftAPIsService', () => { createMessageChannelService.createMessageChannel, ).not.toHaveBeenCalled(); }); + + it('should re-enable sync on existing channels when reconnecting an archived account', async () => { + mockTwentyConfigService.get.mockImplementation((key) => { + if (key === 'CALENDAR_PROVIDER_MICROSOFT_ENABLED') return true; + if (key === 'MESSAGING_PROVIDER_MICROSOFT_ENABLED') return true; + + return false; + }); + + const archivedConnectedAccount = { + id: 'archived-account-id', + handle: 'test@example.com', + userWorkspaceId: 'user-workspace-id', + provider: ConnectedAccountProvider.MICROSOFT, + archivedAt: new Date('2026-01-01'), + } as ConnectedAccountEntity; + + mockConnectedAccountRepository.findOne.mockResolvedValue( + archivedConnectedAccount, + ); + + mockWorkspaceMemberRepository.findOne.mockResolvedValue({ + id: 'workspace-member-id', + userId: 'user-id', + }); + + mockMessageChannelRepository.find.mockResolvedValue([ + { + id: 'message-channel-id', + connectedAccountId: 'archived-account-id', + }, + ]); + + mockCalendarChannelRepository.find.mockResolvedValue([ + { + id: 'calendar-channel-id', + connectedAccountId: 'archived-account-id', + }, + ]); + + await service.refreshMicrosoftRefreshToken({ + handle: 'test@example.com', + userId: 'user-id', + workspaceMemberId: 'workspace-member-id', + workspaceId: 'workspace-id', + accessToken: 'new-access-token' as PlaintextString, + refreshToken: 'new-refresh-token' as PlaintextString, + calendarVisibility: CalendarChannelVisibility.SHARE_EVERYTHING, + messageVisibility: MessageChannelVisibility.SHARE_EVERYTHING, + }); + + expect(mockTransactionEntityRepository.update).toHaveBeenCalledWith( + { + connectedAccountId: 'archived-account-id', + workspaceId: 'workspace-id', + }, + { isSyncEnabled: true }, + ); + expect(mockTransactionEntityRepository.update).toHaveBeenCalledTimes(2); + }); }); }); diff --git a/packages/twenty-server/src/engine/core-modules/auth/services/microsoft-apis.service.ts b/packages/twenty-server/src/engine/core-modules/auth/services/microsoft-apis.service.ts index 5559b823e7..75c7f8bef7 100644 --- a/packages/twenty-server/src/engine/core-modules/auth/services/microsoft-apis.service.ts +++ b/packages/twenty-server/src/engine/core-modules/auth/services/microsoft-apis.service.ts @@ -126,6 +126,7 @@ export class MicrosoftAPIsService { const existingAccountId = connectedAccount?.id; const newOrExistingConnectedAccountId = existingAccountId ?? v4(); + const wasArchived = isDefined(connectedAccount?.archivedAt); const existingMessageChannels = await this.messageChannelRepository.find({ @@ -217,6 +218,30 @@ export class MicrosoftAPIsService { transactionManager, }); } + + if (wasArchived && existingMessageChannels.length > 0) { + await transactionManager + .getRepository(MessageChannelEntity) + .update( + { + connectedAccountId: newOrExistingConnectedAccountId, + workspaceId, + }, + { isSyncEnabled: true }, + ); + } + + if (wasArchived && existingCalendarChannels.length > 0) { + await transactionManager + .getRepository(CalendarChannelEntity) + .update( + { + connectedAccountId: newOrExistingConnectedAccountId, + workspaceId, + }, + { isSyncEnabled: true }, + ); + } }, ); diff --git a/packages/twenty-server/src/engine/core-modules/auth/services/update-connected-account-on-reconnect.service.ts b/packages/twenty-server/src/engine/core-modules/auth/services/update-connected-account-on-reconnect.service.ts index 461c17c524..fd995750fe 100644 --- a/packages/twenty-server/src/engine/core-modules/auth/services/update-connected-account-on-reconnect.service.ts +++ b/packages/twenty-server/src/engine/core-modules/auth/services/update-connected-account-on-reconnect.service.ts @@ -60,6 +60,7 @@ export class UpdateConnectedAccountOnReconnectService { refreshToken: encryptedRefreshToken, scopes, authFailedAt: null, + archivedAt: null, }, ); }, authContext); diff --git a/packages/twenty-server/src/modules/connected-account/services/imap-smtp-caldav-apis.service.spec.ts b/packages/twenty-server/src/modules/connected-account/services/imap-smtp-caldav-apis.service.spec.ts index f91d2e46ed..cb3cd11438 100644 --- a/packages/twenty-server/src/modules/connected-account/services/imap-smtp-caldav-apis.service.spec.ts +++ b/packages/twenty-server/src/modules/connected-account/services/imap-smtp-caldav-apis.service.spec.ts @@ -60,9 +60,11 @@ describe('ImapSmtpCalDavAPIService', () => { let service: ImapSmtpCalDavAPIService; const mockTransactionManagerSave = jest.fn(); + const mockTransactionManagerUpdate = jest.fn(); const mockTransactionManager = { getRepository: jest.fn().mockReturnValue({ save: mockTransactionManagerSave, + update: mockTransactionManagerUpdate, }), }; @@ -284,6 +286,7 @@ describe('ImapSmtpCalDavAPIService', () => { userWorkspaceId: 'user-workspace-id', workspaceId: 'workspace-id', authFailedAt: null, + archivedAt: null, }); expect( @@ -363,6 +366,7 @@ describe('ImapSmtpCalDavAPIService', () => { userWorkspaceId: 'user-workspace-id', workspaceId: 'workspace-id', authFailedAt: null, + archivedAt: null, }); expect( @@ -399,6 +403,73 @@ describe('ImapSmtpCalDavAPIService', () => { ); }); + it('should re-enable sync on existing channels when reconnecting an archived account', async () => { + const archivedAccount = { + id: 'archived-account-id', + handle: 'test@example.com', + userWorkspaceId: 'user-workspace-id', + provider: ConnectedAccountProvider.IMAP_SMTP_CALDAV, + archivedAt: new Date('2026-01-01'), + } as ConnectedAccountEntity; + + const existingMessageChannel = { + id: 'existing-message-channel-id', + connectedAccountId: 'archived-account-id', + syncStage: MessageChannelSyncStage.MESSAGE_LIST_FETCH_PENDING, + } as MessageChannelEntity; + + const existingCalendarChannel = { + id: 'existing-calendar-channel-id', + connectedAccountId: 'archived-account-id', + syncStage: CalendarChannelSyncStage.CALENDAR_EVENT_LIST_FETCH_PENDING, + } as CalendarChannelEntity; + + mockConnectedAccountRepository.findOne.mockResolvedValue(archivedAccount); + mockMessageChannelRepository.findOne.mockResolvedValue( + existingMessageChannel, + ); + mockCalendarChannelRepository.findOne.mockResolvedValue( + existingCalendarChannel, + ); + mockWorkspaceMemberRepository.findOne.mockResolvedValue({ + id: 'workspace-member-id', + userId: 'user-id', + }); + mockUserWorkspaceRepository.findOne.mockResolvedValue({ + id: 'user-workspace-id', + userId: 'user-id', + }); + + const inputWithCalDav = { + ...baseInput, + connectionParameters: { + ...baseInput.connectionParameters, + CALDAV: { + host: 'caldav.example.com', + port: 443, + connectionSecurity: 'SSL_TLS', + username: 'test@example.com', + password: 'password' as PlaintextString, + }, + } as PlaintextImapSmtpCaldavParams, + }; + + await service.upsertConnectedAccount(inputWithCalDav); + + expect(mockTransactionManagerSave).toHaveBeenCalledWith( + expect.objectContaining({ archivedAt: null }), + ); + + expect(mockTransactionManagerUpdate).toHaveBeenCalledWith( + { id: 'existing-message-channel-id', workspaceId: 'workspace-id' }, + { isSyncEnabled: true }, + ); + expect(mockTransactionManagerUpdate).toHaveBeenCalledWith( + { id: 'existing-calendar-channel-id', workspaceId: 'workspace-id' }, + { isSyncEnabled: true }, + ); + }); + it('should leave channels in PENDING_CONFIGURATION untouched', async () => { const existingAccount = { id: 'existing-account-id', @@ -672,6 +743,7 @@ describe('ImapSmtpCalDavAPIService', () => { userWorkspaceId: 'user-workspace-id', workspaceId: 'workspace-id', authFailedAt: null, + archivedAt: null, }); }); diff --git a/packages/twenty-server/src/modules/connected-account/services/imap-smtp-caldav-apis.service.ts b/packages/twenty-server/src/modules/connected-account/services/imap-smtp-caldav-apis.service.ts index 74ced60179..efdcdb7811 100644 --- a/packages/twenty-server/src/modules/connected-account/services/imap-smtp-caldav-apis.service.ts +++ b/packages/twenty-server/src/modules/connected-account/services/imap-smtp-caldav-apis.service.ts @@ -95,6 +95,7 @@ export class ImapSmtpCalDavAPIService { })); const newOrExistingAccountId = existingAccount?.id ?? v4(); + const wasArchived = isDefined(existingAccount?.archivedAt); const existingMessageChannel = existingAccount ? await this.messageChannelRepository.findOne({ @@ -134,6 +135,7 @@ export class ImapSmtpCalDavAPIService { userWorkspaceId, workspaceId, authFailedAt: null, + archivedAt: null, }); if (shouldCreateMessageChannel) { @@ -153,6 +155,32 @@ export class ImapSmtpCalDavAPIService { transactionManager, }); } + + if ( + wasArchived && + isDefined(existingMessageChannel) && + isDefined(input.connectionParameters.IMAP) + ) { + await transactionManager + .getRepository(MessageChannelEntity) + .update( + { id: existingMessageChannel.id, workspaceId }, + { isSyncEnabled: true }, + ); + } + + if ( + wasArchived && + isDefined(existingCalendarChannel) && + isDefined(input.connectionParameters.CALDAV) + ) { + await transactionManager + .getRepository(CalendarChannelEntity) + .update( + { id: existingCalendarChannel.id, workspaceId }, + { isSyncEnabled: true }, + ); + } }, );