Untitled records for CreatedBy (#11914)
# Display "Soft-Deleted Workspace Members" in Actor Field Display Reminder of the issue : <img width="154" alt="Screenshot 2025-05-07 at 12 11 59" src="https://github.com/user-attachments/assets/168f8743-2684-4d9a-b1a4-e86bb335f7a4" /> - `ActorFieldDisplay` component : display soft-deleted members - `UserService` includes soft-deleted records when fetching workspace members. This is the tricky part : do we want that for all workspace members or maybe i could create another property dedicated to workspace members and softdeleted ones. To be discussed Result looks like this (we loose the source and the context in this impleentation) <img width="114" alt="Screenshot 2025-05-07 at 12 05 28" src="https://github.com/user-attachments/assets/3cdddd91-454f-4e96-8d6d-6fe671658945" /> Fixes https://github.com/twentyhq/twenty/issues/11870 Another way we could also get into : We could also, when a workspace user is softDeleted, change the current implementation : we could avoid to delete the ActorMetadata like CreatedByName (and context and source) in the "Person" table. It would look more like this <img width="111" alt="Screenshot 2025-05-07 at 12 06 16" src="https://github.com/user-attachments/assets/daa4ece2-200a-41f0-ba24-177375c72983" /> However, this implementation is requires more work, and IMO harder to maintain since is decouples completely the record from the workspace member. This could be an issue in case we want tohard delete a user, or decide another logic to display the Actor name. Since the usecase should be pretty rare, I chose the first one but willing to discuss it --------- Co-authored-by: prastoin <paul@twenty.com>
This commit is contained in:
@ -0,0 +1,24 @@
|
||||
import { Field, ObjectType } from '@nestjs/graphql';
|
||||
|
||||
import { IDField } from '@ptc-org/nestjs-query-graphql';
|
||||
|
||||
import { UUIDScalarType } from 'src/engine/api/graphql/workspace-schema-builder/graphql-types/scalars';
|
||||
import { FullName } from 'src/engine/core-modules/user/dtos/workspace-member.dto';
|
||||
|
||||
@ObjectType()
|
||||
export class DeletedWorkspaceMember {
|
||||
@IDField(() => UUIDScalarType)
|
||||
id: string;
|
||||
|
||||
@Field(() => FullName)
|
||||
name: FullName;
|
||||
|
||||
@Field({ nullable: false })
|
||||
userEmail: string;
|
||||
|
||||
@Field(() => String, { nullable: true })
|
||||
avatarUrl: string | null;
|
||||
|
||||
@Field(() => String, { nullable: true })
|
||||
userWorkspaceId: string | null;
|
||||
}
|
||||
@ -0,0 +1,64 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
|
||||
import { FileService } from 'src/engine/core-modules/file/services/file.service';
|
||||
import { DeletedWorkspaceMember } from 'src/engine/core-modules/user/dtos/deleted-workspace-member.dto';
|
||||
import { WorkspaceMemberWorkspaceEntity } from 'src/modules/workspace-member/standard-objects/workspace-member.workspace-entity';
|
||||
|
||||
@Injectable()
|
||||
export class DeletedWorkspaceMemberTranspiler {
|
||||
constructor(private readonly fileService: FileService) {}
|
||||
|
||||
generateSignedAvatarUrl({
|
||||
workspaceId,
|
||||
workspaceMember,
|
||||
}: {
|
||||
workspaceMember: Pick<WorkspaceMemberWorkspaceEntity, 'avatarUrl' | 'id'>;
|
||||
workspaceId: string;
|
||||
}): string {
|
||||
const avatarUrlToken = this.fileService.encodeFileToken({
|
||||
workspaceMemberId: workspaceMember.id,
|
||||
workspaceId: workspaceId,
|
||||
});
|
||||
|
||||
return `${workspaceMember.avatarUrl}?token=${avatarUrlToken}`;
|
||||
}
|
||||
|
||||
toDeletedWorkspaceMemberDto(
|
||||
workspaceMember: WorkspaceMemberWorkspaceEntity,
|
||||
userWorkspaceId?: string,
|
||||
): DeletedWorkspaceMember {
|
||||
const {
|
||||
avatarUrl: avatarUrlFromEntity,
|
||||
id,
|
||||
name,
|
||||
userEmail,
|
||||
} = workspaceMember;
|
||||
|
||||
const avatarUrl = userWorkspaceId
|
||||
? this.generateSignedAvatarUrl({
|
||||
workspaceId: userWorkspaceId,
|
||||
workspaceMember: {
|
||||
avatarUrl: avatarUrlFromEntity,
|
||||
id,
|
||||
},
|
||||
})
|
||||
: null;
|
||||
|
||||
return {
|
||||
id,
|
||||
name,
|
||||
userEmail,
|
||||
avatarUrl,
|
||||
userWorkspaceId: userWorkspaceId ?? null,
|
||||
} satisfies DeletedWorkspaceMember;
|
||||
}
|
||||
|
||||
toDeletedWorkspaceMemberDtos(
|
||||
workspaceMembers: WorkspaceMemberWorkspaceEntity[],
|
||||
userWorkspaceId?: string,
|
||||
): DeletedWorkspaceMember[] {
|
||||
return workspaceMembers.map((workspaceMember) =>
|
||||
this.toDeletedWorkspaceMemberDto(workspaceMember, userWorkspaceId),
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -4,7 +4,7 @@ import assert from 'assert';
|
||||
|
||||
import { TypeOrmQueryService } from '@ptc-org/nestjs-query-typeorm';
|
||||
import { isWorkspaceActiveOrSuspended } from 'twenty-shared/workspace';
|
||||
import { Repository } from 'typeorm';
|
||||
import { IsNull, Not, Repository } from 'typeorm';
|
||||
|
||||
import { DatabaseEventAction } from 'src/engine/api/graphql/graphql-query-runner/enums/database-event-action';
|
||||
import {
|
||||
@ -63,7 +63,7 @@ export class UserService extends TypeOrmQueryService<User> {
|
||||
});
|
||||
}
|
||||
|
||||
async loadWorkspaceMembers(workspace: Workspace) {
|
||||
async loadWorkspaceMembers(workspace: Workspace, withDeleted = false) {
|
||||
if (!isWorkspaceActiveOrSuspended(workspace)) {
|
||||
return [];
|
||||
}
|
||||
@ -74,7 +74,24 @@ export class UserService extends TypeOrmQueryService<User> {
|
||||
'workspaceMember',
|
||||
);
|
||||
|
||||
return workspaceMemberRepository.find();
|
||||
return await workspaceMemberRepository.find({ withDeleted: withDeleted });
|
||||
}
|
||||
|
||||
async loadDeletedWorkspaceMembersOnly(workspace: Workspace) {
|
||||
if (!isWorkspaceActiveOrSuspended(workspace)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const workspaceMemberRepository =
|
||||
await this.twentyORMGlobalManager.getRepositoryForWorkspace<WorkspaceMemberWorkspaceEntity>(
|
||||
workspace.id,
|
||||
'workspaceMember',
|
||||
);
|
||||
|
||||
return await workspaceMemberRepository.find({
|
||||
where: { deletedAt: Not(IsNull()) },
|
||||
withDeleted: true,
|
||||
});
|
||||
}
|
||||
|
||||
private async deleteUserFromWorkspace({
|
||||
|
||||
@ -24,6 +24,7 @@ import { DataSourceModule } from 'src/engine/metadata-modules/data-source/data-s
|
||||
import { ObjectMetadataEntity } from 'src/engine/metadata-modules/object-metadata/object-metadata.entity';
|
||||
import { PermissionsModule } from 'src/engine/metadata-modules/permissions/permissions.module';
|
||||
import { UserRoleModule } from 'src/engine/metadata-modules/user-role/user-role.module';
|
||||
import { DeletedWorkspaceMemberTranspiler } from 'src/engine/core-modules/user/services/deleted-workspace-member-transpiler.service';
|
||||
|
||||
import { userAutoResolverOpts } from './user.auto-resolver-opts';
|
||||
|
||||
@ -53,7 +54,12 @@ import { UserService } from './services/user.service';
|
||||
PermissionsModule,
|
||||
UserWorkspaceModule,
|
||||
],
|
||||
exports: [UserService],
|
||||
providers: [UserService, UserResolver, TypeORMService],
|
||||
exports: [UserService, DeletedWorkspaceMemberTranspiler],
|
||||
providers: [
|
||||
UserService,
|
||||
UserResolver,
|
||||
TypeORMService,
|
||||
DeletedWorkspaceMemberTranspiler,
|
||||
],
|
||||
})
|
||||
export class UserModule {}
|
||||
|
||||
@ -34,7 +34,9 @@ import {
|
||||
} from 'src/engine/core-modules/onboarding/onboarding.service';
|
||||
import { TwentyConfigService } from 'src/engine/core-modules/twenty-config/twenty-config.service';
|
||||
import { UserWorkspace } from 'src/engine/core-modules/user-workspace/user-workspace.entity';
|
||||
import { DeletedWorkspaceMember } from 'src/engine/core-modules/user/dtos/deleted-workspace-member.dto';
|
||||
import { WorkspaceMember } from 'src/engine/core-modules/user/dtos/workspace-member.dto';
|
||||
import { DeletedWorkspaceMemberTranspiler } from 'src/engine/core-modules/user/services/deleted-workspace-member-transpiler.service';
|
||||
import { UserService } from 'src/engine/core-modules/user/services/user.service';
|
||||
import { UserVarsService } from 'src/engine/core-modules/user/user-vars/services/user-vars.service';
|
||||
import { User } from 'src/engine/core-modules/user/user.entity';
|
||||
@ -79,6 +81,7 @@ export class UserResolver {
|
||||
private readonly userWorkspaceRepository: Repository<UserWorkspace>,
|
||||
private readonly userRoleService: UserRoleService,
|
||||
private readonly permissionsService: PermissionsService,
|
||||
private readonly deletedWorkspaceMemberTranspiler: DeletedWorkspaceMemberTranspiler,
|
||||
) {}
|
||||
|
||||
@Query(() => User)
|
||||
@ -187,7 +190,7 @@ export class UserResolver {
|
||||
workspaceMember.avatarUrl = `${workspaceMember.avatarUrl}?token=${avatarUrlToken}`;
|
||||
}
|
||||
|
||||
// TODO: Fix typing disrepency between Entity and DTO
|
||||
// TODO Refactor to be transpiled to WorkspaceMember instead
|
||||
return workspaceMember as WorkspaceMember | null;
|
||||
}
|
||||
|
||||
@ -195,17 +198,15 @@ export class UserResolver {
|
||||
nullable: true,
|
||||
})
|
||||
async workspaceMembers(
|
||||
@Parent() user: User,
|
||||
@Parent() _user: User,
|
||||
@AuthWorkspace() workspace: Workspace,
|
||||
): Promise<WorkspaceMember[]> {
|
||||
const workspaceMemberEntities =
|
||||
await this.userService.loadWorkspaceMembers(workspace);
|
||||
const workspaceMemberEntities = await this.userService.loadWorkspaceMembers(
|
||||
workspace,
|
||||
false,
|
||||
);
|
||||
|
||||
const workspaceMembers: WorkspaceMember[] = [];
|
||||
|
||||
let userWorkspacesByUserId = new Map<string, UserWorkspace>();
|
||||
let rolesByUserWorkspaces = new Map<string, RoleDTO[]>();
|
||||
|
||||
const userWorkspaces = await this.userWorkspaceRepository.find({
|
||||
where: {
|
||||
userId: In(workspaceMemberEntities.map((entity) => entity.userId)),
|
||||
@ -213,21 +214,20 @@ export class UserResolver {
|
||||
},
|
||||
});
|
||||
|
||||
userWorkspacesByUserId = new Map(
|
||||
const userWorkspacesByUserId = new Map<string, UserWorkspace>(
|
||||
userWorkspaces.map((userWorkspace) => [
|
||||
userWorkspace.userId,
|
||||
userWorkspace,
|
||||
]),
|
||||
);
|
||||
|
||||
rolesByUserWorkspaces = await this.userRoleService.getRolesByUserWorkspaces(
|
||||
{
|
||||
const rolesByUserWorkspaces: Map<string, RoleDTO[]> =
|
||||
await this.userRoleService.getRolesByUserWorkspaces({
|
||||
userWorkspaceIds: userWorkspaces.map(
|
||||
(userWorkspace) => userWorkspace.id,
|
||||
),
|
||||
workspaceId: workspace.id,
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
for (const workspaceMemberEntity of workspaceMemberEntities) {
|
||||
if (workspaceMemberEntity.avatarUrl) {
|
||||
@ -239,12 +239,14 @@ export class UserResolver {
|
||||
workspaceMemberEntity.avatarUrl = `${workspaceMemberEntity.avatarUrl}?token=${avatarUrlToken}`;
|
||||
}
|
||||
|
||||
// TODO Refactor to be transpiled to WorkspaceMember instead
|
||||
const workspaceMember = workspaceMemberEntity as WorkspaceMember;
|
||||
|
||||
const userWorkspace = userWorkspacesByUserId.get(
|
||||
workspaceMemberEntity.userId,
|
||||
);
|
||||
|
||||
// TODO Refactor should not throw ? typed as nullable ?
|
||||
if (!userWorkspace) {
|
||||
throw new Error('User workspace not found');
|
||||
}
|
||||
@ -279,6 +281,22 @@ export class UserResolver {
|
||||
return workspaceMembers;
|
||||
}
|
||||
|
||||
@ResolveField(() => [DeletedWorkspaceMember], {
|
||||
nullable: true,
|
||||
})
|
||||
async deletedWorkspaceMembers(
|
||||
@Parent() _user: User,
|
||||
@AuthWorkspace() workspace: Workspace,
|
||||
): Promise<DeletedWorkspaceMember[]> {
|
||||
const workspaceMemberEntities =
|
||||
await this.userService.loadDeletedWorkspaceMembersOnly(workspace);
|
||||
|
||||
return this.deletedWorkspaceMemberTranspiler.toDeletedWorkspaceMemberDtos(
|
||||
workspaceMemberEntities,
|
||||
workspace.id,
|
||||
);
|
||||
}
|
||||
|
||||
@ResolveField(() => String, {
|
||||
nullable: true,
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user