mirror of
https://github.com/Sendouc/sendou.ink.git
synced 2026-03-21 18:04:39 -05:00
487 lines
13 KiB
TypeScript
487 lines
13 KiB
TypeScript
import type { ActionFunction } from "react-router";
|
|
import * as R from "remeda";
|
|
import { requireUser } from "~/features/auth/core/user.server";
|
|
import { userIsBanned } from "~/features/ban/core/banned.server";
|
|
import * as ShowcaseTournaments from "~/features/front-page/core/ShowcaseTournaments.server";
|
|
import { notify } from "~/features/notifications/core/notify.server";
|
|
import * as TournamentTeamRepository from "~/features/tournament/TournamentTeamRepository.server";
|
|
import { getServerTournamentManager } from "~/features/tournament-bracket/core/brackets-manager/manager.server";
|
|
import * as Progression from "~/features/tournament-bracket/core/Progression";
|
|
import {
|
|
clearTournamentDataCache,
|
|
tournamentFromDB,
|
|
} from "~/features/tournament-bracket/core/Tournament.server";
|
|
import { deleteSub } from "~/features/tournament-subs/queries/deleteSub.server";
|
|
import invariant from "~/utils/invariant";
|
|
import { logger } from "~/utils/logger";
|
|
import {
|
|
badRequestIfFalsy,
|
|
errorToastIfFalsy,
|
|
parseParams,
|
|
parseRequestPayload,
|
|
successToast,
|
|
} from "~/utils/remix.server";
|
|
import { assertUnreachable } from "~/utils/types";
|
|
import { idObject } from "../../../utils/zod";
|
|
import { changeTeamOwner } from "../queries/changeTeamOwner.server";
|
|
import { deleteTeam } from "../queries/deleteTeam.server";
|
|
import { joinTeam, leaveTeam } from "../queries/joinLeaveTeam.server";
|
|
import * as TournamentRepository from "../TournamentRepository.server";
|
|
import { adminActionSchema } from "../tournament-schemas.server";
|
|
import {
|
|
endDroppedTeamMatches,
|
|
inGameNameIfNeeded,
|
|
} from "../tournament-utils.server";
|
|
|
|
export const action: ActionFunction = async ({ request, params }) => {
|
|
const user = requireUser();
|
|
const data = await parseRequestPayload({
|
|
request,
|
|
schema: adminActionSchema,
|
|
});
|
|
|
|
const { id: tournamentId } = parseParams({
|
|
params,
|
|
schema: idObject,
|
|
});
|
|
const tournament = await tournamentFromDB({ tournamentId, user });
|
|
|
|
const validateIsTournamentAdmin = () =>
|
|
errorToastIfFalsy(tournament.isAdmin(user), "Unauthorized");
|
|
const validateIsTournamentOrganizer = () =>
|
|
errorToastIfFalsy(tournament.isOrganizer(user), "Unauthorized");
|
|
|
|
let message: string;
|
|
switch (data._action) {
|
|
case "ADD_TEAM": {
|
|
validateIsTournamentOrganizer();
|
|
errorToastIfFalsy(
|
|
tournament.ctx.teams.every((t) => t.name !== data.teamName),
|
|
"Team name taken",
|
|
);
|
|
errorToastIfFalsy(
|
|
!tournament.teamMemberOfByUser({ id: data.userId }),
|
|
"User already on a team",
|
|
);
|
|
|
|
await TournamentTeamRepository.create({
|
|
ownerInGameName: await inGameNameIfNeeded({
|
|
tournament,
|
|
userId: data.userId,
|
|
}),
|
|
team: {
|
|
name: data.teamName,
|
|
prefersNotToHost: 0,
|
|
teamId: null,
|
|
},
|
|
userId: data.userId,
|
|
tournamentId,
|
|
});
|
|
deleteSub({ tournamentId, userId: data.userId });
|
|
|
|
ShowcaseTournaments.addToCached({
|
|
tournamentId,
|
|
type: "participant",
|
|
userId: data.userId,
|
|
newTeamCount: tournament.ctx.teams.length + 1,
|
|
});
|
|
|
|
message = "Team added";
|
|
break;
|
|
}
|
|
case "CHANGE_TEAM_OWNER": {
|
|
validateIsTournamentOrganizer();
|
|
const team = tournament.teamById(data.teamId);
|
|
errorToastIfFalsy(team, "Invalid team id");
|
|
const oldCaptain = team.members.find((m) => m.isOwner);
|
|
invariant(oldCaptain, "Team has no captain");
|
|
const newCaptain = team.members.find((m) => m.userId === data.memberId);
|
|
errorToastIfFalsy(newCaptain, "Invalid member id");
|
|
|
|
changeTeamOwner({
|
|
newCaptainId: data.memberId,
|
|
oldCaptainId: oldCaptain.userId,
|
|
tournamentTeamId: data.teamId,
|
|
});
|
|
|
|
message = "Team owner changed";
|
|
break;
|
|
}
|
|
case "CHANGE_TEAM_NAME": {
|
|
validateIsTournamentOrganizer();
|
|
const team = tournament.teamById(data.teamId);
|
|
errorToastIfFalsy(team, "Invalid team id");
|
|
|
|
await TournamentRepository.updateTeamName({
|
|
tournamentTeamId: data.teamId,
|
|
name: data.teamName,
|
|
});
|
|
|
|
message = "Team name changed";
|
|
break;
|
|
}
|
|
case "CHECK_IN": {
|
|
validateIsTournamentOrganizer();
|
|
const team = tournament.teamById(data.teamId);
|
|
errorToastIfFalsy(team, "Invalid team id");
|
|
errorToastIfFalsy(
|
|
data.bracketIdx !== 0 ||
|
|
tournament.checkInConditionsFulfilledByTeamId(team.id).isFulfilled,
|
|
`Can't check-in - ${tournament.checkInConditionsFulfilledByTeamId(team.id).reason}`,
|
|
);
|
|
errorToastIfFalsy(
|
|
team.checkIns.length > 0 || data.bracketIdx === 0,
|
|
"Can't check-in to follow up bracket if not checked in for the event itself",
|
|
);
|
|
|
|
const bracket = tournament.bracketByIdx(data.bracketIdx);
|
|
invariant(bracket, "Invalid bracket idx");
|
|
errorToastIfFalsy(bracket.preview, "Bracket has been started");
|
|
|
|
await TournamentRepository.checkIn({
|
|
tournamentTeamId: data.teamId,
|
|
// no sources = regular check in
|
|
bracketIdx: !bracket.sources ? null : data.bracketIdx,
|
|
});
|
|
|
|
message = "Checked team in";
|
|
break;
|
|
}
|
|
case "CHECK_OUT": {
|
|
validateIsTournamentOrganizer();
|
|
const team = tournament.teamById(data.teamId);
|
|
errorToastIfFalsy(team, "Invalid team id");
|
|
errorToastIfFalsy(
|
|
data.bracketIdx !== 0 || !tournament.hasStarted,
|
|
"Tournament has started",
|
|
);
|
|
|
|
const bracket = tournament.bracketByIdx(data.bracketIdx);
|
|
invariant(bracket, "Invalid bracket idx");
|
|
errorToastIfFalsy(bracket.preview, "Bracket has been started");
|
|
|
|
await TournamentRepository.checkOut({
|
|
tournamentTeamId: data.teamId,
|
|
// no sources = regular check in
|
|
bracketIdx: !bracket.sources ? null : data.bracketIdx,
|
|
});
|
|
logger.info(
|
|
`Checked out: tournament team id: ${data.teamId} - user id: ${user.id} - tournament id: ${tournamentId} - bracket idx: ${data.bracketIdx}`,
|
|
);
|
|
|
|
message = "Checked team out";
|
|
break;
|
|
}
|
|
case "REMOVE_MEMBER": {
|
|
validateIsTournamentOrganizer();
|
|
const team = tournament.teamById(data.teamId);
|
|
errorToastIfFalsy(team, "Invalid team id");
|
|
errorToastIfFalsy(
|
|
team.checkIns.length === 0 ||
|
|
team.members.length > tournament.minMembersPerTeam,
|
|
"Can't remove last member from checked in team",
|
|
);
|
|
errorToastIfFalsy(
|
|
!team.members.find((m) => m.userId === data.memberId)?.isOwner,
|
|
"Cannot remove team owner",
|
|
);
|
|
errorToastIfFalsy(
|
|
!tournament.hasStarted ||
|
|
!tournament
|
|
.participatedPlayersByTeamId(data.teamId)
|
|
.some((p) => p.userId === data.memberId),
|
|
"Cannot remove player that has participated in the tournament",
|
|
);
|
|
|
|
if (team.activeRosterUserIds?.includes(data.memberId)) {
|
|
await TournamentTeamRepository.setActiveRoster({
|
|
teamId: team.id,
|
|
activeRosterUserIds: null,
|
|
});
|
|
}
|
|
|
|
leaveTeam({
|
|
userId: data.memberId,
|
|
teamId: team.id,
|
|
});
|
|
|
|
ShowcaseTournaments.removeFromCached({
|
|
tournamentId,
|
|
type: "participant",
|
|
userId: data.memberId,
|
|
});
|
|
|
|
message = "Member removed";
|
|
break;
|
|
}
|
|
case "ADD_MEMBER": {
|
|
validateIsTournamentOrganizer();
|
|
const team = tournament.teamById(data.teamId);
|
|
errorToastIfFalsy(team, "Invalid team id");
|
|
|
|
const previousTeam = tournament.teamMemberOfByUser({ id: data.userId });
|
|
|
|
errorToastIfFalsy(
|
|
!previousTeam?.id || previousTeam.id !== team.id,
|
|
"User is already in this team",
|
|
);
|
|
|
|
errorToastIfFalsy(
|
|
tournament.hasStarted || !previousTeam,
|
|
"User is already in a team",
|
|
);
|
|
|
|
errorToastIfFalsy(
|
|
!userIsBanned(data.userId),
|
|
"User trying to be added currently has an active ban from sendou.ink",
|
|
);
|
|
|
|
joinTeam({
|
|
userId: data.userId,
|
|
newTeamId: team.id,
|
|
previousTeamId: previousTeam?.id,
|
|
// this team is not checked in & tournament started, so we can simply delete it
|
|
whatToDoWithPreviousTeam:
|
|
previousTeam &&
|
|
previousTeam.checkIns.length === 0 &&
|
|
tournament.hasStarted
|
|
? "DELETE"
|
|
: undefined,
|
|
tournamentId,
|
|
inGameName: await inGameNameIfNeeded({
|
|
tournament,
|
|
userId: data.userId,
|
|
}),
|
|
});
|
|
|
|
ShowcaseTournaments.addToCached({
|
|
tournamentId,
|
|
type: "participant",
|
|
userId: data.userId,
|
|
});
|
|
|
|
if (!tournament.isTest) {
|
|
notify({
|
|
userIds: [data.userId],
|
|
notification: {
|
|
type: "TO_ADDED_TO_TEAM",
|
|
pictureUrl:
|
|
tournament.tournamentTeamLogoSrc(team) ?? tournament.ctx.logoUrl,
|
|
meta: {
|
|
adderUsername: user.username,
|
|
teamName: team.name,
|
|
tournamentId,
|
|
tournamentName: tournament.ctx.name,
|
|
tournamentTeamId: team.id,
|
|
},
|
|
},
|
|
});
|
|
}
|
|
|
|
message = "Member added";
|
|
break;
|
|
}
|
|
case "DELETE_TEAM": {
|
|
validateIsTournamentOrganizer();
|
|
const team = tournament.teamById(data.teamId);
|
|
errorToastIfFalsy(team, "Invalid team id");
|
|
errorToastIfFalsy(!tournament.hasStarted, "Tournament has started");
|
|
|
|
deleteTeam(team.id);
|
|
|
|
for (const member of team.members) {
|
|
ShowcaseTournaments.removeFromCached({
|
|
tournamentId,
|
|
type: "participant",
|
|
userId: member.userId,
|
|
});
|
|
|
|
ShowcaseTournaments.updateCachedTournamentTeamCount({
|
|
tournamentId,
|
|
newTeamCount: tournament.ctx.teams.length - 1,
|
|
});
|
|
}
|
|
|
|
message = "Team deleted from tournament";
|
|
|
|
break;
|
|
}
|
|
case "ADD_STAFF": {
|
|
validateIsTournamentAdmin();
|
|
|
|
errorToastIfFalsy(
|
|
tournament.ctx.staff.every((staff) => staff.id !== data.userId),
|
|
"User is already a staff member",
|
|
);
|
|
|
|
await TournamentRepository.addStaff({
|
|
role: data.role,
|
|
tournamentId: tournament.ctx.id,
|
|
userId: data.userId,
|
|
});
|
|
|
|
if (data.role === "ORGANIZER") {
|
|
ShowcaseTournaments.addToCached({
|
|
tournamentId,
|
|
type: "organizer",
|
|
userId: data.userId,
|
|
});
|
|
}
|
|
|
|
message = "Staff member added";
|
|
break;
|
|
}
|
|
case "REMOVE_STAFF": {
|
|
validateIsTournamentAdmin();
|
|
|
|
await TournamentRepository.removeStaff({
|
|
tournamentId: tournament.ctx.id,
|
|
userId: data.userId,
|
|
});
|
|
|
|
ShowcaseTournaments.removeFromCached({
|
|
tournamentId,
|
|
type: "organizer",
|
|
userId: data.userId,
|
|
});
|
|
|
|
message = "Staff member removed";
|
|
break;
|
|
}
|
|
case "UPDATE_CAST_TWITCH_ACCOUNTS": {
|
|
validateIsTournamentOrganizer();
|
|
await TournamentRepository.updateCastTwitchAccounts({
|
|
tournamentId: tournament.ctx.id,
|
|
castTwitchAccounts: data.castTwitchAccounts,
|
|
});
|
|
|
|
message = "Cast account updated";
|
|
break;
|
|
}
|
|
case "DROP_TEAM_OUT": {
|
|
validateIsTournamentOrganizer();
|
|
const droppingTeam = tournament.teamById(data.teamId);
|
|
errorToastIfFalsy(droppingTeam, "Invalid team id");
|
|
|
|
// Set active roster only for teams with subs (can't infer which players played)
|
|
// Teams without subs have their roster trivially inferred in summarizer
|
|
const hasSubs =
|
|
droppingTeam.members.length > tournament.minMembersPerTeam;
|
|
if (hasSubs && !droppingTeam.activeRosterUserIds) {
|
|
const randomRoster = R.sample(
|
|
droppingTeam.members.map((m) => m.userId),
|
|
tournament.minMembersPerTeam,
|
|
);
|
|
await TournamentTeamRepository.setActiveRoster({
|
|
teamId: data.teamId,
|
|
activeRosterUserIds: randomRoster,
|
|
});
|
|
}
|
|
|
|
endDroppedTeamMatches({
|
|
tournament,
|
|
manager: getServerTournamentManager(),
|
|
droppedTeamId: data.teamId,
|
|
});
|
|
|
|
await TournamentRepository.dropTeamOut({
|
|
tournamentTeamId: data.teamId,
|
|
previewBracketIdxs: tournament.brackets.flatMap((b, idx) =>
|
|
b.preview ? idx : [],
|
|
),
|
|
});
|
|
|
|
message = "Team dropped out";
|
|
break;
|
|
}
|
|
case "UNDO_DROP_TEAM_OUT": {
|
|
validateIsTournamentOrganizer();
|
|
|
|
await TournamentRepository.undoDropTeamOut(data.teamId);
|
|
|
|
message = "Team drop out undone";
|
|
break;
|
|
}
|
|
case "RESET_BRACKET": {
|
|
validateIsTournamentOrganizer();
|
|
errorToastIfFalsy(!tournament.ctx.isFinalized, "Tournament is finalized");
|
|
|
|
const bracketToResetIdx = tournament.brackets.findIndex(
|
|
(b) => b.id === data.stageId,
|
|
);
|
|
const bracketToReset = tournament.brackets[bracketToResetIdx];
|
|
errorToastIfFalsy(bracketToReset, "Invalid bracket id");
|
|
errorToastIfFalsy(!bracketToReset.preview, "Bracket has not started");
|
|
|
|
const inProgressBrackets = tournament.brackets.filter((b) => !b.preview);
|
|
errorToastIfFalsy(
|
|
inProgressBrackets.every(
|
|
(b) =>
|
|
!b.sources ||
|
|
b.sources.every((s) => s.bracketIdx !== bracketToResetIdx),
|
|
),
|
|
"Some bracket that sources teams from this bracket has started",
|
|
);
|
|
|
|
await TournamentRepository.resetBracket(data.stageId);
|
|
|
|
message = "Bracket reset";
|
|
break;
|
|
}
|
|
case "UPDATE_IN_GAME_NAME": {
|
|
validateIsTournamentOrganizer();
|
|
|
|
const teamMemberOf = badRequestIfFalsy(
|
|
tournament.teamMemberOfByUser({ id: data.memberId }),
|
|
);
|
|
|
|
await TournamentTeamRepository.updateMemberInGameName({
|
|
userId: data.memberId,
|
|
inGameName: `${data.inGameNameText}#${data.inGameNameDiscriminator}`,
|
|
tournamentTeamId: teamMemberOf.id,
|
|
});
|
|
|
|
message = "Player in-game name updated";
|
|
break;
|
|
}
|
|
case "DELETE_LOGO": {
|
|
validateIsTournamentOrganizer();
|
|
|
|
await TournamentTeamRepository.deleteLogo(data.teamId);
|
|
|
|
message = "Logo deleted";
|
|
break;
|
|
}
|
|
case "UPDATE_TOURNAMENT_PROGRESSION": {
|
|
validateIsTournamentOrganizer();
|
|
errorToastIfFalsy(!tournament.ctx.isFinalized, "Tournament is finalized");
|
|
|
|
errorToastIfFalsy(
|
|
Progression.changedBracketProgression(
|
|
tournament.ctx.settings.bracketProgression,
|
|
data.bracketProgression,
|
|
).every(
|
|
(changedBracketIdx) =>
|
|
tournament.bracketByIdx(changedBracketIdx)?.preview,
|
|
),
|
|
"Can't change started brackets",
|
|
);
|
|
|
|
await TournamentRepository.updateProgression({
|
|
tournamentId: tournament.ctx.id,
|
|
bracketProgression: data.bracketProgression,
|
|
});
|
|
|
|
message = "Tournament progression updated";
|
|
break;
|
|
}
|
|
default: {
|
|
assertUnreachable(data);
|
|
}
|
|
}
|
|
|
|
clearTournamentDataCache(tournamentId);
|
|
|
|
return successToast(message);
|
|
};
|