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:
@@ -11,7 +11,7 @@ import { WorkspaceDomainsService } from 'src/engine/core-modules/domain/workspac
|
||||
import { EmailVerificationService } from 'src/engine/core-modules/email-verification/services/email-verification.service';
|
||||
import { MessageQueue } from 'src/engine/core-modules/message-queue/message-queue.constants';
|
||||
import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service';
|
||||
import { type UserWorkspaceEntity } from 'src/engine/core-modules/user-workspace/user-workspace.entity';
|
||||
import { UserWorkspaceEntity } from 'src/engine/core-modules/user-workspace/user-workspace.entity';
|
||||
import { UserWorkspaceService } from 'src/engine/core-modules/user-workspace/user-workspace.service';
|
||||
import { UserService } from 'src/engine/core-modules/user/services/user.service';
|
||||
import { WorkspaceMemberTranspiler } from 'src/engine/core-modules/user/services/workspace-member-transpiler.service';
|
||||
@@ -23,6 +23,7 @@ import {
|
||||
PermissionsExceptionCode,
|
||||
} from 'src/engine/metadata-modules/permissions/permissions.exception';
|
||||
import { CoreEntityCacheService } from 'src/engine/core-entity-cache/services/core-entity-cache.service';
|
||||
import { ConnectedAccountMetadataService } from 'src/engine/metadata-modules/connected-account/connected-account-metadata.service';
|
||||
import { UserRoleService } from 'src/engine/metadata-modules/user-role/user-role.service';
|
||||
import { GlobalWorkspaceOrmManager } from 'src/engine/twenty-orm/global-workspace-datasource/global-workspace-orm.manager';
|
||||
import { type WorkspaceRepository } from 'src/engine/twenty-orm/repository/workspace.repository';
|
||||
@@ -96,6 +97,18 @@ describe('UserService', () => {
|
||||
validateUserWorkspaceIsNotUniqueAdminOrThrow: jest.fn(),
|
||||
},
|
||||
},
|
||||
{
|
||||
provide: getRepositoryToken(UserWorkspaceEntity),
|
||||
useValue: {
|
||||
find: jest.fn().mockResolvedValue([]),
|
||||
},
|
||||
},
|
||||
{
|
||||
provide: ConnectedAccountMetadataService,
|
||||
useValue: {
|
||||
transferOwnership: jest.fn(),
|
||||
},
|
||||
},
|
||||
{
|
||||
provide: UserWorkspaceService,
|
||||
useValue: {
|
||||
|
||||
@@ -44,9 +44,11 @@ import {
|
||||
PermissionsExceptionCode,
|
||||
PermissionsExceptionMessage,
|
||||
} from 'src/engine/metadata-modules/permissions/permissions.exception';
|
||||
import { ConnectedAccountMetadataService } from 'src/engine/metadata-modules/connected-account/connected-account-metadata.service';
|
||||
import { UserRoleService } from 'src/engine/metadata-modules/user-role/user-role.service';
|
||||
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 { STANDARD_ROLE } from 'src/engine/workspace-manager/twenty-standard-application/constants/standard-role.constant';
|
||||
import { WorkspaceMemberWorkspaceEntity } from 'src/modules/workspace-member/standard-objects/workspace-member.workspace-entity';
|
||||
|
||||
// oxlint-disable-next-line twenty/inject-workspace-repository
|
||||
@@ -54,6 +56,9 @@ export class UserService extends TypeOrmQueryService<UserEntity> {
|
||||
constructor(
|
||||
@InjectRepository(UserEntity)
|
||||
private readonly userRepository: Repository<UserEntity>,
|
||||
@InjectRepository(UserWorkspaceEntity)
|
||||
private readonly userWorkspaceRepository: Repository<UserWorkspaceEntity>,
|
||||
private readonly connectedAccountMetadataService: ConnectedAccountMetadataService,
|
||||
private readonly workspaceDomainsService: WorkspaceDomainsService,
|
||||
private readonly emailVerificationService: EmailVerificationService,
|
||||
private readonly workspaceService: WorkspaceService,
|
||||
@@ -295,9 +300,11 @@ export class UserService extends TypeOrmQueryService<UserEntity> {
|
||||
async deleteUserWorkspaceAndPotentiallyDeleteUser({
|
||||
userId,
|
||||
workspaceId,
|
||||
actingUserWorkspaceId,
|
||||
}: {
|
||||
userId: string;
|
||||
workspaceId: string;
|
||||
actingUserWorkspaceId?: string;
|
||||
}) {
|
||||
const user = await this.userRepository.findOne({
|
||||
where: {
|
||||
@@ -318,6 +325,7 @@ export class UserService extends TypeOrmQueryService<UserEntity> {
|
||||
|
||||
await this.removeUserFromWorkspaceAndPotentiallyDeleteWorkspace(
|
||||
userWorkspace,
|
||||
actingUserWorkspaceId,
|
||||
);
|
||||
|
||||
if (user.userWorkspaces.length === 1) {
|
||||
@@ -330,6 +338,7 @@ export class UserService extends TypeOrmQueryService<UserEntity> {
|
||||
|
||||
async removeUserFromWorkspaceAndPotentiallyDeleteWorkspace(
|
||||
userWorkspace: UserWorkspaceEntity,
|
||||
actingUserWorkspaceId?: string,
|
||||
) {
|
||||
const workspaceId = userWorkspace.workspaceId;
|
||||
const authContext = buildSystemAuthContext(workspaceId);
|
||||
@@ -390,6 +399,20 @@ export class UserService extends TypeOrmQueryService<UserEntity> {
|
||||
|
||||
assert(workspaceMember, 'WorkspaceMember not found');
|
||||
|
||||
const custodianUserWorkspaceId =
|
||||
await this.resolveConnectedAccountsCustodianUserWorkspaceId({
|
||||
removedUserWorkspace: userWorkspace,
|
||||
actingUserWorkspaceId,
|
||||
});
|
||||
|
||||
if (isDefined(custodianUserWorkspaceId)) {
|
||||
await this.connectedAccountMetadataService.transferOwnership({
|
||||
fromUserWorkspaceId: userWorkspaceId,
|
||||
toUserWorkspaceId: custodianUserWorkspaceId,
|
||||
workspaceId,
|
||||
});
|
||||
}
|
||||
|
||||
await this.globalWorkspaceOrmManager.executeInWorkspaceContext(async () => {
|
||||
const workspaceMemberRepository =
|
||||
await this.globalWorkspaceOrmManager.getRepository<WorkspaceMemberWorkspaceEntity>(
|
||||
@@ -408,6 +431,55 @@ export class UserService extends TypeOrmQueryService<UserEntity> {
|
||||
});
|
||||
}
|
||||
|
||||
private async resolveConnectedAccountsCustodianUserWorkspaceId({
|
||||
removedUserWorkspace,
|
||||
actingUserWorkspaceId,
|
||||
}: {
|
||||
removedUserWorkspace: UserWorkspaceEntity;
|
||||
actingUserWorkspaceId?: string;
|
||||
}): Promise<string | undefined> {
|
||||
const otherUserWorkspaces = await this.userWorkspaceRepository.find({
|
||||
where: {
|
||||
workspaceId: removedUserWorkspace.workspaceId,
|
||||
id: Not(removedUserWorkspace.id),
|
||||
},
|
||||
order: { createdAt: 'ASC' },
|
||||
});
|
||||
|
||||
if (otherUserWorkspaces.length === 0) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const actingUserWorkspace = otherUserWorkspaces.find(
|
||||
(otherUserWorkspace) => otherUserWorkspace.id === actingUserWorkspaceId,
|
||||
);
|
||||
|
||||
if (isDefined(actingUserWorkspace)) {
|
||||
return actingUserWorkspace.id;
|
||||
}
|
||||
|
||||
const rolesByUserWorkspaceId =
|
||||
await this.userRoleService.getRolesByUserWorkspaces({
|
||||
userWorkspaceIds: otherUserWorkspaces.map(
|
||||
(otherUserWorkspace) => otherUserWorkspace.id,
|
||||
),
|
||||
workspaceId: removedUserWorkspace.workspaceId,
|
||||
});
|
||||
|
||||
const oldestAdminUserWorkspace = otherUserWorkspaces.find(
|
||||
(otherUserWorkspace) =>
|
||||
rolesByUserWorkspaceId
|
||||
.get(otherUserWorkspace.id)
|
||||
?.some(
|
||||
(role) =>
|
||||
role.universalIdentifier ===
|
||||
STANDARD_ROLE.admin.universalIdentifier,
|
||||
),
|
||||
);
|
||||
|
||||
return (oldestAdminUserWorkspace ?? otherUserWorkspaces[0]).id;
|
||||
}
|
||||
|
||||
async hasUserAccessToWorkspaceOrThrow(userId: string, workspaceId: string) {
|
||||
const user = await this.userRepository.findOne({
|
||||
where: {
|
||||
|
||||
@@ -22,6 +22,7 @@ import { UserVarsModule } from 'src/engine/core-modules/user/user-vars/user-vars
|
||||
import { UserEntity } from 'src/engine/core-modules/user/user.entity';
|
||||
import { UserResolver } from 'src/engine/core-modules/user/user.resolver';
|
||||
import { WorkspaceModule } from 'src/engine/core-modules/workspace/workspace.module';
|
||||
import { ConnectedAccountMetadataModule } from 'src/engine/metadata-modules/connected-account/connected-account-metadata.module';
|
||||
import { ObjectMetadataEntity } from 'src/engine/metadata-modules/object-metadata/object-metadata.entity';
|
||||
import { PermissionsModule } from 'src/engine/metadata-modules/permissions/permissions.module';
|
||||
import { UserRoleModule } from 'src/engine/metadata-modules/user-role/user-role.module';
|
||||
@@ -48,6 +49,7 @@ import { UserService } from './services/user.service';
|
||||
UserVarsModule,
|
||||
UserWorkspaceModule,
|
||||
UserRoleModule,
|
||||
ConnectedAccountMetadataModule,
|
||||
FeatureFlagModule,
|
||||
PermissionsModule,
|
||||
EmailVerificationModule,
|
||||
|
||||
@@ -443,6 +443,7 @@ export class UserResolver {
|
||||
return this.userService.deleteUserWorkspaceAndPotentiallyDeleteUser({
|
||||
userId: workspaceMemberToDelete.userId,
|
||||
workspaceId: workspace.id,
|
||||
actingUserWorkspaceId: userWorkspaceId,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user