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:
Sonarly Claude Code 2026-04-15 09:07:51 +00:00
parent 3e48be4c31
commit 2181fb541e
2 changed files with 52 additions and 7 deletions

View file

@ -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);
}
}

View file

@ -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 =