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
@@ -4,8 +4,6 @@ import { TypeOrmModule } from '@nestjs/typeorm';
import { UserWorkspaceEntity } from 'src/engine/core-modules/user-workspace/user-workspace.entity';
import { UserVarsModule } from 'src/engine/core-modules/user/user-vars/user-vars.module';
import { ConnectedAccountEntity } from 'src/engine/metadata-modules/connected-account/entities/connected-account.entity';
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';
@@ -14,12 +12,7 @@ import { AccountsToReconnectService } from 'src/modules/connected-account/servic
TypeOrmModule.forFeature([ConnectedAccountEntity, UserWorkspaceEntity]),
UserVarsModule,
],
providers: [
AccountsToReconnectService,
ConnectedAccountListener,
DeleteWorkspaceMemberConnectedAccountsCleanupJob,
ConnectedAccountWorkspaceMemberListener,
],
providers: [AccountsToReconnectService, ConnectedAccountListener],
exports: [AccountsToReconnectService],
})
export class ConnectedAccountModule {}
@@ -1,67 +0,0 @@
import { InjectRepository } from '@nestjs/typeorm';
import { type Repository } from 'typeorm';
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 { UserWorkspaceEntity } from 'src/engine/core-modules/user-workspace/user-workspace.entity';
import { ConnectedAccountEntity } from 'src/engine/metadata-modules/connected-account/entities/connected-account.entity';
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 WorkspaceMemberWorkspaceEntity } from 'src/modules/workspace-member/standard-objects/workspace-member.workspace-entity';
export type DeleteWorkspaceMemberConnectedAccountsCleanupJobData = {
workspaceId: string;
workspaceMemberId: string;
};
@Processor(MessageQueue.deleteCascadeQueue)
export class DeleteWorkspaceMemberConnectedAccountsCleanupJob {
constructor(
private readonly globalWorkspaceOrmManager: GlobalWorkspaceOrmManager,
@InjectRepository(ConnectedAccountEntity)
private readonly connectedAccountRepository: Repository<ConnectedAccountEntity>,
@InjectRepository(UserWorkspaceEntity)
private readonly userWorkspaceRepository: Repository<UserWorkspaceEntity>,
) {}
@Process(DeleteWorkspaceMemberConnectedAccountsCleanupJob.name)
async handle(
data: DeleteWorkspaceMemberConnectedAccountsCleanupJobData,
): Promise<void> {
const { workspaceId, workspaceMemberId } = data;
const authContext = buildSystemAuthContext(workspaceId);
await this.globalWorkspaceOrmManager.executeInWorkspaceContext(async () => {
const workspaceMemberRepo =
await this.globalWorkspaceOrmManager.getRepository<WorkspaceMemberWorkspaceEntity>(
workspaceId,
'workspaceMember',
{ shouldBypassPermissionChecks: true },
);
const member = await workspaceMemberRepo.findOne({
where: { id: workspaceMemberId },
});
if (!member) {
return;
}
const userWorkspace = await this.userWorkspaceRepository.findOne({
where: { userId: member.userId, workspaceId },
});
if (!userWorkspace) {
return;
}
await this.connectedAccountRepository.delete({
userWorkspaceId: userWorkspace.id,
workspaceId,
});
}, authContext);
}
}
@@ -1,47 +0,0 @@
import { Injectable } from '@nestjs/common';
import {
type ObjectRecordDeleteEvent,
type ObjectRecordDestroyEvent,
} from 'twenty-shared/database-events';
import { OnDatabaseBatchEvent } from 'src/engine/api/graphql/graphql-query-runner/decorators/on-database-batch-event.decorator';
import { DatabaseEventAction } from 'src/engine/api/graphql/graphql-query-runner/enums/database-event-action';
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 { WorkspaceEventBatch } from 'src/engine/workspace-event-emitter/types/workspace-event-batch.type';
import {
DeleteWorkspaceMemberConnectedAccountsCleanupJob,
type DeleteWorkspaceMemberConnectedAccountsCleanupJobData,
} from 'src/modules/connected-account/jobs/delete-workspace-member-connected-accounts.job';
import { WorkspaceMemberWorkspaceEntity } from 'src/modules/workspace-member/standard-objects/workspace-member.workspace-entity';
@Injectable()
export class ConnectedAccountWorkspaceMemberListener {
constructor(
@InjectMessageQueue(MessageQueue.deleteCascadeQueue)
private readonly messageQueueService: MessageQueueService,
) {}
@OnDatabaseBatchEvent('workspaceMember', DatabaseEventAction.DESTROYED)
@OnDatabaseBatchEvent('workspaceMember', DatabaseEventAction.DELETED)
async handleWorkspaceMemberRemovalEvent(
payload: WorkspaceEventBatch<
| ObjectRecordDeleteEvent<WorkspaceMemberWorkspaceEntity>
| ObjectRecordDestroyEvent<WorkspaceMemberWorkspaceEntity>
>,
) {
await Promise.all(
payload.events.map((eventPayload) =>
this.messageQueueService.add<DeleteWorkspaceMemberConnectedAccountsCleanupJobData>(
DeleteWorkspaceMemberConnectedAccountsCleanupJob.name,
{
workspaceId: payload.workspaceId,
workspaceMemberId: eventPayload.recordId,
},
),
),
);
}
}