Skip to content

Fix user deletion flows#15614

Merged
ijreilly merged 21 commits intomainfrom
fix--role-deletion
Nov 6, 2025
Merged

Fix user deletion flows#15614
ijreilly merged 21 commits intomainfrom
fix--role-deletion

Conversation

@ijreilly
Copy link
Copy Markdown
Contributor

@ijreilly ijreilly commented Nov 4, 2025

Before

  • any user with workpace_members permission was able to remove a user from their workspace. This triggered the deletion of workspaceMember + of userWorkspace, but did not delete the user (even if they had no workspace left) nor the roleTarget (acts as junction between role and userWorkspace) which was left with a userWorkspaceId pointing to nothing. This is because roleTarget points to userWorkspaceId but the foreign key constraint was not implemented
  • any user could delete their own account. This triggered the deletion of all their workspaceMembers, but not of their userWorkspace nor their user nor the roleTarget --> we have orphaned userWorkspace, not technically but product wise - a userWorkspace without a workspaceMember does not make sense

So the problems are

  • we have some roleTargets pointing to non-existing userWorkspaceId (which caused Deleting a role gives a message Workspace not found #14608 )
  • we have userWorkspaces that should not exist and that have no workspaceMember counterpart
  • it is not possible for a user to leave a workspace by themselves, they can only leave all workspaces at once, except if they are being removed from the workspace by another user

Now

  • if a user has multiple workspaces, they are given the possibility to leave one workspace while remaining in the others (we show two buttons: Leave workspace and Delete account buttons). if a user has just one workspace, they only see Delete account
  • when a user leaves a workspace, we delete their workspaceMember, userWorkspace and roleTarget. If they don't belong to any other workspace we also soft-delete their user
  • soft-deleted users get hard deleted after 30 days thanks to a cron
  • we have two commands to clean the orphans roleTarget and userWorkspace (TODO: query db to see how many must be run)

Next

  • once the commands have been run, we can implement and introduce the foreign key constraint on roleTarget

Fixes #14608

Copy link
Copy Markdown
Contributor

@greptile-apps greptile-apps bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Greptile Overview

Greptile Summary

This PR fixes user deletion flows by properly cleaning up workspaceMember, userWorkspace, and roleTarget records. It adds the ability for users to leave individual workspaces while remaining in others, and implements soft-deletion with a 30-day retention period.

Key Changes:

  • Added deleteUserWorkspaceAndPotentiallyDeleteUser to allow users to leave workspaces individually
  • Introduced soft-delete for users with hard-delete after 30 days via cron job
  • Created cleanup commands for orphaned roleTarget and userWorkspace records
  • Updated frontend to show "Leave workspace" button when user has multiple workspaces

Critical Issues Found:

  • Missing await on async call in user.service.ts:144 will cause incomplete cleanup
  • Frontend mutation missing required workspaceMemberIdToDelete parameter - feature will fail at runtime
  • Cron job has infinite loop bug - hasMore flag never updated
  • Role targets cleanup command only processes one workspace due to early return

Confidence Score: 1/5

  • This PR has critical bugs that will cause runtime failures and data inconsistency
  • Score reflects multiple critical logic errors: missing await causing race conditions, missing mutation parameter causing frontend failures, infinite loop in cron job, and broken cleanup command. These issues will prevent the feature from working correctly and could lead to orphaned records.
  • Critical attention needed: user.service.ts (missing await), DeleteAccount.tsx (missing mutation parameter), user-hard-delete.cron.job.ts (infinite loop), and 1-11-clean-orphaned-role-targets.command.ts (only processes one workspace)

Important Files Changed

File Analysis

Filename Score Overview
packages/twenty-server/src/engine/core-modules/user/services/user.service.ts 1/5 Added user deletion logic with workspace cleanup, but missing await on async call (line 144) which could cause incomplete cleanup
packages/twenty-front/src/modules/settings/profile/components/DeleteAccount.tsx 0/5 UI for leave workspace feature, but mutation call missing required workspaceMemberIdToDelete parameter
packages/twenty-server/src/engine/core-modules/user/crons/user-hard-delete.cron.job.ts 2/5 Cron job for hard-deleting soft-deleted users after 30 days, but hasMore flag never updated causing infinite loop
packages/twenty-server/src/database/commands/upgrade-version-command/1-11/1-11-clean-orphaned-role-targets.command.ts 2/5 Cleanup command for orphaned role targets, but hasRunOnce flag prevents processing multiple workspaces

Sequence Diagram

sequenceDiagram
    participant User
    participant Frontend
    participant UserResolver
    participant UserService
    participant UserWorkspaceService
    participant WorkspaceMemberRepo
    participant UserWorkspaceRepo
    participant RoleTargetsRepo
    participant WorkspaceService

    alt User has multiple workspaces - Leave Workspace
        User->>Frontend: Click "Leave workspace"
        Frontend->>UserResolver: deleteUserFromWorkspace(workspaceMemberIdToDelete)
        UserResolver->>UserResolver: Validate permissions
        UserResolver->>UserService: deleteUserWorkspaceAndPotentiallyDeleteUser()
        UserService->>UserService: Load user with userWorkspaces
        UserService->>UserService: Find userWorkspace for workspaceId
        UserService->>UserService: removeUserFromWorkspaceAndPotentiallyDeleteWorkspace()
        UserService->>WorkspaceMemberRepo: delete(userId)
        UserService->>UserWorkspaceService: deleteUserWorkspace(userWorkspaceId)
        UserWorkspaceService->>RoleTargetsRepo: delete(userWorkspaceId)
        UserWorkspaceService->>UserWorkspaceRepo: delete(id)
        alt User has only 1 workspace
            UserService->>UserService: softDelete(userId)
        end
    else User deletes account entirely
        User->>Frontend: Click "Delete account"
        Frontend->>UserResolver: deleteUser()
        UserResolver->>UserService: deleteUser(userId)
        UserService->>UserService: Load user with userWorkspaces
        loop For each userWorkspace
            UserService->>UserService: removeUserFromWorkspaceAndPotentiallyDeleteWorkspace()
            UserService->>WorkspaceMemberRepo: delete(userId)
            UserService->>UserWorkspaceService: deleteUserWorkspace(userWorkspaceId)
            UserWorkspaceService->>RoleTargetsRepo: delete(userWorkspaceId)
            UserWorkspaceService->>UserWorkspaceRepo: delete(id)
            alt Last member in workspace
                UserService->>WorkspaceService: deleteWorkspace(workspaceId)
            end
        end
        UserService->>UserService: softDelete(userId)
    end

    Note over UserService: After 30 days
    UserHardDeleteCronJob->>UserWorkspaceRepo: Find soft-deleted users older than 30 days
    UserHardDeleteCronJob->>UserWorkspaceRepo: Hard delete users
Loading

27 files reviewed, 5 comments

Edit Code Review Agent Settings | Greptile


if (workspaceMembers.length === 1) {
await this.workspaceService.deleteWorkspace(workspaceId);
this.removeUserFromWorkspaceAndPotentiallyDeleteWorkspace(userWorkspace);
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

logic: missing await - removeUserFromWorkspaceAndPotentiallyDeleteWorkspace is async but not awaited

Suggested change
this.removeUserFromWorkspaceAndPotentiallyDeleteWorkspace(userWorkspace);
await this.removeUserFromWorkspaceAndPotentiallyDeleteWorkspace(userWorkspace);
Prompt To Fix With AI
This is a comment left during a code review.
Path: packages/twenty-server/src/engine/core-modules/user/services/user.service.ts
Line: 144:144

Comment:
**logic:** missing `await` - `removeUserFromWorkspaceAndPotentiallyDeleteWorkspace` is async but not awaited

```suggestion
    await this.removeUserFromWorkspaceAndPotentiallyDeleteWorkspace(userWorkspace);
```

How can I resolve this? If you propose a fix, please make it concise.

let totalDeleted = 0;

let hasMore = true;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

logic: hasMore variable is set but never used - the while loop will run indefinitely

Suggested change
while (hasMore) {
Prompt To Fix With AI
This is a comment left during a code review.
Path: packages/twenty-server/src/engine/core-modules/user/crons/user-hard-delete.cron.job.ts
Line: 36:36

Comment:
**logic:** `hasMore` variable is set but never used - the while loop will run indefinitely

```suggestion
    while (hasMore) {
```

How can I resolve this? If you propose a fix, please make it concise.

Comment on lines +47 to +52
if (usersToDelete.length === 0) break;

await this.userRepository.delete({
id: In(usersToDelete.map((user) => user.id)),
});
totalDeleted += usersToDelete.length;
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

logic: hasMore should be updated after determining batch size

Suggested change
if (usersToDelete.length === 0) break;
await this.userRepository.delete({
id: In(usersToDelete.map((user) => user.id)),
});
totalDeleted += usersToDelete.length;
if (usersToDelete.length === 0) {
hasMore = false;
break;
}
await this.userRepository.delete({
id: In(usersToDelete.map((user) => user.id)),
});
totalDeleted += usersToDelete.length;
hasMore = usersToDelete.length === BATCH_SIZE;
Prompt To Fix With AI
This is a comment left during a code review.
Path: packages/twenty-server/src/engine/core-modules/user/crons/user-hard-delete.cron.job.ts
Line: 47:52

Comment:
**logic:** `hasMore` should be updated after determining batch size

```suggestion
      if (usersToDelete.length === 0) {
        hasMore = false;
        break;
      }

      await this.userRepository.delete({
        id: In(usersToDelete.map((user) => user.id)),
      });
      totalDeleted += usersToDelete.length;

      hasMore = usersToDelete.length === BATCH_SIZE;
```

How can I resolve this? If you propose a fix, please make it concise.

Comment on lines +35 to +39
if (this.hasRunOnce) {
this.logger.log('Skipping kanban field metadata id foreign key creation');

return;
}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

logic: early return prevents command from processing multiple workspaces - only first workspace will be processed

Suggested change
if (this.hasRunOnce) {
this.logger.log('Skipping kanban field metadata id foreign key creation');
return;
}
const isDryRun = options.dryRun || false;
if (isDryRun && this.hasRunOnce) {
this.logger.log('Dry run already executed, skipping');
return;
}
if (!isDryRun && this.hasRunOnce) {
this.logger.log('Command already executed, skipping');
return;
}
Prompt To Fix With AI
This is a comment left during a code review.
Path: packages/twenty-server/src/database/commands/upgrade-version-command/1-11/1-11-clean-orphaned-role-targets.command.ts
Line: 35:39

Comment:
**logic:** early return prevents command from processing multiple workspaces - only first workspace will be processed

```suggestion
    const isDryRun = options.dryRun || false;

    if (isDryRun && this.hasRunOnce) {
      this.logger.log('Dry run already executed, skipping');
      return;
    }

    if (!isDryRun && this.hasRunOnce) {
      this.logger.log('Command already executed, skipping');
      return;
    }
```

How can I resolve this? If you propose a fix, please make it concise.

Comment on lines +45 to +48
const leaveWorkspace = async () => {
await deleteUserFromWorkspace();
await signOut();
};
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

logic: missing required argument workspaceMemberIdToDelete for mutation - backend expects this parameter but frontend doesn't provide it

Check backend mutation signature:

deleteUserFromWorkspace(
  @Args('workspaceMemberIdToDelete') workspaceMemberIdToDelete: string,
  ...
)
Prompt To Fix With AI
This is a comment left during a code review.
Path: packages/twenty-front/src/modules/settings/profile/components/DeleteAccount.tsx
Line: 45:48

Comment:
**logic:** missing required argument `workspaceMemberIdToDelete` for mutation - backend expects this parameter but frontend doesn't provide it

Check backend mutation signature:
```typescript
deleteUserFromWorkspace(
  @Args('workspaceMemberIdToDelete') workspaceMemberIdToDelete: string,
  ...
)
```

How can I resolve this? If you propose a fix, please make it concise.

@github-actions
Copy link
Copy Markdown
Contributor

github-actions bot commented Nov 4, 2025

🚀 Preview Environment Ready!

Your preview environment is available at: http://bore.pub:37301

This environment will automatically shut down when the PR is closed or after 5 hours.

fieldName: string,
fieldMetadataSettings: FieldMetadataRelationSettings,
): string => {
if (fieldMetadataSettings.relationType === RelationType.ONE_TO_MANY) {
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

forgotten from another pr

@Weiko Weiko self-requested a review November 5, 2025 10:12
Copy link
Copy Markdown
Member

@Weiko Weiko left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Left some comments otherwise LGTM

const hasReadPermission = objectPermissions.canReadObjectRecords;

const { data, loading, error, fetchMore } =
const { data, loading, error, fetchMore, refetch } =
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm not sure about exposing the refetch for useFindMany for this new use case only 🤔 Could we use optimistic update / apollo cache mutation instead?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

why not? by fear it would be use in the wrong context?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copy link
Copy Markdown
Contributor

@lucasbordeau lucasbordeau Nov 5, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There's dedicated tooling for fetchMore and doing a refetch like that has not been QAed, so I would not expose this method.

@ijreilly
Copy link
Copy Markdown
Contributor Author

ijreilly commented Nov 5, 2025

@Bonapara @StephanieJoly4 @FelixMalfait
Do you agree with soft-deleted users being hard-deleted after 30 days (unlike today - they are never hard deleted), or should we keep them forever?

@etiennejouan @charlesBochet fyi

Copy link
Copy Markdown
Member

@Weiko Weiko left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM

@ijreilly ijreilly enabled auto-merge (squash) November 5, 2025 17:54
@ijreilly ijreilly merged commit 4ce93ae into main Nov 6, 2025
65 checks passed
@ijreilly ijreilly deleted the fix--role-deletion branch November 6, 2025 18:29
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Deleting a role gives a message Workspace not found

4 participants