Keep synced messages and events when removing a workspace member (#21443)

## Context

Removing a workspace member deletes their connected accounts, which
cascades into deleting every message and calendar event those accounts
synced. For a CRM, losing the email history of departed teammates is a
big deal.

## What this does

Connected accounts are now kept and reassigned instead of deleted when a
member is removed:

- Ownership moves to the acting user (whoever removed the member). When
members remove themselves (leave workspace, account deletion), it falls
back to the oldest admin.
- OAuth tokens are revoked, credentials wiped, message/calendar channels
get `isSyncEnabled = false`, and the account is stamped with a new
`archivedAt` column (fast instance command included).
- Synced messages, threads and calendar events stay in the workspace.
Channel visibility settings keep applying as before, since channels and
associations survive.
- The reassigned account appears in the new owner's Settings → Accounts,
where it can still be deleted (with its data) like any other account.

The transfer happens synchronously during removal, while the member's
userWorkspace row still exists. This also removes
`DeleteWorkspaceMemberConnectedAccountsCleanupJob` and its listener: the
async job had to reconstruct the account-owner link from rows the
removal flow had just deleted, which was race-prone (see 2181fb541e).

Archived accounts are excluded from the workflow send-email default
account resolution, and both removal confirmation modals now mention
what happens to synced data.

---------

Co-authored-by: claude[bot] <41898282+claude[bot]@users.noreply.github.com>
This commit is contained in:
Félix Malfait
2026-06-13 15:52:13 +02:00
committed by GitHub
parent f63f053444
commit 76f69efb43
27 changed files with 485 additions and 398 deletions
@@ -1,7 +1,7 @@
import { Injectable, Logger } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { In, IsNull, Repository } from 'typeorm';
import { AppOAuthRevokeService } from 'src/engine/core-modules/application/connection-provider/refresh/services/app-oauth-revoke.service';
import { CALENDAR_CHANNEL_DELETED_EVENT } from 'src/engine/metadata-modules/calendar-channel/constants/calendar-channel-deleted.constant';
@@ -161,6 +161,61 @@ export class ConnectedAccountMetadataService {
return this.repository.findOneOrFail({ where: { id, workspaceId } });
}
async transferOwnership({
fromUserWorkspaceId,
toUserWorkspaceId,
workspaceId,
}: {
fromUserWorkspaceId: string;
toUserWorkspaceId: string;
workspaceId: string;
}): Promise<void> {
const connectedAccounts = await this.repository.find({
where: { userWorkspaceId: fromUserWorkspaceId, workspaceId },
});
if (connectedAccounts.length === 0) {
return;
}
const connectedAccountIds = connectedAccounts.map((account) => account.id);
await this.repository.manager.transaction(async (entityManager) => {
await entityManager.update(
ConnectedAccountEntity,
{ id: In(connectedAccountIds), workspaceId },
{
userWorkspaceId: toUserWorkspaceId,
accessToken: null,
refreshToken: null,
connectionParameters: null,
},
);
await entityManager.update(
ConnectedAccountEntity,
{ id: In(connectedAccountIds), workspaceId, archivedAt: IsNull() },
{ archivedAt: new Date() },
);
await entityManager.update(
MessageChannelEntity,
{ connectedAccountId: In(connectedAccountIds), workspaceId },
{ isSyncEnabled: false },
);
await entityManager.update(
CalendarChannelEntity,
{ connectedAccountId: In(connectedAccountIds), workspaceId },
{ isSyncEnabled: false },
);
});
for (const connectedAccount of connectedAccounts) {
await this.appOAuthRevokeService.revokeIfApp(connectedAccount);
}
}
async delete({
id,
workspaceId,
@@ -45,6 +45,13 @@ export class ConnectedAccountDTO {
@Field(() => Date, { nullable: true })
authFailedAt: Date | null;
// Set when the account is frozen after its owner is removed from the
// workspace: synced data is kept but the account is read-only.
@IsDateString()
@IsOptional()
@Field(() => Date, { nullable: true })
archivedAt: Date | null;
@IsArray()
@IsOptional()
@Field(() => [String], { nullable: true })
@@ -65,6 +65,9 @@ export class ConnectedAccountEntity extends WorkspaceRelatedEntity {
@Column({ type: 'timestamptz', nullable: true })
authFailedAt: Date | null;
@Column({ type: 'timestamptz', nullable: true })
archivedAt: Date | null;
@Column({ type: 'varchar', array: true, nullable: true })
handleAliases: string[] | null;