diff --git a/app/db/tables-json.ts b/app/db/tables-json.ts index 9d6d2c89a..f5592713d 100644 --- a/app/db/tables-json.ts +++ b/app/db/tables-json.ts @@ -222,6 +222,8 @@ export interface TournamentAuditLogMetadata { bracketIdx?: number; /** The new in-game name, for `UPDATE_IN_GAME_NAME` events. */ inGameName?: string; + /** The new tournament name, for `UPDATE_TOURNAMENT_NAME` events. `null` = it was cleared. */ + tournamentName?: string | null; } /** diff --git a/app/db/tables.ts b/app/db/tables.ts index 5b11eed60..68fcfa3e8 100644 --- a/app/db/tables.ts +++ b/app/db/tables.ts @@ -856,6 +856,8 @@ export interface User { customName: string | null; /** coalesce(customName, discordName) */ username: ColumnType; + /** Name the user is shown under in tournaments, set by organizers of established organizations. `null` = their `username` is used. */ + tournamentName: string | null; discordUniqueName: string | null; /** User's favorite badges they want to show on the front page of the badge display. Index = 0 big badge. */ favoriteBadgeIds: JSONColumnTypeNullable; diff --git a/app/features/api-public/routes/tournament.$id.teams.test.ts b/app/features/api-public/routes/tournament.$id.teams.test.ts new file mode 100644 index 000000000..0b891dfda --- /dev/null +++ b/app/features/api-public/routes/tournament.$id.teams.test.ts @@ -0,0 +1,72 @@ +import { describe, expect, test } from "vitest"; +import * as TournamentFactory from "~/db/seed/factories/TournamentFactory"; +import * as TournamentTeamFactory from "~/db/seed/factories/TournamentTeamFactory"; +import * as UserFactory from "~/db/seed/factories/UserFactory"; +import * as TournamentTeamRepository from "~/features/tournament/TournamentTeamRepository.server"; +import { withUserId, wrappedLoader } from "~/utils/Test"; +import type { GetTournamentTeamsResponse } from "../schema"; +import { loader } from "./tournament.$id.teams"; + +const TEAM_NAME = "Team Olive"; + +const teamsLoader = wrappedLoader({ loader }); + +const fetchTeams = async (tournamentId: number) => { + const response = await teamsLoader({ + params: { id: String(tournamentId) }, + }); + + return (await response.json()) as GetTournamentTeamsResponse; +}; + +const registeredPlayer = async () => { + const organizer = await UserFactory.create(); + const player = await UserFactory.create({ + discordName: "xXsplatlordXx", + profile: null, + }); + const tournament = await TournamentFactory.create({ + authorId: organizer.id, + }); + const team = await TournamentTeamFactory.create({ + tournamentId: tournament.id, + memberUserIds: [player.id], + team: { name: TEAM_NAME, prefersNotToHost: 0, teamId: null }, + }); + + return { organizer, player, tournament, team }; +}; + +describe("GET /api/tournament/:id/teams", () => { + test("returns the tournament name organizers gave a player instead of their username", async () => { + const { organizer, player, tournament, team } = await registeredPlayer(); + + await withUserId(organizer.id, () => + TournamentTeamRepository.upsertRegistration({ + tournamentTeamId: team.id, + tournamentId: tournament.id, + name: TEAM_NAME, + teamId: null, + avatarImgId: null, + ownerUserId: player.id, + ownerChange: null, + membersToAdd: [], + membersToRemove: [], + inGameNameUpdates: [], + tournamentNameUpdates: [{ userId: player.id, tournamentName: "Riko" }], + }), + ); + + const teams = await fetchTeams(tournament.id); + + expect(teams[0].members[0].name).toBe("Riko"); + }); + + test("falls back to the username of a player without a tournament name", async () => { + const { tournament } = await registeredPlayer(); + + const teams = await fetchTeams(tournament.id); + + expect(teams[0].members[0].name).toBe("xXsplatlordXx"); + }); +}); diff --git a/app/features/api-public/routes/tournament.$id.teams.ts b/app/features/api-public/routes/tournament.$id.teams.ts index e703b98af..a0eb2eb87 100644 --- a/app/features/api-public/routes/tournament.$id.teams.ts +++ b/app/features/api-public/routes/tournament.$id.teams.ts @@ -8,7 +8,10 @@ import * as TournamentRepository from "~/features/tournament/TournamentRepositor import { getFixedTForLanguage } from "~/modules/i18n/i18next.server"; import { nullifyingAvg } from "~/utils/arrays"; import { databaseTimestampToDate } from "~/utils/dates"; -import { concatUserSubmittedImagePrefix } from "~/utils/kysely.server"; +import { + concatUserSubmittedImagePrefix, + tournamentUsername, +} from "~/utils/kysely.server"; import { parseParams } from "~/utils/remix.server"; import { id } from "~/utils/zod"; import type { GetTournamentTeamsResponse } from "../schema"; @@ -78,7 +81,7 @@ export const loader = async ({ params }: LoaderFunctionArgs) => { ) .select([ "User.id as userId", - "User.username", + tournamentUsername().as("username"), "User.discordId", "User.discordAvatar", "User.battlefy", diff --git a/app/features/api-public/routes/tournament.$id.teams.upsert.test.ts b/app/features/api-public/routes/tournament.$id.teams.upsert.test.ts new file mode 100644 index 000000000..938dcf9ad --- /dev/null +++ b/app/features/api-public/routes/tournament.$id.teams.upsert.test.ts @@ -0,0 +1,88 @@ +import { describe, expect, test } from "vitest"; +import * as TournamentFactory from "~/db/seed/factories/TournamentFactory"; +import * as TournamentTeamFactory from "~/db/seed/factories/TournamentTeamFactory"; +import * as UserFactory from "~/db/seed/factories/UserFactory"; +import { db } from "~/db/sql"; +import { ADMIN_ID } from "~/features/admin/admin-constants"; +import * as TournamentTeamRepository from "~/features/tournament/TournamentTeamRepository.server"; +import { withUserId, wrappedAction } from "~/utils/Test"; +import { action } from "./tournament.$id.teams.upsert"; + +const TEAM_NAME = "Team Olive"; + +const upsertAction = wrappedAction({ action, isJsonSubmission: true }); + +const tournamentNameOf = async (userId: number) => + ( + await db + .selectFrom("User") + .select("User.tournamentName") + .where("User.id", "=", userId) + .executeTakeFirstOrThrow() + ).tournamentName; + +/** A one player team of a tournament the API token holder organizes, the player named "Riko". */ +const namedPlayerTeam = async () => { + await UserFactory.createAdmin(); + const player = await UserFactory.create(); + const tournament = await TournamentFactory.create({ authorId: ADMIN_ID }); + const team = await TournamentTeamFactory.create({ + tournamentId: tournament.id, + memberUserIds: [player.id], + team: { name: TEAM_NAME, prefersNotToHost: 0, teamId: null }, + }); + + await withUserId(ADMIN_ID, () => + TournamentTeamRepository.upsertRegistration({ + tournamentTeamId: team.id, + tournamentId: tournament.id, + name: TEAM_NAME, + teamId: null, + avatarImgId: null, + ownerUserId: player.id, + ownerChange: null, + membersToAdd: [], + membersToRemove: [], + inGameNameUpdates: [], + tournamentNameUpdates: [{ userId: player.id, tournamentName: "Riko" }], + }), + ); + + return { player, tournament, team }; +}; + +// tournament names are readable through the API but never writable, not even by a +// token holder who is allowed to edit them in the admin form +describe("POST /api/tournament/:id/teams/upsert", () => { + test("keeps the tournament names of the roster", async () => { + const { player, tournament, team } = await namedPlayerTeam(); + + await upsertAction( + { + tournamentTeamId: team.id, + name: "Renamed Team", + ownerUserId: player.id, + members: [{ userId: player.id }], + }, + { user: "admin", params: { id: String(tournament.id) } }, + ); + + expect(await tournamentNameOf(player.id)).toBe("Riko"); + }); + + test("ignores a tournament name submitted for a member", async () => { + const { player, tournament, team } = await namedPlayerTeam(); + + await upsertAction( + { + tournamentTeamId: team.id, + name: TEAM_NAME, + ownerUserId: player.id, + members: [{ userId: player.id, tournamentName: "Not Riko" }], + }, + { user: "admin", params: { id: String(tournament.id) } }, + ); + + expect(await tournamentNameOf(player.id)).toBe("Riko"); + }); +}); diff --git a/app/features/api-public/routes/tournament.$id.teams.upsert.ts b/app/features/api-public/routes/tournament.$id.teams.upsert.ts index bc9346e39..477dbb008 100644 --- a/app/features/api-public/routes/tournament.$id.teams.upsert.ts +++ b/app/features/api-public/routes/tournament.$id.teams.upsert.ts @@ -2,7 +2,7 @@ import type { ActionFunctionArgs } from "react-router"; import { z } from "zod"; import * as TournamentRepository from "~/features/tournament/TournamentRepository.server"; import { TOURNAMENT } from "~/features/tournament/tournament-constants"; -import { action as adminAction } from "~/features/tournament-admin/actions/to.$id.admin.registration.server"; +import { upsertRegistrationAction } from "~/features/tournament-admin/actions/to.$id.admin.registration.server"; import { ADMIN_REGISTRATION_MAX_MEMBERS } from "~/features/tournament-admin/tournament-admin-registration-schemas"; import { existingImage } from "~/form/image-field"; import { parseBody, parseParams } from "~/utils/remix.server"; @@ -79,9 +79,16 @@ export const action = async (args: ActionFunctionArgs) => { }), }); - return wrapActionForApi(adminAction, { - ...args, - params: { id: String(tournamentId) }, - request: internalRequest, - }); + return wrapActionForApi( + (actionArgs) => + upsertRegistrationAction(actionArgs, { + // tournament names can only be read through the API, never written + allowTournamentNameUpdates: false, + }), + { + ...args, + params: { id: String(tournamentId) }, + request: internalRequest, + }, + ); }; diff --git a/app/features/search/routes/search.ts b/app/features/search/routes/search.ts index f92e2a140..98a1c7f90 100644 --- a/app/features/search/routes/search.ts +++ b/app/features/search/routes/search.ts @@ -49,6 +49,7 @@ async function searchByType({ id: u.id, name: u.username, inGameName: u.inGameName, + tournamentName: u.tournamentName, avatarUrl: null, discordId: u.discordId, discordAvatar: u.discordAvatar, diff --git a/app/features/team/TeamRepository.server.ts b/app/features/team/TeamRepository.server.ts index 0f40f5ecd..113d8b53e 100644 --- a/app/features/team/TeamRepository.server.ts +++ b/app/features/team/TeamRepository.server.ts @@ -67,7 +67,7 @@ export function searchByName({ eb .selectFrom("TeamMemberWithSecondary") .innerJoin("User", "User.id", "TeamMemberWithSecondary.userId") - .select(["User.id", "User.username"]) + .select(["User.id", "User.username", "User.tournamentName"]) .whereRef("TeamMemberWithSecondary.teamId", "=", "Team.id") .where((eb2) => eb2.and([ diff --git a/app/features/tournament-admin/actions/to.$id.admin.registration.server.ts b/app/features/tournament-admin/actions/to.$id.admin.registration.server.ts index 8c6b5f9fc..8cc1e1267 100644 --- a/app/features/tournament-admin/actions/to.$id.admin.registration.server.ts +++ b/app/features/tournament-admin/actions/to.$id.admin.registration.server.ts @@ -1,4 +1,8 @@ -import { type ActionFunction, redirect } from "react-router"; +import { + type ActionFunction, + type ActionFunctionArgs, + redirect, +} from "react-router"; import * as ShowcaseTournaments from "~/features/front-page/core/ShowcaseTournaments.server"; import { notify } from "~/features/notifications/core/notify.server"; import * as TeamRepository from "~/features/team/TeamRepository.server"; @@ -12,11 +16,23 @@ import * as TournamentLFGRepository from "~/features/tournament-lfg/TournamentLF import { syncPickupChatMetadata } from "~/features/tournament-lfg/tournament-lfg-utils.server"; import { parseFormDataWithImages } from "~/form/parse.server"; import invariant from "~/utils/invariant"; +import { logger } from "~/utils/logger"; import { errorToastIfFalsy } from "~/utils/remix.server"; import { tournamentAdminPage } from "~/utils/urls"; import { adminRegistrationFormSchemaServer } from "../tournament-admin-registration-schemas.server"; -export const action: ActionFunction = async ({ request, params }) => { +export const action: ActionFunction = (args) => + upsertRegistrationAction(args, { allowTournamentNameUpdates: true }); + +/** + * The registration upsert itself, shared with the public API's version of this + * endpoint. That one passes `allowTournamentNameUpdates: false`: tournament names + * are the admin form's business and the API can only read them. + */ +export const upsertRegistrationAction = async ( + { request, params }: ActionFunctionArgs, + { allowTournamentNameUpdates }: { allowTournamentNameUpdates: boolean }, +) => { const { tournament, tournamentId, user } = await tournamentFromParams( params, { for: "organizer" }, @@ -76,18 +92,36 @@ export const action: ActionFunction = async ({ request, params }) => { return [{ userId: member.userId, inGameName: member.inGameName }]; }); - await TournamentTeamRepository.upsertRegistration({ - tournamentTeamId: team?.id, - tournamentId, - name, - teamId: linkedTeamId, - avatarImgId, - ownerUserId, - ownerChange, - membersToAdd, - membersToRemove, - inGameNameUpdates, - }); + // only a submission from someone allowed to edit tournament names says anything + // about them, everyone else leaves the names the players have untouched + const tournamentNameUpdates = + allowTournamentNameUpdates && tournament.canEditTournamentNames(user) + ? submittedMembers.map((member) => ({ + userId: member.userId, + tournamentName: member.tournamentName ?? null, + })) + : []; + + const { appliedTournamentNameChanges } = + await TournamentTeamRepository.upsertRegistration({ + tournamentTeamId: team?.id, + tournamentId, + name, + teamId: linkedTeamId, + avatarImgId, + ownerUserId, + ownerChange, + membersToAdd, + membersToRemove, + inGameNameUpdates, + tournamentNameUpdates, + }); + + for (const change of appliedTournamentNameChanges) { + logger.info( + `Tournament name updated: subject user id: ${change.userId} - "${change.previousTournamentName ?? ""}" -> "${change.tournamentName ?? ""}" - by user id: ${user.id} - tournament id: ${tournamentId}`, + ); + } for (const addId of membersToAdd) { await TournamentLFGRepository.leaveLfg({ diff --git a/app/features/tournament-admin/routes/to.$id.admin.audit.tsx b/app/features/tournament-admin/routes/to.$id.admin.audit.tsx index 3048550d4..882a13958 100644 --- a/app/features/tournament-admin/routes/to.$id.admin.audit.tsx +++ b/app/features/tournament-admin/routes/to.$id.admin.audit.tsx @@ -86,7 +86,10 @@ function AuditLogRow({ event }: { event: AuditLogEvent }) { const detail = typeof event.metadata?.bracketIdx === "number" ? tournament.bracketsMeta[event.metadata.bracketIdx]?.name - : event.metadata?.inGameName; + : event.type === "UPDATE_TOURNAMENT_NAME" + ? (event.metadata?.tournamentName ?? + t("tournament:admin.audit.detail.tournamentNameCleared")) + : event.metadata?.inGameName; return ( diff --git a/app/features/tournament-admin/routes/to.$id.admin.import-teams.ts b/app/features/tournament-admin/routes/to.$id.admin.import-teams.ts index cd1bd0cc8..e27d87eda 100644 --- a/app/features/tournament-admin/routes/to.$id.admin.import-teams.ts +++ b/app/features/tournament-admin/routes/to.$id.admin.import-teams.ts @@ -45,6 +45,7 @@ export const loader = async ({ request }: LoaderFunctionArgs) => { userId: member.userId, username: member.username, inGameName: member.inGameName, + tournamentName: member.tournamentName, isOwner: member.role === "OWNER", })), })), diff --git a/app/features/tournament-admin/routes/to.$id.admin.registration.$tid.browser.test.tsx b/app/features/tournament-admin/routes/to.$id.admin.registration.$tid.browser.test.tsx index b0b6032a2..908d061a6 100644 --- a/app/features/tournament-admin/routes/to.$id.admin.registration.$tid.browser.test.tsx +++ b/app/features/tournament-admin/routes/to.$id.admin.registration.$tid.browser.test.tsx @@ -7,6 +7,7 @@ const { mockTournament, mockLoaderData, submitMock, loadMock } = vi.hoisted( () => ({ mockTournament: { ctx: { id: 1, settings: { requireInGameNames: false } }, + canEditTournamentNames: (): boolean => false, }, mockLoaderData: { team: null as unknown }, submitMock: vi.fn(), @@ -100,3 +101,44 @@ describe("tournament admin registration - captain field", () => { .not.toBeInTheDocument(); }); }); + +describe("tournament admin registration - tournament name field", () => { + beforeEach(() => { + mockLoaderData.team = { + id: 10, + name: "low ink buddies", + team: undefined, + pickupAvatarUrl: null, + avatarImgId: null, + members: [ + { + userId: 1, + username: "sanu", + inGameName: null, + tournamentName: "Sanu", + role: "OWNER", + }, + ], + }; + }); + + test("is not shown to organizers who can't edit tournament names", async () => { + mockTournament.canEditTournamentNames = () => false; + + const screen = await renderPage(); + + await expect + .element(screen.getByLabelText("Tournament name")) + .not.toBeInTheDocument(); + }); + + test("shows the player's current tournament name", async () => { + mockTournament.canEditTournamentNames = () => true; + + const screen = await renderPage(); + + await expect + .element(screen.getByLabelText("Tournament name")) + .toHaveValue("Sanu"); + }); +}); diff --git a/app/features/tournament-admin/routes/to.$id.admin.registration.$tid.captain-label.browser.test.tsx b/app/features/tournament-admin/routes/to.$id.admin.registration.$tid.captain-label.browser.test.tsx index 8ec1599b6..dde80ffdc 100644 --- a/app/features/tournament-admin/routes/to.$id.admin.registration.$tid.captain-label.browser.test.tsx +++ b/app/features/tournament-admin/routes/to.$id.admin.registration.$tid.captain-label.browser.test.tsx @@ -7,6 +7,7 @@ import { render } from "vitest-browser-react"; const { mockTournament } = vi.hoisted(() => ({ mockTournament: { ctx: { id: 1, settings: { requireInGameNames: false } }, + canEditTournamentNames: () => false, }, })); diff --git a/app/features/tournament-admin/routes/to.$id.admin.registration.$tid.tsx b/app/features/tournament-admin/routes/to.$id.admin.registration.$tid.tsx index 1b932a5ec..71b02e4c7 100644 --- a/app/features/tournament-admin/routes/to.$id.admin.registration.$tid.tsx +++ b/app/features/tournament-admin/routes/to.$id.admin.registration.$tid.tsx @@ -4,6 +4,7 @@ import { useTranslation } from "react-i18next"; import { useFetcher, useLoaderData } from "react-router"; import { LinkButton, SendouButton } from "~/components/elements/Button"; import { SendouDialog } from "~/components/elements/Dialog"; +import { useUser } from "~/features/auth/core/user"; import { useTournament } from "~/features/tournament/tournament-context"; import type { TournamentTeamFull } from "~/features/tournament-bracket/core/Tournament.server"; import { FormField } from "~/form/FormField"; @@ -34,6 +35,7 @@ export { loader } from "../loaders/to.$id.admin.registration.$tid.server"; type RosterMemberValue = { userId?: number; inGameName?: string | null; + tournamentName?: string | null; }; type ImportableTeam = ImportTeamsLoaderData["teams"][number]; @@ -75,6 +77,7 @@ export default function TournamentAdminRegistrationPage() { inGameName: tournament.ctx.settings.requireInGameNames ? (member.inGameName ?? null) : null, + tournamentName: member.tournamentName ?? null, })), } : undefined; @@ -104,6 +107,7 @@ export default function TournamentAdminRegistrationPage() { function RegistrationFields({ team }: { team: TournamentTeamFull | null }) { const { t } = useTranslation(["forms"]); const tournament = useTournament(); + const user = useUser(); const { values, setValue, revalidateAll, hasSubmitted } = useFormFieldContext(); @@ -122,6 +126,7 @@ function RegistrationFields({ team }: { team: TournamentTeamFull | null }) { const linkedTeam = Boolean(values.linkedTeam); const members = (values.members as RosterMemberValue[]) ?? []; const requireInGameNames = tournament.ctx.settings.requireInGameNames; + const canEditTournamentNames = tournament.canEditTournamentNames(user); const handleImport = (importedTeam: ImportableTeam) => { setUsernames((prev) => { @@ -154,6 +159,7 @@ function RegistrationFields({ team }: { team: TournamentTeamFull | null }) { importedValues.members = importedTeam.members.map((member) => ({ userId: member.userId, inGameName: member.inGameName, + tournamentName: member.tournamentName, // fresh key so the member rows remount and their user-search inputs // re-resolve when importing a different team over a previous import _key: crypto.randomUUID(), @@ -240,6 +246,7 @@ function RegistrationFields({ team }: { team: TournamentTeamFull | null }) { selected.members.map((member) => ({ userId: member.id, inGameName: null, + tournamentName: member.tournamentName, })), ); setValue("ownerId", String(selected.members[0]?.id ?? "")); @@ -269,6 +276,14 @@ function RegistrationFields({ team }: { team: TournamentTeamFull | null }) { if (requireInGameNames && user.inGameName) { setValue(`${itemName}.inGameName`, user.inGameName); } + // the field is saved as is, so it has to show the name the + // user already has or saving would clear it + if (canEditTournamentNames) { + setValue( + `${itemName}.tournamentName`, + user.tournamentName, + ); + } }, } satisfies UserSearchFieldOptions } @@ -276,6 +291,9 @@ function RegistrationFields({ team }: { team: TournamentTeamFull | null }) { {requireInGameNames ? ( ) : null} + {canEditTournamentNames ? ( + + ) : null} )} diff --git a/app/features/tournament-admin/tournament-admin-registration-schemas.ts b/app/features/tournament-admin/tournament-admin-registration-schemas.ts index 3808c292b..f79186fdc 100644 --- a/app/features/tournament-admin/tournament-admin-registration-schemas.ts +++ b/app/features/tournament-admin/tournament-admin-registration-schemas.ts @@ -14,6 +14,7 @@ import { userSearch, } from "~/form/fields"; import { IN_GAME_NAME_MAX_LENGTH } from "../user-page/in-game-name"; +import { USER } from "../user-page/user-page-constants"; /** * Roster size cap for organizer-managed registrations. The per-tournament * `maxMembersPerTeam` limit intentionally doesn't apply to organizers, so this @@ -28,6 +29,16 @@ const memberFieldset = fieldset({ label: "labels.inGameName", maxLength: IN_GAME_NAME_MAX_LENGTH, }), + /** + * Only editable by members of an established organization + * (`Tournament.canEditTournamentNames`), whose submission is authoritative: + * `null` clears the name the player has. Ignored from everyone else. + */ + tournamentName: textFieldOptional({ + label: "labels.tournamentName", + bottomText: "bottomTexts.tournamentName", + maxLength: USER.CUSTOM_NAME_MAX_LENGTH, + }), }), }); diff --git a/app/features/tournament-bracket/core/Tournament.ts b/app/features/tournament-bracket/core/Tournament.ts index c777eedcf..995302a0a 100644 --- a/app/features/tournament-bracket/core/Tournament.ts +++ b/app/features/tournament-bracket/core/Tournament.ts @@ -1416,6 +1416,24 @@ export class Tournament { ); } + /** + * Checks if the given user can set the tournament names of the tournament's players. + * + * Restricted to members of an established organization because the name they set is + * shown in every tournament from then on, not only in this one. + */ + canEditTournamentNames(user: OptionalIdObject) { + if (!user) return false; + if (isAdmin(user)) return true; + if (!this.ctx.organization?.isEstablished) return false; + + return this.ctx.organization.members.some( + (member) => + member.userId === user.id && + ["ADMIN", "ORGANIZER"].includes(member.role), + ); + } + /** Checks if the given user is an organizer of the tournament. */ isOrganizer(user: OptionalIdObject) { return isTournamentOrganizer({ ctx: this.ctx, user }); diff --git a/app/features/tournament-match/TournamentMatchRepository.server.ts b/app/features/tournament-match/TournamentMatchRepository.server.ts index 5279ae035..be1251e71 100644 --- a/app/features/tournament-match/TournamentMatchRepository.server.ts +++ b/app/features/tournament-match/TournamentMatchRepository.server.ts @@ -51,7 +51,7 @@ export async function findMatchById(id: number) { .selectFrom("TournamentTeamMember") .innerJoin("User", "User.id", "TournamentTeamMember.userId") .select((eb) => [ - ...commonUserSelect(eb), + ...commonUserSelect(eb, { inTournament: true }), "TournamentTeamMember.tournamentTeamId", sql< string | null diff --git a/app/features/tournament/TournamentRepository.server.ts b/app/features/tournament/TournamentRepository.server.ts index dc3e6f589..7c8b571ba 100644 --- a/app/features/tournament/TournamentRepository.server.ts +++ b/app/features/tournament/TournamentRepository.server.ts @@ -26,6 +26,7 @@ import { commonUserSelect, concatUserSubmittedImagePrefix, tournamentLogoWithDefault, + tournamentUsername, } from "~/utils/kysely.server"; import type { Unwrapped } from "~/utils/types"; import type { TournamentTierNumber } from "./core/tiering"; @@ -355,7 +356,7 @@ export async function findStreamsByTournamentId(tournamentId: number) { "LiveStream.viewerCount", "LiveStream.thumbnailUrl", "TournamentTeam.name as teamName", - ...commonUserSelect(eb), + ...commonUserSelect(eb, { inTournament: true }), ]) .where("TournamentTeam.tournamentId", "=", tournamentId) .where("TournamentTeam.isPlaceholder", "=", 0) @@ -464,8 +465,9 @@ export async function findTeamsFullByTournamentId(tournamentId: number) { ), ) .select((eb) => [ - ...commonUserSelect(eb, { idAs: "userId" }), + ...commonUserSelect(eb, { idAs: "userId", inTournament: true }), "User.country", + "User.tournamentName", "SeedingSkill.ordinal", "TournamentTeamMember.role", "TournamentTeamMember.createdAt", @@ -855,7 +857,7 @@ export function findAllForShowcase() { .whereRef("TournamentResult.tournamentId", "=", "Tournament.id") .where("TournamentResult.placement", "=", 1) .select((eb) => [ - ...commonUserSelect(eb), + ...commonUserSelect(eb, { inTournament: true }), "User.country", "TournamentResult.div", "TournamentTeam.name as teamName", @@ -1624,7 +1626,7 @@ export function updateTeamSeeds({ .select([ "TournamentTeamMember.tournamentTeamId", "User.id as userId", - "User.username", + tournamentUsername().as("username"), ]) .where("TournamentTeamMember.tournamentTeamId", "in", teamIds) .execute() diff --git a/app/features/tournament/TournamentTeamRepository.server.test.ts b/app/features/tournament/TournamentTeamRepository.server.test.ts index 1427d1d20..697d91629 100644 --- a/app/features/tournament/TournamentTeamRepository.server.test.ts +++ b/app/features/tournament/TournamentTeamRepository.server.test.ts @@ -22,6 +22,15 @@ const membersByTeamId = (tournamentTeamId: number) => .where("TournamentTeamMember.tournamentTeamId", "=", tournamentTeamId) .execute(); +const tournamentNameOf = async (userId: number) => + ( + await db + .selectFrom("User") + .select("User.tournamentName") + .where("User.id", "=", userId) + .executeTakeFirstOrThrow() + ).tournamentName; + const roleOf = ( members: Array<{ userId: number; role: string }>, userId: number, @@ -49,6 +58,7 @@ describe("TournamentTeamRepository", () => { membersToAdd: [owner.id, member.id, anotherMember.id], membersToRemove: [], inGameNameUpdates: [], + tournamentNameUpdates: [], }), ); @@ -88,6 +98,7 @@ describe("TournamentTeamRepository", () => { membersToAdd: [member.id, anotherMember.id], membersToRemove: [], inGameNameUpdates: [], + tournamentNameUpdates: [], }), ); @@ -115,6 +126,7 @@ describe("TournamentTeamRepository", () => { membersToAdd: [owner.id, member.id], membersToRemove: [], inGameNameUpdates: [], + tournamentNameUpdates: [], }), ); @@ -130,6 +142,121 @@ describe("TournamentTeamRepository", () => { true, ); }); + + test("updates tournament names of members", async () => { + const tournament = await TournamentFactory.create({ + authorId: organizer.id, + }); + + const { appliedTournamentNameChanges } = await withUserId( + organizer.id, + () => + TournamentTeamRepository.upsertRegistration({ + tournamentId: tournament.id, + name: "Team Olive", + teamId: null, + avatarImgId: null, + ownerUserId: owner.id, + ownerChange: null, + membersToAdd: [owner.id, member.id], + membersToRemove: [], + inGameNameUpdates: [], + tournamentNameUpdates: [ + { userId: owner.id, tournamentName: "Sendou" }, + { userId: member.id, tournamentName: null }, + ], + }), + ); + + expect(appliedTournamentNameChanges).toEqual([ + { + userId: owner.id, + previousTournamentName: null, + tournamentName: "Sendou", + }, + ]); + expect(await tournamentNameOf(owner.id)).toBe("Sendou"); + expect(await tournamentNameOf(member.id)).toBeNull(); + }); + + test("logs a tournament name change in the audit log", async () => { + const tournament = await TournamentFactory.create({ + authorId: organizer.id, + }); + + await withUserId(organizer.id, () => + TournamentTeamRepository.upsertRegistration({ + tournamentId: tournament.id, + name: "Team Olive", + teamId: null, + avatarImgId: null, + ownerUserId: owner.id, + ownerChange: null, + membersToAdd: [owner.id], + membersToRemove: [], + inGameNameUpdates: [], + tournamentNameUpdates: [ + { userId: owner.id, tournamentName: "Sendou" }, + ], + }), + ); + + const events = await db + .selectFrom("TournamentAuditLog") + .select([ + "TournamentAuditLog.actorUserId", + "TournamentAuditLog.subjectUserId", + "TournamentAuditLog.metadata", + ]) + .where("TournamentAuditLog.type", "=", "UPDATE_TOURNAMENT_NAME") + .execute(); + + expect(events).toHaveLength(1); + expect(events[0].actorUserId).toBe(organizer.id); + expect(events[0].subjectUserId).toBe(owner.id); + expect(events[0].metadata?.tournamentName).toBe("Sendou"); + }); + + test("does not touch a tournament name that did not change", async () => { + const tournament = await TournamentFactory.create({ + authorId: organizer.id, + }); + const team = await TournamentTeamFactory.create({ + tournamentId: tournament.id, + memberUserIds: [owner.id], + team: { name: "Team Olive", prefersNotToHost: 0, teamId: null }, + }); + + const upsert = (tournamentName: string) => + withUserId(organizer.id, () => + TournamentTeamRepository.upsertRegistration({ + tournamentTeamId: team.id, + tournamentId: tournament.id, + name: "Team Olive", + teamId: null, + avatarImgId: null, + ownerUserId: owner.id, + ownerChange: null, + membersToAdd: [], + membersToRemove: [], + inGameNameUpdates: [], + tournamentNameUpdates: [{ userId: owner.id, tournamentName }], + }), + ); + + await upsert("Sendou"); + const { appliedTournamentNameChanges } = await upsert("Sendou"); + + expect(appliedTournamentNameChanges).toEqual([]); + + const events = await db + .selectFrom("TournamentAuditLog") + .select("TournamentAuditLog.id") + .where("TournamentAuditLog.type", "=", "UPDATE_TOURNAMENT_NAME") + .execute(); + + expect(events).toHaveLength(1); + }); }); describe("join", () => { diff --git a/app/features/tournament/TournamentTeamRepository.server.ts b/app/features/tournament/TournamentTeamRepository.server.ts index da294d0e9..cdab4acc8 100644 --- a/app/features/tournament/TournamentTeamRepository.server.ts +++ b/app/features/tournament/TournamentTeamRepository.server.ts @@ -211,11 +211,14 @@ export function insert({ /** * Creates a new registration or applies a full-state edit to an existing one in a * single transaction: team name, linked sendou.ink team, owner assignment/transfer, - * member adds/removes and in-game name updates. Pass `tournamentTeamId` to edit an - * existing team, or omit it to create a new one (all members are then "added" and - * `ownerUserId` becomes the owner). The caller is responsible for validating the - * derived ops and for any side effects (cache updates, notifications) outside the - * transaction. + * member adds/removes, in-game name updates and tournament name updates. Pass + * `tournamentTeamId` to edit an existing team, or omit it to create a new one (all + * members are then "added" and `ownerUserId` becomes the owner). The caller is + * responsible for validating the derived ops and for any side effects (cache updates, + * notifications) outside the transaction. + * + * Returns the tournament name changes that were actually applied (submitted values + * equal to the user's current one are no-ops), for the caller to log. */ export function upsertRegistration({ tournamentTeamId, @@ -228,6 +231,7 @@ export function upsertRegistration({ membersToAdd, membersToRemove, inGameNameUpdates, + tournamentNameUpdates, }: { /** Present when editing an existing team, omitted when creating a new one. */ tournamentTeamId?: number; @@ -244,6 +248,11 @@ export function upsertRegistration({ membersToAdd: number[]; membersToRemove: number[]; inGameNameUpdates: Array<{ userId: number; inGameName: string }>; + /** Organizer-set names shown in every tournament. `null` clears the user's current one. */ + tournamentNameUpdates: Array<{ + userId: number; + tournamentName: string | null; + }>; }) { const isNew = typeof tournamentTeamId !== "number"; @@ -395,6 +404,45 @@ export function upsertRegistration({ trx, ); } + + const appliedTournamentNameChanges: Array<{ + userId: number; + previousTournamentName: string | null; + tournamentName: string | null; + }> = []; + for (const { userId, tournamentName } of tournamentNameUpdates) { + const { tournamentName: previousTournamentName } = await trx + .selectFrom("User") + .select("User.tournamentName") + .where("User.id", "=", userId) + .executeTakeFirstOrThrow(); + + if (previousTournamentName === tournamentName) continue; + + await trx + .updateTable("User") + .set({ tournamentName }) + .where("User.id", "=", userId) + .execute(); + + await TournamentAuditLogRepository.insert( + { + type: "UPDATE_TOURNAMENT_NAME", + tournamentTeamId: id, + subjectUserId: userId, + metadata: { tournamentName }, + }, + trx, + ); + + appliedTournamentNameChanges.push({ + userId, + previousTournamentName, + tournamentName, + }); + } + + return { appliedTournamentNameChanges }; }); } diff --git a/app/features/tournament/tournament-constants.ts b/app/features/tournament/tournament-constants.ts index 0001365b5..c8cbbb0b9 100644 --- a/app/features/tournament/tournament-constants.ts +++ b/app/features/tournament/tournament-constants.ts @@ -66,6 +66,7 @@ export const TOURNAMENT_AUDIT_LOG_TYPES = [ "TEAM_DROPPED_OUT", "TEAM_DROP_OUT_UNDONE", "UPDATE_IN_GAME_NAME", + "UPDATE_TOURNAMENT_NAME", ] as const; export type TournamentAuditLogType = diff --git a/app/features/user-page/UserRepository.server.ts b/app/features/user-page/UserRepository.server.ts index 42ad2247e..1bbe8b25d 100644 --- a/app/features/user-page/UserRepository.server.ts +++ b/app/features/user-page/UserRepository.server.ts @@ -946,6 +946,7 @@ const searchSelectedFields = (eb: ExpressionBuilder) => [ ...commonUserSelect(eb), "User.inGameName", + "User.tournamentName", "PlusTier.tier as plusTier", eb .fn("iif", [ diff --git a/app/utils/kysely.server.ts b/app/utils/kysely.server.ts index 4307a64d9..570c7cb98 100644 --- a/app/utils/kysely.server.ts +++ b/app/utils/kysely.server.ts @@ -48,6 +48,8 @@ type CommonUserSelectOptions = { alias?: string; prefix?: string; idAs?: string; + /** For tournament scoped queries: `username` resolves to {@link tournamentUsername}. */ + inTournament?: boolean; }; type UserTableAlias = O extends { alias: infer A extends string } @@ -79,7 +81,8 @@ type CommonUserSelectResult = readonly [ * `User.customAvatarImgId`), or `null` when they have none. By default reads from `"User"` which * must be in scope at the call site; pass `alias` when the table is joined under another name * (`alias: "LinkedUser"`), `prefix` to prefix every output column (`prefix: "sender"` → - * `senderId`, `senderUsername`, ...) and `idAs` to rename only the id column (`idAs: "userId"`). + * `senderId`, `senderUsername`, ...), `idAs` to rename only the id column (`idAs: "userId"`) and + * `inTournament` to resolve `username` via {@link tournamentUsername}. */ export function commonUserSelect( eb: ExpressionBuilder, @@ -94,7 +97,9 @@ export function commonUserSelect( return [ `${alias}.id as ${idName}`, - `${alias}.username as ${outputName("username")}`, + options?.inTournament + ? tournamentUsername(alias).as(outputName("username")) + : `${alias}.username as ${outputName("username")}`, `${alias}.discordId as ${outputName("discordId")}`, `${alias}.discordAvatar as ${outputName("discordAvatar")}`, `${alias}.customUrl as ${outputName("customUrl")}`, @@ -389,3 +394,15 @@ export function userProfileWeapons(eb: ExpressionBuilder) { .orderBy("UserWeapon.order", "asc"), ); } + +/** + * The name a user is shown under inside tournaments: the name organizers have given them + * (`User.tournamentName`) falling back to their `username`. Alias it (`.as("username")`) when + * selecting it directly. Prefer `commonUserSelect(eb, { inTournament: true })`; reach for this + * only when the query doesn't select the common user fields. + */ +export function tournamentUsername(alias = "User") { + return sql`coalesce(${sql.ref(`${alias}.tournamentName`)}, ${sql.ref( + `${alias}.username`, + )})`; +} diff --git a/e2e/pages/tournament/tournament-admin-registration-page.ts b/e2e/pages/tournament/tournament-admin-registration-page.ts index 0a72a818e..93d1422ee 100644 --- a/e2e/pages/tournament/tournament-admin-registration-page.ts +++ b/e2e/pages/tournament/tournament-admin-registration-page.ts @@ -60,6 +60,11 @@ export class TournamentAdminRegistrationPage { await this.page.keyboard.press("Enter"); } + /** Names the roster member at `index` for tournaments. Only shown to organizers who may set it. */ + async setTournamentName(index: number, name: string) { + await this.page.getByLabel("Tournament name").nth(index).fill(name); + } + async selectCaptain(userId: number) { await this.page .getByLabel("Captain", { exact: true }) diff --git a/e2e/tournament-admin.spec.ts b/e2e/tournament-admin.spec.ts index e109dfa7e..908e57537 100644 --- a/e2e/tournament-admin.spec.ts +++ b/e2e/tournament-admin.spec.ts @@ -15,6 +15,7 @@ import { TournamentAdminAuditPage } from "./pages/tournament/tournament-admin-au import { TournamentAdminPage } from "./pages/tournament/tournament-admin-page"; import { TournamentAdminRegistrationPage } from "./pages/tournament/tournament-admin-registration-page"; import { TournamentSubsPage } from "./pages/tournament/tournament-subs-page"; +import { TournamentTeamPage } from "./pages/tournament/tournament-team-page"; const ROSTER_SIZE = 4; const CAPTAIN_DISCORD_ID = "1234567890123456789"; @@ -24,7 +25,11 @@ test.describe("Tournament admin team management", () => { page, factories, }) => { - const tournament = await createTournament(factories); + // an established organization's tournament, so its captain's tournament name + // can be set as part of the edit + const tournament = await createTournament(factories, { + establishedOrganization: true, + }); const roster = await factories.UserFactory.createMany(ROSTER_SIZE); const team = await factories.TournamentTeamFactory.create({ tournamentId: tournament.id, @@ -39,6 +44,7 @@ test.describe("Tournament admin team management", () => { await expect(registration.locators.editHeading).toBeVisible(); await registration.form.fill("pickUpName", "Renamed Team"); + await registration.setTournamentName(0, "Riko"); await registration.save(); // back on the team list, the rename is reflected @@ -46,6 +52,12 @@ test.describe("Tournament admin team management", () => { await expect(admin.locators.searchInput).toBeVisible(); await expect(admin.teamName("Renamed Team")).toBeVisible(); + // the captain is shown under the name the organizer gave them + const teamPage = new TournamentTeamPage(page); + await teamPage.goto(tournament.id, team.id); + await expect(teamPage.locators.memberNames.first()).toHaveText("Riko"); + + await admin.goto(tournament.id); await admin.checkTeamIn(0); await admin.checkTeamOut(0); @@ -58,6 +70,7 @@ test.describe("Tournament admin team management", () => { await expect(audit.eventCell("Team checked in")).toBeVisible(); await expect(audit.eventCell("Team checked out")).toBeVisible(); await expect(audit.eventCell("Team unregistered")).toBeVisible(); + await expect(audit.eventCell("Tournament name changed")).toBeVisible(); }); test("adds a new team and records it in the audit log", async ({ @@ -308,9 +321,22 @@ test.describe("Tournament admin bracket progression editing", () => { }); /** A tournament whose check-in window is open but that has not started. */ -function createTournament(factories: Factories) { +async function createTournament( + factories: Factories, + { + establishedOrganization = false, + }: { establishedOrganization?: boolean } = {}, +) { + const organization = establishedOrganization + ? await factories.TournamentOrganizationFactory.create( + { ownerId: NZAP_TEST_ID }, + { isEstablished: true }, + ) + : null; + return factories.TournamentFactory.create({ authorId: NZAP_TEST_ID, + organizationId: organization?.id ?? null, startTimes: [dateToDatabaseTimestamp(addMinutes(new Date(), 30))], }); } diff --git a/locales/da/forms.json b/locales/da/forms.json index 4f2509fdc..e4f8c76fd 100644 --- a/locales/da/forms.json +++ b/locales/da/forms.json @@ -298,6 +298,8 @@ "labels.regTeamName": "", "labels.regTeam": "", "labels.regCaptain": "", + "labels.tournamentName": "", + "bottomTexts.tournamentName": "", "labels.regSignUpAs": "", "labels.regPickUpName": "", "labels.regPrefersNotToHost": "", diff --git a/locales/da/tournament.json b/locales/da/tournament.json index be9803ac4..86e6128e7 100644 --- a/locales/da/tournament.json +++ b/locales/da/tournament.json @@ -102,6 +102,8 @@ "admin.audit.event.TEAM_DROPPED_OUT": "", "admin.audit.event.TEAM_DROP_OUT_UNDONE": "", "admin.audit.event.UPDATE_IN_GAME_NAME": "", + "admin.audit.event.UPDATE_TOURNAMENT_NAME": "", + "admin.audit.detail.tournamentNameCleared": "", "admin.actions.CHANGE_TEAM_OWNER": "Ændr holdkaptajn", "admin.actions.CHANGE_TEAM_NAME": "Ændr holdnavn", "admin.actions.CHECK_IN": "Tjek ind", diff --git a/locales/de/forms.json b/locales/de/forms.json index 933a4ae87..de5d2ab8e 100644 --- a/locales/de/forms.json +++ b/locales/de/forms.json @@ -298,6 +298,8 @@ "labels.regTeamName": "", "labels.regTeam": "", "labels.regCaptain": "", + "labels.tournamentName": "", + "bottomTexts.tournamentName": "", "labels.regSignUpAs": "", "labels.regPickUpName": "", "labels.regPrefersNotToHost": "", diff --git a/locales/de/tournament.json b/locales/de/tournament.json index 18c286714..c2f2bf5a0 100644 --- a/locales/de/tournament.json +++ b/locales/de/tournament.json @@ -102,6 +102,8 @@ "admin.audit.event.TEAM_DROPPED_OUT": "", "admin.audit.event.TEAM_DROP_OUT_UNDONE": "", "admin.audit.event.UPDATE_IN_GAME_NAME": "", + "admin.audit.event.UPDATE_TOURNAMENT_NAME": "", + "admin.audit.detail.tournamentNameCleared": "", "admin.actions.CHANGE_TEAM_OWNER": "Kapitän ändern", "admin.actions.CHANGE_TEAM_NAME": "Teamnamen ändern", "admin.actions.CHECK_IN": "Einchecken", diff --git a/locales/en/forms.json b/locales/en/forms.json index 4e7bc63f2..cc10aded3 100644 --- a/locales/en/forms.json +++ b/locales/en/forms.json @@ -298,6 +298,8 @@ "labels.regTeamName": "Team name", "labels.regTeam": "Team", "labels.regCaptain": "Captain", + "labels.tournamentName": "Tournament name", + "bottomTexts.tournamentName": "The name shown for this player in tournaments. Also applies to tournaments other organizers host in the future. If empty, their sendou.ink username is shown.", "labels.regSignUpAs": "Team signing up as", "labels.regPickUpName": "Pick-up name", "labels.regPrefersNotToHost": "My team prefers not to host rooms", diff --git a/locales/en/tournament.json b/locales/en/tournament.json index f4e8b8b08..ee65992c3 100644 --- a/locales/en/tournament.json +++ b/locales/en/tournament.json @@ -102,6 +102,8 @@ "admin.audit.event.TEAM_DROPPED_OUT": "Team dropped out", "admin.audit.event.TEAM_DROP_OUT_UNDONE": "Team drop out undone", "admin.audit.event.UPDATE_IN_GAME_NAME": "In-game name changed", + "admin.audit.event.UPDATE_TOURNAMENT_NAME": "Tournament name changed", + "admin.audit.detail.tournamentNameCleared": "Name cleared", "admin.actions.CHANGE_TEAM_OWNER": "Change captain", "admin.actions.CHANGE_TEAM_NAME": "Change team name", "admin.actions.CHECK_IN": "Check in", diff --git a/locales/es-ES/forms.json b/locales/es-ES/forms.json index bde7f6bdd..d82a1bf96 100644 --- a/locales/es-ES/forms.json +++ b/locales/es-ES/forms.json @@ -298,6 +298,8 @@ "labels.regTeamName": "Nombre del equipo", "labels.regTeam": "Equipo", "labels.regCaptain": "Capitán", + "labels.tournamentName": "", + "bottomTexts.tournamentName": "", "labels.regSignUpAs": "El equipo se inscribe como", "labels.regPickUpName": "Nombre del pick-up", "labels.regPrefersNotToHost": "Mi equipo prefiere no hostear salas", diff --git a/locales/es-ES/tournament.json b/locales/es-ES/tournament.json index 2e8d94eef..9a4498039 100644 --- a/locales/es-ES/tournament.json +++ b/locales/es-ES/tournament.json @@ -103,6 +103,8 @@ "admin.audit.event.TEAM_DROPPED_OUT": "El equipo se retiró", "admin.audit.event.TEAM_DROP_OUT_UNDONE": "Retirada del equipo deshecha", "admin.audit.event.UPDATE_IN_GAME_NAME": "Nombre en el juego cambiado", + "admin.audit.event.UPDATE_TOURNAMENT_NAME": "", + "admin.audit.detail.tournamentNameCleared": "", "admin.actions.CHANGE_TEAM_OWNER": "Cambiar capitán", "admin.actions.CHANGE_TEAM_NAME": "Cambiar nombre de equipo", "admin.actions.CHECK_IN": "Check in", diff --git a/locales/es-US/forms.json b/locales/es-US/forms.json index 7ba613fd4..f4112b36c 100644 --- a/locales/es-US/forms.json +++ b/locales/es-US/forms.json @@ -298,6 +298,8 @@ "labels.regTeamName": "", "labels.regTeam": "", "labels.regCaptain": "", + "labels.tournamentName": "", + "bottomTexts.tournamentName": "", "labels.regSignUpAs": "", "labels.regPickUpName": "", "labels.regPrefersNotToHost": "", diff --git a/locales/es-US/tournament.json b/locales/es-US/tournament.json index 3984997b4..c214538fc 100644 --- a/locales/es-US/tournament.json +++ b/locales/es-US/tournament.json @@ -103,6 +103,8 @@ "admin.audit.event.TEAM_DROPPED_OUT": "", "admin.audit.event.TEAM_DROP_OUT_UNDONE": "", "admin.audit.event.UPDATE_IN_GAME_NAME": "", + "admin.audit.event.UPDATE_TOURNAMENT_NAME": "", + "admin.audit.detail.tournamentNameCleared": "", "admin.actions.CHANGE_TEAM_OWNER": "Cambiar capitán", "admin.actions.CHANGE_TEAM_NAME": "Cambiar nombre de equipo", "admin.actions.CHECK_IN": "Check in", diff --git a/locales/fr-CA/forms.json b/locales/fr-CA/forms.json index 08e46827c..8424518a1 100644 --- a/locales/fr-CA/forms.json +++ b/locales/fr-CA/forms.json @@ -298,6 +298,8 @@ "labels.regTeamName": "", "labels.regTeam": "", "labels.regCaptain": "", + "labels.tournamentName": "", + "bottomTexts.tournamentName": "", "labels.regSignUpAs": "", "labels.regPickUpName": "", "labels.regPrefersNotToHost": "", diff --git a/locales/fr-CA/tournament.json b/locales/fr-CA/tournament.json index 8e8f7f3cf..d95d88e97 100644 --- a/locales/fr-CA/tournament.json +++ b/locales/fr-CA/tournament.json @@ -103,6 +103,8 @@ "admin.audit.event.TEAM_DROPPED_OUT": "", "admin.audit.event.TEAM_DROP_OUT_UNDONE": "", "admin.audit.event.UPDATE_IN_GAME_NAME": "", + "admin.audit.event.UPDATE_TOURNAMENT_NAME": "", + "admin.audit.detail.tournamentNameCleared": "", "admin.actions.CHANGE_TEAM_OWNER": "Changer le capitaine", "admin.actions.CHANGE_TEAM_NAME": "", "admin.actions.CHECK_IN": "S'enregister", diff --git a/locales/fr-EU/forms.json b/locales/fr-EU/forms.json index c30a31c0d..f4db23fb0 100644 --- a/locales/fr-EU/forms.json +++ b/locales/fr-EU/forms.json @@ -298,6 +298,8 @@ "labels.regTeamName": "", "labels.regTeam": "", "labels.regCaptain": "", + "labels.tournamentName": "", + "bottomTexts.tournamentName": "", "labels.regSignUpAs": "", "labels.regPickUpName": "", "labels.regPrefersNotToHost": "", diff --git a/locales/fr-EU/tournament.json b/locales/fr-EU/tournament.json index e4addfa29..afd45f496 100644 --- a/locales/fr-EU/tournament.json +++ b/locales/fr-EU/tournament.json @@ -103,6 +103,8 @@ "admin.audit.event.TEAM_DROPPED_OUT": "", "admin.audit.event.TEAM_DROP_OUT_UNDONE": "", "admin.audit.event.UPDATE_IN_GAME_NAME": "", + "admin.audit.event.UPDATE_TOURNAMENT_NAME": "", + "admin.audit.detail.tournamentNameCleared": "", "admin.actions.CHANGE_TEAM_OWNER": "Changer le capitaine", "admin.actions.CHANGE_TEAM_NAME": "Changer le nom de l'équipe ", "admin.actions.CHECK_IN": "S'enregister", diff --git a/locales/he/forms.json b/locales/he/forms.json index 17cf14288..b745a1f1f 100644 --- a/locales/he/forms.json +++ b/locales/he/forms.json @@ -298,6 +298,8 @@ "labels.regTeamName": "", "labels.regTeam": "", "labels.regCaptain": "", + "labels.tournamentName": "", + "bottomTexts.tournamentName": "", "labels.regSignUpAs": "", "labels.regPickUpName": "", "labels.regPrefersNotToHost": "", diff --git a/locales/he/tournament.json b/locales/he/tournament.json index ef9ef4658..8f8529028 100644 --- a/locales/he/tournament.json +++ b/locales/he/tournament.json @@ -103,6 +103,8 @@ "admin.audit.event.TEAM_DROPPED_OUT": "", "admin.audit.event.TEAM_DROP_OUT_UNDONE": "", "admin.audit.event.UPDATE_IN_GAME_NAME": "", + "admin.audit.event.UPDATE_TOURNAMENT_NAME": "", + "admin.audit.detail.tournamentNameCleared": "", "admin.actions.CHANGE_TEAM_OWNER": "שינוי קפטן", "admin.actions.CHANGE_TEAM_NAME": "", "admin.actions.CHECK_IN": "קבלה", diff --git a/locales/it/forms.json b/locales/it/forms.json index 0a6115297..8d85c520d 100644 --- a/locales/it/forms.json +++ b/locales/it/forms.json @@ -298,6 +298,8 @@ "labels.regTeamName": "", "labels.regTeam": "", "labels.regCaptain": "", + "labels.tournamentName": "", + "bottomTexts.tournamentName": "", "labels.regSignUpAs": "", "labels.regPickUpName": "", "labels.regPrefersNotToHost": "", diff --git a/locales/it/tournament.json b/locales/it/tournament.json index 9deba33c1..0aa071e79 100644 --- a/locales/it/tournament.json +++ b/locales/it/tournament.json @@ -103,6 +103,8 @@ "admin.audit.event.TEAM_DROPPED_OUT": "", "admin.audit.event.TEAM_DROP_OUT_UNDONE": "", "admin.audit.event.UPDATE_IN_GAME_NAME": "", + "admin.audit.event.UPDATE_TOURNAMENT_NAME": "", + "admin.audit.detail.tournamentNameCleared": "", "admin.actions.CHANGE_TEAM_OWNER": "Cambia capitano", "admin.actions.CHANGE_TEAM_NAME": "Cambia nome team", "admin.actions.CHECK_IN": "Check-in", diff --git a/locales/ja/forms.json b/locales/ja/forms.json index cdb92de0a..612d19075 100644 --- a/locales/ja/forms.json +++ b/locales/ja/forms.json @@ -298,6 +298,8 @@ "labels.regTeamName": "", "labels.regTeam": "", "labels.regCaptain": "", + "labels.tournamentName": "", + "bottomTexts.tournamentName": "", "labels.regSignUpAs": "", "labels.regPickUpName": "", "labels.regPrefersNotToHost": "", diff --git a/locales/ja/tournament.json b/locales/ja/tournament.json index 5f51b507c..254505a49 100644 --- a/locales/ja/tournament.json +++ b/locales/ja/tournament.json @@ -100,6 +100,8 @@ "admin.audit.event.TEAM_DROPPED_OUT": "", "admin.audit.event.TEAM_DROP_OUT_UNDONE": "", "admin.audit.event.UPDATE_IN_GAME_NAME": "", + "admin.audit.event.UPDATE_TOURNAMENT_NAME": "", + "admin.audit.detail.tournamentNameCleared": "", "admin.actions.CHANGE_TEAM_OWNER": "キャプテンを変更", "admin.actions.CHANGE_TEAM_NAME": "チーム名変更", "admin.actions.CHECK_IN": "チェックイン", diff --git a/locales/ko/forms.json b/locales/ko/forms.json index a5a87aaca..68bf7b0a8 100644 --- a/locales/ko/forms.json +++ b/locales/ko/forms.json @@ -298,6 +298,8 @@ "labels.regTeamName": "", "labels.regTeam": "", "labels.regCaptain": "", + "labels.tournamentName": "", + "bottomTexts.tournamentName": "", "labels.regSignUpAs": "", "labels.regPickUpName": "", "labels.regPrefersNotToHost": "", diff --git a/locales/ko/tournament.json b/locales/ko/tournament.json index 51bb6d85e..536c0864e 100644 --- a/locales/ko/tournament.json +++ b/locales/ko/tournament.json @@ -100,6 +100,8 @@ "admin.audit.event.TEAM_DROPPED_OUT": "", "admin.audit.event.TEAM_DROP_OUT_UNDONE": "", "admin.audit.event.UPDATE_IN_GAME_NAME": "", + "admin.audit.event.UPDATE_TOURNAMENT_NAME": "", + "admin.audit.detail.tournamentNameCleared": "", "admin.actions.CHANGE_TEAM_OWNER": "", "admin.actions.CHANGE_TEAM_NAME": "", "admin.actions.CHECK_IN": "", diff --git a/locales/nl/forms.json b/locales/nl/forms.json index 8ac90b831..165827b75 100644 --- a/locales/nl/forms.json +++ b/locales/nl/forms.json @@ -298,6 +298,8 @@ "labels.regTeamName": "", "labels.regTeam": "", "labels.regCaptain": "", + "labels.tournamentName": "", + "bottomTexts.tournamentName": "", "labels.regSignUpAs": "", "labels.regPickUpName": "", "labels.regPrefersNotToHost": "", diff --git a/locales/nl/tournament.json b/locales/nl/tournament.json index 1d1d050aa..e06be8ceb 100644 --- a/locales/nl/tournament.json +++ b/locales/nl/tournament.json @@ -102,6 +102,8 @@ "admin.audit.event.TEAM_DROPPED_OUT": "", "admin.audit.event.TEAM_DROP_OUT_UNDONE": "", "admin.audit.event.UPDATE_IN_GAME_NAME": "", + "admin.audit.event.UPDATE_TOURNAMENT_NAME": "", + "admin.audit.detail.tournamentNameCleared": "", "admin.actions.CHANGE_TEAM_OWNER": "", "admin.actions.CHANGE_TEAM_NAME": "", "admin.actions.CHECK_IN": "", diff --git a/locales/pl/forms.json b/locales/pl/forms.json index ecb7f5d79..992de9ff2 100644 --- a/locales/pl/forms.json +++ b/locales/pl/forms.json @@ -298,6 +298,8 @@ "labels.regTeamName": "", "labels.regTeam": "", "labels.regCaptain": "", + "labels.tournamentName": "", + "bottomTexts.tournamentName": "", "labels.regSignUpAs": "", "labels.regPickUpName": "", "labels.regPrefersNotToHost": "", diff --git a/locales/pl/tournament.json b/locales/pl/tournament.json index c26c7178d..dcbfc229c 100644 --- a/locales/pl/tournament.json +++ b/locales/pl/tournament.json @@ -104,6 +104,8 @@ "admin.audit.event.TEAM_DROPPED_OUT": "", "admin.audit.event.TEAM_DROP_OUT_UNDONE": "", "admin.audit.event.UPDATE_IN_GAME_NAME": "", + "admin.audit.event.UPDATE_TOURNAMENT_NAME": "", + "admin.audit.detail.tournamentNameCleared": "", "admin.actions.CHANGE_TEAM_OWNER": "", "admin.actions.CHANGE_TEAM_NAME": "", "admin.actions.CHECK_IN": "", diff --git a/locales/pt-BR/forms.json b/locales/pt-BR/forms.json index 432f0bd1f..4ac8f9224 100644 --- a/locales/pt-BR/forms.json +++ b/locales/pt-BR/forms.json @@ -298,6 +298,8 @@ "labels.regTeamName": "", "labels.regTeam": "", "labels.regCaptain": "", + "labels.tournamentName": "", + "bottomTexts.tournamentName": "", "labels.regSignUpAs": "", "labels.regPickUpName": "", "labels.regPrefersNotToHost": "", diff --git a/locales/pt-BR/tournament.json b/locales/pt-BR/tournament.json index 9f2ed4ccb..5a7c13462 100644 --- a/locales/pt-BR/tournament.json +++ b/locales/pt-BR/tournament.json @@ -103,6 +103,8 @@ "admin.audit.event.TEAM_DROPPED_OUT": "", "admin.audit.event.TEAM_DROP_OUT_UNDONE": "", "admin.audit.event.UPDATE_IN_GAME_NAME": "", + "admin.audit.event.UPDATE_TOURNAMENT_NAME": "", + "admin.audit.detail.tournamentNameCleared": "", "admin.actions.CHANGE_TEAM_OWNER": "Mudar capitão", "admin.actions.CHANGE_TEAM_NAME": "Mudar nome do time", "admin.actions.CHECK_IN": "Check-in", diff --git a/locales/ru/forms.json b/locales/ru/forms.json index 39eb63860..83332fbea 100644 --- a/locales/ru/forms.json +++ b/locales/ru/forms.json @@ -298,6 +298,8 @@ "labels.regTeamName": "", "labels.regTeam": "", "labels.regCaptain": "", + "labels.tournamentName": "", + "bottomTexts.tournamentName": "", "labels.regSignUpAs": "", "labels.regPickUpName": "", "labels.regPrefersNotToHost": "", diff --git a/locales/ru/tournament.json b/locales/ru/tournament.json index 6acae6d3f..438f6e2d9 100644 --- a/locales/ru/tournament.json +++ b/locales/ru/tournament.json @@ -104,6 +104,8 @@ "admin.audit.event.TEAM_DROPPED_OUT": "", "admin.audit.event.TEAM_DROP_OUT_UNDONE": "", "admin.audit.event.UPDATE_IN_GAME_NAME": "", + "admin.audit.event.UPDATE_TOURNAMENT_NAME": "", + "admin.audit.detail.tournamentNameCleared": "", "admin.actions.CHANGE_TEAM_OWNER": "Изменить капитана", "admin.actions.CHANGE_TEAM_NAME": "Изменить имя команды", "admin.actions.CHECK_IN": "Зарегистрировать", diff --git a/locales/zh/forms.json b/locales/zh/forms.json index 987e1324d..717b166c5 100644 --- a/locales/zh/forms.json +++ b/locales/zh/forms.json @@ -298,6 +298,8 @@ "labels.regTeamName": "队伍名称", "labels.regTeam": "队伍", "labels.regCaptain": "队长", + "labels.tournamentName": "", + "bottomTexts.tournamentName": "", "labels.regSignUpAs": "队伍报名形式", "labels.regPickUpName": "临时队伍名称", "labels.regPrefersNotToHost": "我方队伍不倾向于担任房主", diff --git a/locales/zh/tournament.json b/locales/zh/tournament.json index 2625f86fa..da09d0935 100644 --- a/locales/zh/tournament.json +++ b/locales/zh/tournament.json @@ -101,6 +101,8 @@ "admin.audit.event.TEAM_DROPPED_OUT": "队伍已退赛", "admin.audit.event.TEAM_DROP_OUT_UNDONE": "已撤销队伍退赛", "admin.audit.event.UPDATE_IN_GAME_NAME": "游戏内昵称已更改", + "admin.audit.event.UPDATE_TOURNAMENT_NAME": "", + "admin.audit.detail.tournamentNameCleared": "", "admin.actions.CHANGE_TEAM_OWNER": "更换队长", "admin.actions.CHANGE_TEAM_NAME": "更改队伍名称", "admin.actions.CHECK_IN": "签到", diff --git a/migrations/20260806150959-user-tournament-name.ts b/migrations/20260806150959-user-tournament-name.ts new file mode 100644 index 000000000..a1ebeeb8a --- /dev/null +++ b/migrations/20260806150959-user-tournament-name.ts @@ -0,0 +1,10 @@ +import type { Kysely } from "kysely"; + +export async function up(db: Kysely): Promise { + await db.transaction().execute(async (trx) => { + await trx.schema + .alterTable("User") + .addColumn("tournamentName", "text") + .execute(); + }); +} diff --git a/scripts/create-migration.ts b/scripts/create-migration.ts index 5d07d28de..e656078b4 100644 --- a/scripts/create-migration.ts +++ b/scripts/create-migration.ts @@ -8,9 +8,7 @@ const MIGRATION_FOLDER = fileURLToPath( const TEMPLATE = `import type { Kysely } from "kysely"; -/** TODO: describe what this migration changes */ export async function up(db: Kysely): Promise { - // kysely does not wrap sqlite migrations in a transaction, so do it here await db.transaction().execute(async (trx) => { await trx.schema.alterTable("TODO").addColumn("TODO", "text").execute(); });