From 44635db04ddc38030f7257f73f00dc7f5a145713 Mon Sep 17 00:00:00 2001 From: Luke Policinski Date: Fri, 18 Sep 2026 07:55:30 -0400 Subject: [PATCH 1/3] bug: let organizers add more than one team to a tournament --- .../draft_tournament_free_agent_teams.sql | 5 -- .../promote_tournament_free_agent.sql | 7 ++- .../down.sql | 11 ++++ .../up.sql | 6 +++ hasura/triggers/tournament_free_agents.sql | 9 ++-- test/tournament-roster-duplicate.spec.ts | 54 +++++++++++++++++++ 6 files changed, 77 insertions(+), 15 deletions(-) create mode 100644 hasura/migrations/default/1886000000620_tournament_teams_drop_owner_unique/down.sql create mode 100644 hasura/migrations/default/1886000000620_tournament_teams_drop_owner_unique/up.sql diff --git a/hasura/functions/tournaments/draft_tournament_free_agent_teams.sql b/hasura/functions/tournaments/draft_tournament_free_agent_teams.sql index 306c46bf4..f172a28ad 100644 --- a/hasura/functions/tournaments/draft_tournament_free_agent_teams.sql +++ b/hasura/functions/tournaments/draft_tournament_free_agent_teams.sql @@ -83,11 +83,6 @@ BEGIN -- so drafting them would abort the whole transition on a key violation. -- -- Owning a team counts as being in the tournament even with no roster row. - -- tournament_teams is UNIQUE (owner_steam_id, tournament_id) and every - -- generated team takes its top-rated player as owner, so drafting one of - -- these would raise a duplicate key -- inside tau_tournaments, which rolls - -- the entire RegistrationOpen -> RegistrationClosed transition back and - -- fails identically on every retry, with no way for the organizer out. -- -- Both guards are PER MEMBER: an ineligible member shrinks their party by -- one rather than knocking the whole party out. The rest of the party did diff --git a/hasura/functions/tournaments/promote_tournament_free_agent.sql b/hasura/functions/tournaments/promote_tournament_free_agent.sql index 0e702c7c4..edc52b2a5 100644 --- a/hasura/functions/tournaments/promote_tournament_free_agent.sql +++ b/hasura/functions/tournaments/promote_tournament_free_agent.sql @@ -112,10 +112,9 @@ BEGIN AND public.player_meets_tournament_requirements(_tournament_id, fa.player_steam_id) -- Already playing, under either identity. The roster key is unique per -- (tournament, player) so the insert below would abort the caller's whole - -- statement, and owning a team collides with - -- UNIQUE (owner_steam_id, tournament_id) -- the collision that hard-stalled - -- the draft once already. Per member: an ineligible member shrinks - -- their party, it does not disqualify the party. + -- statement, and owning a team counts as being in the tournament. Per + -- member: an ineligible member shrinks their party, it does not + -- disqualify the party. AND NOT EXISTS ( SELECT 1 FROM public.tournament_team_roster ttr WHERE ttr.tournament_id = _tournament_id diff --git a/hasura/migrations/default/1886000000620_tournament_teams_drop_owner_unique/down.sql b/hasura/migrations/default/1886000000620_tournament_teams_drop_owner_unique/down.sql new file mode 100644 index 000000000..c5e9569a3 --- /dev/null +++ b/hasura/migrations/default/1886000000620_tournament_teams_drop_owner_unique/down.sql @@ -0,0 +1,11 @@ +DO $$ +BEGIN + IF NOT EXISTS ( + SELECT 1 FROM pg_constraint + WHERE conname = 'tournament_teams_creator_steam_id_tournament_id_key' + ) THEN + ALTER TABLE "public"."tournament_teams" + ADD CONSTRAINT "tournament_teams_creator_steam_id_tournament_id_key" + UNIQUE ("owner_steam_id", "tournament_id"); + END IF; +END $$; diff --git a/hasura/migrations/default/1886000000620_tournament_teams_drop_owner_unique/up.sql b/hasura/migrations/default/1886000000620_tournament_teams_drop_owner_unique/up.sql new file mode 100644 index 000000000..6f92e4f5e --- /dev/null +++ b/hasura/migrations/default/1886000000620_tournament_teams_drop_owner_unique/up.sql @@ -0,0 +1,6 @@ +-- owner_steam_id is whoever created the row (a Hasura insert preset), so an +-- organizer adding teams by hand, or one person owning an A and a B team, owns +-- several teams in the same tournament. A player can still only play once per +-- tournament: tournament_roster_pkey is (player_steam_id, tournament_id). +ALTER TABLE "public"."tournament_teams" + DROP CONSTRAINT IF EXISTS "tournament_teams_creator_steam_id_tournament_id_key"; diff --git a/hasura/triggers/tournament_free_agents.sql b/hasura/triggers/tournament_free_agents.sql index 3d3df671f..e7f432efb 100644 --- a/hasura/triggers/tournament_free_agents.sql +++ b/hasura/triggers/tournament_free_agents.sql @@ -36,12 +36,9 @@ BEGIN MESSAGE = 'Player does not meet this tournament''s entry requirements'; END IF; - -- A team owner is already in the tournament. The draft makes its top-rated - -- player the generated team's owner, and tournament_teams is - -- UNIQUE (owner_steam_id, tournament_id), so letting an owner into the pool - -- sets up a duplicate key that aborts the whole registration-close - -- transition. The draft skips them too; this only refuses the join outright - -- so the pool never shows a slot that could not be honoured. + -- A team owner is already in the tournament. The draft skips them too; this + -- only refuses the join outright so the pool never shows a slot that could + -- not be honoured. IF EXISTS ( SELECT 1 FROM public.tournament_teams tt diff --git a/test/tournament-roster-duplicate.spec.ts b/test/tournament-roster-duplicate.spec.ts index 9848a90ca..79357b7f9 100644 --- a/test/tournament-roster-duplicate.spec.ts +++ b/test/tournament-roster-duplicate.spec.ts @@ -141,6 +141,60 @@ describe("tournament roster duplicate key (SQL-driven)", () => { expect(teamBSize).toBeGreaterThan(0); }); + // Hasura presets owner_steam_id to the caller on insert, so every team an + // organizer adds by hand is owned by the organizer. + it("an organizer can add several tournament-only teams", async () => { + const { id: tournamentId, organizer } = await createTournament(); + const [first, second] = [await fx.player(), await fx.player()]; + + for (const [index, player] of [first, second].entries()) { + await runAsUser(postgres, organizer, "user", async (query) => { + const [tt] = (await query( + `INSERT INTO tournament_teams (tournament_id, team_id, name, owner_steam_id, captain_steam_id) + VALUES ($1, NULL, $2, $3, $4) RETURNING id`, + [tournamentId, fx.nextName(`adhoc${index}`), organizer, player], + )) as Array<{ id: string }>; + + await query( + `INSERT INTO tournament_team_roster (tournament_team_id, player_steam_id, tournament_id) + VALUES ($1, $2, $3)`, + [tt.id, player, tournamentId], + ); + }); + } + + const [{ count }] = await postgres.query>( + `SELECT count(*)::int AS count FROM tournament_teams WHERE tournament_id = $1`, + [tournamentId], + ); + expect(count).toBe(2); + }); + + it("two existing teams with the same owner can both register", async () => { + const { id: tournamentId } = await createTournament(); + const teamA = await fx.team(1); + const [teamB] = await postgres.query>( + "INSERT INTO teams (name, short_name, owner_steam_id) VALUES ($1, $1, $2) RETURNING id", + [fx.nextName("team"), teamA.owner], + ); + const mate = await fx.player(); + await runAsUser(postgres, teamA.owner, "admin", (query) => + query( + "INSERT INTO team_roster (team_id, player_steam_id, status) VALUES ($1, $2, 'Starter')", + [teamB.id, mate], + ), + ); + + await registerRealTeam(tournamentId, teamA); + await registerRealTeam(tournamentId, { id: teamB.id, owner: teamA.owner }); + + const [{ count }] = await postgres.query>( + `SELECT count(*)::int AS count FROM tournament_teams WHERE tournament_id = $1`, + [tournamentId], + ); + expect(count).toBe(2); + }); + const memberIds = async (teamId: string) => { const rows = await postgres.query>( "SELECT player_steam_id FROM team_roster WHERE team_id = $1", From 5f8668fe30be076b5e308ad45fa0c62f79cbce53 Mon Sep 17 00:00:00 2001 From: Luke Policinski Date: Fri, 18 Sep 2026 07:58:37 -0400 Subject: [PATCH 2/3] bug: keep the one team per tournament limit for everyone but organizers --- .../up.sql | 8 +-- hasura/triggers/tournament_teams.sql | 23 +++++++ test/tournament-roster-duplicate.spec.ts | 67 ++++++++++++++++++- 3 files changed, 93 insertions(+), 5 deletions(-) diff --git a/hasura/migrations/default/1886000000620_tournament_teams_drop_owner_unique/up.sql b/hasura/migrations/default/1886000000620_tournament_teams_drop_owner_unique/up.sql index 6f92e4f5e..3a5202e62 100644 --- a/hasura/migrations/default/1886000000620_tournament_teams_drop_owner_unique/up.sql +++ b/hasura/migrations/default/1886000000620_tournament_teams_drop_owner_unique/up.sql @@ -1,6 +1,6 @@ --- owner_steam_id is whoever created the row (a Hasura insert preset), so an --- organizer adding teams by hand, or one person owning an A and a B team, owns --- several teams in the same tournament. A player can still only play once per --- tournament: tournament_roster_pkey is (player_steam_id, tournament_id). +-- Replaced by a check in tbi_tournament_team: owner_steam_id is whoever created +-- the row (a Hasura insert preset), so an organizer adding teams by hand owns +-- all of them. Everyone else stays at one team per tournament, and a player can +-- only play once per tournament through tournament_roster_pkey. ALTER TABLE "public"."tournament_teams" DROP CONSTRAINT IF EXISTS "tournament_teams_creator_steam_id_tournament_id_key"; diff --git a/hasura/triggers/tournament_teams.sql b/hasura/triggers/tournament_teams.sql index 352983794..f72b7dd5d 100644 --- a/hasura/triggers/tournament_teams.sql +++ b/hasura/triggers/tournament_teams.sql @@ -60,6 +60,29 @@ BEGIN NEW.captain_steam_id = COALESCE(NEW.owner_steam_id, _session_steam_id); END IF; + -- One team per owner per tournament, except for organizers, who add teams + -- by hand and own every one of them through the owner_steam_id preset. + -- The lock stands in for the unique constraint this replaced: without it + -- two concurrent registrations both pass the check. + IF (_session ->> 'x-hasura-role') IS NOT NULL + AND NEW.owner_steam_id IS NOT NULL + AND NOT is_tournament_organizer(tournament, _session) THEN + PERFORM pg_advisory_xact_lock( + hashtext('tournament_team_owner'), + hashtext(NEW.tournament_id::text || ':' || NEW.owner_steam_id::text) + ); + + IF EXISTS ( + SELECT 1 + FROM tournament_teams tt + WHERE tt.tournament_id = NEW.tournament_id + AND tt.owner_steam_id = NEW.owner_steam_id + ) THEN + RAISE EXCEPTION USING ERRCODE = '22000', + MESSAGE = 'You already have a team in this tournament'; + END IF; + END IF; + -- Registering after the window opened counts as checked in: a team that -- signs up at T-30 must not be swept at T-15 for failing to confirm a -- prompt it was never shown. diff --git a/test/tournament-roster-duplicate.spec.ts b/test/tournament-roster-duplicate.spec.ts index 79357b7f9..040133e2e 100644 --- a/test/tournament-roster-duplicate.spec.ts +++ b/test/tournament-roster-duplicate.spec.ts @@ -170,7 +170,7 @@ describe("tournament roster duplicate key (SQL-driven)", () => { expect(count).toBe(2); }); - it("two existing teams with the same owner can both register", async () => { + it("an admin can register two existing teams with the same owner", async () => { const { id: tournamentId } = await createTournament(); const teamA = await fx.team(1); const [teamB] = await postgres.query>( @@ -195,6 +195,71 @@ describe("tournament roster duplicate key (SQL-driven)", () => { expect(count).toBe(2); }); + const addAdhocTeam = ( + tournamentId: string, + steamId: string, + role: string, + name: string, + ) => + runAsUser(postgres, steamId, role, (query) => + query( + `INSERT INTO tournament_teams (tournament_id, team_id, name, owner_steam_id) + VALUES ($1, NULL, $2, $3)`, + [tournamentId, name, steamId], + ), + ); + + it("a co-organizer can add several teams", async () => { + const { id: tournamentId } = await createTournament(); + const coOrganizer = await fx.player(); + await postgres.query( + "INSERT INTO tournament_organizers (tournament_id, steam_id) VALUES ($1, $2)", + [tournamentId, coOrganizer], + ); + + await addAdhocTeam(tournamentId, coOrganizer, "user", "first"); + await addAdhocTeam(tournamentId, coOrganizer, "user", "second"); + + const [{ count }] = await postgres.query>( + `SELECT count(*)::int AS count FROM tournament_teams WHERE tournament_id = $1`, + [tournamentId], + ); + expect(count).toBe(2); + }); + + it("a regular user cannot register a second team", async () => { + const { id: tournamentId } = await createTournament(); + const player = await fx.player(); + + await addAdhocTeam(tournamentId, player, "user", "first"); + await expect( + addAdhocTeam(tournamentId, player, "user", "second"), + ).rejects.toThrow(/already have a team in this tournament/); + }); + + it("a regular user cannot register a second team they own", async () => { + const { id: tournamentId } = await createTournament(); + const teamA = await fx.team(1); + const [teamB] = await postgres.query>( + "INSERT INTO teams (name, short_name, owner_steam_id) VALUES ($1, $1, $2) RETURNING id", + [fx.nextName("teamb"), teamA.owner], + ); + + const register = (teamId: string) => + runAsUser(postgres, teamA.owner, "user", (query) => + query( + `INSERT INTO tournament_teams (tournament_id, team_id, name) + SELECT $1, id, name FROM teams WHERE id = $2`, + [tournamentId, teamId], + ), + ); + + await register(teamA.id); + await expect(register(teamB.id)).rejects.toThrow( + /already have a team in this tournament/, + ); + }); + const memberIds = async (teamId: string) => { const rows = await postgres.query>( "SELECT player_steam_id FROM team_roster WHERE team_id = $1", From b89caefb110f0d19cd9b71649ddec2d3d1e600a5 Mon Sep 17 00:00:00 2001 From: Luke Policinski Date: Fri, 18 Sep 2026 08:02:37 -0400 Subject: [PATCH 3/3] bug: hide the join button when the team owner already has a team --- .../tournaments/can_join_tournament.sql | 4 +- test/tournament-roster-duplicate.spec.ts | 111 ++++++++++++++++++ 2 files changed, 114 insertions(+), 1 deletion(-) diff --git a/hasura/functions/tournaments/can_join_tournament.sql b/hasura/functions/tournaments/can_join_tournament.sql index ec5e4556c..f7a429dbe 100644 --- a/hasura/functions/tournaments/can_join_tournament.sql +++ b/hasura/functions/tournaments/can_join_tournament.sql @@ -59,6 +59,8 @@ BEGIN -- Otherwise they can still register another team they manage that is not -- already in this tournament (e.g. an A team and a B team that share -- members): being on another team's roster does not block registration. + -- The team's owner must not already have one here, or tbi_tournament_team + -- refuses the insert. RETURN EXISTS ( SELECT 1 FROM public.teams t @@ -67,7 +69,7 @@ BEGIN SELECT 1 FROM public.tournament_teams tt WHERE tt.tournament_id = tournament.id - AND tt.team_id = t.id + AND (tt.team_id = t.id OR tt.owner_steam_id = t.owner_steam_id) ) ); END; diff --git a/test/tournament-roster-duplicate.spec.ts b/test/tournament-roster-duplicate.spec.ts index 040133e2e..28b2db207 100644 --- a/test/tournament-roster-duplicate.spec.ts +++ b/test/tournament-roster-duplicate.spec.ts @@ -1,5 +1,6 @@ import { PostgresService } from "./../src/postgres/postgres.service"; import { Fixtures } from "./utils/fixtures"; +import { TournamentFixtures } from "./utils/tournament-fixtures"; import { bootMigratedDb, runAsUser, SqlTestDb } from "./utils/sql-test-db"; // Reproduces the "duplicate key value violates unique constraint @@ -260,6 +261,116 @@ describe("tournament roster duplicate key (SQL-driven)", () => { ); }); + describe("can_join_tournament", () => { + let tfx: TournamentFixtures; + + beforeAll(() => { + tfx = new TournamentFixtures(postgres, fx); + }); + + const openTournament = async () => { + const tournament = await tfx.createTournament([ + { type: "SingleElimination", order: 1, minTeams: 4, maxTeams: 8 }, + ]); + await tfx.setStatus( + tournament.id, + tournament.organizer, + "RegistrationOpen", + ); + return tournament.id; + }; + + const canJoin = async ( + tournamentId: string, + steamId: string, + role = "user", + ) => { + const [row] = await postgres.query>( + `SELECT can_join_tournament(t, $2::json) AS can_join + FROM tournaments t WHERE t.id = $1`, + [ + tournamentId, + JSON.stringify({ + "x-hasura-role": role, + "x-hasura-user-id": steamId, + }), + ], + ); + return row.can_join; + }; + + const ownedTeam = async (owner: string) => { + const [team] = await postgres.query>( + "INSERT INTO teams (name, short_name, owner_steam_id) VALUES ($1, $1, $2) RETURNING id", + [fx.nextName(`owned${owner}`), owner], + ); + return { id: team.id, owner }; + }; + + const makeTeamAdmin = async ( + team: { id: string; owner: string }, + steamId: string, + ) => { + await runAsUser(postgres, team.owner, "admin", (query) => + query( + "INSERT INTO team_roster (team_id, player_steam_id, status) VALUES ($1, $2, 'Starter')", + [team.id, steamId], + ), + ); + await postgres.query( + "UPDATE team_roster SET role = 'Admin' WHERE team_id = $1 AND player_steam_id = $2", + [team.id, steamId], + ); + }; + + it("lets a player with no team join", async () => { + const tournamentId = await openTournament(); + expect(await canJoin(tournamentId, await fx.player())).toBe(true); + }); + + it("does not offer a second team the player also owns", async () => { + const tournamentId = await openTournament(); + const teamA = await fx.team(1); + await ownedTeam(teamA.owner); + await tfx.registerTeam(tournamentId, teamA); + + expect(await canJoin(tournamentId, teamA.owner)).toBe(false); + }); + + it("offers a second team the player manages but someone else owns", async () => { + const tournamentId = await openTournament(); + const teamA = await fx.team(1); + await tfx.registerTeam(tournamentId, teamA); + const teamB = await ownedTeam(await fx.player()); + await makeTeamAdmin(teamB, teamA.owner); + + expect(await canJoin(tournamentId, teamA.owner)).toBe(true); + }); + + it("does not offer a team whose owner already has a team in the tournament", async () => { + const tournamentId = await openTournament(); + const teamA = await fx.team(1); + await tfx.registerTeam(tournamentId, teamA); + const teamC = await fx.team(1); + await tfx.registerTeam(tournamentId, teamC); + const teamB = await ownedTeam(teamC.owner); + await makeTeamAdmin(teamB, teamA.owner); + + expect(await canJoin(tournamentId, teamA.owner)).toBe(false); + }); + + it("always lets a tournament organizer add teams", async () => { + const tournamentId = await openTournament(); + const teamA = await fx.team(1); + await ownedTeam(teamA.owner); + await tfx.registerTeam(tournamentId, teamA); + + expect( + await canJoin(tournamentId, teamA.owner, "tournament_organizer"), + ).toBe(true); + }); + }); + const memberIds = async (teamId: string) => { const rows = await postgres.query>( "SELECT player_steam_id FROM team_roster WHERE team_id = $1",