From 3d4ac9f4524c4662e9ea287c8ff0c26ac5eea299 Mon Sep 17 00:00:00 2001 From: Kalle <38327916+Sendouc@users.noreply.github.com> Date: Wed, 5 Aug 2026 21:49:35 +0300 Subject: [PATCH] Add upsert tournament team write API Closes #3294 --- .../api-public/api-action-wrapper.server.ts | 14 +++ .../routes/tournament.$id.teams.upsert.ts | 87 +++++++++++++++++ app/features/api-public/schema.ts | 19 ++++ app/routes.ts | 4 + e2e/api-public.spec.ts | 97 +++++++++++++++++++ 5 files changed, 221 insertions(+) create mode 100644 app/features/api-public/routes/tournament.$id.teams.upsert.ts diff --git a/app/features/api-public/api-action-wrapper.server.ts b/app/features/api-public/api-action-wrapper.server.ts index 6c450d9d2..1d8bb226b 100644 --- a/app/features/api-public/api-action-wrapper.server.ts +++ b/app/features/api-public/api-action-wrapper.server.ts @@ -7,6 +7,7 @@ import type { ActionFunction, ActionFunctionArgs } from "react-router"; * 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, @@ -19,6 +20,19 @@ export async function wrapActionForApi( 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) { diff --git a/app/features/api-public/routes/tournament.$id.teams.upsert.ts b/app/features/api-public/routes/tournament.$id.teams.upsert.ts new file mode 100644 index 000000000..bc9346e39 --- /dev/null +++ b/app/features/api-public/routes/tournament.$id.teams.upsert.ts @@ -0,0 +1,87 @@ +import type { ActionFunctionArgs } from "react-router"; +import { z } from "zod"; +import * as TournamentRepository from "~/features/tournament/TournamentRepository.server"; +import { TOURNAMENT } from "~/features/tournament/tournament-constants"; +import { action as adminAction } from "~/features/tournament-admin/actions/to.$id.admin.registration.server"; +import { 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, + })), + }), + }); + + return wrapActionForApi(adminAction, { + ...args, + params: { id: String(tournamentId) }, + request: internalRequest, + }); +}; diff --git a/app/features/api-public/schema.ts b/app/features/api-public/schema.ts index 7c1a58bee..4fa9ca78c 100644 --- a/app/features/api-public/schema.ts +++ b/app/features/api-public/schema.ts @@ -564,6 +564,25 @@ export interface TournamentStartingBracketsBody { }>; } +/** 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 */ diff --git a/app/routes.ts b/app/routes.ts index c7cbd8165..2b138d7ae 100644 --- a/app/routes.ts +++ b/app/routes.ts @@ -416,6 +416,10 @@ export default [ "/tournament/:id/streams", "features/api-public/routes/tournament.$id.streams.ts", ), + route( + "/tournament/:id/teams/upsert", + "features/api-public/routes/tournament.$id.teams.upsert.ts", + ), route( "/tournament/:id/teams/:teamId/add-member", "features/api-public/routes/tournament.$id.teams.$teamId.add-member.ts", diff --git a/e2e/api-public.spec.ts b/e2e/api-public.spec.ts index 42b4c8e85..edb91fd16 100644 --- a/e2e/api-public.spec.ts +++ b/e2e/api-public.spec.ts @@ -1,3 +1,4 @@ +import type { Page } from "@playwright/test"; import { addHours } from "date-fns"; import { ADMIN_ID } from "~/features/admin/admin-constants"; import { FULL_GROUP_SIZE } from "~/features/sendouq/q-constants"; @@ -274,6 +275,87 @@ test.describe("Public API - Write endpoints", () => { expect(response.status()).toBe(200); }); + test("upserts tournament team registration via API", async ({ + page, + factories, + }) => { + const { tournamentId, token } = await organizedTournament(factories); + const roster = await factories.UserFactory.createMany(ROSTER_SIZE); + + await impersonate(page, ADMIN_ID); + + const createResponse = await page.request.fetch( + `/api/tournament/${tournamentId}/teams/upsert`, + { + method: "POST", + headers: authorized(token), + data: { + name: "Api Pickup", + ownerUserId: roster[0].id, + members: roster.map((user) => ({ userId: user.id })), + }, + }, + ); + expect(createResponse.status()).toBe(200); + + const createdTeam = await teamByName(page, token, { + tournamentId, + name: "Api Pickup", + }); + expect(createdTeam).toBeTruthy(); + expect(createdTeam.members).toHaveLength(ROSTER_SIZE); + + const editResponse = await page.request.fetch( + `/api/tournament/${tournamentId}/teams/upsert`, + { + method: "POST", + headers: authorized(token), + data: { + tournamentTeamId: createdTeam.id, + name: "Api Pickup Edited", + ownerUserId: roster[0].id, + members: roster + .slice(0, ROSTER_SIZE - 1) + .map((user) => ({ userId: user.id })), + }, + }, + ); + expect(editResponse.status()).toBe(200); + + const editedTeam = await teamByName(page, token, { + tournamentId, + name: "Api Pickup Edited", + }); + expect(editedTeam.id).toBe(createdTeam.id); + expect(editedTeam.members).toHaveLength(ROSTER_SIZE - 1); + }); + + test("returns 400 with field errors for invalid upsert registration body", async ({ + page, + factories, + }) => { + const { tournamentId, token } = await organizedTournament(factories); + const owner = await factories.UserFactory.create(); + + await impersonate(page, ADMIN_ID); + + const response = await page.request.fetch( + `/api/tournament/${tournamentId}/teams/upsert`, + { + method: "POST", + headers: authorized(token), + data: { + ownerUserId: owner.id, + members: [{ userId: owner.id }], + }, + }, + ); + + expect(response.status()).toBe(400); + const data = await response.json(); + expect(data.fieldErrors.pickUpName).toBeTruthy(); + }); + test("updates member IGN via API", async ({ page, factories }) => { const { tournamentId, teamId, memberUserIds, token } = await organizedTournament(factories); @@ -359,6 +441,21 @@ async function organizedTournament( }; } +async function teamByName( + page: Page, + token: string, + { tournamentId, name }: { tournamentId: number; name: string }, +) { + const response = await page.request.fetch( + `/api/tournament/${tournamentId}/teams`, + { headers: authorized(token) }, + ); + expect(response.status()).toBe(200); + const teams = await response.json(); + + return teams.find((team: { name: string }) => team.name === name); +} + async function readToken(factories: Factories, userId: number) { await factories.UserFactory.grant(userId, { roles: ["API_ACCESSER"] });