From f808c4dcbc08dff6574098600a9b298c2d8ecb64 Mon Sep 17 00:00:00 2001 From: Kalle <38327916+Sendouc@users.noreply.github.com> Date: Thu, 6 Aug 2026 07:15:52 +0300 Subject: [PATCH] Restrict teams leaving invitational tournaments that were added by the TO --- app/db/tables.ts | 2 + ...tournament.$id.teams.$teamId.add-member.ts | 1 + .../tournament/TournamentRepository.server.ts | 1 + .../TournamentTeamRepository.server.test.ts | 65 ++++++++++++- .../TournamentTeamRepository.server.ts | 32 +++++- .../actions/to.$id.register.server.ts | 7 ++ .../tournament/routes/to.$id.register.tsx | 19 +++- .../tournament-admin-registration-page.ts | 4 +- e2e/pages/tournament/tournament-nav.ts | 1 + .../tournament/tournament-register-page.ts | 7 ++ e2e/pages/tournament/tournament-teams-page.ts | 4 + e2e/tournament-invitational.spec.ts | 97 +++++++++++++++++++ ...-tournament-team-member-organizer-added.ts | 12 +++ scripts/benchmark-db/cases.ts | 9 ++ 14 files changed, 253 insertions(+), 8 deletions(-) create mode 100644 e2e/tournament-invitational.spec.ts create mode 100644 migrations/20260805190302-tournament-team-member-organizer-added.ts diff --git a/app/db/tables.ts b/app/db/tables.ts index e614ef2f4..5b11eed60 100644 --- a/app/db/tables.ts +++ b/app/db/tables.ts @@ -715,6 +715,8 @@ export interface TournamentTeamMember { isStayAsSub: Generated; /** Set when the member was added to the roster after registration closed. */ isSub: Generated; + /** Set when the member was added to the roster by the tournament organizer instead of joining on their own. */ + isOrganizerAdded: Generated; // denormalized from TournamentTeam.isLooking isLooking: Generated; } diff --git a/app/features/api-public/routes/tournament.$id.teams.$teamId.add-member.ts b/app/features/api-public/routes/tournament.$id.teams.$teamId.add-member.ts index 775ad368f..1e8be51c8 100644 --- a/app/features/api-public/routes/tournament.$id.teams.$teamId.add-member.ts +++ b/app/features/api-public/routes/tournament.$id.teams.$teamId.add-member.ts @@ -95,6 +95,7 @@ export const action = async (args: ActionFunctionArgs) => { userId, newTeamId: team.id, previousTeamIdToDelete, + isOrganizerAdded: true, }); if (previousTeamPickupChat) { diff --git a/app/features/tournament/TournamentRepository.server.ts b/app/features/tournament/TournamentRepository.server.ts index 8e156a16d..dc3e6f589 100644 --- a/app/features/tournament/TournamentRepository.server.ts +++ b/app/features/tournament/TournamentRepository.server.ts @@ -470,6 +470,7 @@ export async function findTeamsFullByTournamentId(tournamentId: number) { "TournamentTeamMember.role", "TournamentTeamMember.createdAt", "TournamentTeamMember.isSub", + "TournamentTeamMember.isOrganizerAdded", sql /*sql*/`coalesce( "TournamentTeamMember"."inGameName", "User"."inGameName" diff --git a/app/features/tournament/TournamentTeamRepository.server.test.ts b/app/features/tournament/TournamentTeamRepository.server.test.ts index a661df569..1427d1d20 100644 --- a/app/features/tournament/TournamentTeamRepository.server.test.ts +++ b/app/features/tournament/TournamentTeamRepository.server.test.ts @@ -14,7 +14,11 @@ let anotherMember: { id: number }; const membersByTeamId = (tournamentTeamId: number) => db .selectFrom("TournamentTeamMember") - .select(["TournamentTeamMember.userId", "TournamentTeamMember.role"]) + .select([ + "TournamentTeamMember.userId", + "TournamentTeamMember.role", + "TournamentTeamMember.isOrganizerAdded", + ]) .where("TournamentTeamMember.tournamentTeamId", "=", tournamentTeamId) .execute(); @@ -94,5 +98,64 @@ describe("TournamentTeamRepository", () => { expect(roleOf(members, member.id)).toBe("REGULAR"); expect(roleOf(members, anotherMember.id)).toBe("REGULAR"); }); + + test("marks added members as organizer added", async () => { + const tournament = await TournamentFactory.create({ + authorId: organizer.id, + }); + + await withUserId(organizer.id, () => + TournamentTeamRepository.upsertRegistration({ + tournamentId: tournament.id, + name: "Team Olive", + teamId: null, + avatarImgId: null, + ownerUserId: owner.id, + ownerChange: null, + membersToAdd: [owner.id, member.id], + membersToRemove: [], + inGameNameUpdates: [], + }), + ); + + const team = await db + .selectFrom("TournamentTeam") + .select("TournamentTeam.id") + .where("TournamentTeam.tournamentId", "=", tournament.id) + .executeTakeFirstOrThrow(); + + const members = await membersByTeamId(team.id); + + expect(members.every((teamMember) => teamMember.isOrganizerAdded)).toBe( + true, + ); + }); + }); + + describe("join", () => { + test("joining on your own is not marked as organizer added", async () => { + const tournament = await TournamentFactory.create({ + authorId: organizer.id, + }); + const team = await TournamentTeamFactory.create({ + tournamentId: tournament.id, + memberUserIds: [owner.id], + team: { name: "Team Olive", prefersNotToHost: 0, teamId: null }, + }); + + await withUserId(member.id, () => + TournamentTeamRepository.join({ + newTeamId: team.id, + userId: member.id, + }), + ); + + const members = await membersByTeamId(team.id); + + expect( + members.find((teamMember) => teamMember.userId === member.id) + ?.isOrganizerAdded, + ).toBe(0); + }); }); }); diff --git a/app/features/tournament/TournamentTeamRepository.server.ts b/app/features/tournament/TournamentTeamRepository.server.ts index 17b0b01f5..da294d0e9 100644 --- a/app/features/tournament/TournamentTeamRepository.server.ts +++ b/app/features/tournament/TournamentTeamRepository.server.ts @@ -9,6 +9,7 @@ import { flatZip } from "~/utils/arrays"; import { databaseTimestampNow, dateToDatabaseTimestamp } from "~/utils/dates"; import { shortNanoid } from "~/utils/id"; import invariant from "~/utils/invariant"; +import { toDBBoolean } from "~/utils/sql"; import * as TournamentAuditLogRepository from "./TournamentAuditLogRepository.server"; export function setActiveRoster({ @@ -318,7 +319,12 @@ export function upsertRegistration({ const members: Array< Pick< Tables["TournamentTeamMember"], - "tournamentTeamId" | "userId" | "inGameName" | "isSub" | "role" + | "tournamentTeamId" + | "userId" + | "inGameName" + | "isSub" + | "role" + | "isOrganizerAdded" > > = []; for (const userId of membersToAdd) { @@ -333,6 +339,7 @@ export function upsertRegistration({ isSub, // every row needs the same keys, otherwise Kysely inserts null for the missing ones role: isOwner ? "OWNER" : "REGULAR", + isOrganizerAdded: 1, }); } @@ -485,6 +492,7 @@ export function copyFromAnotherTournament({ "TournamentTeamMember.role", "TournamentTeamMember.userId", "TournamentTeamMember.isSub", + "TournamentTeamMember.isOrganizerAdded", // -- exclude these // "TournamentTeamMember.tournamentTeamId" @@ -776,12 +784,15 @@ export function join({ previousTeamIdToDelete, newTeamId, userId, + isOrganizerAdded = false, }: { /** Team to delete as the user joins, e.g. a solo team they leave behind. */ previousTeamIdToDelete?: number; newTeamId: number; /** The user joining the team. */ userId: number; + /** Was the user added to the team by the tournament organizer instead of joining on their own? */ + isOrganizerAdded?: boolean; }) { return db.transaction().execute(async (trx) => { if (previousTeamIdToDelete) { @@ -816,6 +827,7 @@ export function join({ userId, inGameName, isSub, + isOrganizerAdded: toDBBoolean(isOrganizerAdded), }) .execute(); @@ -852,6 +864,24 @@ export function deleteById(tournamentTeamId: number) { }); } +/** Was the user's membership in the given team added by the tournament organizer instead of the user joining on their own? */ +export async function isOrganizerAddedMember({ + tournamentTeamId, + userId, +}: { + tournamentTeamId: number; + userId: number; +}) { + const member = await db + .selectFrom("TournamentTeamMember") + .select("TournamentTeamMember.isOrganizerAdded") + .where("TournamentTeamMember.tournamentTeamId", "=", tournamentTeamId) + .where("TournamentTeamMember.userId", "=", userId) + .executeTakeFirst(); + + return Boolean(member?.isOrganizerAdded); +} + export function leave({ teamId, userId, diff --git a/app/features/tournament/actions/to.$id.register.server.ts b/app/features/tournament/actions/to.$id.register.server.ts index ed00867ab..116a6e77a 100644 --- a/app/features/tournament/actions/to.$id.register.server.ts +++ b/app/features/tournament/actions/to.$id.register.server.ts @@ -179,6 +179,13 @@ export const action: ActionFunction = async ({ request, params }) => { const teamMemberOf = tournament.teamMemberOfByUser(user); errorToastIfFalsy(teamMemberOf, "You are not in a team"); + errorToastIfFalsy( + !(await TournamentTeamRepository.isOrganizerAddedMember({ + tournamentTeamId: teamMemberOf.id, + userId: user.id, + })), + "You were added to the team by the organizer, contact the TO to leave the team", + ); errorToastIfFalsy( teamMemberOf.checkIns.length === 0, "You cannot leave after checking in", diff --git a/app/features/tournament/routes/to.$id.register.tsx b/app/features/tournament/routes/to.$id.register.tsx index d1ccaa412..4f75452a1 100644 --- a/app/features/tournament/routes/to.$id.register.tsx +++ b/app/features/tournament/routes/to.$id.register.tsx @@ -103,14 +103,21 @@ export default function TournamentRegisterPage() { } function LeaveTeamControl() { + const data = useLoaderData(); const user = useUser(); const tournament = useTournament(); const teamMemberOf = tournament.teamMemberOfByUser(user); - if (!teamMemberOf) return null; + if (!user || !teamMemberOf) return null; const checkedIn = teamMemberOf.checkIns.length > 0; - const cannotLeave = checkedIn || !tournament.registrationOpen; + const organizerAdded = Boolean( + data?.ownTeam?.members.some( + (member) => member.userId === user.id && member.isOrganizerAdded, + ), + ); + const cannotLeave = + organizerAdded || checkedIn || !tournament.registrationOpen; if (cannotLeave) { return ( @@ -121,9 +128,11 @@ function LeaveTeamControl() { } > - {checkedIn - ? "Your team has checked in. Contact the TO to leave the team." - : "Registration has closed. Contact the TO to leave the team."} + {organizerAdded + ? "You were added to the team by the organizer. Contact the TO to leave the team." + : checkedIn + ? "Your team has checked in. Contact the TO to leave the team." + : "Registration has closed. Contact the TO to leave the team."} ); } diff --git a/e2e/pages/tournament/tournament-admin-registration-page.ts b/e2e/pages/tournament/tournament-admin-registration-page.ts index 1b7d5ff0e..0a72a818e 100644 --- a/e2e/pages/tournament/tournament-admin-registration-page.ts +++ b/e2e/pages/tournament/tournament-admin-registration-page.ts @@ -61,7 +61,9 @@ export class TournamentAdminRegistrationPage { } async selectCaptain(userId: number) { - await this.page.getByLabel("Captain").selectOption(String(userId)); + await this.page + .getByLabel("Captain", { exact: true }) + .selectOption(String(userId)); } save() { diff --git a/e2e/pages/tournament/tournament-nav.ts b/e2e/pages/tournament/tournament-nav.ts index 240306e90..ccf2a0f7b 100644 --- a/e2e/pages/tournament/tournament-nav.ts +++ b/e2e/pages/tournament/tournament-nav.ts @@ -13,6 +13,7 @@ export class TournamentNav { this.page = page; this.locators = { teamsTab: page.locator('[data-testid="teams-tab"]:visible'), + registerTab: page.locator('[data-testid="register-tab"]:visible'), }; } diff --git a/e2e/pages/tournament/tournament-register-page.ts b/e2e/pages/tournament/tournament-register-page.ts index 04a9b3283..4732f6f3d 100644 --- a/e2e/pages/tournament/tournament-register-page.ts +++ b/e2e/pages/tournament/tournament-register-page.ts @@ -26,6 +26,13 @@ export class TournamentRegisterPage { }); this.locators = { fillRosterHeading: page.getByText("Fill roster"), + registrationClosedAlert: page.getByText( + "Registration for this tournament has closed", + ), + leaveTeamButton: page.getByRole("button", { name: "Leave the team" }), + organizerAddedLeaveExplanation: page.getByText( + "You were added to the team by the organizer. Contact the TO to leave the team.", + ), }; } diff --git a/e2e/pages/tournament/tournament-teams-page.ts b/e2e/pages/tournament/tournament-teams-page.ts index 9cf522a89..fb164bd2f 100644 --- a/e2e/pages/tournament/tournament-teams-page.ts +++ b/e2e/pages/tournament/tournament-teams-page.ts @@ -21,4 +21,8 @@ export class TournamentTeamsPage { memberNamed(name: string) { return this.locators.teamMemberNames.getByText(name); } + + teamNamed(name: string) { + return this.locators.teamNames.getByText(name); + } } diff --git a/e2e/tournament-invitational.spec.ts b/e2e/tournament-invitational.spec.ts new file mode 100644 index 000000000..3b960bc8f --- /dev/null +++ b/e2e/tournament-invitational.spec.ts @@ -0,0 +1,97 @@ +import { addHours } from "date-fns"; +import { NZAP_TEST_ID } from "~/db/seed/constants"; +import { dateToDatabaseTimestamp } from "~/utils/dates"; +import type { Factories } from "./helpers/factories"; +import { expect, impersonate, test } from "./helpers/playwright"; +import { TournamentAdminPage } from "./pages/tournament/tournament-admin-page"; +import { TournamentAdminRegistrationPage } from "./pages/tournament/tournament-admin-registration-page"; +import { TournamentRegisterPage } from "./pages/tournament/tournament-register-page"; +import { TournamentTeamsPage } from "./pages/tournament/tournament-teams-page"; + +test.describe("Invitational tournament", () => { + test("team can't register on their own, the TO adds them instead", async ({ + page, + factories, + }) => { + const tournament = await createInvitational(factories); + const captain = await factories.UserFactory.create({ + discordName: "Captain Carla", + }); + + await impersonate(page, captain.id); + + const register = new TournamentRegisterPage(page); + await register.goto(tournament.id); + await expect(register.locators.registrationClosedAlert).toBeVisible(); + await expect(register.nav.locators.registerTab).toHaveCount(0); + + await impersonate(page, NZAP_TEST_ID); + + const registration = new TournamentAdminRegistrationPage(page); + await registration.gotoNew(tournament.id); + await expect(registration.locators.addHeading).toBeVisible(); + + await registration.form.fill("pickUpName", "Invited Squad"); + await registration.selectPlayer("Captain Carla"); + await registration.selectCaptain(captain.id); + await registration.save(); + + const admin = new TournamentAdminPage(page); + await expect(admin.teamName("Invited Squad")).toBeVisible(); + + await impersonate(page, captain.id); + + const teams = new TournamentTeamsPage(page); + await teams.goto(tournament.id); + await expect(teams.teamNamed("Invited Squad")).toBeVisible(); + await expect(teams.memberNamed("Captain Carla")).toBeVisible(); + + // as the captain of an invitational team they can now manage the registration + await expect(register.nav.locators.registerTab).toBeVisible(); + }); + + test("member added by the TO can't leave the team", async ({ + page, + factories, + }) => { + const tournament = await createInvitational(factories); + const captain = await factories.UserFactory.create({ + discordName: "Captain Carla", + }); + const member = await factories.UserFactory.create({ + discordName: "Member Mia", + }); + + await impersonate(page, NZAP_TEST_ID); + + const registration = new TournamentAdminRegistrationPage(page); + await registration.gotoNew(tournament.id); + await expect(registration.locators.addHeading).toBeVisible(); + + await registration.form.fill("pickUpName", "Invited Squad"); + await registration.selectPlayer("Captain Carla"); + await registration.addMember("Member Mia"); + await registration.selectCaptain(captain.id); + await registration.save(); + + const admin = new TournamentAdminPage(page); + await expect(admin.teamName("Invited Squad")).toBeVisible(); + + await impersonate(page, member.id); + + const register = new TournamentRegisterPage(page); + await register.goto(tournament.id); + await register.locators.leaveTeamButton.click(); + await expect( + register.locators.organizerAddedLeaveExplanation, + ).toBeVisible(); + }); +}); + +function createInvitational(factories: Factories) { + return factories.TournamentFactory.create({ + authorId: NZAP_TEST_ID, + isInvitational: true, + startTimes: [dateToDatabaseTimestamp(addHours(new Date(), 2))], + }); +} diff --git a/migrations/20260805190302-tournament-team-member-organizer-added.ts b/migrations/20260805190302-tournament-team-member-organizer-added.ts new file mode 100644 index 000000000..163c16e37 --- /dev/null +++ b/migrations/20260805190302-tournament-team-member-organizer-added.ts @@ -0,0 +1,12 @@ +import { type Kysely, sql } from "kysely"; + +export async function up(db: Kysely): Promise { + await db.transaction().execute(async (trx) => { + await trx.schema + .alterTable("TournamentTeamMember") + .addColumn("isOrganizerAdded", "integer", (col) => + col.notNull().defaultTo(sql`0`), + ) + .execute(); + }); +} diff --git a/scripts/benchmark-db/cases.ts b/scripts/benchmark-db/cases.ts index 7bab47b1b..f65d016e8 100644 --- a/scripts/benchmark-db/cases.ts +++ b/scripts/benchmark-db/cases.ts @@ -1019,6 +1019,15 @@ export function buildCases(fx: Fixtures): { fx.tournamentTeamPair, (teamIds) => TournamentTeamRepository.findMapPoolsByTeamIds(teamIds), ); + add( + "TournamentTeamRepository.isOrganizerAddedMember", + both(fx.heavyTournamentTeamId, fx.heavyUser), + ([tournamentTeamId, user]) => + TournamentTeamRepository.isOrganizerAddedMember({ + tournamentTeamId, + userId: user.id, + }), + ); // TrophyRepository addStatic("TrophyRepository.all", () => TrophyRepository.all());