Skip to content

Commit 0754b1c

Browse files
authored
Merge pull request #1346 from Northeastern-Electric-Racing/#1244-set-leads-endpoint
#1244: set teams lead
2 parents 47ead33 + 5075032 commit 0754b1c

8 files changed

Lines changed: 248 additions & 12 deletions

File tree

src/backend/src/controllers/teams.controllers.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,4 +62,16 @@ export default class TeamsController {
6262
next(error);
6363
}
6464
}
65+
66+
static async setTeamLeads(req: Request, res: Response, next: NextFunction) {
67+
try {
68+
const { userIds } = req.body;
69+
const { teamId } = req.params;
70+
const submitter = await getCurrentUser(res);
71+
const team = await TeamsService.setTeamLeads(submitter, teamId, userIds);
72+
return res.status(200).json(team);
73+
} catch (error: unknown) {
74+
next(error);
75+
}
76+
}
6577
}

src/backend/src/routes/teams.routes.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,13 @@ teamsRouter.post(
1515
validateInputs,
1616
TeamsController.setTeamMembers
1717
);
18+
teamsRouter.post(
19+
'/:teamId/set-leads',
20+
body('userIds').isArray(),
21+
intMinZero(body('userIds.*')),
22+
validateInputs,
23+
TeamsController.setTeamLeads
24+
);
1825
teamsRouter.post(
1926
'/:teamId/edit-description',
2027
body('newDescription').isString(),

src/backend/src/services/teams.services.ts

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -162,4 +162,53 @@ export default class TeamsService {
162162

163163
return teamTransformer(updateTeam);
164164
}
165+
166+
/**
167+
* Update the given teamId's team's leads
168+
* @param submitter a user who's making this request
169+
* @param teamId a id of team to be updated
170+
* @param userIds a array of user Ids that replaces team's old leads
171+
* @returns an updated team
172+
* @throws if the team is not found, the submitter has no privilege, or any user from the given userIds does not exist
173+
*/
174+
static async setTeamLeads(submitter: User, teamId: string, userIds: number[]): Promise<Team> {
175+
const team = await prisma.team.findUnique({
176+
where: { teamId },
177+
...teamQueryArgs
178+
});
179+
180+
const newLeads = await getUsers(userIds);
181+
182+
if (!team) throw new NotFoundException('Team', teamId);
183+
184+
if (!isAdmin(submitter.role) && submitter.userId !== team.headId) {
185+
throw new AccessDeniedException('You must be an admin or the head to update the lead!');
186+
}
187+
188+
if (newLeads.map((lead) => lead.userId).includes(team.headId)) {
189+
throw new HttpException(400, 'A lead cannot be the head of the team!');
190+
}
191+
192+
if (team.members.map((member) => member.userId).some((memberId) => userIds.includes(memberId))) {
193+
throw new HttpException(400, 'A lead cannot be a member of the team!');
194+
}
195+
196+
const transformedLeads = newLeads.map((lead) => {
197+
return {
198+
userId: lead.userId
199+
};
200+
});
201+
202+
const updateTeam = await prisma.team.update({
203+
where: { teamId },
204+
data: {
205+
leads: {
206+
set: transformedLeads
207+
}
208+
},
209+
...teamQueryArgs
210+
});
211+
212+
return teamTransformer(updateTeam);
213+
}
165214
}

src/backend/tests/teams.test.ts

Lines changed: 84 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,18 @@
11
import TeamsService from '../src/services/teams.services';
22
import prisma from '../src/prisma/prisma';
33
import * as teamsTransformer from '../src/transformers/teams.transformer';
4-
import { prismaTeam1, sharedTeam1, justiceLeague } from './test-data/teams.test-data';
4+
import { prismaTeam1, sharedTeam1, justiceLeague, primsaTeam2 } from './test-data/teams.test-data';
55
import teamQueryArgs from '../src/prisma-query-args/teams.query-args';
6-
import { batman, flash, greenlantern, superman, theVisitor, wonderwoman } from './test-data/users.test-data';
6+
import {
7+
alfred,
8+
aquaman,
9+
batman,
10+
flash,
11+
greenlantern,
12+
superman,
13+
theVisitor,
14+
wonderwoman
15+
} from './test-data/users.test-data';
716
import * as userUtils from '../src/utils/users.utils';
817
import { AccessDeniedException, HttpException } from '../src/utils/errors.utils';
918
import teamTransformer from '../src/transformers/teams.transformer';
@@ -208,4 +217,77 @@ describe('Teams', () => {
208217
expect(res).toStrictEqual(sharedTeam1);
209218
});
210219
});
220+
221+
describe('setTeamLeads', () => {
222+
test('setTeamLeads submitter is not the head or admin', async () => {
223+
vi.spyOn(prisma.team, 'findUnique').mockResolvedValue(prismaTeam1);
224+
vi.spyOn(prisma.user, 'findMany').mockResolvedValue([theVisitor]);
225+
vi.spyOn(prisma.team, 'findMany').mockResolvedValue([prismaTeam1, primsaTeam2, justiceLeague]);
226+
227+
const callSetTeamLeads = async () =>
228+
await TeamsService.setTeamLeads(wonderwoman, prismaTeam1.teamId, [theVisitor.userId]);
229+
230+
const expectedException = new HttpException(
231+
400,
232+
'Access Denied: You must be an admin or the head to update the lead!'
233+
);
234+
235+
await expect(callSetTeamLeads).rejects.toThrow(expectedException);
236+
});
237+
238+
test('setTeamLeads lead is a member', async () => {
239+
vi.spyOn(prisma.team, 'findUnique').mockResolvedValue(prismaTeam1);
240+
vi.spyOn(prisma.user, 'findMany').mockResolvedValue([aquaman]);
241+
vi.spyOn(prisma.team, 'findMany').mockResolvedValue([prismaTeam1, primsaTeam2, justiceLeague]);
242+
243+
const callSetTeamLeads = async () => await TeamsService.setTeamLeads(flash, sharedTeam1.teamId, [aquaman.userId]);
244+
245+
const expectedException = new HttpException(400, 'A lead cannot be a member of the team!');
246+
247+
await expect(callSetTeamLeads).rejects.toThrow(expectedException);
248+
});
249+
250+
test('setTeamLeads lead is a head', async () => {
251+
vi.spyOn(prisma.team, 'findUnique').mockResolvedValue(justiceLeague);
252+
vi.spyOn(userUtils, 'getUsers').mockResolvedValue([batman]);
253+
vi.spyOn(prisma.team, 'findMany').mockResolvedValue([prismaTeam1, primsaTeam2, justiceLeague]);
254+
255+
const callSetTeamLeads = async () => await TeamsService.setTeamLeads(alfred, justiceLeague.teamId, [batman.userId]);
256+
257+
const expectedException = new HttpException(400, 'A lead cannot be the head of the team!');
258+
259+
await expect(callSetTeamLeads).rejects.toThrow(expectedException);
260+
});
261+
262+
test('setTeamLeads works', async () => {
263+
vi.spyOn(prisma.team, 'findUnique').mockResolvedValue(prismaTeam1);
264+
vi.spyOn(prisma.team, 'update').mockResolvedValue(prismaTeam1);
265+
vi.spyOn(userUtils, 'getUsers').mockResolvedValue([greenlantern, theVisitor]);
266+
vi.spyOn(prisma.team, 'findMany').mockResolvedValue([prismaTeam1, primsaTeam2, justiceLeague]);
267+
268+
const teamId = 'id1';
269+
const userIds = [
270+
{
271+
userId: 5
272+
},
273+
{
274+
userId: 7
275+
}
276+
];
277+
const res = await TeamsService.setTeamLeads(flash, sharedTeam1.teamId, [5, 7]);
278+
279+
expect(prisma.team.findUnique).toHaveBeenCalledTimes(1);
280+
expect(prisma.team.update).toHaveBeenCalledTimes(1);
281+
expect(prisma.team.update).toHaveBeenCalledWith({
282+
where: { teamId },
283+
data: {
284+
leads: {
285+
set: userIds
286+
}
287+
},
288+
...teamQueryArgs
289+
});
290+
expect(res).toStrictEqual(sharedTeam1);
291+
});
292+
});
211293
});

src/frontend/src/apis/teams.api.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,3 +36,9 @@ export const setTeamHead = (id: string, userId: number) => {
3636
userId
3737
});
3838
};
39+
40+
export const setTeamLeads = (id: string, userIds: number[]) => {
41+
return axios.post<Team>(apiUrls.teamsSetLeads(id), {
42+
userIds
43+
});
44+
};

src/frontend/src/hooks/teams.hooks.ts

Lines changed: 24 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,14 @@
55

66
import { useQuery, useQueryClient, useMutation } from 'react-query';
77
import { Team } from 'shared';
8-
import { getAllTeams, getSingleTeam, setTeamMembers, setTeamDescription, setTeamHead } from '../apis/teams.api';
8+
import {
9+
getAllTeams,
10+
getSingleTeam,
11+
setTeamMembers,
12+
setTeamDescription,
13+
setTeamHead,
14+
setTeamLeads
15+
} from '../apis/teams.api';
916

1017
export const useAllTeams = () => {
1118
return useQuery<Team[], Error>(['teams'], async () => {
@@ -67,3 +74,19 @@ export const useEditTeamDescription = (teamId: string) => {
6774
}
6875
);
6976
};
77+
78+
export const useSetTeamLeads = (teamId: string) => {
79+
const queryClient = useQueryClient();
80+
return useMutation<Team, Error, any>(
81+
['teams', 'edit'],
82+
async (userIds: number[]) => {
83+
const { data } = await setTeamLeads(teamId, userIds);
84+
return data;
85+
},
86+
{
87+
onSuccess: () => {
88+
queryClient.invalidateQueries(['teams']);
89+
}
90+
}
91+
);
92+
};

src/frontend/src/pages/TeamsPage/TeamMembersPageBlock.tsx

Lines changed: 64 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -5,10 +5,9 @@
55

66
import { Autocomplete, Box, Grid, IconButton, TextField, Typography } from '@mui/material';
77
import { useState } from 'react';
8-
import { useAuth } from '../../hooks/auth.hooks';
9-
import { useAllUsers } from '../../hooks/users.hooks';
10-
import { useSetTeamHead, useSetTeamMembers } from '../../hooks/teams.hooks';
11-
import { isAdmin, isHead, Team, User } from 'shared';
8+
import { useAllUsers, useCurrentUser } from '../../hooks/users.hooks';
9+
import { useSetTeamHead, useSetTeamLeads, useSetTeamMembers } from '../../hooks/teams.hooks';
10+
import { isAdmin, isHead, isLeadership, Team, User } from 'shared';
1211
import { fullNamePipe } from '../../utils/pipes';
1312
import { Cancel, Edit, Save } from '@mui/icons-material';
1413
import LoadingIndicator from '../../components/LoadingIndicator';
@@ -27,22 +26,26 @@ const userToAutocompleteOption = (user: User): { label: string; id: string } =>
2726
};
2827

2928
const TeamMembersPageBlock: React.FC<TeamMembersPageBlockProps> = ({ team }) => {
30-
const auth = useAuth();
29+
const user = useCurrentUser();
3130
const [isEditingMembers, setIsEditingMembers] = useState(false);
3231
const [isEditingHead, setIsEditingHead] = useState(false);
32+
const [isEditingLeads, setIsEditingLeads] = useState(false);
3333
const [members, setMembers] = useState(team.members.map(userToAutocompleteOption));
3434
const [head, setHead] = useState(userToAutocompleteOption(team.head));
35+
const [leads, setLeads] = useState(team.leads.map(userToAutocompleteOption));
3536

3637
const { isLoading: allUsersIsLoading, isError: allUsersIsError, error: allUsersError, data: users } = useAllUsers();
3738
const { isLoading: setTeamMembersIsLoading, mutateAsync: setTeamMembersMutateAsync } = useSetTeamMembers(team.teamId);
3839
const { isLoading: setTeamHeadIsLoading, mutateAsync: setTeamHeadMutateAsync } = useSetTeamHead(team.teamId);
40+
const { isLoading: setTeamLeadsIsLoading, mutateAsync: setTeamLeadsMutateAsync } = useSetTeamLeads(team.teamId);
3941

4042
const toast = useToast();
4143

4244
if (allUsersIsError) return <ErrorPage message={allUsersError?.message} />;
43-
if (allUsersIsLoading || setTeamMembersIsLoading || setTeamHeadIsLoading || !users) return <LoadingIndicator />;
45+
if (allUsersIsLoading || setTeamMembersIsLoading || setTeamHeadIsLoading || setTeamLeadsIsLoading || !users)
46+
return <LoadingIndicator />;
4447

45-
const hasPerms = auth.user && (isAdmin(auth.user.role) || auth.user.userId === team.head.userId);
48+
const hasPerms = isAdmin(user.role) || user.userId === team.head.userId;
4649

4750
const memberOptions = users
4851
.filter((user) => user.userId !== team.head.userId && !team.leads.map((lead) => lead.userId).includes(user.userId))
@@ -54,6 +57,11 @@ const TeamMembersPageBlock: React.FC<TeamMembersPageBlockProps> = ({ team }) =>
5457
.sort((a, b) => (a.firstName > b.firstName ? 1 : -1))
5558
.map(userToAutocompleteOption);
5659

60+
const leadOptions = users
61+
.filter((user) => memberOptions.some((option) => parseInt(option.id) === user.userId) && isLeadership(user.role))
62+
.sort((a, b) => (a.firstName > b.firstName ? 1 : -1))
63+
.map(userToAutocompleteOption);
64+
5765
const submitHead = async () => {
5866
try {
5967
await setTeamHeadMutateAsync(parseInt(head.id));
@@ -72,6 +80,15 @@ const TeamMembersPageBlock: React.FC<TeamMembersPageBlockProps> = ({ team }) =>
7280
}
7381
};
7482

83+
const submitLeads = async () => {
84+
try {
85+
await setTeamLeadsMutateAsync(leads.map((lead) => parseInt(lead.id)));
86+
setIsEditingLeads(false);
87+
} catch (error) {
88+
error instanceof Error && toast.error(error.message);
89+
}
90+
};
91+
7592
const EditingHeadView = () => (
7693
<Grid container spacing={1}>
7794
<Grid item>
@@ -132,6 +149,37 @@ const TeamMembersPageBlock: React.FC<TeamMembersPageBlockProps> = ({ team }) =>
132149
</Box>
133150
);
134151

152+
const EditingLeadsView = () => (
153+
<Box>
154+
<Typography sx={{ fontWeight: 'bold' }} display="inline">
155+
Leads:
156+
</Typography>
157+
<Grid container direction={'row'}>
158+
<Grid item xs={9} md={10} lg={11}>
159+
<Autocomplete
160+
isOptionEqualToValue={(option, value) => option.id === value.id}
161+
filterSelectedOptions
162+
multiple
163+
id="tags-standard"
164+
options={leadOptions}
165+
value={leads}
166+
onChange={(_event, newValue) => setLeads(newValue)}
167+
getOptionLabel={(option) => option.label}
168+
renderInput={(params) => <TextField {...params} variant="standard" placeholder="Select A Lead" />}
169+
/>
170+
</Grid>
171+
<Grid container xs={3} md={2} lg={1}>
172+
<Grid item>
173+
<IconButton children={<Save />} onClick={submitLeads} />
174+
</Grid>
175+
<Grid item>
176+
<IconButton children={<Cancel />} onClick={() => setIsEditingLeads(false)} />
177+
</Grid>
178+
</Grid>
179+
</Grid>
180+
</Box>
181+
);
182+
135183
const NonEditingHeadView = () => (
136184
<Grid container>
137185
<Grid item>
@@ -144,7 +192,14 @@ const TeamMembersPageBlock: React.FC<TeamMembersPageBlockProps> = ({ team }) =>
144192
);
145193

146194
const NonEditingLeadsView = () => (
147-
<DetailDisplay label="Leads" content={team.leads.map((lead) => fullNamePipe(lead)).join(', ')} />
195+
<Grid container>
196+
<Grid item xs={11} lg="auto">
197+
<DetailDisplay label="Leads" content={team.leads.map((lead) => fullNamePipe(lead)).join(', ')} />
198+
</Grid>
199+
<Grid item xs={1} mt={-1}>
200+
{hasPerms && <IconButton children={<Edit />} onClick={() => setIsEditingLeads(true)} />}
201+
</Grid>
202+
</Grid>
148203
);
149204

150205
const NonEditingMembersView = () => (
@@ -161,7 +216,7 @@ const TeamMembersPageBlock: React.FC<TeamMembersPageBlockProps> = ({ team }) =>
161216
return (
162217
<PageBlock title={'People'}>
163218
{isEditingHead ? <EditingHeadView /> : <NonEditingHeadView />}
164-
<NonEditingLeadsView />
219+
{isEditingLeads ? <EditingLeadsView /> : <NonEditingLeadsView />}
165220
{isEditingMembers ? <EditingMembersView /> : <NonEditingMembersView />}
166221
</PageBlock>
167222
);

src/frontend/src/utils/urls.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,7 @@ const teamsById = (id: string) => `${teams()}/${id}`;
7272
const teamsSetMembers = (id: string) => `${teamsById(id)}/set-members`;
7373
const teamsSetHead = (id: string) => `${teamsById(id)}/set-head`;
7474
const teamsSetDescription = (id: string) => `${teamsById(id)}/edit-description`;
75+
const teamsSetLeads = (id: string) => `${teamsById(id)}/set-leads`;
7576

7677
/**************** Description Bullet Endpoints ****************/
7778
const descriptionBullets = () => `${API_URL}/description-bullets`;
@@ -156,6 +157,7 @@ export const apiUrls = {
156157
teamsSetMembers,
157158
teamsSetHead,
158159
teamsSetDescription,
160+
teamsSetLeads,
159161

160162
descriptionBulletsCheck,
161163

0 commit comments

Comments
 (0)