Move app to apps/web-react in pnpm workspace layout

This commit is contained in:
Kalle
2026-08-16 09:20:33 +03:00
parent 7e365ccfcf
commit bbc8ea57af
2913 changed files with 227 additions and 250 deletions

View File

@@ -0,0 +1,52 @@
import type { ActionFunction, ActionFunctionArgs } from "react-router";
/**
* Wraps an existing action function for API use.
* Converts redirect-based success/error responses to JSON responses.
*
* The existing actions use:
* - `successToast(message)` which returns `redirect("?__success=message")`
* - `errorToastIfFalsy/errorToastIfErr` which throw `redirect("?__error=message")`
* - `{ fieldErrors }` returns for form validation failures
*/
export async function wrapActionForApi(
actionFn: ActionFunction,
args: ActionFunctionArgs,
): Promise<Response> {
try {
const response = await actionFn(args);
if (response instanceof Response && response.status === 302) {
return new Response(null, { status: 200 });
}
if (response && typeof response === "object" && "fieldErrors" in response) {
return new Response(
JSON.stringify({
error: "Validation failed",
fieldErrors: response.fieldErrors,
}),
{
status: 400,
headers: { "Content-Type": "application/json" },
},
);
}
return response as Response;
} catch (e) {
if (e instanceof Response && e.status === 302) {
const location = e.headers.get("Location") ?? "";
const search = location.slice(location.indexOf("?") + 1);
const errorMsg = new URLSearchParams(search).get("__error");
if (errorMsg !== null) {
return new Response(JSON.stringify({ error: errorMsg }), {
status: 400,
headers: { "Content-Type": "application/json" },
});
}
}
throw e;
}
}

View File

@@ -0,0 +1,64 @@
import { userAsyncLocalStorage } from "~/features/auth/core/user-context.server";
import * as UserRepository from "~/features/user-page/UserRepository.server";
import { getTokenInfo } from "./api-public-utils.server";
const USER_IDS_PATTERN = /^\/api\/user\/[^/]+\/ids$/;
type MiddlewareArgs = {
request: Request;
context: unknown;
};
type MiddlewareFn = (
args: MiddlewareArgs,
next: () => Promise<Response>,
) => Promise<Response>;
function extractToken(req: Request): string | null {
const authHeader = req.headers.get("Authorization");
if (!authHeader) return null;
return authHeader.replace("Bearer ", "");
}
function isPublicRoute(request: Request): boolean {
if (request.method !== "GET") return false;
const url = new URL(request.url);
return USER_IDS_PATTERN.test(url.pathname);
}
export const apiAuthMiddleware: MiddlewareFn = async ({ request }, next) => {
if (request.method === "OPTIONS") {
return next();
}
if (isPublicRoute(request)) {
return next();
}
const token = extractToken(request);
if (!token) {
return Response.json(
{ error: "Missing Authorization header" },
{ status: 401 },
);
}
const tokenInfo = getTokenInfo(token);
if (!tokenInfo) {
return Response.json({ error: "Invalid token" }, { status: 401 });
}
if (request.method === "POST" && tokenInfo.type !== "write") {
return Response.json({ error: "Write token required" }, { status: 403 });
}
if (request.method === "POST") {
const user = await UserRepository.findLeanById(tokenInfo.userId);
if (!user) {
return Response.json({ error: "User not found" }, { status: 401 });
}
return userAsyncLocalStorage.run({ user }, () => next());
}
return next();
};

View File

@@ -0,0 +1,38 @@
type MiddlewareArgs = {
request: Request;
context: unknown;
};
type MiddlewareFn = (
args: MiddlewareArgs,
next: () => Promise<Response>,
) => Promise<Response>;
const CORS_HEADERS = {
"Access-Control-Allow-Origin": "*",
"Access-Control-Allow-Methods": "GET, HEAD, POST, OPTIONS",
"Access-Control-Allow-Headers": "Content-Type, Authorization",
"Access-Control-Max-Age": "86400",
};
export const apiCorsMiddleware: MiddlewareFn = async ({ request }, next) => {
if (request.method === "OPTIONS") {
return new Response(null, {
status: 204,
headers: CORS_HEADERS,
});
}
const response = await next();
const newHeaders = new Headers(response.headers);
for (const [key, value] of Object.entries(CORS_HEADERS)) {
newHeaders.set(key, value);
}
return new Response(response.body, {
status: response.status,
statusText: response.statusText,
headers: newHeaders,
});
};

View File

@@ -0,0 +1,26 @@
import * as ApiRepository from "~/features/api/ApiRepository.server";
import type { ApiTokenType } from "~/features/api/api-types";
type CachedToken = { type: ApiTokenType; userId: number };
async function loadApiTokensCache() {
const dbTokens = await ApiRepository.findAllApiTokens();
const tokenMap = new Map<string, CachedToken>();
for (const { token, type, userId } of dbTokens) {
tokenMap.set(token, { type, userId });
}
return tokenMap;
}
let apiTokens: Map<string, CachedToken> = await loadApiTokensCache();
export function getTokenInfo(token: string): CachedToken | undefined {
return apiTokens.get(token);
}
export async function refreshApiTokensCache() {
apiTokens = await loadApiTokensCache();
}

View File

@@ -0,0 +1,8 @@
import { apiAuthMiddleware } from "../api-auth-middleware.server";
import { apiCorsMiddleware } from "../api-cors-middleware.server";
import type { Route } from "./+types/api.layout";
export const middleware: Route.MiddlewareFunction[] = [
apiCorsMiddleware,
apiAuthMiddleware,
];

View File

@@ -0,0 +1,34 @@
import { describe, expect, test } from "vitest";
import * as CalendarEventFactory from "~/db/seed/factories/CalendarEventFactory";
import * as UserFactory from "~/db/seed/factories/UserFactory";
import { dateToDatabaseTimestamp } from "~/utils/dates";
import { wrappedLoader } from "~/utils/Test";
import type { GetCalendarWeekResponse } from "../schema";
import { loader } from "./calendar.$year.$week";
const weekLoader = wrappedLoader<Response>({ loader });
const fetchWeek = async (year: number, week: number) => {
const response = await weekLoader({
params: { year: String(year), week: String(week) },
});
return (await response.json()) as GetCalendarWeekResponse;
};
describe("GET /api/calendar/:year/:week", () => {
test("an event starting exactly at the week boundary is returned for exactly one week", async () => {
const user = await UserFactory.createRegular();
// Monday 2025-01-13 00:00 UTC, i.e. Sunday 7 PM EST — the boundary
// between ISO weeks 2 and 3 of 2025
await CalendarEventFactory.create({
authorId: user.id,
startTimes: [dateToDatabaseTimestamp(new Date("2025-01-13T00:00:00Z"))],
});
const weekTwoEvents = await fetchWeek(2025, 2);
const weekThreeEvents = await fetchWeek(2025, 3);
expect(weekTwoEvents.length + weekThreeEvents.length).toBe(1);
});
});

View File

@@ -0,0 +1,62 @@
import type { LoaderFunctionArgs } from "react-router";
import { z } from "zod";
import { db } from "~/db/sql";
import {
databaseTimestampToDate,
dateToDatabaseTimestamp,
weekNumberToDateRange,
} from "~/utils/dates";
import { parseParams } from "~/utils/remix.server";
import type { GetCalendarWeekResponse } from "../schema";
const paramsSchema = z.object({
year: z.coerce.number().int().min(2020).max(2100),
week: z.coerce.number().int().min(1).max(53),
});
export const loader = async ({ params }: LoaderFunctionArgs) => {
const { week, year } = parseParams({ params, schema: paramsSchema });
const events = await fetchEventsOfWeek({
week,
year,
});
const result: GetCalendarWeekResponse = events.map((event) => ({
name: event.name,
startTime: databaseTimestampToDate(event.startsAt).toISOString(),
tournamentId: event.tournamentId,
tournamentUrl: event.tournamentId
? `https://sendou.ink/to/${event.tournamentId}/brackets`
: null,
}));
return Response.json(result);
};
function fetchEventsOfWeek(args: { week: number; year: number }) {
const { startTime, endTime } = weekNumberToDateRange(args);
return db
.selectFrom("CalendarEvent")
.innerJoin(
"CalendarEventDate",
"CalendarEvent.id",
"CalendarEventDate.eventId",
)
.leftJoin("Tournament", "CalendarEvent.tournamentId", "Tournament.id")
.select([
"Tournament.id as tournamentId",
"CalendarEvent.name",
"CalendarEventDate.startsAt",
])
.where(
"CalendarEventDate.startsAt",
">=",
dateToDatabaseTimestamp(startTime),
)
.where("CalendarEventDate.startsAt", "<", dateToDatabaseTimestamp(endTime))
.where("CalendarEvent.hidden", "=", 0)
.orderBy("CalendarEventDate.startsAt", "asc")
.execute();
}

View File

@@ -0,0 +1,73 @@
import type { LoaderFunctionArgs } from "react-router";
import { z } from "zod";
import { db } from "~/db/sql";
import {
concatUserSubmittedImagePrefix,
jsonArrayFrom,
} from "~/utils/kysely.server";
import { notFoundIfNullish, parseParams } from "~/utils/remix.server";
import { id } from "~/utils/zod";
import type { GetTournamentOrganizationResponse } from "../schema";
const paramsSchema = z.object({
id,
});
export const loader = async ({ params }: LoaderFunctionArgs) => {
const { id } = parseParams({ params, schema: paramsSchema });
const organization = notFoundIfNullish(
await db
.selectFrom("TournamentOrganization")
.leftJoin(
"UserSubmittedImage",
"UserSubmittedImage.id",
"TournamentOrganization.avatarImgId",
)
.select((eb) => [
"TournamentOrganization.id",
"TournamentOrganization.name",
"TournamentOrganization.description",
"TournamentOrganization.socials",
"TournamentOrganization.slug",
concatUserSubmittedImagePrefix(eb.ref("UserSubmittedImage.url")).as(
"logoUrl",
),
jsonArrayFrom(
eb
.selectFrom("TournamentOrganizationMember")
.innerJoin("User", "User.id", "TournamentOrganizationMember.userId")
.select([
"User.id",
"User.discordId",
"User.username",
"User.pronouns",
"TournamentOrganizationMember.role",
"TournamentOrganizationMember.roleDisplayName",
])
.where("TournamentOrganizationMember.organizationId", "=", id),
).as("members"),
])
.where("TournamentOrganization.id", "=", id)
.executeTakeFirst(),
);
const result: GetTournamentOrganizationResponse = {
id: organization.id,
name: organization.name,
description: organization.description,
logoUrl: organization.logoUrl,
socialLinkUrls: organization.socials ?? [],
url: `https://sendou.ink/org/${organization.slug}`,
members: organization.members.map((member) => ({
userId: member.id,
discordId: member.discordId,
name: member.username,
pronouns: member.pronouns,
role: member.role,
roleDisplayName: member.roleDisplayName,
})),
};
return Response.json(result);
};

View File

@@ -0,0 +1,25 @@
import type { LoaderFunctionArgs } from "react-router";
import { z } from "zod";
import { SendouQ } from "~/features/sendouq/core/SendouQ.server";
import { parseParams } from "~/utils/remix.server";
import { id } from "~/utils/zod";
import type { GetUsersActiveSendouqMatchResponse } from "../schema";
const paramsSchema = z.object({
userId: id,
});
export const loader = async ({ params }: LoaderFunctionArgs) => {
const { userId } = parseParams({
params,
schema: paramsSchema,
});
const current = SendouQ.findOwnGroup(userId);
const result: GetUsersActiveSendouqMatchResponse = {
matchId: current?.matchId ?? null,
};
return Response.json(result);
};

View File

@@ -0,0 +1,82 @@
import type { LoaderFunctionArgs } from "react-router";
import { z } from "zod";
import * as SQMatchRepository from "~/features/sendouq-match/SQMatchRepository.server";
import { getFixedTForLanguage } from "~/modules/i18n/i18next.server";
import { notFoundIfNullish, parseParams } from "~/utils/remix.server";
import { id } from "~/utils/zod";
import type { GetSendouqMatchResponse, MapListMap } from "../schema";
const paramsSchema = z.object({
matchId: id,
});
export const loader = async ({ params }: LoaderFunctionArgs) => {
const { matchId } = parseParams({
params,
schema: paramsSchema,
});
const match = notFoundIfNullish(await SQMatchRepository.findById(matchId));
const t = await getFixedTForLanguage("en", ["game-misc"]);
const userIdToRank = (userId: number) => {
const memento = match.memento;
if (!memento) return null;
const userMemento = memento.users[userId];
return userMemento.skill && userMemento.skill !== "CALCULATING"
? userMemento.skill.tier
: null;
};
const score = match.mapList.reduce(
(acc, cur) => {
if (!cur.winnerGroupId) return acc;
if (cur.winnerGroupId === match.groupAlpha.id) {
return [acc[0] + 1, acc[1]];
}
return [acc[0], acc[1] + 1];
},
[0, 0],
);
const result: GetSendouqMatchResponse = {
mapList: match.mapList.map((map) => ({
map: {
mode: map.mode,
stage: {
id: map.stageId,
name: t(`game-misc:STAGE_${map.stageId}`),
},
},
winnerTeamId: map.winnerGroupId,
source: Number.isNaN(Number(map.source))
? (map.source as MapListMap["source"])
: Number(map.source),
participatedUserIds: null,
ko: null,
})),
teamAlpha: {
id: match.groupAlpha.id,
score: score[0],
players: match.groupAlpha.members.map((member) => ({
userId: member.id,
rank: userIdToRank(member.id),
})),
},
teamBravo: {
id: match.groupBravo.id,
score: score[1],
players: match.groupBravo.members.map((member) => ({
userId: member.id,
rank: userIdToRank(member.id),
})),
},
};
return Response.json(result);
};

View File

@@ -0,0 +1,44 @@
import type { LoaderFunctionArgs } from "react-router";
import { z } from "zod";
import { db } from "~/db/sql";
import { concatUserSubmittedImagePrefix } from "~/utils/kysely.server";
import { notFoundIfNullish, parseParams } from "~/utils/remix.server";
import { id } from "~/utils/zod";
import type { GetTeamResponse } from "../schema";
const paramsSchema = z.object({
id,
});
export const loader = async ({ params }: LoaderFunctionArgs) => {
const { id: teamId } = parseParams({ params, schema: paramsSchema });
const team = notFoundIfNullish(
await db
.selectFrom("Team")
.leftJoin(
"UserSubmittedImage",
"UserSubmittedImage.id",
"Team.avatarImgId",
)
.select((eb) => [
"Team.id",
"Team.name",
"Team.customUrl",
concatUserSubmittedImagePrefix(eb.ref("UserSubmittedImage.url")).as(
"logoUrl",
),
])
.where("Team.id", "=", teamId)
.executeTakeFirst(),
);
const result: GetTeamResponse = {
id: team.id,
name: team.name,
logoUrl: team.logoUrl,
teamPageUrl: `https://sendou.ink/t/${team.customUrl}`,
};
return Response.json(result);
};

View File

@@ -0,0 +1,169 @@
import type { LoaderFunctionArgs } from "react-router";
import { z } from "zod";
import { db } from "~/db/sql";
import * as TournamentRepository from "~/features/tournament/TournamentRepository.server";
import * as TournamentTeamRepository from "~/features/tournament/TournamentTeamRepository.server";
import { tournamentSharedCached } from "~/features/tournament-bracket/core/Tournament.server";
import { resolveMapList } from "~/features/tournament-match/core/mapList.server";
import { getFixedTForLanguage } from "~/modules/i18n/i18next.server";
import { parseMaplistSource } from "~/modules/tournament-map-list-generator/source";
import { jsonArrayFrom } from "~/utils/kysely.server";
import { logger } from "~/utils/logger";
import { notFoundIfNullish, parseParams } from "~/utils/remix.server";
import { id } from "~/utils/zod";
import type { GetTournamentMatchResponse } from "../schema";
const paramsSchema = z.object({
id,
});
export const loader = async ({ params }: LoaderFunctionArgs) => {
const t = await getFixedTForLanguage("en", ["game-misc"]);
const { id } = parseParams({
params,
schema: paramsSchema,
});
const match = notFoundIfNullish(
await db
.selectFrom("TournamentMatch")
.innerJoin(
"TournamentStage",
"TournamentStage.id",
"TournamentMatch.stageId",
)
.innerJoin("Tournament", "Tournament.id", "TournamentStage.tournamentId")
.innerJoin(
"TournamentRound",
"TournamentRound.id",
"TournamentMatch.roundId",
)
.select(({ eb }) => [
"TournamentStage.tournamentId",
"TournamentMatch.id",
"TournamentMatch.opponentOne",
"TournamentMatch.opponentTwo",
"TournamentMatch.winnerSide",
"Tournament.mapPickingStyle",
"TournamentRound.maps",
jsonArrayFrom(
eb
.selectFrom("TournamentMatchGameResult")
.select(({ eb: innerEb }) => [
"TournamentMatchGameResult.stageId",
"TournamentMatchGameResult.mode",
"TournamentMatchGameResult.winnerTeamId",
"TournamentMatchGameResult.source",
"TournamentMatchGameResult.ko",
jsonArrayFrom(
innerEb
.selectFrom("TournamentMatchGameResultParticipant")
.select("TournamentMatchGameResultParticipant.userId")
.whereRef(
"TournamentMatchGameResultParticipant.matchGameResultId",
"=",
"TournamentMatchGameResult.id",
),
).as("participants"),
])
.where("TournamentMatchGameResult.matchId", "=", id)
.orderBy("TournamentMatchGameResult.number", "asc"),
).as("playedMapList"),
])
.where("TournamentMatch.id", "=", id)
.executeTakeFirst(),
);
const tournament = await tournamentSharedCached(match.tournamentId);
const mapList = async (): Promise<GetTournamentMatchResponse["mapList"]> => {
const { opponentOne, opponentTwo } = match;
if (!opponentOne?.id || !opponentTwo?.id) {
return null;
}
if (match.winnerSide) {
return match.playedMapList.map((playedMap) => ({
map: {
mode: playedMap.mode,
stage: {
id: playedMap.stageId,
name: t(`game-misc:STAGE_${playedMap.stageId}`),
},
},
participatedUserIds: playedMap.participants.map((p) => p.userId),
winnerTeamId: playedMap.winnerTeamId,
source: parseMaplistSource(playedMap.source),
ko: playedMap.ko !== null ? Boolean(playedMap.ko) : null,
}));
}
const pickBanEvents = match.maps?.pickBan
? await TournamentRepository.findPickBanEventsByMatchId(match.id)
: [];
const mapPools = await TournamentTeamRepository.findMapPoolsByTeamIds([
opponentOne.id,
opponentTwo.id,
]);
return resolveMapList({
tournamentId: match.tournamentId,
matchId: id,
teams: [opponentOne.id, opponentTwo.id],
mapPoolByTeamId: (teamId) => mapPools.get(teamId) ?? [],
mapPickingStyle: match.mapPickingStyle,
maps: match.maps,
tieBreakerMapPool: tournament.ctx.tieBreakerMapPool,
pickBanEvents,
recentlyPlayedMaps:
match.mapPickingStyle !== "TO"
? await TournamentTeamRepository.findRecentlyPlayedMapsByIds({
teamIds: [opponentOne.id, opponentTwo.id],
excludeMatchId: id,
}).catch((error) => {
logger.error("Failed to fetch recently played maps", error);
return [];
})
: undefined,
}).map((mapListMap) => {
return {
map: {
mode: mapListMap.mode,
stage: {
id: mapListMap.stageId,
name: t(`game-misc:STAGE_${mapListMap.stageId}`),
},
},
participatedUserIds: null,
winnerTeamId: null,
source: mapListMap.source,
ko: null,
};
});
};
const { bracketName, roundNameWithoutMatchIdentifier } =
tournament.matchContextNamesById(id);
const result: GetTournamentMatchResponse = {
teamOne: match.opponentOne?.id
? {
id: match.opponentOne.id,
score: match.opponentOne.score ?? 0,
}
: null,
teamTwo: match.opponentTwo?.id
? {
id: match.opponentTwo.id,
score: match.opponentTwo.score ?? 0,
}
: null,
url: `https://sendou.ink/to/${match.tournamentId}/matches/${id}`,
mapList: await mapList(),
bracketName: bracketName ?? null,
roundName: roundNameWithoutMatchIdentifier ?? null,
};
return Response.json(result);
};

View File

@@ -0,0 +1,44 @@
import type { LoaderFunctionArgs } from "react-router";
import { z } from "zod";
import { tournamentFromDB } from "~/features/tournament-bracket/core/Tournament.server";
import { notFoundIfNullish, parseParams } from "~/utils/remix.server";
import { id } from "~/utils/zod";
import type { GetTournamentBracketStandingsResponse } from "../schema";
const paramsSchema = z.object({
id,
bidx: z.coerce.number().int(),
});
export const loader = async ({ params }: LoaderFunctionArgs) => {
const { id, bidx } = parseParams({ params, schema: paramsSchema });
const tournament = await tournamentFromDB(id);
const bracket = notFoundIfNullish(tournament.bracketByIdx(bidx));
if (bracket.preview) throw new Response(null, { status: 404 });
const result: GetTournamentBracketStandingsResponse = {
finished: bracket.standingsAreFinal,
standings: bracket.liveStandings.map((standing) => ({
tournamentTeamId: standing.team.id,
placement: standing.placement,
groupId: standing.groupId,
stats: standing.stats
? {
setWins: standing.stats.setWins,
setLosses: standing.stats.setLosses,
mapWins: standing.stats.mapWins,
mapLosses: standing.stats.mapLosses,
koCount: standing.stats.koCount,
winsAgainstTied: standing.stats.winsAgainstTied,
lossesAgainstTied: standing.stats.lossesAgainstTied,
opponentSetWinPercentage: standing.stats.opponentSetWinPercentage,
opponentMapWinPercentage: standing.stats.opponentMapWinPercentage,
}
: undefined,
})),
};
return Response.json(result);
};

View File

@@ -0,0 +1,61 @@
import type { LoaderFunctionArgs } from "react-router";
import { z } from "zod";
import type { Bracket } from "~/features/tournament-bracket/core/Bracket";
import { tournamentFromDB } from "~/features/tournament-bracket/core/Tournament.server";
import { notFoundIfNullish, parseParams } from "~/utils/remix.server";
import { id } from "~/utils/zod";
import type { GetTournamentBracketResponse } from "../schema";
const paramsSchema = z.object({
id,
bidx: z.coerce.number().int(),
});
export const loader = async ({ params }: LoaderFunctionArgs) => {
const { id, bidx } = parseParams({ params, schema: paramsSchema });
const tournament = await tournamentFromDB(id);
const bracket = notFoundIfNullish(tournament.bracketByIdx(bidx));
const result: GetTournamentBracketResponse = {
data: bracket.data,
teams: teams(bracket),
meta: {
teamsPerGroup:
bracket.type === "round_robin"
? (bracket.settings?.teamsPerGroup ??
tournament.ctx.settings.teamsPerGroup)
: undefined,
groupCount:
bracket.type === "swiss"
? (bracket.settings?.groupCount ??
tournament.ctx.settings.swiss?.groupCount)
: undefined,
roundCount:
bracket.type === "swiss"
? (bracket.settings?.roundCount ??
tournament.ctx.settings.swiss?.roundCount)
: undefined,
},
};
return Response.json(result);
};
function teams(bracket: Bracket) {
const checkedIn = bracket.seeding ?? bracket.participantTournamentTeamIds;
const pending = bracket.teamsPendingCheckIn ?? [];
return checkedIn
.map((teamId) => ({
id: teamId,
checkedIn: true,
}))
.concat(
pending.map((teamId) => ({
id: teamId,
checkedIn: false,
})),
);
}

View File

@@ -0,0 +1,46 @@
import type { LoaderFunctionArgs } from "react-router";
import { z } from "zod";
import { db } from "~/db/sql";
import { notFoundIfNullish, parseParams } from "~/utils/remix.server";
import { id } from "~/utils/zod";
import type { GetCastedTournamentMatchesResponse } from "../schema";
const paramsSchema = z.object({
id,
});
export const loader = async ({ params }: LoaderFunctionArgs) => {
const { id } = parseParams({
params,
schema: paramsSchema,
});
const tournament = notFoundIfNullish(
await db
.selectFrom("Tournament")
.select(["Tournament.castedMatchesInfo"])
.where("Tournament.id", "=", id)
.executeTakeFirst(),
);
const result: GetCastedTournamentMatchesResponse = {
current:
tournament.castedMatchesInfo?.castedMatches.map((match) => ({
matchId: match.matchId,
channel: {
type: "TWITCH",
channelId: match.twitchAccount,
},
})) ?? [],
future:
tournament.castedMatchesInfo?.lockedMatches.map((lm) => ({
matchId: lm.matchId,
channel: {
type: "TWITCH" as const,
channelId: lm.twitchAccount,
},
})) ?? [],
};
return Response.json(result);
};

View File

@@ -0,0 +1,22 @@
import type { LoaderFunctionArgs } from "react-router";
import { z } from "zod";
import * as TournamentMatchRepository from "~/features/tournament-match/TournamentMatchRepository.server";
import { parseParams } from "~/utils/remix.server";
import { id } from "~/utils/zod";
import type { GetTournamentPlayersResponse } from "../schema";
const paramsSchema = z.object({
id,
});
export const loader = async ({ params }: LoaderFunctionArgs) => {
const { id } = parseParams({
params,
schema: paramsSchema,
});
const participants: GetTournamentPlayersResponse =
await TournamentMatchRepository.findUserParticipationByTournamentId(id);
return Response.json(participants);
};

View File

@@ -0,0 +1,40 @@
import type { ActionFunctionArgs } from "react-router";
import { z } from "zod";
import { action as adminAction } from "~/features/tournament-admin/actions/to.$id.admin.seeds.server";
import { parseBody, parseParams } from "~/utils/remix.server";
import { id } from "~/utils/zod";
import { wrapActionForApi } from "../api-action-wrapper.server";
const paramsSchema = z.object({
id,
});
const bodySchema = z.object({
tournamentTeamIds: z.array(id),
});
export const action = async (args: ActionFunctionArgs) => {
const { id: tournamentId } = parseParams({
params: args.params,
schema: paramsSchema,
});
const { tournamentTeamIds } = await parseBody({
request: args.request,
schema: bodySchema,
});
const internalRequest = new Request(args.request.url, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
_action: "UPDATE_SEEDS",
seeds: tournamentTeamIds,
}),
});
return wrapActionForApi(adminAction, {
...args,
params: { id: String(tournamentId) },
request: internalRequest,
});
};

View File

@@ -0,0 +1,45 @@
import type { ActionFunctionArgs } from "react-router";
import { z } from "zod";
import { action as adminAction } from "~/features/tournament-admin/actions/to.$id.admin.seeds.server";
import { parseBody, parseParams } from "~/utils/remix.server";
import { id } from "~/utils/zod";
import { wrapActionForApi } from "../api-action-wrapper.server";
const paramsSchema = z.object({
id,
});
const bodySchema = z.object({
startingBrackets: z.array(
z.object({
tournamentTeamId: id,
startingBracketIdx: z.number().int().min(0),
}),
),
});
export const action = async (args: ActionFunctionArgs) => {
const { id: tournamentId } = parseParams({
params: args.params,
schema: paramsSchema,
});
const { startingBrackets } = await parseBody({
request: args.request,
schema: bodySchema,
});
const internalRequest = new Request(args.request.url, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
_action: "UPDATE_STARTING_BRACKETS",
startingBrackets,
}),
});
return wrapActionForApi(adminAction, {
...args,
params: { id: String(tournamentId) },
request: internalRequest,
});
};

View File

@@ -0,0 +1,80 @@
import { sql } from "kysely";
import type { LoaderFunctionArgs } from "react-router";
import { z } from "zod";
import { db } from "~/db/sql";
import { jsonArrayFrom } from "~/utils/kysely.server";
import { notFoundIfNullish, parseParams } from "~/utils/remix.server";
import { id } from "~/utils/zod";
import type { GetTournamentStreamsResponse } from "../schema";
const paramsSchema = z.object({
id,
});
export const loader = async ({ params }: LoaderFunctionArgs) => {
const { id } = parseParams({
params,
schema: paramsSchema,
});
const tournament = notFoundIfNullish(
await db
.selectFrom("Tournament")
.select([
jsonArrayFrom(
db
.selectFrom("LiveStream")
.leftJoin(
"TournamentTeamMember",
"TournamentTeamMember.userId",
"LiveStream.userId",
)
.leftJoin(
"TournamentTeam",
"TournamentTeam.id",
"TournamentTeamMember.tournamentTeamId",
)
.select([
"LiveStream.userId",
"LiveStream.twitch",
"LiveStream.viewerCount",
])
.where("TournamentTeam.tournamentId", "=", id),
).as("playerStreams"),
jsonArrayFrom(
db
.selectFrom("LiveStream")
.select(["LiveStream.twitch", "LiveStream.viewerCount"])
.where(
sql<boolean>`"LiveStream"."twitch" IN (SELECT value FROM json_each("Tournament"."castTwitchAccounts"))`,
),
).as("castStreams"),
])
.where("Tournament.id", "=", id)
.executeTakeFirst(),
);
const playerStreams: GetTournamentStreamsResponse =
tournament?.playerStreams.map((stream) => ({
type: "PLAYER",
userId: stream.userId!,
platform: "TWITCH",
channelId: stream.twitch!,
viewerCount: stream.viewerCount!,
})) ?? [];
const castStreams: GetTournamentStreamsResponse =
tournament?.castStreams.map((stream) => ({
type: "CAST",
platform: "TWITCH",
channelId: stream.twitch!,
viewerCount: stream.viewerCount!,
})) ?? [];
const result: GetTournamentStreamsResponse = [
...playerStreams,
...castStreams,
].sort((a, b) => b.viewerCount - a.viewerCount);
return Response.json(result);
};

View File

@@ -0,0 +1,143 @@
import type { ActionFunctionArgs } from "react-router";
import { z } from "zod";
import { requireUser } from "~/features/auth/core/user.server";
import { userIsBanned } from "~/features/ban/core/banned.server";
import * as ChatSystemMessage from "~/features/chat/ChatSystemMessage.server";
import * as ShowcaseTournaments from "~/features/front-page/core/ShowcaseTournaments.server";
import { notify } from "~/features/notifications/core/notify.server";
import * as TournamentTeamRepository from "~/features/tournament/TournamentTeamRepository.server";
import {
clearTournamentDataCache,
requireTournamentOrganizer,
tournamentFromDB,
} from "~/features/tournament-bracket/core/Tournament.server";
import * as TournamentLFGRepository from "~/features/tournament-lfg/TournamentLFGRepository.server";
import { syncPickupChatMetadata } from "~/features/tournament-lfg/tournament-lfg-utils.server";
import * as UserRepository from "~/features/user-page/UserRepository.server";
import {
errorToastIfFalsy,
parseBody,
parseParams,
} from "~/utils/remix.server";
import { id } from "~/utils/zod";
import { wrapActionForApi } from "../api-action-wrapper.server";
const paramsSchema = z.object({
id,
teamId: id,
});
const bodySchema = z.object({
userId: id,
});
export const action = async (args: ActionFunctionArgs) => {
const { id: tournamentId, teamId } = parseParams({
params: args.params,
schema: paramsSchema,
});
const { userId } = await parseBody({
request: args.request,
schema: bodySchema,
});
return wrapActionForApi(async () => {
const user = requireUser();
const tournament = await tournamentFromDB(tournamentId);
requireTournamentOrganizer(tournament, user);
const team = tournament.teamById(teamId);
errorToastIfFalsy(team, "Invalid team id");
const previousTeam = tournament.teamMemberOfByUser({ id: userId });
errorToastIfFalsy(
!previousTeam?.id || previousTeam.id !== team.id,
"User is already in this team",
);
errorToastIfFalsy(
tournament.hasStarted || !previousTeam,
"User is already in a team",
);
errorToastIfFalsy(
!userIsBanned(userId),
"User trying to be added currently has an active ban from sendou.ink",
);
const addMemberUser = await UserRepository.findLeanById(userId);
errorToastIfFalsy(addMemberUser?.friendCode, "User has no friend code set");
errorToastIfFalsy(
!tournament.ctx.settings.requireInGameNames || addMemberUser?.inGameName,
"User has no in-game name set",
);
await TournamentLFGRepository.leaveLfg({
userId,
tournamentId,
});
// this team is not checked in & tournament started, so we can simply delete it
const previousTeamIdToDelete =
previousTeam &&
previousTeam.checkIns.length === 0 &&
tournament.hasStarted
? previousTeam.id
: undefined;
const previousTeamPickupChat = previousTeamIdToDelete
? await TournamentLFGRepository.findPickupChatTeamById(
previousTeamIdToDelete,
)
: null;
await TournamentTeamRepository.join({
userId,
newTeamId: team.id,
previousTeamIdToDelete,
isOrganizerAdded: true,
});
if (previousTeamPickupChat) {
ChatSystemMessage.removeRoom(previousTeamPickupChat.chatCode);
}
ShowcaseTournaments.addToCached({
tournamentId,
type: "participant",
userId,
});
await ShowcaseTournaments.refreshCachedTournamentCounts(tournamentId);
await syncPickupChatMetadata({
teamId: team.id,
tournament: {
id: tournamentId,
name: tournament.ctx.name,
logoUrl: tournament.ctx.logoUrl,
startTime: tournament.ctx.startsAt,
},
});
if (!tournament.isTest && !tournament.isDraft) {
notify({
userIds: [userId],
notification: {
type: "TO_ADDED_TO_TEAM",
pictureUrl: team.logoUrl ?? tournament.ctx.logoUrl,
meta: {
adderUsername: user.username,
teamName: team.name,
tournamentId,
tournamentName: tournament.ctx.name,
tournamentTeamId: team.id,
},
},
});
}
clearTournamentDataCache(tournamentId);
return null;
}, args);
};

View File

@@ -0,0 +1,91 @@
import type { ActionFunctionArgs } from "react-router";
import { z } from "zod";
import { requireUser } from "~/features/auth/core/user.server";
import * as ShowcaseTournaments from "~/features/front-page/core/ShowcaseTournaments.server";
import * as TournamentTeamRepository from "~/features/tournament/TournamentTeamRepository.server";
import {
clearTournamentDataCache,
requireTournamentOrganizer,
tournamentFromDB,
} from "~/features/tournament-bracket/core/Tournament.server";
import { syncPickupChatMetadata } from "~/features/tournament-lfg/tournament-lfg-utils.server";
import {
errorToastIfFalsy,
parseBody,
parseParams,
} from "~/utils/remix.server";
import { id } from "~/utils/zod";
import { wrapActionForApi } from "../api-action-wrapper.server";
const paramsSchema = z.object({
id,
teamId: id,
});
const bodySchema = z.object({
userId: id,
});
export const action = async (args: ActionFunctionArgs) => {
const { id: tournamentId, teamId } = parseParams({
params: args.params,
schema: paramsSchema,
});
const { userId } = await parseBody({
request: args.request,
schema: bodySchema,
});
return wrapActionForApi(async () => {
const user = requireUser();
const tournament = await tournamentFromDB(tournamentId);
requireTournamentOrganizer(tournament, user);
const team = tournament.teamById(teamId);
errorToastIfFalsy(team, "Invalid team id");
errorToastIfFalsy(
team.checkIns.length === 0 ||
team.memberUserIds.length > tournament.minMembersPerTeam,
"Can't remove last member from checked in team",
);
errorToastIfFalsy(team.ownerUserId !== userId, "Cannot remove team owner");
errorToastIfFalsy(
!tournament.hasStarted ||
!tournament.participatedPlayerUserIdsByTeamId(teamId).includes(userId),
"Cannot remove player that has participated in the tournament",
);
if (team.activeRosterUserIds?.includes(userId)) {
await TournamentTeamRepository.setActiveRoster({
teamId: team.id,
activeRosterUserIds: null,
});
}
await TournamentTeamRepository.leave({
userId,
teamId: team.id,
});
ShowcaseTournaments.removeFromCached({
tournamentId,
type: "participant",
userId,
});
await ShowcaseTournaments.refreshCachedTournamentCounts(tournamentId);
await syncPickupChatMetadata({
teamId: team.id,
tournament: {
id: tournamentId,
name: tournament.ctx.name,
logoUrl: tournament.ctx.logoUrl,
startTime: tournament.ctx.startsAt,
},
});
clearTournamentDataCache(tournamentId);
return null;
}, args);
};

View File

@@ -0,0 +1,58 @@
import type { ActionFunctionArgs } from "react-router";
import { z } from "zod";
import { requireUser } from "~/features/auth/core/user.server";
import * as TournamentTeamRepository from "~/features/tournament/TournamentTeamRepository.server";
import {
clearTournamentDataCache,
requireTournamentOrganizer,
tournamentFromDB,
} from "~/features/tournament-bracket/core/Tournament.server";
import { inGameNameIsValid } from "~/features/user-page/in-game-name";
import {
badRequestIfFalsy,
parseBody,
parseParams,
} from "~/utils/remix.server";
import { id } from "~/utils/zod";
import { wrapActionForApi } from "../api-action-wrapper.server";
const paramsSchema = z.object({
id,
teamId: id,
});
const bodySchema = z.object({
userId: id,
inGameName: z.string().refine(inGameNameIsValid),
});
export const action = async (args: ActionFunctionArgs) => {
const { id: tournamentId } = parseParams({
params: args.params,
schema: paramsSchema,
});
const { userId, inGameName } = await parseBody({
request: args.request,
schema: bodySchema,
});
return wrapActionForApi(async () => {
const user = requireUser();
const tournament = await tournamentFromDB(tournamentId);
requireTournamentOrganizer(tournament, user);
const teamMemberOf = badRequestIfFalsy(
tournament.teamMemberOfByUser({ id: userId }),
);
await TournamentTeamRepository.updateMemberInGameName({
userId,
inGameName,
tournamentTeamId: teamMemberOf.id,
});
clearTournamentDataCache(tournamentId);
return null;
}, args);
};

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

@@ -0,0 +1,184 @@
import { sql } from "kysely";
import type { LoaderFunctionArgs } from "react-router";
import { z } from "zod";
import { db } from "~/db/sql";
import { ordinalToSp } from "~/features/mmr/mmr-utils";
import * as TournamentRepository from "~/features/tournament/TournamentRepository.server";
import { getFixedTForLanguage } from "~/modules/i18n/i18next.server";
import { nullifyingAvg } from "~/utils/arrays";
import { databaseTimestampToDate } from "~/utils/dates";
import {
concatUserSubmittedImagePrefix,
jsonArrayFrom,
jsonObjectFrom,
tournamentUsername,
} from "~/utils/kysely.server";
import { parseParams } from "~/utils/remix.server";
import { id } from "~/utils/zod";
import type { GetTournamentTeamsResponse } from "../schema";
const paramsSchema = z.object({
id,
});
export const loader = async ({ params }: LoaderFunctionArgs) => {
const t = await getFixedTForLanguage("en", ["game-misc"]);
const { id } = parseParams({
params,
schema: paramsSchema,
});
const teams = await db
.selectFrom("TournamentTeam")
.leftJoin("UserSubmittedImage", "avatarImgId", "UserSubmittedImage.id")
.leftJoin("TournamentTeamCheckIn", (join) =>
join
.onRef(
"TournamentTeam.id",
"=",
"TournamentTeamCheckIn.tournamentTeamId",
)
.on("TournamentTeamCheckIn.bracketIdx", "is", null),
)
.select(({ eb }) => [
"TournamentTeam.id",
"TournamentTeam.name",
"TournamentTeam.seed",
"TournamentTeam.createdAt",
"TournamentTeamCheckIn.checkedInAt",
concatUserSubmittedImagePrefix(eb.ref("UserSubmittedImage.url")).as(
"avatarUrl",
),
jsonObjectFrom(
eb
.selectFrom("AllTeam")
.leftJoin(
"UserSubmittedImage",
"AllTeam.avatarImgId",
"UserSubmittedImage.id",
)
.whereRef("AllTeam.id", "=", "TournamentTeam.teamId")
.select([
"AllTeam.customUrl",
concatUserSubmittedImagePrefix(eb.ref("UserSubmittedImage.url")).as(
"logoUrl",
),
"AllTeam.deletedAt",
]),
).as("team"),
jsonArrayFrom(
eb
.selectFrom("TournamentTeamMember")
.innerJoin("User", "User.id", "TournamentTeamMember.userId")
.leftJoin("SeedingSkill as RankedSeedingSkill", (join) =>
join
.onRef("User.id", "=", "RankedSeedingSkill.userId")
.on("RankedSeedingSkill.type", "=", "RANKED"),
)
.leftJoin("SeedingSkill as UnrankedSeedingSkill", (join) =>
join
.onRef("User.id", "=", "UnrankedSeedingSkill.userId")
.on("UnrankedSeedingSkill.type", "=", "UNRANKED"),
)
.select([
"User.id as userId",
tournamentUsername().as("username"),
"User.discordId",
"User.discordAvatar",
"User.battlefy",
"User.country",
"User.pronouns",
"TournamentTeamMember.inGameName",
"TournamentTeamMember.role",
"TournamentTeamMember.createdAt",
"RankedSeedingSkill.ordinal as rankedOrdinal",
"UnrankedSeedingSkill.ordinal as unrankedOrdinal",
])
.whereRef(
"TournamentTeamMember.tournamentTeamId",
"=",
"TournamentTeam.id",
)
.orderBy(sql`"TournamentTeamMember"."role" = 'OWNER'`, "desc")
.orderBy("TournamentTeamMember.createdAt", "asc"),
).as("members"),
jsonArrayFrom(
eb
.selectFrom("MapPoolMap")
.select(["MapPoolMap.stageId", "MapPoolMap.mode"])
.whereRef("MapPoolMap.tournamentTeamId", "=", "TournamentTeam.id"),
).as("mapPool"),
])
.where("TournamentTeam.tournamentId", "=", id)
.where("TournamentTeam.isPlaceholder", "=", 0)
.orderBy("TournamentTeam.createdAt", "asc")
.execute();
const friendCodes =
await TournamentRepository.findFriendCodesByTournamentId(id);
const result: GetTournamentTeamsResponse = teams.map((team) => {
return {
id: team.id,
name: team.name,
url: `https://sendou.ink/to/${id}/teams/${team.id}`,
teamPageUrl:
team.team?.customUrl && !team.team.deletedAt
? `https://sendou.ink/t/${team.team.customUrl}`
: null,
seed: team.seed,
registeredAt: databaseTimestampToDate(team.createdAt).toISOString(),
checkedIn: Boolean(team.checkedInAt),
seedingPower: {
ranked: toSeedingPowerSP(
team.members.map((member) => member.rankedOrdinal),
),
unranked: toSeedingPowerSP(
team.members.map((member) => member.unrankedOrdinal),
),
},
members: team.members.map((member) => {
return {
userId: member.userId,
name: member.username,
battlefy: member.battlefy,
discordId: member.discordId,
avatarUrl: member.discordAvatar
? `https://cdn.discordapp.com/avatars/${member.discordId}/${member.discordAvatar}.png`
: null,
country: member.country,
captain: member.role === "OWNER",
inGameName: member.inGameName,
pronouns: member.pronouns,
friendCode: friendCodes[member.userId],
joinedAt: databaseTimestampToDate(member.createdAt).toISOString(),
};
}),
logoUrl: team.team?.logoUrl ?? team.avatarUrl,
mapPool:
team.mapPool.length > 0
? team.mapPool.map((map) => {
return {
mode: map.mode,
stage: {
id: map.stageId,
name: t(`game-misc:STAGE_${map.stageId}`),
},
};
})
: null,
};
});
return Response.json(result);
};
function toSeedingPowerSP(ordinals: (number | null)[]) {
const avg = nullifyingAvg(
ordinals.filter((ordinal) => typeof ordinal === "number"),
);
if (typeof avg !== "number") return null;
return ordinalToSp(avg);
}

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

@@ -0,0 +1,96 @@
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 { 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";
import { id } from "~/utils/zod";
import { wrapActionForApi } from "../api-action-wrapper.server";
const paramsSchema = z.object({
id,
});
const bodySchema = z.object({
tournamentTeamId: id.optional(),
name: z.string().max(TOURNAMENT.TEAM_NAME_MAX_LENGTH).optional(),
teamId: id.optional(),
ownerUserId: id,
members: z
.array(
z.object({
userId: id,
inGameName: z.string().optional(),
}),
)
.min(1)
.max(ADMIN_REGISTRATION_MAX_MEMBERS),
});
export const action = async (args: ActionFunctionArgs) => {
const { id: tournamentId } = parseParams({
params: args.params,
schema: paramsSchema,
});
const body = await parseBody({
request: args.request,
schema: bodySchema,
});
const existingTeam =
typeof body.tournamentTeamId === "number"
? (
await TournamentRepository.findTeamsFullByTournamentId(tournamentId)
).find((team) => team.id === body.tournamentTeamId)
: undefined;
if (typeof body.tournamentTeamId === "number" && !existingTeam) {
return Response.json(
{ error: "Invalid tournament team id" },
{
status: 400,
},
);
}
const linkedTeam = typeof body.teamId === "number";
// the API can't upload logos, so an existing pickup logo is carried over as is
const logo =
!linkedTeam && existingTeam
? existingImage(existingTeam.avatarImgId, existingTeam.pickupAvatarUrl)
: null;
const internalRequest = new Request(args.request.url, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
_action: "UPSERT_REGISTRATION",
tournamentTeamId: body.tournamentTeamId,
linkedTeam,
pickUpName: body.name ?? null,
logo,
teamId: body.teamId ?? null,
ownerId: String(body.ownerUserId),
members: body.members.map((member) => ({
userId: member.userId,
inGameName: member.inGameName ?? null,
})),
// the API can't edit counterpick maps, so the team's existing pool is carried over as is
mapPool: existingTeam?.mapPool ?? [],
}),
});
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

@@ -0,0 +1,81 @@
import type { LoaderFunctionArgs } from "react-router";
import { z } from "zod";
import { db } from "~/db/sql";
import { databaseTimestampToDate } from "~/utils/dates";
import { jsonArrayFrom } from "~/utils/kysely.server";
import { notFoundIfNullish, parseParams } from "~/utils/remix.server";
import { id } from "~/utils/zod";
import type { GetTournamentResponse } from "../schema";
const paramsSchema = z.object({
id,
});
export const loader = async ({ params }: LoaderFunctionArgs) => {
const { id } = parseParams({ params, schema: paramsSchema });
const tournament = notFoundIfNullish(
await db
.selectFrom("Tournament")
.innerJoin("CalendarEvent", "CalendarEvent.tournamentId", "Tournament.id")
.innerJoin(
"CalendarEventDate",
"CalendarEventDate.eventId",
"CalendarEvent.id",
)
.select(({ eb, exists, selectFrom }) => [
"CalendarEvent.name",
"CalendarEvent.organizationId",
"CalendarEventDate.startsAt",
"Tournament.settings",
exists(
selectFrom("TournamentResult")
.where("TournamentResult.tournamentId", "=", id)
.select("TournamentResult.tournamentId"),
).as("isFinalized"),
eb
.selectFrom("UserSubmittedImage")
.select(["UserSubmittedImage.url"])
.whereRef("CalendarEvent.avatarImgId", "=", "UserSubmittedImage.id")
.as("logoUrl"),
jsonArrayFrom(
eb
.selectFrom("TournamentTeam")
.leftJoin("TournamentTeamCheckIn", (join) =>
join
.onRef(
"TournamentTeam.id",
"=",
"TournamentTeamCheckIn.tournamentTeamId",
)
.on("TournamentTeamCheckIn.bracketIdx", "is", null),
)
.select(["TournamentTeamCheckIn.checkedInAt"])
.where("TournamentTeam.tournamentId", "=", id)
.where("TournamentTeam.isPlaceholder", "=", 0),
).as("teams"),
])
.where("Tournament.id", "=", id)
.executeTakeFirst(),
);
const result: GetTournamentResponse = {
name: tournament.name,
startTime: databaseTimestampToDate(tournament.startsAt).toISOString(),
url: `https://sendou.ink/to/${id}/brackets`,
logoUrl: tournament.logoUrl,
teams: {
checkedInCount: tournament.teams.filter((team) => team.checkedInAt)
.length,
registeredCount: tournament.teams.length,
},
brackets: tournament.settings.bracketProgression.map((bp) => ({
name: bp.name,
type: bp.type,
})),
organizationId: tournament.organizationId,
isFinalized: Boolean(tournament.isFinalized),
};
return Response.json(result);
};

View File

@@ -0,0 +1,27 @@
import type { LoaderFunctionArgs } from "react-router";
import { z } from "zod/v4";
import { userByIdentifierQuery } from "~/utils/kysely.server";
import { notFoundIfNullish, parseParams } from "~/utils/remix.server";
import type { GetUserIdsResponse } from "../schema";
const paramsSchema = z.object({
identifier: z.string(),
});
export const loader = async ({ params }: LoaderFunctionArgs) => {
const { identifier } = parseParams({ params, schema: paramsSchema });
const user = notFoundIfNullish(
await userByIdentifierQuery(identifier)
.select(["User.discordId", "User.customUrl"])
.executeTakeFirst(),
);
const result: GetUserIdsResponse = {
id: user.id,
discordId: user.discordId,
customUrl: user.customUrl,
};
return Response.json(result);
};

View File

@@ -0,0 +1,121 @@
import type { LoaderFunctionArgs } from "react-router";
import { z } from "zod";
import { db } from "~/db/sql";
import * as Seasons from "~/features/mmr/core/Seasons";
import { userSkills as _userSkills } from "~/features/mmr/tiered.server";
import * as UserRepository from "~/features/user-page/UserRepository.server";
import { getFixedTForLanguage } from "~/modules/i18n/i18next.server";
import { jsonArrayFrom, peakXpOverallSql } from "~/utils/kysely.server";
import { safeNumberParse } from "~/utils/number";
import { notFoundIfNullish, parseParams } from "~/utils/remix.server";
import { badgeUrl } from "~/utils/urls";
import type { GetUserResponse } from "../schema";
const paramsSchema = z.object({
identifier: z.string(),
});
export const loader = async ({ params }: LoaderFunctionArgs) => {
const t = await getFixedTForLanguage("en", ["weapons"]);
const { identifier } = parseParams({ params, schema: paramsSchema });
const user = notFoundIfNullish(
await db
.selectFrom("User")
.leftJoin("PlusTier", "PlusTier.userId", "User.id")
.leftJoin("SplatoonPlayer", "SplatoonPlayer.userId", "User.id")
.select(({ eb }) => [
"User.id",
"User.country",
"User.discordName",
"User.twitch",
"User.battlefy",
"User.bsky",
"User.customUrl",
"User.discordId",
"User.discordAvatar",
"User.inGameName",
"User.pronouns",
"PlusTier.tier",
jsonArrayFrom(
eb
.selectFrom("UserWeapon")
.select(["UserWeapon.isFavorite", "UserWeapon.weaponSplId"])
.whereRef("UserWeapon.userId", "=", "User.id")
.orderBy("UserWeapon.order", "asc"),
).as("weapons"),
peakXpOverallSql().as("peakXp"),
jsonArrayFrom(
eb
.selectFrom("TeamMemberWithSecondary")
.innerJoin("Team", "Team.id", "TeamMemberWithSecondary.teamId")
.select(["Team.id", "TeamMemberWithSecondary.role"])
.whereRef("TeamMemberWithSecondary.userId", "=", "User.id")
.orderBy("TeamMemberWithSecondary.isMainTeam", "desc")
.orderBy("TeamMemberWithSecondary.createdAt", "asc"),
).as("teams"),
])
.where((eb) => {
// we don't want to parse discord id's as numbers (length = 18)
const parsedId =
identifier.length < 10 ? safeNumberParse(identifier) : null;
if (parsedId) {
return eb("User.id", "=", parsedId);
}
return eb("User.discordId", "=", identifier);
})
.executeTakeFirst(),
);
const badges = await UserRepository.findOwnedBadgesByUserId(user.id);
const season = Seasons.currentOrPrevious(new Date())!.nth;
const { isAccurateTiers, userSkills } = await _userSkills(season);
const skill = isAccurateTiers ? userSkills[user.id] : null;
const result: GetUserResponse = {
id: user.id,
name: user.discordName,
discordId: user.discordId,
avatarUrl: user.discordAvatar
? `https://cdn.discordapp.com/avatars/${user.discordId}/${user.discordAvatar}.png`
: null,
url: `https://sendou.ink/u/${user.customUrl ?? user.discordId}`,
country: user.country,
inGameName: user.inGameName,
pronouns: user.pronouns,
plusServerTier: user.tier as GetUserResponse["plusServerTier"],
socials: {
twitch: user.twitch,
battlefy: user.battlefy,
bsky: user.bsky,
twitter: null, // deprecated field
},
currentRank: skill?.tier
? {
season,
tier: skill.tier,
}
: null,
peakXp: user.peakXp,
weaponPool: user.weapons.map((weapon) => ({
id: weapon.weaponSplId,
name: t(`weapons:MAIN_${weapon.weaponSplId}`),
isFiveStar: Boolean(weapon.isFavorite),
})),
badges: badges.map((badge) => ({
name: badge.displayName,
count: badge.count,
gifUrl: badgeUrl({ code: badge.code, extension: "gif" }),
imageUrl: badgeUrl({ code: badge.code }),
})),
teams: user.teams.map((team) => ({
id: team.id,
role: team.role,
})),
};
return Response.json(result);
};

View File

@@ -0,0 +1,50 @@
import type { LoaderFunctionArgs } from "react-router";
import { z } from "zod";
import { SendouQ } from "~/features/sendouq/core/SendouQ.server";
import { RunningTournaments } from "~/features/tournament-bracket/core/RunningTournaments.server";
import { parseParams } from "~/utils/remix.server";
import { id } from "~/utils/zod";
import type { GetUsersActiveMatchResponse } from "../schema";
const paramsSchema = z.object({
userId: id,
});
export const loader = async ({ params }: LoaderFunctionArgs) => {
const { userId } = parseParams({
params,
schema: paramsSchema,
});
const sendouqGroup = SendouQ.findOwnGroup(userId);
if (sendouqGroup?.matchId) {
const result: GetUsersActiveMatchResponse = {
matchId: sendouqGroup.matchId,
lobby: "sendouq",
tournamentId: null,
bracketIdx: null,
};
return Response.json(result);
}
for (const tournament of RunningTournaments.all) {
const status = tournament.teamMemberOfProgressStatus({ id: userId });
if (status?.type === "MATCH") {
const result: GetUsersActiveMatchResponse = {
matchId: status.matchId,
lobby: "tournament",
tournamentId: tournament.ctx.id,
bracketIdx: tournament.matchIdToBracketIdx(status.matchId),
};
return Response.json(result);
}
}
const result: GetUsersActiveMatchResponse = {
matchId: null,
lobby: null,
tournamentId: null,
bracketIdx: null,
};
return Response.json(result);
};

View File

@@ -0,0 +1,600 @@
import type { Pronouns } from "~/db/tables-json";
import type { TierName } from "~/features/mmr/mmr-constants";
import type { BracketData } from "~/features/tournament-bracket/core/engine/types";
/** GET /api/user/{userId|discordId} */
export interface GetUserResponse {
id: number;
/**
* @example "Sendou"
*/
name: string;
/**
* @example "79237403620945920"
*/
discordId: string;
/**
* @example "https://sendou.ink/u/sendou"
*/
url: string;
/**
* @example "https://cdn.discordapp.com/avatars/79237403620945920/6fc41a44b069a0d2152ac06d1e496c6c.png"
*/
avatarUrl: string | null;
/**
* @example "FI"
*/
country: string | null;
socials: {
twitch: string | null;
// @deprecated
twitter: null;
battlefy: string | null;
bsky: string | null;
};
plusServerTier: 1 | 2 | 3 | null;
weaponPool: Array<ProfileWeapon>;
badges: Array<Badge>;
/** Teams user is member of. The main team is always first in the array. */
teams: Array<GlobalTeamMembership>;
/**
* Splatoon 3 splashtag name & ID, if one is set.
*
* @example "Sendou#2955"
*/
inGameName: string | null;
/**
* User's pronouns.
*
* @example { "subject": "he", "object": "him" }
*/
pronouns: Pronouns | null;
peakXp: number | null;
/** Users current (or previous if it's off-season) ranked season (SendouQ & ranked tournaments) rank. Null if no rank for the season in question or the season does not have yet enough players on the leaderboard. */
currentRank: SeasonalRank | null;
}
/** GET /api/user/{userId|discordId|customUrl}/ids */
export interface GetUserIdsResponse {
id: number;
/**
* @example "79237403620945920"
*/
discordId: string;
/**
* @example "sendou"
*/
customUrl: string | null;
}
/** GET /api/team/{teamId} */
export interface GetTeamResponse {
id: number;
/**
* Name of the global team.
*
* @example "Moonlight"
*/
name: string;
/**
* URL for the global team page.
*
* @example "https://sendou.ink/t/moonlight"
*/
teamPageUrl: string;
/**
* URL for the global team logo.
*
* @example "https://sendou.nyc3.cdn.digitaloceanspaces.com/pickup-logo-uReSb1b1XS3TWGLCKMDUD-1719054364813.webp"
*/
logoUrl: string | null;
}
/** GET /api/calendar/{year}/{week} */
export type GetCalendarWeekResponse = Array<{
/**
* @example "In The Zone 30"
*/
name: string;
tournamentId: number | null;
/**
* @example "https://sendou.ink/to/9/brackets"
*/
tournamentUrl: string | null;
/**
* @example "2024-01-12T20:00:00.000Z"
*/
startTime: string;
}>;
/** GET /api/user/{userId}/active-match */
export interface GetUsersActiveMatchResponse {
/** The user's current match ID or null if none */
matchId: number | null;
/** What kind of match the user is in right now */
lobby: "sendouq" | "tournament" | null;
/** The ID of the tournament (null for sendouq or no match) */
tournamentId: number | null;
/** The bracket index within the tournament (null for sendouq or no match). Can be used with GET /api/tournament/{tournamentId}/brackets/{bracketIdx} */
bracketIdx: number | null;
}
/** GET /api/sendouq/active-match/{userId} */
export interface GetUsersActiveSendouqMatchResponse {
/** The user's current match ID or null if none */
matchId: number | null;
}
/** GET /api/sendouq/match/{matchId} */
export interface GetSendouqMatchResponse {
teamAlpha: SendouqMatchTeam | null;
teamBravo: SendouqMatchTeam | null;
mapList: Array<MapListMap>;
}
type SendouqMatchTeam = {
id: number;
score: number;
players: Array<SendouqMatchPlayer>;
};
type SendouqMatchPlayer = {
userId: number;
/** User's at the start time of the match */
rank: SendouQRank | null;
};
type SendouQRank = { name: TierName; isPlus: boolean };
/** GET /api/tournament/{tournamentId} */
export interface GetTournamentResponse {
/**
* @example "In The Zone 30"
*/
name: string;
/**
* @example "https://sendou.ink/to/9/brackets"
*/
url: string;
/**
* @example "https://sendou-assets.nyc3.cdn.digitaloceanspaces.com/img/tournament-logos/itz.avif"
*/
logoUrl: string | null;
/**
* @example "2024-01-12T20:00:00.000Z"
*/
startTime: string;
teams: {
registeredCount: number;
checkedInCount: number;
};
brackets: TournamentBracket[];
organizationId: number | null;
/** Has the tournament concluded (results added to user profiles & no editing possible anymore) */
isFinalized: boolean;
}
/** GET /api/tournament/{tournamentId}/teams */
export type GetTournamentTeamsResponse = Array<{
id: number;
/**
* @example "Team Olive"
*/
name: string;
/**
* @example "2024-01-12T20:00:00.000Z"
*/
registeredAt: string;
checkedIn: boolean;
/**
* URL for the tournament team page.
*
* @example "https://sendou.ink/to/9/teams/327"
*/
url: string;
/**
* URL for the global team page.
*
* @example "https://sendou.ink/t/moonlight"
*/
teamPageUrl: string | null;
/**
* @example "https://sendou.nyc3.cdn.digitaloceanspaces.com/pickup-logo-uReSb1b1XS3TWGLCKMDUD-1719054364813.webp"
*/
logoUrl: string | null;
seed: number | null;
mapPool: Array<StageWithMode> | null;
/**
* Seeding power is a non-resetting MMR value that is used for sendou.ink's autoseeding capabilities.
* It is calculated as the average of the team's members' seeding power.
* Ranked and unranked tournaments contribute to different seeding power values.
*/
seedingPower: {
ranked: number | null;
unranked: number | null;
};
members: Array<{
userId: number;
/**
* @example "Sendou"
*/
name: string;
/**
* @example "79237403620945920"
*/
discordId: string;
/**
* @example "sendouc"
*/
battlefy: string | null;
/**
* @example "https://cdn.discordapp.com/avatars/79237403620945920/6fc41a44b069a0d2152ac06d1e496c6c.png"
*/
avatarUrl: string | null;
/**
* @example "FI"
*/
country: string | null;
captain: boolean;
/**
* Splatoon 3 splashtag name & ID. Notice the value returned is the player's set name at the time of the tournament.
* Only available for tournaments with the "Require IGN's" option enabled.
*
* @example "Sendou#2955"
*/
inGameName: string | null;
/**
* User's pronouns.
*
* @example { "subject": "he", "object": "him" }
*/
pronouns: Pronouns | null;
/**
* Switch friend code used for identification purposes.
*
* @example "1234-5678-9101"
*/
friendCode: string;
/**
* @example "2024-01-12T20:00:00.000Z"
*/
joinedAt: string;
}>;
}>;
/** GET /api/tournament/{tournamentId}/players */
export type GetTournamentPlayersResponse = Array<{
userId: number;
matchIds: number[];
}>;
/** GET /api/tournament/{tournamentId}/casted */
export interface GetCastedTournamentMatchesResponse {
/*
* Matches that are currently being played and casted. Note: at the moment only one match can be casted at a time but this is an array for future proofing.
*/
current: Array<{
matchId: number;
channel: TournamentCastChannel;
}>;
/*
* Matches that are locked to be casted.
*/
future: Array<{
matchId: number;
channel: TournamentCastChannel;
}>;
}
type TournamentCastChannel = {
type: "TWITCH";
/**
* @example "iplsplatoon"
*/
channelId: string;
};
/** GET /api/tournament/{tournamentId}/streams */
export type GetTournamentStreamsResponse = Array<
{
platform: "TWITCH";
channelId: string;
viewerCount: number;
} & ({ type: "PLAYER"; userId: number } | { type: "CAST" })
>;
/** GET /api/tournament-match/{matchId} */
export interface GetTournamentMatchResponse {
teamOne: TournamentMatchTeam | null;
teamTwo: TournamentMatchTeam | null;
/**
* Name of the bracket this match belongs to.
*
* @example "Alpha Bracket"
*/
bracketName: string | null;
/**
* Name of the round this match belongs to.
*
* @example "Grand Finals"
*/
roundName: string | null;
mapList: Array<MapListMap> | null;
/**
* @example "https://sendou.ink/to/9/matches/695"
*/
url: string;
}
/** GET /api/tournament/{tournamentId}/brackets/{bracketIndex} */
export interface GetTournamentBracketResponse {
data: TournamentBracketData;
teams: Array<{
id: number;
checkedIn: boolean;
}>;
meta: {
/** How many teams per group? (round robin only) */
teamsPerGroup?: number;
/** How many groups? (swiss only) */
groupCount?: number;
/** How many rounds? (swiss only) */
roundCount?: number;
};
}
/** GET /api/tournament/{tournamentId}/brackets/{bracketIndex}/standings */
export interface GetTournamentBracketStandingsResponse {
finished: boolean;
standings: Array<{
tournamentTeamId: number;
placement: number;
/** (round robin & swiss only) id of the group the team played in. Placements are shared across groups, meaning e.g. every group's winner has the placement 1. */
groupId?: number;
stats?: {
setWins: number;
setLosses: number;
mapWins: number;
mapLosses: number;
/** @deprecated points are no longer tracked, see koCount instead */
points?: number;
/** (round robin only) how many knockout wins the team has */
koCount?: number;
winsAgainstTied: number;
lossesAgainstTied?: number;
/** (swiss only) average win percentage of the team's opponents in sets, used as a tiebreaker */
opponentSetWinPercentage?: number;
/** (swiss only) average win percentage of the team's opponents in maps, used as a tiebreaker */
opponentMapWinPercentage?: number;
};
}>;
}
/** GET /api/org/{organizationId} */
export interface GetTournamentOrganizationResponse {
id: number;
/**
* @example "Dapple Productions"
*/
name: string;
description: string | null;
/**
* @example "https://sendou.ink/org/dapple-productions"
*/
url: string;
/**
* @example "https://sendou.nyc3.cdn.digitaloceanspaces.com/gBn45bbUMXM6359ZDQS5_-1722059432073.webp"
*/
logoUrl: string | null;
members: Array<TournamentOrganizationMember>;
socialLinkUrls: Array<string>;
}
interface TournamentOrganizationMember {
userId: number;
/**
* @example "Sendou"
*/
name: string;
/**
* @example "79237403620945920"
*/
discordId: string;
/**
* User's pronouns.
*
* @example { "subject": "he", "object": "him" }
*/
pronouns: Pronouns | null;
role: "ADMIN" | "MEMBER" | "ORGANIZER" | "STREAMER";
roleDisplayName: string | null;
}
/* ----------------------------------------- */
type Weapon = {
id: number;
name: string;
};
type ProfileWeapon = Weapon & { isFiveStar: boolean };
interface GlobalTeamMembership {
/**
* ID for the global team page.
*/
id: number;
/**
* Role of the user in the team.
*/
role: TeamMemberRole | null;
}
type TeamMemberRole =
| "CAPTAIN"
| "CO_CAPTAIN"
| "FRONTLINE"
| "SLAYER"
| "SKIRMISHER"
| "SUPPORT"
| "MIDLINE"
| "BACKLINE"
| "FLEX"
| "SUB"
| "COACH"
| "CHEERLEADER";
interface SeasonalRank {
tier: {
name: RankTierName;
isPlus: boolean;
};
/**
* Which season this rank is for.
*
* @example 7
*/
season: number;
}
type RankTierName =
| "LEVIATHAN"
| "DIAMOND"
| "PLATINUM"
| "GOLD"
| "SILVER"
| "BRONZE"
| "IRON";
type Badge = {
/**
* @example "Monday Afterparty"
*/
name: string;
count: number;
/**
* @example "https://sendou-assets.nyc3.cdn.digitaloceanspaces.com/badges/monday.avif"
*/
imageUrl: string;
/**
* @example "https://sendou-assets.nyc3.cdn.digitaloceanspaces.com/badges/monday.gif"
*/
gifUrl: string;
};
type ModeShort = "TW" | "SZ" | "TC" | "RM" | "CB";
type Stage = {
id: number;
name: string;
};
type StageWithMode = {
mode: ModeShort;
stage: Stage;
};
export type MapListMap = {
map: StageWithMode;
/**
* One of the following:
* - id of the team that picked the map
* - "DEFAULT" if it was a default map, something went wrong with the algorithm typically
* - "TIEBREAKER" if it was a tiebreaker map (selected by the TO)
* - "BOTH" both teams picked the map
* - "TO" if it was a TO pick (from predefined maplist)
* - "COUNTERPICK" if it was a counterpick
* - "ROLL" if it was randomly selected
*/
source:
| number
| "DEFAULT"
| "TIEBREAKER"
| "BOTH"
| "TO"
| "COUNTERPICK"
| "ROLL";
winnerTeamId: number | null;
participatedUserIds: Array<number> | null;
/** (round robin only) whether the map ended in a knockout. `null` if not tracked. */
ko: boolean | null;
};
type TournamentMatchTeam = {
id: number;
score: number;
};
type TournamentBracket = {
type: "double_elimination" | "single_elimination" | "round_robin" | "swiss";
name: string;
};
type TournamentBracketData = BracketData;
/** POST /api/tournament/{id}/seeds */
/** @lintignore */
export interface TournamentSeedsBody {
tournamentTeamIds: number[];
}
/** POST /api/tournament/{id}/starting-brackets */
/** @lintignore */
export interface TournamentStartingBracketsBody {
startingBrackets: Array<{
tournamentTeamId: number;
startingBracketIdx: number;
}>;
}
/** POST /api/tournament/{id}/teams/upsert */
/** @lintignore */
export interface TournamentUpsertTeamBody {
/** Present when editing an existing registration, absent when adding a new team. */
tournamentTeamId?: number;
/** Team name for a pickup team. Either `name` or `teamId` must be given. */
name?: string;
/** Linked sendou.ink team id. Name and logo are sourced from the team. */
teamId?: number;
/** Roster member that is the team owner/captain. */
ownerUserId: number;
/** Full roster; members missing from the list are removed from the team. */
members: Array<{
userId: number;
inGameName?: string;
}>;
}
/** POST /api/tournament/{id}/teams/{tournamentTeamId}/add-member */
/** POST /api/tournament/{id}/teams/{tournamentTeamId}/remove-member */
/** @lintignore */
export interface TournamentTeamMemberBody {
userId: number;
}
/** POST /api/tournament/{id}/teams/{tournamentTeamId}/update-member-ign */
/** @lintignore */
export interface TournamentUpdateMemberIgnBody {
userId: number;
inGameName: string;
}