mirror of
https://github.com/twentyhq/twenty
synced 2026-04-21 13:37:22 +00:00
fix: handle deleted UserWorkspace in contact creation and connected account cleanup
https://sonarly.com/issue/26526?type=bug
When a user is removed from a workspace (via workspace-member deletion or user removal), the
UserWorkspace row is hard-deleted synchronously, but the associated ConnectedAccount rows are
only cleaned up asynchronously via a BullMQ job (DeleteWorkspaceMemberConnectedAccountsCleanupJob).
That cleanup job has a bug: it queries for the workspace member without `withDeleted: true`,
finds nothing (the member is already soft-deleted), and silently returns — never deleting the
connected accounts. The orphaned connected accounts continue syncing email, and when new
contacts are discovered, the CreateCompanyAndContactJob tries to look up the now-deleted
UserWorkspace by its stale `userWorkspaceId`, throwing:
`Error: UserWorkspace with id efe7fd46-238b-464f-b3a4-19c72c4f0141 not found`
This failure path was introduced by commit d3f0162cf5 (2026-04-06, "Remove connected account
feature flag"), which changed ConnectedAccount from a workspace entity (with a direct
`accountOwnerId` → WorkspaceMember relation) to a core entity (with a `userWorkspaceId` UUID
column and no foreign key to UserWorkspace). The old code looked up the account owner directly;
the new code adds an intermediate UserWorkspace lookup that breaks when the row is gone.
Fix: Two changes that work together to fix the crash:
1. **create-company-and-contact.service.ts** (proximate fix): When UserWorkspace is not found, return `null` instead of throwing. The downstream `createCompaniesAndPeople()` already accepts `accountOwner: WorkspaceMemberWorkspaceEntity | null` and handles it gracefully — contacts are still created, just without a `createdBy.workspaceMemberId`. This stops the Sentry error immediately.
2. **delete-workspace-member-connected-accounts.job.ts** (root cause fix): Three changes:
- Added `withDeleted: true` to the workspace member query so soft-deleted members are still found
- Added `shouldBypassPermissionChecks: true` to the repository (consistent with other system jobs)
- Added fallback path: when UserWorkspace is already hard-deleted, scan for orphaned connected accounts in the workspace (accounts whose `userWorkspaceId` references a non-existent UserWorkspace row) and delete them
Together: the cleanup job now actually cleans up connected accounts when a member is removed, AND the contact creation service no longer crashes if an orphaned account still manages to trigger a sync.
This commit is contained in:
parent
3e48be4c31
commit
2181fb541e
2 changed files with 52 additions and 7 deletions
|
|
@ -1,3 +1,4 @@
|
|||
import { Logger } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
|
||||
import { type Repository } from 'typeorm';
|
||||
|
|
@ -18,6 +19,10 @@ export type DeleteWorkspaceMemberConnectedAccountsCleanupJobData = {
|
|||
|
||||
@Processor(MessageQueue.deleteCascadeQueue)
|
||||
export class DeleteWorkspaceMemberConnectedAccountsCleanupJob {
|
||||
private readonly logger = new Logger(
|
||||
DeleteWorkspaceMemberConnectedAccountsCleanupJob.name,
|
||||
);
|
||||
|
||||
constructor(
|
||||
private readonly globalWorkspaceOrmManager: GlobalWorkspaceOrmManager,
|
||||
@InjectRepository(ConnectedAccountEntity)
|
||||
|
|
@ -39,13 +44,19 @@ export class DeleteWorkspaceMemberConnectedAccountsCleanupJob {
|
|||
await this.globalWorkspaceOrmManager.getRepository<WorkspaceMemberWorkspaceEntity>(
|
||||
workspaceId,
|
||||
'workspaceMember',
|
||||
{ shouldBypassPermissionChecks: true },
|
||||
);
|
||||
|
||||
const member = await workspaceMemberRepo.findOne({
|
||||
where: { id: workspaceMemberId },
|
||||
withDeleted: true,
|
||||
});
|
||||
|
||||
if (!member) {
|
||||
this.logger.warn(
|
||||
`Workspace member ${workspaceMemberId} not found (even with soft-deleted) in workspace ${workspaceId}`,
|
||||
);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
@ -53,14 +64,44 @@ export class DeleteWorkspaceMemberConnectedAccountsCleanupJob {
|
|||
where: { userId: member.userId, workspaceId },
|
||||
});
|
||||
|
||||
if (!userWorkspace) {
|
||||
if (userWorkspace) {
|
||||
await this.connectedAccountRepository.delete({
|
||||
userWorkspaceId: userWorkspace.id,
|
||||
workspaceId,
|
||||
});
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
await this.connectedAccountRepository.delete({
|
||||
userWorkspaceId: userWorkspace.id,
|
||||
workspaceId,
|
||||
// UserWorkspace was already hard-deleted — look up connected accounts
|
||||
// directly by the workspace member's userId via userWorkspace records
|
||||
// that may still reference this workspace
|
||||
const connectedAccounts = await this.connectedAccountRepository.find({
|
||||
where: { workspaceId },
|
||||
});
|
||||
|
||||
const orphanedAccounts = [];
|
||||
|
||||
for (const account of connectedAccounts) {
|
||||
const accountUserWorkspace =
|
||||
await this.userWorkspaceRepository.findOne({
|
||||
where: { id: account.userWorkspaceId },
|
||||
});
|
||||
|
||||
if (!accountUserWorkspace) {
|
||||
orphanedAccounts.push(account);
|
||||
}
|
||||
}
|
||||
|
||||
if (orphanedAccounts.length > 0) {
|
||||
this.logger.warn(
|
||||
`Found ${orphanedAccounts.length} orphaned connected account(s) in workspace ${workspaceId} after UserWorkspace hard-delete for member ${workspaceMemberId}`,
|
||||
);
|
||||
|
||||
await this.connectedAccountRepository.delete(
|
||||
orphanedAccounts.map((account) => account.id),
|
||||
);
|
||||
}
|
||||
}, authContext);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { Injectable } from '@nestjs/common';
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
|
||||
import { isNonEmptyString, isNull } from '@sniptt/guards';
|
||||
|
|
@ -33,6 +33,8 @@ import { isWorkDomain, isWorkEmail } from 'src/utils/is-work-email';
|
|||
|
||||
@Injectable()
|
||||
export class CreateCompanyAndPersonService {
|
||||
private readonly logger = new Logger(CreateCompanyAndPersonService.name);
|
||||
|
||||
constructor(
|
||||
private readonly createPersonService: CreatePersonService,
|
||||
private readonly createCompaniesService: CreateCompanyService,
|
||||
|
|
@ -175,9 +177,11 @@ export class CreateCompanyAndPersonService {
|
|||
});
|
||||
|
||||
if (!userWorkspace) {
|
||||
throw new Error(
|
||||
`UserWorkspace with id ${connectedAccount.userWorkspaceId} not found`,
|
||||
this.logger.warn(
|
||||
`UserWorkspace with id ${connectedAccount.userWorkspaceId} not found for connected account in workspace ${workspaceId} — proceeding without account owner`,
|
||||
);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
const workspaceMemberRepository =
|
||||
|
|
|
|||
Loading…
Reference in a new issue