From 890b73f7a1a35a8ff90dd549da2022a7dc06fa4a Mon Sep 17 00:00:00 2001 From: Kalle <38327916+Sendouc@users.noreply.github.com> Date: Sun, 9 Jun 2024 11:23:00 +0300 Subject: [PATCH] Tournament require IGNs option (#1760) * Initial * Edit IGN from profile * Admin action * Show IGN * Toggle visibility --- app/components/FriendCodeInput.tsx | 11 ++- app/db/tables.ts | 2 + .../calendar/CalendarRepository.server.ts | 3 + .../calendar/actions/calendar.new.server.ts | 5 ++ app/features/calendar/routes/calendar.new.tsx | 31 +++++++- app/features/sendouq/QRepository.server.ts | 4 +- .../tournament/TournamentRepository.server.ts | 7 +- .../TournamentTeamRepository.server.ts | 70 +++++++++++++++++ .../tournament/components/TeamWithRoster.tsx | 10 ++- .../tournament/queries/createTeam.server.ts | 10 ++- .../queries/joinLeaveTeam.server.ts | 10 ++- .../tournament/routes/to.$id.admin.tsx | 67 +++++++++++++++- .../tournament/routes/to.$id.join.tsx | 35 ++++++++- .../tournament/routes/to.$id.register.tsx | 77 ++++++++++++++++--- app/features/tournament/routes/to.$id.tsx | 1 + .../tournament/tournament-schemas.server.ts | 9 +++ .../tournament/tournament-utils.server.ts | 19 +++++ app/features/tournament/tournament.css | 2 +- .../user-page/UserRepository.server.ts | 11 +++ .../user-page/routes/u.$identifier.edit.tsx | 24 +++--- app/root.tsx | 1 + app/styles/utils.css | 4 + locales/en/tournament.json | 1 + migrations/060-tournament-team-member-ign.js | 7 ++ 24 files changed, 386 insertions(+), 35 deletions(-) create mode 100644 app/features/tournament/tournament-utils.server.ts create mode 100644 migrations/060-tournament-team-member-ign.js diff --git a/app/components/FriendCodeInput.tsx b/app/components/FriendCodeInput.tsx index 7203aea31..2c5d356b5 100644 --- a/app/components/FriendCodeInput.tsx +++ b/app/components/FriendCodeInput.tsx @@ -1,4 +1,5 @@ import { useFetcher } from "@remix-run/react"; +import clsx from "clsx"; import { useTranslation } from "react-i18next"; import { Input } from "~/components/Input"; import { Label } from "~/components/Label"; @@ -12,9 +13,15 @@ export function FriendCodeInput({ friendCode }: { friendCode?: string }) { return ( -
+
- + {!friendCode ? ( + + ) : null} {friendCode ? (
SW-{friendCode}
) : ( diff --git a/app/db/tables.ts b/app/db/tables.ts index 45d70b275..d20826e7c 100644 --- a/app/db/tables.ts +++ b/app/db/tables.ts @@ -428,6 +428,7 @@ export interface TournamentSettings { autoCheckInAll?: boolean; enableNoScreenToggle?: boolean; deadlines?: "STRICT" | "DEFAULT"; + requireInGameNames?: boolean; isInvitational?: boolean; /** Can teams add subs on their own while tournament is in progress? */ autonomousSubs?: boolean; @@ -616,6 +617,7 @@ export interface TournamentTeamCheckIn { export interface TournamentTeamMember { createdAt: Generated; isOwner: Generated; + inGameName: string | null; tournamentTeamId: number; userId: number; } diff --git a/app/features/calendar/CalendarRepository.server.ts b/app/features/calendar/CalendarRepository.server.ts index 865130c89..095721c0f 100644 --- a/app/features/calendar/CalendarRepository.server.ts +++ b/app/features/calendar/CalendarRepository.server.ts @@ -421,6 +421,7 @@ type CreateArgs = Pick< teamsPerGroup?: number; thirdPlaceMatch?: boolean; autoCheckInAll?: boolean; + requireInGameNames?: boolean; isRanked?: boolean; isInvitational?: boolean; deadlines: TournamentSettings["deadlines"]; @@ -461,6 +462,7 @@ export async function create(args: CreateArgs) { autonomousSubs: args.autonomousSubs, regClosesAt: args.regClosesAt, autoCheckInAll: args.autoCheckInAll, + requireInGameNames: args.requireInGameNames, swiss: args.swissGroupCount && args.swissRoundCount ? { @@ -613,6 +615,7 @@ export async function update(args: UpdateArgs) { autonomousSubs: args.autonomousSubs, regClosesAt: args.regClosesAt, autoCheckInAll: args.autoCheckInAll, + requireInGameNames: args.requireInGameNames, swiss: args.swissGroupCount && args.swissRoundCount ? { diff --git a/app/features/calendar/actions/calendar.new.server.ts b/app/features/calendar/actions/calendar.new.server.ts index d904fed09..b2aabbe46 100644 --- a/app/features/calendar/actions/calendar.new.server.ts +++ b/app/features/calendar/actions/calendar.new.server.ts @@ -99,6 +99,7 @@ export const action: ActionFunction = async ({ request }) => { isInvitational: data.isInvitational ?? false, deadlines: data.strictDeadline ? ("STRICT" as const) : ("DEFAULT" as const), enableNoScreenToggle: data.enableNoScreenToggle ?? undefined, + requireInGameNames: data.requireInGameNames ?? undefined, autoCheckInAll: data.autoCheckInAll ?? undefined, autonomousSubs: data.autonomousSubs ?? undefined, swissGroupCount: data.swissGroupCount ?? undefined, @@ -266,6 +267,10 @@ export const newCalendarEventActionSchema = z autonomousSubs: z.preprocess(checkboxValueToBoolean, z.boolean().nullish()), strictDeadline: z.preprocess(checkboxValueToBoolean, z.boolean().nullish()), isInvitational: z.preprocess(checkboxValueToBoolean, z.boolean().nullish()), + requireInGameNames: z.preprocess( + checkboxValueToBoolean, + z.boolean().nullish(), + ), // // tournament format related fields // diff --git a/app/features/calendar/routes/calendar.new.tsx b/app/features/calendar/routes/calendar.new.tsx index 3fe3c1d8f..0aa3e5ebb 100644 --- a/app/features/calendar/routes/calendar.new.tsx +++ b/app/features/calendar/routes/calendar.new.tsx @@ -203,6 +203,7 @@ function EventForm() { + @@ -863,6 +864,34 @@ function AutonomousSubsToggle() { ); } +function RequireIGNToggle() { + const baseEvent = useBaseEvent(); + const [requireIGNs, setRequireIGNs] = React.useState( + baseEvent?.tournamentCtx?.settings.requireInGameNames ?? false, + ); + const id = React.useId(); + + return ( +
+ + + + If enabled players can't join the tournament without an in-game + name (e.g. Sendou#1234). Players can't change the IGNs after the + registration closes. + +
+ ); +} + function InvitationalToggle() { const baseEvent = useBaseEvent(); const [isInvitational, setIsInvitational] = React.useState( @@ -1194,7 +1223,7 @@ function TournamentFormatSelector() { ? baseEvent.tournamentCtx.settings.bracketProgression.some( (b) => b.name === BRACKET_NAMES.UNDERGROUND, ) - : true, + : false, ); const [thirdPlaceMatch, setThirdPlaceMatch] = React.useState( baseEvent?.tournamentCtx?.settings.thirdPlaceMatch ?? true, diff --git a/app/features/sendouq/QRepository.server.ts b/app/features/sendouq/QRepository.server.ts index c7f766a1a..21bc44a19 100644 --- a/app/features/sendouq/QRepository.server.ts +++ b/app/features/sendouq/QRepository.server.ts @@ -282,7 +282,7 @@ export async function usersThatTrusted(userId: number) { .selectFrom("TeamMember") .innerJoin("User", "User.id", "TeamMember.userId") .innerJoin("UserFriendCode", "UserFriendCode.userId", "User.id") - .select(COMMON_USER_FIELDS) + .select([...COMMON_USER_FIELDS, "User.inGameName"]) .where((eb) => eb( "TeamMember.teamId", @@ -298,7 +298,7 @@ export async function usersThatTrusted(userId: number) { .selectFrom("TrustRelationship") .innerJoin("User", "User.id", "TrustRelationship.trustGiverUserId") .innerJoin("UserFriendCode", "UserFriendCode.userId", "User.id") - .select(COMMON_USER_FIELDS) + .select([...COMMON_USER_FIELDS, "User.inGameName"]) .where("TrustRelationship.trustReceiverUserId", "=", userId), ) .orderBy("User.username asc") diff --git a/app/features/tournament/TournamentRepository.server.ts b/app/features/tournament/TournamentRepository.server.ts index e8cc46dd4..c5be42a3b 100644 --- a/app/features/tournament/TournamentRepository.server.ts +++ b/app/features/tournament/TournamentRepository.server.ts @@ -1,5 +1,5 @@ import { add } from "date-fns"; -import type { Insertable, NotNull, Transaction } from "kysely"; +import { sql, type Insertable, type NotNull, type Transaction } from "kysely"; import { jsonArrayFrom, jsonObjectFrom } from "kysely/helpers/sqlite"; import { nanoid } from "nanoid"; import { db } from "~/db/sql"; @@ -98,11 +98,14 @@ export async function findById(id: number) { "User.discordId", "User.discordAvatar", "User.customUrl", - "User.inGameName", "User.country", "PlusTier.tier as plusTier", "TournamentTeamMember.isOwner", "TournamentTeamMember.createdAt", + sql/*sql*/ `coalesce( + "TournamentTeamMember"."inGameName", + "User"."inGameName" + )`.as("inGameName"), ]) .whereRef( "TournamentTeamMember.tournamentTeamId", diff --git a/app/features/tournament/TournamentTeamRepository.server.ts b/app/features/tournament/TournamentTeamRepository.server.ts index 8916ddf2d..0d43fd7c8 100644 --- a/app/features/tournament/TournamentTeamRepository.server.ts +++ b/app/features/tournament/TournamentTeamRepository.server.ts @@ -1,6 +1,8 @@ // TODO: add rest of the functions here that relate more to tournament teams than tournament/bracket +import { sql } from "kysely"; import { db } from "~/db/sql"; +import { databaseTimestampNow } from "~/utils/dates"; export function setActiveRoster({ teamId, @@ -15,3 +17,71 @@ export function setActiveRoster({ .where("TournamentTeam.id", "=", teamId) .execute(); } + +const regOpenTournamentTeamIdsByJoinedUserId = (userId: number) => + db + .selectFrom("TournamentTeamMember") + .innerJoin( + "TournamentTeam", + "TournamentTeam.id", + "TournamentTeamMember.tournamentTeamId", + ) + .innerJoin("Tournament", "Tournament.id", "TournamentTeam.tournamentId") + .innerJoin("CalendarEvent", "CalendarEvent.tournamentId", "Tournament.id") + .innerJoin( + "CalendarEventDate", + "CalendarEventDate.eventId", + "CalendarEvent.id", + ) + .select("TournamentTeamMember.tournamentTeamId") + .where("TournamentTeamMember.userId", "=", userId) + .where( + sql`coalesce( + "Tournament"."settings" ->> 'regClosesAt', + "CalendarEventDate"."startTime" + )`, + ">", + databaseTimestampNow(), + ) + .execute() + .then((rows) => rows.map((row) => row.tournamentTeamId)); + +export async function updateMemberInGameName({ + userId, + inGameName, + tournamentTeamId, +}: { + userId: number; + inGameName: string; + tournamentTeamId: number; +}) { + return db + .updateTable("TournamentTeamMember") + .set({ inGameName }) + .where("TournamentTeamMember.userId", "=", userId) + .where("TournamentTeamMember.tournamentTeamId", "=", tournamentTeamId) + .execute(); +} + +export async function updateMemberInGameNameForNonStarted({ + userId, + inGameName, +}: { + userId: number; + inGameName: string; +}) { + const tournamentTeamIds = + await regOpenTournamentTeamIdsByJoinedUserId(userId); + + return ( + db + .updateTable("TournamentTeamMember") + .set({ inGameName }) + .where("TournamentTeamMember.userId", "=", userId) + // after they have checked in no longer can update their IGN from here + .where("TournamentTeamMember.tournamentTeamId", "in", tournamentTeamIds) + // if the tournament doesn't have the setting to require IGN, ignore + .where("TournamentTeamMember.inGameName", "is not", null) + .execute() + ); +} diff --git a/app/features/tournament/components/TeamWithRoster.tsx b/app/features/tournament/components/TeamWithRoster.tsx index e18b65c79..f9d0e9766 100644 --- a/app/features/tournament/components/TeamWithRoster.tsx +++ b/app/features/tournament/components/TeamWithRoster.tsx @@ -59,6 +59,14 @@ export function TeamWithRoster({ databaseTimestampToDate(member.createdAt) > tournament.ctx.startTime; + const name = () => { + if (!tournament.ctx.settings.requireInGameNames) { + return member.username; + } + + return member.inGameName ?? member.username; + }; + return (
  • {member.isOwner ? ( @@ -89,7 +97,7 @@ export function TeamWithRoster({ to={userPage(member)} className="tournament__team-member-name" > - {member.username}{" "} + {name()}
  • {friendCode ? ( diff --git a/app/features/tournament/queries/createTeam.server.ts b/app/features/tournament/queries/createTeam.server.ts index 148bda44f..cd46896eb 100644 --- a/app/features/tournament/queries/createTeam.server.ts +++ b/app/features/tournament/queries/createTeam.server.ts @@ -25,10 +25,12 @@ const createMemberStm = sql.prepare(/*sql*/ ` insert into "TournamentTeamMember" ( "tournamentTeamId", "userId", + "inGameName", "isOwner" ) values ( @tournamentTeamId, @userId, + @inGameName, 1 ) `); @@ -38,6 +40,7 @@ export const createTeam = sql.transaction( tournamentId, name, ownerId, + ownerInGameName, prefersNotToHost, noScreen, teamId, @@ -45,6 +48,7 @@ export const createTeam = sql.transaction( tournamentId: TournamentTeam["tournamentId"]; name: TournamentTeam["name"]; ownerId: User["id"]; + ownerInGameName: string | null; prefersNotToHost: TournamentTeam["prefersNotToHost"]; noScreen: number; teamId: number | null; @@ -58,6 +62,10 @@ export const createTeam = sql.transaction( teamId, }) as TournamentTeam; - createMemberStm.run({ tournamentTeamId: team.id, userId: ownerId }); + createMemberStm.run({ + tournamentTeamId: team.id, + inGameName: ownerInGameName, + userId: ownerId, + }); }, ); diff --git a/app/features/tournament/queries/joinLeaveTeam.server.ts b/app/features/tournament/queries/joinLeaveTeam.server.ts index 9e04ac976..1ac9f0e16 100644 --- a/app/features/tournament/queries/joinLeaveTeam.server.ts +++ b/app/features/tournament/queries/joinLeaveTeam.server.ts @@ -6,9 +6,11 @@ import { deleteSub } from "~/features/tournament-subs"; const createTeamMemberStm = sql.prepare(/*sql*/ ` insert into "TournamentTeamMember" ( "tournamentTeamId", + "inGameName", "userId" ) values ( @tournamentTeamId, + @inGameName, @userId ) `); @@ -31,6 +33,7 @@ export const joinTeam = sql.transaction( whatToDoWithPreviousTeam, newTeamId, userId, + inGameName, tournamentId, checkOutTeam = false, }: { @@ -38,6 +41,7 @@ export const joinTeam = sql.transaction( whatToDoWithPreviousTeam?: "LEAVE" | "DELETE"; newTeamId: number; userId: number; + inGameName: string | null; tournamentId: number; checkOutTeam?: boolean; }) => { @@ -59,7 +63,11 @@ export const joinTeam = sql.transaction( checkOut(previousTeamId); } - createTeamMemberStm.run({ tournamentTeamId: newTeamId, userId }); + createTeamMemberStm.run({ + tournamentTeamId: newTeamId, + userId, + inGameName, + }); }, ); diff --git a/app/features/tournament/routes/to.$id.admin.tsx b/app/features/tournament/routes/to.$id.admin.tsx index cd389ba0e..01d777cd6 100644 --- a/app/features/tournament/routes/to.$id.admin.tsx +++ b/app/features/tournament/routes/to.$id.admin.tsx @@ -20,7 +20,11 @@ import type { TournamentData } from "~/features/tournament-bracket/core/Tourname import { tournamentFromDB } from "~/features/tournament-bracket/core/Tournament.server"; import { isAdmin } from "~/permissions"; import { databaseTimestampToDate } from "~/utils/dates"; -import { parseRequestFormData, validate } from "~/utils/remix"; +import { + badRequestIfFalsy, + parseRequestFormData, + validate, +} from "~/utils/remix"; import { assertUnreachable } from "~/utils/types"; import { calendarEditPage, @@ -40,6 +44,9 @@ import { findMapPoolByTeamId } from "~/features/tournament-bracket/queries/findM import { Input } from "~/components/Input"; import { logger } from "~/utils/logger"; import { userIsBanned } from "~/features/ban/core/banned.server"; +import { inGameNameIfNeeded } from "../tournament-utils.server"; +import * as TournamentTeamRepository from "~/features/tournament/TournamentTeamRepository.server"; +import { USER } from "~/constants"; export const action: ActionFunction = async ({ request, params }) => { const user = await requireUserId(request); @@ -75,6 +82,10 @@ export const action: ActionFunction = async ({ request, params }) => { ownerId: data.userId, prefersNotToHost: 0, noScreen: 0, + ownerInGameName: await inGameNameIfNeeded({ + tournament, + userId: data.userId, + }), }); break; @@ -224,6 +235,10 @@ export const action: ActionFunction = async ({ request, params }) => { // this team is not checked in so we can simply delete it whatToDoWithPreviousTeam: previousTeam ? "DELETE" : undefined, tournamentId, + inGameName: await inGameNameIfNeeded({ + tournament, + userId: data.userId, + }), }); break; } @@ -302,6 +317,20 @@ export const action: ActionFunction = async ({ request, params }) => { 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, + }); + break; + } default: { assertUnreachable(data); } @@ -375,7 +404,8 @@ type Input = | "REGISTERED_TEAM" | "USER" | "ROSTER_MEMBER" - | "BRACKET"; + | "BRACKET" + | "IN_GAME_NAME"; const actions = [ { type: "ADD_TEAM", @@ -427,6 +457,11 @@ const actions = [ inputs: ["REGISTERED_TEAM"] as Input[], when: ["TOURNAMENT_AFTER_START", "IS_SWISS"], }, + { + type: "UPDATE_IN_GAME_NAME", + inputs: ["ROSTER_MEMBER", "REGISTERED_TEAM", "IN_GAME_NAME"] as Input[], + when: ["IN_GAME_NAME_REQUIRED"], + }, ] as const; function TeamActions() { @@ -461,12 +496,14 @@ function TeamActions() { if (tournament.hasStarted) { return false; } + break; } case "TOURNAMENT_AFTER_START": { if (!tournament.hasStarted) { return false; } + break; } case "IS_SWISS": { @@ -476,6 +513,13 @@ function TeamActions() { break; } + case "IN_GAME_NAME_REQUIRED": { + if (!tournament.ctx.settings.requireInGameNames) { + return false; + } + + break; + } default: { assertUnreachable(when); } @@ -563,6 +607,25 @@ function TeamActions() {
    ) : null} + {selectedTeam && selectedAction.inputs.includes("IN_GAME_NAME") ? ( +
    + +
    + +
    #
    + +
    +
    + ) : null} { const tournamentId = tournamentIdFromParams(params); @@ -85,6 +88,10 @@ export const action: ActionFunction = async ({ request, params }) => { previousTeam.members.length <= TOURNAMENT.TEAM_MIN_MEMBERS_FOR_FULL, whatToDoWithPreviousTeam, tournamentId, + inGameName: await inGameNameIfNeeded({ + tournament, + userId: user.id, + }), }); if (data.trust) { const inviterUserId = teamToJoin.members.find( @@ -150,12 +157,32 @@ export default function JoinTeamPage() { } }; + if (tournament.ctx.settings.requireInGameNames && user && !user.inGameName) { + return ( + +
    + This tournament requires you to have an in-game name set{" "} + + Edit profile + +
    +
    + ); + } + return (
    {textPrompt()}
    - {validationStatus === "VALID" ? ( - - ) : null} +
    + {validationStatus === "VALID" ? ( + + ) : null} + {user?.inGameName ? ( +
    + IGN {user.inGameName} +
    + ) : null} +
    {validationStatus === "VALID" ? (
    diff --git a/app/features/tournament/routes/to.$id.register.tsx b/app/features/tournament/routes/to.$id.register.tsx index 55a4226e3..bd1e23217 100644 --- a/app/features/tournament/routes/to.$id.register.tsx +++ b/app/features/tournament/routes/to.$id.register.tsx @@ -51,6 +51,7 @@ import { readonlyMapsPage, tournamentJoinPage, tournamentSubsPage, + userEditProfilePage, userPage, } from "~/utils/urls"; import { checkIn } from "../queries/checkIn.server"; @@ -79,6 +80,7 @@ import { useSearchParamState } from "~/hooks/useSearchParamState"; import * as TeamRepository from "~/features/team/TeamRepository.server"; import { Toggle } from "~/components/Toggle"; import { DiscordIcon } from "~/components/icons/Discord"; +import { inGameNameIfNeeded } from "../tournament-utils.server"; export const action: ActionFunction = async ({ request, params }) => { const user = await requireUser(request); @@ -135,6 +137,10 @@ export const action: ActionFunction = async ({ request, params }) => { ownerId: user.id, prefersNotToHost: booleanToInt(data.prefersNotToHost), noScreen: booleanToInt(data.noScreen), + ownerInGameName: await inGameNameIfNeeded({ + tournament, + userId: user.id, + }), teamId: data.teamId ?? null, }); } @@ -239,6 +245,10 @@ export const action: ActionFunction = async ({ request, params }) => { userId: data.userId, newTeamId: ownTeam.id, tournamentId, + inGameName: await inGameNameIfNeeded({ + tournament, + userId: data.userId, + }), }); break; } @@ -377,6 +387,12 @@ function TournamentRegisterInfoTabs() { revive: Number, }); + const showAddIGNAlert = + tournament.ctx.settings.requireInGameNames && + !teamOwned && + user && + !user?.inGameName; + return (
    ) : null}
    + ) : showAddIGNAlert ? ( +
    + +
    + This tournament requires you to have an in-game name set{" "} + + Edit profile + +
    +
    +
    ) : ( )} {user && !tournament.teamMemberOfByUser(user) && tournament.canAddNewSubPost && + !showAddIGNAlert && !tournament.hasStarted ? ( ) : null}
    - {data?.team ? ( + {data?.team && tournament.registrationOpen ? (
    + {friendCode ? ( +
    + Is the friend code above wrong? Contact Sendou directly to change it. +
    + ) : null}
    ); } @@ -933,9 +966,14 @@ function FillRoster({ const playersAvailableToDirectlyAdd = (() => { return (data!.trusterPlayers ?? []).filter((user) => { - return tournament.ctx.teams.every((team) => + const isNotInTeam = tournament.ctx.teams.every((team) => team.members.every((member) => member.userId !== user.id), ); + + const hasInGameNameIfNeeded = + !tournament.ctx.settings.requireInGameNames || user.inGameName; + + return isNotInTeam && hasInGameNameIfNeeded; }); })(); @@ -979,7 +1017,20 @@ function FillRoster({ data-testid={`member-num-${i + 1}`} > - {member.username} + {tournament.ctx.settings.requireInGameNames ? ( +
    +
    + {member.inGameName ?? member.username} +
    + {member.inGameName ? ( +
    + {member.username} +
    + ) : null} +
    + ) : ( + member.username + )}
    ); })} @@ -1005,12 +1056,20 @@ function FillRoster({ ) : null} -
    - {t("tournament:pre.roster.footer", { - atLeastCount: TOURNAMENT.TEAM_MIN_MEMBERS_FOR_FULL, - maxCount: tournament.maxTeamMemberCount, - })} -
    + {tournament.ctx.settings.requireInGameNames ? ( +
    + Note that you are expected to use the in-game names as listed above. + Playing in the event with a different name or using the alias feature + might result in disqualification. +
    + ) : ( +
    + {t("tournament:pre.roster.footer", { + atLeastCount: TOURNAMENT.TEAM_MIN_MEMBERS_FOR_FULL, + maxCount: tournament.maxTeamMemberCount, + })} +
    + )}
    ); } diff --git a/app/features/tournament/routes/to.$id.tsx b/app/features/tournament/routes/to.$id.tsx index 7f450daa9..2eb5e4f04 100644 --- a/app/features/tournament/routes/to.$id.tsx +++ b/app/features/tournament/routes/to.$id.tsx @@ -54,6 +54,7 @@ export const meta: MetaFunction = (args) => { const title = makeTitle(data.tournament.ctx.name); return [ + { title }, { property: "og:title", content: title, diff --git a/app/features/tournament/tournament-schemas.server.ts b/app/features/tournament/tournament-schemas.server.ts index 6f00f753a..f442d07ed 100644 --- a/app/features/tournament/tournament-schemas.server.ts +++ b/app/features/tournament/tournament-schemas.server.ts @@ -10,6 +10,7 @@ import { } from "~/utils/zod"; import { TOURNAMENT } from "./tournament-constants"; import { bracketIdx } from "../tournament-bracket/tournament-bracket-schemas.server"; +import { USER } from "~/constants"; const teamName = z.string().trim().min(1).max(TOURNAMENT.TEAM_NAME_MAX_LENGTH); @@ -129,6 +130,14 @@ export const adminActionSchema = z.union([ _action: _action("RESET_BRACKET"), stageId: id, }), + z.object({ + _action: _action("UPDATE_IN_GAME_NAME"), + inGameNameText: z.string().max(USER.IN_GAME_NAME_TEXT_MAX_LENGTH), + inGameNameDiscriminator: z + .string() + .refine((val) => /^[0-9a-z]{4,5}$/.test(val)), + memberId: id, + }), ]); export const joinSchema = z.object({ diff --git a/app/features/tournament/tournament-utils.server.ts b/app/features/tournament/tournament-utils.server.ts new file mode 100644 index 000000000..365f8d5d9 --- /dev/null +++ b/app/features/tournament/tournament-utils.server.ts @@ -0,0 +1,19 @@ +import { validate } from "~/utils/remix"; +import * as UserRepository from "~/features/user-page/UserRepository.server"; +import type { Tournament } from "../tournament-bracket/core/Tournament"; + +export const inGameNameIfNeeded = async ({ + tournament, + userId, +}: { + tournament: Tournament; + userId: number; +}) => { + if (!tournament.ctx.settings.requireInGameNames) return null; + + const inGameName = await UserRepository.inGameNameByUserId(userId); + + validate(inGameName, "No in-game name"); + + return inGameName; +}; diff --git a/app/features/tournament/tournament.css b/app/features/tournament/tournament.css index c42293f14..22408536c 100644 --- a/app/features/tournament/tournament.css +++ b/app/features/tournament/tournament.css @@ -186,7 +186,7 @@ color: var(--text); text-overflow: ellipsis; white-space: nowrap; - max-width: 100px; + max-width: 150px; } .tournament__team-member-name__role { diff --git a/app/features/user-page/UserRepository.server.ts b/app/features/user-page/UserRepository.server.ts index 1ee4d78d4..adf02b41d 100644 --- a/app/features/user-page/UserRepository.server.ts +++ b/app/features/user-page/UserRepository.server.ts @@ -100,6 +100,7 @@ export function findLeanById(id: number) { "User.patronTier", "User.favoriteBadgeId", "User.languages", + "User.inGameName", "PlusTier.tier as plusTier", ]) .executeTakeFirst(); @@ -354,6 +355,16 @@ export async function currentFriendCodeByUserId(userId: number) { .executeTakeFirst(); } +export async function inGameNameByUserId(userId: number) { + return ( + await db + .selectFrom("User") + .select("User.inGameName") + .where("id", "=", userId) + .executeTakeFirst() + )?.inGameName; +} + export function insertFriendCode(args: TablesInsertable["UserFriendCode"]) { return db.insertInto("UserFriendCode").values(args).execute(); } diff --git a/app/features/user-page/routes/u.$identifier.edit.tsx b/app/features/user-page/routes/u.$identifier.edit.tsx index 1303e14e9..59a94ff88 100644 --- a/app/features/user-page/routes/u.$identifier.edit.tsx +++ b/app/features/user-page/routes/u.$identifier.edit.tsx @@ -24,7 +24,7 @@ import { StarIcon } from "~/components/icons/Star"; import { StarFilledIcon } from "~/components/icons/StarFilled"; import { TrashIcon } from "~/components/icons/Trash"; import { USER } from "~/constants"; -import type { User, UserWeapon } from "~/db/types"; +import type { User } from "~/db/types"; import { useUser } from "~/features/auth/core/user"; import { requireUser, requireUserId } from "~/features/auth/core/user.server"; import * as UserRepository from "~/features/user-page/UserRepository.server"; @@ -50,6 +50,7 @@ import { } from "~/utils/zod"; import { type UserPageLoaderData } from "./u.$identifier"; import { userParamsSchema } from "../user-page-schemas.server"; +import * as TournamentTeamRepository from "~/features/tournament/TournamentTeamRepository.server"; import "~/styles/u-edit.css"; @@ -166,21 +167,26 @@ export const action: ActionFunction = async ({ request }) => { const { inGameNameText, inGameNameDiscriminator, ...data } = parsedInput.data; const user = await requireUserId(request); + const inGameName = + inGameNameText && inGameNameDiscriminator + ? `${inGameNameText}#${inGameNameDiscriminator}` + : null; try { const editedUser = await UserRepository.updateProfile({ ...data, - weapons: data.weapons as Array< - Pick - >, - inGameName: - inGameNameText && inGameNameDiscriminator - ? `${inGameNameText}#${inGameNameDiscriminator}` - : null, + inGameName, userId: user.id, - showDiscordUniqueName: data.showDiscordUniqueName, }); + // TODO: to transaction + if (inGameName) { + await TournamentTeamRepository.updateMemberInGameNameForNonStarted({ + inGameName, + userId: user.id, + }); + } + throw redirect(userPage(editedUser)); } catch (e) { if (!errorIsSqliteUniqueConstraintFailure(e)) { diff --git a/app/root.tsx b/app/root.tsx index b258a2c55..86efb855e 100644 --- a/app/root.tsx +++ b/app/root.tsx @@ -121,6 +121,7 @@ export const loader = async ({ request }: LoaderFunctionArgs) => { patronTier: user.patronTier, isArtist: user.isArtist, isVideoAdder: user.isVideoAdder, + inGameName: user.inGameName, languages: user.languages ? user.languages.split(",") : [], } : undefined, diff --git a/app/styles/utils.css b/app/styles/utils.css index 7b0bc8c60..f75acf2bf 100644 --- a/app/styles/utils.css +++ b/app/styles/utils.css @@ -58,6 +58,10 @@ color: var(--theme-warning); } +.text-warning-important { + color: var(--theme-warning) !important; +} + .text-theme { color: var(--theme); } diff --git a/locales/en/tournament.json b/locales/en/tournament.json index e23d8d98b..743633de5 100644 --- a/locales/en/tournament.json +++ b/locales/en/tournament.json @@ -77,6 +77,7 @@ "admin.actions.ADD_TEAM": "Register team", "admin.actions.DROP_TEAM_OUT": "Drop out team", "admin.actions.UNDO_DROP_TEAM_OUT": "Undo drop out", + "admin.actions.UPDATE_IN_GAME_NAME": "Update player IGN", "staff.role.ORGANIZER": "organizer", "staff.role.STREAMER": "streamer", diff --git a/migrations/060-tournament-team-member-ign.js b/migrations/060-tournament-team-member-ign.js new file mode 100644 index 000000000..0111f66c0 --- /dev/null +++ b/migrations/060-tournament-team-member-ign.js @@ -0,0 +1,7 @@ +export function up(db) { + db.transaction(() => { + db.prepare( + /* sql */ `alter table "tournamentTeamMember" add "inGameName" text`, + ).run(); + })(); +}