Tournament specific usernames set by established org TO's

This commit is contained in:
Kalle 2026-08-07 11:57:23 +03:00
parent d59bb9c25b
commit 000eda055a
59 changed files with 642 additions and 40 deletions

View File

@ -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;
}
/**

View File

@ -856,6 +856,8 @@ export interface User {
customName: string | null;
/** coalesce(customName, discordName) */
username: ColumnType<string, never, never>;
/** 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<number[]>;

View File

@ -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<Response>({ 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");
});
});

View File

@ -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",

View File

@ -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");
});
});

View File

@ -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,
},
);
};

View File

@ -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,

View File

@ -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([

View File

@ -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({

View File

@ -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 (
<tr>

View File

@ -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",
})),
})),

View File

@ -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");
});
});

View File

@ -7,6 +7,7 @@ import { render } from "vitest-browser-react";
const { mockTournament } = vi.hoisted(() => ({
mockTournament: {
ctx: { id: 1, settings: { requireInGameNames: false } },
canEditTournamentNames: () => false,
},
}));

View File

@ -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 ? (
<FormField name={`${itemName}.inGameName`} />
) : null}
{canEditTournamentNames ? (
<FormField name={`${itemName}.tournamentName`} />
) : null}
</div>
)}
</FormField>

View File

@ -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,
}),
}),
});

View File

@ -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 });

View File

@ -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

View File

@ -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()

View File

@ -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", () => {

View File

@ -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 };
});
}

View File

@ -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 =

View File

@ -946,6 +946,7 @@ const searchSelectedFields = (eb: ExpressionBuilder<DB, "User">) =>
[
...commonUserSelect(eb),
"User.inGameName",
"User.tournamentName",
"PlusTier.tier as plusTier",
eb
.fn<string | null>("iif", [

View File

@ -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> = O extends { alias: infer A extends string }
@ -79,7 +81,8 @@ type CommonUserSelectResult<O> = 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<const O extends CommonUserSelectOptions>(
eb: ExpressionBuilder<DB, any>,
@ -94,7 +97,9 @@ export function commonUserSelect<const O extends CommonUserSelectOptions>(
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<DB, any>) {
.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<string>`coalesce(${sql.ref(`${alias}.tournamentName`)}, ${sql.ref(
`${alias}.username`,
)})`;
}

View File

@ -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 })

View File

@ -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))],
});
}

View File

@ -298,6 +298,8 @@
"labels.regTeamName": "",
"labels.regTeam": "",
"labels.regCaptain": "",
"labels.tournamentName": "",
"bottomTexts.tournamentName": "",
"labels.regSignUpAs": "",
"labels.regPickUpName": "",
"labels.regPrefersNotToHost": "",

View File

@ -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",

View File

@ -298,6 +298,8 @@
"labels.regTeamName": "",
"labels.regTeam": "",
"labels.regCaptain": "",
"labels.tournamentName": "",
"bottomTexts.tournamentName": "",
"labels.regSignUpAs": "",
"labels.regPickUpName": "",
"labels.regPrefersNotToHost": "",

View File

@ -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",

View File

@ -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",

View File

@ -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",

View File

@ -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",

View File

@ -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",

View File

@ -298,6 +298,8 @@
"labels.regTeamName": "",
"labels.regTeam": "",
"labels.regCaptain": "",
"labels.tournamentName": "",
"bottomTexts.tournamentName": "",
"labels.regSignUpAs": "",
"labels.regPickUpName": "",
"labels.regPrefersNotToHost": "",

View File

@ -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",

View File

@ -298,6 +298,8 @@
"labels.regTeamName": "",
"labels.regTeam": "",
"labels.regCaptain": "",
"labels.tournamentName": "",
"bottomTexts.tournamentName": "",
"labels.regSignUpAs": "",
"labels.regPickUpName": "",
"labels.regPrefersNotToHost": "",

View File

@ -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",

View File

@ -298,6 +298,8 @@
"labels.regTeamName": "",
"labels.regTeam": "",
"labels.regCaptain": "",
"labels.tournamentName": "",
"bottomTexts.tournamentName": "",
"labels.regSignUpAs": "",
"labels.regPickUpName": "",
"labels.regPrefersNotToHost": "",

View File

@ -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",

View File

@ -298,6 +298,8 @@
"labels.regTeamName": "",
"labels.regTeam": "",
"labels.regCaptain": "",
"labels.tournamentName": "",
"bottomTexts.tournamentName": "",
"labels.regSignUpAs": "",
"labels.regPickUpName": "",
"labels.regPrefersNotToHost": "",

View File

@ -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": "קבלה",

View File

@ -298,6 +298,8 @@
"labels.regTeamName": "",
"labels.regTeam": "",
"labels.regCaptain": "",
"labels.tournamentName": "",
"bottomTexts.tournamentName": "",
"labels.regSignUpAs": "",
"labels.regPickUpName": "",
"labels.regPrefersNotToHost": "",

View File

@ -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",

View File

@ -298,6 +298,8 @@
"labels.regTeamName": "",
"labels.regTeam": "",
"labels.regCaptain": "",
"labels.tournamentName": "",
"bottomTexts.tournamentName": "",
"labels.regSignUpAs": "",
"labels.regPickUpName": "",
"labels.regPrefersNotToHost": "",

View File

@ -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": "チェックイン",

View File

@ -298,6 +298,8 @@
"labels.regTeamName": "",
"labels.regTeam": "",
"labels.regCaptain": "",
"labels.tournamentName": "",
"bottomTexts.tournamentName": "",
"labels.regSignUpAs": "",
"labels.regPickUpName": "",
"labels.regPrefersNotToHost": "",

View File

@ -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": "",

View File

@ -298,6 +298,8 @@
"labels.regTeamName": "",
"labels.regTeam": "",
"labels.regCaptain": "",
"labels.tournamentName": "",
"bottomTexts.tournamentName": "",
"labels.regSignUpAs": "",
"labels.regPickUpName": "",
"labels.regPrefersNotToHost": "",

View File

@ -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": "",

View File

@ -298,6 +298,8 @@
"labels.regTeamName": "",
"labels.regTeam": "",
"labels.regCaptain": "",
"labels.tournamentName": "",
"bottomTexts.tournamentName": "",
"labels.regSignUpAs": "",
"labels.regPickUpName": "",
"labels.regPrefersNotToHost": "",

View File

@ -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": "",

View File

@ -298,6 +298,8 @@
"labels.regTeamName": "",
"labels.regTeam": "",
"labels.regCaptain": "",
"labels.tournamentName": "",
"bottomTexts.tournamentName": "",
"labels.regSignUpAs": "",
"labels.regPickUpName": "",
"labels.regPrefersNotToHost": "",

View File

@ -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",

View File

@ -298,6 +298,8 @@
"labels.regTeamName": "",
"labels.regTeam": "",
"labels.regCaptain": "",
"labels.tournamentName": "",
"bottomTexts.tournamentName": "",
"labels.regSignUpAs": "",
"labels.regPickUpName": "",
"labels.regPrefersNotToHost": "",

View File

@ -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": "Зарегистрировать",

View File

@ -298,6 +298,8 @@
"labels.regTeamName": "队伍名称",
"labels.regTeam": "队伍",
"labels.regCaptain": "队长",
"labels.tournamentName": "",
"bottomTexts.tournamentName": "",
"labels.regSignUpAs": "队伍报名形式",
"labels.regPickUpName": "临时队伍名称",
"labels.regPrefersNotToHost": "我方队伍不倾向于担任房主",

View File

@ -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": "签到",

View File

@ -0,0 +1,10 @@
import type { Kysely } from "kysely";
export async function up(db: Kysely<any>): Promise<void> {
await db.transaction().execute(async (trx) => {
await trx.schema
.alterTable("User")
.addColumn("tournamentName", "text")
.execute();
});
}

View File

@ -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<any>): Promise<void> {
// 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();
});