diff --git a/app/core/tournament/permissions.ts b/app/core/tournament/permissions.ts index c7facff97..24d192d74 100644 --- a/app/core/tournament/permissions.ts +++ b/app/core/tournament/permissions.ts @@ -1,13 +1,3 @@ -export function isTournamentAdmin({ - userId, - organization, -}: { - userId?: string; - organization: { ownerId: string }; -}) { - return organization.ownerId === userId; -} - export function canReportMatchScore({ userId, members, diff --git a/app/db/tournament/mutations/updateSeeds.ts b/app/db/tournament/mutations/updateSeeds.ts new file mode 100644 index 000000000..52c606090 --- /dev/null +++ b/app/db/tournament/mutations/updateSeeds.ts @@ -0,0 +1,18 @@ +import { db } from "~/utils/db.server"; + +export async function updateSeeds({ + tournamentId, + seeds, +}: { + tournamentId: string; + seeds: string[]; +}) { + return db.tournament.update({ + where: { + id: tournamentId, + }, + data: { + seeds, + }, + }); +} diff --git a/app/db/tournament/queries/findTournamentById.ts b/app/db/tournament/queries/findTournamentById.ts new file mode 100644 index 000000000..3b9621b40 --- /dev/null +++ b/app/db/tournament/queries/findTournamentById.ts @@ -0,0 +1,17 @@ +import { Prisma } from "@prisma/client"; +import { db } from "~/utils/db.server"; + +export type FindTournamentById = Prisma.PromiseReturnType< + typeof findTournamentById +>; + +export function findTournamentById(id: string) { + return db.tournament.findUnique({ + where: { id }, + include: { + organizer: true, + brackets: { include: { rounds: true } }, + teams: { include: { members: true } }, + }, + }); +} diff --git a/app/models/Tournament.ts b/app/models/Tournament.ts index 863fcd09c..531340d6f 100644 --- a/app/models/Tournament.ts +++ b/app/models/Tournament.ts @@ -137,21 +137,3 @@ export function findByNameForUrlWithInviteCodes(tournamentNameForUrl: string) { }, }); } - -export type UpdateSeeds = Prisma.PromiseReturnType; -export function updateSeeds({ - tournamentId, - seeds, -}: { - tournamentId: string; - seeds: string[]; -}) { - return db.tournament.update({ - where: { - id: tournamentId, - }, - data: { - seeds, - }, - }); -} diff --git a/app/routes/to/$organization.$tournament.tsx b/app/routes/to/$organization.$tournament.tsx index f215e7196..7cd3529bc 100644 --- a/app/routes/to/$organization.$tournament.tsx +++ b/app/routes/to/$organization.$tournament.tsx @@ -14,7 +14,6 @@ import invariant from "tiny-invariant"; import { AdminIcon } from "~/components/icons/Admin"; import { CheckinActions } from "~/components/tournament/CheckinActions"; import { InfoBanner } from "~/components/tournament/InfoBanner"; -import { isTournamentAdmin } from "~/core/tournament/permissions"; import { tournamentHasStarted } from "~/core/tournament/utils"; import { checkIn, @@ -26,6 +25,7 @@ import type { MyCSSProperties } from "~/utils"; import { useUser } from "~/hooks/common"; import tournamentStylesUrl from "../../styles/tournament.css"; import * as React from "react"; +import { isTournamentAdmin } from "~/validators/tournament"; export const links: LinksFunction = () => { return [{ rel: "stylesheet", href: tournamentStylesUrl }]; diff --git a/app/routes/to/$organization.$tournament/seeds.tsx b/app/routes/to/$organization.$tournament/seeds.tsx index a1b57c90c..e2c932235 100644 --- a/app/routes/to/$organization.$tournament/seeds.tsx +++ b/app/routes/to/$organization.$tournament/seeds.tsx @@ -28,18 +28,22 @@ import { Alert } from "~/components/Alert"; import { Button } from "~/components/Button"; import { Catcher } from "~/components/Catcher"; import { Draggable } from "~/components/Draggable"; -import { - FindTournamentByNameForUrlI, - updateSeeds, -} from "~/services/tournament"; +import { FindTournamentByNameForUrlI } from "~/services/tournament"; import seedsStylesUrl from "~/styles/tournament-seeds.css"; import { parseRequestFormData, requireUser, safeJSONParse, Unpacked, + validate, } from "~/utils"; import { useTimeoutState } from "~/hooks/common"; +import { updateSeeds } from "~/db/tournament/mutations/updateSeeds"; +import { findTournamentById } from "~/db/tournament/queries/findTournamentById"; +import { + isTournamentAdmin, + tournamentHasNotStarted, +} from "~/validators/tournament"; const seedsActionSchema = z.object({ tournamentId: z.string().uuid(), @@ -53,10 +57,20 @@ export const action: ActionFunction = async ({ context, request }) => { }); const user = requireUser(context); + const tournament = await findTournamentById(data.tournamentId); + validate(tournament, "Invalid tournament id"); + validate( + isTournamentAdmin({ userId: user.id, organization: tournament.organizer }), + "Not tournament admin" + ); + validate( + tournamentHasNotStarted(tournament), + "Can't change seeds after tournament has started" + ); + await updateSeeds({ tournamentId: data.tournamentId, - userId: user.id, - newSeeds: data.seeds, + seeds: data.seeds, }); return null; diff --git a/app/services/tournament.ts b/app/services/tournament.ts index 85c23d5c3..e9495c98f 100644 --- a/app/services/tournament.ts +++ b/app/services/tournament.ts @@ -4,7 +4,6 @@ import { TOURNAMENT_TEAM_ROSTER_MAX_SIZE, } from "~/constants"; import { MapListIds, tournamentRoundsForDB } from "~/core/tournament/bracket"; -import { isTournamentAdmin } from "~/core/tournament/permissions"; import { captainOfTeam, sortTeamsBySeed } from "~/core/tournament/utils"; import * as Tournament from "~/models/Tournament"; import * as TournamentTeam from "~/models/TournamentTeam"; @@ -12,6 +11,7 @@ import * as TournamentTeamMember from "~/models/TournamentTeamMember"; import * as TrustRelationship from "~/models/TrustRelationship"; import { Serialized, Unpacked } from "~/utils"; import { db } from "~/utils/db.server"; +import { isTournamentAdmin } from "~/validators/tournament"; export type FindTournamentByNameForUrlI = Serialized< Prisma.PromiseReturnType @@ -409,27 +409,3 @@ export async function checkOut({ return TournamentTeam.checkOut(teamId); } - -export async function updateSeeds({ - tournamentId, - userId, - newSeeds, -}: { - tournamentId: string; - userId: string; - newSeeds: string[]; -}) { - const tournament = await Tournament.findById(tournamentId); - if (!tournament) throw new Response("Invalid tournament id", { status: 400 }); - if ( - !isTournamentAdmin({ - organization: tournament.organizer, - userId, - }) - ) { - throw new Response("Not tournament admin", { status: 401 }); - } - - // TODO: fail if tournament has started - return Tournament.updateSeeds({ tournamentId, seeds: newSeeds }); -} diff --git a/app/utils/index.ts b/app/utils/index.ts index 624dfb2e9..b870ecd63 100644 --- a/app/utils/index.ts +++ b/app/utils/index.ts @@ -40,6 +40,13 @@ export function requireEvents(ctx: unknown) { } } +/** Asserts condition is truthy. Throws a new `Response` with status code 400 and given message if falsy. */ +export function validate(condition: any, message: string): asserts condition { + if (condition) return; + + throw new Response(message, { status: 400 }); +} + /** Get link to log in with query param set as current page */ export function getLogInUrl(location: ReturnType) { return `/auth/discord?origin=${encodeURIComponent( diff --git a/app/validators/tournament.ts b/app/validators/tournament.ts new file mode 100644 index 000000000..f111926a8 --- /dev/null +++ b/app/validators/tournament.ts @@ -0,0 +1,19 @@ +import { FindTournamentById } from "~/db/tournament/queries/findTournamentById"; + +/** Checks that a user is considered an admin of the tournament. An admin can perform all sorts of actions that normal users can't. */ +export function isTournamentAdmin({ + userId, + organization, +}: { + userId?: string; + organization: { ownerId: string }; +}) { + return organization.ownerId === userId; +} + +/** Checks if tournament has not started meaning there is no bracket with rounds generated. */ +export function tournamentHasNotStarted( + tournament: NonNullable +) { + return (tournament.brackets[0]?.rounds.length ?? 0) === 0; +}