diff --git a/backend/src/libs/guards/teamRoles.guard.ts b/backend/src/libs/guards/teamRoles.guard.ts new file mode 100644 index 000000000..b8db3c913 --- /dev/null +++ b/backend/src/libs/guards/teamRoles.guard.ts @@ -0,0 +1,26 @@ +import { CanActivate, ExecutionContext, Injectable } from '@nestjs/common'; +import { Reflector } from '@nestjs/core'; +import { InjectModel } from '@nestjs/mongoose'; +import { Model } from 'mongoose'; + +import TeamUser, { TeamUserDocument } from '../../modules/teams/schemas/team.user.schema'; + +@Injectable() +export class TeamUserGuard implements CanActivate { + constructor( + private readonly reflector: Reflector, + @InjectModel(TeamUser.name) private teamUserModel: Model + ) {} + + async canActivate(context: ExecutionContext) { + const permission = this.reflector.get('permission', context.getHandler()); + const request = context.switchToHttp().getRequest(); + + const user = request.user; + const team: string = request.params.teamId; + + const userFound = await this.teamUserModel.findOne({ user: user._id, teamId: team }).exec(); + + return user.isSAdmin || permission === userFound?.role; + } +} diff --git a/backend/src/modules/teams/applications/update.team.application.ts b/backend/src/modules/teams/applications/update.team.application.ts new file mode 100644 index 000000000..bf0391242 --- /dev/null +++ b/backend/src/modules/teams/applications/update.team.application.ts @@ -0,0 +1,18 @@ +import { Inject, Injectable } from '@nestjs/common'; + +import TeamUserDto from '../dto/team.user.dto'; +import { UpdateTeamApplicationInterface } from '../interfaces/applications/update.team.application.interface'; +import { UpdateTeamServiceInterface } from '../interfaces/services/update.team.service.interface'; +import { TYPES } from '../interfaces/types'; + +@Injectable() +export class UpdateTeamApplication implements UpdateTeamApplicationInterface { + constructor( + @Inject(TYPES.services.UpdateTeamService) + private updateTeamService: UpdateTeamServiceInterface + ) {} + + updateTeamUser(teamData: TeamUserDto) { + return this.updateTeamService.updateTeamUser(teamData); + } +} diff --git a/backend/src/modules/teams/controller/team.controller.ts b/backend/src/modules/teams/controller/team.controller.ts index 372e70ae0..6f8b547c0 100644 --- a/backend/src/modules/teams/controller/team.controller.ts +++ b/backend/src/modules/teams/controller/team.controller.ts @@ -9,6 +9,7 @@ import { Put, Query, Req, + SetMetadata, UseGuards, UsePipes, ValidationPipe @@ -16,8 +17,11 @@ import { import { ApiBadRequestResponse, ApiBearerAuth, + ApiBody, ApiCreatedResponse, + ApiForbiddenResponse, ApiInternalServerErrorResponse, + ApiNotFoundResponse, ApiOkResponse, ApiOperation, ApiParam, @@ -29,13 +33,17 @@ import { import { TeamParams } from 'libs/dto/param/team.params'; import { TeamQueryParams } from 'libs/dto/param/team.query.params'; import { TeamRoles } from 'libs/enum/team.roles'; -import { INSERT_FAILED } from 'libs/exceptions/messages'; +import { INSERT_FAILED, UPDATE_FAILED } from 'libs/exceptions/messages'; import JwtAuthenticationGuard from 'libs/guards/jwtAuth.guard'; import RequestWithUser from 'libs/interfaces/requestWithUser.interface'; import { BadRequestResponse } from 'libs/swagger/errors/bad-request.swagger'; import { InternalServerErrorResponse } from 'libs/swagger/errors/internal-server-error.swagger'; import { UnauthorizedResponse } from 'libs/swagger/errors/unauthorized.swagger'; +import { TeamUserGuard } from '../../../libs/guards/teamRoles.guard'; +import { ForbiddenResponse } from '../../../libs/swagger/errors/forbidden.swagger'; +import { NotFoundResponse } from '../../../libs/swagger/errors/not-found.swagger'; +import { UpdateTeamApplication } from '../applications/update.team.application'; import { CreateTeamDto } from '../dto/crate-team.dto'; import TeamDto from '../dto/team.dto'; import TeamUserDto from '../dto/team.user.dto'; @@ -43,6 +51,8 @@ import { CreateTeamApplicationInterface } from '../interfaces/applications/creat import { GetTeamApplicationInterface } from '../interfaces/applications/get.team.application.interface'; import { TYPES } from '../interfaces/types'; +const TeamUser = (permission: string) => SetMetadata('permission', permission); + @ApiBearerAuth('access-token') @ApiTags('Teams') @UseGuards(JwtAuthenticationGuard) @@ -52,7 +62,9 @@ export default class TeamsController { @Inject(TYPES.applications.CreateTeamApplication) private createTeamApp: CreateTeamApplicationInterface, @Inject(TYPES.applications.GetTeamApplication) - private getTeamApp: GetTeamApplicationInterface + private getTeamApp: GetTeamApplicationInterface, + @Inject(TYPES.applications.UpdateTeamApplication) + private updateTeamApp: UpdateTeamApplication ) {} @ApiOperation({ summary: 'Create a new team' }) @@ -170,4 +182,42 @@ export default class TeamsController { getTeam(@Param() { teamId }: TeamParams, @Query() teamQueryParams?: TeamQueryParams) { return this.getTeamApp.getTeam(teamId, teamQueryParams); } + + @ApiOperation({ summary: 'Update a specific team member' }) + @ApiParam({ type: String, name: 'teamId', required: true }) + @ApiBody({ type: TeamUserDto }) + @ApiOkResponse({ + type: TeamUserDto, + description: 'Team member updated successfully!' + }) + @ApiBadRequestResponse({ + description: 'Bad Request', + type: BadRequestResponse + }) + @ApiUnauthorizedResponse({ + description: 'Unauthorized', + type: UnauthorizedResponse + }) + @ApiNotFoundResponse({ + type: NotFoundResponse, + description: 'Not found!' + }) + @ApiForbiddenResponse({ + description: 'Forbidden', + type: ForbiddenResponse + }) + @ApiInternalServerErrorResponse({ + description: 'Internal Server Error', + type: InternalServerErrorResponse + }) + @TeamUser(TeamRoles.ADMIN) + @UseGuards(TeamUserGuard) + @Put(':teamId') + async updateTeamUser(@Body() teamData: TeamUserDto) { + const teamUser = await this.updateTeamApp.updateTeamUser(teamData); + + if (!teamUser) throw new BadRequestException(UPDATE_FAILED); + + return teamUser; + } } diff --git a/backend/src/modules/teams/dto/team.user.dto.ts b/backend/src/modules/teams/dto/team.user.dto.ts index ddb12dd15..9397eebce 100644 --- a/backend/src/modules/teams/dto/team.user.dto.ts +++ b/backend/src/modules/teams/dto/team.user.dto.ts @@ -25,6 +25,5 @@ export default class TeamUserDto { @ApiPropertyOptional() @IsOptional() @IsBoolean() - @IsMongoId() isNewJoiner?: boolean; } diff --git a/backend/src/modules/teams/interfaces/applications/update.team.application.interface.ts b/backend/src/modules/teams/interfaces/applications/update.team.application.interface.ts new file mode 100644 index 000000000..f974f020f --- /dev/null +++ b/backend/src/modules/teams/interfaces/applications/update.team.application.interface.ts @@ -0,0 +1,8 @@ +import { LeanDocument } from 'mongoose'; + +import TeamUserDto from '../../dto/team.user.dto'; +import { TeamUserDocument } from '../../schemas/team.user.schema'; + +export interface UpdateTeamApplicationInterface { + updateTeamUser(teamData: TeamUserDto): Promise | null>; +} diff --git a/backend/src/modules/teams/interfaces/services/get.team.service.interface.ts b/backend/src/modules/teams/interfaces/services/get.team.service.interface.ts index c3d13b113..b6bf24843 100644 --- a/backend/src/modules/teams/interfaces/services/get.team.service.interface.ts +++ b/backend/src/modules/teams/interfaces/services/get.team.service.interface.ts @@ -19,7 +19,7 @@ export interface GetTeamServiceInterface { getUsersOfTeam(teamId: string): Promise[]>; - getTeamUser(userId: string, boardId: string): Promise | null>; + getTeamUser(userId: string, teamId: string): Promise | null>; getAllTeams(): Promise[]>; } diff --git a/backend/src/modules/teams/interfaces/services/update.team.service.interface.ts b/backend/src/modules/teams/interfaces/services/update.team.service.interface.ts index e69de29bb..054211e58 100644 --- a/backend/src/modules/teams/interfaces/services/update.team.service.interface.ts +++ b/backend/src/modules/teams/interfaces/services/update.team.service.interface.ts @@ -0,0 +1,8 @@ +import { LeanDocument } from 'mongoose'; + +import TeamUserDto from '../../dto/team.user.dto'; +import { TeamUserDocument } from '../../schemas/team.user.schema'; + +export interface UpdateTeamServiceInterface { + updateTeamUser(teamData: TeamUserDto): Promise | null>; +} diff --git a/backend/src/modules/teams/interfaces/types.ts b/backend/src/modules/teams/interfaces/types.ts index 710157411..2e8f45e71 100644 --- a/backend/src/modules/teams/interfaces/types.ts +++ b/backend/src/modules/teams/interfaces/types.ts @@ -1,13 +1,14 @@ export const TYPES = { services: { CreateTeamService: 'CreateTeamService', - GetTeamService: 'GetTeamService' + GetTeamService: 'GetTeamService', + UpdateTeamService: 'UpdateTeamService' // DeleteTeamService: 'DeleteTeamService', - // UpdateTeamService: 'UpdateTeamService', }, applications: { CreateTeamApplication: 'CreateTeamApplication', - GetTeamApplication: 'GetTeamApplication' + GetTeamApplication: 'GetTeamApplication', + UpdateTeamApplication: 'UpdateTeamApplication' // DeleteBoardApplication: 'DeleteBoardApplication', // UpdateBoardApplication: 'UpdateBoardApplication', } diff --git a/backend/src/modules/teams/providers.ts b/backend/src/modules/teams/providers.ts index 7e6808c7f..a448fbb14 100644 --- a/backend/src/modules/teams/providers.ts +++ b/backend/src/modules/teams/providers.ts @@ -1,8 +1,10 @@ import { CreateTeamApplication } from './applications/create.team.application'; import { GetTeamApplication } from './applications/get.team.application'; +import { UpdateTeamApplication } from './applications/update.team.application'; import { TYPES } from './interfaces/types'; import CreateTeamService from './services/create.team.service'; import GetTeamService from './services/get.team.service'; +import UpdateTeamService from './services/update.team.service'; export const createTeamService = { provide: TYPES.services.CreateTeamService, @@ -23,3 +25,13 @@ export const getTeamApplication = { provide: TYPES.applications.GetTeamApplication, useClass: GetTeamApplication }; + +export const updateTeamService = { + provide: TYPES.services.UpdateTeamService, + useClass: UpdateTeamService +}; + +export const updateTeamApplication = { + provide: TYPES.applications.UpdateTeamApplication, + useClass: UpdateTeamApplication +}; diff --git a/backend/src/modules/teams/services/update.team.service.ts b/backend/src/modules/teams/services/update.team.service.ts new file mode 100644 index 000000000..f08b16b9d --- /dev/null +++ b/backend/src/modules/teams/services/update.team.service.ts @@ -0,0 +1,23 @@ +import { Injectable } from '@nestjs/common'; +import { InjectModel } from '@nestjs/mongoose'; +import { LeanDocument, Model } from 'mongoose'; + +import TeamUserDto from '../dto/team.user.dto'; +import { UpdateTeamServiceInterface } from '../interfaces/services/update.team.service.interface'; +import TeamUser, { TeamUserDocument } from '../schemas/team.user.schema'; + +@Injectable() +export default class UpdateTeamService implements UpdateTeamServiceInterface { + constructor(@InjectModel(TeamUser.name) private teamUserModel: Model) {} + + updateTeamUser(teamData: TeamUserDto): Promise | null> { + return this.teamUserModel + .findOneAndUpdate( + { user: teamData.user, team: teamData.team }, + { $set: { role: teamData.role, isNewJoiner: teamData.isNewJoiner } }, + { new: true } + ) + .lean() + .exec(); + } +} diff --git a/backend/src/modules/teams/teams.module.ts b/backend/src/modules/teams/teams.module.ts index 518720168..427d93a9f 100644 --- a/backend/src/modules/teams/teams.module.ts +++ b/backend/src/modules/teams/teams.module.ts @@ -11,13 +11,22 @@ import { createTeamApplication, createTeamService, getTeamApplication, - getTeamService + getTeamService, + updateTeamApplication, + updateTeamService } from './providers'; @Module({ imports: [mongooseTeamModule, mongooseTeamUserModule, forwardRef(() => BoardsModule)], - providers: [createTeamService, createTeamApplication, getTeamService, getTeamApplication], + providers: [ + createTeamService, + createTeamApplication, + getTeamService, + getTeamApplication, + updateTeamService, + updateTeamApplication + ], controllers: [TeamsController], - exports: [getTeamApplication, getTeamService, createTeamService] + exports: [getTeamApplication, getTeamService, createTeamService, updateTeamService] }) export default class TeamsModule {} diff --git a/frontend/src/api/teamService.tsx b/frontend/src/api/teamService.tsx index acde8c4ff..fa12fe7ec 100644 --- a/frontend/src/api/teamService.tsx +++ b/frontend/src/api/teamService.tsx @@ -2,6 +2,7 @@ import { GetServerSidePropsContext } from 'next'; import { CreateTeamDto, Team } from 'types/team/team'; import fetchData from 'utils/fetchData'; +import { TeamUserUpdate } from '../types/team/team.user'; export const getAllTeams = (context?: GetServerSidePropsContext): Promise => { return fetchData(`/teams`, { context, serverSide: !!context }); @@ -18,3 +19,7 @@ export const createTeamRequest = (newTeam: CreateTeamDto): Promise => { export const getTeamRequest = (id: string, context?: GetServerSidePropsContext): Promise => { return fetchData(`/teams/${id}`, { context, serverSide: !!context }); }; + +export const updateTeamUserRequest = (team: TeamUserUpdate): Promise => { + return fetchData(`/teams/${team.team}`, { method: 'PUT', data: team }); +}; diff --git a/frontend/src/components/Teams/CreateTeam/CardMember/RoleSettings.tsx b/frontend/src/components/Teams/CreateTeam/CardMember/RoleSettings.tsx index 2a9b13ca5..ca910e620 100644 --- a/frontend/src/components/Teams/CreateTeam/CardMember/RoleSettings.tsx +++ b/frontend/src/components/Teams/CreateTeam/CardMember/RoleSettings.tsx @@ -4,93 +4,125 @@ import { Popover } from '@radix-ui/react-popover'; import { PopoverContent } from 'components/Primitives/Popover'; import Text from 'components/Primitives/Text'; +import useTeam from '../../../../hooks/useTeam'; import { membersListState } from '../../../../store/team/atom/team.atom'; +import { TeamUserUpdate } from '../../../../types/team/team.user'; import { TeamUserRoles } from '../../../../utils/enums/team.user.roles'; import Icon from '../../../icons/Icon'; import { PopoverCloseStyled, PopoverItemStyled, PopoverTriggerStyled } from './styles'; interface PopoverRoleSettingsProps { userId: string; + isTeamPage?: boolean; } -const PopoverRoleSettings: React.FC = React.memo(({ userId }) => { - const listMembers = useRecoilValue(membersListState); - const setListMembers = useSetRecoilState(membersListState); +const PopoverRoleSettings: React.FC = React.memo( + ({ userId, isTeamPage }) => { + const membersList = useRecoilValue(membersListState); + const setMembersList = useSetRecoilState(membersListState); - const selectRole = (value: TeamUserRoles) => { - const members = listMembers.map((member) => { - if (member.user._id === userId) { - return { ...member, role: value }; + const { + updateTeamUser: { mutate } + } = useTeam({ autoFetchTeam: false }); + + const selectRole = (value: TeamUserRoles) => { + const members = membersList.map((member) => { + return member.user._id === userId ? { ...member, role: value } : member; + }); + + setMembersList(members); + }; + + const updateUserRole = (value: TeamUserRoles) => { + const userFound = membersList.find((member) => member.user._id === userId); + + if (userFound && userFound.team) { + const updateTeamUser: TeamUserUpdate = { + team: userFound.team, + user: userId, + role: value, + isNewJoiner: userFound.isNewJoiner + }; + + mutate(updateTeamUser); } - return member; - }); + }; + + const handleSelectFunction = (role: TeamUserRoles) => { + return isTeamPage ? updateUserRole(role) : selectRole(role); + }; - setListMembers(members); - }; - return ( - - - + - + > + + - - - selectRole(TeamUserRoles.MEMBER)} - > - - Team Member - + + + { + handleSelectFunction(TeamUserRoles.MEMBER); + }} + > + + Team Member + - - The team member can create boards and can create teams. - - - - - selectRole(TeamUserRoles.ADMIN)} - > - - Team Admin - - - The team admin can nominate team admins / stakeholder and can - create/delete/edit team boards. - - - - - selectRole(TeamUserRoles.STAKEHOLDER)} - > - - Stakeholder - - - Stakeholders will not be included in sub-team SPLIT retrospectives. - - - - - - ); -}); + + The team member can create boards and can create teams. + + + + + { + handleSelectFunction(TeamUserRoles.ADMIN); + }} + > + + Team Admin + + + The team admin can nominate team admins / stakeholder and can + create/delete/edit team boards. + + + + + { + handleSelectFunction(TeamUserRoles.STAKEHOLDER); + }} + > + + Stakeholder + + + Stakeholders will not be included in sub-team SPLIT retrospectives. + + + + + + ); + } +); export default PopoverRoleSettings; diff --git a/frontend/src/components/Teams/CreateTeam/CardMember/index.tsx b/frontend/src/components/Teams/CreateTeam/CardMember/index.tsx index feeb1d4c3..def2a1ae4 100644 --- a/frontend/src/components/Teams/CreateTeam/CardMember/index.tsx +++ b/frontend/src/components/Teams/CreateTeam/CardMember/index.tsx @@ -74,7 +74,7 @@ const CardMember = React.memo( - {isTeamMemberOrStakeholder ? ( + {isTeamMemberOrStakeholder && isNewJoiner && ( New Joiner @@ -97,7 +97,8 @@ const CardMember = React.memo( - ) : ( + )} + {!isTeamMemberOrStakeholder && ( ( )} {isTeamPage && ( { return ( {!isTeamMemberOrStakeholder && !isTeamCreator && ( - + )} ); diff --git a/frontend/src/hooks/useTeam.tsx b/frontend/src/hooks/useTeam.tsx index 2e2dc7006..331d63dc1 100644 --- a/frontend/src/hooks/useTeam.tsx +++ b/frontend/src/hooks/useTeam.tsx @@ -1,7 +1,13 @@ import { useMutation, useQuery } from 'react-query'; import { ToastStateEnum } from 'utils/enums/toast-types'; -import { createTeamRequest, getAllTeams, getTeamRequest, getTeamsOfUser } from '../api/teamService'; +import { + createTeamRequest, + getAllTeams, + getTeamRequest, + getTeamsOfUser, + updateTeamUserRequest +} from '../api/teamService'; import UseTeamType from '../types/team/useTeam'; import useTeamUtils from './useTeamUtils'; @@ -10,7 +16,7 @@ interface AutoFetchProps { } const useTeam = ({ autoFetchTeam = false }: AutoFetchProps): UseTeamType => { - const { teamId, setToastState } = useTeamUtils(); + const { teamId, setToastState, membersList, setMembersList, queryClient } = useTeamUtils(); const fetchAllTeams = useQuery(['allTeams'], () => getAllTeams(), { enabled: autoFetchTeam, @@ -72,11 +78,40 @@ const useTeam = ({ autoFetchTeam = false }: AutoFetchProps): UseTeamType => { } }); + const updateTeamUser = useMutation(updateTeamUserRequest, { + onSuccess: (data) => { + queryClient.invalidateQueries('team'); + + // updates the membersList recoil + const members = membersList.map((member) => { + return member.user._id === data.user + ? { ...member, role: data.role, isNewJoiner: data.isNewJoiner } + : member; + }); + + setMembersList(members); + + setToastState({ + open: true, + content: 'The team user was successfully updated.', + type: ToastStateEnum.SUCCESS + }); + }, + onError: () => { + setToastState({ + open: true, + content: 'Error while updating the team user role', + type: ToastStateEnum.ERROR + }); + } + }); + return { fetchAllTeams, fetchTeamsOfUser, createTeam, - fetchTeam + fetchTeam, + updateTeamUser }; }; diff --git a/frontend/src/hooks/useTeamUtils.tsx b/frontend/src/hooks/useTeamUtils.tsx index 66d79cddc..9f3fe4394 100644 --- a/frontend/src/hooks/useTeamUtils.tsx +++ b/frontend/src/hooks/useTeamUtils.tsx @@ -1,9 +1,11 @@ import { QueryClient, useQueryClient } from 'react-query'; import { NextRouter, useRouter } from 'next/router'; import { useSession } from 'next-auth/react'; -import { SetterOrUpdater, useSetRecoilState } from 'recoil'; +import { SetterOrUpdater, useRecoilValue, useSetRecoilState } from 'recoil'; import { toastState } from 'store/toast/atom/toast.atom'; +import { membersListState } from '../store/team/atom/team.atom'; +import { TeamUser } from '../types/team/team.user'; import { ToastStateEnum } from '../utils/enums/toast-types'; type TeamUtilsType = { @@ -12,6 +14,8 @@ type TeamUtilsType = { queryClient: QueryClient; setToastState: SetterOrUpdater<{ open: boolean; type: ToastStateEnum; content: string }>; router: NextRouter; + membersList: TeamUser[]; + setMembersList: SetterOrUpdater; }; const useTeamUtils = (): TeamUtilsType => { @@ -25,6 +29,8 @@ const useTeamUtils = (): TeamUtilsType => { if (session) userId = session.user.id; const setToastState = useSetRecoilState(toastState); + const membersList = useRecoilValue(membersListState); + const setMembersList = useSetRecoilState(membersListState); const { teamId } = router.query; @@ -33,7 +39,9 @@ const useTeamUtils = (): TeamUtilsType => { teamId, queryClient, setToastState, - router + router, + membersList, + setMembersList }; }; diff --git a/frontend/src/types/team/team.user.ts b/frontend/src/types/team/team.user.ts index 767b13282..1463f8d3a 100644 --- a/frontend/src/types/team/team.user.ts +++ b/frontend/src/types/team/team.user.ts @@ -4,7 +4,7 @@ import { User } from '../user/user'; export interface TeamUser { user: User; role: TeamUserRoles; - isNewJoiner?: boolean; + isNewJoiner: boolean; _id?: string; team?: string; } @@ -14,3 +14,10 @@ export interface CreateTeamUser { role: TeamUserRoles; isNewJoiner?: boolean; } + +export interface TeamUserUpdate { + role: TeamUserRoles; + isNewJoiner: boolean; + user: string; + team: string; +} diff --git a/frontend/src/types/team/useTeam.ts b/frontend/src/types/team/useTeam.ts index 98022eebf..6f36e2092 100644 --- a/frontend/src/types/team/useTeam.ts +++ b/frontend/src/types/team/useTeam.ts @@ -1,10 +1,12 @@ import { UseMutationResult, UseQueryResult } from 'react-query'; import { CreateTeamDto, Team } from './team'; +import { TeamUserUpdate } from './team.user'; export default interface UseTeamType { createTeam: UseMutationResult; fetchAllTeams: UseQueryResult; fetchTeamsOfUser: UseQueryResult; fetchTeam: UseQueryResult; + updateTeamUser: UseMutationResult; }