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:
+66
-1
@@ -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 },
|
||||
);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
|
||||
+66
-1
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
+25
@@ -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 },
|
||||
);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
|
||||
+1
@@ -60,6 +60,7 @@ export class UpdateConnectedAccountOnReconnectService {
|
||||
refreshToken: encryptedRefreshToken,
|
||||
scopes,
|
||||
authFailedAt: null,
|
||||
archivedAt: null,
|
||||
},
|
||||
);
|
||||
}, authContext);
|
||||
|
||||
+72
@@ -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,
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
+28
@@ -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 },
|
||||
);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user