diff --git a/backend/eslint.config.mjs b/backend/eslint.config.mjs index d78d2d0..d4013fb 100644 --- a/backend/eslint.config.mjs +++ b/backend/eslint.config.mjs @@ -5,7 +5,7 @@ import tseslint from "typescript-eslint"; export default tseslint.config( { - ignores: ["node_modules", "dist", "./eslint.config.mjs"], + ignores: ["node_modules", "dist", "__mocks__", "./eslint.config.mjs"], }, eslint.configs.recommended, tseslint.configs.recommended, diff --git a/backend/prisma/migrations/20241127224521_add_season_type/migration.sql b/backend/prisma/migrations/20241127224521_add_season_type/migration.sql new file mode 100644 index 0000000..1e9dd66 --- /dev/null +++ b/backend/prisma/migrations/20241127224521_add_season_type/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE `Season` ADD COLUMN `type` ENUM('RANKED', 'PLAY_OFF', 'TOURNEY', 'CASUAL') NOT NULL DEFAULT 'RANKED'; diff --git a/backend/prisma/migrations/20260907033654_split_seasons_and_remove_game_type/migration.sql b/backend/prisma/migrations/20260907033654_split_seasons_and_remove_game_type/migration.sql new file mode 100644 index 0000000..1566296 --- /dev/null +++ b/backend/prisma/migrations/20260907033654_split_seasons_and_remove_game_type/migration.sql @@ -0,0 +1,74 @@ +-- Collect every game type used by each legacy season across both variants. +CREATE TEMPORARY TABLE `_SeasonGameType` ( + `seasonId` VARCHAR(191) NOT NULL, + `type` ENUM('RANKED', 'PLAY_OFF', 'TOURNEY', 'CASUAL') NOT NULL, + PRIMARY KEY (`seasonId`, `type`) +); + +INSERT IGNORE INTO `_SeasonGameType` (`seasonId`, `type`) +SELECT `seasonId`, `type` FROM `JapaneseGame`; + +INSERT IGNORE INTO `_SeasonGameType` (`seasonId`, `type`) +SELECT `seasonId`, `type` FROM `HongKongGame`; + +-- Prefer to keep ranked games attached to the original season. For seasons +-- without ranked games, keep one of their existing types on the original row. +CREATE TEMPORARY TABLE `_SeasonPrimaryType` AS +SELECT + `seasonId`, + CASE + WHEN SUM(`type` = 'RANKED') > 0 THEN 'RANKED' + ELSE MIN(`type`) + END AS `type` +FROM `_SeasonGameType` +GROUP BY `seasonId`; + +UPDATE `Season` AS `season` +INNER JOIN `_SeasonPrimaryType` AS `primaryType` + ON `primaryType`.`seasonId` = `season`.`id` +SET `season`.`type` = `primaryType`.`type`; + +-- Map every additional type to a deterministic cloned season. +CREATE TEMPORARY TABLE `_SeasonTypeMap` AS +SELECT + `gameType`.`seasonId` AS `oldSeasonId`, + `gameType`.`type`, + CASE + WHEN `gameType`.`type` = `primaryType`.`type` THEN `gameType`.`seasonId` + ELSE CONCAT('split_', MD5(CONCAT(`gameType`.`seasonId`, ':', `gameType`.`type`))) + END AS `newSeasonId` +FROM `_SeasonGameType` AS `gameType` +INNER JOIN `_SeasonPrimaryType` AS `primaryType` + ON `primaryType`.`seasonId` = `gameType`.`seasonId`; + +INSERT INTO `Season` (`id`, `name`, `type`, `startDate`, `endDate`) +SELECT + `mapping`.`newSeasonId`, + `season`.`name`, + `mapping`.`type`, + `season`.`startDate`, + `season`.`endDate` +FROM `_SeasonTypeMap` AS `mapping` +INNER JOIN `Season` AS `season` + ON `season`.`id` = `mapping`.`oldSeasonId` +WHERE `mapping`.`newSeasonId` <> `mapping`.`oldSeasonId`; + +UPDATE `JapaneseGame` AS `game` +INNER JOIN `_SeasonTypeMap` AS `mapping` + ON `mapping`.`oldSeasonId` = `game`.`seasonId` + AND `mapping`.`type` = `game`.`type` +SET `game`.`seasonId` = `mapping`.`newSeasonId`; + +UPDATE `HongKongGame` AS `game` +INNER JOIN `_SeasonTypeMap` AS `mapping` + ON `mapping`.`oldSeasonId` = `game`.`seasonId` + AND `mapping`.`type` = `game`.`type` +SET `game`.`seasonId` = `mapping`.`newSeasonId`; + +DROP TEMPORARY TABLE `_SeasonTypeMap`; +DROP TEMPORARY TABLE `_SeasonPrimaryType`; +DROP TEMPORARY TABLE `_SeasonGameType`; + +-- Game type is now owned exclusively by the season container. +ALTER TABLE `JapaneseGame` DROP COLUMN `type`; +ALTER TABLE `HongKongGame` DROP COLUMN `type`; diff --git a/backend/prisma/schema.prisma b/backend/prisma/schema.prisma index 8427b60..35fbc92 100644 --- a/backend/prisma/schema.prisma +++ b/backend/prisma/schema.prisma @@ -1,45 +1,45 @@ generator client { - provider = "prisma-client-js" + provider = "prisma-client-js" } datasource db { - provider = "mysql" - url = env("DATABASE_URL") + provider = "mysql" + url = env("DATABASE_URL") } enum GameType { - RANKED - PLAY_OFF - TOURNEY - CASUAL + RANKED + PLAY_OFF + TOURNEY + CASUAL } enum JapaneseTransactionType { - DEAL_IN - SELF_DRAW - DEAL_IN_PAO - SELF_DRAW_PAO - NAGASHI_MANGAN - INROUND_RYUUKYOKU + DEAL_IN + SELF_DRAW + DEAL_IN_PAO + SELF_DRAW_PAO + NAGASHI_MANGAN + INROUND_RYUUKYOKU } enum HongKongTransactionType { - DEAL_IN - SELF_DRAW - DEAL_IN_PAO - SELF_DRAW_PAO + DEAL_IN + SELF_DRAW + DEAL_IN_PAO + SELF_DRAW_PAO } enum GameStatus { - IN_PROGRESS - FINISHED + IN_PROGRESS + FINISHED } enum Wind { - EAST - SOUTH - WEST - NORTH + EAST + SOUTH + WEST + NORTH } model Player { @@ -62,118 +62,117 @@ model Player { } model JapanesePlayerGame { - id String @id @default(cuid()) - wind Wind - eloChange Float? - player Player @relation(fields: [playerId], references: [id]) - playerId String - chomboCount Int @default(0) - game JapaneseGame? @relation(fields: [gameId], references: [id], onDelete: Cascade) - gameId Int? + id String @id @default(cuid()) + wind Wind + eloChange Float? + player Player @relation(fields: [playerId], references: [id]) + playerId String + chomboCount Int @default(0) + game JapaneseGame? @relation(fields: [gameId], references: [id], onDelete: Cascade) + gameId Int? } model HongKongPlayerGame { - id String @id @default(cuid()) - wind Wind - eloChange Float? - player Player @relation(fields: [playerId], references: [id]) - playerId String - chomboCount Int @default(0) - game HongKongGame? @relation(fields: [gameId], references: [id]) - gameId Int? + id String @id @default(cuid()) + wind Wind + eloChange Float? + player Player @relation(fields: [playerId], references: [id]) + playerId String + chomboCount Int @default(0) + game HongKongGame? @relation(fields: [gameId], references: [id]) + gameId Int? } model Season { - id String @id @default(cuid()) - name String - startDate DateTime @default(now()) - endDate DateTime - japaneseGames JapaneseGame[] - hongKongGames HongKongGame[] + id String @id @default(cuid()) + name String + type GameType + startDate DateTime @default(now()) + endDate DateTime + japaneseGames JapaneseGame[] + hongKongGames HongKongGame[] } model JapaneseGame { - id Int @id @default(autoincrement()) - season Season @relation(fields: [seasonId], references: [id]) - seasonId String - status GameStatus - type GameType - createdAt DateTime @default(now()) - endedAt DateTime? - recordedBy Player @relation(fields: [recordedById], references: [id]) - recordedById String - rounds JapaneseRound[] - players JapanesePlayerGame[] + id Int @id @default(autoincrement()) + season Season @relation(fields: [seasonId], references: [id]) + seasonId String + status GameStatus + createdAt DateTime @default(now()) + endedAt DateTime? + recordedBy Player @relation(fields: [recordedById], references: [id]) + recordedById String + rounds JapaneseRound[] + players JapanesePlayerGame[] } model JapaneseRound { - id String @id @default(cuid()) - roundCount Int - roundWind Wind - roundNumber Int - bonus Int - startRiichiStickCount Int - endRiichiStickCount Int - game JapaneseGame @relation(fields: [gameId], references: [id], onDelete: Cascade) - gameId Int - player0Riichi Boolean @default(false) - player1Riichi Boolean @default(false) - player2Riichi Boolean @default(false) - player3Riichi Boolean @default(false) - player0Tenpai Boolean @default(false) - player1Tenpai Boolean @default(false) - player2Tenpai Boolean @default(false) - player3Tenpai Boolean @default(false) - transactions JapaneseTransaction[] + id String @id @default(cuid()) + roundCount Int + roundWind Wind + roundNumber Int + bonus Int + startRiichiStickCount Int + endRiichiStickCount Int + game JapaneseGame @relation(fields: [gameId], references: [id], onDelete: Cascade) + gameId Int + player0Riichi Boolean @default(false) + player1Riichi Boolean @default(false) + player2Riichi Boolean @default(false) + player3Riichi Boolean @default(false) + player0Tenpai Boolean @default(false) + player1Tenpai Boolean @default(false) + player2Tenpai Boolean @default(false) + player3Tenpai Boolean @default(false) + transactions JapaneseTransaction[] } model JapaneseTransaction { - id String @id @default(cuid()) - transactionType JapaneseTransactionType - player0ScoreChange Int @default(0) - player1ScoreChange Int @default(0) - player2ScoreChange Int @default(0) - player3ScoreChange Int @default(0) - han Int? - fu Int? - dora Int? - paoPlayerIndex Int? - round JapaneseRound @relation(fields: [roundId], references: [id], onDelete: Cascade) - roundId String + id String @id @default(cuid()) + transactionType JapaneseTransactionType + player0ScoreChange Int @default(0) + player1ScoreChange Int @default(0) + player2ScoreChange Int @default(0) + player3ScoreChange Int @default(0) + han Int? + fu Int? + dora Int? + paoPlayerIndex Int? + round JapaneseRound @relation(fields: [roundId], references: [id], onDelete: Cascade) + roundId String } model HongKongGame { - id Int @id @default(autoincrement()) - season Season @relation(fields: [seasonId], references: [id]) - seasonId String - status GameStatus - type GameType - createdAt DateTime @default(now()) - endedAt DateTime? - recordedBy Player @relation(fields: [recordedById], references: [id]) - recordedById String - rounds HongKongRound[] - players HongKongPlayerGame[] + id Int @id @default(autoincrement()) + season Season @relation(fields: [seasonId], references: [id]) + seasonId String + status GameStatus + createdAt DateTime @default(now()) + endedAt DateTime? + recordedBy Player @relation(fields: [recordedById], references: [id]) + recordedById String + rounds HongKongRound[] + players HongKongPlayerGame[] } model HongKongRound { - id String @id @default(cuid()) - roundCount Int - roundWind Wind - roundNumber Int - transactions HongKongTransaction[] - game HongKongGame @relation(fields: [gameId], references: [id], onDelete: Cascade) - gameId Int + id String @id @default(cuid()) + roundCount Int + roundWind Wind + roundNumber Int + transactions HongKongTransaction[] + game HongKongGame @relation(fields: [gameId], references: [id], onDelete: Cascade) + gameId Int } model HongKongTransaction { - id String @id @default(cuid()) - transactionType HongKongTransactionType - player0ScoreChange Int @default(0) - player1ScoreChange Int @default(0) - player2ScoreChange Int @default(0) - player3ScoreChange Int @default(0) - hand Int? - round HongKongRound @relation(fields: [roundId], references: [id], onDelete: Cascade) - roundId String + id String @id @default(cuid()) + transactionType HongKongTransactionType + player0ScoreChange Int @default(0) + player1ScoreChange Int @default(0) + player2ScoreChange Int @default(0) + player3ScoreChange Int @default(0) + hand Int? + round HongKongRound @relation(fields: [roundId], references: [id], onDelete: Cascade) + roundId String } diff --git a/backend/src/controllers/admin.controller.ts b/backend/src/controllers/admin.controller.ts index 17c9fe8..36b6b8f 100644 --- a/backend/src/controllers/admin.controller.ts +++ b/backend/src/controllers/admin.controller.ts @@ -1,12 +1,7 @@ import { Request, Response } from "express"; import { deletePlayer, findAllPlayers, updatePlayer } from "../services/player.service"; import createError from "http-errors"; -import { - createSeason, - deleteSeason, - findCurrentSeason, - updateSeason, -} from "../services/season.service"; +import { createSeason, deleteSeason, updateSeason } from "../services/season.service"; import { makeDummyAdmins } from "../services/admin.service"; import { playerSchema } from "../validation/player.validation"; import { createSeasonSchema, updateSeasonSchema } from "../validation/season.validation"; @@ -45,10 +40,6 @@ const deletePlayerHandler = async (req: Request, res: Response): Promise = }; const createSeasonHandler = async (req: Request, res: Response): Promise => { - if (await findCurrentSeason()) { - throw createError.Conflict("Season already in progress"); - } - const season = createSeasonSchema.parse(req.body?.season); const startDate = new Date(season.startDate); const endDate = new Date(season.endDate); @@ -56,7 +47,7 @@ const createSeasonHandler = async (req: Request, res: Response): Promise = throw createError.BadRequest("End date must be in the future"); } - const createdSeason = await createSeason(season.name, startDate, endDate); + const createdSeason = await createSeason(season.name, season.type, startDate, endDate); res.json({ ...createdSeason }); }; diff --git a/backend/src/controllers/game.controller.ts b/backend/src/controllers/game.controller.ts index 3c36750..ac5f56d 100644 --- a/backend/src/controllers/game.controller.ts +++ b/backend/src/controllers/game.controller.ts @@ -2,23 +2,16 @@ import { Request, Response } from "express"; import createError from "http-errors"; import { createGameSchema, + gameIdSchema, gameVariantSchema, setChomboSchema, } from "../validation/game.validation"; -import { getCurrentSeason } from "../services/season.service"; +import { seasonIdSchema } from "../validation/season.validation"; +import { findSeason } from "../services/season.service"; import { GameFilterArgs } from "../services/game/game.util"; import { createRoundForVariant, getGameService } from "../services/game/gameService.factory"; import { addGameListener, sendGameUpdate } from "../services/game/liveGame.service"; -import { GameStatus, GameType } from "@prisma/client"; - -const parseGameId = (value: unknown): number => { - const gameId = typeof value === "string" ? Number(value) : Number.NaN; - if (!Number.isSafeInteger(gameId) || gameId <= 0) { - throw createError.NotFound("Invalid game id"); - } - - return gameId; -}; +import { GameStatus } from "@prisma/client"; const getGamesHandler = async (req: Request, res: Response): Promise => { const gameVariant = gameVariantSchema.parse(req.params.gameVariant); @@ -30,7 +23,6 @@ const getGamesHandler = async (req: Request, res: Response): Promise => { req.query.playerIds === "" || typeof req.query.playerIds === "undefined" ? undefined : req.query.playerIds.toString().split(","), - gameType: GameType.RANKED, gameStatus: GameStatus.FINISHED, }; @@ -44,7 +36,7 @@ const respondWithGame = async ( res: Response, addListener: boolean, ): Promise => { - const id = parseGameId(req.params.id); + const id = gameIdSchema.parse(req.params.id); const gameVariant = gameVariantSchema.parse(req.params.gameVariant); const gameService = getGameService(gameVariant); @@ -81,11 +73,15 @@ const getLiveGamesHandler = async (req: Request, res: Response): Promise = const createGameHandler = async (req: Request, res: Response): Promise => { const gameVariant = gameVariantSchema.parse(req.params.gameVariant); - const { players, gameType } = createGameSchema.parse(req.body); - const season = await getCurrentSeason(); + const { players, seasonId } = createGameSchema.parse(req.body); + const season = await findSeason(seasonId); + const now = new Date(); + if (!season || season.startDate > now || season.endDate <= now) { + throw createError.BadRequest("Season is not active"); + } const gameService = getGameService(gameVariant); - const newGame = await gameService.createGame(gameType, players, req.player.id, season.id); + const newGame = await gameService.createGame(season, players, req.player.id); res.status(201).json({ id: newGame.id, @@ -94,7 +90,7 @@ const createGameHandler = async (req: Request, res: Response): Promise => const deleteGameHandler = async (req: Request, res: Response): Promise => { const gameVariant = gameVariantSchema.parse(req.params.gameVariant); - const gameId = parseGameId(req.params.id); + const gameId = gameIdSchema.parse(req.params.id); const gameService = getGameService(gameVariant); const game = await gameService.getGame(gameId); @@ -112,7 +108,7 @@ const deleteGameHandler = async (req: Request, res: Response): Promise => const submitGameHandler = async (req: Request, res: Response): Promise => { const gameVariant = gameVariantSchema.parse(req.params.gameVariant); - const gameId = parseGameId(req.params.id); + const gameId = gameIdSchema.parse(req.params.id); const gameService = getGameService(gameVariant); const game = await gameService.getGame(gameId); @@ -130,7 +126,7 @@ const submitGameHandler = async (req: Request, res: Response): Promise => const createRoundHandler = async (req: Request, res: Response): Promise => { const gameVariant = gameVariantSchema.parse(req.params.gameVariant); - const gameId = parseGameId(req.params.id); + const gameId = gameIdSchema.parse(req.params.id); const roundRequest: unknown = req.body?.roundRequest; const gameService = getGameService(gameVariant); @@ -153,7 +149,7 @@ const createRoundHandler = async (req: Request, res: Response): Promise => const deleteLastRoundHandler = async (req: Request, res: Response): Promise => { const gameVariant = gameVariantSchema.parse(req.params.gameVariant); - const gameId = parseGameId(req.params.id); + const gameId = gameIdSchema.parse(req.params.id); const gameService = getGameService(gameVariant); const game = await gameService.getGame(gameId); @@ -177,16 +173,16 @@ const deleteLastRoundHandler = async (req: Request, res: Response): Promise => { const gameVariant = gameVariantSchema.parse(req.params.gameVariant); - const seasonId = await getCurrentSeason(); + const seasonId = seasonIdSchema.parse(req.params.seasonId); const gameService = getGameService(gameVariant); - const newEloStats = await gameService.recalcSeason(seasonId.id); + const newEloStats = await gameService.recalcSeason(seasonId); res.status(201).json(newEloStats); }; const setChomboHandler = async (req: Request, res: Response): Promise => { const gameVariant = gameVariantSchema.parse(req.params.gameVariant); const { playerId, chomboCount } = setChomboSchema.parse(req.body); - const gameId = parseGameId(req.params.id); + const gameId = gameIdSchema.parse(req.params.id); const gameService = getGameService(gameVariant); const result = await gameService.setChombo(gameId, playerId, chomboCount); res.status(201).json(result); diff --git a/backend/src/controllers/player.controller.ts b/backend/src/controllers/player.controller.ts index 52d57e3..ba11613 100644 --- a/backend/src/controllers/player.controller.ts +++ b/backend/src/controllers/player.controller.ts @@ -17,7 +17,7 @@ import { } from "../services/player.service"; import { addAuthCookieToResponse, generateToken } from "../middleware/jwt"; import bcrypt from "bcryptjs"; -import { getCurrentSeason } from "../services/season.service"; +import { findSeason } from "../services/season.service"; import { STARTING_ELO } from "../services/game/game.util"; import { getGameService } from "../services/game/gameService.factory"; import { gameTypeSchema, gameVariantSchema } from "../validation/game.validation"; @@ -30,6 +30,14 @@ interface PlayerStatisticsParams { seasonId: string; } +const requireStringParam = (value: unknown, errorMessage: string): string => { + if (typeof value !== "string" || value.length === 0) { + throw createError.BadRequest(errorMessage); + } + + return value; +}; + const registerHandler = async (req: Request, res: Response): Promise => { const registerPlayerRequest = registerSchema.parse(req.body); const player = await createPlayer(registerPlayerRequest); @@ -103,20 +111,14 @@ const getQualifiedPlayersHandler = async (req: Request, res: Response): Promise< const getPlayerLeaderboardHandler = async (req: Request, res: Response): Promise => { const gameVariant = gameVariantSchema.parse(req.params.gameVariant); - const gameType = gameTypeSchema.parse(req.params.gameType); - let seasonId: string; - if (typeof req.query.seasonId === "string" && req.query.seasonId.length > 0) { - seasonId = req.query.seasonId; - } else { - if (typeof req.query.seasonId !== "undefined") { - throw createError.BadRequest("Invalid season id"); - } - const season = await getCurrentSeason(); - seasonId = season.id; + const seasonId = requireStringParam(req.query.seasonId, "Invalid season id"); + const season = await findSeason(seasonId); + if (!season) { + throw createError.NotFound("Season not found"); } const gameService = getGameService(gameVariant); - const playerElos = await gameService.getAllPlayerElos(seasonId, gameType); + const playerElos = await gameService.getAllPlayerElos(seasonId); playerElos.forEach((playerElo) => { playerElo.elo = Number(playerElo.elo) + STARTING_ELO; playerElo.gameCount = Number(playerElo.gameCount); diff --git a/backend/src/controllers/season.controller.ts b/backend/src/controllers/season.controller.ts index c0842a0..c2f474c 100644 --- a/backend/src/controllers/season.controller.ts +++ b/backend/src/controllers/season.controller.ts @@ -1,24 +1,12 @@ import { Request, Response } from "express"; -import { findAllSeasons, findCurrentSeason } from "../services/season.service"; -import createError from "http-errors"; +import { findAllSeasons, getCurrentSeasons } from "../services/season.service"; -const getCurrentSeasonHandler = async (_req: Request, res: Response): Promise => { - const season = await findCurrentSeason(); - if (!season) { - throw createError.NotFound("No season in progress"); - } - - res.status(200).json({ - id: season.id, - name: season.name, - startDate: season.startDate, - endDate: season.endDate, - }); +const getCurrentSeasonsHandler = async (_req: Request, res: Response): Promise => { + res.status(200).json(await getCurrentSeasons()); }; const getSeasonsHandler = async (_req: Request, res: Response): Promise => { - const seasons = await findAllSeasons(); - res.json(seasons); + res.json(await findAllSeasons()); }; -export { getCurrentSeasonHandler, getSeasonsHandler }; +export { getCurrentSeasonsHandler, getSeasonsHandler }; diff --git a/backend/src/routes/index.ts b/backend/src/routes/index.ts index 4fcd6eb..afe4387 100644 --- a/backend/src/routes/index.ts +++ b/backend/src/routes/index.ts @@ -38,12 +38,12 @@ import { updatePlayerHandler, updateSeasonHandler, } from "../controllers/admin.controller"; -import { getCurrentSeasonHandler, getSeasonsHandler } from "../controllers/season.controller"; +import { getCurrentSeasonsHandler, getSeasonsHandler } from "../controllers/season.controller"; import { checkInHandler, checkOutHandler, getStatusHandler, - getCheckedInPlayersHandler + getCheckedInPlayersHandler, } from "../controllers/checkin.controller"; const router: Router = Router(); @@ -66,7 +66,7 @@ router.post("/games/:gameVariant/:id/chombo", isAuthenticated, setChomboHandler) router.delete("/games/:gameVariant/:id/rounds", isAuthenticated, deleteLastRoundHandler); router.get("/players/qualified/:gameVariant/:gameType/names", getQualifiedPlayersHandler); -router.get("/players/qualified/:gameVariant/:gameType/leaderboard", getPlayerLeaderboardHandler); +router.get("/players/qualified/:gameVariant/leaderboard", getPlayerLeaderboardHandler); router.get("/players/current", isAuthenticated, getCurrentPlayerHandler); router.put("/players/current/settings", isAuthenticated, updateSettingsHandler); @@ -76,14 +76,14 @@ router.get( "/players/:playerId/:gameVariant/:seasonId/placement-history", getPlacementHistoryHandler, ); -router.get("/seasons/current", getCurrentSeasonHandler); +router.get("/seasons/current", getCurrentSeasonsHandler); router.get("/seasons", getSeasonsHandler); router.get("/admin/players", isAuthenticated, isAdmin, getPlayersHandler); router.put("/admin/players/:id", isAuthenticated, isAdmin, updatePlayerHandler); router.delete("/admin/players/:id", isAuthenticated, isAdmin, deletePlayerHandler); -router.put("/admin/recalc/:gameVariant/", isAuthenticated, isAdmin, recalcSeasonHandler); +router.put("/admin/recalc/:gameVariant/:seasonId", isAuthenticated, isAdmin, recalcSeasonHandler); router.put("/admin/removeQualification", isAuthenticated, isAdmin, removeQualificationHandler); router.post("/admin/seasons", isAuthenticated, isAdmin, createSeasonHandler); router.put("/admin/seasons/:id", isAuthenticated, isAdmin, updateSeasonHandler); diff --git a/backend/src/services/game/game.service.ts b/backend/src/services/game/game.service.ts index 9f5ee3f..b1ffa08 100644 --- a/backend/src/services/game/game.service.ts +++ b/backend/src/services/game/game.service.ts @@ -1,4 +1,4 @@ -import { GameStatus, GameType, Player, Prisma, Wind } from "@prisma/client"; +import { GameStatus, GameType, Player, Prisma, Season, Wind } from "@prisma/client"; import { checkPlayerListUnique, GameFilterArgs, @@ -23,7 +23,7 @@ interface GamePlayerWithPlayer { interface GameWithRelations { id: number; seasonId: string; - type: GameType; + season: Season; status: GameStatus; recordedById: string; createdAt: Date; @@ -34,7 +34,7 @@ interface GameWithRelations { interface MappedGame { id: number; - type: GameType; + season: Season; status: GameStatus; recordedById: string; createdAt: Date; @@ -64,7 +64,6 @@ interface PlayerGameEloUpdate { interface GameRecord { id: number; seasonId: string; - type: GameType; status: GameStatus; recordedById: string; createdAt: Date; @@ -126,10 +125,9 @@ abstract class GameService< } public async createGame( - gameType: GameType, + season: Pick, players: string[], recorderId: string, - seasonId: string, ): Promise { checkPlayerListUnique(players); @@ -145,7 +143,7 @@ abstract class GameService< return player; }); // Throws error if the player is not eligible for the game type - if (gameType === GameType.RANKED) { + if (season.type !== GameType.CASUAL) { for (const player of foundPlayers) { if (!this.isEligible(player)) { throw new InvalidGameInputError("Player not eligible for game type"); @@ -157,10 +155,9 @@ abstract class GameService< data: { season: { connect: { - id: seasonId, + id: season.id, }, }, - type: gameType, status: GameStatus.IN_PROGRESS, recordedBy: { connect: { @@ -187,6 +184,7 @@ abstract class GameService< id: id, }, include: { + season: true, players: { include: { player: true, @@ -215,6 +213,7 @@ abstract class GameService< return this.gameDatabase.findMany({ where: whereQuery, include: { + season: true, players: { include: { player: true, @@ -271,7 +270,7 @@ abstract class GameService< }, {}); return { id: game.id, - type: game.type, + season: game.season, status: game.status, recordedById: game.recordedById, createdAt: game.createdAt, @@ -300,7 +299,7 @@ abstract class GameService< }, }); } - const eloDict = await this.getSelectedPlayerElos(game.seasonId, game.players, game.type); + const eloDict = await this.getSelectedPlayerElos(game.seasonId, game.players); return this.getEloDeltas(game.players, playerScores, eloDict); } @@ -323,10 +322,7 @@ abstract class GameService< } abstract getNextRound(game: TGame): TNextRound; - public async getAllPlayerElos( - seasonId: string, - gameType: GameType, - ): Promise { + public async getAllPlayerElos(seasonId: string): Promise { const result = await this.playerGameDatabase.groupBy({ by: "playerId", _sum: { @@ -340,7 +336,6 @@ abstract class GameService< game: { seasonId: seasonId, status: GameStatus.FINISHED, - type: gameType, }, }, }); @@ -369,7 +364,6 @@ abstract class GameService< public async getSelectedPlayerElos( seasonId: string, playerGames: Pick[], - gameType: GameType, ): Promise { const playerIds: string[] = playerGames.map((playerGame) => playerGame.playerId); const dbResult = await this.playerGameDatabase.groupBy< @@ -383,7 +377,6 @@ abstract class GameService< game: { seasonId: seasonId, status: GameStatus.FINISHED, - type: gameType, }, playerId: { in: playerIds, @@ -448,7 +441,6 @@ abstract class GameService< public async recalcSeason(seasonId: string): Promise { const finishedGames = await this.getGames({ seasonId: seasonId, - gameType: GameType.RANKED, gameStatus: GameStatus.FINISHED, }); finishedGames.sort((a, b) => { diff --git a/backend/src/services/game/game.util.ts b/backend/src/services/game/game.util.ts index 45a350d..85f0fd4 100644 --- a/backend/src/services/game/game.util.ts +++ b/backend/src/services/game/game.util.ts @@ -1,10 +1,9 @@ -import { GameStatus, GameType, Player, Wind } from "@prisma/client"; +import { GameStatus, Player, Wind } from "@prisma/client"; import { Transaction } from "../../validation/game.validation"; import { InvalidGameInputError } from "../../errors/domain.error"; interface GameFilterArgs { seasonId?: string; playerIds?: string[]; - gameType?: GameType; gameStatus?: GameStatus; } @@ -37,7 +36,6 @@ const generatePlayerQuery = (playerList: Player[]) => { const generateGameQuery = (filter: GameFilterArgs) => { const query: { seasonId?: string; - type?: GameType; status?: GameStatus; AND?: { players: { some: { playerId: string } } }[]; } = {}; @@ -55,9 +53,6 @@ const generateGameQuery = (filter: GameFilterArgs) => { }; }); } - if (typeof filter.gameType !== "undefined") { - query.type = filter.gameType; - } if (typeof filter.gameStatus !== "undefined") { query.status = filter.gameStatus; } diff --git a/backend/src/services/game/hongKongGame.service.ts b/backend/src/services/game/hongKongGame.service.ts index c5f4c2a..04f2487 100644 --- a/backend/src/services/game/hongKongGame.service.ts +++ b/backend/src/services/game/hongKongGame.service.ts @@ -20,6 +20,7 @@ import { GameService } from "./game.service"; type FullHongKongGame = Prisma.HongKongGameGetPayload<{ include: { + season: true; players: { include: { player: true; diff --git a/backend/src/services/game/japaneseGame.service.ts b/backend/src/services/game/japaneseGame.service.ts index b384626..d38b3e1 100644 --- a/backend/src/services/game/japaneseGame.service.ts +++ b/backend/src/services/game/japaneseGame.service.ts @@ -26,6 +26,7 @@ import { getJapaneseEloChanges } from "./japaneseEloCalculator"; type FullJapaneseGame = Prisma.JapaneseGameGetPayload<{ include: { + season: true; players: { include: { player: true; @@ -269,6 +270,7 @@ class JapaneseGameService extends GameService< }, }, include: { + season: true, players: { include: { player: true, diff --git a/backend/src/services/season.service.ts b/backend/src/services/season.service.ts index cfdf893..654ed3c 100644 --- a/backend/src/services/season.service.ts +++ b/backend/src/services/season.service.ts @@ -1,23 +1,26 @@ -import { Prisma, Season } from "@prisma/client"; +import { GameType, Prisma, Season } from "@prisma/client"; import prisma from "../db"; import { NoCurrentSeasonError } from "../errors/domain.error"; -const findCurrentSeason = async (): Promise => { - const seasons: Season[] = await prisma.season.findMany({ - orderBy: { - endDate: Prisma.SortOrder.desc, +const getCurrentSeasons = async (type?: GameType): Promise => { + const now = new Date(); + return prisma.season.findMany({ + where: { + startDate: { lte: now }, + endDate: { gt: now }, + ...(type ? { type } : {}), }, + orderBy: { endDate: Prisma.SortOrder.desc }, }); +}; - if (seasons.length === 0 || seasons[0].endDate < new Date()) { - return null; - } - - return seasons[0]; +const findCurrentSeason = async (type?: GameType): Promise => { + const seasons = await getCurrentSeasons(type); + return seasons[0] ?? null; }; -const getCurrentSeason = async (): Promise => { - const season = await findCurrentSeason(); +const getCurrentSeason = async (type?: GameType): Promise => { + const season = await findCurrentSeason(type); if (!season) { throw new NoCurrentSeasonError(); } @@ -25,6 +28,10 @@ const getCurrentSeason = async (): Promise => { return season; }; +const findSeason = async (id: string): Promise => { + return prisma.season.findUnique({ where: { id } }); +}; + const findAllSeasons = async (): Promise => { return prisma.season.findMany({ orderBy: { @@ -35,19 +42,21 @@ const findAllSeasons = async (): Promise => { const createSeason = async ( seasonName: string, + type: GameType, startDate: Date, endDate: Date, ): Promise => { return prisma.season.create({ data: { name: seasonName, + type: type, startDate: startDate, endDate: endDate, }, }); }; -const updateSeason = async (season: Season): Promise => { +const updateSeason = async (season: Omit): Promise => { return prisma.season.update({ where: { id: season.id, @@ -69,8 +78,10 @@ const deleteSeason = async (id: string): Promise => { }; export { + getCurrentSeasons, findCurrentSeason, getCurrentSeason, + findSeason, findAllSeasons, createSeason, updateSeason, diff --git a/backend/src/test/controllers/game.controller.test.ts b/backend/src/test/controllers/game.controller.test.ts new file mode 100644 index 0000000..8139186 --- /dev/null +++ b/backend/src/test/controllers/game.controller.test.ts @@ -0,0 +1,56 @@ +import { Request, Response } from "express"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { ZodError } from "zod"; +import { recalcSeasonHandler } from "../../controllers/game.controller"; +import { getGameService } from "../../services/game/gameService.factory"; + +vi.mock("../../services/game/gameService.factory", () => ({ + getGameService: vi.fn(), +})); +vi.mock("../../services/season.service", () => ({ findSeason: vi.fn() })); +vi.mock("../../services/game/liveGame.service", () => ({ + addGameListener: vi.fn(), + sendGameUpdate: vi.fn(), +})); + +describe("recalcSeasonHandler", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it.each([undefined, "", " ", ["season-id"], { id: "season-id" }])( + "rejects an invalid or missing seasonId: %j", + async (seasonId) => { + const req = { + params: { gameVariant: "jp", seasonId }, + query: { seasonId: "query-season" }, + } as unknown as Request; + + await expect(recalcSeasonHandler(req, {} as Response)).rejects.toBeInstanceOf(ZodError); + expect(getGameService).not.toHaveBeenCalled(); + }, + ); + + it.each(["hk", "jp"])( + "recalculates the explicitly requested season for %s", + async (gameVariant) => { + const result = { eloDict: {}, orderedGames: [], debugStats: [] }; + const recalcSeason = vi.fn().mockResolvedValue(result); + vi.mocked(getGameService).mockReturnValue({ recalcSeason } as unknown as ReturnType< + typeof getGameService + >); + const req = { + params: { gameVariant, seasonId: "previous-season" }, + query: { seasonId: "query-season" }, + } as unknown as Request; + const res = { status: vi.fn().mockReturnThis(), json: vi.fn() }; + + await recalcSeasonHandler(req, res as unknown as Response); + + expect(getGameService).toHaveBeenCalledWith(gameVariant); + expect(recalcSeason).toHaveBeenCalledWith("previous-season"); + expect(res.status).toHaveBeenCalledWith(201); + expect(res.json).toHaveBeenCalledWith(result); + }, + ); +}); diff --git a/backend/src/test/services/GameService/game.service.test.common.ts b/backend/src/test/services/GameService/game.service.test.common.ts index 2ff0884..8f82359 100644 --- a/backend/src/test/services/GameService/game.service.test.common.ts +++ b/backend/src/test/services/GameService/game.service.test.common.ts @@ -3,6 +3,7 @@ import { initialise } from "../util"; import { getGameService } from "../../../services/game/gameService.factory"; import { GameStatus, GameType, Wind } from "@prisma/client"; import type { GameVariant } from "../../../validation/game.validation"; +import prisma from "../../../db"; export function testGameServiceCommon(gameVariant: GameVariant) { return describe("Common Game Service Tests", () => { @@ -13,10 +14,9 @@ export function testGameServiceCommon(gameVariant: GameVariant) { }); it("should start a game and a round", async () => { const ret = await gameService.createGame( - GameType.RANKED, + initState.season, ["testUser1", "testUser2", "testUser3", "testUser4"], "test1", - initState.season.id, ); const id = ret.id; expect(ret).toMatchObject({ @@ -24,7 +24,6 @@ export function testGameServiceCommon(gameVariant: GameVariant) { recordedById: "test1", seasonId: initState.season.id, status: GameStatus.IN_PROGRESS, - type: GameType.RANKED, }); const fullGame = await gameService.getGameOrThrow(id); const mappedGame = await gameService.mapGameObject(fullGame); @@ -59,9 +58,29 @@ export function testGameServiceCommon(gameVariant: GameVariant) { ], recordedById: "test1", rounds: [], + season: initState.season, status: GameStatus.IN_PROGRESS, - type: GameType.RANKED, }); }); + + it("requires qualification for every competitive season type", async () => { + await prisma.player.update({ + where: { id: "test1" }, + data: + gameVariant === "jp" + ? { japaneseQualified: false } + : { hongKongQualified: false }, + }); + + const createCompetitiveGame = gameService.createGame( + { ...initState.season, type: GameType.TOURNEY }, + ["testUser1", "testUser2", "testUser3", "testUser4"], + "test2", + ); + + await expect(createCompetitiveGame).rejects.toThrow( + "Player not eligible for game type", + ); + }); }); } diff --git a/backend/src/test/services/season.service.test.ts b/backend/src/test/services/season.service.test.ts new file mode 100644 index 0000000..945979a --- /dev/null +++ b/backend/src/test/services/season.service.test.ts @@ -0,0 +1,57 @@ +import { GameType } from "@prisma/client"; +import { afterAll, beforeAll, describe, expect, it, vi } from "vitest"; + +import { + createSeason, + findCurrentSeason, + findSeason, + getCurrentSeasons, +} from "../../services/season.service"; + +vi.mock("@prisma/client"); + +describe("season service", () => { + const now = new Date("2026-09-06T12:00:00Z"); + + beforeAll(() => { + vi.useFakeTimers(); + vi.setSystemTime(now); + }); + + afterAll(() => { + vi.useRealTimers(); + }); + + it("returns only active seasons and can filter them by type", async () => { + const ranked = await createSeason( + "Ranked", + GameType.RANKED, + new Date("2026-09-01T00:00:00Z"), + new Date("2026-09-10T00:00:00Z"), + ); + const casual = await createSeason( + "Casual", + GameType.CASUAL, + new Date("2026-09-01T00:00:00Z"), + new Date("2026-09-20T00:00:00Z"), + ); + await createSeason( + "Expired", + GameType.RANKED, + new Date("2026-08-01T00:00:00Z"), + new Date("2026-09-01T00:00:00Z"), + ); + await createSeason( + "Future", + GameType.RANKED, + new Date("2026-09-07T00:00:00Z"), + new Date("2026-09-30T00:00:00Z"), + ); + + const currentSeasonIds = (await getCurrentSeasons()).map((season) => season.id); + expect(currentSeasonIds).toHaveLength(2); + expect(currentSeasonIds).toEqual(expect.arrayContaining([casual.id, ranked.id])); + expect(await findCurrentSeason(GameType.RANKED)).toMatchObject({ id: ranked.id }); + expect(await findSeason(casual.id)).toMatchObject({ type: GameType.CASUAL }); + }); +}); diff --git a/backend/src/test/services/util.ts b/backend/src/test/services/util.ts index fee6832..efe0039 100644 --- a/backend/src/test/services/util.ts +++ b/backend/src/test/services/util.ts @@ -50,7 +50,7 @@ export async function createTestPlayers() { } export async function initialise() { const players = await createTestPlayers(); - const season = await createSeason("testSeason", new Date(), new Date()); + const season = await createSeason("testSeason", GameType.RANKED, new Date(), new Date()); return { players, season }; } @@ -68,10 +68,9 @@ export async function initialiseGame< recordingPlayerID = "test1", ) { const initGame = await gameService.createGame( - GameType.RANKED, + { id: seasonID, type: GameType.RANKED }, playerUsernames, recordingPlayerID, - seasonID, ); await gameService.updateGame(initGame.id, state); return gameService.getGameOrThrow(initGame.id); diff --git a/backend/src/test/validation/game.validation.test.ts b/backend/src/test/validation/game.validation.test.ts index 0d8b8dc..bd7ea9d 100644 --- a/backend/src/test/validation/game.validation.test.ts +++ b/backend/src/test/validation/game.validation.test.ts @@ -1,11 +1,43 @@ import { JapaneseTransactionType } from "@prisma/client"; import { describe, expect, it } from "vitest"; -import { JapaneseTransactionSchema } from "../../validation/game.validation"; +import { + createGameSchema, + gameIdSchema, + JapaneseTransactionSchema, +} from "../../validation/game.validation"; const scoreDeltas = [-1000, 0, 1000, 0]; const hand = { dora: 0, fu: 30, han: 1 }; +describe("gameIdSchema", () => { + it.each(["1", "42", String(Number.MAX_SAFE_INTEGER)])( + "parses a positive safe integer: %s", + (id) => { + expect(gameIdSchema.parse(id)).toBe(Number(id)); + }, + ); + + it.each([ + undefined, + null, + 1, + true, + ["1"], + "", + " ", + "0", + "-1", + "1.5", + "abc", + "1abc", + "Infinity", + "9007199254740992", + ])("rejects an invalid game id: %j", (id) => { + expect(gameIdSchema.safeParse(id).success).toBe(false); + }); +}); + describe("JapaneseTransactionSchema", () => { it.each([JapaneseTransactionType.DEAL_IN, JapaneseTransactionType.SELF_DRAW])( "requires a hand for %s", @@ -59,3 +91,20 @@ describe("JapaneseTransactionSchema", () => { ).toBe(false); }); }); + +describe("createGameSchema season selection", () => { + const request = { seasonId: "selected-season", players: ["east", "south", "west", "north"] }; + + it("preserves an explicitly selected season", () => { + expect(createGameSchema.parse(request).seasonId).toBe("selected-season"); + }); + + it("requires a season selection", () => { + const { seasonId: _, ...requestWithoutSeason } = request; + expect(createGameSchema.safeParse(requestWithoutSeason).success).toBe(false); + }); + + it.each(["", " ", 123, null])("rejects an invalid season selection: %s", (seasonId) => { + expect(createGameSchema.safeParse({ ...request, seasonId }).success).toBe(false); + }); +}); diff --git a/backend/src/validation/game.validation.ts b/backend/src/validation/game.validation.ts index cacec56..a6efb24 100644 --- a/backend/src/validation/game.validation.ts +++ b/backend/src/validation/game.validation.ts @@ -1,12 +1,14 @@ import * as z from "zod"; import { GameType, HongKongTransactionType, JapaneseTransactionType, Wind } from "@prisma/client"; +import { seasonIdSchema } from "./season.validation"; +const gameIdSchema = z.string().transform(Number).pipe(z.int().positive()); const gameVariantSchema = z.enum(["jp", "hk"]); const gameTypeSchema = z.enum(GameType); const createGameSchema = z.object({ - gameType: gameTypeSchema, + seasonId: seasonIdSchema, players: z.array(z.string()).length(4), }); @@ -83,6 +85,7 @@ type HongKongTransactionT = z.infer; type Transaction = JapaneseTransactionT | HongKongTransactionT; export { + gameIdSchema, createGameSchema, gameVariantSchema, gameTypeSchema, diff --git a/backend/src/validation/season.validation.ts b/backend/src/validation/season.validation.ts index 5eea681..20fa67d 100644 --- a/backend/src/validation/season.validation.ts +++ b/backend/src/validation/season.validation.ts @@ -1,7 +1,11 @@ import { z } from "zod"; +import { GameType } from "@prisma/client"; + +const seasonIdSchema = z.string().regex(/\S/, "Invalid season id"); const createSeasonSchema = z.object({ name: z.string(), + type: z.enum(GameType), startDate: z.string(), endDate: z.string(), }); @@ -17,4 +21,10 @@ const updateSeasonSchema = z.object({ type UpdateSeasonType = z.infer; -export { createSeasonSchema, CreateSeasonType, updateSeasonSchema, UpdateSeasonType }; +export { + seasonIdSchema, + createSeasonSchema, + CreateSeasonType, + updateSeasonSchema, + UpdateSeasonType, +}; diff --git a/frontend/src/admin/AdminPlayers.tsx b/frontend/src/admin/AdminPlayers.tsx index 68f51ab..eff8eda 100644 --- a/frontend/src/admin/AdminPlayers.tsx +++ b/frontend/src/admin/AdminPlayers.tsx @@ -8,6 +8,7 @@ import { useDeletePlayerMutation, useSavePlayerMutation, useAdminPlayers, + useSeasons, } from "@/hooks/AdminHooks"; import type { Player } from "@/types"; import { @@ -18,7 +19,9 @@ import { DialogContent, DialogContentText, DialogTitle, + MenuItem, Stack, + TextField, useMediaQuery, useTheme, } from "@mui/material"; @@ -36,6 +39,7 @@ import { responsiveDataGridContainer } from "@/theme/utils"; const AdminPlayers = () => { const { player, loading } = useContext(AuthContext); const [rowModesModel, setRowModesModel] = useState({}); + const [selectedSeasonId, setSelectedSeasonId] = useState(""); const theme = useTheme(); const isMobile = useMediaQuery(theme.breakpoints.down("sm")); @@ -43,6 +47,8 @@ const AdminPlayers = () => { const { isPending, data, error } = useAdminPlayers(player || undefined); const deletePlayerMut = useDeletePlayerMutation(player || undefined); const savePlayerMut = useSavePlayerMutation(player || undefined); + const { data: seasons, isPending: seasonsPending, error: seasonsError } = useSeasons(); + const selectedSeason = seasons?.find((season) => season.id === selectedSeasonId); // Early return after all hooks if (loading) { @@ -157,18 +163,20 @@ const AdminPlayers = () => { } }; - const recalcCurrentSeasonHK = async () => { + const recalcSelectedSeasonHK = async () => { + if (!selectedSeason) return; try { - const response = await recalcSeasonAPI("hk"); + const response = await recalcSeasonAPI("hk", selectedSeason.id); logger.log("HK Recalculation Complete", response.data); } catch (err) { logger.log("Error recalculating hk", (err as AxiosError).response?.data); } }; - const recalcCurrentSeasonJP = async () => { + const recalcSelectedSeasonJP = async () => { + if (!selectedSeason) return; try { - const response = await recalcSeasonAPI("jp"); + const response = await recalcSeasonAPI("jp", selectedSeason.id); logger.log("JP Recalculation Complete", response.data); } catch (err) { logger.log("Error recalculating riichi", (err as AxiosError).response?.data); @@ -211,16 +219,53 @@ const AdminPlayers = () => { editMode="row" /> - - - - + + + - - - setShowCreateSeasonModal(false)} - aria-labelledby="alert-dialog-title" - aria-describedby="alert-dialog-description" - > - Create Season - + + + {currentSeasons.length === 0 ? ( + No active seasons + ) : ( + currentSeasons.map((season) => ( + + {season.name} + Type: {season.type} + + {season.startDate.toDateString()} –{" "} + {season.endDate.toDateString()} + + + )) + )} + + + + ); + } + + return ( + + + + {getCurrentSeasonPanel()} + + + setShowCreateSeasonModal(false)}> + Create Season + + setName(e.target.value)} + onChange={(event) => setName(event.target.value)} /> + + Type + + setEndDate(newValue)} + onChange={setEndDate} /> - - - - - - - - ); - } - - return ( - - - - {getCurrentSeasonPanel()} - + + + + + + + All Seasons diff --git a/frontend/src/api/AdminAPI.ts b/frontend/src/api/AdminAPI.ts index e3aa18d..e5ac24f 100644 --- a/frontend/src/api/AdminAPI.ts +++ b/frontend/src/api/AdminAPI.ts @@ -18,6 +18,10 @@ const getSeasonsAPI = async () => { return axios.get(baseUrl + "/seasons"); }; +const getCurrentSeasonsAPI = async () => { + return axios.get(baseUrl + "/seasons/current"); +}; + const createSeasonAdminAPI = async (season: Partial) => { return axios.post(baseUrl + "/admin/seasons", { season }, getAuthConfig()); }; @@ -34,8 +38,12 @@ const resetAllCheckInsAPI = async () => { return axios.post(baseUrl + "/admin/checkin/reset", {}, getAuthConfig()); }; -const recalcSeasonAPI = async (variant: GameVariant) => { - return axios.put(baseUrl + "/admin/recalc/" + variant, {}, getAuthConfig()); +const recalcSeasonAPI = async (variant: GameVariant, seasonId: string) => { + return axios.put( + `${baseUrl}/admin/recalc/${variant}/${encodeURIComponent(seasonId)}`, + {}, + getAuthConfig(), + ); }; export async function removeQualificationAPI() { @@ -47,6 +55,7 @@ export { deletePlayerAPI, updatePlayerAPI, getSeasonsAPI, + getCurrentSeasonsAPI, createSeasonAdminAPI, updateSeasonAPI, makeDummyAdminsAPI, diff --git a/frontend/src/api/CheckInAPI.ts b/frontend/src/api/CheckInAPI.ts index f86c12e..69165b3 100644 --- a/frontend/src/api/CheckInAPI.ts +++ b/frontend/src/api/CheckInAPI.ts @@ -1,15 +1,15 @@ import axios from "axios"; import { baseUrl, getAuthConfig } from "./APIUtils"; -type CheckInStatus = { +interface CheckInStatus { checkedInAt: string | null; -}; +} -type CheckedInPlayer = { +interface CheckedInPlayer { id: string; username: string; checkedInAt: string; -}; +} const checkInAPI = async () => { return axios.post(baseUrl + "/checkin", {}, getAuthConfig()); @@ -31,5 +31,5 @@ export { checkInAPI, checkOutAPI, getStatusAPI, - getCheckedInPlayersAPI -} \ No newline at end of file + getCheckedInPlayersAPI, +}; diff --git a/frontend/src/api/GameAPI.ts b/frontend/src/api/GameAPI.ts index 04e0679..8268882 100644 --- a/frontend/src/api/GameAPI.ts +++ b/frontend/src/api/GameAPI.ts @@ -8,11 +8,11 @@ const getPlayerNames = async (gameVariant: string, gameType: GameType) => { ); }; -const createGameAPI = async (gameType: GameType, gameVariant: GameVariant, players: string[]) => { +const createGameAPI = async (gameVariant: GameVariant, players: string[], seasonId: string) => { return axios.post<{ id: string }>( baseUrl + `/games/${gameVariant}`, { - gameType, + seasonId, players, }, getAuthConfig(), diff --git a/frontend/src/api/LeaderboardAPI.ts b/frontend/src/api/LeaderboardAPI.ts index ff2f22b..a182783 100644 --- a/frontend/src/api/LeaderboardAPI.ts +++ b/frontend/src/api/LeaderboardAPI.ts @@ -1,14 +1,10 @@ import axios from "axios"; import { baseUrl } from "./APIUtils"; -import type { Season, GameType, LeaderboardType } from "@/types"; +import type { LeaderboardType } from "@/types"; -const getCurrentSeason = async () => { - return axios.get(baseUrl + "/seasons/current"); -}; - -const getPlayerLeaderboard = async (gameVariant: string, gameType: GameType, seasonId: string) => { +const getPlayerLeaderboard = async (gameVariant: string, seasonId: string) => { return axios.get<{ players: LeaderboardType[] }>( - `${baseUrl}/players/qualified/${gameVariant}/${gameType}/leaderboard/?seasonId=${seasonId}`, + `${baseUrl}/players/qualified/${gameVariant}/leaderboard?seasonId=${seasonId}`, ); }; @@ -37,4 +33,4 @@ async function getPlacementHistory(playerId: string, gameVariant: string, season >(baseUrl + "/players/" + playerId + "/" + gameVariant + "/" + seasonId + "/placement-history"); } -export { getCurrentSeason, getPlayerLeaderboard, getUserStatistics, getPlacementHistory }; +export { getPlayerLeaderboard, getUserStatistics, getPlacementHistory }; diff --git a/frontend/src/common/AuthContext.tsx b/frontend/src/common/AuthContext.tsx index 11c9ec0..8b147ce 100644 --- a/frontend/src/common/AuthContext.tsx +++ b/frontend/src/common/AuthContext.tsx @@ -1,5 +1,5 @@ -import { ReactNode, useEffect, useCallback } from "react"; -import { createContext, useState } from "react"; +import { ReactNode, createContext } from "react"; +import { useQuery, useQueryClient } from "@tanstack/react-query"; import { useNavigate } from "react-router"; import { loginAPICall, registerAPICall } from "@/api/AuthAPI"; import { getCurrentPlayer } from "@/api/AccountAPI"; @@ -23,54 +23,39 @@ const AuthContext = createContext({ reloadPlayer: notInstantiated, }); +const currentPlayerKey = ["currentPlayer"] as const; + const AuthContextProvider = (props: ChildProps) => { const navigate = useNavigate(); - const [player, setPlayer] = useState(undefined); - const [loading, setLoading] = useState(true); - - useEffect(() => { - const abortController = new AbortController(); - - const checkAuth = async () => { - // Check if user is already authenticated via cookie + const queryClient = useQueryClient(); + const currentPlayerQuery = useQuery({ + queryKey: currentPlayerKey, + queryFn: async (): Promise => { try { const response = await getCurrentPlayer(); - if (!abortController.signal.aborted) { - setPlayer(response.data.player); - } + return response.data.player; } catch { - // No valid session, user is not logged in - if (!abortController.signal.aborted) { - setPlayer(undefined); - } - } finally { - if (!abortController.signal.aborted) { - setLoading(false); - } + return null; } - }; - checkAuth(); - - return () => { - abortController.abort(); - }; - }, []); + }, + retry: false, + }); + const player = currentPlayerQuery.data ?? undefined; + const loading = currentPlayerQuery.isPending; - const authLogin = useCallback(async (loginData: LoginDataType) => { + const authLogin = async (loginData: LoginDataType) => { const apiResponse = await loginAPICall(loginData); - const playerAPIData = apiResponse.data; - setPlayer(playerAPIData.player); + queryClient.setQueryData(currentPlayerKey, apiResponse.data.player); navigate("/"); - }, []); + }; - const authRegister = useCallback(async (registerData: RegisterDataType) => { + const authRegister = async (registerData: RegisterDataType) => { const apiResponse = await registerAPICall(registerData); - const playerAPIData = apiResponse.data; - setPlayer(playerAPIData.player); + queryClient.setQueryData(currentPlayerKey, apiResponse.data.player); navigate("/"); - }, []); + }; - const authLogout = useCallback(async () => { + const authLogout = async () => { try { await fetch(`${baseUrl}/logout`, { method: "POST", @@ -79,21 +64,20 @@ const AuthContextProvider = (props: ChildProps) => { } catch (error) { logger.error("Logout error:", error); } - setPlayer(undefined); + queryClient.setQueryData(currentPlayerKey, null); navigate("/login"); - }, []); + }; - const reloadPlayer = useCallback(async () => { + const reloadPlayer = async () => { try { const response = await getCurrentPlayer(); - setPlayer(response.data.player); + queryClient.setQueryData(currentPlayerKey, response.data.player); } catch (error) { logger.error("Error reloading player:", error); - // If token is invalid/expired, log out - setPlayer(undefined); + queryClient.setQueryData(currentPlayerKey, null); navigate("/login"); } - }, []); + }; return ( { onClick={closeDrawer} color="inherit" > - - - - + - - - - + @@ -102,41 +88,19 @@ const NavBar = () => { - {player.japaneseQualified && ( - - - - )} - + - {player.hongKongQualified && ( - - - - )} - + @@ -263,28 +227,14 @@ const NavBar = () => { to="/leaderboard/jp" onClick={() => setLeaderboardAnchor(null)} > - {getGameVariantString("jp", "RANKED")} - - setLeaderboardAnchor(null)} - > - {getGameVariantString("jp", "CASUAL")} + {getGameVariantString("jp")} setLeaderboardAnchor(null)} > - {getGameVariantString("hk", "RANKED")} - - setLeaderboardAnchor(null)} - > - {getGameVariantString("hk", "CASUAL")} + {getGameVariantString("hk")} {!loading && player && ( @@ -313,37 +263,19 @@ const NavBar = () => { horizontal: "left", }} > - {player.japaneseQualified && ( - setRecordGameAnchor(null)} - > - {getGameVariantString("jp", "RANKED")} - - )} setRecordGameAnchor(null)} > - {getGameVariantString("jp", "CASUAL")} + {getGameVariantString("jp")} - {player.hongKongQualified && ( - setRecordGameAnchor(null)} - > - {getGameVariantString("hk", "RANKED")} - - )} setRecordGameAnchor(null)} > - {getGameVariantString("hk", "CASUAL")} + {getGameVariantString("hk")} diff --git a/frontend/src/game/CreateGame.tsx b/frontend/src/game/CreateGame.tsx index 5cf7330..1bc2b35 100644 --- a/frontend/src/game/CreateGame.tsx +++ b/frontend/src/game/CreateGame.tsx @@ -1,10 +1,12 @@ -import { useState } from "react"; +import { useContext, useState } from "react"; import { createGameAPI } from "@/api/GameAPI"; import { AxiosError } from "axios"; import { withPlayerCondition } from "@/common/withPlayerCondition"; import { useNavigate } from "react-router"; import { getGameVariantString } from "@/common/Utils"; import { usePlayers } from "@/hooks/GameHooks"; +import { useCurrentSeasons } from "@/hooks/AdminHooks"; +import { AuthContext } from "@/common/AuthContext"; import LoadingFallback from "@/common/LoadingFallback"; import { Autocomplete, @@ -17,13 +19,21 @@ import { Box, Alert, } from "@mui/material"; -import type { GameCreationProp, GameVariant, Player, PlayerNamesDataType } from "@/types"; +import type { GameVariantProp, GameVariant, Player, PlayerNamesDataType, Season } from "@/types"; -const CreateGameComponent = ({ - gameVariant, - gameType, -}: GameCreationProp) => { +const CreateGameComponent = ({ gameVariant }: GameVariantProp) => { const navigate = useNavigate(); + const { player } = useContext(AuthContext); + const currentSeasonsResult = useCurrentSeasons(); + const eligibleSeasons = (currentSeasonsResult.data ?? []).filter((candidate) => { + if (candidate.type === "CASUAL") return true; + return gameVariant === "jp" ? player?.japaneseQualified : player?.hongKongQualified; + }); + const [selectedSeasonId, setSelectedSeasonId] = useState(); + const season = + eligibleSeasons.find((candidate) => candidate.id === selectedSeasonId) ?? + eligibleSeasons[0] ?? + null; const [eastPlayer, setEastPlayer] = useState(null); const [southPlayer, setSouthPlayer] = useState(null); @@ -31,21 +41,30 @@ const CreateGameComponent = ({ const [northPlayer, setNorthPlayer] = useState(null); const [attemptedSubmit, setAttemptedSubmit] = useState(false); - const playerNamesResult = usePlayers(gameVariant, gameType); + const playerNamesResult = usePlayers(gameVariant, season?.type); + + const selectSeason = (selectedSeason: Season) => { + setSelectedSeasonId(selectedSeason.id); + setEastPlayer(null); + setSouthPlayer(null); + setWestPlayer(null); + setNorthPlayer(null); + setAttemptedSubmit(false); + }; const createGame = async () => { setAttemptedSubmit(true); - if (playerSelectMissing() || playerListNotUnique()) { + if (!season || playerSelectMissing() || playerListNotUnique()) { return; } const playerList = [eastPlayer, southPlayer, westPlayer, northPlayer]; try { const response = await createGameAPI( - gameType, gameVariant, playerList.map((playerName) => playerName!.username), + season.id, ); navigate(`/games/${gameVariant}/${response.data.id}`); } catch (error) { @@ -53,11 +72,8 @@ const CreateGameComponent = ({ } }; - const title = `Create ${getGameVariantString(gameVariant, gameType)} Game`; - - // const playerSelectMissing = !eastPlayer || !southPlayer || !westPlayer || !northPlayer; + const title = `Create ${getGameVariantString(gameVariant, season?.type)} Game`; - // Get validation errors const getValidationErrors = () => { const errors: string[] = []; @@ -93,6 +109,26 @@ const CreateGameComponent = ({ }; const validationErrors = attemptedSubmit ? getValidationErrors() : []; + if (currentSeasonsResult.isPending) { + return ; + } + if (currentSeasonsResult.error) { + return ( + + Failed to load active seasons. + + ); + } + if (eligibleSeasons.length === 0) { + return ( + + + Create {getGameVariantString(gameVariant)} Game + + There are no active seasons you can record games for. + + ); + } if (playerNamesResult.error) return ( @@ -111,6 +147,16 @@ const CreateGameComponent = ({ {title} + `${option.name} (${option.type.replace("_", " ")})`} + isOptionEqualToValue={(option, value) => option.id === value.id} + disableClearable + onChange={(_event, value) => selectSeason(value)} + renderInput={(params) => } + /> + ({ const hasGamePermissions = ( player: Player | undefined, - props: GameCreationProp, + _props: GameVariantProp, ): boolean => { - if (player === undefined) { - return false; - } - if (props.gameType === "CASUAL") { - return true; // everyone is allowed to start casual games - } - if (props.gameVariant === "jp") { - return player.japaneseQualified; - } else if (props.gameVariant === "hk") { - return player.hongKongQualified; - } - return false; + return player !== undefined; }; const CreateGame = withPlayerCondition(CreateGameComponent, hasGamePermissions, "/unauthorized"); diff --git a/frontend/src/game/Game.tsx b/frontend/src/game/Game.tsx index ed7646d..07c1d12 100644 --- a/frontend/src/game/Game.tsx +++ b/frontend/src/game/Game.tsx @@ -1,17 +1,12 @@ -import { useContext, useEffect, useState } from "react"; -import { useNavigate, useParams } from "react-router"; +import { useContext, useEffect } from "react"; +import { Navigate, useNavigate, useParams } from "react-router"; +import { useQueryClient } from "@tanstack/react-query"; import { AxiosError } from "axios"; import type { GameVariant, Game, GamePlayer, RoundByVariant } from "@/types"; -import { - addRoundAPI, - deleteGameAPI, - deleteRoundAPI, - getGameAPI, - submitGameAPI, -} from "@/api/GameAPI"; +import { addRoundAPI, deleteGameAPI, deleteRoundAPI, submitGameAPI } from "@/api/GameAPI"; +import { gameQueryKey, useGame } from "@/hooks/GameHooks"; import { AuthContext } from "@/common/AuthContext"; import { getGameVariantString, validateGameVariant } from "@/common/Utils"; -import { logger } from "@/common/logger"; import LoadingFallback from "@/common/LoadingFallback"; import alert from "@/common/AlertDialog"; import confirmDialog from "@/common/ConfirmationDialog"; @@ -25,33 +20,17 @@ const Game = () => { const { id, variant: variantParam } = useParams(); const { player, loading } = useContext(AuthContext); const navigate = useNavigate(); + const queryClient = useQueryClient(); const gameId = Number(id); // Validate and cast variant to GameVariant type const variant = (validateGameVariant(variantParam) ? variantParam : undefined) as T | undefined; - - const [game, setGame] = useState | undefined>(undefined); - - useEffect(() => { - const fetchGame = async () => { - if (isNaN(gameId) || !variant) { - navigate("/games/not-found"); - return; - } - - try { - const response = await getGameAPI(gameId, variant); - setGame(response.data); - } catch (error) { - logger.error("Error fetching game: ", (error as AxiosError).response?.data); - if ((error as AxiosError).response?.status === 404) { - navigate("/games/not-found"); - return; - } - } - }; - fetchGame(); - }, [gameId, navigate, variant]); + const validGameId = Number.isSafeInteger(gameId) && gameId > 0 ? gameId : undefined; + const gameQuery = useGame(validGameId, variant); + const game = gameQuery.data; + const updateGame = (updatedGame: Game) => { + queryClient.setQueryData(gameQueryKey(validGameId, variant), updatedGame); + }; useEffect(() => { // Only setup EventSource for spectators (not the recorder) watching live games @@ -60,7 +39,7 @@ const Game = () => { eventSource.onmessage = (event) => { const gameResult = JSON.parse(event.data); - setGame(gameResult); + queryClient.setQueryData(gameQueryKey(validGameId, variant), gameResult); }; eventSource.onerror = () => { @@ -73,12 +52,12 @@ const Game = () => { eventSource.close(); }; } - }, [game?.id, game?.status, player, variant]); + }, [game?.id, game?.recordedById, game?.status, player?.id, queryClient, validGameId, variant]); const handleSubmitRound = async (roundRequest: RoundByVariant) => { try { const response = await addRoundAPI(gameId, variant!, roundRequest); - setGame(response.data); + updateGame(response.data); } catch (error) { alert(`Add Round Error: ${(error as AxiosError).response?.data}`); } @@ -98,7 +77,7 @@ const Game = () => { try { const response = await deleteRoundAPI(gameId, variant!); - setGame(response.data); + updateGame(response.data); } catch (e) { const error = e as Error; await alert(`Delete Round Error: ${error.message}`); @@ -138,7 +117,7 @@ const Game = () => { await alert(`Game Submitted`); const tempGame = { ...game! }; tempGame.status = "FINISHED"; - setGame(tempGame); + updateGame(tempGame); } catch (error) { await alert(`Delete Game Error: ${(error as AxiosError).message}`); } @@ -180,12 +159,22 @@ const Game = () => { } }; - if (isNaN(gameId) || !validateGameVariant(variant)) { - navigate("/games/not-found"); - return; - } else if (typeof game === "undefined") { + if (!validGameId || !variant) { + return ; + } + if (gameQuery.isError) { + return (gameQuery.error as AxiosError).response?.status === 404 ? ( + + ) : ( + + Failed to load game. + + ); + } + if (typeof game === "undefined") { return ; - } else if (loading) { + } + if (loading) { return ; } const canUpdateGame = @@ -194,7 +183,9 @@ const Game = () => { return ( - {getGameVariantString(variant, game.type)} + + {getGameVariantString(variant, game.season.type)} + {game.status === "IN_PROGRESS" && ( {gameRoundString(game, variant)} diff --git a/frontend/src/game/GameLogs.tsx b/frontend/src/game/GameLogs.tsx index 3ce2c76..a0f6d1a 100644 --- a/frontend/src/game/GameLogs.tsx +++ b/frontend/src/game/GameLogs.tsx @@ -1,4 +1,4 @@ -import { useCallback, useEffect, useState } from "react"; +import { useState } from "react"; import { getGamesAPI } from "@/api/GameAPI"; import { AxiosError } from "axios"; import { logger } from "@/common/logger"; @@ -37,7 +37,7 @@ const MAX_GAMES_PER_PAGE = 12; const GameLogs = () => { const [queryGameVariant, setQueryGameVariant] = useState(gameVariants[0].variant); - const [season, setSeason] = useState(null); + const [selectedSeasonId, setSelectedSeasonId] = useState(); const [queryPlayers, setQueryPlayers] = useState([]); const [loading, setLoading] = useState(false); @@ -47,20 +47,15 @@ const GameLogs = () => { const { isSuccess: seasonsSuccess, data: seasons } = useSeasons(); const playersResult = usePlayers(queryGameVariant, "CASUAL"); - const seasonsSorted = - seasonsSuccess && seasons ? [...seasons].sort((a, b) => b.id.localeCompare(a.id)) : []; - useEffect(() => { - // Always set the first season (most recent) as the default season if available - if (!season && seasonsSorted.length > 0) { - setSeason(seasonsSorted[0]); - } - }, [seasonsSorted]); + const seasonsSorted = seasonsSuccess && seasons ? seasons : []; + const season = + seasonsSorted.find((candidate) => candidate.id === selectedSeasonId) ?? + seasonsSorted[0] ?? + null; - const disableQueryButton = useCallback((): boolean => { - return loading || season === null; - }, [loading, season]); + const queryDisabled = loading || season === null; - const getGames = useCallback(async () => { + const getGames = async () => { if (season !== null) { setLoading(true); try { @@ -81,7 +76,7 @@ const GameLogs = () => { setLoading(false); } } - }, [queryGameVariant, season, queryPlayers]); + }; const getPaginatedGames = () => { const startIdx = (pagination - 1) * MAX_GAMES_PER_PAGE; @@ -156,7 +151,7 @@ const GameLogs = () => { value={season!} blurOnSelect disableClearable - onChange={(_e, value) => setSeason(value)} + onChange={(_e, value) => setSelectedSeasonId(value.id)} renderInput={(params) => ( () => {