Fix user deletion flows (#15614)

**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 https://github.com/twentyhq/twenty/issues/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 https://github.com/twentyhq/twenty/issues/14608
This commit is contained in:
Marie
2025-11-06 19:29:12 +01:00
committed by GitHub
parent bfe1f47065
commit 4ce93aee52
38 changed files with 980 additions and 173 deletions
@@ -129,8 +129,11 @@ export class AuthService {
}
throw new AuthException(
"You're not member of this workspace.",
'User is not a member of the workspace.',
AuthExceptionCode.FORBIDDEN_EXCEPTION,
{
userFriendlyMessage: msg`User is not a member of the workspace.`,
},
);
}
@@ -1,6 +1,7 @@
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { msg } from '@lingui/core/macro';
import { addMilliseconds } from 'date-fns';
import { type Request } from 'express';
import ms from 'ms';
@@ -96,6 +97,9 @@ export class AccessTokenService {
new AuthException(
'User is not a member of the workspace',
AuthExceptionCode.FORBIDDEN_EXCEPTION,
{
userFriendlyMessage: msg`User is not a member of the workspace.`,
},
),
);
@@ -16,6 +16,7 @@ import { WorkspaceInvitationModule } from 'src/engine/core-modules/workspace-inv
import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity';
import { DataSourceModule } from 'src/engine/metadata-modules/data-source/data-source.module';
import { ObjectMetadataEntity } from 'src/engine/metadata-modules/object-metadata/object-metadata.entity';
import { RoleTargetsEntity } from 'src/engine/metadata-modules/role/role-targets.entity';
import { UserRoleModule } from 'src/engine/metadata-modules/user-role/user-role.module';
import { TwentyORMModule } from 'src/engine/twenty-orm/twenty-orm.module';
import { WorkspaceDataSourceModule } from 'src/engine/workspace-datasource/workspace-datasource.module';
@@ -28,6 +29,7 @@ import { WorkspaceDataSourceModule } from 'src/engine/workspace-datasource/works
UserEntity,
UserWorkspaceEntity,
WorkspaceEntity,
RoleTargetsEntity,
]),
NestjsQueryTypeOrmModule.forFeature([ObjectMetadataEntity]),
TypeORMModule,
@@ -24,6 +24,7 @@ import { type WorkspaceEntity } from 'src/engine/core-modules/workspace/workspac
import { DataSourceService } from 'src/engine/metadata-modules/data-source/data-source.service';
import { ObjectMetadataEntity } from 'src/engine/metadata-modules/object-metadata/object-metadata.entity';
import { PermissionsException } from 'src/engine/metadata-modules/permissions/permissions.exception';
import { RoleTargetsEntity } from 'src/engine/metadata-modules/role/role-targets.entity';
import { UserRoleService } from 'src/engine/metadata-modules/user-role/user-role.service';
import { TwentyORMGlobalManager } from 'src/engine/twenty-orm/twenty-orm-global.manager';
@@ -66,6 +67,12 @@ describe('UserWorkspaceService', () => {
findOneOrFail: jest.fn(),
},
},
{
provide: getRepositoryToken(RoleTargetsEntity),
useValue: {
findOneOrFail: jest.fn(),
},
},
{
provide: DataSourceService,
useValue: {
@@ -30,6 +30,7 @@ import {
PermissionsExceptionCode,
PermissionsExceptionMessage,
} from 'src/engine/metadata-modules/permissions/permissions.exception';
import { RoleTargetsEntity } from 'src/engine/metadata-modules/role/role-targets.entity';
import { UserRoleService } from 'src/engine/metadata-modules/user-role/user-role.service';
import { TwentyORMGlobalManager } from 'src/engine/twenty-orm/twenty-orm-global.manager';
import { WorkspaceMemberWorkspaceEntity } from 'src/modules/workspace-member/standard-objects/workspace-member.workspace-entity';
@@ -42,6 +43,8 @@ export class UserWorkspaceService extends TypeOrmQueryService<UserWorkspaceEntit
private readonly userWorkspaceRepository: Repository<UserWorkspaceEntity>,
@InjectRepository(UserEntity)
private readonly userRepository: Repository<UserEntity>,
@InjectRepository(RoleTargetsEntity)
private readonly roleTargetsRepository: Repository<RoleTargetsEntity>,
private readonly workspaceInvitationService: WorkspaceInvitationService,
private readonly workspaceDomainsService: WorkspaceDomainsService,
private readonly loginTokenService: LoginTokenService,
@@ -222,6 +225,22 @@ export class UserWorkspaceService extends TypeOrmQueryService<UserWorkspaceEntit
return await this.userWorkspaceRepository.count({ where: { userId } });
}
async deleteUserWorkspace({
userWorkspaceId,
softDelete = false,
}: {
userWorkspaceId: string;
softDelete?: boolean;
}): Promise<void> {
if (softDelete) {
await this.roleTargetsRepository.softRemove({ userWorkspaceId });
await this.userWorkspaceRepository.softDelete({ id: userWorkspaceId });
} else {
await this.roleTargetsRepository.delete({ userWorkspaceId }); // TODO remove once userWorkspace foreign key is added on roleTarget
await this.userWorkspaceRepository.delete({ id: userWorkspaceId });
}
}
async findAvailableWorkspacesByEmail(email: string) {
const user = await this.userRepository.findOne({
where: {
@@ -2,9 +2,11 @@ import { Test, type TestingModule } from '@nestjs/testing';
import { getRepositoryToken } from '@nestjs/typeorm';
import { WorkspaceActivationStatus } from 'twenty-shared/workspace';
import { type Repository } from 'typeorm';
import { type Repository, type UpdateResult } from 'typeorm';
import { AuthException } from 'src/engine/core-modules/auth/auth.exception';
import { type 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 { UserEntity } from 'src/engine/core-modules/user/user.entity';
import { WorkspaceService } from 'src/engine/core-modules/workspace/services/workspace.service';
@@ -40,6 +42,7 @@ describe('UserService', () => {
useValue: {
findOne: jest.fn(),
save: jest.fn(),
softDelete: jest.fn(),
},
},
{
@@ -58,6 +61,12 @@ describe('UserService', () => {
validateUserWorkspaceIsNotUniqueAdminOrThrow: jest.fn(),
},
},
{
provide: UserWorkspaceService,
useValue: {
deleteUserWorkspace: jest.fn(),
},
},
],
}).compile();
@@ -311,9 +320,14 @@ describe('UserService', () => {
});
it('deletes workspace member and workspace when user is sole member', async () => {
const mockedUserWorkspace = {
id: 'uw2',
workspaceId: 'w2',
} as UserWorkspaceEntity;
(userRepository.findOne as jest.Mock).mockResolvedValue({
id: 'u2',
userWorkspaces: [{ id: 'uw2', workspaceId: 'w2' }],
userWorkspaces: [mockedUserWorkspace],
});
jest
@@ -323,11 +337,14 @@ describe('UserService', () => {
.spyOn(twentyORMGlobalManager, 'getRepositoryForWorkspace')
.mockResolvedValue(mockWorkspaceMemberRepo);
(userRepository.softDelete as jest.Mock).mockResolvedValue({
affected: 1,
raw: [],
generatedMaps: [],
} as UpdateResult);
const res = await service.deleteUser('u2');
expect(mockWorkspaceMemberRepo.delete).toHaveBeenCalledWith({
userId: 'u2',
});
expect(workspaceService.deleteWorkspace).toHaveBeenCalledWith('w2');
expect(res).toMatchObject({ id: 'u2' });
});
@@ -4,7 +4,7 @@ import assert from 'assert';
import { msg } from '@lingui/core/macro';
import { TypeOrmQueryService } from '@ptc-org/nestjs-query-typeorm';
import { assertIsDefinedOrThrow } from 'twenty-shared/utils';
import { assertIsDefinedOrThrow, isDefined } from 'twenty-shared/utils';
import { isWorkspaceActiveOrSuspended } from 'twenty-shared/workspace';
import { IsNull, Not, Repository } from 'typeorm';
@@ -12,6 +12,8 @@ import {
AuthException,
AuthExceptionCode,
} from 'src/engine/core-modules/auth/auth.exception';
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 { UserEntity } from 'src/engine/core-modules/user/user.entity';
import { userValidator } from 'src/engine/core-modules/user/user.validate';
import { WorkspaceService } from 'src/engine/core-modules/workspace/services/workspace.service';
@@ -33,6 +35,7 @@ export class UserService extends TypeOrmQueryService<UserEntity> {
private readonly workspaceService: WorkspaceService,
private readonly twentyORMGlobalManager: TwentyORMGlobalManager,
private readonly userRoleService: UserRoleService,
private readonly userWorkspaceService: UserWorkspaceService,
) {
super(userRepository);
}
@@ -89,7 +92,7 @@ export class UserService extends TypeOrmQueryService<UserEntity> {
});
}
async deleteUser(userId: string): Promise<UserEntity> {
async deleteUser(userId: string) {
const user = await this.userRepository.findOne({
where: {
id: userId,
@@ -99,78 +102,116 @@ export class UserService extends TypeOrmQueryService<UserEntity> {
userValidator.assertIsDefinedOrThrow(user);
const prepareForUserDeletionInWorkspaces = await Promise.all(
user.userWorkspaces.map(async (userWorkspace) => {
const { workspaceId } = userWorkspace;
for (const userWorkspace of user.userWorkspaces) {
await this.removeUserFromWorkspaceAndPotentiallyDeleteWorkspace(
userWorkspace,
);
}
const workspaceMemberRepository =
await this.twentyORMGlobalManager.getRepositoryForWorkspace<WorkspaceMemberWorkspaceEntity>(
await this.userRepository.softDelete({ id: userId });
return await this.userRepository.findOne({
where: {
id: userId,
},
withDeleted: true,
});
}
async deleteUserWorkspaceAndPotentiallyDeleteUser({
userId,
workspaceId,
}: {
userId: string;
workspaceId: string;
}) {
const user = await this.userRepository.findOne({
where: {
id: userId,
},
relations: { userWorkspaces: true },
});
userValidator.assertIsDefinedOrThrow(user);
const userWorkspace = user.userWorkspaces.find(
(userWorkspace) => userWorkspace.workspaceId === workspaceId,
);
if (!isDefined(userWorkspace)) {
throw new Error('User workspace not found.');
}
await this.removeUserFromWorkspaceAndPotentiallyDeleteWorkspace(
userWorkspace,
);
if (user.userWorkspaces.length === 1) {
await this.userRepository.softDelete(userId);
}
return userWorkspace;
}
async removeUserFromWorkspaceAndPotentiallyDeleteWorkspace(
userWorkspace: UserWorkspaceEntity,
) {
const workspaceId = userWorkspace.workspaceId;
const workspaceMemberRepository =
await this.twentyORMGlobalManager.getRepositoryForWorkspace<WorkspaceMemberWorkspaceEntity>(
workspaceId,
'workspaceMember',
{ shouldBypassPermissionChecks: true },
);
const workspaceMembers = await workspaceMemberRepository.find();
const userWorkspaceId = userWorkspace.id;
if (workspaceMembers.length === 1) {
await this.workspaceService.deleteWorkspace(workspaceId);
return;
}
if (workspaceMembers.length > 1) {
try {
await this.userRoleService.validateUserWorkspaceIsNotUniqueAdminOrThrow(
{
workspaceId,
'workspaceMember',
{ shouldBypassPermissionChecks: true },
);
const workspaceMembers = await workspaceMemberRepository.find();
if (workspaceMembers.length > 1) {
try {
await this.userRoleService.validateUserWorkspaceIsNotUniqueAdminOrThrow(
{
workspaceId,
userWorkspaceId: userWorkspace.id,
},
);
} catch (error) {
if (
error instanceof PermissionsException &&
error.code === PermissionsExceptionCode.CANNOT_UNASSIGN_LAST_ADMIN
) {
throw new PermissionsException(
PermissionsExceptionMessage.CANNOT_DELETE_LAST_ADMIN_USER,
PermissionsExceptionCode.CANNOT_DELETE_LAST_ADMIN_USER,
{
userFriendlyMessage: msg`Cannot delete account: you are the only admin. Assign another admin or delete the workspace(s) first.`,
},
);
}
throw error;
}
}
const workspaceMember = workspaceMembers.find(
(member: WorkspaceMemberWorkspaceEntity) => member.userId === userId,
userWorkspaceId: userWorkspace.id,
},
);
} catch (error) {
if (
error instanceof PermissionsException &&
error.code === PermissionsExceptionCode.CANNOT_UNASSIGN_LAST_ADMIN
) {
throw new PermissionsException(
PermissionsExceptionMessage.CANNOT_DELETE_LAST_ADMIN_USER,
PermissionsExceptionCode.CANNOT_DELETE_LAST_ADMIN_USER,
{
userFriendlyMessage: msg`Cannot delete account: you are the only admin. Assign another admin or delete the workspace(s) first.`,
},
);
}
throw error;
}
}
assert(workspaceMember, 'WorkspaceMember not found');
return {
workspaceId,
workspaceMemberRepository,
workspaceMembers,
workspaceMember,
};
}),
const workspaceMember = workspaceMembers.find(
(member: WorkspaceMemberWorkspaceEntity) =>
member.userId === userWorkspace.userId,
);
await Promise.all(
prepareForUserDeletionInWorkspaces.map(
async ({
workspaceId,
workspaceMemberRepository,
workspaceMembers,
}) => {
await workspaceMemberRepository.delete({ userId });
assert(workspaceMember, 'WorkspaceMember not found');
if (workspaceMembers.length === 1) {
await this.workspaceService.deleteWorkspace(workspaceId);
await workspaceMemberRepository.delete({ userId: userWorkspace.userId });
return;
}
},
),
);
return user;
await this.userWorkspaceService.deleteUserWorkspace({
userWorkspaceId,
});
}
async hasUserAccessToWorkspaceOrThrow(userId: string, workspaceId: string) {
@@ -1,4 +1,4 @@
import { UseFilters, UseGuards } from '@nestjs/common';
import { BadRequestException, UseFilters, UseGuards } from '@nestjs/common';
import {
Args,
Mutation,
@@ -11,6 +11,7 @@ import { InjectRepository } from '@nestjs/typeorm';
import crypto from 'crypto';
import { msg } from '@lingui/core/macro';
import { GraphQLJSONObject } from 'graphql-type-json';
import { FileUpload, GraphQLUpload } from 'graphql-upload';
import { isDefined } from 'twenty-shared/utils';
@@ -48,17 +49,27 @@ import { UserEntity } from 'src/engine/core-modules/user/user.entity';
import { userValidator } from 'src/engine/core-modules/user/user.validate';
import { AuthProviderEnum } from 'src/engine/core-modules/workspace/types/workspace.type';
import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity';
import { AuthApiKey } from 'src/engine/decorators/auth/auth-api-key.decorator';
import { AuthProvider } from 'src/engine/decorators/auth/auth-provider.decorator';
import { AuthUserWorkspaceId } from 'src/engine/decorators/auth/auth-user-workspace-id.decorator';
import { AuthUser } from 'src/engine/decorators/auth/auth-user.decorator';
import { AuthWorkspace } from 'src/engine/decorators/auth/auth-workspace.decorator';
import { UserAuthGuard } from 'src/engine/guards/user-auth.guard';
import { WorkspaceAuthGuard } from 'src/engine/guards/workspace-auth.guard';
import { PermissionFlagType } from 'src/engine/metadata-modules/permissions/constants/permission-flag-type.constants';
import {
PermissionsException,
PermissionsExceptionCode,
PermissionsExceptionMessage,
} from 'src/engine/metadata-modules/permissions/permissions.exception';
import { PermissionsService } from 'src/engine/metadata-modules/permissions/permissions.service';
import { type UserWorkspacePermissions } from 'src/engine/metadata-modules/permissions/types/user-workspace-permissions';
import { PermissionsGraphqlApiExceptionFilter } from 'src/engine/metadata-modules/permissions/utils/permissions-graphql-api-exception.filter';
import { fromUserWorkspacePermissionsToUserWorkspacePermissionsDto } from 'src/engine/metadata-modules/role/utils/fromUserWorkspacePermissionsToUserWorkspacePermissionsDto';
import { UserRoleService } from 'src/engine/metadata-modules/user-role/user-role.service';
import { TwentyORMGlobalManager } from 'src/engine/twenty-orm/twenty-orm-global.manager';
import { AccountsToReconnectKeys } from 'src/modules/connected-account/types/accounts-to-reconnect-key-value.type';
import { WorkspaceMemberWorkspaceEntity } from 'src/modules/workspace-member/standard-objects/workspace-member.workspace-entity';
import { streamToBuffer } from 'src/utils/stream-to-buffer';
const getHMACKey = (email?: string, key?: string | null) => {
@@ -87,6 +98,7 @@ export class UserResolver {
private readonly workspaceMemberTranspiler: WorkspaceMemberTranspiler,
private readonly userWorkspaceService: UserWorkspaceService,
private readonly twentyORMGlobalManager: TwentyORMGlobalManager,
) {}
private async getUserWorkspacePermissions({
@@ -391,6 +403,70 @@ export class UserResolver {
return this.userService.deleteUser(userId);
}
@Mutation(() => UserWorkspaceEntity)
@UseGuards(UserAuthGuard)
async deleteUserFromWorkspace(
@Args('workspaceMemberIdToDelete') workspaceMemberIdToDelete: string,
@AuthUser() { id: userId }: UserEntity,
@AuthUserWorkspaceId() userWorkspaceId: string,
@AuthWorkspace()
workspace: WorkspaceEntity,
@AuthApiKey() apiKey?: string,
) {
if (!workspace) {
throw new AuthException(
'Workspace not found',
AuthExceptionCode.WORKSPACE_NOT_FOUND,
);
}
const workspaceMemberRepository =
await this.twentyORMGlobalManager.getRepositoryForWorkspace<WorkspaceMemberWorkspaceEntity>(
workspace.id,
'workspaceMember',
{ shouldBypassPermissionChecks: true },
);
const workspaceMemberToDelete = await workspaceMemberRepository.findOne({
where: {
id: workspaceMemberIdToDelete,
},
});
if (!isDefined(workspaceMemberToDelete)) {
throw new BadRequestException(
'Workspace member to delete not found in workspace',
);
}
const workspaceMemberToDeleteIsAuthenticatedUser =
workspaceMemberToDelete.userId === userId;
const canDeleteUserFromWorkspace =
workspaceMemberToDeleteIsAuthenticatedUser ||
(await this.permissionsService.userHasWorkspaceSettingPermission({
userWorkspaceId,
workspaceId: workspace.id,
setting: PermissionFlagType.WORKSPACE_MEMBERS,
apiKeyId: apiKey ?? undefined,
}));
if (!canDeleteUserFromWorkspace) {
throw new PermissionsException(
PermissionsExceptionMessage.PERMISSION_DENIED,
PermissionsExceptionCode.PERMISSION_DENIED,
{
userFriendlyMessage: msg`You do not have permission to delete this user from the workspace. Please contact your workspace administrator for access.`,
},
);
}
return this.userService.deleteUserWorkspaceAndPotentiallyDeleteUser({
userId: workspaceMemberToDelete.userId,
workspaceId: workspace.id,
});
}
@ResolveField(() => OnboardingStatus, {
nullable: true,
})
@@ -39,6 +39,7 @@ describe('WorkspaceService', () => {
let messageQueueService: MessageQueueService;
let dnsManagerService: DnsManagerService;
let billingSubscriptionService: BillingSubscriptionService;
let userWorkspaceService: UserWorkspaceService;
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
@@ -121,6 +122,12 @@ describe('WorkspaceService', () => {
flushFlatEntityMaps: jest.fn(),
},
},
{
provide: UserWorkspaceService,
useValue: {
deleteUserWorkspace: jest.fn(),
},
},
{
provide: getQueueToken(MessageQueue.deleteCascadeQueue),
useValue: {
@@ -151,6 +158,8 @@ describe('WorkspaceService', () => {
billingSubscriptionService = module.get<BillingSubscriptionService>(
BillingSubscriptionService,
);
userWorkspaceService =
module.get<UserWorkspaceService>(UserWorkspaceService);
});
afterEach(() => {
@@ -163,7 +172,13 @@ describe('WorkspaceService', () => {
describe('handleRemoveWorkspaceMember', () => {
it('should soft delete the user workspace record', async () => {
jest.spyOn(userWorkspaceRepository, 'find').mockResolvedValue([]);
jest.spyOn(userWorkspaceRepository, 'find').mockResolvedValue([
{
userId: 'user-id',
workspaceId: 'workspace-id',
id: 'user-workspace-id',
} as UserWorkspaceEntity,
]);
await service.handleRemoveWorkspaceMember(
'workspace-id',
@@ -171,15 +186,21 @@ describe('WorkspaceService', () => {
true,
);
expect(userWorkspaceRepository.softDelete).toHaveBeenCalledWith({
userId: 'user-id',
workspaceId: 'workspace-id',
expect(userWorkspaceService.deleteUserWorkspace).toHaveBeenCalledWith({
userWorkspaceId: 'user-workspace-id',
softDelete: true,
});
expect(userWorkspaceRepository.delete).not.toHaveBeenCalled();
expect(userRepository.softDelete).toHaveBeenCalledWith('user-id');
});
it('should destroy the user workspace record', async () => {
jest.spyOn(userWorkspaceRepository, 'find').mockResolvedValue([]);
jest.spyOn(userWorkspaceRepository, 'find').mockResolvedValue([
{
id: 'user-workspace-id',
userId: 'user-id',
workspaceId: 'workspace-id',
} as UserWorkspaceEntity,
]);
await service.handleRemoveWorkspaceMember(
'workspace-id',
@@ -187,20 +208,26 @@ describe('WorkspaceService', () => {
false,
);
expect(userWorkspaceRepository.delete).toHaveBeenCalledWith({
userId: 'user-id',
workspaceId: 'workspace-id',
expect(userWorkspaceService.deleteUserWorkspace).toHaveBeenCalledWith({
userWorkspaceId: 'user-workspace-id',
softDelete: false,
});
expect(userWorkspaceRepository.softDelete).not.toHaveBeenCalled();
expect(userRepository.softDelete).toHaveBeenCalledWith('user-id');
});
it('should not soft delete the user record if there are other user workspace records', async () => {
jest
.spyOn(userWorkspaceRepository, 'find')
.mockResolvedValue([
{ id: 'remaining-user-workspace-id' } as UserWorkspaceEntity,
]);
jest.spyOn(userWorkspaceRepository, 'find').mockResolvedValue([
{
id: 'remaining-user-workspace-id',
userId: 'user-id',
workspaceId: 'other-workspace-id',
} as UserWorkspaceEntity,
{
id: 'user-workspace-id',
userId: 'user-id',
workspaceId: 'workspace-id',
} as UserWorkspaceEntity,
]);
await service.handleRemoveWorkspaceMember(
'workspace-id',
@@ -208,11 +235,16 @@ describe('WorkspaceService', () => {
false,
);
expect(userWorkspaceRepository.delete).toHaveBeenCalledWith({
userId: 'user-id',
workspaceId: 'workspace-id',
expect(userWorkspaceService.deleteUserWorkspace).toHaveBeenCalledWith({
userWorkspaceId: 'user-workspace-id',
softDelete: false,
});
expect(userWorkspaceRepository.softDelete).not.toHaveBeenCalled();
expect(userWorkspaceService.deleteUserWorkspace).not.toHaveBeenCalledWith(
{
userWorkspaceId: 'remaining-user-workspace-id',
softDelete: false,
},
);
expect(userRepository.softDelete).not.toHaveBeenCalled();
});
});
@@ -247,7 +247,9 @@ export class WorkspaceService extends TypeOrmQueryService<WorkspaceEntity> {
}
async deleteMetadataSchemaCacheAndUserWorkspace(workspace: WorkspaceEntity) {
await this.userWorkspaceRepository.delete({ workspaceId: workspace.id });
await this.userWorkspaceService.deleteUserWorkspace({
userWorkspaceId: workspace.id,
});
if (this.billingService.isBillingEnabled()) {
await this.billingSubscriptionService.deleteSubscriptions(workspace.id);
@@ -259,12 +261,6 @@ export class WorkspaceService extends TypeOrmQueryService<WorkspaceEntity> {
}
async deleteWorkspace(id: string, softDelete = false) {
//TODO: delete all logs when #611 closed
this.logger.log(
`${softDelete ? 'Soft' : 'Hard'} deleting workspace ${id} ...`,
);
const workspace = await this.workspaceRepository.findOne({
where: { id },
withDeleted: true,
@@ -335,25 +331,31 @@ export class WorkspaceService extends TypeOrmQueryService<WorkspaceEntity> {
userId: string,
softDelete = false,
) {
if (softDelete) {
await this.userWorkspaceRepository.softDelete({
userId,
workspaceId,
});
} else {
await this.userWorkspaceRepository.delete({
userId,
workspaceId,
});
}
const userWorkspaces = await this.userWorkspaceRepository.find({
where: {
userId,
},
});
if (userWorkspaces.length === 0) {
const userWorkspaceOfRemovedWorkspaceMember = userWorkspaces?.find(
(userWorkspace: UserWorkspaceEntity) =>
userWorkspace.workspaceId === workspaceId,
);
if (isDefined(userWorkspaceOfRemovedWorkspaceMember)) {
await this.userWorkspaceService.deleteUserWorkspace({
userWorkspaceId: userWorkspaceOfRemovedWorkspaceMember.id,
softDelete,
});
}
const hasOtherUserWorkspaces = isDefined(
userWorkspaceOfRemovedWorkspaceMember,
)
? userWorkspaces.length > 1
: userWorkspaces.length > 0;
if (!hasOtherUserWorkspaces) {
await this.userRepository.softDelete(userId);
}
}