From 73c8c5121e48451fb63f9ec913f90e1d7d16b263 Mon Sep 17 00:00:00 2001 From: Kalle <38327916+Sendouc@users.noreply.github.com> Date: Sun, 10 Aug 2025 12:05:05 +0300 Subject: [PATCH] Add fields to the user public API endpoint Closes #2425 --- .../api-public/routes/user.$identifier.ts | 31 ++++++++++ app/features/api-public/schema.ts | 59 +++++++++++++++++++ 2 files changed, 90 insertions(+) diff --git a/app/features/api-public/routes/user.$identifier.ts b/app/features/api-public/routes/user.$identifier.ts index 3f612d351..c5bb309c8 100644 --- a/app/features/api-public/routes/user.$identifier.ts +++ b/app/features/api-public/routes/user.$identifier.ts @@ -3,6 +3,8 @@ import { jsonArrayFrom } from "kysely/helpers/sqlite"; import { cors } from "remix-utils/cors"; import { z } from "zod/v4"; import { db } from "~/db/sql"; +import * as Seasons from "~/features/mmr/core/Seasons"; +import { userSkills as _userSkills } from "~/features/mmr/tiered.server"; import { i18next } from "~/modules/i18n/i18next.server"; import { safeNumberParse } from "~/utils/number"; import { notFoundIfFalsy, parseParams } from "~/utils/remix.server"; @@ -68,6 +70,19 @@ export const loader = async ({ params, request }: LoaderFunctionArgs) => { .select(["XRankPlacement.power"]) .whereRef("SplatoonPlayer.userId", "=", "User.id"), ).as("xRankPlacements"), + jsonArrayFrom( + eb + .selectFrom("TeamMemberWithSecondary") + .innerJoin("Team", "Team.id", "TeamMemberWithSecondary.teamId") + .select([ + "Team.name", + "Team.customUrl", + "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) @@ -82,6 +97,11 @@ export const loader = async ({ params, request }: LoaderFunctionArgs) => { .executeTakeFirst(), ); + const season = Seasons.currentOrPrevious(new Date())!.nth; + + const { isAccurateTiers, userSkills } = _userSkills(season); + const skill = isAccurateTiers ? userSkills[user.id] : null; + const result: GetUserResponse = { id: user.id, name: user.discordName, @@ -98,6 +118,12 @@ export const loader = async ({ params, request }: LoaderFunctionArgs) => { bsky: user.bsky, twitter: null, // deprecated field }, + currentRank: skill?.tier + ? { + season, + tier: skill.tier, + } + : null, peakXp: user.xRankPlacements.length > 0 ? user.xRankPlacements.reduce((acc, cur) => { @@ -116,6 +142,11 @@ export const loader = async ({ params, request }: LoaderFunctionArgs) => { gifUrl: `https://sendou.ink/static-assets/badges/${badge.code}.gif`, imageUrl: `https://sendou.ink/static-assets/badges/${badge.code}.png`, })), + teams: user.teams.map((team) => ({ + name: team.name, + role: team.role, + teamPageUrl: `https://sendou.ink/t/${team.customUrl}`, + })), }; return await cors(request, json(result)); diff --git a/app/features/api-public/schema.ts b/app/features/api-public/schema.ts index b1e852f83..8139fd137 100644 --- a/app/features/api-public/schema.ts +++ b/app/features/api-public/schema.ts @@ -35,7 +35,11 @@ export interface GetUserResponse { plusServerTier: 1 | 2 | 3 | null; weaponPool: Array; badges: Array; + /** Teams user is member of. The main team is always first in the array. */ + teams: Array; 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/calendar/{year}/{week} */ @@ -329,6 +333,61 @@ type Weapon = { type ProfileWeapon = Weapon & { isFiveStar: boolean }; +interface GlobalTeamMembership { + /** + * Name of the global team. + * + * @example "Moonlight" + */ + name: string; + /** + * Role of the user in the team. + */ + role: TeamMemberRole | null; + /** + * URL for the global team page. + * + * @example "https://sendou.ink/t/moonlight" + */ + teamPageUrl: string; +} + +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"