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:
+56
-1
@@ -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,
|
||||
|
||||
+7
@@ -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 })
|
||||
|
||||
+3
@@ -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;
|
||||
|
||||
|
||||
Reference in New Issue
Block a user