mirror of
https://github.com/Sendouc/sendou.ink.git
synced 2026-08-07 21:56:33 -05:00
Tournament specific usernames set by established org TO's
This commit is contained in:
parent
d59bb9c25b
commit
000eda055a
|
|
@ -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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -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[]>;
|
||||
|
|
|
|||
72
app/features/api-public/routes/tournament.$id.teams.test.ts
Normal file
72
app/features/api-public/routes/tournament.$id.teams.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
});
|
||||
});
|
||||
|
|
@ -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,
|
||||
},
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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([
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
})),
|
||||
})),
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ import { render } from "vitest-browser-react";
|
|||
const { mockTournament } = vi.hoisted(() => ({
|
||||
mockTournament: {
|
||||
ctx: { id: 1, settings: { requireInGameNames: false } },
|
||||
canEditTournamentNames: () => false,
|
||||
},
|
||||
}));
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}),
|
||||
}),
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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", () => {
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
});
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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 =
|
||||
|
|
|
|||
|
|
@ -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", [
|
||||
|
|
|
|||
|
|
@ -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`,
|
||||
)})`;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 })
|
||||
|
|
|
|||
|
|
@ -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))],
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -298,6 +298,8 @@
|
|||
"labels.regTeamName": "",
|
||||
"labels.regTeam": "",
|
||||
"labels.regCaptain": "",
|
||||
"labels.tournamentName": "",
|
||||
"bottomTexts.tournamentName": "",
|
||||
"labels.regSignUpAs": "",
|
||||
"labels.regPickUpName": "",
|
||||
"labels.regPrefersNotToHost": "",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -298,6 +298,8 @@
|
|||
"labels.regTeamName": "",
|
||||
"labels.regTeam": "",
|
||||
"labels.regCaptain": "",
|
||||
"labels.tournamentName": "",
|
||||
"bottomTexts.tournamentName": "",
|
||||
"labels.regSignUpAs": "",
|
||||
"labels.regPickUpName": "",
|
||||
"labels.regPrefersNotToHost": "",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -298,6 +298,8 @@
|
|||
"labels.regTeamName": "",
|
||||
"labels.regTeam": "",
|
||||
"labels.regCaptain": "",
|
||||
"labels.tournamentName": "",
|
||||
"bottomTexts.tournamentName": "",
|
||||
"labels.regSignUpAs": "",
|
||||
"labels.regPickUpName": "",
|
||||
"labels.regPrefersNotToHost": "",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -298,6 +298,8 @@
|
|||
"labels.regTeamName": "",
|
||||
"labels.regTeam": "",
|
||||
"labels.regCaptain": "",
|
||||
"labels.tournamentName": "",
|
||||
"bottomTexts.tournamentName": "",
|
||||
"labels.regSignUpAs": "",
|
||||
"labels.regPickUpName": "",
|
||||
"labels.regPrefersNotToHost": "",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -298,6 +298,8 @@
|
|||
"labels.regTeamName": "",
|
||||
"labels.regTeam": "",
|
||||
"labels.regCaptain": "",
|
||||
"labels.tournamentName": "",
|
||||
"bottomTexts.tournamentName": "",
|
||||
"labels.regSignUpAs": "",
|
||||
"labels.regPickUpName": "",
|
||||
"labels.regPrefersNotToHost": "",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -298,6 +298,8 @@
|
|||
"labels.regTeamName": "",
|
||||
"labels.regTeam": "",
|
||||
"labels.regCaptain": "",
|
||||
"labels.tournamentName": "",
|
||||
"bottomTexts.tournamentName": "",
|
||||
"labels.regSignUpAs": "",
|
||||
"labels.regPickUpName": "",
|
||||
"labels.regPrefersNotToHost": "",
|
||||
|
|
|
|||
|
|
@ -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": "קבלה",
|
||||
|
|
|
|||
|
|
@ -298,6 +298,8 @@
|
|||
"labels.regTeamName": "",
|
||||
"labels.regTeam": "",
|
||||
"labels.regCaptain": "",
|
||||
"labels.tournamentName": "",
|
||||
"bottomTexts.tournamentName": "",
|
||||
"labels.regSignUpAs": "",
|
||||
"labels.regPickUpName": "",
|
||||
"labels.regPrefersNotToHost": "",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -298,6 +298,8 @@
|
|||
"labels.regTeamName": "",
|
||||
"labels.regTeam": "",
|
||||
"labels.regCaptain": "",
|
||||
"labels.tournamentName": "",
|
||||
"bottomTexts.tournamentName": "",
|
||||
"labels.regSignUpAs": "",
|
||||
"labels.regPickUpName": "",
|
||||
"labels.regPrefersNotToHost": "",
|
||||
|
|
|
|||
|
|
@ -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": "チェックイン",
|
||||
|
|
|
|||
|
|
@ -298,6 +298,8 @@
|
|||
"labels.regTeamName": "",
|
||||
"labels.regTeam": "",
|
||||
"labels.regCaptain": "",
|
||||
"labels.tournamentName": "",
|
||||
"bottomTexts.tournamentName": "",
|
||||
"labels.regSignUpAs": "",
|
||||
"labels.regPickUpName": "",
|
||||
"labels.regPrefersNotToHost": "",
|
||||
|
|
|
|||
|
|
@ -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": "",
|
||||
|
|
|
|||
|
|
@ -298,6 +298,8 @@
|
|||
"labels.regTeamName": "",
|
||||
"labels.regTeam": "",
|
||||
"labels.regCaptain": "",
|
||||
"labels.tournamentName": "",
|
||||
"bottomTexts.tournamentName": "",
|
||||
"labels.regSignUpAs": "",
|
||||
"labels.regPickUpName": "",
|
||||
"labels.regPrefersNotToHost": "",
|
||||
|
|
|
|||
|
|
@ -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": "",
|
||||
|
|
|
|||
|
|
@ -298,6 +298,8 @@
|
|||
"labels.regTeamName": "",
|
||||
"labels.regTeam": "",
|
||||
"labels.regCaptain": "",
|
||||
"labels.tournamentName": "",
|
||||
"bottomTexts.tournamentName": "",
|
||||
"labels.regSignUpAs": "",
|
||||
"labels.regPickUpName": "",
|
||||
"labels.regPrefersNotToHost": "",
|
||||
|
|
|
|||
|
|
@ -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": "",
|
||||
|
|
|
|||
|
|
@ -298,6 +298,8 @@
|
|||
"labels.regTeamName": "",
|
||||
"labels.regTeam": "",
|
||||
"labels.regCaptain": "",
|
||||
"labels.tournamentName": "",
|
||||
"bottomTexts.tournamentName": "",
|
||||
"labels.regSignUpAs": "",
|
||||
"labels.regPickUpName": "",
|
||||
"labels.regPrefersNotToHost": "",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -298,6 +298,8 @@
|
|||
"labels.regTeamName": "",
|
||||
"labels.regTeam": "",
|
||||
"labels.regCaptain": "",
|
||||
"labels.tournamentName": "",
|
||||
"bottomTexts.tournamentName": "",
|
||||
"labels.regSignUpAs": "",
|
||||
"labels.regPickUpName": "",
|
||||
"labels.regPrefersNotToHost": "",
|
||||
|
|
|
|||
|
|
@ -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": "Зарегистрировать",
|
||||
|
|
|
|||
|
|
@ -298,6 +298,8 @@
|
|||
"labels.regTeamName": "队伍名称",
|
||||
"labels.regTeam": "队伍",
|
||||
"labels.regCaptain": "队长",
|
||||
"labels.tournamentName": "",
|
||||
"bottomTexts.tournamentName": "",
|
||||
"labels.regSignUpAs": "队伍报名形式",
|
||||
"labels.regPickUpName": "临时队伍名称",
|
||||
"labels.regPrefersNotToHost": "我方队伍不倾向于担任房主",
|
||||
|
|
|
|||
|
|
@ -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": "签到",
|
||||
|
|
|
|||
10
migrations/20260806150959-user-tournament-name.ts
Normal file
10
migrations/20260806150959-user-tournament-name.ts
Normal 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();
|
||||
});
|
||||
}
|
||||
|
|
@ -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();
|
||||
});
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user