From 7ec1b6e6ce8cc852d1e2c7aca5eb34de4108bf71 Mon Sep 17 00:00:00 2001 From: Baptiste Studer Date: Thu, 6 Jul 2023 00:16:39 +0200 Subject: [PATCH 1/2] refactor(gameServices): Optimise find one --- .../src/modules/games/controllers/index.ts | 13 ++++- packages/server/src/modules/games/routes.ts | 6 +-- .../src/modules/games/services/index.ts | 53 ++++++------------- .../src/modules/games/services/register.ts | 2 +- packages/server/src/modules/teams/routes.ts | 2 +- 5 files changed, 32 insertions(+), 44 deletions(-) diff --git a/packages/server/src/modules/games/controllers/index.ts b/packages/server/src/modules/games/controllers/index.ts index 74ab911c..63440bc8 100644 --- a/packages/server/src/modules/games/controllers/index.ts +++ b/packages/server/src/modules/games/controllers/index.ts @@ -79,7 +79,18 @@ async function getGame(request: Request, response: Response) { id: z.string().regex(/^\d+$/).transform(Number), }); const { id } = paramsSchema.parse(request.params); - const document = await services.getDocument(id); + const document = await services.findOne( + { id }, + { + include: { + teams: { + include: { + players: true, + }, + }, + }, + } + ); response.status(200).json({ document }); } diff --git a/packages/server/src/modules/games/routes.ts b/packages/server/src/modules/games/routes.ts index fa085408..11b27ee2 100644 --- a/packages/server/src/modules/games/routes.ts +++ b/packages/server/src/modules/games/routes.ts @@ -30,7 +30,7 @@ router.put( roles: ["admin"], ownership: async (user, request) => { const gameId = parseInt(request.body.gameId, 10); - const game = await gameServices.getDocument(gameId); + const game = await gameServices.findOne({ id: gameId }); return { success: user.id === game?.teacherId, @@ -51,7 +51,7 @@ router.post( roles: ["admin"], ownership: async (user, request) => { const gameId = parseInt(request.body.gameId, 10); - const game = await gameServices.getDocument(gameId); + const game = await gameServices.findOne({ id: gameId }); return { success: user.id === game?.teacherId, @@ -82,7 +82,7 @@ router.put( roles: ["admin"], ownership: async (user, request) => { const gameId = parseInt(request.params.id, 10); - const game = await gameServices.getDocument(gameId); + const game = await gameServices.findOne({ id: gameId }); return { success: user.id === game?.teacherId, diff --git a/packages/server/src/modules/games/services/index.ts b/packages/server/src/modules/games/services/index.ts index 93702ce7..d24a48b6 100644 --- a/packages/server/src/modules/games/services/index.ts +++ b/packages/server/src/modules/games/services/index.ts @@ -10,7 +10,7 @@ type Model = Game; const crudServices = { queries: model, - getDocument, + findOne, getMany, create, update, @@ -19,43 +19,20 @@ const services = { ...crudServices, register }; export { services }; -async function getDocument( - idOrWhere: number | Prisma.GameFindFirstArgs["where"] -): Promise { - return model.findFirst({ - where: typeof idOrWhere === "number" ? { id: idOrWhere } : idOrWhere, - include: { - teams: { - where: { isDeleted: false }, - include: { - players: { - include: { - user: true, - actions: { - include: { - action: true, - }, - }, - profile: { - include: { - personalization: true, - }, - }, - }, - }, - actions: { - include: { - action: { - include: { - pointsIntervals: true, - }, - }, - }, - }, - }, - }, - }, - }); +async function findOne< + OptionsInclude extends Parameters[0]["include"] +>( + where: Parameters[0]["where"], + options: { + include?: OptionsInclude; + } = {} +): Promise | null> { + return model.findUnique({ + where, + ...options, + }) as any; } async function getMany(partial: Partial = {}): Promise { diff --git a/packages/server/src/modules/games/services/register.ts b/packages/server/src/modules/games/services/register.ts index 8e7c738f..8a21bcc5 100644 --- a/packages/server/src/modules/games/services/register.ts +++ b/packages/server/src/modules/games/services/register.ts @@ -13,7 +13,7 @@ async function register({ gameCode: string; userId: number; }): Promise { - const game = await gameServices.getDocument({ code: gameCode }); + const game = await gameServices.findOne({ code: gameCode }); if (!game) { throw createBusinessError("GAME_NOT_FOUND", { diff --git a/packages/server/src/modules/teams/routes.ts b/packages/server/src/modules/teams/routes.ts index c892389f..1b1484be 100644 --- a/packages/server/src/modules/teams/routes.ts +++ b/packages/server/src/modules/teams/routes.ts @@ -16,7 +16,7 @@ router.post( roles: ["admin"], ownership: async (user, request) => { const gameId = parseInt(request.body.gameId, 10); - const game = await services.getDocument(gameId); + const game = await services.findOne({ id: gameId }); return { success: user.id === game?.teacherId, From 11665e9d1b94b03f5d527e20ff676701070dd3b2 Mon Sep 17 00:00:00 2001 From: Baptiste Studer Date: Thu, 6 Jul 2023 00:38:54 +0200 Subject: [PATCH 2/2] feat(Games): Enable removing game --- .../Games/Game/services/mutations.ts | 33 ++-- .../modules/administration/Games/Games.tsx | 145 +++++++++++++----- .../components/Dialog/Dialog.styles.tsx | 2 +- .../modules/common/components/Icon/Icon.tsx | 4 + .../src/modules/common/hooks/useDialog.ts | 21 +++ .../translations/resources/fr/common.json | 4 + .../src/modules/games/controllers/index.ts | 13 ++ packages/server/src/modules/games/routes.ts | 15 ++ .../src/modules/games/services/index.ts | 54 +++++++ .../teamActions/services/teamActions.ts | 10 +- .../src/modules/teams/services/index.ts | 17 +- 11 files changed, 264 insertions(+), 54 deletions(-) create mode 100644 packages/client/src/modules/common/hooks/useDialog.ts diff --git a/packages/client/src/modules/administration/Games/Game/services/mutations.ts b/packages/client/src/modules/administration/Games/Game/services/mutations.ts index d96e7d16..13fdcda2 100644 --- a/packages/client/src/modules/administration/Games/Game/services/mutations.ts +++ b/packages/client/src/modules/administration/Games/Game/services/mutations.ts @@ -1,23 +1,36 @@ import { useMutation, useQueryClient } from "react-query"; import { http } from "../../../../../utils/request"; -export const useRemovePlayerMutation = (gameId: number) => { +export { useRemoveGameMutation, useRemovePlayerMutation }; + +const useRemoveGameMutation = () => { + const queryClient = useQueryClient(); + + const removeGameMutation = useMutation< + Response, + { message: string }, + { gameId: number } + >(({ gameId }) => http.delete(`/api/games/${gameId}`), { + onSuccess: () => { + queryClient.invalidateQueries(`games`); + }, + }); + + return { removeGameMutation }; +}; + +const useRemovePlayerMutation = (gameId: number) => { const queryClient = useQueryClient(); const removePlayerMutation = useMutation< Response, { message: string }, { userId: number } - >( - ({ userId }) => { - return http.post("/api/games/remove-player", { gameId, userId }); + >(({ userId }) => http.post("/api/games/remove-player", { gameId, userId }), { + onSuccess: () => { + queryClient.invalidateQueries(`/api/games/${gameId}/players`); }, - { - onSuccess: () => { - queryClient.invalidateQueries(`/api/games/${gameId}/players`); - }, - } - ); + }); return { removePlayerMutation }; }; diff --git a/packages/client/src/modules/administration/Games/Games.tsx b/packages/client/src/modules/administration/Games/Games.tsx index d5963e66..4206cf65 100644 --- a/packages/client/src/modules/administration/Games/Games.tsx +++ b/packages/client/src/modules/administration/Games/Games.tsx @@ -1,12 +1,11 @@ +import { Box, CircularProgress, Paper } from "@mui/material"; import { - Box, - Button, - CircularProgress, - Paper, - Typography, -} from "@mui/material"; -import AddBoxIcon from "@mui/icons-material/AddBox"; -import { DataGrid, GridColDef, GridRenderCellParams } from "@mui/x-data-grid"; + DataGrid, + GridActionsCellItem, + GridColumns, + GridRenderCellParams, + GridRowParams, +} from "@mui/x-data-grid"; import { useMutation, useQuery, useQueryClient } from "react-query"; import { useNavigate } from "react-router-dom"; import { ErrorAlert, SuccessAlert } from "../../alert"; @@ -15,6 +14,13 @@ import { IGameWithTeacher } from "../../../utils/types"; import { I18nTranslateFunction } from "../../translations"; import { useTranslation } from "../../translations/useTranslation"; import { http } from "../../../utils/request"; +import { Icon } from "../../common/components/Icon"; +import { useRemoveGameMutation } from "./Game/services/mutations"; +import { useCallback, useState } from "react"; +import { Dialog } from "../../common/components/Dialog"; +import { Button } from "../../common/components/Button"; +import { Typography } from "../../common/components/Typography"; +import { useDialog } from "../../common/hooks/useDialog"; export { Games }; @@ -23,7 +29,7 @@ function Games(): JSX.Element { return ( <> - + {t("page.admin.games.title")} @@ -34,9 +40,15 @@ function Games(): JSX.Element { ); } -const buildColumns: (args: { +const buildColumns = ({ + idOfGameBeingRemoved, + removeGame, + t, +}: { + idOfGameBeingRemoved?: number; + removeGame: (gameId: number) => void; t: I18nTranslateFunction; -}) => GridColDef[] = ({ t }) => [ +}): GridColumns => [ { field: "id", headerName: t("table.column.game-id.label"), width: 80 }, { field: "code", @@ -82,15 +94,50 @@ const buildColumns: (args: { flex: 1, minWidth: 150, }, + { + field: "actions", + type: "actions", + headerName: t("table.column.actions.label"), + width: 80, + getActions: (params: GridRowParams) => [ + } + label={`Delete game ${params.row.name}`} + onClick={() => removeGame(params.row.id)} + disabled={idOfGameBeingRemoved === params.row.id} + />, + ], + }, ]; function GamesDataGrid() { const navigate = useNavigate(); const { t } = useTranslation(); + const [idOfGameToRemove, setIdOfGameToRemove] = useState(null); + const { + isOpen: isRemoveGameDialogOpen, + openDialog: openRemoveGameDialog, + closeDialog: closeRemoveGameDialog, + } = useDialog(); const query = useQuery("games", () => { return http.get("/api/games"); }); + const { removeGameMutation } = useRemoveGameMutation(); + + const onRemoveGame = useCallback( + (gameId: number) => { + setIdOfGameToRemove(gameId); + openRemoveGameDialog(); + }, + [openRemoveGameDialog, setIdOfGameToRemove] + ); + + const removeGame = useCallback(() => { + if (idOfGameToRemove != null) { + removeGameMutation.mutate({ gameId: idOfGameToRemove }); + } + }, [idOfGameToRemove, removeGameMutation]); if (query.isLoading) { return ; @@ -99,26 +146,56 @@ function GamesDataGrid() { const rows: IGameWithTeacher[] = query?.data?.data?.documents ?? []; return ( - - { - const path = rowData.id - ? `/administration/games/${rowData.id}` - : "/administration/games/"; - return navigate(path); - }} + <> + + { + const path = rowData.id + ? `/administration/games/${rowData.id}` + : "/administration/games/"; + return navigate(path); + }} + /> + + + {t("modal.remove-game.title", { game: idOfGameToRemove })} + + } + actions={ + <> + + + + } + handleClose={closeRemoveGameDialog} /> - + ); } @@ -139,12 +216,8 @@ function NewGame() { <> {mutation.isError && } {mutation.isSuccess && } - ); diff --git a/packages/client/src/modules/common/components/Dialog/Dialog.styles.tsx b/packages/client/src/modules/common/components/Dialog/Dialog.styles.tsx index 85251f70..4006b960 100644 --- a/packages/client/src/modules/common/components/Dialog/Dialog.styles.tsx +++ b/packages/client/src/modules/common/components/Dialog/Dialog.styles.tsx @@ -15,7 +15,7 @@ const CustomDialogActions = styled(DialogActions)(({ theme }) => ({ padding: "12px 24px 16px", flexDirection: "row", alignItems: "center", - justifyContent: "center", + justifyContent: "space-evenly", }, "> *": { diff --git a/packages/client/src/modules/common/components/Icon/Icon.tsx b/packages/client/src/modules/common/components/Icon/Icon.tsx index 60f8ba97..d29d144f 100644 --- a/packages/client/src/modules/common/components/Icon/Icon.tsx +++ b/packages/client/src/modules/common/components/Icon/Icon.tsx @@ -27,12 +27,14 @@ import AutoGraphRoundedIcon from "@mui/icons-material/AutoGraphRounded"; import { SvgIconProps } from "@mui/material"; import PersonPinRounded from "@mui/icons-material/PersonPinRounded"; import { + Add, Badge, Bolt, Close, Computer, ConnectingAirports, ContentCopy, + Delete, DirectionsCar, DoNotDisturb, DryCleaning, @@ -75,7 +77,9 @@ const ICONS = { computer: Computer, consumption: ShoppingCart, copy: ContentCopy, + create: Add, draft: HistoryEdu, + delete: Delete, energy: Bolt, "form-draft": HistoryEdu, "form-pending-validation": SettingsSuggestIcon, diff --git a/packages/client/src/modules/common/hooks/useDialog.ts b/packages/client/src/modules/common/hooks/useDialog.ts new file mode 100644 index 00000000..49296b58 --- /dev/null +++ b/packages/client/src/modules/common/hooks/useDialog.ts @@ -0,0 +1,21 @@ +import { useCallback, useState } from "react"; + +export { useDialog }; + +function useDialog() { + const [isOpen, setIsOpen] = useState(false); + + const openDialog = useCallback(() => { + setIsOpen(true); + }, [setIsOpen]); + + const closeDialog = useCallback(() => { + setIsOpen(false); + }, [setIsOpen]); + + return { + isOpen, + closeDialog, + openDialog, + }; +} diff --git a/packages/client/src/modules/translations/resources/fr/common.json b/packages/client/src/modules/translations/resources/fr/common.json index d6ae1bae..729ed0b8 100644 --- a/packages/client/src/modules/translations/resources/fr/common.json +++ b/packages/client/src/modules/translations/resources/fr/common.json @@ -1,6 +1,8 @@ { "cta.assign-team-to-players": "Répartir les joueurs", + "cta.cancel": "Annuler", "cta.check-forms": "Vérifier les formulaires", + "cta.delete": "Supprimer", "cta.retrieve-emails": "Récupérer les emails", "cta.create-game": "Nouveau jeu", "cta.end-turn": "Terminer le tour", @@ -58,6 +60,7 @@ "message.success.game-joined": "Vous avez rejoint l'atelier avec succès", "message.success.sign-in.user-authenticated": "Connexion réussie", + "modal.remove-game.title": "Voulez-vous supprimer le jeu {{game}} ?", "modal.remove-player.title": "Voulez-vous supprimer le joueur ?", "modal.remove-team.title": "Voulez-vous supprimer l'équipe ?", "modal.validate-consumption-choices.title": "Les choix ne seront plus modifiables, souhaites-tu valider tes choix ?", @@ -72,6 +75,7 @@ "form.validation.yellow": "Les cases colorées en jaune contiennent les valeurs à vérifier.", "form.validation.heating": "Concernant le chauffage, si le joueur a donné deux valeurs (facture en € et consommation en kWh), il est nécessaire de vérifier que les deux valeurs correspondent. Si ce n’est pas le cas, merci de ne laisser que la valeur la plus probable et de supprimer l’autre.", + "table.column.actions.label": "Actions", "table.column.form-status.label": "Statut du formulaire", "table.column.form-status.label.short": "Statut", "table.column.game-code.label": "Code", diff --git a/packages/server/src/modules/games/controllers/index.ts b/packages/server/src/modules/games/controllers/index.ts index 63440bc8..40ad771e 100644 --- a/packages/server/src/modules/games/controllers/index.ts +++ b/packages/server/src/modules/games/controllers/index.ts @@ -28,6 +28,7 @@ const crudController = { getPlayersController, putPlayersInTeamsController, registerController, + removeGame, removePlayerController, removeTeamController, updateGame, @@ -145,3 +146,15 @@ async function prepareGameForLaunch(gameId: number) { await Promise.all(processingTeamActions); }); } + +async function removeGame(request: Request, response: Response) { + const paramsSchema = z.object({ + id: z.string().regex(/^\d+$/).transform(Number), + }); + + const { id } = paramsSchema.parse(request.params); + + await services.remove({ id }); + + response.status(200).json({}); +} diff --git a/packages/server/src/modules/games/routes.ts b/packages/server/src/modules/games/routes.ts index 11b27ee2..2bd6cc96 100644 --- a/packages/server/src/modules/games/routes.ts +++ b/packages/server/src/modules/games/routes.ts @@ -91,3 +91,18 @@ router.put( }), asyncErrorHandler(controllers.updateGame) ); +router.delete( + "/:id", + guardResource({ + roles: ["admin"], + ownership: async (user, request) => { + const gameId = parseInt(request.params.id, 10); + const game = await gameServices.findOne({ id: gameId }); + + return { + success: user.id === game?.teacherId, + }; + }, + }), + asyncErrorHandler(controllers.removeGame) +); diff --git a/packages/server/src/modules/games/services/index.ts b/packages/server/src/modules/games/services/index.ts index d24a48b6..1e2c40ec 100644 --- a/packages/server/src/modules/games/services/index.ts +++ b/packages/server/src/modules/games/services/index.ts @@ -2,8 +2,11 @@ import { Prisma } from "@prisma/client"; import { database } from "../../../database"; import { NO_TEAM } from "../../teams/constants/teams"; import { services as teamServices } from "../../teams/services"; +import { services as playerServices } from "../../players/services"; import { Game } from "../types"; import { register } from "./register"; +import { createBusinessError } from "../../utils/businessError"; +import { batchItems } from "../../../lib/array"; const model = database.game; type Model = Game; @@ -13,6 +16,7 @@ const crudServices = { findOne, getMany, create, + remove, update, }; const services = { ...crudServices, register }; @@ -54,3 +58,53 @@ async function update(id: number, document: Partial>) { }, }); } + +async function remove( + where: Parameters[0]["where"] +): Promise { + const BATCH_SIZE = 25; + + const game = await findOne(where, { + include: { + teams: { + select: { + id: true, + }, + }, + players: { + select: { + gameId: true, + userId: true, + }, + }, + }, + }); + if (!game) { + throw createBusinessError( + "GAME_NOT_FOUND", + where.code != null + ? { + prop: "code", + value: where.code, + } + : { + prop: "id", + value: where.id, + } + ); + } + + await batchItems(game?.players || [], BATCH_SIZE, async (playerBatch) => { + const processingPlayers = playerBatch.map(playerServices.remove); + await Promise.all(processingPlayers); + }); + + await batchItems(game?.teams || [], BATCH_SIZE, async (teamBatch) => { + const processingTeams = teamBatch.map(teamServices.remove); + await Promise.all(processingTeams); + }); + + await model.delete({ + where, + }); +} diff --git a/packages/server/src/modules/teamActions/services/teamActions.ts b/packages/server/src/modules/teamActions/services/teamActions.ts index 7f953832..cfd97a82 100644 --- a/packages/server/src/modules/teamActions/services/teamActions.ts +++ b/packages/server/src/modules/teamActions/services/teamActions.ts @@ -7,7 +7,7 @@ import * as productionActionsServices from "../../productionActions/services"; const model = database.teamActions; type Model = TeamActions; -export { model as queries, create, getMany, getOrCreateTeamActions }; +export { model as queries, create, getMany, getOrCreateTeamActions, remove }; async function create({ actionId, @@ -90,3 +90,11 @@ async function getOrCreateTeamActions(teamId: number) { return []; } } + +async function remove({ teamId }: { teamId: number }): Promise { + await model.deleteMany({ + where: { + teamId, + }, + }); +} diff --git a/packages/server/src/modules/teams/services/index.ts b/packages/server/src/modules/teams/services/index.ts index 3bf25aec..f2cc93cb 100644 --- a/packages/server/src/modules/teams/services/index.ts +++ b/packages/server/src/modules/teams/services/index.ts @@ -2,6 +2,7 @@ import { Prisma, Team } from "@prisma/client"; import { database } from "../../../database"; import { createBusinessError } from "../../utils/businessError"; import { NO_TEAM } from "../constants/teams"; +import * as teamActionServices from "../../teamActions/services"; const model = database.team; type Model = Team; @@ -85,15 +86,19 @@ async function remove({ id: teamId }: { id: number }) { }); } - const targetTeam = await database.team.findFirst({ + const targetTeam = await model.findFirst({ where: { gameId: teamToRemove.gameId, name: NO_TEAM }, }); - await database.players.updateMany({ - where: { teamId }, - data: { teamId: targetTeam?.id }, - }); + if (targetTeam) { + await database.players.updateMany({ + where: { teamId }, + data: { teamId: targetTeam.id }, + }); + } + + await teamActionServices.remove({ teamId }); - return database.team.delete({ + return model.delete({ where: { id: teamId }, }); }