Restrict teams leaving invitational tournaments that were added by the TO

This commit is contained in:
Kalle
2026-08-06 07:15:52 +03:00
parent 3d4ac9f452
commit f808c4dcbc
14 changed files with 253 additions and 8 deletions

View File

@@ -715,6 +715,8 @@ export interface TournamentTeamMember {
isStayAsSub: Generated<DBBoolean>;
/** Set when the member was added to the roster after registration closed. */
isSub: Generated<DBBoolean>;
/** Set when the member was added to the roster by the tournament organizer instead of joining on their own. */
isOrganizerAdded: Generated<DBBoolean>;
// denormalized from TournamentTeam.isLooking
isLooking: Generated<DBBoolean>;
}

View File

@@ -95,6 +95,7 @@ export const action = async (args: ActionFunctionArgs) => {
userId,
newTeamId: team.id,
previousTeamIdToDelete,
isOrganizerAdded: true,
});
if (previousTeamPickupChat) {

View File

@@ -470,6 +470,7 @@ export async function findTeamsFullByTournamentId(tournamentId: number) {
"TournamentTeamMember.role",
"TournamentTeamMember.createdAt",
"TournamentTeamMember.isSub",
"TournamentTeamMember.isOrganizerAdded",
sql<string | null> /*sql*/`coalesce(
"TournamentTeamMember"."inGameName",
"User"."inGameName"

View File

@@ -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);
});
});
});

View File

@@ -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,

View File

@@ -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",

View File

@@ -103,14 +103,21 @@ export default function TournamentRegisterPage() {
}
function LeaveTeamControl() {
const data = useLoaderData<TournamentRegisterPageLoader>();
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() {
</SendouButton>
}
>
{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."}
</SendouPopover>
);
}

View File

@@ -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() {

View File

@@ -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'),
};
}

View File

@@ -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.",
),
};
}

View File

@@ -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);
}
}

View File

@@ -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))],
});
}

View File

@@ -0,0 +1,12 @@
import { type Kysely, sql } from "kysely";
export async function up(db: Kysely<any>): Promise<void> {
await db.transaction().execute(async (trx) => {
await trx.schema
.alterTable("TournamentTeamMember")
.addColumn("isOrganizerAdded", "integer", (col) =>
col.notNull().defaultTo(sql`0`),
)
.execute();
});
}

View File

@@ -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());