mirror of
https://github.com/Sendouc/sendou.ink.git
synced 2026-09-30 23:27:55 -05:00
Restrict teams leaving invitational tournaments that were added by the TO
This commit is contained in:
@@ -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>;
|
||||
}
|
||||
|
||||
@@ -95,6 +95,7 @@ export const action = async (args: ActionFunctionArgs) => {
|
||||
userId,
|
||||
newTeamId: team.id,
|
||||
previousTeamIdToDelete,
|
||||
isOrganizerAdded: true,
|
||||
});
|
||||
|
||||
if (previousTeamPickupChat) {
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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'),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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.",
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
97
e2e/tournament-invitational.spec.ts
Normal file
97
e2e/tournament-invitational.spec.ts
Normal 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))],
|
||||
});
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
}
|
||||
@@ -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());
|
||||
|
||||
Reference in New Issue
Block a user