Messaging archived account fix (#23553)

Fix case where workspace admin wants to reconnect inherited connected
account

Case:
- workspace admin inherits team accounts from other workspace member who
left the workspace
- admin wants to reconnect inherited channels but it's not possible as
there's no path to make archived connected account active

Expected outcome: admin, who has credentials to archived connected
accounts, can reconnect said accounts

<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/23553?utm_source=github"
target="_blank" rel="noopener noreferrer"
data-no-image-dialog="true"><picture><source
media="(prefers-color-scheme: dark)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"><source
media="(prefers-color-scheme: light)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-light.svg"><img
alt="Review in cubic"
src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"></picture></a>
<!-- End of auto-generated description by cubic. -->

---------

Co-authored-by: neo773 <huzef@twenty.com>
Co-authored-by: neo773 <62795688+neo773@users.noreply.github.com>
This commit is contained in:
BOHEUS
2026-08-03 14:34:31 +02:00
committed by GitHub
parent e81fdbcc7a
commit 495bd193a3
7 changed files with 293 additions and 2 deletions
@@ -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);
});
});
});
@@ -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 },
);
}
},
);
@@ -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);
});
});
});
@@ -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 },
);
}
},
);
@@ -60,6 +60,7 @@ export class UpdateConnectedAccountOnReconnectService {
refreshToken: encryptedRefreshToken,
scopes,
authFailedAt: null,
archivedAt: null,
},
);
}, authContext);
@@ -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,
});
});
@@ -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 },
);
}
},
);