Skip to content

Commit 19eebb8

Browse files
committed
Merge branch 'develop' into #1182-create-team-endpoint
2 parents 2e572a3 + c0356df commit 19eebb8

19 files changed

Lines changed: 364 additions & 75 deletions

File tree

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

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -73,4 +73,16 @@ export default class TeamsController {
7373
next(error);
7474
}
7575
}
76+
77+
static async setTeamLeads(req: Request, res: Response, next: NextFunction) {
78+
try {
79+
const { userIds } = req.body;
80+
const { teamId } = req.params;
81+
const submitter = await getCurrentUser(res);
82+
const team = await TeamsService.setTeamLeads(submitter, teamId, userIds);
83+
return res.status(200).json(team);
84+
} catch (error: unknown) {
85+
next(error);
86+
}
87+
}
7688
}

src/backend/src/prisma/seed.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import {
1212
Task_Priority,
1313
Task_Status,
1414
Team,
15+
Vendor,
1516
WBS_Element_Status
1617
} from '@prisma/client';
1718
import { dbSeedAllUsers } from './seed-data/users.seed';
@@ -684,6 +685,10 @@ const performSeed: () => Promise<void> = async () => {
684685
);
685686

686687
const vendor = await ReimbursementRequestService.createVendor(thomasEmrax, 'Tesla');
688+
const vendor2 = await ReimbursementRequestService.createVendor(thomasEmrax, 'Amazon');
689+
const vendor3 = await ReimbursementRequestService.createVendor(thomasEmrax, 'Google');
690+
691+
const vendors: Vendor[] = [vendor, vendor2, vendor3];
687692

688693
const expenseType = await ReimbursementRequestService.createExpenseType(thomasEmrax, 'Equipment', 123, true);
689694

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
@@ -217,4 +217,53 @@ export default class TeamsService {
217217

218218
return teamTransformer(createdTeam);
219219
}
220+
221+
/*
222+
* Update the given teamId's team's leads
223+
* @param submitter a user who's making this request
224+
* @param teamId a id of team to be updated
225+
* @param userIds a array of user Ids that replaces team's old leads
226+
* @returns an updated team
227+
* @throws if the team is not found, the submitter has no privilege, or any user from the given userIds does not exist
228+
*/
229+
static async setTeamLeads(submitter: User, teamId: string, userIds: number[]): Promise<Team> {
230+
const team = await prisma.team.findUnique({
231+
where: { teamId },
232+
...teamQueryArgs
233+
});
234+
235+
const newLeads = await getUsers(userIds);
236+
237+
if (!team) throw new NotFoundException('Team', teamId);
238+
239+
if (!isAdmin(submitter.role) && submitter.userId !== team.headId) {
240+
throw new AccessDeniedException('You must be an admin or the head to update the lead!');
241+
}
242+
243+
if (newLeads.map((lead) => lead.userId).includes(team.headId)) {
244+
throw new HttpException(400, 'A lead cannot be the head of the team!');
245+
}
246+
247+
if (team.members.map((member) => member.userId).some((memberId) => userIds.includes(memberId))) {
248+
throw new HttpException(400, 'A lead cannot be a member of the team!');
249+
}
250+
251+
const transformedLeads = newLeads.map((lead) => {
252+
return {
253+
userId: lead.userId
254+
};
255+
});
256+
257+
const updateTeam = await prisma.team.update({
258+
where: { teamId },
259+
data: {
260+
leads: {
261+
set: transformedLeads
262+
}
263+
},
264+
...teamQueryArgs
265+
});
266+
267+
return teamTransformer(updateTeam);
268+
}
220269
}

src/backend/src/utils/google-integration.utils.ts

Lines changed: 24 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,18 @@ export const sendMailToAdvisor = async (subject: string, text: string) => {
6666
}
6767
};
6868

69+
interface GoogleDriveErrorListError {
70+
domain: string;
71+
reason: string;
72+
message: string;
73+
}
74+
75+
interface GoogleDriveError {
76+
errors: GoogleDriveErrorListError[];
77+
code: number;
78+
message: string;
79+
}
80+
6981
//tutorial used to set this up: https://www.labnol.org/google-drive-api-upload-220412
7082
export const uploadFile = async (fileObject: Express.Multer.File) => {
7183
const bufferStream = new stream.PassThrough();
@@ -101,7 +113,18 @@ export const uploadFile = async (fileObject: Express.Multer.File) => {
101113
const { id, name } = response.data;
102114
return { id, name };
103115
} catch (error: unknown) {
104-
if (error instanceof Error) {
116+
if ((error as GoogleDriveError).errors) {
117+
const gError = error as GoogleDriveError;
118+
throw new HttpException(
119+
gError.code,
120+
`Failed to Upload Receipt(s): ${gError.message}, ${gError.errors.reduce(
121+
(acc: string, curr: GoogleDriveErrorListError) => {
122+
return acc + ' ' + curr.message + ' ' + curr.reason;
123+
},
124+
''
125+
)}`
126+
);
127+
} else if (error instanceof Error) {
105128
throw new HttpException(500, `Failed to Upload Receipt(s): ${error.message}`);
106129
}
107130
console.log('error' + error);

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
@@ -41,3 +41,9 @@ export const setTeamHead = (id: string, userId: number) => {
4141
export const createTeam = (payload: CreateTeamPayload) => {
4242
return axios.post<Team>(apiUrls.teamsCreate(), payload);
4343
};
44+
45+
export const setTeamLeads = (id: string, userIds: number[]) => {
46+
return axios.post<Team>(apiUrls.teamsSetLeads(id), {
47+
userIds
48+
});
49+
};

src/frontend/src/components/NERAutocomplete.tsx

Lines changed: 2 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -7,13 +7,11 @@ import {
77
Autocomplete,
88
AutocompleteRenderInputParams,
99
FormHelperText,
10-
InputAdornment,
1110
SxProps,
1211
TextField,
1312
Theme,
1413
useTheme
1514
} from '@mui/material';
16-
import SearchIcon from '@mui/icons-material/Search';
1715
import { HTMLAttributes } from 'react';
1816
import { FieldError } from 'react-hook-form';
1917

@@ -48,15 +46,8 @@ const NERAutocomplete: React.FC<NERAutocompleteProps> = ({
4846
height: '40px',
4947
backgroundColor: theme.palette.background.default,
5048
width: '100%',
51-
borderRadius: '25px',
5249
border: 0,
53-
'.MuiOutlinedInput-notchedOutline': {
54-
borderColor: 'black',
55-
borderRadius: '25px'
56-
},
57-
'&.Mui-focused .MuiOutlinedInput-notchedOutline': {
58-
borderColor: 'red'
59-
},
50+
borderColor: 'black',
6051
...sx
6152
};
6253

@@ -65,12 +56,7 @@ const NERAutocomplete: React.FC<NERAutocompleteProps> = ({
6556
<TextField
6657
{...params}
6758
InputProps={{
68-
...params.InputProps,
69-
startAdornment: (
70-
<InputAdornment position="start">
71-
<SearchIcon />
72-
</InputAdornment>
73-
)
59+
...params.InputProps
7460
}}
7561
placeholder={placeholder}
7662
required

src/frontend/src/components/ReactHookTextField.tsx

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,8 @@
44
*/
55
import { Control, Controller, FieldError } from 'react-hook-form';
66
import { TextField, InputAdornment, SxProps, Theme, SvgIconProps } from '@mui/material';
7+
import { kMaxLength } from 'buffer';
8+
import { countWords, isUnderWordCount } from 'shared';
79

810
interface ReactHookTextFieldProps {
911
name: string;
@@ -20,6 +22,7 @@ interface ReactHookTextFieldProps {
2022
rows?: number;
2123
endAdornment?: React.ReactElement;
2224
errorMessage?: FieldError;
25+
maxLength?: number;
2326
}
2427

2528
const ReactHookTextField: React.FC<ReactHookTextFieldProps> = ({
@@ -36,7 +39,8 @@ const ReactHookTextField: React.FC<ReactHookTextFieldProps> = ({
3639
multiline,
3740
rows,
3841
endAdornment,
39-
errorMessage
42+
errorMessage,
43+
maxLength
4044
}) => {
4145
const defaultRules = { required: true };
4246

@@ -70,10 +74,11 @@ const ReactHookTextField: React.FC<ReactHookTextFieldProps> = ({
7074
sx={sx}
7175
type={type}
7276
InputProps={inputProps}
77+
inputProps={maxLength ? { ...inputProps, maxLength: isUnderWordCount(value, maxLength) ? null : 0 } : undefined}
7378
multiline={multiline}
7479
rows={rows}
7580
error={!!errorMessage}
76-
helperText={errorMessage?.message}
81+
helperText={errorMessage ? errorMessage.message : maxLength ? `${countWords(value)}/300 words` : undefined}
7782
/>
7883
)}
7984
/>

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

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

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

1018
export interface CreateTeamPayload {
1119
teamName: string;
@@ -90,3 +98,19 @@ export const useCreateTeam = () => {
9098
}
9199
);
92100
};
101+
102+
export const useSetTeamLeads = (teamId: string) => {
103+
const queryClient = useQueryClient();
104+
return useMutation<Team, Error, any>(
105+
['teams', 'edit'],
106+
async (userIds: number[]) => {
107+
const { data } = await setTeamLeads(teamId, userIds);
108+
return data;
109+
},
110+
{
111+
onSuccess: () => {
112+
queryClient.invalidateQueries(['teams']);
113+
}
114+
}
115+
);
116+
};

0 commit comments

Comments
 (0)