diff --git a/src/modules/workspaces/dto/change-user-role.dto.ts b/src/modules/workspaces/dto/change-user-role.dto.ts new file mode 100644 index 000000000..0af47f57d --- /dev/null +++ b/src/modules/workspaces/dto/change-user-role.dto.ts @@ -0,0 +1,12 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { IsNotEmpty } from 'class-validator'; +import { WorkspaceRole } from '../guards/workspace-required-access.decorator'; + +export class ChangeUserRoleDto { + @ApiProperty({ + example: 'TEAM_MANAGER', + description: 'Role to be assigned to user', + }) + @IsNotEmpty() + role: Omit; +} diff --git a/src/modules/workspaces/repositories/team.repository.ts b/src/modules/workspaces/repositories/team.repository.ts index 747fd77ad..6ade4e568 100644 --- a/src/modules/workspaces/repositories/team.repository.ts +++ b/src/modules/workspaces/repositories/team.repository.ts @@ -70,6 +70,20 @@ export class SequelizeWorkspaceTeamRepository { }; } + async getTeamUser( + userUuid: UserAttributes['uuid'], + teamId: WorkspaceTeamAttributes['id'], + ): Promise { + const teamUser = await this.teamUserModel.findOne({ + where: { + memberId: userUuid, + teamId, + }, + }); + + return teamUser ? this.teamUserToDomain(teamUser) : null; + } + async getTeamById( teamId: WorkspaceTeamAttributes['id'], ): Promise { diff --git a/src/modules/workspaces/workspaces.controller.spec.ts b/src/modules/workspaces/workspaces.controller.spec.ts new file mode 100644 index 000000000..df44328cc --- /dev/null +++ b/src/modules/workspaces/workspaces.controller.spec.ts @@ -0,0 +1,46 @@ +import { DeepMocked, createMock } from '@golevelup/ts-jest'; +import { BadRequestException } from '@nestjs/common'; +import { WorkspacesController } from './workspaces.controller'; +import { WorkspacesUsecases } from './workspaces.usecase'; +import { WorkspaceRole } from './guards/workspace-required-access.decorator'; + +describe('Workspace Controller', () => { + let workspacesController: WorkspacesController; + let workspacesUsecases: DeepMocked; + + beforeEach(async () => { + workspacesUsecases = createMock(); + + workspacesController = new WorkspacesController(workspacesUsecases); + }); + + it('should be defined', () => { + expect(workspacesController).toBeDefined(); + }); + + describe('PATCH /:workspaceId/teams/:teamId/members/:memberId/role', () => { + it('When memberId is not a valid uuid, then it throws.', async () => { + workspacesUsecases.changeUserRole.mockRejectedValueOnce( + new BadRequestException(), + ); + await expect( + workspacesController.changeMemberRole('', '', 'notValidUuid', { + role: WorkspaceRole.MEMBER, + }), + ).rejects.toThrow(BadRequestException); + }); + + it('When input is valid, then it works', async () => { + await expect( + workspacesController.changeMemberRole( + '', + '', + '9aa9399e-8697-41f7-88e3-df1d78794cb8', + { + role: WorkspaceRole.MEMBER, + }, + ), + ).resolves.toBeTruthy(); + }); + }); +}); diff --git a/src/modules/workspaces/workspaces.controller.ts b/src/modules/workspaces/workspaces.controller.ts index 7793a6dee..1f6476468 100644 --- a/src/modules/workspaces/workspaces.controller.ts +++ b/src/modules/workspaces/workspaces.controller.ts @@ -23,6 +23,8 @@ import { WorkspaceRole, } from './guards/workspace-required-access.decorator'; import { CreateWorkspaceInviteDto } from './dto/create-workspace-invite.dto'; +import { WorkspaceTeamAttributes } from './attributes/workspace-team.attributes'; +import { ChangeUserRoleDto } from './dto/change-user-role.dto'; @ApiTags('Workspaces') @Controller('workspaces') @@ -98,4 +100,25 @@ export class WorkspacesController { async getTeamMembers() { throw new NotImplementedException(); } + + @Patch('/:workspaceId/teams/:teamId/members/:memberId/role') + @UseGuards(WorkspaceGuard) + @WorkspaceRequiredAccess(AccessContext.WORKSPACE, WorkspaceRole.OWNER) + async changeMemberRole( + @Param('workspaceId') workspaceId: WorkspaceAttributes['id'], + @Param('teamId') teamId: WorkspaceTeamAttributes['id'], + @Param('memberId') userUuid: User['uuid'], + @Body() changeUserRoleBody: ChangeUserRoleDto, + ) { + if (!userUuid || !isUUID(userUuid)) { + throw new BadRequestException('Invalid User Uuid'); + } + + return this.workspaceUseCases.changeUserRole( + workspaceId, + teamId, + userUuid, + changeUserRoleBody, + ); + } } diff --git a/src/modules/workspaces/workspaces.usecase.spec.ts b/src/modules/workspaces/workspaces.usecase.spec.ts index 5341ee04d..9c1688917 100644 --- a/src/modules/workspaces/workspaces.usecase.spec.ts +++ b/src/modules/workspaces/workspaces.usecase.spec.ts @@ -10,11 +10,15 @@ import { newUser, newWorkspace, newWorkspaceInvite, + newWorkspaceTeam, newWorkspaceUser, } from '../../../test/fixtures'; import { BadRequestException, NotFoundException } from '@nestjs/common'; import { PreCreatedUser } from '../user/pre-created-user.domain'; import { BridgeService } from '../../externals/bridge/bridge.service'; +import { SequelizeWorkspaceTeamRepository } from './repositories/team.repository'; +import { WorkspaceRole } from './guards/workspace-required-access.decorator'; +import { WorkspaceTeamUser } from './domains/workspace-team-user.domain'; jest.mock('../../middlewares/passport', () => { const originalModule = jest.requireActual('../../middlewares/passport'); @@ -29,6 +33,7 @@ jest.mock('../../middlewares/passport', () => { describe('WorkspacesUsecases', () => { let service: WorkspacesUsecases; let workspaceRepository: SequelizeWorkspaceRepository; + let teamRepository: SequelizeWorkspaceTeamRepository; let userRepository: SequelizeUserRepository; let userUsecases: UserUseCases; let mailerService: MailerService; @@ -46,6 +51,9 @@ describe('WorkspacesUsecases', () => { workspaceRepository = module.get( SequelizeWorkspaceRepository, ); + teamRepository = module.get( + SequelizeWorkspaceTeamRepository, + ); userRepository = module.get( SequelizeUserRepository, ); @@ -311,6 +319,155 @@ describe('WorkspacesUsecases', () => { }); }); + describe('changeUserRole', () => { + it('When team does not exist, then error is thrown', async () => { + jest.spyOn(teamRepository, 'getTeamById').mockResolvedValue(null); + + await expect( + service.changeUserRole('workspaceId', 'teamId', 'userId', { + role: WorkspaceRole.MEMBER, + }), + ).rejects.toThrow(NotFoundException); + }); + + it('When user is not part of team, then error is thrown', async () => { + jest + .spyOn(teamRepository, 'getTeamById') + .mockResolvedValue(newWorkspaceTeam()); + jest.spyOn(teamRepository, 'getTeamUser').mockResolvedValue(null); + + await expect( + service.changeUserRole('workspaceId', 'teamId', 'userId', { + role: WorkspaceRole.MEMBER, + }), + ).rejects.toThrow(NotFoundException); + }); + + it('When user does not exist, then error is thrown', async () => { + jest + .spyOn(teamRepository, 'getTeamById') + .mockResolvedValue(newWorkspaceTeam()); + jest.spyOn(teamRepository, 'getTeamUser').mockResolvedValue( + new WorkspaceTeamUser({ + id: '', + teamId: '', + memberId: '', + createdAt: new Date(), + updatedAt: new Date(), + }), + ); + jest.spyOn(userRepository, 'findByUuid').mockResolvedValue(null); + + await expect( + service.changeUserRole('workspaceId', 'teamId', 'userId', { + role: WorkspaceRole.MEMBER, + }), + ).rejects.toThrow(BadRequestException); + }); + + it('When a member of the team is upgrade to manager, then it works', async () => { + const member = newUser(); + const team = newWorkspaceTeam(); + + jest.spyOn(teamRepository, 'getTeamById').mockResolvedValue(team); + jest.spyOn(teamRepository, 'getTeamUser').mockResolvedValue( + new WorkspaceTeamUser({ + id: '', + teamId: team.id, + memberId: member.uuid, + createdAt: new Date(), + updatedAt: new Date(), + }), + ); + jest.spyOn(userRepository, 'findByUuid').mockResolvedValue(member); + + await service.changeUserRole('workspaceId', 'teamId', 'userId', { + role: WorkspaceRole.MANAGER, + }); + + expect(teamRepository.updateById).toHaveBeenCalledWith(team.id, { + managerId: member.uuid, + }); + }); + + it('When a team manager role is changed to member, then the owner is assigned as manager', async () => { + const manager = newUser(); + const workspaceOwner = newUser(); + const team = newWorkspaceTeam({ manager: manager }); + const workspace = newWorkspace({ owner: workspaceOwner }); + + jest.spyOn(teamRepository, 'getTeamById').mockResolvedValue(team); + jest.spyOn(teamRepository, 'getTeamUser').mockResolvedValue( + new WorkspaceTeamUser({ + id: '', + teamId: team.id, + memberId: manager.uuid, + createdAt: new Date(), + updatedAt: new Date(), + }), + ); + jest.spyOn(userRepository, 'findByUuid').mockResolvedValue(manager); + jest.spyOn(workspaceRepository, 'findById').mockResolvedValue(workspace); + + await service.changeUserRole('workspaceId', 'teamId', 'userId', { + role: WorkspaceRole.MEMBER, + }); + + expect(teamRepository.updateById).toHaveBeenCalledWith(team.id, { + managerId: workspaceOwner.uuid, + }); + }); + + it('When user is already manager and tries to update their role to manager, then it does nothing ', async () => { + const manager = newUser(); + const workspaceOwner = newUser(); + const team = newWorkspaceTeam({ manager: manager }); + const workspace = newWorkspace({ owner: workspaceOwner }); + + jest.spyOn(teamRepository, 'getTeamById').mockResolvedValue(team); + jest.spyOn(teamRepository, 'getTeamUser').mockResolvedValue( + new WorkspaceTeamUser({ + id: '', + teamId: team.id, + memberId: manager.uuid, + createdAt: new Date(), + updatedAt: new Date(), + }), + ); + jest.spyOn(userRepository, 'findByUuid').mockResolvedValue(manager); + jest.spyOn(workspaceRepository, 'findById').mockResolvedValue(workspace); + + await service.changeUserRole('workspaceId', 'teamId', 'userId', { + role: WorkspaceRole.MANAGER, + }); + + expect(teamRepository.updateById).not.toHaveBeenCalled(); + }); + + it('When user is already member and tries to update their role to member, then it does nothing', async () => { + const member = newUser(); + const team = newWorkspaceTeam(); + + jest.spyOn(teamRepository, 'getTeamById').mockResolvedValue(team); + jest.spyOn(teamRepository, 'getTeamUser').mockResolvedValue( + new WorkspaceTeamUser({ + id: '', + teamId: team.id, + memberId: member.uuid, + createdAt: new Date(), + updatedAt: new Date(), + }), + ); + jest.spyOn(userRepository, 'findByUuid').mockResolvedValue(member); + + await service.changeUserRole('workspaceId', 'teamId', 'userId', { + role: WorkspaceRole.MEMBER, + }); + + expect(teamRepository.updateById).not.toHaveBeenCalled(); + }); + }); + describe('getAssignableSpaceInWorkspace', () => { const workspace = newWorkspace(); const workspaceDefaultUser = newUser(); diff --git a/src/modules/workspaces/workspaces.usecase.ts b/src/modules/workspaces/workspaces.usecase.ts index 6eb654a66..522f1412f 100644 --- a/src/modules/workspaces/workspaces.usecase.ts +++ b/src/modules/workspaces/workspaces.usecase.ts @@ -24,6 +24,9 @@ import { CreateWorkspaceInviteDto } from './dto/create-workspace-invite.dto'; import { MailerService } from '../../externals/mailer/mailer.service'; import { ConfigService } from '@nestjs/config'; import { Sign } from '../../middlewares/passport'; +import { ChangeUserRoleDto } from './dto/change-user-role.dto'; +import { WorkspaceTeamAttributes } from './attributes/workspace-team.attributes'; +import { WorkspaceRole } from './guards/workspace-required-access.decorator'; @Injectable() export class WorkspacesUsecases { @@ -328,6 +331,56 @@ export class WorkspacesUsecases { return this.workspaceRepository.findById(workspaceId); } + async changeUserRole( + workspaceId: WorkspaceAttributes['id'], + teamId: WorkspaceTeamAttributes['id'], + userUuid: User['uuid'], + changeUserRoleDto: ChangeUserRoleDto, + ): Promise { + const { role } = changeUserRoleDto; + + const [team, teamUser] = await Promise.all([ + this.teamRepository.getTeamById(teamId), + this.teamRepository.getTeamUser(userUuid, teamId), + ]); + + if (!team) { + throw new NotFoundException('Team not found.'); + } + + if (!teamUser) { + throw new NotFoundException('User not part of the team.'); + } + + const user = await this.userRepository.findByUuid(teamUser.memberId); + + if (!user) { + throw new BadRequestException(); + } + + const isUserAlreadyManager = team.isUserManager(user); + + let newManagerId: UserAttributes['uuid']; + + if (role === WorkspaceRole.MEMBER && isUserAlreadyManager) { + const workspaceOwner = + await this.workspaceRepository.findById(workspaceId); + newManagerId = workspaceOwner.ownerId; + } + + if (role === WorkspaceRole.MANAGER && !isUserAlreadyManager) { + newManagerId = user.uuid; + } + + if (!newManagerId) { + return; + } + + await this.teamRepository.updateById(team.id, { + managerId: newManagerId, + }); + } + findUserInTeam( userUuid: string, teamId: string,