mirror of
https://github.com/Sendouc/sendou.ink.git
synced 2026-10-01 07:37:55 -05:00
Move app to apps/web-react in pnpm workspace layout
This commit is contained in:
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
};
|
||||
@@ -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,
|
||||
});
|
||||
};
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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,
|
||||
];
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
}
|
||||
73
apps/web-react/app/features/api-public/routes/org.$id.ts
Normal file
73
apps/web-react/app/features/api-public/routes/org.$id.ts
Normal 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);
|
||||
};
|
||||
@@ -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);
|
||||
};
|
||||
@@ -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);
|
||||
};
|
||||
44
apps/web-react/app/features/api-public/routes/team.$id.ts
Normal file
44
apps/web-react/app/features/api-public/routes/team.$id.ts
Normal 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);
|
||||
};
|
||||
@@ -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);
|
||||
};
|
||||
@@ -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);
|
||||
};
|
||||
@@ -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,
|
||||
})),
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
};
|
||||
@@ -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);
|
||||
};
|
||||
@@ -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,
|
||||
});
|
||||
};
|
||||
@@ -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,
|
||||
});
|
||||
};
|
||||
@@ -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);
|
||||
};
|
||||
@@ -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);
|
||||
};
|
||||
@@ -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);
|
||||
};
|
||||
@@ -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);
|
||||
};
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
},
|
||||
);
|
||||
};
|
||||
@@ -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);
|
||||
};
|
||||
@@ -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);
|
||||
};
|
||||
@@ -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);
|
||||
};
|
||||
@@ -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);
|
||||
};
|
||||
600
apps/web-react/app/features/api-public/schema.ts
Normal file
600
apps/web-react/app/features/api-public/schema.ts
Normal 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;
|
||||
}
|
||||
Reference in New Issue
Block a user