Skip to content

Commit a7e998a

Browse files
authored
Merge pull request #1537 from Northeastern-Electric-Racing/#1183-delete-team-endpoint
#1183 Delete Team Endpoint
2 parents 94f182f + c8072cb commit a7e998a

9 files changed

Lines changed: 213 additions & 3 deletions

File tree

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

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -85,4 +85,15 @@ export default class TeamsController {
8585
next(error);
8686
}
8787
}
88+
89+
static async deleteTeam(req: Request, res: Response, next: NextFunction) {
90+
try {
91+
const { teamId } = req.params;
92+
const deleter = await getCurrentUser(res);
93+
await TeamsService.deleteTeam(deleter, teamId);
94+
return res.status(204).json({ message: `Successfully deleted team with id ${teamId}` });
95+
} catch (error: unknown) {
96+
next(error);
97+
}
98+
}
8899
}

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

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ teamsRouter.post(
2929
TeamsController.editDescription
3030
);
3131
teamsRouter.post('/:teamId/set-head', intMinZero(body('userId')), validateInputs, TeamsController.setTeamHead);
32+
teamsRouter.post('/:teamId/delete', TeamsController.deleteTeam);
3233
teamsRouter.post(
3334
'/create',
3435
nonEmptyString(body('teamName')),

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

Lines changed: 20 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,12 @@ import { User } from '@prisma/client';
33
import teamQueryArgs from '../prisma-query-args/teams.query-args';
44
import prisma from '../prisma/prisma';
55
import teamTransformer from '../transformers/teams.transformer';
6-
import { NotFoundException, AccessDeniedException, HttpException } from '../utils/errors.utils';
6+
import {
7+
NotFoundException,
8+
AccessDeniedException,
9+
HttpException,
10+
AccessDeniedAdminOnlyException
11+
} from '../utils/errors.utils';
712
import { getUsers } from '../utils/users.utils';
813
import { isUnderWordCount } from 'shared';
914

@@ -159,10 +164,23 @@ export default class TeamsService {
159164
},
160165
...teamQueryArgs
161166
});
162-
163167
return teamTransformer(updateTeam);
164168
}
165169

170+
/**
171+
* Hard deletes the team with the given teamId
172+
* @param deleter the user submitting this request
173+
* @param teamId the id of the team to be deleted
174+
*/
175+
static async deleteTeam(deleter: User, teamId: string): Promise<void> {
176+
const team = await prisma.team.findUnique({ where: { teamId }, ...teamQueryArgs });
177+
178+
if (!team) throw new NotFoundException('Team', teamId);
179+
if (!isAdmin(deleter.role)) throw new AccessDeniedAdminOnlyException('delete teams');
180+
181+
await prisma.team.delete({ where: { teamId }, ...teamQueryArgs });
182+
}
183+
166184
/**
167185
* Creates a new team in the database
168186
* @param submitter The submitter who is trying to create a new team

src/backend/tests/teams.test.ts

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -218,6 +218,42 @@ describe('Teams', () => {
218218
});
219219
});
220220

221+
describe('deleteTeam', () => {
222+
test('deleteTeam team not found', async () => {
223+
vi.spyOn(prisma.team, 'findUnique').mockResolvedValue(null);
224+
225+
const callDeleteTeam = async () => await TeamsService.deleteTeam(flash, 'fakeId');
226+
227+
const expectedException = new HttpException(404, 'Team with id: fakeId not found!');
228+
229+
await expect(callDeleteTeam).rejects.toThrow(expectedException);
230+
});
231+
232+
test('deleteTeam submitter is not admin', async () => {
233+
vi.spyOn(prisma.team, 'findUnique').mockResolvedValue(prismaTeam1);
234+
235+
const callDeleteTeam = async () => await TeamsService.deleteTeam(greenlantern, prismaTeam1.teamId);
236+
237+
const expectedException = new HttpException(
238+
403,
239+
'Access Denied: admin and app-admin only have the ability to delete teams'
240+
);
241+
242+
await expect(callDeleteTeam).rejects.toThrow(expectedException);
243+
});
244+
245+
test('deleteTeam works', async () => {
246+
vi.spyOn(prisma.team, 'findUnique').mockResolvedValue(prismaTeam1);
247+
vi.spyOn(prisma.team, 'delete').mockResolvedValue(prismaTeam1);
248+
249+
await TeamsService.deleteTeam(batman, prismaTeam1.teamId);
250+
251+
expect(prisma.team.findUnique).toHaveBeenCalledTimes(1);
252+
expect(prisma.team.delete).toHaveBeenCalledTimes(1);
253+
expect(prisma.team.delete).toHaveBeenCalledWith({ where: { teamId: prismaTeam1.teamId }, ...teamQueryArgs });
254+
});
255+
});
256+
221257
describe('setTeamLeads', () => {
222258
test('setTeamLeads submitter is not the head or admin', async () => {
223259
vi.spyOn(prisma.team, 'findUnique').mockResolvedValue(prismaTeam1);

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

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,10 @@ export const setTeamHead = (id: string, userId: number) => {
3838
});
3939
};
4040

41+
export const deleteTeam = (id: string) => {
42+
return axios.post<{ message: string }>(apiUrls.teamsDelete(id));
43+
};
44+
4145
export const createTeam = (payload: CreateTeamPayload) => {
4246
return axios.post<Team>(apiUrls.teamsCreate(), payload);
4347
};

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

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import {
1111
setTeamMembers,
1212
setTeamDescription,
1313
setTeamHead,
14+
deleteTeam,
1415
createTeam,
1516
setTeamLeads
1617
} from '../apis/teams.api';
@@ -83,6 +84,22 @@ export const useEditTeamDescription = (teamId: string) => {
8384
);
8485
};
8586

87+
export const useDeleteTeam = () => {
88+
const queryClient = useQueryClient();
89+
return useMutation<{ message: string }, Error, any>(
90+
['teams', 'delete'],
91+
async (teamId: string) => {
92+
const { data } = await deleteTeam(teamId);
93+
return data;
94+
},
95+
{
96+
onSuccess: () => {
97+
queryClient.invalidateQueries(['teams']);
98+
}
99+
}
100+
);
101+
};
102+
86103
export const useCreateTeam = () => {
87104
const queryClient = useQueryClient();
88105
return useMutation<Team, Error, CreateTeamPayload>(
Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,97 @@
1+
import { yupResolver } from '@hookform/resolvers/yup';
2+
import { useForm } from 'react-hook-form';
3+
import { useToast } from '../../hooks/toasts.hooks';
4+
import * as yup from 'yup';
5+
import { useDeleteTeam, useSingleTeam } from '../../hooks/teams.hooks';
6+
import { useHistory } from 'react-router-dom';
7+
import LoadingIndicator from '../../components/LoadingIndicator';
8+
import ErrorPage from '../ErrorPage';
9+
import NERFormModal from '../../components/NERFormModal';
10+
import { Typography, FormControl, FormLabel } from '@mui/material';
11+
import ReactHookTextField from '../../components/ReactHookTextField';
12+
13+
interface DeleteTeamInputs {
14+
teamName: string;
15+
}
16+
17+
interface DeleteTeamModalProps {
18+
teamId: string;
19+
showModal: boolean;
20+
onHide: () => void;
21+
}
22+
23+
const DeleteTeamModal: React.FC<DeleteTeamModalProps> = ({ teamId, showModal, onHide }: DeleteTeamModalProps) => {
24+
const { data: team, isLoading: teamIsLoading, isError: teamIsError, error: teamError } = useSingleTeam(teamId);
25+
const { isLoading: deleteIsLoading, isError: deleteIsError, error: deleteError, mutateAsync } = useDeleteTeam();
26+
const toast = useToast();
27+
const teamNameTester = (teamName: string | undefined) =>
28+
team !== undefined && teamName !== undefined && teamName.toLowerCase() === team.teamName.toLowerCase();
29+
const schema = yup.object().shape({
30+
teamName: yup.string().required().test('team-name-test', 'Team name does not match', teamNameTester)
31+
});
32+
const {
33+
handleSubmit,
34+
control,
35+
reset,
36+
formState: { errors, isValid }
37+
} = useForm({
38+
resolver: yupResolver(schema),
39+
defaultValues: {
40+
teamName: ''
41+
},
42+
mode: 'onChange'
43+
});
44+
const history = useHistory();
45+
46+
const handleConfirm = async ({ teamName }: DeleteTeamInputs) => {
47+
if (team?.teamName.toLowerCase() === teamName.toLowerCase()) {
48+
try {
49+
await mutateAsync(teamId);
50+
onHide();
51+
history.goBack();
52+
toast.success('Team deleted successfully!');
53+
} catch (e) {
54+
if (e instanceof Error) {
55+
toast.error(e.message);
56+
}
57+
}
58+
}
59+
};
60+
61+
if (!team || teamIsLoading || deleteIsLoading) return <LoadingIndicator />;
62+
if (teamIsError) return <ErrorPage message={teamError.message} />;
63+
if (deleteIsError) return <ErrorPage message={deleteError?.message} />;
64+
65+
return (
66+
<NERFormModal
67+
open={showModal}
68+
onHide={onHide}
69+
title={`Delete Team ${team.teamName}`}
70+
reset={() => reset({ teamName: '' })}
71+
handleUseFormSubmit={handleSubmit}
72+
onFormSubmit={handleConfirm}
73+
formId="delete-team-form"
74+
submitText="Delete"
75+
disabled={!isValid}
76+
showCloseButton
77+
>
78+
<Typography sx={{ marginBottom: '1rem' }}>Are you sure you want to delete Team {team.teamName}?</Typography>
79+
<Typography sx={{ fontWeight: 'bold' }}>This action cannot be undone!</Typography>
80+
<FormControl>
81+
<FormLabel sx={{ marginTop: '1rem', marginBottom: '1rem' }}>
82+
To confirm deletion, please type in the name of this team.
83+
</FormLabel>
84+
<ReactHookTextField
85+
control={control}
86+
name="teamName"
87+
errorMessage={errors.teamName}
88+
placeholder="Enter Team Name here"
89+
sx={{ width: 1 }}
90+
type="string"
91+
/>
92+
</FormControl>
93+
</NERFormModal>
94+
);
95+
};
96+
97+
export default DeleteTeamModal;

src/frontend/src/pages/TeamsPage/TeamSpecificPage.tsx

Lines changed: 25 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,12 @@ import ActiveProjectCardView from './ProjectCardsView';
99
import DescriptionPageBlock from './DescriptionPageBlock';
1010
import { routes } from '../../utils/routes';
1111
import PageLayout from '../../components/PageLayout';
12+
import { Delete } from '@mui/icons-material';
13+
import { NERButton } from '../../components/NERButton';
14+
import { useCurrentUser } from '../../hooks/users.hooks';
15+
import { isAdmin } from 'shared';
16+
import { useState } from 'react';
17+
import DeleteTeamModal from './DeleteTeamModal';
1218

1319
interface ParamTypes {
1420
teamId: string;
@@ -17,12 +23,29 @@ interface ParamTypes {
1723
const TeamSpecificPage: React.FC = () => {
1824
const { teamId } = useParams<ParamTypes>();
1925
const { isLoading, isError, data, error } = useSingleTeam(teamId);
26+
const user = useCurrentUser();
27+
const [showDeleteModal, setShowDeleteModal] = useState(false);
2028

2129
if (isError) return <ErrorPage message={error?.message} />;
2230
if (isLoading || !data) return <LoadingIndicator />;
2331

32+
const deleteButton = (
33+
<NERButton
34+
variant="contained"
35+
disabled={!isAdmin(user.role)}
36+
startIcon={<Delete />}
37+
onClick={() => setShowDeleteModal(true)}
38+
>
39+
Delete
40+
</NERButton>
41+
);
42+
2443
return (
25-
<PageLayout title={`Team ${data.teamName}`} previousPages={[{ name: 'Teams', route: routes.TEAMS }]}>
44+
<PageLayout
45+
headerRight={isAdmin(user.role) && deleteButton}
46+
title={`Team ${data.teamName}`}
47+
previousPages={[{ name: 'Teams', route: routes.TEAMS }]}
48+
>
2649
<Grid container spacing={2}>
2750
<Grid item xs={12}>
2851
<TeamMembersPageBlock team={data} />
@@ -40,6 +63,7 @@ const TeamSpecificPage: React.FC = () => {
4063
<DescriptionPageBlock team={data} />
4164
</Grid>
4265
</Grid>
66+
<DeleteTeamModal teamId={teamId} showModal={showDeleteModal} onHide={() => setShowDeleteModal(false)} />
4367
</PageLayout>
4468
);
4569
};

src/frontend/src/utils/urls.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,7 @@ const changeRequestRequestReviewer = (id: string) => changeRequestsById(id) + '/
6969
/**************** Teams Endpoints ****************/
7070
const teams = () => `${API_URL}/teams`;
7171
const teamsById = (id: string) => `${teams()}/${id}`;
72+
const teamsDelete = (id: string) => `${teamsById(id)}/delete`;
7273
const teamsSetMembers = (id: string) => `${teamsById(id)}/set-members`;
7374
const teamsSetHead = (id: string) => `${teamsById(id)}/set-head`;
7475
const teamsSetDescription = (id: string) => `${teamsById(id)}/edit-description`;
@@ -155,6 +156,7 @@ export const apiUrls = {
155156

156157
teams,
157158
teamsById,
159+
teamsDelete,
158160
teamsSetMembers,
159161
teamsSetHead,
160162
teamsSetDescription,

0 commit comments

Comments
 (0)