mirror of
https://github.com/Sendouc/sendou.ink.git
synced 2026-08-20 18:15:35 -05:00
Refactor permissions and make tournament object fully cacheable (#3341)
This commit is contained in:
@@ -147,7 +147,7 @@ export async function playOut(
|
||||
brackets: PlayedBrackets = 0,
|
||||
{ maps }: { maps?: RoundMaps } = {},
|
||||
): Promise<PlayedMatch[]> {
|
||||
const tournament = await tournamentFromDB({ tournamentId, user: undefined });
|
||||
const tournament = await tournamentFromDB(tournamentId);
|
||||
const bracketIdxs =
|
||||
brackets === "all"
|
||||
? tournament.ctx.settings.bracketProgression.map((_, idx) => idx)
|
||||
@@ -192,7 +192,7 @@ export async function startBracket(
|
||||
maps = ROUND_MAPS,
|
||||
}: { bracketIdx?: number; maps?: RoundMaps } = {},
|
||||
) {
|
||||
const tournament = await tournamentFromDB({ tournamentId, user: undefined });
|
||||
const tournament = await tournamentFromDB(tournamentId);
|
||||
|
||||
const bracket = tournament.bracketByIdx(bracketIdx);
|
||||
invariant(bracket, `Tournament has no bracket at index ${bracketIdx}`);
|
||||
@@ -219,7 +219,7 @@ export async function startBracket(
|
||||
|
||||
clearTournamentDataCache(tournamentId);
|
||||
|
||||
const started = await tournamentFromDB({ tournamentId, user: undefined });
|
||||
const started = await tournamentFromDB(tournamentId);
|
||||
const startedBracket = started.bracketByIdx(bracketIdx);
|
||||
invariant(startedBracket, `Bracket at index ${bracketIdx} was not created`);
|
||||
|
||||
@@ -280,7 +280,7 @@ interface PlayedMatch {
|
||||
export async function playMatches(
|
||||
tournamentId: number,
|
||||
): Promise<PlayedMatch[]> {
|
||||
const tournament = await tournamentFromDB({ tournamentId, user: undefined });
|
||||
const tournament = await tournamentFromDB(tournamentId);
|
||||
|
||||
const played = playableMatches(tournament);
|
||||
for (const match of played) {
|
||||
@@ -305,7 +305,7 @@ async function generateNextSwissRound(
|
||||
tournamentId: number,
|
||||
bracketIdx: number,
|
||||
) {
|
||||
const tournament = await tournamentFromDB({ tournamentId, user: undefined });
|
||||
const tournament = await tournamentFromDB(tournamentId);
|
||||
|
||||
const bracket = tournament.bracketByIdx(bracketIdx);
|
||||
if (bracket?.type !== "swiss" || bracket.preview) return false;
|
||||
@@ -405,7 +405,7 @@ function playableMatches(
|
||||
}
|
||||
|
||||
async function setActiveRosters(tournamentId: number, match: PlayedMatch) {
|
||||
const tournament = await tournamentFromDB({ tournamentId, user: undefined });
|
||||
const tournament = await tournamentFromDB(tournamentId);
|
||||
|
||||
for (const teamId of [match.winnerTeamId, match.loserTeamId]) {
|
||||
const team = tournament.teamById(teamId);
|
||||
@@ -434,10 +434,7 @@ async function playOutMatch(tournamentId: number, match: PlayedMatch) {
|
||||
|
||||
while (!setOver) {
|
||||
// rehydrated per map, the previous report having moved the score on
|
||||
const tournament = await tournamentFromDB({
|
||||
tournamentId,
|
||||
user: undefined,
|
||||
});
|
||||
const tournament = await tournamentFromDB(tournamentId);
|
||||
const matchRow = await findMatch(match.id);
|
||||
|
||||
const reported = await reportScore({
|
||||
@@ -461,7 +458,7 @@ async function playOutMatch(tournamentId: number, match: PlayedMatch) {
|
||||
}
|
||||
|
||||
async function finalize(tournamentId: number) {
|
||||
const tournament = await tournamentFromDB({ tournamentId, user: undefined });
|
||||
const tournament = await tournamentFromDB(tournamentId);
|
||||
|
||||
const event = await CalendarRepository.findById(tournament.ctx.eventId, {
|
||||
includeBadgePrizes: true,
|
||||
|
||||
@@ -3,7 +3,7 @@ import { z } from "zod";
|
||||
import { db } from "~/db/sql";
|
||||
import * as TournamentRepository from "~/features/tournament/TournamentRepository.server";
|
||||
import * as TournamentTeamRepository from "~/features/tournament/TournamentTeamRepository.server";
|
||||
import { tournamentFromDBCached } from "~/features/tournament-bracket/core/Tournament.server";
|
||||
import { tournamentSharedCached } from "~/features/tournament-bracket/core/Tournament.server";
|
||||
import { resolveMapList } from "~/features/tournament-match/core/mapList.server";
|
||||
import { getFixedTForLanguage } from "~/modules/i18n/i18next.server";
|
||||
import { parseMaplistSource } from "~/modules/tournament-map-list-generator/source";
|
||||
@@ -74,10 +74,7 @@ export const loader = async ({ params }: LoaderFunctionArgs) => {
|
||||
.executeTakeFirst(),
|
||||
);
|
||||
|
||||
const tournament = await tournamentFromDBCached({
|
||||
tournamentId: match.tournamentId,
|
||||
user: undefined,
|
||||
});
|
||||
const tournament = await tournamentSharedCached(match.tournamentId);
|
||||
|
||||
const mapList = async (): Promise<GetTournamentMatchResponse["mapList"]> => {
|
||||
const { opponentOne, opponentTwo } = match;
|
||||
|
||||
@@ -13,10 +13,7 @@ const paramsSchema = z.object({
|
||||
export const loader = async ({ params }: LoaderFunctionArgs) => {
|
||||
const { id, bidx } = parseParams({ params, schema: paramsSchema });
|
||||
|
||||
const tournament = await tournamentFromDB({
|
||||
user: undefined,
|
||||
tournamentId: id,
|
||||
});
|
||||
const tournament = await tournamentFromDB(id);
|
||||
|
||||
const bracket = notFoundIfNullish(tournament.bracketByIdx(bidx));
|
||||
if (bracket.preview) throw new Response(null, { status: 404 });
|
||||
|
||||
@@ -14,10 +14,7 @@ const paramsSchema = z.object({
|
||||
export const loader = async ({ params }: LoaderFunctionArgs) => {
|
||||
const { id, bidx } = parseParams({ params, schema: paramsSchema });
|
||||
|
||||
const tournament = await tournamentFromDB({
|
||||
user: undefined,
|
||||
tournamentId: id,
|
||||
});
|
||||
const tournament = await tournamentFromDB(id);
|
||||
|
||||
const bracket = notFoundIfNullish(tournament.bracketByIdx(bidx));
|
||||
|
||||
|
||||
@@ -43,7 +43,7 @@ export const action = async (args: ActionFunctionArgs) => {
|
||||
|
||||
return wrapActionForApi(async () => {
|
||||
const user = requireUser();
|
||||
const tournament = await tournamentFromDB({ tournamentId, user });
|
||||
const tournament = await tournamentFromDB(tournamentId);
|
||||
requireTournamentOrganizer(tournament, user);
|
||||
|
||||
const team = tournament.teamById(teamId);
|
||||
|
||||
@@ -38,7 +38,7 @@ export const action = async (args: ActionFunctionArgs) => {
|
||||
|
||||
return wrapActionForApi(async () => {
|
||||
const user = requireUser();
|
||||
const tournament = await tournamentFromDB({ tournamentId, user });
|
||||
const tournament = await tournamentFromDB(tournamentId);
|
||||
requireTournamentOrganizer(tournament, user);
|
||||
|
||||
const team = tournament.teamById(teamId);
|
||||
|
||||
@@ -38,7 +38,7 @@ export const action = async (args: ActionFunctionArgs) => {
|
||||
|
||||
return wrapActionForApi(async () => {
|
||||
const user = requireUser();
|
||||
const tournament = await tournamentFromDB({ tournamentId, user });
|
||||
const tournament = await tournamentFromDB(tournamentId);
|
||||
requireTournamentOrganizer(tournament, user);
|
||||
|
||||
const teamMemberOf = badRequestIfFalsy(
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { sql, type Transaction } from "kysely";
|
||||
import { type ExpressionBuilder, sql, type Transaction } from "kysely";
|
||||
import { db } from "~/db/sql";
|
||||
import type { DB, Tables } from "~/db/tables";
|
||||
import { actorId } from "~/features/auth/core/user.server";
|
||||
@@ -59,6 +59,7 @@ export async function findShowcaseArts(): Promise<ListedArt[]> {
|
||||
concatUserSubmittedImagePrefix(eb.ref("UserSubmittedImage.url")).as(
|
||||
"url",
|
||||
),
|
||||
linkedUsersSubquery(eb).as("linkedUsers"),
|
||||
])
|
||||
.orderBy("Art.isShowcase", "desc")
|
||||
.orderBy("Art.createdAt", "desc")
|
||||
@@ -77,6 +78,10 @@ export async function findShowcaseArts(): Promise<ListedArt[]> {
|
||||
discordId: a.discordId,
|
||||
username: a.username,
|
||||
},
|
||||
permissions: artPermissions({
|
||||
authorId: a.userId,
|
||||
linkedUsers: a.linkedUsers,
|
||||
}),
|
||||
}));
|
||||
|
||||
const { seededShuffle } = seededRandom(getDailySeed());
|
||||
@@ -100,6 +105,7 @@ export async function findShowcaseArtsByTag(
|
||||
concatUserSubmittedImagePrefix(eb.ref("UserSubmittedImage.url")).as(
|
||||
"url",
|
||||
),
|
||||
linkedUsersSubquery(eb).as("linkedUsers"),
|
||||
])
|
||||
.where("TaggedArt.tagId", "=", tagId)
|
||||
.orderBy("Art.isShowcase", "desc")
|
||||
@@ -130,6 +136,10 @@ export async function findShowcaseArtsByTag(
|
||||
discordId: a.discordId,
|
||||
username: a.username,
|
||||
},
|
||||
permissions: artPermissions({
|
||||
authorId: a.userId,
|
||||
linkedUsers: a.linkedUsers,
|
||||
}),
|
||||
}));
|
||||
}
|
||||
|
||||
@@ -147,6 +157,7 @@ export async function findRecentlyUploadedArts(): Promise<ListedArt[]> {
|
||||
concatUserSubmittedImagePrefix(eb.ref("UserSubmittedImage.url")).as(
|
||||
"url",
|
||||
),
|
||||
linkedUsersSubquery(eb).as("linkedUsers"),
|
||||
])
|
||||
.orderBy("Art.createdAt", "desc")
|
||||
.limit(100)
|
||||
@@ -164,6 +175,10 @@ export async function findRecentlyUploadedArts(): Promise<ListedArt[]> {
|
||||
discordId: a.discordId,
|
||||
username: a.username,
|
||||
},
|
||||
permissions: artPermissions({
|
||||
authorId: a.userId,
|
||||
linkedUsers: a.linkedUsers,
|
||||
}),
|
||||
}));
|
||||
}
|
||||
|
||||
@@ -180,6 +195,30 @@ export async function deleteOrphanTags() {
|
||||
return Number(result.numDeletedRows);
|
||||
}
|
||||
|
||||
/** Art by its id, with the ids of the users tagged in it. */
|
||||
export async function findById(id: Tables["Art"]["id"]) {
|
||||
const row = await db
|
||||
.selectFrom("Art")
|
||||
.select(({ eb }) => [
|
||||
"Art.id",
|
||||
"Art.authorId",
|
||||
linkedUsersSubquery(eb).as("linkedUsers"),
|
||||
])
|
||||
.where("Art.id", "=", id)
|
||||
.executeTakeFirst();
|
||||
|
||||
if (!row) return;
|
||||
|
||||
return {
|
||||
id: row.id,
|
||||
linkedUserIds: row.linkedUsers.map((linkedUser) => linkedUser.id),
|
||||
permissions: artPermissions({
|
||||
authorId: row.authorId,
|
||||
linkedUsers: row.linkedUsers,
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
export async function findArtsByUserId(
|
||||
userId: number,
|
||||
{ includeAuthored = true, includeTagged = true } = {},
|
||||
@@ -279,6 +318,10 @@ export async function findArtsByUserId(
|
||||
customAvatarUrl: row.customAvatarUrl,
|
||||
commissionsOpen: row.commissionsOpen,
|
||||
},
|
||||
permissions: artPermissions({
|
||||
authorId: row.userId,
|
||||
linkedUsers: row.linkedUsers,
|
||||
}),
|
||||
})),
|
||||
...authored.map((row) => ({
|
||||
id: row.id,
|
||||
@@ -289,6 +332,10 @@ export async function findArtsByUserId(
|
||||
tags: row.tags.length > 0 ? row.tags : undefined,
|
||||
linkedUsers: row.linkedUsers.length > 0 ? row.linkedUsers : undefined,
|
||||
author: undefined,
|
||||
permissions: artPermissions({
|
||||
authorId: userId,
|
||||
linkedUsers: row.linkedUsers,
|
||||
}),
|
||||
})),
|
||||
];
|
||||
|
||||
@@ -432,3 +479,25 @@ async function insertTags(
|
||||
.values(tagIds.map((tagId) => ({ artId, tagId })))
|
||||
.execute();
|
||||
}
|
||||
|
||||
function linkedUsersSubquery(eb: ExpressionBuilder<DB, "Art">) {
|
||||
return jsonArrayFrom(
|
||||
eb
|
||||
.selectFrom("ArtUserMetadata")
|
||||
.select("ArtUserMetadata.userId as id")
|
||||
.whereRef("ArtUserMetadata.artId", "=", "Art.id"),
|
||||
);
|
||||
}
|
||||
|
||||
function artPermissions({
|
||||
authorId,
|
||||
linkedUsers,
|
||||
}: {
|
||||
authorId: number;
|
||||
linkedUsers: Array<{ id: number }>;
|
||||
}): ListedArt["permissions"] {
|
||||
return {
|
||||
EDIT: [authorId],
|
||||
UNLINK: linkedUsers.map((linkedUser) => linkedUser.id),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -5,9 +5,12 @@ import * as ArtRepository from "~/features/art/ArtRepository.server";
|
||||
import { requireUser } from "~/features/auth/core/user.server";
|
||||
import { notify } from "~/features/notifications/core/notify.server";
|
||||
import { parseFormData } from "~/form/parse.server";
|
||||
import { requireRole } from "~/modules/permissions/guards.server";
|
||||
import {
|
||||
requirePermission,
|
||||
requireRole,
|
||||
} from "~/modules/permissions/guards.server";
|
||||
import { dateToDatabaseTimestamp } from "~/utils/dates";
|
||||
import { errorToastIfFalsy } from "~/utils/remix.server";
|
||||
import { badRequestIfFalsy, errorToastIfFalsy } from "~/utils/remix.server";
|
||||
import { toDBBoolean } from "~/utils/sql";
|
||||
import { userArtPage } from "~/utils/urls";
|
||||
import { ART_FORM_MAX_BODY_BYTES } from "../art-image";
|
||||
@@ -34,11 +37,10 @@ export const action: ActionFunction = async ({ request }) => {
|
||||
);
|
||||
|
||||
if (data.artId) {
|
||||
const userArts = await ArtRepository.findArtsByUserId(user.id, {
|
||||
includeTagged: false,
|
||||
});
|
||||
const existingArt = userArts.find((art) => art.id === data.artId);
|
||||
errorToastIfFalsy(existingArt, "Art author is someone else");
|
||||
const existingArt = badRequestIfFalsy(
|
||||
await ArtRepository.findById(data.artId),
|
||||
);
|
||||
requirePermission(existingArt, "EDIT");
|
||||
|
||||
const editedArtId = await ArtRepository.update(data.artId, {
|
||||
description: data.description,
|
||||
@@ -47,11 +49,8 @@ export const action: ActionFunction = async ({ request }) => {
|
||||
tags: data.tags,
|
||||
});
|
||||
|
||||
const existingLinkedUserIds =
|
||||
existingArt.linkedUsers?.map((u) => u.id) ?? [];
|
||||
|
||||
notify({
|
||||
userIds: R.difference(linkedUsers, existingLinkedUserIds),
|
||||
userIds: R.difference(linkedUsers, existingArt.linkedUserIds),
|
||||
notification: {
|
||||
type: "TAGGED_TO_ART",
|
||||
meta: {
|
||||
|
||||
@@ -19,6 +19,10 @@ export interface ListedArt {
|
||||
customAvatarUrl: string | null;
|
||||
commissionsOpen: Tables["User"]["commissionsOpen"];
|
||||
};
|
||||
permissions: {
|
||||
EDIT: Array<number>;
|
||||
UNLINK: Array<number>;
|
||||
};
|
||||
}
|
||||
|
||||
export const ART_SOURCES = ["ALL", "MADE-BY", "MADE-OF"] as const;
|
||||
|
||||
@@ -12,6 +12,7 @@ import { useDateTimeFormat } from "~/hooks/intl/useDateTimeFormat";
|
||||
import { useFormatDistanceToNow } from "~/hooks/intl/useFormatDistanceToNow";
|
||||
import { useHydrated } from "~/hooks/useHydrated";
|
||||
import { usePagination } from "~/hooks/usePagination";
|
||||
import { useHasPermission } from "~/modules/permissions/hooks";
|
||||
import { useSearchParam } from "~/modules/search-params/hooks";
|
||||
import { databaseTimestampToDate } from "~/utils/dates";
|
||||
import { artPage, newArtPage, userArtPage, userPage } from "~/utils/urls";
|
||||
@@ -25,12 +26,10 @@ import styles from "./ArtGrid.module.css";
|
||||
export function ArtGrid({
|
||||
arts,
|
||||
enablePreview = false,
|
||||
canEdit = false,
|
||||
showUploadDate = false,
|
||||
}: {
|
||||
arts: ListedArt[];
|
||||
enablePreview?: boolean;
|
||||
canEdit?: boolean;
|
||||
showUploadDate?: boolean;
|
||||
}) {
|
||||
const {
|
||||
@@ -62,7 +61,6 @@ export function ArtGrid({
|
||||
<ImagePreview
|
||||
key={art.id}
|
||||
art={art}
|
||||
canEdit={canEdit}
|
||||
enablePreview={enablePreview}
|
||||
showUploadDate={showUploadDate}
|
||||
onClick={enablePreview ? () => setBigArtId(art.id) : undefined}
|
||||
@@ -155,15 +153,15 @@ function ImagePreview({
|
||||
art,
|
||||
onClick,
|
||||
enablePreview = false,
|
||||
canEdit = false,
|
||||
showUploadDate = false,
|
||||
}: {
|
||||
art: ListedArt;
|
||||
onClick?: () => void;
|
||||
enablePreview?: boolean;
|
||||
canEdit?: boolean;
|
||||
showUploadDate?: boolean;
|
||||
}) {
|
||||
const canEdit = useHasPermission(art, "EDIT");
|
||||
const canUnlink = useHasPermission(art, "UNLINK");
|
||||
const [imageSettled, setImageSettled] = React.useState(false);
|
||||
const { t } = useTranslation(["common", "art"]);
|
||||
const formatDistanceToNow = useFormatDistanceToNow();
|
||||
@@ -227,7 +225,7 @@ function ImagePreview({
|
||||
{img}
|
||||
<div
|
||||
className={clsx("stack horizontal justify-between", {
|
||||
"mt-2": canEdit,
|
||||
"mt-2": canUnlink,
|
||||
})}
|
||||
>
|
||||
<Link
|
||||
@@ -248,7 +246,7 @@ function ImagePreview({
|
||||
{uploadDateText}
|
||||
</div>
|
||||
) : null}
|
||||
{canEdit ? (
|
||||
{canUnlink ? (
|
||||
<FormWithConfirm
|
||||
dialogHeading={t("art:unlink.title", {
|
||||
username: art.author.username,
|
||||
|
||||
@@ -61,7 +61,12 @@ export async function findAllByUserId(
|
||||
.orderBy("Build.updatedAt", "desc")
|
||||
.execute();
|
||||
|
||||
return rows.map((row) => buildRowToResult(row, shouldSortAbilities));
|
||||
return rows.map((row) => ({
|
||||
...buildRowToResult(row, shouldSortAbilities),
|
||||
permissions: {
|
||||
EDIT: [userId],
|
||||
},
|
||||
}));
|
||||
}
|
||||
|
||||
interface CreateArgs {
|
||||
|
||||
@@ -333,11 +333,24 @@ export async function findById(
|
||||
|
||||
if (!firstRow) return null;
|
||||
|
||||
const startTimes = [firstRow, ...rest].map((row) => row.startsAt);
|
||||
const now = new Date();
|
||||
|
||||
return {
|
||||
...firstRow,
|
||||
tags: firstRow.tags ?? [],
|
||||
startTimes: [firstRow, ...rest].map((row) => row.startsAt),
|
||||
startTimes,
|
||||
startsAt: undefined,
|
||||
permissions: {
|
||||
EDIT: [firstRow.authorId],
|
||||
DELETE:
|
||||
databaseTimestampToDate(startTimes[0]) > now ? [firstRow.authorId] : [],
|
||||
REPORT_WINNERS: startTimes.every(
|
||||
(startTime) => databaseTimestampToDate(startTime) < now,
|
||||
)
|
||||
? [firstRow.authorId]
|
||||
: [],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -1,20 +1,14 @@
|
||||
import type { ActionFunction } from "react-router";
|
||||
import { redirect } from "react-router";
|
||||
import { requireUser } from "~/features/auth/core/user.server";
|
||||
import * as CalendarRepository from "~/features/calendar/CalendarRepository.server";
|
||||
import { parseFormData } from "~/form/parse.server";
|
||||
import {
|
||||
errorToastIfFalsy,
|
||||
notFoundIfNullish,
|
||||
parseParams,
|
||||
} from "~/utils/remix.server";
|
||||
import { requirePermission } from "~/modules/permissions/guards.server";
|
||||
import { notFoundIfNullish, parseParams } from "~/utils/remix.server";
|
||||
import { calendarEventPage } from "~/utils/urls";
|
||||
import { idObject } from "~/utils/zod";
|
||||
import { reportWinnersFormSchema } from "../calendar-schemas";
|
||||
import { canReportCalendarEventWinners } from "../calendar-utils";
|
||||
|
||||
export const action: ActionFunction = async (args) => {
|
||||
const user = requireUser();
|
||||
const params = parseParams({
|
||||
params: args.params,
|
||||
schema: idObject,
|
||||
@@ -29,14 +23,7 @@ export const action: ActionFunction = async (args) => {
|
||||
}
|
||||
|
||||
const event = notFoundIfNullish(await CalendarRepository.findById(params.id));
|
||||
errorToastIfFalsy(
|
||||
canReportCalendarEventWinners({
|
||||
user,
|
||||
event,
|
||||
startTimes: event.startTimes,
|
||||
}),
|
||||
"Unauthorized",
|
||||
);
|
||||
requirePermission(event, "REPORT_WINNERS");
|
||||
|
||||
await CalendarRepository.upsertReportedScores({
|
||||
eventId: params.id,
|
||||
|
||||
@@ -1,19 +1,16 @@
|
||||
import type { ActionFunction } from "react-router";
|
||||
import { redirect } from "react-router";
|
||||
import { z } from "zod";
|
||||
import { requireUser } from "~/features/auth/core/user.server";
|
||||
import * as CalendarRepository from "~/features/calendar/CalendarRepository.server";
|
||||
import * as ShowcaseTournaments from "~/features/front-page/core/ShowcaseTournaments.server";
|
||||
import * as BracketRepository from "~/features/tournament-bracket/BracketRepository.server";
|
||||
import { clearTournamentDataCache } from "~/features/tournament-bracket/core/Tournament.server";
|
||||
import { databaseTimestampToDate } from "~/utils/dates";
|
||||
import { requirePermission } from "~/modules/permissions/guards.server";
|
||||
import { errorToastIfFalsy, notFoundIfNullish } from "~/utils/remix.server";
|
||||
import { CALENDAR_PAGE } from "~/utils/urls";
|
||||
import { actualNumber, id } from "~/utils/zod";
|
||||
import { canDeleteCalendarEvent } from "../calendar-utils";
|
||||
|
||||
export const action: ActionFunction = async ({ params }) => {
|
||||
const user = requireUser();
|
||||
const parsedParams = z
|
||||
.object({ id: z.preprocess(actualNumber, id) })
|
||||
.parse(params);
|
||||
@@ -21,14 +18,7 @@ export const action: ActionFunction = async ({ params }) => {
|
||||
await CalendarRepository.findById(parsedParams.id),
|
||||
);
|
||||
|
||||
errorToastIfFalsy(
|
||||
canDeleteCalendarEvent({
|
||||
user,
|
||||
event,
|
||||
startTime: databaseTimestampToDate(event.startTimes[0]),
|
||||
}),
|
||||
"Cannot delete event",
|
||||
);
|
||||
requirePermission(event, "DELETE");
|
||||
|
||||
if (event.tournamentId) {
|
||||
errorToastIfFalsy(
|
||||
|
||||
@@ -15,7 +15,10 @@ import * as TrophyRepository from "~/features/trophies/TrophyRepository.server";
|
||||
import { canAccessTrophies } from "~/features/trophies/trophies-utils";
|
||||
import { parseFormDataWithImages } from "~/form/parse.server";
|
||||
import { rankedModesShort } from "~/modules/in-game-lists/modes";
|
||||
import { requireRole } from "~/modules/permissions/guards.server";
|
||||
import {
|
||||
requirePermission,
|
||||
requireRole,
|
||||
} from "~/modules/permissions/guards.server";
|
||||
import {
|
||||
databaseTimestampToDate,
|
||||
dateToDatabaseTimestamp,
|
||||
@@ -30,7 +33,7 @@ import { calendarEventPage } from "~/utils/urls";
|
||||
import { CALENDAR_EVENT } from "../calendar-constants";
|
||||
import { calendarNewSchemaServer } from "../calendar-new-schemas.server";
|
||||
import { formValuesToInputBrackets } from "../calendar-progression-form";
|
||||
import { canEditCalendarEvent, regClosesAtDate } from "../calendar-utils";
|
||||
import { regClosesAtDate } from "../calendar-utils";
|
||||
import { findValidOrganizations } from "../loaders/calendar.new.server";
|
||||
|
||||
export const action: ActionFunction = async ({ request }) => {
|
||||
@@ -148,19 +151,13 @@ export const action: ActionFunction = async ({ request }) => {
|
||||
await CalendarRepository.findById(data.eventToEditId),
|
||||
);
|
||||
if (eventToEdit.tournamentId) {
|
||||
const tournament = await tournamentFromDB({
|
||||
tournamentId: eventToEdit.tournamentId,
|
||||
user,
|
||||
});
|
||||
const tournament = await tournamentFromDB(eventToEdit.tournamentId);
|
||||
errorToastIfFalsy(
|
||||
!tournament.hasStarted,
|
||||
"Tournament has already started",
|
||||
);
|
||||
|
||||
errorToastIfFalsy(
|
||||
tournament.canEditEventInfo(user, { isTournamentAdder }),
|
||||
"Not authorized",
|
||||
);
|
||||
errorToastIfFalsy(tournament.canEditEventInfo(user), "Not authorized");
|
||||
|
||||
// once published, a tournament can't be flipped back to draft
|
||||
if (!tournament.isDraft) {
|
||||
@@ -168,10 +165,7 @@ export const action: ActionFunction = async ({ request }) => {
|
||||
}
|
||||
} else {
|
||||
// editing regular calendar event
|
||||
errorToastIfFalsy(
|
||||
canEditCalendarEvent({ user, event: eventToEdit }),
|
||||
"Not authorized",
|
||||
);
|
||||
requirePermission(eventToEdit, "EDIT");
|
||||
}
|
||||
|
||||
await CalendarRepository.update({
|
||||
|
||||
@@ -1,8 +1,4 @@
|
||||
import { addDays, addWeeks, startOfWeek, subWeeks } from "date-fns";
|
||||
import type { Tables } from "~/db/tables";
|
||||
import { isAdmin } from "~/modules/permissions/utils";
|
||||
import { allTruthy } from "~/utils/arrays";
|
||||
import { databaseTimestampToDate } from "~/utils/dates";
|
||||
import { logger } from "~/utils/logger";
|
||||
import { assertUnreachable } from "~/utils/types";
|
||||
import type { DayMonthYear } from "~/utils/zod";
|
||||
@@ -127,53 +123,6 @@ export function datesToRegClosesAt({
|
||||
return "0";
|
||||
}
|
||||
|
||||
interface CanEditCalendarEventArgs {
|
||||
user?: Pick<Tables["User"], "id">;
|
||||
event: Pick<Tables["CalendarEvent"], "authorId">;
|
||||
}
|
||||
export function canEditCalendarEvent({
|
||||
user,
|
||||
event,
|
||||
}: CanEditCalendarEventArgs) {
|
||||
if (isAdmin(user)) return true;
|
||||
|
||||
return user?.id === event.authorId;
|
||||
}
|
||||
|
||||
export function canDeleteCalendarEvent({
|
||||
user,
|
||||
event,
|
||||
startTime,
|
||||
}: CanEditCalendarEventArgs & { startTime: Date }) {
|
||||
if (isAdmin(user)) return true;
|
||||
|
||||
return user?.id === event.authorId && startTime > new Date();
|
||||
}
|
||||
|
||||
interface CanReportCalendarEventWinnersArgs {
|
||||
user?: Pick<Tables["User"], "id">;
|
||||
event: Pick<Tables["CalendarEvent"], "authorId">;
|
||||
startTimes: number[];
|
||||
}
|
||||
export function canReportCalendarEventWinners({
|
||||
user,
|
||||
event,
|
||||
startTimes,
|
||||
}: CanReportCalendarEventWinnersArgs) {
|
||||
return allTruthy([
|
||||
canEditCalendarEvent({ user, event }),
|
||||
eventStartedInThePast(startTimes),
|
||||
]);
|
||||
}
|
||||
|
||||
function eventStartedInThePast(
|
||||
startTimes: CanReportCalendarEventWinnersArgs["startTimes"],
|
||||
) {
|
||||
return startTimes.every(
|
||||
(startTime) => databaseTimestampToDate(startTime).getTime() < Date.now(),
|
||||
);
|
||||
}
|
||||
|
||||
export function daysForCalendar(currentDate?: DayMonthYear) {
|
||||
const anchor = currentDate
|
||||
? new Date(currentDate.year, currentDate.month, currentDate.day)
|
||||
|
||||
@@ -1,29 +1,17 @@
|
||||
import type { LoaderFunctionArgs } from "react-router";
|
||||
import { requireUser } from "~/features/auth/core/user.server";
|
||||
import * as CalendarRepository from "~/features/calendar/CalendarRepository.server";
|
||||
import {
|
||||
notFoundIfNullish,
|
||||
parseParams,
|
||||
unauthorizedIfFalsy,
|
||||
} from "~/utils/remix.server";
|
||||
import { requirePermission } from "~/modules/permissions/guards.server";
|
||||
import { notFoundIfNullish, parseParams } from "~/utils/remix.server";
|
||||
import { idObject } from "~/utils/zod";
|
||||
import { canReportCalendarEventWinners } from "../calendar-utils";
|
||||
|
||||
export const loader = async (args: LoaderFunctionArgs) => {
|
||||
const params = parseParams({
|
||||
params: args.params,
|
||||
schema: idObject,
|
||||
});
|
||||
const user = requireUser();
|
||||
const event = notFoundIfNullish(await CalendarRepository.findById(params.id));
|
||||
|
||||
unauthorizedIfFalsy(
|
||||
canReportCalendarEventWinners({
|
||||
user,
|
||||
event,
|
||||
startTimes: event.startTimes,
|
||||
}),
|
||||
);
|
||||
requirePermission(event, "REPORT_WINNERS");
|
||||
|
||||
return {
|
||||
name: event.name,
|
||||
|
||||
@@ -10,9 +10,9 @@ import * as TournamentOrganizationRepository from "~/features/tournament-organiz
|
||||
import * as TrophyRepository from "~/features/trophies/TrophyRepository.server";
|
||||
import { canAccessTrophies } from "~/features/trophies/trophies-utils";
|
||||
import { requireRole } from "~/modules/permissions/guards.server";
|
||||
import { hasPermission } from "~/modules/permissions/utils";
|
||||
import { tournamentBracketsPage } from "~/utils/urls";
|
||||
import { calendarNewSearchParams } from "../calendar-search-params";
|
||||
import { canEditCalendarEvent } from "../calendar-utils";
|
||||
|
||||
export const loader = async ({ url }: LoaderFunctionArgs) => {
|
||||
const user = requireUser();
|
||||
@@ -39,10 +39,7 @@ export const loader = async ({ url }: LoaderFunctionArgs) => {
|
||||
|
||||
return {
|
||||
...event,
|
||||
tournament: await tournamentData({
|
||||
tournamentId: event.tournamentId,
|
||||
user,
|
||||
}),
|
||||
tournament: await tournamentData(event.tournamentId),
|
||||
rules: await TournamentRepository.findRulesById(event.tournamentId),
|
||||
};
|
||||
};
|
||||
@@ -50,15 +47,11 @@ export const loader = async ({ url }: LoaderFunctionArgs) => {
|
||||
const eventToEdit = await eventWithTournament(eventId);
|
||||
const canEditEvent = (() => {
|
||||
if (!eventToEdit) return false;
|
||||
if (
|
||||
eventToEdit.tournament?.ctx.organization?.members.some(
|
||||
(member) => member.userId === user.id && member.role === "ADMIN",
|
||||
)
|
||||
) {
|
||||
return true;
|
||||
if (eventToEdit.tournament) {
|
||||
return hasPermission(eventToEdit.tournament.ctx, "EDIT_EVENT_INFO", user);
|
||||
}
|
||||
|
||||
return canEditCalendarEvent({ user, event: eventToEdit });
|
||||
return hasPermission(eventToEdit, "EDIT", user);
|
||||
})();
|
||||
|
||||
// no editing tournament after the start
|
||||
|
||||
@@ -5,7 +5,7 @@ import * as TournamentFactory from "~/db/seed/factories/TournamentFactory";
|
||||
import * as UserFactory from "~/db/seed/factories/UserFactory";
|
||||
import * as CalendarRepository from "~/features/calendar/CalendarRepository.server";
|
||||
import { dateToDatabaseTimestamp } from "~/utils/dates";
|
||||
import { assertResponseErrored, wrappedAction } from "~/utils/Test";
|
||||
import { wrappedAction } from "~/utils/Test";
|
||||
import { action } from "./calendar.$id";
|
||||
|
||||
const deleteAction = wrappedAction<z.ZodType<Record<string, never>>>({
|
||||
@@ -20,14 +20,15 @@ describe("calendar event deletion", () => {
|
||||
authorId: admin.id,
|
||||
});
|
||||
|
||||
const response = await deleteAction(
|
||||
{},
|
||||
{ user: "regular", params: { id: String(tournament.eventId) } },
|
||||
);
|
||||
await expect(
|
||||
deleteAction(
|
||||
{},
|
||||
{ user: "regular", params: { id: String(tournament.eventId) } },
|
||||
),
|
||||
).rejects.toThrow("Response thrown with status code: 403");
|
||||
|
||||
const eventAfter = await CalendarRepository.findById(tournament.eventId);
|
||||
expect(eventAfter, "the tournament was deleted").toBeTruthy();
|
||||
assertResponseErrored(response);
|
||||
});
|
||||
|
||||
test("lets the author delete their own not-yet-started tournament", async () => {
|
||||
|
||||
@@ -14,9 +14,8 @@ import { Placement } from "~/components/Placement";
|
||||
import { Section } from "~/components/Section";
|
||||
import { Table } from "~/components/Table";
|
||||
import { UserLink } from "~/components/UserLink";
|
||||
import { useUser } from "~/features/auth/core/user";
|
||||
import { MapPool } from "~/features/map-list-generator/core/map-pool";
|
||||
import { databaseTimestampToDate } from "~/utils/dates";
|
||||
import { useHasPermission } from "~/modules/permissions/hooks";
|
||||
import type { SendouRouteHandle } from "~/utils/remix.server";
|
||||
import {
|
||||
CALENDAR_PAGE,
|
||||
@@ -30,11 +29,6 @@ import {
|
||||
import { metaTags, type SerializeFrom } from "../../../utils/remix";
|
||||
import { action } from "../actions/calendar.$id.server";
|
||||
import styles from "../calendar-event.module.css";
|
||||
import {
|
||||
canDeleteCalendarEvent,
|
||||
canEditCalendarEvent,
|
||||
canReportCalendarEventWinners,
|
||||
} from "../calendar-utils";
|
||||
import { Tags } from "../components/Tags";
|
||||
import { loader } from "../loaders/calendar.$id.server";
|
||||
|
||||
@@ -77,9 +71,11 @@ export const handle: SendouRouteHandle = {
|
||||
};
|
||||
|
||||
export default function CalendarEventPage() {
|
||||
const user = useUser();
|
||||
const data = useLoaderData<typeof loader>();
|
||||
const { t } = useTranslation(["common", "calendar"]);
|
||||
const canEdit = useHasPermission(data.event, "EDIT");
|
||||
const canReportWinners = useHasPermission(data.event, "REPORT_WINNERS");
|
||||
const canDelete = useHasPermission(data.event, "DELETE");
|
||||
|
||||
return (
|
||||
<Main className="stack lg">
|
||||
@@ -134,26 +130,22 @@ export default function CalendarEventPage() {
|
||||
>
|
||||
{resolveBaseUrl(data.event.bracketUrl)}
|
||||
</LinkButton>
|
||||
{canEditCalendarEvent({ user, event: data.event }) && (
|
||||
{canEdit ? (
|
||||
<LinkButton
|
||||
size="small"
|
||||
to={calendarEditPage(data.event.eventId)}
|
||||
>
|
||||
{t("common:actions.edit")}
|
||||
</LinkButton>
|
||||
)}
|
||||
{canReportCalendarEventWinners({
|
||||
user,
|
||||
event: data.event,
|
||||
startTimes: data.event.startTimes,
|
||||
}) && (
|
||||
) : null}
|
||||
{canReportWinners ? (
|
||||
<LinkButton
|
||||
size="small"
|
||||
to={calendarReportWinnersPage(data.event.eventId)}
|
||||
>
|
||||
{t("calendar:actions.reportWinners")}
|
||||
</LinkButton>
|
||||
)}
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
@@ -161,11 +153,7 @@ export default function CalendarEventPage() {
|
||||
<MapPoolInfo />
|
||||
<div className="stack md">
|
||||
<Description />
|
||||
{canDeleteCalendarEvent({
|
||||
user,
|
||||
startTime: databaseTimestampToDate(data.event.startTimes[0]),
|
||||
event: data.event,
|
||||
}) ? (
|
||||
{canDelete ? (
|
||||
<FormWithConfirm
|
||||
dialogHeading={t("calendar:actions.delete.confirm", {
|
||||
name: data.event.name,
|
||||
|
||||
@@ -91,13 +91,21 @@ export async function findAllPosts(user?: {
|
||||
.$narrowType<{ author: NotNull }>()
|
||||
.execute();
|
||||
|
||||
return rows.filter((row) => {
|
||||
if (!row.plusTierVisibility) return true;
|
||||
if (row.author.id === userId) return true;
|
||||
if (!user?.plusTier) return false;
|
||||
return rows
|
||||
.filter((row) => {
|
||||
if (!row.plusTierVisibility) return true;
|
||||
if (row.author.id === userId) return true;
|
||||
if (!user?.plusTier) return false;
|
||||
|
||||
return row.plusTierVisibility >= user.plusTier;
|
||||
});
|
||||
return row.plusTierVisibility >= user.plusTier;
|
||||
})
|
||||
.map((row) => ({
|
||||
...row,
|
||||
permissions: {
|
||||
EDIT: [row.author.id],
|
||||
DELETE: [row.author.id],
|
||||
},
|
||||
}));
|
||||
}
|
||||
|
||||
const postExpiryCutoff = () =>
|
||||
|
||||
@@ -4,6 +4,7 @@ import type { Tables } from "~/db/tables";
|
||||
import { requireUser } from "~/features/auth/core/user.server";
|
||||
import * as UserRepository from "~/features/user-page/UserRepository.server";
|
||||
import { parseFormData } from "~/form/parse.server";
|
||||
import { requirePermission } from "~/modules/permissions/guards.server";
|
||||
import { errorToastIfFalsy } from "~/utils/remix.server";
|
||||
import { LFG_PAGE } from "~/utils/urls";
|
||||
import * as LFGRepository from "../LFGRepository.server";
|
||||
@@ -80,8 +81,5 @@ const validateCanUpdatePost = async ({
|
||||
const posts = await LFGRepository.findAllPosts(user);
|
||||
const post = posts.find((post) => post.id === postId);
|
||||
errorToastIfFalsy(post, "Post to update not found");
|
||||
errorToastIfFalsy(
|
||||
post.author.id === user.id,
|
||||
"You can only update your own posts",
|
||||
);
|
||||
requirePermission(post, "EDIT");
|
||||
};
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { ActionFunctionArgs } from "react-router";
|
||||
import { requireUser } from "~/features/auth/core/user.server";
|
||||
import { requirePermission } from "~/modules/permissions/guards.server";
|
||||
import { errorToastIfFalsy, parseRequestPayload } from "~/utils/remix.server";
|
||||
import * as LFGRepository from "../LFGRepository.server";
|
||||
import { lfgActionSchema } from "../lfg-schemas";
|
||||
@@ -14,17 +15,15 @@ export const action = async ({ request }: ActionFunctionArgs) => {
|
||||
const posts = await LFGRepository.findAllPosts(user);
|
||||
const post = posts.find((post) => post.id === data.id);
|
||||
errorToastIfFalsy(post, "Post not found");
|
||||
errorToastIfFalsy(
|
||||
post.author.id === user.id || user.roles.includes("ADMIN"),
|
||||
"Not your own post",
|
||||
);
|
||||
|
||||
switch (data._action) {
|
||||
case "DELETE_POST": {
|
||||
requirePermission(post, "DELETE");
|
||||
await LFGRepository.deletePost(data.id);
|
||||
break;
|
||||
}
|
||||
case "BUMP_POST": {
|
||||
requirePermission(post, "EDIT");
|
||||
await LFGRepository.bumpPost(data.id);
|
||||
break;
|
||||
}
|
||||
|
||||
@@ -11,7 +11,6 @@ import { FormWithConfirm } from "~/components/FormWithConfirm";
|
||||
import { WeaponImage } from "~/components/Image";
|
||||
import { LocaleTime } from "~/components/LocaleTime";
|
||||
import { NoteAvatar } from "~/components/NoteAvatar";
|
||||
import { useUser } from "~/features/auth/core/user";
|
||||
import {
|
||||
UserCard,
|
||||
useUserCardData,
|
||||
@@ -19,7 +18,7 @@ import {
|
||||
import { useFormatDistanceToNow } from "~/hooks/intl/useFormatDistanceToNow";
|
||||
import { useHydrated } from "~/hooks/useHydrated";
|
||||
import type { UnifiedLanguageCode } from "~/modules/i18n/config";
|
||||
import { useHasRole } from "~/modules/permissions/hooks";
|
||||
import { useHasPermission } from "~/modules/permissions/hooks";
|
||||
import { databaseTimestampToDate } from "~/utils/dates";
|
||||
import { lfgNewPostPage } from "~/utils/urls";
|
||||
import { hourDifferenceBetweenTimezones } from "../core/timezone";
|
||||
@@ -39,8 +38,8 @@ export function LFGPost({ post }: { post: Post }) {
|
||||
|
||||
const USER_POST_EXPANDABLE_CRITERIA = 300;
|
||||
function UserLFGPost({ post }: { post: Post }) {
|
||||
const user = useUser();
|
||||
const isAdmin = useHasRole("ADMIN");
|
||||
const canEdit = useHasPermission(post, "EDIT");
|
||||
const canDelete = useHasPermission(post, "DELETE");
|
||||
const [isExpanded, setIsExpanded] = React.useState(false);
|
||||
|
||||
return (
|
||||
@@ -54,14 +53,14 @@ function UserLFGPost({ post }: { post: Post }) {
|
||||
<PostPills
|
||||
languages={post.languages}
|
||||
timezone={post.timezone}
|
||||
canEdit={post.author.id === user?.id}
|
||||
canEdit={canEdit}
|
||||
postId={post.id}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<div className="stack horizontal justify-between items-center">
|
||||
<PostTextTypeHeader type={post.type} />
|
||||
{post.author.id === user?.id || isAdmin ? (
|
||||
{canDelete ? (
|
||||
<PostDeleteButton id={post.id} type={post.type} />
|
||||
) : null}
|
||||
</div>
|
||||
@@ -82,8 +81,8 @@ function TeamLFGPost({
|
||||
post: Post & { team: NonNullable<Post["team"]> };
|
||||
}) {
|
||||
const isHydrated = useHydrated();
|
||||
const user = useUser();
|
||||
const isAdmin = useHasRole("ADMIN");
|
||||
const canEdit = useHasPermission(post, "EDIT");
|
||||
const canDelete = useHasPermission(post, "DELETE");
|
||||
const [isExpanded, setIsExpanded] = React.useState(false);
|
||||
|
||||
return (
|
||||
@@ -102,9 +101,7 @@ function TeamLFGPost({
|
||||
<Divider />
|
||||
<div className="stack horizontal justify-between items-center">
|
||||
<PostTime createdAt={post.createdAt} updatedAt={post.updatedAt} />
|
||||
{post.author.id === user?.id ? (
|
||||
<PostEditButton id={post.id} />
|
||||
) : null}
|
||||
{canEdit ? <PostEditButton id={post.id} /> : null}
|
||||
</div>
|
||||
</div>
|
||||
{isExpanded ? (
|
||||
@@ -116,7 +113,7 @@ function TeamLFGPost({
|
||||
<div>
|
||||
<div className="stack horizontal justify-between">
|
||||
<PostTextTypeHeader type={post.type} />
|
||||
{post.author.id === user?.id || isAdmin ? (
|
||||
{canDelete ? (
|
||||
<PostDeleteButton id={post.id} type={post.type} />
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
@@ -2,7 +2,7 @@ import { formatDistance } from "date-fns";
|
||||
import type { ExpressionBuilder, Insertable, NotNull } from "kysely";
|
||||
import { db } from "~/db/sql";
|
||||
import type { DB } from "~/db/tables";
|
||||
import type { MonthYear } from "~/features/plus-voting/core";
|
||||
import { isVotingActive, type MonthYear } from "~/features/plus-voting/core";
|
||||
import { databaseTimestampNow, databaseTimestampToDate } from "~/utils/dates";
|
||||
import { commonUserSelect, jsonObjectFrom } from "~/utils/kysely.server";
|
||||
import type { Unwrapped } from "~/utils/types";
|
||||
@@ -104,7 +104,23 @@ export async function findAllByMonth(args: MonthYear & { tier?: number }) {
|
||||
}
|
||||
}
|
||||
|
||||
return result.sort((a, b) => b.entries[0].createdAt - a.entries[0].createdAt);
|
||||
const votingActive =
|
||||
process.env.NODE_ENV === "test" ? false : isVotingActive();
|
||||
|
||||
return result
|
||||
.sort((a, b) => b.entries[0].createdAt - a.entries[0].createdAt)
|
||||
.map((suggestion) => ({
|
||||
...suggestion,
|
||||
entries: suggestion.entries.map((entry, index) => ({
|
||||
...entry,
|
||||
permissions: entryPermissions({
|
||||
authorId: entry.author.id,
|
||||
isFirstSuggestion: index === 0,
|
||||
entryCount: suggestion.entries.length,
|
||||
votingActive,
|
||||
}),
|
||||
})),
|
||||
}));
|
||||
}
|
||||
|
||||
export interface MonthSummary {
|
||||
@@ -196,6 +212,33 @@ export function deleteWithCommentsBySuggestedUserId({
|
||||
}
|
||||
|
||||
/** Plus tier the suggested user already has, if any. */
|
||||
// the first entry is the suggestion itself; deleting it deletes the whole
|
||||
// suggestion which the author may only do while it has no comments
|
||||
function entryPermissions({
|
||||
authorId,
|
||||
isFirstSuggestion,
|
||||
entryCount,
|
||||
votingActive,
|
||||
}: {
|
||||
authorId: number;
|
||||
isFirstSuggestion: boolean;
|
||||
entryCount: number;
|
||||
votingActive: boolean;
|
||||
}) {
|
||||
if (!isFirstSuggestion) {
|
||||
return { EDIT: [], DELETE: [authorId] };
|
||||
}
|
||||
|
||||
if (votingActive) {
|
||||
return { EDIT: [], DELETE: [] };
|
||||
}
|
||||
|
||||
return {
|
||||
EDIT: [authorId],
|
||||
DELETE: entryCount === 1 ? [authorId] : [],
|
||||
};
|
||||
}
|
||||
|
||||
function suggestedPlusTier(eb: ExpressionBuilder<DB, "PlusSuggestion">) {
|
||||
return eb
|
||||
.selectFrom("PlusTier")
|
||||
|
||||
@@ -7,16 +7,12 @@ import {
|
||||
rangeToMonthYear,
|
||||
} from "~/features/plus-voting/core";
|
||||
import { parseFormData } from "~/form/parse.server";
|
||||
import { requirePermission } from "~/modules/permissions/guards.server";
|
||||
import invariant from "~/utils/invariant";
|
||||
import { badRequestIfFalsy, errorToastIfFalsy } from "~/utils/remix.server";
|
||||
import { badRequestIfFalsy } from "~/utils/remix.server";
|
||||
import { assertUnreachable } from "~/utils/types";
|
||||
import { plusSuggestionPage } from "~/utils/urls";
|
||||
import { suggestionActionSchema } from "../plus-suggestions-schemas";
|
||||
import {
|
||||
canDeleteComment,
|
||||
canEditSuggestion,
|
||||
isFirstSuggestion,
|
||||
} from "../plus-suggestions-utils";
|
||||
|
||||
export const action: ActionFunction = async ({ request }) => {
|
||||
const user = requireUser();
|
||||
@@ -48,15 +44,7 @@ export const action: ActionFunction = async ({ request }) => {
|
||||
const entry = suggestion.entries.find((e) => e.id === data.suggestionId);
|
||||
invariant(entry);
|
||||
|
||||
errorToastIfFalsy(
|
||||
canEditSuggestion({
|
||||
user,
|
||||
author: entry.author,
|
||||
suggestionId: data.suggestionId,
|
||||
suggestions,
|
||||
}),
|
||||
"No permissions to edit this suggestion",
|
||||
);
|
||||
requirePermission(entry, "EDIT");
|
||||
|
||||
await PlusSuggestionRepository.updateTextById(
|
||||
data.suggestionId,
|
||||
@@ -78,24 +66,13 @@ export const action: ActionFunction = async ({ request }) => {
|
||||
);
|
||||
invariant(entryToDelete);
|
||||
|
||||
errorToastIfFalsy(
|
||||
canDeleteComment({
|
||||
user,
|
||||
author: entryToDelete.author,
|
||||
suggestionId: data.suggestionId,
|
||||
suggestions,
|
||||
}),
|
||||
"No permissions to delete this comment",
|
||||
);
|
||||
requirePermission(entryToDelete, "DELETE");
|
||||
|
||||
const suggestionHasComments = suggestionToDelete.entries.length > 1;
|
||||
|
||||
if (
|
||||
suggestionHasComments &&
|
||||
isFirstSuggestion({
|
||||
suggestionId: data.suggestionId,
|
||||
suggestions,
|
||||
})
|
||||
suggestionToDelete.entries[0].id === data.suggestionId
|
||||
) {
|
||||
// admin only action
|
||||
await PlusSuggestionRepository.deleteWithCommentsBySuggestedUserId({
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import type { Tables } from "~/db/tables";
|
||||
import type * as PlusSuggestionRepository from "~/features/plus-suggestions/PlusSuggestionRepository.server";
|
||||
import { isAdmin } from "~/modules/permissions/utils";
|
||||
import { allTruthy } from "~/utils/arrays";
|
||||
import type { UserWithPlusTier } from "~/utils/kysely.server";
|
||||
import * as Seasons from "../mmr/core/Seasons";
|
||||
@@ -40,41 +39,6 @@ export function canAddCommentToSuggestionBE({
|
||||
]);
|
||||
}
|
||||
|
||||
interface CanDeleteCommentArgs {
|
||||
suggestionId: Tables["PlusSuggestion"]["id"];
|
||||
author: Pick<Tables["User"], "id">;
|
||||
user?: Pick<Tables["User"], "id" | "discordId">;
|
||||
suggestions: PlusSuggestionRepository.FindAllByMonthItem[];
|
||||
}
|
||||
export function canDeleteComment(args: CanDeleteCommentArgs) {
|
||||
const votingActive =
|
||||
process.env.NODE_ENV === "test" ? false : isVotingActive();
|
||||
|
||||
if (isFirstSuggestion(args)) {
|
||||
if (votingActive) return false;
|
||||
if (isAdmin(args.user)) return true;
|
||||
|
||||
return allTruthy([isOwnComment(args), suggestionHasNoOtherComments(args)]);
|
||||
}
|
||||
|
||||
return isOwnComment(args);
|
||||
}
|
||||
|
||||
export function isFirstSuggestion({
|
||||
suggestionId,
|
||||
suggestions,
|
||||
}: Pick<CanDeleteCommentArgs, "suggestionId" | "suggestions">) {
|
||||
for (const suggestedUser of Object.values(suggestions).flat()) {
|
||||
for (const [i, entry] of suggestedUser.entries.entries()) {
|
||||
if (entry.id !== suggestionId) continue;
|
||||
|
||||
return i === 0;
|
||||
}
|
||||
}
|
||||
|
||||
throw new Error(`Invalid suggestion id: ${suggestionId}`);
|
||||
}
|
||||
|
||||
function alreadyCommentedByUser({
|
||||
user,
|
||||
suggestions,
|
||||
@@ -111,42 +75,6 @@ function targetPlusTierIsSmallerOrEqual({
|
||||
return user?.plusTier && user.plusTier <= targetPlusTier;
|
||||
}
|
||||
|
||||
function isOwnComment({ author, user }: CanDeleteCommentArgs) {
|
||||
return author.id === user?.id;
|
||||
}
|
||||
|
||||
function suggestionHasNoOtherComments({
|
||||
suggestions,
|
||||
suggestionId,
|
||||
}: Pick<CanDeleteCommentArgs, "suggestionId" | "suggestions">) {
|
||||
for (const suggestedUser of Object.values(suggestions).flat()) {
|
||||
for (const entry of suggestedUser.entries) {
|
||||
if (entry.id !== suggestionId) continue;
|
||||
|
||||
return suggestedUser.entries.length === 1;
|
||||
}
|
||||
}
|
||||
|
||||
throw new Error(`Invalid suggestion id: ${suggestionId}`);
|
||||
}
|
||||
|
||||
interface CanEditSuggestionArgs {
|
||||
suggestionId: Tables["PlusSuggestion"]["id"];
|
||||
author: Pick<Tables["User"], "id">;
|
||||
user?: Pick<Tables["User"], "id">;
|
||||
suggestions: PlusSuggestionRepository.FindAllByMonthItem[];
|
||||
}
|
||||
export function canEditSuggestion(args: CanEditSuggestionArgs) {
|
||||
const votingActive =
|
||||
process.env.NODE_ENV === "test" ? false : isVotingActive();
|
||||
|
||||
return allTruthy([
|
||||
!votingActive,
|
||||
isFirstSuggestion(args),
|
||||
args.author.id === args.user?.id,
|
||||
]);
|
||||
}
|
||||
|
||||
interface CanSuggestNewUserArgs {
|
||||
user?: Pick<UserWithPlusTier, "id" | "plusTier">;
|
||||
/** Whether the user has already started a suggestion this month, any tier. */
|
||||
|
||||
@@ -40,6 +40,10 @@ const suggestionOf = (
|
||||
updatedAt: null,
|
||||
updatedAtRelative: null,
|
||||
author: AUTHOR,
|
||||
permissions: {
|
||||
EDIT: [AUTHOR.id],
|
||||
DELETE: [AUTHOR.id],
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
@@ -54,14 +58,13 @@ describe("PlusSuggestionComments", () => {
|
||||
const router = createBrowserRouter([
|
||||
{
|
||||
path: "*",
|
||||
loader: () => ({ user: AUTHOR_AS_LOGGED_IN_USER }),
|
||||
element: (
|
||||
<PlusSuggestionComments
|
||||
suggestion={suggestions[0]}
|
||||
deleteButtonArgs={{
|
||||
suggested: suggestions[0].suggested,
|
||||
user: AUTHOR_AS_LOGGED_IN_USER,
|
||||
tier: "2",
|
||||
suggestions,
|
||||
}}
|
||||
defaultOpen
|
||||
/>
|
||||
|
||||
@@ -19,6 +19,7 @@ import {
|
||||
} from "~/features/plus-voting/core";
|
||||
import { UserCard } from "~/features/user-card/components/UserCard";
|
||||
import { SendouForm } from "~/form/SendouForm";
|
||||
import { hasPermission } from "~/modules/permissions/utils";
|
||||
import {
|
||||
useSearchParam,
|
||||
useSearchParamsTyped,
|
||||
@@ -38,10 +39,7 @@ import {
|
||||
} from "../plus-suggestions-search-params";
|
||||
import {
|
||||
canAddCommentToSuggestionFE,
|
||||
canDeleteComment,
|
||||
canEditSuggestion,
|
||||
canSuggestNewUser,
|
||||
isFirstSuggestion,
|
||||
} from "../plus-suggestions-utils";
|
||||
|
||||
export { action, loader };
|
||||
@@ -231,9 +229,7 @@ function SuggestedUser({
|
||||
suggestion={suggestion}
|
||||
deleteButtonArgs={{
|
||||
suggested: suggestion.suggested,
|
||||
user,
|
||||
tier: String(tier),
|
||||
suggestions: data.suggestions,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
@@ -247,14 +243,13 @@ export function PlusSuggestionComments({
|
||||
}: {
|
||||
suggestion: PlusSuggestionRepository.FindAllByMonthItem;
|
||||
deleteButtonArgs?: {
|
||||
user?: Pick<Tables["User"], "id" | "discordId">;
|
||||
suggestions: PlusSuggestionRepository.FindAllByMonthItem[];
|
||||
tier: string;
|
||||
suggested: PlusSuggestionRepository.FindAllByMonthItem["suggested"];
|
||||
};
|
||||
defaultOpen?: true;
|
||||
}) {
|
||||
const { t } = useTranslation(["common"]);
|
||||
const user = useUser();
|
||||
const [, setEditingSuggestionId] = useSearchParam(
|
||||
plusSuggestionsSearchParams,
|
||||
"editingSuggestionId",
|
||||
@@ -298,13 +293,7 @@ export function PlusSuggestionComments({
|
||||
)
|
||||
</span>
|
||||
) : null}
|
||||
{deleteButtonArgs &&
|
||||
canEditSuggestion({
|
||||
author: entry.author,
|
||||
user: deleteButtonArgs.user,
|
||||
suggestionId: entry.id,
|
||||
suggestions: deleteButtonArgs.suggestions,
|
||||
}) ? (
|
||||
{deleteButtonArgs && hasPermission(entry, "EDIT", user) ? (
|
||||
<SendouButton
|
||||
className="plus__edit-button"
|
||||
icon={<SquarePen />}
|
||||
@@ -313,21 +302,12 @@ export function PlusSuggestionComments({
|
||||
onPress={() => setEditingSuggestionId(entry.id)}
|
||||
/>
|
||||
) : null}
|
||||
{deleteButtonArgs &&
|
||||
canDeleteComment({
|
||||
author: entry.author,
|
||||
user: deleteButtonArgs.user,
|
||||
suggestionId: entry.id,
|
||||
suggestions: deleteButtonArgs.suggestions,
|
||||
}) ? (
|
||||
{deleteButtonArgs && hasPermission(entry, "DELETE", user) ? (
|
||||
<CommentDeleteButton
|
||||
suggestionId={entry.id}
|
||||
tier={deleteButtonArgs.tier}
|
||||
suggestedUsername={deleteButtonArgs.suggested.username}
|
||||
isFirstSuggestion={isFirstSuggestion({
|
||||
suggestionId: entry.id,
|
||||
suggestions: deleteButtonArgs.suggestions,
|
||||
})}
|
||||
isFirstSuggestion={suggestion.entries[0].id === entry.id}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
@@ -130,14 +130,14 @@ export type findByCustomUrl = NonNullable<
|
||||
Awaited<ReturnType<typeof findByCustomUrl>>
|
||||
>;
|
||||
|
||||
export function findByCustomUrl(
|
||||
export async function findByCustomUrl(
|
||||
customUrl: string,
|
||||
{ includeInviteCode = false, includeUnvalidatedImages = false } = {},
|
||||
) {
|
||||
// join the unvalidated table (instead of the validated-only `UserSubmittedImage` view) so the
|
||||
// edit page can preview images still pending moderation; for everyone else the url is gated on
|
||||
// `validatedAt` so pending images stay hidden
|
||||
return db
|
||||
const row = await db
|
||||
.selectFrom("Team")
|
||||
.leftJoin(
|
||||
"UnvalidatedUserSubmittedImage as AvatarImage",
|
||||
@@ -200,6 +200,23 @@ export function findByCustomUrl(
|
||||
.$if(includeInviteCode, (qb) => qb.select("Team.inviteCode"))
|
||||
.where("Team.customUrl", "=", customUrl.toLowerCase())
|
||||
.executeTakeFirst();
|
||||
|
||||
if (!row) return;
|
||||
|
||||
const managerIds = row.members
|
||||
.filter((member) => member.isOwner || member.isManager)
|
||||
.map((member) => member.id);
|
||||
|
||||
return {
|
||||
...row,
|
||||
permissions: {
|
||||
EDIT: managerIds,
|
||||
MANAGE_ROSTER: managerIds,
|
||||
DELETE: row.members
|
||||
.filter((member) => member.isOwner)
|
||||
.map((member) => member.id),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export type FindResultPlacementsById = NonNullable<
|
||||
|
||||
@@ -2,6 +2,7 @@ import type { ActionFunction } from "react-router";
|
||||
import { redirect } from "react-router";
|
||||
import { requireUser } from "~/features/auth/core/user.server";
|
||||
import { parseFormDataWithImages } from "~/form/parse.server";
|
||||
import { requirePermission } from "~/modules/permissions/guards.server";
|
||||
import { clampThemeToGamut } from "~/utils/oklch-gamut";
|
||||
import { errorToastIfFalsy, notFoundIfNullish } from "~/utils/remix.server";
|
||||
import { assertUnreachable } from "~/utils/types";
|
||||
@@ -9,20 +10,17 @@ import { mySlugify, teamPage } from "~/utils/urls";
|
||||
import * as TeamRepository from "../TeamRepository.server";
|
||||
import { editTeamActionSchema } from "../team-schemas";
|
||||
import { teamParamsSchema } from "../team-schemas.server";
|
||||
import { canAddCustomizedColors, isTeamManager } from "../team-utils";
|
||||
import { canAddCustomizedColors } from "../team-utils";
|
||||
|
||||
export const action: ActionFunction = async ({ request, params }) => {
|
||||
const user = requireUser();
|
||||
requireUser();
|
||||
const { customUrl } = teamParamsSchema.parse(params);
|
||||
|
||||
const team = notFoundIfNullish(
|
||||
await TeamRepository.findByCustomUrl(customUrl),
|
||||
);
|
||||
|
||||
errorToastIfFalsy(
|
||||
isTeamManager({ team, user }) || user.roles.includes("ADMIN"),
|
||||
"You are not a team manager",
|
||||
);
|
||||
requirePermission(team, "EDIT");
|
||||
|
||||
const result = await parseFormDataWithImages({
|
||||
request,
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { ActionFunction } from "react-router";
|
||||
import { redirect } from "react-router";
|
||||
import { requireUser } from "~/features/auth/core/user.server";
|
||||
import { requirePermission } from "~/modules/permissions/guards.server";
|
||||
import {
|
||||
errorToastIfFalsy,
|
||||
notFoundIfNullish,
|
||||
@@ -53,10 +54,7 @@ export const action: ActionFunction = async ({ request, params }) => {
|
||||
break;
|
||||
}
|
||||
case "DELETE_TEAM": {
|
||||
errorToastIfFalsy(
|
||||
isTeamOwner({ user, team }) || user.roles.includes("ADMIN"),
|
||||
"You are not the team owner",
|
||||
);
|
||||
requirePermission(team, "DELETE");
|
||||
|
||||
await TeamRepository.deleteById(team.id);
|
||||
throw redirect("/");
|
||||
|
||||
@@ -6,13 +6,13 @@ import type {
|
||||
MemberRoleType,
|
||||
} from "~/features/team/team-constants";
|
||||
import { parseFormData } from "~/form/parse.server";
|
||||
import { requirePermission } from "~/modules/permissions/guards.server";
|
||||
import { errorToastIfFalsy, notFoundIfNullish } from "~/utils/remix.server";
|
||||
import { assertUnreachable } from "~/utils/types";
|
||||
import { teamPage } from "~/utils/urls";
|
||||
import * as TeamRepository from "../TeamRepository.server";
|
||||
import { CUSTOM_ROLE_VALUE } from "../team-schemas";
|
||||
import { manageRosterSchema, teamParamsSchema } from "../team-schemas.server";
|
||||
import { isTeamManager } from "../team-utils";
|
||||
|
||||
export const action: ActionFunction = async ({ request, params }) => {
|
||||
const user = requireUser();
|
||||
@@ -21,10 +21,7 @@ export const action: ActionFunction = async ({ request, params }) => {
|
||||
const team = notFoundIfNullish(
|
||||
await TeamRepository.findByCustomUrl(customUrl),
|
||||
);
|
||||
errorToastIfFalsy(
|
||||
isTeamManager({ team, user }) || user.roles.includes("ADMIN"),
|
||||
"Only team manager or owner can manage roster",
|
||||
);
|
||||
requirePermission(team, "MANAGE_ROSTER");
|
||||
|
||||
const result = await parseFormData({
|
||||
request,
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
import type { LoaderFunctionArgs } from "react-router";
|
||||
import { redirect } from "react-router";
|
||||
import { requireUser } from "~/features/auth/core/user.server";
|
||||
import { hasPermission } from "~/modules/permissions/utils";
|
||||
import { notFoundIfNullish } from "~/utils/remix.server";
|
||||
import { teamPage } from "~/utils/urls";
|
||||
import * as TeamRepository from "../TeamRepository.server";
|
||||
import { teamParamsSchema } from "../team-schemas.server";
|
||||
import { canAddCustomizedColors, isTeamManager } from "../team-utils";
|
||||
import { canAddCustomizedColors } from "../team-utils";
|
||||
|
||||
export const loader = async ({ params }: LoaderFunctionArgs) => {
|
||||
const user = requireUser();
|
||||
@@ -17,7 +18,7 @@ export const loader = async ({ params }: LoaderFunctionArgs) => {
|
||||
}),
|
||||
);
|
||||
|
||||
if (!isTeamManager({ team, user }) && !user.roles.includes("ADMIN")) {
|
||||
if (!hasPermission(team, "EDIT", user)) {
|
||||
throw redirect(teamPage(customUrl));
|
||||
}
|
||||
|
||||
|
||||
@@ -1,14 +1,10 @@
|
||||
import type { LoaderFunctionArgs } from "react-router";
|
||||
import { redirect } from "react-router";
|
||||
import { requireUser } from "~/features/auth/core/user.server";
|
||||
import { requirePermission } from "~/modules/permissions/guards.server";
|
||||
import { notFoundIfNullish } from "~/utils/remix.server";
|
||||
import { teamPage } from "~/utils/urls";
|
||||
import * as TeamRepository from "../TeamRepository.server";
|
||||
import { teamParamsSchema } from "../team-schemas.server";
|
||||
import { isTeamManager } from "../team-utils";
|
||||
|
||||
export const loader = async ({ params }: LoaderFunctionArgs) => {
|
||||
const user = requireUser();
|
||||
const { customUrl } = teamParamsSchema.parse(params);
|
||||
|
||||
const team = notFoundIfNullish(
|
||||
@@ -17,9 +13,7 @@ export const loader = async ({ params }: LoaderFunctionArgs) => {
|
||||
}),
|
||||
);
|
||||
|
||||
if (!isTeamManager({ team, user }) && !user.roles.includes("ADMIN")) {
|
||||
throw redirect(teamPage(customUrl));
|
||||
}
|
||||
requirePermission(team, "MANAGE_ROSTER");
|
||||
|
||||
return {
|
||||
team,
|
||||
|
||||
@@ -27,7 +27,7 @@ import { UserLink } from "~/components/UserLink";
|
||||
import { useUser } from "~/features/auth/core/user";
|
||||
import type { TeamLoaderData } from "~/features/team/loaders/t.$customUrl.server";
|
||||
import { useActionSubmit } from "~/hooks/useActionSubmit";
|
||||
import { useHasRole } from "~/modules/permissions/hooks";
|
||||
import { useHasPermission } from "~/modules/permissions/hooks";
|
||||
import invariant from "~/utils/invariant";
|
||||
import { editTeamPage, manageTeamRosterPage, userPage } from "~/utils/urls";
|
||||
import { action } from "../actions/t.$customUrl.index.server";
|
||||
@@ -36,7 +36,6 @@ import styles from "../team.module.css";
|
||||
import { teamProfilePageActionSchema } from "../team-schemas";
|
||||
import {
|
||||
getMemberRoleType,
|
||||
isTeamManager,
|
||||
isTeamMember,
|
||||
isTeamOwner,
|
||||
resolveNewOwner,
|
||||
@@ -110,19 +109,20 @@ export default function TeamIndexPage() {
|
||||
function ActionButtons() {
|
||||
const { t } = useTranslation(["team"]);
|
||||
const user = useUser();
|
||||
const isAdmin = useHasRole("ADMIN");
|
||||
const [, parentRoute] = useMatches();
|
||||
invariant(parentRoute);
|
||||
const layoutData = parentRoute.loaderData as TeamLoaderData;
|
||||
const team = layoutData.team;
|
||||
const canManageRoster = useHasPermission(team, "MANAGE_ROSTER");
|
||||
const canEditTeam = useHasPermission(team, "EDIT");
|
||||
|
||||
if (!isTeamMember({ user, team }) && !isAdmin) {
|
||||
if (!isTeamMember({ user, team }) && !canManageRoster && !canEditTeam) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={styles.actionButtons}>
|
||||
{isTeamManager({ user, team }) || isAdmin ? (
|
||||
{canManageRoster ? (
|
||||
<LinkButton
|
||||
size="small"
|
||||
to={manageTeamRosterPage(team.customUrl)}
|
||||
@@ -134,7 +134,7 @@ function ActionButtons() {
|
||||
{t("team:actionButtons.manageRoster")}
|
||||
</LinkButton>
|
||||
) : null}
|
||||
{isTeamManager({ user, team }) || isAdmin ? (
|
||||
{canEditTeam ? (
|
||||
<LinkButton
|
||||
size="small"
|
||||
to={editTeamPage(team.customUrl)}
|
||||
@@ -154,7 +154,6 @@ function ActionButtons() {
|
||||
function TeamActionsMenu({ team }: { team: TeamLoaderData["team"] }) {
|
||||
const { t } = useTranslation(["common", "team"]);
|
||||
const user = useUser();
|
||||
const isAdmin = useHasRole("ADMIN");
|
||||
const { submit } = useActionSubmit(teamProfilePageActionSchema);
|
||||
const [confirming, setConfirming] = React.useState<"LEAVE" | "DELETE" | null>(
|
||||
null,
|
||||
@@ -166,7 +165,7 @@ function TeamActionsMenu({ team }: { team: TeamLoaderData["team"] }) {
|
||||
const showMainTeamIndicator = isTeamMember({ user, team }) && isMainTeam;
|
||||
const canMakeMainTeam = isTeamMember({ user, team }) && !isMainTeam;
|
||||
const canLeaveTeam = isTeamMember({ user, team }) && team.members.length > 1;
|
||||
const canDeleteTeam = isTeamOwner({ user, team }) || isAdmin;
|
||||
const canDeleteTeam = useHasPermission(team, "DELETE");
|
||||
|
||||
if (
|
||||
!showMainTeamIndicator &&
|
||||
|
||||
@@ -105,12 +105,12 @@ describe("Secondary teams", () => {
|
||||
test("only the team owner (or admin) can delete a team", async () => {
|
||||
const { customUrl } = await createTeamWithRegularMember({ name: "Team 1" });
|
||||
|
||||
const response = await teamPageAction(
|
||||
{ _action: "DELETE_TEAM" },
|
||||
{ user: "regular", params: { customUrl } },
|
||||
);
|
||||
|
||||
assertResponseErrored(response);
|
||||
await expect(
|
||||
teamPageAction(
|
||||
{ _action: "DELETE_TEAM" },
|
||||
{ user: "regular", params: { customUrl } },
|
||||
),
|
||||
).rejects.toThrow("Response thrown with status code: 403");
|
||||
|
||||
expect(await TeamRepository.findByCustomUrl(customUrl)).toBeTruthy();
|
||||
});
|
||||
|
||||
@@ -18,20 +18,6 @@ export function isTeamOwner({
|
||||
return team.members.some((member) => member.isOwner && member.id === user.id);
|
||||
}
|
||||
|
||||
export function isTeamManager({
|
||||
team,
|
||||
user,
|
||||
}: {
|
||||
team: TeamRepository.findByCustomUrl;
|
||||
user?: { id: number };
|
||||
}) {
|
||||
if (!user) return false;
|
||||
|
||||
return team.members.some(
|
||||
(member) => (member.isManager || member.isOwner) && member.id === user.id,
|
||||
);
|
||||
}
|
||||
|
||||
export function isTeamMember({
|
||||
team,
|
||||
user,
|
||||
|
||||
@@ -22,9 +22,7 @@ export const loader = async ({ request }: LoaderFunctionArgs) => {
|
||||
tournamentImportTeamsSearchParams.parse(request).fromTournamentId,
|
||||
);
|
||||
|
||||
const { ctx } = await tournamentDataCached({
|
||||
tournamentId: fromTournamentId,
|
||||
});
|
||||
const { ctx } = await tournamentDataCached(fromTournamentId);
|
||||
requireTournamentVisible({ ctx, user });
|
||||
|
||||
const fromTournamentTeams = await tournamentTeamsFullCached({
|
||||
|
||||
@@ -23,7 +23,6 @@ import { Redirect } from "~/components/Redirect";
|
||||
import { DANGEROUS_CAN_ACCESS_DEV_CONTROLS } from "~/features/admin/core/dev-controls";
|
||||
import { useUser } from "~/features/auth/core/user";
|
||||
import { useTournament } from "~/features/tournament/tournament-context";
|
||||
import { useHasRole } from "~/modules/permissions/hooks";
|
||||
import {
|
||||
calendarEventPage,
|
||||
tournamentAdminPage,
|
||||
@@ -43,7 +42,6 @@ export default function TournamentAdminLayout() {
|
||||
const tournament = useTournament();
|
||||
const outletContext = useOutletContext();
|
||||
const user = useUser();
|
||||
const isTournamentAdder = useHasRole("TOURNAMENT_ADDER");
|
||||
const location = useLocation();
|
||||
|
||||
const showReopen = Boolean(
|
||||
@@ -75,8 +73,7 @@ export default function TournamentAdminLayout() {
|
||||
|
||||
return (
|
||||
<div className={clsx("stack lg", containerClassName("wide"))}>
|
||||
{tournament.canEditEventInfo(user, { isTournamentAdder }) &&
|
||||
!tournament.hasStarted ? (
|
||||
{tournament.canEditEventInfo(user) && !tournament.hasStarted ? (
|
||||
<div className="stack horizontal items-end">
|
||||
<LinkButton
|
||||
to={tournamentEditPage(tournament.ctx.eventId)}
|
||||
|
||||
@@ -89,7 +89,7 @@ export const action = async ({ request, params }: ActionFunctionArgs) => {
|
||||
}
|
||||
|
||||
// ensure RunningTournament = sidebar updates
|
||||
await tournamentFromDB({ tournamentId, user });
|
||||
await tournamentFromDB(tournamentId);
|
||||
|
||||
return successToastWithRedirect({
|
||||
url: tournamentBracketsPage({ tournamentId }),
|
||||
|
||||
@@ -181,7 +181,7 @@ export const action: ActionFunction = async ({ params, request }) => {
|
||||
ShowcaseTournaments.clearCachedTournaments();
|
||||
|
||||
// update RunningTournaments
|
||||
await tournamentFromDB({ tournamentId, user });
|
||||
await tournamentFromDB(tournamentId);
|
||||
|
||||
emitTournamentUpdate = true;
|
||||
|
||||
|
||||
@@ -9,9 +9,9 @@ import {
|
||||
import { clearCombinedStreamsCache } from "~/features/core/streams/streams.server";
|
||||
import * as TournamentRepository from "~/features/tournament/TournamentRepository.server";
|
||||
import * as BracketRepository from "~/features/tournament-bracket/BracketRepository.server";
|
||||
import type { BracketData } from "~/features/tournament-bracket/core/engine/types";
|
||||
import { getTentativeTier } from "~/features/tournament-organization/core/tentativeTiers.server";
|
||||
import { LRUCache } from "~/modules/cache";
|
||||
import { hasPermission } from "~/modules/permissions/utils";
|
||||
import { IN_MILLISECONDS } from "~/utils/cache.server";
|
||||
import {
|
||||
databaseTimestampToDate,
|
||||
@@ -29,26 +29,50 @@ import type { Bracket } from "./Bracket";
|
||||
import { RunningTournaments } from "./RunningTournaments.server";
|
||||
import {
|
||||
type BracketDerivedMeta,
|
||||
isTournamentOrganizer,
|
||||
type OptionalIdObject,
|
||||
type SerializedBracket,
|
||||
Tournament,
|
||||
type TournamentOrganizerCtx,
|
||||
type TournamentStream,
|
||||
} from "./Tournament";
|
||||
|
||||
const combinedTournamentData = async (tournamentId: number) => {
|
||||
/**
|
||||
* Everything a tournament is made of including brackets and streams.
|
||||
*/
|
||||
export async function tournamentData(tournamentId: number) {
|
||||
const ctx = await TournamentRepository.findById(tournamentId);
|
||||
if (!ctx) return null;
|
||||
|
||||
const data = await BracketRepository.findByTournamentId(tournamentId);
|
||||
const tournamentHasStarted = data.stage.length > 0;
|
||||
|
||||
const tentativeTier =
|
||||
!ctx.tier && ctx.organization?.id
|
||||
? getTentativeTier(ctx.organization.id, ctx.name)
|
||||
: null;
|
||||
|
||||
return {
|
||||
data: await BracketRepository.findByTournamentId(tournamentId),
|
||||
ctx,
|
||||
data,
|
||||
participatedUsers:
|
||||
await TournamentRepository.findParticipatedUserIdsById(tournamentId),
|
||||
streams: await fetchTournamentStreams(tournamentId),
|
||||
ctx: {
|
||||
...ctx,
|
||||
tentativeTier,
|
||||
teams: ctx.teams.map(
|
||||
({
|
||||
teamLogoUrl,
|
||||
pickupAvatarUrl,
|
||||
inviteCode: _inviteCode,
|
||||
...team
|
||||
}): TournamentDataTeam => ({
|
||||
...team,
|
||||
logoUrl:
|
||||
teamLogoUrl ?? (tournamentHasStarted ? pickupAvatarUrl : null),
|
||||
}),
|
||||
),
|
||||
},
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Live streams of the tournament read fresh from the database, bypassing the tournament
|
||||
@@ -110,15 +134,19 @@ export type TournamentDataTeam = Omit<
|
||||
TournamentRepository.FindById["teams"][number],
|
||||
"teamLogoUrl" | "pickupAvatarUrl" | "inviteCode"
|
||||
> & {
|
||||
/** Logo of the linked team, falling back to the pickup avatar when it may be revealed. */
|
||||
/**
|
||||
* Logo of the linked team, falling back to the pickup avatar once the tournament has
|
||||
* started. The views that show pickup avatars before that (own team, organizer views)
|
||||
* read them off {@link tournamentTeamsFullCached}, which censors per viewer.
|
||||
*/
|
||||
logoUrl: string | null;
|
||||
/** Only set for the viewer's own team. */
|
||||
inviteCode: string | null;
|
||||
};
|
||||
|
||||
/** The parts of a tournament that decide whether it may be seen at all. */
|
||||
type TournamentVisibilityCtx = TournamentOrganizerCtx &
|
||||
Pick<TournamentData["ctx"], "settings">;
|
||||
type TournamentVisibilityCtx = Pick<
|
||||
TournamentData["ctx"],
|
||||
"permissions" | "settings"
|
||||
>;
|
||||
|
||||
/**
|
||||
* Ensures the tournament may be seen by the given user. Draft tournaments are only visible
|
||||
@@ -138,7 +166,7 @@ export function requireTournamentVisible({
|
||||
user: OptionalIdObject;
|
||||
}) {
|
||||
if (!ctx.settings.isDraft) return;
|
||||
if (isTournamentOrganizer({ ctx, user })) return;
|
||||
if (hasPermission(ctx, "ORGANIZE", user)) return;
|
||||
|
||||
throw new Response(null, { status: 404 });
|
||||
}
|
||||
@@ -164,17 +192,16 @@ export function requireTournamentAdmin(
|
||||
errorToastIfFalsy(tournament.isAdmin(user), "Not a tournament admin");
|
||||
}
|
||||
|
||||
type TournamentFromParamsOptions =
|
||||
| { for: "view"; personalized?: boolean }
|
||||
| { for: "action" | "organizer" | "admin" };
|
||||
type TournamentFromParamsOptions = {
|
||||
for: "view" | "action" | "organizer" | "admin";
|
||||
};
|
||||
|
||||
/**
|
||||
* The shared preamble of `to.$id.*` loaders and actions: parses the tournament id from the
|
||||
* route params (404 on invalid), loads the tournament and runs the access guard.
|
||||
*
|
||||
* - `view`: anyone the tournament is visible to; cached read. With `personalized` the
|
||||
* tournament is censored for the viewer specifically (own team's invite code etc.);
|
||||
* without it every viewer shares one anonymous instance, amortizing bracket building.
|
||||
* - `view`: anyone the tournament is visible to; cached read. The tournament is the same
|
||||
* for every viewer, so one shared instance serves them all, amortizing bracket building.
|
||||
* - `action`: any logged-in user; fresh read from the database for actions that do their
|
||||
* own per `_action` authorization.
|
||||
* - `organizer` / `admin`: like `action` but non-organizers/non-admins are redirected to
|
||||
@@ -182,7 +209,7 @@ type TournamentFromParamsOptions =
|
||||
*/
|
||||
export async function tournamentFromParams(
|
||||
params: Params<string>,
|
||||
opts: { for: "view"; personalized?: boolean },
|
||||
opts: { for: "view" },
|
||||
): Promise<{
|
||||
tournament: Tournament;
|
||||
tournamentId: number;
|
||||
@@ -204,17 +231,14 @@ export async function tournamentFromParams(
|
||||
|
||||
if (opts.for === "view") {
|
||||
const user = getUser();
|
||||
const tournament =
|
||||
opts.personalized && user
|
||||
? await tournamentFromDBCached({ tournamentId, user })
|
||||
: await tournamentSharedCached(tournamentId);
|
||||
const tournament = await tournamentSharedCached(tournamentId);
|
||||
requireTournamentVisible({ ctx: tournament.ctx, user });
|
||||
|
||||
return { tournament, tournamentId, user };
|
||||
}
|
||||
|
||||
const user = requireUser();
|
||||
const tournament = await tournamentFromDB({ tournamentId, user });
|
||||
const tournament = await tournamentFromDB(tournamentId);
|
||||
requireTournamentVisible({ ctx: tournament.ctx, user });
|
||||
|
||||
const isAuthorized =
|
||||
@@ -229,85 +253,8 @@ export async function tournamentFromParams(
|
||||
return { tournament, tournamentId, user };
|
||||
}
|
||||
|
||||
export async function tournamentData({
|
||||
user,
|
||||
tournamentId,
|
||||
}: {
|
||||
user?: { id: number };
|
||||
tournamentId: number;
|
||||
}) {
|
||||
const data = await combinedTournamentData(tournamentId);
|
||||
if (!data) return null;
|
||||
|
||||
return dataMapped({ user, ...data });
|
||||
}
|
||||
|
||||
function dataMapped({
|
||||
data,
|
||||
ctx,
|
||||
participatedUsers,
|
||||
streams,
|
||||
user,
|
||||
}: {
|
||||
data: BracketData;
|
||||
ctx: TournamentRepository.FindById;
|
||||
participatedUsers: number[];
|
||||
streams: TournamentStream[];
|
||||
user?: { id: number };
|
||||
}) {
|
||||
const revealInfo = shouldRevealInfo({
|
||||
tournamentHasStarted: data.stage.length > 0,
|
||||
ctx,
|
||||
user,
|
||||
});
|
||||
|
||||
const tentativeTier =
|
||||
!ctx.tier && ctx.organization?.id
|
||||
? getTentativeTier(ctx.organization.id, ctx.name)
|
||||
: null;
|
||||
|
||||
return {
|
||||
data,
|
||||
participatedUsers,
|
||||
streams,
|
||||
ctx: {
|
||||
...ctx,
|
||||
tentativeTier,
|
||||
teams: ctx.teams.map(
|
||||
({ teamLogoUrl, pickupAvatarUrl, ...team }): TournamentDataTeam => {
|
||||
const isOwnTeam =
|
||||
typeof user?.id === "number" &&
|
||||
team.memberUserIds.includes(user.id);
|
||||
|
||||
return {
|
||||
...team,
|
||||
inviteCode: isOwnTeam ? team.inviteCode : null,
|
||||
logoUrl:
|
||||
teamLogoUrl ?? (revealInfo || isOwnTeam ? pickupAvatarUrl : null),
|
||||
};
|
||||
},
|
||||
),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function shouldRevealInfo({
|
||||
tournamentHasStarted,
|
||||
ctx,
|
||||
user,
|
||||
}: {
|
||||
tournamentHasStarted: boolean;
|
||||
ctx: TournamentOrganizerCtx;
|
||||
user?: { id: number };
|
||||
}) {
|
||||
return tournamentHasStarted || isTournamentOrganizer({ ctx, user });
|
||||
}
|
||||
|
||||
export async function tournamentFromDB(args: {
|
||||
user: { id: number } | undefined;
|
||||
tournamentId: number;
|
||||
}) {
|
||||
const data = notFoundIfNullish(await tournamentData(args));
|
||||
export async function tournamentFromDB(tournamentId: number) {
|
||||
const data = notFoundIfNullish(await tournamentData(tournamentId));
|
||||
|
||||
const tournament = new Tournament(data);
|
||||
syncTournamentToRegistry(tournament);
|
||||
@@ -315,15 +262,6 @@ export async function tournamentFromDB(args: {
|
||||
return tournament;
|
||||
}
|
||||
|
||||
export async function tournamentFromDBCached(args: {
|
||||
user: { id: number } | undefined;
|
||||
tournamentId: number;
|
||||
}) {
|
||||
const data = notFoundIfNullish(await tournamentDataCached(args));
|
||||
|
||||
return new Tournament(data);
|
||||
}
|
||||
|
||||
const TOURNAMENT_DATA_CACHE_MAX_ENTRIES = 250;
|
||||
const TOURNAMENT_DATA_CACHE_TTL_MS = IN_MILLISECONDS.HALF_HOUR;
|
||||
|
||||
@@ -331,39 +269,22 @@ type TournamentDataCacheEntry = {
|
||||
storedAt: number;
|
||||
// caching promise ensures that if many requests are made for the same tournament
|
||||
// at the same time they reuse the same resolving promise
|
||||
combined: ReturnType<typeof combinedTournamentData>;
|
||||
// the vast majority of viewers are logged out and get the exact same censoring applied
|
||||
anonymousMapped?: ReturnType<typeof dataMapped>;
|
||||
// brackets are expensive to build (preview brackets are generated from scratch) and what
|
||||
// they derive from is the same for every viewer, so one instance serves them all
|
||||
anonymousTournament?: Tournament;
|
||||
data: ReturnType<typeof tournamentData>;
|
||||
// what the brackets derive from is the same for every viewer, so building them once
|
||||
// per cache fill serves every request
|
||||
tournament?: Tournament;
|
||||
};
|
||||
|
||||
const tournamentDataCache = new LRUCache<number, TournamentDataCacheEntry>({
|
||||
max: TOURNAMENT_DATA_CACHE_MAX_ENTRIES,
|
||||
});
|
||||
|
||||
export async function tournamentDataCached({
|
||||
user,
|
||||
tournamentId,
|
||||
}: {
|
||||
user?: { id: number };
|
||||
tournamentId: number;
|
||||
}) {
|
||||
export async function tournamentDataCached(tournamentId: number) {
|
||||
if (ServerConfig.disableCache) {
|
||||
return notFoundIfNullish(await tournamentData({ user, tournamentId }));
|
||||
return notFoundIfNullish(await tournamentData(tournamentId));
|
||||
}
|
||||
|
||||
const entry = tournamentDataCacheEntry(tournamentId);
|
||||
const data = notFoundIfNullish(await entry.combined);
|
||||
|
||||
if (user) return dataMapped({ user, ...data });
|
||||
|
||||
if (!entry.anonymousMapped) {
|
||||
entry.anonymousMapped = dataMapped({ user: undefined, ...data });
|
||||
}
|
||||
|
||||
return entry.anonymousMapped;
|
||||
return notFoundIfNullish(await tournamentDataCacheEntry(tournamentId).data);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -371,24 +292,20 @@ export async function tournamentDataCached({
|
||||
* level derivations (bracket state, standings, one bracket's data) are the same for every
|
||||
* viewer, so building the brackets happens once per cache fill instead of once per request.
|
||||
*/
|
||||
async function tournamentSharedCached(tournamentId: number) {
|
||||
export async function tournamentSharedCached(tournamentId: number) {
|
||||
if (ServerConfig.disableCache) {
|
||||
return new Tournament(
|
||||
notFoundIfNullish(await tournamentData({ tournamentId })),
|
||||
notFoundIfNullish(await tournamentData(tournamentId)),
|
||||
);
|
||||
}
|
||||
|
||||
const entry = tournamentDataCacheEntry(tournamentId);
|
||||
const data = notFoundIfNullish(await entry.combined);
|
||||
|
||||
if (!entry.anonymousMapped) {
|
||||
entry.anonymousMapped = dataMapped({ user: undefined, ...data });
|
||||
}
|
||||
if (!entry.anonymousTournament) {
|
||||
entry.anonymousTournament = new Tournament(entry.anonymousMapped);
|
||||
if (!entry.tournament) {
|
||||
entry.tournament = new Tournament(notFoundIfNullish(await entry.data));
|
||||
}
|
||||
|
||||
return entry.anonymousTournament;
|
||||
return entry.tournament;
|
||||
}
|
||||
|
||||
/** State of every bracket of the tournament, without any of the match data it derives from. */
|
||||
@@ -427,7 +344,10 @@ export function serializeBracket(
|
||||
};
|
||||
}
|
||||
|
||||
function groupsData(data: BracketData, groupId: number): BracketData {
|
||||
function groupsData(
|
||||
data: SerializedBracket["data"],
|
||||
groupId: number,
|
||||
): SerializedBracket["data"] {
|
||||
return {
|
||||
...data,
|
||||
round: data.round.filter((round) => round.groupId === groupId),
|
||||
@@ -443,9 +363,9 @@ function tournamentDataCacheEntry(tournamentId: number) {
|
||||
|
||||
const entry: TournamentDataCacheEntry = {
|
||||
storedAt: Date.now(),
|
||||
combined: combinedTournamentData(tournamentId),
|
||||
data: tournamentData(tournamentId),
|
||||
};
|
||||
entry.combined.catch(() => {
|
||||
entry.data.catch(() => {
|
||||
if (tournamentDataCache.get(tournamentId) === entry) {
|
||||
tournamentDataCache.delete(tournamentId);
|
||||
}
|
||||
@@ -480,13 +400,11 @@ export async function tournamentTeamsFullCached({
|
||||
user?: { id: number };
|
||||
tournamentId: number;
|
||||
}) {
|
||||
const ctx = notFoundIfNullish(await tournamentDataCached({ tournamentId }));
|
||||
const ctx = notFoundIfNullish(await tournamentDataCached(tournamentId));
|
||||
|
||||
const revealInfo = shouldRevealInfo({
|
||||
tournamentHasStarted: ctx.data.stage.length > 0,
|
||||
ctx: ctx.ctx,
|
||||
user,
|
||||
});
|
||||
// pickup avatars and map pools are only revealed to organizers before the start
|
||||
const revealInfo =
|
||||
ctx.data.stage.length > 0 || hasPermission(ctx.ctx, "ORGANIZE", user);
|
||||
|
||||
if (ServerConfig.disableCache) {
|
||||
return censoredTeams({
|
||||
@@ -653,7 +571,7 @@ async function primeRunningTournamentsCache() {
|
||||
const tournamentIds = await TournamentRepository.findRunningTournamentIds();
|
||||
|
||||
for (const tournamentId of tournamentIds) {
|
||||
const data = await tournamentData({ user: undefined, tournamentId });
|
||||
const data = await tournamentData(tournamentId);
|
||||
if (!data) continue;
|
||||
|
||||
const tournament = new Tournament(data);
|
||||
|
||||
@@ -14,7 +14,7 @@ import {
|
||||
import type { MatchData } from "~/features/tournament-bracket/core/engine/types";
|
||||
import * as Progression from "~/features/tournament-bracket/core/Progression";
|
||||
import type { ModeShort } from "~/modules/in-game-lists/types";
|
||||
import { isAdmin } from "~/modules/permissions/utils";
|
||||
import { hasPermission } from "~/modules/permissions/utils";
|
||||
import {
|
||||
databaseTimestampToDate,
|
||||
dateToDatabaseTimestamp,
|
||||
@@ -1380,89 +1380,27 @@ export class Tournament {
|
||||
|
||||
/** Checks if the given user is an admin of the tournament. */
|
||||
isAdmin(user: OptionalIdObject) {
|
||||
if (!user) return false;
|
||||
if (isAdmin(user)) return true;
|
||||
|
||||
if (
|
||||
this.ctx.organization?.members.some(
|
||||
(member) => member.userId === user.id && member.role === "ADMIN",
|
||||
)
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return this.ctx.author.id === user.id;
|
||||
return hasPermission(this.ctx, "ADMIN", user);
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if the given user can edit the tournament's calendar event info.
|
||||
*
|
||||
* Mirrors the authorization enforced when the edit is submitted: organization
|
||||
* admins can only edit when the organization is established, unless they have
|
||||
* the TOURNAMENT_ADDER role.
|
||||
*/
|
||||
canEditEventInfo(
|
||||
user: OptionalIdObject,
|
||||
{ isTournamentAdder }: { isTournamentAdder: boolean },
|
||||
) {
|
||||
if (!user) return false;
|
||||
if (isAdmin(user)) return true;
|
||||
if (this.ctx.author.id === user.id) return true;
|
||||
|
||||
const isOrganizationAdmin = this.ctx.organization?.members.some(
|
||||
(member) => member.userId === user.id && member.role === "ADMIN",
|
||||
);
|
||||
|
||||
return Boolean(
|
||||
isOrganizationAdmin &&
|
||||
(isTournamentAdder || this.ctx.organization?.isEstablished),
|
||||
);
|
||||
/** Checks if the given user can edit the tournament's calendar event info. */
|
||||
canEditEventInfo(user: OptionalIdObject) {
|
||||
return hasPermission(this.ctx, "EDIT_EVENT_INFO", user);
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if the given user can set the tournament names of the tournament's players.
|
||||
*
|
||||
* Restricted to members of an established organization because the name they set is
|
||||
* shown in every tournament from then on, not only in this one.
|
||||
*/
|
||||
/** Checks if the given user can set the in-game names of the tournament's players. */
|
||||
canEditTournamentNames(user: OptionalIdObject) {
|
||||
if (!user) return false;
|
||||
if (isAdmin(user)) return true;
|
||||
if (!this.ctx.organization?.isEstablished) return false;
|
||||
|
||||
return this.ctx.organization.members.some(
|
||||
(member) =>
|
||||
member.userId === user.id &&
|
||||
["ADMIN", "ORGANIZER"].includes(member.role),
|
||||
);
|
||||
return hasPermission(this.ctx, "EDIT_IN_GAME_NAMES", user);
|
||||
}
|
||||
|
||||
/** Checks if the given user is an organizer of the tournament. */
|
||||
isOrganizer(user: OptionalIdObject) {
|
||||
return isTournamentOrganizer({ ctx: this.ctx, user });
|
||||
return hasPermission(this.ctx, "ORGANIZE", user);
|
||||
}
|
||||
|
||||
/** Checks if the given user is an organizer or streamer of the tournament. */
|
||||
isOrganizerOrStreamer(user: OptionalIdObject) {
|
||||
if (!user) return false;
|
||||
if (isAdmin(user)) return true;
|
||||
|
||||
if (this.ctx.author.id === user.id) return true;
|
||||
|
||||
if (
|
||||
this.ctx.organization?.members.some(
|
||||
(member) =>
|
||||
member.userId === user.id &&
|
||||
["ADMIN", "ORGANIZER", "STREAMER"].includes(member.role),
|
||||
)
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return this.ctx.staff.some(
|
||||
(staff) =>
|
||||
staff.id === user.id && ["ORGANIZER", "STREAMER"].includes(staff.role),
|
||||
);
|
||||
return hasPermission(this.ctx, "MANAGE_MATCHES", user);
|
||||
}
|
||||
|
||||
/** Live streams of the tournament, empty in the views whose loader did not ship them. */
|
||||
@@ -1517,40 +1455,3 @@ function swissTeamRecord(matches: MatchData[], teamId: number) {
|
||||
|
||||
return { wins, losses };
|
||||
}
|
||||
|
||||
/** The parts of a tournament that decide who organizes it. */
|
||||
export type TournamentOrganizerCtx = Pick<
|
||||
TournamentData["ctx"],
|
||||
"author" | "staff" | "organization"
|
||||
>;
|
||||
|
||||
/**
|
||||
* Checks if the given user is an organizer of the tournament, off its context alone.
|
||||
* {@link Tournament.isOrganizer} is the same check for when a `Tournament` is at hand.
|
||||
*/
|
||||
export function isTournamentOrganizer({
|
||||
ctx,
|
||||
user,
|
||||
}: {
|
||||
ctx: TournamentOrganizerCtx;
|
||||
user: OptionalIdObject;
|
||||
}) {
|
||||
if (!user) return false;
|
||||
if (isAdmin(user)) return true;
|
||||
|
||||
if (ctx.author.id === user.id) return true;
|
||||
|
||||
if (
|
||||
ctx.organization?.members.some(
|
||||
(member) =>
|
||||
member.userId === user.id &&
|
||||
["ADMIN", "ORGANIZER"].includes(member.role),
|
||||
)
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return ctx.staff.some(
|
||||
(staff) => staff.id === user.id && staff.role === "ORGANIZER",
|
||||
);
|
||||
}
|
||||
|
||||
@@ -6481,6 +6481,13 @@ export const LOW_INK_DECEMBER_2024 = (): TournamentData => ({
|
||||
customUrl: "hoeenhero",
|
||||
customAvatarUrl: null,
|
||||
},
|
||||
permissions: {
|
||||
ADMIN: [4941],
|
||||
ORGANIZE: [4941],
|
||||
MANAGE_MATCHES: [4941],
|
||||
EDIT_EVENT_INFO: [4941],
|
||||
EDIT_IN_GAME_NAMES: [],
|
||||
},
|
||||
staff: [
|
||||
{
|
||||
id: 405,
|
||||
@@ -6656,7 +6663,6 @@ export const LOW_INK_DECEMBER_2024 = (): TournamentData => ({
|
||||
abDivision: null,
|
||||
createdAt: 1733157607,
|
||||
activeRosterUserIds: [25875, 21063, 11226, 31597],
|
||||
inviteCode: null,
|
||||
memberUserIds: [11226, 27529, 25875, 21063, 31597],
|
||||
ownerUserId: 11226,
|
||||
checkIns: [
|
||||
@@ -6680,7 +6686,6 @@ export const LOW_INK_DECEMBER_2024 = (): TournamentData => ({
|
||||
abDivision: null,
|
||||
createdAt: 1733157629,
|
||||
activeRosterUserIds: [14837, 27260, 42704, 9379],
|
||||
inviteCode: null,
|
||||
memberUserIds: [14837, 27260, 42704, 6211, 9379],
|
||||
ownerUserId: 14837,
|
||||
checkIns: [
|
||||
@@ -6704,7 +6709,6 @@ export const LOW_INK_DECEMBER_2024 = (): TournamentData => ({
|
||||
abDivision: null,
|
||||
createdAt: 1733161494,
|
||||
activeRosterUserIds: [34424, 31195, 31395, 26103],
|
||||
inviteCode: null,
|
||||
memberUserIds: [34424, 31195, 31395, 41682, 26103],
|
||||
ownerUserId: 34424,
|
||||
checkIns: [
|
||||
@@ -6728,7 +6732,6 @@ export const LOW_INK_DECEMBER_2024 = (): TournamentData => ({
|
||||
abDivision: null,
|
||||
createdAt: 1733166918,
|
||||
activeRosterUserIds: null,
|
||||
inviteCode: null,
|
||||
memberUserIds: [20292, 44033, 19717, 9404],
|
||||
ownerUserId: 20292,
|
||||
checkIns: [
|
||||
@@ -6752,7 +6755,6 @@ export const LOW_INK_DECEMBER_2024 = (): TournamentData => ({
|
||||
abDivision: null,
|
||||
createdAt: 1733166213,
|
||||
activeRosterUserIds: [32160, 29267, 25591, 36962],
|
||||
inviteCode: null,
|
||||
memberUserIds: [32160, 29267, 25591, 36962, 37749],
|
||||
ownerUserId: 32160,
|
||||
checkIns: [
|
||||
@@ -6776,7 +6778,6 @@ export const LOW_INK_DECEMBER_2024 = (): TournamentData => ({
|
||||
abDivision: null,
|
||||
createdAt: 1733189945,
|
||||
activeRosterUserIds: [12418, 34355, 2319, 7430],
|
||||
inviteCode: null,
|
||||
memberUserIds: [12418, 34355, 2319, 39480, 7430],
|
||||
ownerUserId: 12418,
|
||||
checkIns: [
|
||||
@@ -6800,7 +6801,6 @@ export const LOW_INK_DECEMBER_2024 = (): TournamentData => ({
|
||||
abDivision: null,
|
||||
createdAt: 1733244862,
|
||||
activeRosterUserIds: [29425, 31524, 35674, 26285],
|
||||
inviteCode: null,
|
||||
memberUserIds: [29425, 31524, 35674, 7126, 26285],
|
||||
ownerUserId: 29425,
|
||||
checkIns: [
|
||||
@@ -6824,7 +6824,6 @@ export const LOW_INK_DECEMBER_2024 = (): TournamentData => ({
|
||||
abDivision: null,
|
||||
createdAt: 1733282085,
|
||||
activeRosterUserIds: [26747, 27292, 5708, 6309],
|
||||
inviteCode: null,
|
||||
memberUserIds: [26747, 5708, 1661, 27292, 21588, 6309],
|
||||
ownerUserId: 26747,
|
||||
checkIns: [
|
||||
@@ -6848,7 +6847,6 @@ export const LOW_INK_DECEMBER_2024 = (): TournamentData => ({
|
||||
abDivision: null,
|
||||
createdAt: 1733291438,
|
||||
activeRosterUserIds: [24459, 40851, 23974, 43608],
|
||||
inviteCode: null,
|
||||
memberUserIds: [43608, 23974, 24459, 40851, 18090, 42350],
|
||||
ownerUserId: 43608,
|
||||
checkIns: [
|
||||
@@ -6872,7 +6870,6 @@ export const LOW_INK_DECEMBER_2024 = (): TournamentData => ({
|
||||
abDivision: null,
|
||||
createdAt: 1733439755,
|
||||
activeRosterUserIds: null,
|
||||
inviteCode: null,
|
||||
memberUserIds: [11085, 37000, 37835, 26807],
|
||||
ownerUserId: 11085,
|
||||
checkIns: [
|
||||
@@ -6896,7 +6893,6 @@ export const LOW_INK_DECEMBER_2024 = (): TournamentData => ({
|
||||
abDivision: null,
|
||||
createdAt: 1733485884,
|
||||
activeRosterUserIds: [30686, 1961, 30685, 22396],
|
||||
inviteCode: null,
|
||||
memberUserIds: [30685, 30686, 22396, 1961, 46305, 18698],
|
||||
ownerUserId: 30685,
|
||||
checkIns: [
|
||||
@@ -6920,7 +6916,6 @@ export const LOW_INK_DECEMBER_2024 = (): TournamentData => ({
|
||||
abDivision: null,
|
||||
createdAt: 1733937993,
|
||||
activeRosterUserIds: [12434, 30263, 5861, 24275],
|
||||
inviteCode: null,
|
||||
memberUserIds: [12434, 30411, 24275, 30263, 5861, 30870],
|
||||
ownerUserId: 12434,
|
||||
checkIns: [
|
||||
@@ -6944,7 +6939,6 @@ export const LOW_INK_DECEMBER_2024 = (): TournamentData => ({
|
||||
abDivision: null,
|
||||
createdAt: 1733166818,
|
||||
activeRosterUserIds: [32670, 38046, 42638, 34589],
|
||||
inviteCode: null,
|
||||
memberUserIds: [32670, 38046, 42638, 34589, 44378],
|
||||
ownerUserId: 32670,
|
||||
checkIns: [
|
||||
@@ -6968,7 +6962,6 @@ export const LOW_INK_DECEMBER_2024 = (): TournamentData => ({
|
||||
abDivision: null,
|
||||
createdAt: 1733167616,
|
||||
activeRosterUserIds: [45102, 26711, 41739, 4533],
|
||||
inviteCode: null,
|
||||
memberUserIds: [4533, 45102, 3362, 41739, 26711],
|
||||
ownerUserId: 4533,
|
||||
checkIns: [
|
||||
@@ -6992,7 +6985,6 @@ export const LOW_INK_DECEMBER_2024 = (): TournamentData => ({
|
||||
abDivision: null,
|
||||
createdAt: 1733201503,
|
||||
activeRosterUserIds: [20807, 31556, 33373, 42703],
|
||||
inviteCode: null,
|
||||
memberUserIds: [20807, 35282, 20774, 33373, 42703, 31556],
|
||||
ownerUserId: 20807,
|
||||
checkIns: [
|
||||
@@ -7016,7 +7008,6 @@ export const LOW_INK_DECEMBER_2024 = (): TournamentData => ({
|
||||
abDivision: null,
|
||||
createdAt: 1733218069,
|
||||
activeRosterUserIds: [26509, 7959, 7690, 7958],
|
||||
inviteCode: null,
|
||||
memberUserIds: [7958, 7690, 7959, 26509, 7102],
|
||||
ownerUserId: 7958,
|
||||
checkIns: [
|
||||
@@ -7040,7 +7031,6 @@ export const LOW_INK_DECEMBER_2024 = (): TournamentData => ({
|
||||
abDivision: null,
|
||||
createdAt: 1733319202,
|
||||
activeRosterUserIds: [10714, 21685, 8840, 10028],
|
||||
inviteCode: null,
|
||||
memberUserIds: [4285, 21685, 34842, 10714, 10028, 8840],
|
||||
ownerUserId: 4285,
|
||||
checkIns: [
|
||||
@@ -7064,7 +7054,6 @@ export const LOW_INK_DECEMBER_2024 = (): TournamentData => ({
|
||||
abDivision: null,
|
||||
createdAt: 1733471556,
|
||||
activeRosterUserIds: [17532, 30204, 36007, 38896],
|
||||
inviteCode: null,
|
||||
memberUserIds: [17532, 36007, 38896, 30204, 41285],
|
||||
ownerUserId: 17532,
|
||||
checkIns: [
|
||||
@@ -7088,7 +7077,6 @@ export const LOW_INK_DECEMBER_2024 = (): TournamentData => ({
|
||||
abDivision: null,
|
||||
createdAt: 1733501938,
|
||||
activeRosterUserIds: [30495, 43073, 30488, 45295],
|
||||
inviteCode: null,
|
||||
memberUserIds: [28410, 43073, 30495, 30488, 45295],
|
||||
ownerUserId: 30488,
|
||||
checkIns: [
|
||||
@@ -7112,7 +7100,6 @@ export const LOW_INK_DECEMBER_2024 = (): TournamentData => ({
|
||||
abDivision: null,
|
||||
createdAt: 1733622364,
|
||||
activeRosterUserIds: null,
|
||||
inviteCode: null,
|
||||
memberUserIds: [5584, 30612, 13671, 36898],
|
||||
ownerUserId: 5584,
|
||||
checkIns: [],
|
||||
@@ -7130,7 +7117,6 @@ export const LOW_INK_DECEMBER_2024 = (): TournamentData => ({
|
||||
abDivision: null,
|
||||
createdAt: 1733635706,
|
||||
activeRosterUserIds: null,
|
||||
inviteCode: null,
|
||||
memberUserIds: [2279, 30466, 26162, 24013],
|
||||
ownerUserId: 2279,
|
||||
checkIns: [
|
||||
@@ -7154,7 +7140,6 @@ export const LOW_INK_DECEMBER_2024 = (): TournamentData => ({
|
||||
abDivision: null,
|
||||
createdAt: 1733671856,
|
||||
activeRosterUserIds: null,
|
||||
inviteCode: null,
|
||||
memberUserIds: [33632, 29433, 21181, 32002],
|
||||
ownerUserId: 33632,
|
||||
checkIns: [
|
||||
@@ -7178,7 +7163,6 @@ export const LOW_INK_DECEMBER_2024 = (): TournamentData => ({
|
||||
abDivision: null,
|
||||
createdAt: 1733810204,
|
||||
activeRosterUserIds: [1959, 17352, 33954, 22403],
|
||||
inviteCode: null,
|
||||
memberUserIds: [1959, 5906, 17352, 22403, 33954],
|
||||
ownerUserId: 1959,
|
||||
checkIns: [
|
||||
@@ -7202,7 +7186,6 @@ export const LOW_INK_DECEMBER_2024 = (): TournamentData => ({
|
||||
abDivision: null,
|
||||
createdAt: 1733889961,
|
||||
activeRosterUserIds: [6696, 32107, 33402, 30619],
|
||||
inviteCode: null,
|
||||
memberUserIds: [6696, 32107, 33402, 30619, 35133],
|
||||
ownerUserId: 6696,
|
||||
checkIns: [
|
||||
@@ -7226,7 +7209,6 @@ export const LOW_INK_DECEMBER_2024 = (): TournamentData => ({
|
||||
abDivision: null,
|
||||
createdAt: 1733892132,
|
||||
activeRosterUserIds: [21670, 8993, 8395, 3566],
|
||||
inviteCode: null,
|
||||
memberUserIds: [21670, 8993, 8395, 3566, 46637],
|
||||
ownerUserId: 21670,
|
||||
checkIns: [
|
||||
@@ -7250,7 +7232,6 @@ export const LOW_INK_DECEMBER_2024 = (): TournamentData => ({
|
||||
abDivision: null,
|
||||
createdAt: 1734035170,
|
||||
activeRosterUserIds: [24510, 10670, 22577, 31143],
|
||||
inviteCode: null,
|
||||
memberUserIds: [24510, 31143, 10670, 5261, 22577],
|
||||
ownerUserId: 24510,
|
||||
checkIns: [
|
||||
@@ -7274,7 +7255,6 @@ export const LOW_INK_DECEMBER_2024 = (): TournamentData => ({
|
||||
abDivision: null,
|
||||
createdAt: 1734107844,
|
||||
activeRosterUserIds: [28170, 14309, 17310, 23164],
|
||||
inviteCode: null,
|
||||
memberUserIds: [28170, 14309, 23164, 17310, 2199],
|
||||
ownerUserId: 28170,
|
||||
checkIns: [
|
||||
@@ -7298,7 +7278,6 @@ export const LOW_INK_DECEMBER_2024 = (): TournamentData => ({
|
||||
abDivision: null,
|
||||
createdAt: 1734132225,
|
||||
activeRosterUserIds: null,
|
||||
inviteCode: null,
|
||||
memberUserIds: [11117, 16387, 31154, 6051],
|
||||
ownerUserId: 11117,
|
||||
checkIns: [
|
||||
@@ -7322,7 +7301,6 @@ export const LOW_INK_DECEMBER_2024 = (): TournamentData => ({
|
||||
abDivision: null,
|
||||
createdAt: 1733194304,
|
||||
activeRosterUserIds: [40505, 29011, 23082, 45036],
|
||||
inviteCode: null,
|
||||
memberUserIds: [40505, 29011, 23082, 21549, 45036],
|
||||
ownerUserId: 40505,
|
||||
checkIns: [
|
||||
@@ -7346,7 +7324,6 @@ export const LOW_INK_DECEMBER_2024 = (): TournamentData => ({
|
||||
abDivision: null,
|
||||
createdAt: 1733195091,
|
||||
activeRosterUserIds: null,
|
||||
inviteCode: null,
|
||||
memberUserIds: [30122, 26820, 3513, 10297],
|
||||
ownerUserId: 30122,
|
||||
checkIns: [
|
||||
@@ -7370,7 +7347,6 @@ export const LOW_INK_DECEMBER_2024 = (): TournamentData => ({
|
||||
abDivision: null,
|
||||
createdAt: 1733364647,
|
||||
activeRosterUserIds: [22801, 31150, 35354, 27747],
|
||||
inviteCode: null,
|
||||
memberUserIds: [22801, 8953, 27747, 31150, 35354],
|
||||
ownerUserId: 22801,
|
||||
checkIns: [
|
||||
@@ -7394,7 +7370,6 @@ export const LOW_INK_DECEMBER_2024 = (): TournamentData => ({
|
||||
abDivision: null,
|
||||
createdAt: 1733374295,
|
||||
activeRosterUserIds: null,
|
||||
inviteCode: null,
|
||||
memberUserIds: [26441, 38686, 12005, 31295],
|
||||
ownerUserId: 12005,
|
||||
checkIns: [
|
||||
@@ -7418,7 +7393,6 @@ export const LOW_INK_DECEMBER_2024 = (): TournamentData => ({
|
||||
abDivision: null,
|
||||
createdAt: 1733433864,
|
||||
activeRosterUserIds: null,
|
||||
inviteCode: null,
|
||||
memberUserIds: [18574, 40636, 44489, 34314, 28247, 41628],
|
||||
ownerUserId: 18574,
|
||||
checkIns: [],
|
||||
@@ -7436,7 +7410,6 @@ export const LOW_INK_DECEMBER_2024 = (): TournamentData => ({
|
||||
abDivision: null,
|
||||
createdAt: 1733513814,
|
||||
activeRosterUserIds: null,
|
||||
inviteCode: null,
|
||||
memberUserIds: [5037, 26321, 22324, 11088],
|
||||
ownerUserId: 5037,
|
||||
checkIns: [
|
||||
@@ -7460,7 +7433,6 @@ export const LOW_INK_DECEMBER_2024 = (): TournamentData => ({
|
||||
abDivision: null,
|
||||
createdAt: 1733602400,
|
||||
activeRosterUserIds: [10826, 4248, 20419, 11180],
|
||||
inviteCode: null,
|
||||
memberUserIds: [11180, 20419, 4248, 10826, 28504],
|
||||
ownerUserId: 11180,
|
||||
checkIns: [
|
||||
@@ -7484,7 +7456,6 @@ export const LOW_INK_DECEMBER_2024 = (): TournamentData => ({
|
||||
abDivision: null,
|
||||
createdAt: 1733753214,
|
||||
activeRosterUserIds: [27903, 28446, 34634, 30728],
|
||||
inviteCode: null,
|
||||
memberUserIds: [27903, 28446, 30728, 32158, 34634],
|
||||
ownerUserId: 27903,
|
||||
checkIns: [
|
||||
@@ -7508,7 +7479,6 @@ export const LOW_INK_DECEMBER_2024 = (): TournamentData => ({
|
||||
abDivision: null,
|
||||
createdAt: 1733914001,
|
||||
activeRosterUserIds: [32909, 10190, 35922, 40304],
|
||||
inviteCode: null,
|
||||
memberUserIds: [32909, 31189, 10190, 37959, 40304, 35922],
|
||||
ownerUserId: 32909,
|
||||
checkIns: [
|
||||
@@ -7532,7 +7502,6 @@ export const LOW_INK_DECEMBER_2024 = (): TournamentData => ({
|
||||
abDivision: null,
|
||||
createdAt: 1733966548,
|
||||
activeRosterUserIds: [35617, 37669, 37436, 35811],
|
||||
inviteCode: null,
|
||||
memberUserIds: [37246, 35617, 37669, 37436, 35811],
|
||||
ownerUserId: 37669,
|
||||
checkIns: [
|
||||
@@ -7556,7 +7525,6 @@ export const LOW_INK_DECEMBER_2024 = (): TournamentData => ({
|
||||
abDivision: null,
|
||||
createdAt: 1734032213,
|
||||
activeRosterUserIds: null,
|
||||
inviteCode: null,
|
||||
memberUserIds: [2990, 1925, 11391, 27355],
|
||||
ownerUserId: 2990,
|
||||
checkIns: [
|
||||
@@ -7580,7 +7548,6 @@ export const LOW_INK_DECEMBER_2024 = (): TournamentData => ({
|
||||
abDivision: null,
|
||||
createdAt: 1734106606,
|
||||
activeRosterUserIds: [37173, 43269, 43623, 16054],
|
||||
inviteCode: null,
|
||||
memberUserIds: [43623, 43269, 37173, 34448, 16054],
|
||||
ownerUserId: 43623,
|
||||
checkIns: [
|
||||
@@ -7604,7 +7571,6 @@ export const LOW_INK_DECEMBER_2024 = (): TournamentData => ({
|
||||
abDivision: null,
|
||||
createdAt: 1734116765,
|
||||
activeRosterUserIds: [25312, 10378, 46771, 26044],
|
||||
inviteCode: null,
|
||||
memberUserIds: [25312, 10378, 46771, 5350, 12609, 26044],
|
||||
ownerUserId: 25312,
|
||||
checkIns: [
|
||||
@@ -7628,7 +7594,6 @@ export const LOW_INK_DECEMBER_2024 = (): TournamentData => ({
|
||||
abDivision: null,
|
||||
createdAt: 1734125312,
|
||||
activeRosterUserIds: null,
|
||||
inviteCode: null,
|
||||
memberUserIds: [25806, 43060, 22622, 22968],
|
||||
ownerUserId: 25806,
|
||||
checkIns: [
|
||||
@@ -7652,7 +7617,6 @@ export const LOW_INK_DECEMBER_2024 = (): TournamentData => ({
|
||||
abDivision: null,
|
||||
createdAt: 1734134382,
|
||||
activeRosterUserIds: [26758, 25689, 42164, 44475],
|
||||
inviteCode: null,
|
||||
memberUserIds: [44475, 42164, 25689, 43524, 26758],
|
||||
ownerUserId: 44475,
|
||||
checkIns: [
|
||||
@@ -7676,7 +7640,6 @@ export const LOW_INK_DECEMBER_2024 = (): TournamentData => ({
|
||||
abDivision: null,
|
||||
createdAt: 1733156802,
|
||||
activeRosterUserIds: [9036, 7434, 3738, 9112],
|
||||
inviteCode: null,
|
||||
memberUserIds: [3742, 9112, 3738, 7434, 9036],
|
||||
ownerUserId: 9036,
|
||||
checkIns: [
|
||||
@@ -7700,7 +7663,6 @@ export const LOW_INK_DECEMBER_2024 = (): TournamentData => ({
|
||||
abDivision: null,
|
||||
createdAt: 1733157391,
|
||||
activeRosterUserIds: [5935, 38204, 3741, 8080],
|
||||
inviteCode: null,
|
||||
memberUserIds: [5935, 38204, 35506, 3741, 31728, 8080],
|
||||
ownerUserId: 5935,
|
||||
checkIns: [
|
||||
@@ -7724,7 +7686,6 @@ export const LOW_INK_DECEMBER_2024 = (): TournamentData => ({
|
||||
abDivision: null,
|
||||
createdAt: 1733162274,
|
||||
activeRosterUserIds: null,
|
||||
inviteCode: null,
|
||||
memberUserIds: [40972, 42975, 20319, 28054],
|
||||
ownerUserId: 40972,
|
||||
checkIns: [
|
||||
@@ -7748,7 +7709,6 @@ export const LOW_INK_DECEMBER_2024 = (): TournamentData => ({
|
||||
abDivision: null,
|
||||
createdAt: 1733367806,
|
||||
activeRosterUserIds: null,
|
||||
inviteCode: null,
|
||||
memberUserIds: [46245, 46586, 33790, 18632],
|
||||
ownerUserId: 46245,
|
||||
checkIns: [
|
||||
@@ -7772,7 +7732,6 @@ export const LOW_INK_DECEMBER_2024 = (): TournamentData => ({
|
||||
abDivision: null,
|
||||
createdAt: 1733456080,
|
||||
activeRosterUserIds: [10386, 33369, 29617, 22942],
|
||||
inviteCode: null,
|
||||
memberUserIds: [34071, 10386, 22942, 33369, 29617],
|
||||
ownerUserId: 22942,
|
||||
checkIns: [
|
||||
@@ -7796,7 +7755,6 @@ export const LOW_INK_DECEMBER_2024 = (): TournamentData => ({
|
||||
abDivision: null,
|
||||
createdAt: 1733579092,
|
||||
activeRosterUserIds: null,
|
||||
inviteCode: null,
|
||||
memberUserIds: [26089, 14170, 25419, 14413],
|
||||
ownerUserId: 26089,
|
||||
checkIns: [],
|
||||
@@ -7814,7 +7772,6 @@ export const LOW_INK_DECEMBER_2024 = (): TournamentData => ({
|
||||
abDivision: null,
|
||||
createdAt: 1733769667,
|
||||
activeRosterUserIds: [3481, 38022, 41269, 43551],
|
||||
inviteCode: null,
|
||||
memberUserIds: [3481, 38022, 41269, 7935, 43856, 43551],
|
||||
ownerUserId: 3481,
|
||||
checkIns: [
|
||||
@@ -7838,7 +7795,6 @@ export const LOW_INK_DECEMBER_2024 = (): TournamentData => ({
|
||||
abDivision: null,
|
||||
createdAt: 1733794148,
|
||||
activeRosterUserIds: [22820, 29636, 27036, 28959],
|
||||
inviteCode: null,
|
||||
memberUserIds: [28959, 27036, 22820, 28021, 1890, 29636],
|
||||
ownerUserId: 28959,
|
||||
checkIns: [
|
||||
@@ -7862,7 +7818,6 @@ export const LOW_INK_DECEMBER_2024 = (): TournamentData => ({
|
||||
abDivision: null,
|
||||
createdAt: 1733820540,
|
||||
activeRosterUserIds: null,
|
||||
inviteCode: null,
|
||||
memberUserIds: [35310, 21989, 25242, 43081],
|
||||
ownerUserId: 35310,
|
||||
checkIns: [
|
||||
@@ -7886,7 +7841,6 @@ export const LOW_INK_DECEMBER_2024 = (): TournamentData => ({
|
||||
abDivision: null,
|
||||
createdAt: 1733825084,
|
||||
activeRosterUserIds: null,
|
||||
inviteCode: null,
|
||||
memberUserIds: [4334, 24252, 32444, 36113],
|
||||
ownerUserId: 4334,
|
||||
checkIns: [
|
||||
@@ -7910,7 +7864,6 @@ export const LOW_INK_DECEMBER_2024 = (): TournamentData => ({
|
||||
abDivision: null,
|
||||
createdAt: 1733865890,
|
||||
activeRosterUserIds: [15425, 41975, 28938, 8587],
|
||||
inviteCode: null,
|
||||
memberUserIds: [15425, 27828, 41975, 28938, 8587],
|
||||
ownerUserId: 15425,
|
||||
checkIns: [
|
||||
@@ -7934,7 +7887,6 @@ export const LOW_INK_DECEMBER_2024 = (): TournamentData => ({
|
||||
abDivision: null,
|
||||
createdAt: 1733873149,
|
||||
activeRosterUserIds: [40550, 7115, 29674, 30031],
|
||||
inviteCode: null,
|
||||
memberUserIds: [40550, 7115, 29674, 39569, 30031, 28866],
|
||||
ownerUserId: 40550,
|
||||
checkIns: [
|
||||
@@ -7958,7 +7910,6 @@ export const LOW_INK_DECEMBER_2024 = (): TournamentData => ({
|
||||
abDivision: null,
|
||||
createdAt: 1733875608,
|
||||
activeRosterUserIds: null,
|
||||
inviteCode: null,
|
||||
memberUserIds: [15110, 33824, 35608, 22677],
|
||||
ownerUserId: 15110,
|
||||
checkIns: [
|
||||
@@ -7982,7 +7933,6 @@ export const LOW_INK_DECEMBER_2024 = (): TournamentData => ({
|
||||
abDivision: null,
|
||||
createdAt: 1733888417,
|
||||
activeRosterUserIds: null,
|
||||
inviteCode: null,
|
||||
memberUserIds: [26269, 30030, 21454, 42483],
|
||||
ownerUserId: 26269,
|
||||
checkIns: [
|
||||
@@ -8006,7 +7956,6 @@ export const LOW_INK_DECEMBER_2024 = (): TournamentData => ({
|
||||
abDivision: null,
|
||||
createdAt: 1734008857,
|
||||
activeRosterUserIds: [30266, 37341, 22699, 28145],
|
||||
inviteCode: null,
|
||||
memberUserIds: [30266, 37341, 22699, 28145, 39363],
|
||||
ownerUserId: 30266,
|
||||
checkIns: [
|
||||
@@ -8030,7 +7979,6 @@ export const LOW_INK_DECEMBER_2024 = (): TournamentData => ({
|
||||
abDivision: null,
|
||||
createdAt: 1734018352,
|
||||
activeRosterUserIds: null,
|
||||
inviteCode: null,
|
||||
memberUserIds: [34147, 20990, 22469, 41594],
|
||||
ownerUserId: 34147,
|
||||
checkIns: [
|
||||
@@ -8054,7 +8002,6 @@ export const LOW_INK_DECEMBER_2024 = (): TournamentData => ({
|
||||
abDivision: null,
|
||||
createdAt: 1734019701,
|
||||
activeRosterUserIds: [35421, 33524, 22500, 32802],
|
||||
inviteCode: null,
|
||||
memberUserIds: [32802, 35421, 33524, 22500, 42081, 20063],
|
||||
ownerUserId: 32802,
|
||||
checkIns: [
|
||||
@@ -8078,7 +8025,6 @@ export const LOW_INK_DECEMBER_2024 = (): TournamentData => ({
|
||||
abDivision: null,
|
||||
createdAt: 1734023441,
|
||||
activeRosterUserIds: [1852, 2898, 25763, 3466],
|
||||
inviteCode: null,
|
||||
memberUserIds: [1852, 2898, 25763, 3466, 34662],
|
||||
ownerUserId: 1852,
|
||||
checkIns: [
|
||||
@@ -8102,7 +8048,6 @@ export const LOW_INK_DECEMBER_2024 = (): TournamentData => ({
|
||||
abDivision: null,
|
||||
createdAt: 1734099744,
|
||||
activeRosterUserIds: [39098, 22624, 28137, 2769],
|
||||
inviteCode: null,
|
||||
memberUserIds: [2769, 7461, 39098, 22624, 28137],
|
||||
ownerUserId: 2769,
|
||||
checkIns: [
|
||||
@@ -8126,7 +8071,6 @@ export const LOW_INK_DECEMBER_2024 = (): TournamentData => ({
|
||||
abDivision: null,
|
||||
createdAt: 1734109256,
|
||||
activeRosterUserIds: [29661, 15158, 35067, 31655],
|
||||
inviteCode: null,
|
||||
memberUserIds: [15158, 29661, 10333, 35067, 31655, 40743],
|
||||
ownerUserId: 15158,
|
||||
checkIns: [
|
||||
@@ -8150,7 +8094,6 @@ export const LOW_INK_DECEMBER_2024 = (): TournamentData => ({
|
||||
abDivision: null,
|
||||
createdAt: 1734125682,
|
||||
activeRosterUserIds: [36575, 30425, 32430, 24290],
|
||||
inviteCode: null,
|
||||
memberUserIds: [30425, 32430, 26701, 30584, 24290, 36575],
|
||||
ownerUserId: 30425,
|
||||
checkIns: [
|
||||
@@ -8174,7 +8117,6 @@ export const LOW_INK_DECEMBER_2024 = (): TournamentData => ({
|
||||
abDivision: null,
|
||||
createdAt: 1733515005,
|
||||
activeRosterUserIds: null,
|
||||
inviteCode: null,
|
||||
memberUserIds: [25543, 36921, 37563, 37665],
|
||||
ownerUserId: 25543,
|
||||
checkIns: [
|
||||
@@ -8198,7 +8140,6 @@ export const LOW_INK_DECEMBER_2024 = (): TournamentData => ({
|
||||
abDivision: null,
|
||||
createdAt: 1733521735,
|
||||
activeRosterUserIds: [44772, 38912, 36853, 42599],
|
||||
inviteCode: null,
|
||||
memberUserIds: [44772, 38912, 23357, 36853, 42599],
|
||||
ownerUserId: 44772,
|
||||
checkIns: [
|
||||
@@ -8222,7 +8163,6 @@ export const LOW_INK_DECEMBER_2024 = (): TournamentData => ({
|
||||
abDivision: null,
|
||||
createdAt: 1733525617,
|
||||
activeRosterUserIds: null,
|
||||
inviteCode: null,
|
||||
memberUserIds: [11783, 21818, 22991, 2266],
|
||||
ownerUserId: 11783,
|
||||
checkIns: [
|
||||
@@ -8246,7 +8186,6 @@ export const LOW_INK_DECEMBER_2024 = (): TournamentData => ({
|
||||
abDivision: null,
|
||||
createdAt: 1733847805,
|
||||
activeRosterUserIds: [33615, 32015, 45778, 32970],
|
||||
inviteCode: null,
|
||||
memberUserIds: [33615, 32015, 30663, 45778, 32970],
|
||||
ownerUserId: 33615,
|
||||
checkIns: [
|
||||
@@ -8270,7 +8209,6 @@ export const LOW_INK_DECEMBER_2024 = (): TournamentData => ({
|
||||
abDivision: null,
|
||||
createdAt: 1733858126,
|
||||
activeRosterUserIds: [34545, 35567, 41108, 41255],
|
||||
inviteCode: null,
|
||||
memberUserIds: [41255, 35567, 41108, 34545, 26564],
|
||||
ownerUserId: 41255,
|
||||
checkIns: [
|
||||
@@ -8294,7 +8232,6 @@ export const LOW_INK_DECEMBER_2024 = (): TournamentData => ({
|
||||
abDivision: null,
|
||||
createdAt: 1733966096,
|
||||
activeRosterUserIds: [39470, 42874, 32878, 25741],
|
||||
inviteCode: null,
|
||||
memberUserIds: [10788, 25741, 42874, 39470, 32878],
|
||||
ownerUserId: 10788,
|
||||
checkIns: [
|
||||
@@ -8318,7 +8255,6 @@ export const LOW_INK_DECEMBER_2024 = (): TournamentData => ({
|
||||
abDivision: null,
|
||||
createdAt: 1734021147,
|
||||
activeRosterUserIds: [45250, 45174, 6976, 10222],
|
||||
inviteCode: null,
|
||||
memberUserIds: [45250, 45174, 10222, 6976, 46504],
|
||||
ownerUserId: 45250,
|
||||
checkIns: [
|
||||
@@ -8342,7 +8278,6 @@ export const LOW_INK_DECEMBER_2024 = (): TournamentData => ({
|
||||
abDivision: null,
|
||||
createdAt: 1734040772,
|
||||
activeRosterUserIds: null,
|
||||
inviteCode: null,
|
||||
memberUserIds: [33573, 43753, 45806, 46648],
|
||||
ownerUserId: 33573,
|
||||
checkIns: [
|
||||
@@ -8366,7 +8301,6 @@ export const LOW_INK_DECEMBER_2024 = (): TournamentData => ({
|
||||
abDivision: null,
|
||||
createdAt: 1734033803,
|
||||
activeRosterUserIds: [27800, 12235, 30044, 29531],
|
||||
inviteCode: null,
|
||||
memberUserIds: [27800, 44328, 12235, 29531, 30044],
|
||||
ownerUserId: 27800,
|
||||
checkIns: [
|
||||
@@ -8390,7 +8324,6 @@ export const LOW_INK_DECEMBER_2024 = (): TournamentData => ({
|
||||
abDivision: null,
|
||||
createdAt: 1734099612,
|
||||
activeRosterUserIds: [24572, 7058, 37641, 33913],
|
||||
inviteCode: null,
|
||||
memberUserIds: [7058, 37641, 33913, 42597, 24572],
|
||||
ownerUserId: 7058,
|
||||
checkIns: [
|
||||
@@ -8414,7 +8347,6 @@ export const LOW_INK_DECEMBER_2024 = (): TournamentData => ({
|
||||
abDivision: null,
|
||||
createdAt: 1734113463,
|
||||
activeRosterUserIds: null,
|
||||
inviteCode: null,
|
||||
memberUserIds: [20209, 33276, 41038, 11198],
|
||||
ownerUserId: 20209,
|
||||
checkIns: [
|
||||
@@ -8438,7 +8370,6 @@ export const LOW_INK_DECEMBER_2024 = (): TournamentData => ({
|
||||
abDivision: null,
|
||||
createdAt: 1734118202,
|
||||
activeRosterUserIds: null,
|
||||
inviteCode: null,
|
||||
memberUserIds: [9200, 14927, 1236, 27069],
|
||||
ownerUserId: 9200,
|
||||
checkIns: [],
|
||||
@@ -8456,7 +8387,6 @@ export const LOW_INK_DECEMBER_2024 = (): TournamentData => ({
|
||||
abDivision: null,
|
||||
createdAt: 1734134334,
|
||||
activeRosterUserIds: [11186, 27611, 25952, 23481],
|
||||
inviteCode: null,
|
||||
memberUserIds: [27544, 25952, 27611, 11186, 23481],
|
||||
ownerUserId: 27544,
|
||||
checkIns: [
|
||||
@@ -8480,7 +8410,6 @@ export const LOW_INK_DECEMBER_2024 = (): TournamentData => ({
|
||||
abDivision: null,
|
||||
createdAt: 1733169181,
|
||||
activeRosterUserIds: null,
|
||||
inviteCode: null,
|
||||
memberUserIds: [31145, 38610, 40713, 40766],
|
||||
ownerUserId: 31145,
|
||||
checkIns: [
|
||||
@@ -8504,7 +8433,6 @@ export const LOW_INK_DECEMBER_2024 = (): TournamentData => ({
|
||||
abDivision: null,
|
||||
createdAt: 1733247691,
|
||||
activeRosterUserIds: null,
|
||||
inviteCode: null,
|
||||
memberUserIds: [46518, 46538, 46523, 46501],
|
||||
ownerUserId: 46518,
|
||||
checkIns: [
|
||||
@@ -8528,7 +8456,6 @@ export const LOW_INK_DECEMBER_2024 = (): TournamentData => ({
|
||||
abDivision: null,
|
||||
createdAt: 1733452618,
|
||||
activeRosterUserIds: null,
|
||||
inviteCode: null,
|
||||
memberUserIds: [42089, 17049, 36888, 46546, 42172],
|
||||
ownerUserId: 42089,
|
||||
checkIns: [],
|
||||
@@ -8546,7 +8473,6 @@ export const LOW_INK_DECEMBER_2024 = (): TournamentData => ({
|
||||
abDivision: null,
|
||||
createdAt: 1733481710,
|
||||
activeRosterUserIds: null,
|
||||
inviteCode: null,
|
||||
memberUserIds: [44893, 40912, 30455, 27649, 43703],
|
||||
ownerUserId: 44893,
|
||||
checkIns: [],
|
||||
@@ -8564,7 +8490,6 @@ export const LOW_INK_DECEMBER_2024 = (): TournamentData => ({
|
||||
abDivision: null,
|
||||
createdAt: 1733508949,
|
||||
activeRosterUserIds: null,
|
||||
inviteCode: null,
|
||||
memberUserIds: [44493, 46451, 44412, 45194],
|
||||
ownerUserId: 44493,
|
||||
checkIns: [
|
||||
@@ -8588,7 +8513,6 @@ export const LOW_INK_DECEMBER_2024 = (): TournamentData => ({
|
||||
abDivision: null,
|
||||
createdAt: 1733611261,
|
||||
activeRosterUserIds: null,
|
||||
inviteCode: null,
|
||||
memberUserIds: [18149, 21755, 27984, 27990],
|
||||
ownerUserId: 18149,
|
||||
checkIns: [
|
||||
@@ -8612,7 +8536,6 @@ export const LOW_INK_DECEMBER_2024 = (): TournamentData => ({
|
||||
abDivision: null,
|
||||
createdAt: 1733841846,
|
||||
activeRosterUserIds: [41943, 46289, 45290, 46394],
|
||||
inviteCode: null,
|
||||
memberUserIds: [46289, 41943, 45290, 46394, 46400],
|
||||
ownerUserId: 46289,
|
||||
checkIns: [
|
||||
@@ -8636,7 +8559,6 @@ export const LOW_INK_DECEMBER_2024 = (): TournamentData => ({
|
||||
abDivision: null,
|
||||
createdAt: 1733878153,
|
||||
activeRosterUserIds: null,
|
||||
inviteCode: null,
|
||||
memberUserIds: [40484, 38960, 32199, 40126],
|
||||
ownerUserId: 40484,
|
||||
checkIns: [
|
||||
@@ -8660,7 +8582,6 @@ export const LOW_INK_DECEMBER_2024 = (): TournamentData => ({
|
||||
abDivision: null,
|
||||
createdAt: 1734135144,
|
||||
activeRosterUserIds: null,
|
||||
inviteCode: null,
|
||||
memberUserIds: [44839, 44866, 44642, 45391],
|
||||
ownerUserId: 44839,
|
||||
checkIns: [
|
||||
|
||||
@@ -1969,6 +1969,13 @@ export const SWIM_OR_SINK_167 = (
|
||||
customUrl: "grace",
|
||||
customAvatarUrl: null,
|
||||
},
|
||||
permissions: {
|
||||
ADMIN: [1402],
|
||||
ORGANIZE: [1402],
|
||||
MANAGE_MATCHES: [1402],
|
||||
EDIT_EVENT_INFO: [1402],
|
||||
EDIT_IN_GAME_NAMES: [],
|
||||
},
|
||||
staff: [
|
||||
{
|
||||
id: 52,
|
||||
@@ -2152,7 +2159,6 @@ export const SWIM_OR_SINK_167 = (
|
||||
droppedOut: 0,
|
||||
createdAt: 1730771673,
|
||||
activeRosterUserIds: [8852, 34724, 9403, 27222],
|
||||
inviteCode: null,
|
||||
memberUserIds: [8852, 9403, 31868, 13562, 34724, 27222],
|
||||
ownerUserId: 8852,
|
||||
checkIns: [
|
||||
@@ -2196,7 +2202,6 @@ export const SWIM_OR_SINK_167 = (
|
||||
droppedOut: 0,
|
||||
createdAt: 1730931681,
|
||||
activeRosterUserIds: null,
|
||||
inviteCode: null,
|
||||
memberUserIds: [23016, 331, 44, 65],
|
||||
ownerUserId: 23016,
|
||||
checkIns: [
|
||||
@@ -2240,7 +2245,6 @@ export const SWIM_OR_SINK_167 = (
|
||||
droppedOut: 0,
|
||||
createdAt: 1730864603,
|
||||
activeRosterUserIds: [22344, 1038, 1059, 10200],
|
||||
inviteCode: null,
|
||||
memberUserIds: [22344, 10200, 1038, 1059, 267],
|
||||
ownerUserId: 22344,
|
||||
checkIns: [
|
||||
@@ -2284,7 +2288,6 @@ export const SWIM_OR_SINK_167 = (
|
||||
droppedOut: 0,
|
||||
createdAt: 1730932511,
|
||||
activeRosterUserIds: null,
|
||||
inviteCode: null,
|
||||
memberUserIds: [9001, 9034, 590, 29643],
|
||||
ownerUserId: 9001,
|
||||
checkIns: [
|
||||
@@ -2328,7 +2331,6 @@ export const SWIM_OR_SINK_167 = (
|
||||
droppedOut: 0,
|
||||
createdAt: 1730922495,
|
||||
activeRosterUserIds: null,
|
||||
inviteCode: null,
|
||||
memberUserIds: [3930, 5368, 373, 37677],
|
||||
ownerUserId: 3930,
|
||||
checkIns: [
|
||||
@@ -2372,7 +2374,6 @@ export const SWIM_OR_SINK_167 = (
|
||||
droppedOut: 0,
|
||||
createdAt: 1730788095,
|
||||
activeRosterUserIds: [7807, 11815, 5001, 7216],
|
||||
inviteCode: null,
|
||||
memberUserIds: [7807, 11143, 20311, 11815, 5001, 7216],
|
||||
ownerUserId: 7807,
|
||||
checkIns: [
|
||||
@@ -2416,7 +2417,6 @@ export const SWIM_OR_SINK_167 = (
|
||||
droppedOut: 0,
|
||||
createdAt: 1730774561,
|
||||
activeRosterUserIds: [3657, 5227, 25622, 25053],
|
||||
inviteCode: null,
|
||||
memberUserIds: [3657, 20026, 5227, 25622, 25053],
|
||||
ownerUserId: 3657,
|
||||
checkIns: [
|
||||
@@ -2460,7 +2460,6 @@ export const SWIM_OR_SINK_167 = (
|
||||
droppedOut: 0,
|
||||
createdAt: 1730935243,
|
||||
activeRosterUserIds: [73, 8760, 1548, 163],
|
||||
inviteCode: null,
|
||||
memberUserIds: [73, 5947, 8760, 1548, 163],
|
||||
ownerUserId: 73,
|
||||
checkIns: [
|
||||
@@ -2504,7 +2503,6 @@ export const SWIM_OR_SINK_167 = (
|
||||
droppedOut: 0,
|
||||
createdAt: 1730851309,
|
||||
activeRosterUserIds: null,
|
||||
inviteCode: null,
|
||||
memberUserIds: [19093, 2670, 36265, 23505],
|
||||
ownerUserId: 19093,
|
||||
checkIns: [
|
||||
@@ -2548,7 +2546,6 @@ export const SWIM_OR_SINK_167 = (
|
||||
droppedOut: 0,
|
||||
createdAt: 1730870818,
|
||||
activeRosterUserIds: [22614, 11244, 3181, 11495],
|
||||
inviteCode: null,
|
||||
memberUserIds: [22614, 11244, 3181, 11495, 31073],
|
||||
ownerUserId: 22614,
|
||||
checkIns: [
|
||||
@@ -2587,7 +2584,6 @@ export const SWIM_OR_SINK_167 = (
|
||||
droppedOut: 0,
|
||||
createdAt: 1730872875,
|
||||
activeRosterUserIds: [21487, 28391, 23292, 13854],
|
||||
inviteCode: null,
|
||||
memberUserIds: [13854, 21487, 28391, 23292, 27438],
|
||||
ownerUserId: 13854,
|
||||
checkIns: [
|
||||
@@ -2631,7 +2627,6 @@ export const SWIM_OR_SINK_167 = (
|
||||
droppedOut: 0,
|
||||
createdAt: 1730934390,
|
||||
activeRosterUserIds: null,
|
||||
inviteCode: null,
|
||||
memberUserIds: [3449, 4307, 20731, 22706],
|
||||
ownerUserId: 3449,
|
||||
checkIns: [
|
||||
@@ -2675,7 +2670,6 @@ export const SWIM_OR_SINK_167 = (
|
||||
droppedOut: 0,
|
||||
createdAt: 1730925172,
|
||||
activeRosterUserIds: [863, 34414, 27917, 15278],
|
||||
inviteCode: null,
|
||||
memberUserIds: [27917, 15278, 34414, 863, 31526],
|
||||
ownerUserId: 27917,
|
||||
checkIns: [
|
||||
@@ -2719,7 +2713,6 @@ export const SWIM_OR_SINK_167 = (
|
||||
droppedOut: 0,
|
||||
createdAt: 1730526186,
|
||||
activeRosterUserIds: [1736, 986, 25464, 2300],
|
||||
inviteCode: null,
|
||||
memberUserIds: [31259, 1736, 986, 2300, 25464, 30204],
|
||||
ownerUserId: 31259,
|
||||
checkIns: [
|
||||
@@ -2763,7 +2756,6 @@ export const SWIM_OR_SINK_167 = (
|
||||
droppedOut: 0,
|
||||
createdAt: 1730940438,
|
||||
activeRosterUserIds: null,
|
||||
inviteCode: null,
|
||||
memberUserIds: [11350, 11275, 9718],
|
||||
ownerUserId: 11350,
|
||||
checkIns: [],
|
||||
@@ -2781,7 +2773,6 @@ export const SWIM_OR_SINK_167 = (
|
||||
droppedOut: 0,
|
||||
createdAt: 1730912708,
|
||||
activeRosterUserIds: null,
|
||||
inviteCode: null,
|
||||
memberUserIds: [19035, 2088, 9454, 2059],
|
||||
ownerUserId: 19035,
|
||||
checkIns: [
|
||||
@@ -2825,7 +2816,6 @@ export const SWIM_OR_SINK_167 = (
|
||||
droppedOut: 0,
|
||||
createdAt: 1730936919,
|
||||
activeRosterUserIds: null,
|
||||
inviteCode: null,
|
||||
memberUserIds: [35571, 25168, 27485, 14007],
|
||||
ownerUserId: 35571,
|
||||
checkIns: [
|
||||
@@ -2869,7 +2859,6 @@ export const SWIM_OR_SINK_167 = (
|
||||
droppedOut: 0,
|
||||
createdAt: 1730937337,
|
||||
activeRosterUserIds: null,
|
||||
inviteCode: null,
|
||||
memberUserIds: [460, 1338, 5679, 241],
|
||||
ownerUserId: 460,
|
||||
checkIns: [
|
||||
@@ -2913,7 +2902,6 @@ export const SWIM_OR_SINK_167 = (
|
||||
droppedOut: 0,
|
||||
createdAt: 1730769135,
|
||||
activeRosterUserIds: [33116, 34014, 44751, 22756],
|
||||
inviteCode: null,
|
||||
memberUserIds: [11951, 33116, 34014, 44751, 44198, 22756],
|
||||
ownerUserId: 11951,
|
||||
checkIns: [
|
||||
@@ -2957,7 +2945,6 @@ export const SWIM_OR_SINK_167 = (
|
||||
droppedOut: 0,
|
||||
createdAt: 1730836875,
|
||||
activeRosterUserIds: [1616, 17310, 34657, 22409],
|
||||
inviteCode: null,
|
||||
memberUserIds: [1616, 1487, 17310, 34657, 22409],
|
||||
ownerUserId: 1616,
|
||||
checkIns: [
|
||||
@@ -3001,7 +2988,6 @@ export const SWIM_OR_SINK_167 = (
|
||||
droppedOut: 0,
|
||||
createdAt: 1730844165,
|
||||
activeRosterUserIds: null,
|
||||
inviteCode: null,
|
||||
memberUserIds: [5471, 2672, 4504, 25856],
|
||||
ownerUserId: 5471,
|
||||
checkIns: [
|
||||
@@ -3045,7 +3031,6 @@ export const SWIM_OR_SINK_167 = (
|
||||
droppedOut: 0,
|
||||
createdAt: 1730926359,
|
||||
activeRosterUserIds: null,
|
||||
inviteCode: null,
|
||||
memberUserIds: [5334, 34071, 23946, 21339],
|
||||
ownerUserId: 5334,
|
||||
checkIns: [
|
||||
@@ -3089,7 +3074,6 @@ export const SWIM_OR_SINK_167 = (
|
||||
droppedOut: 0,
|
||||
createdAt: 1730928135,
|
||||
activeRosterUserIds: null,
|
||||
inviteCode: null,
|
||||
memberUserIds: [23115, 5261, 190, 12585],
|
||||
ownerUserId: 23115,
|
||||
checkIns: [
|
||||
@@ -3133,7 +3117,6 @@ export const SWIM_OR_SINK_167 = (
|
||||
droppedOut: 0,
|
||||
createdAt: 1730880730,
|
||||
activeRosterUserIds: null,
|
||||
inviteCode: null,
|
||||
memberUserIds: [5662, 2731, 31764, 25133, 17855],
|
||||
ownerUserId: 5662,
|
||||
checkIns: [],
|
||||
@@ -3151,7 +3134,6 @@ export const SWIM_OR_SINK_167 = (
|
||||
droppedOut: 0,
|
||||
createdAt: 1730939363,
|
||||
activeRosterUserIds: [21205, 1953, 32885, 2888],
|
||||
inviteCode: null,
|
||||
memberUserIds: [21205, 32885, 1953, 15188, 2888],
|
||||
ownerUserId: 21205,
|
||||
checkIns: [
|
||||
@@ -3195,7 +3177,6 @@ export const SWIM_OR_SINK_167 = (
|
||||
droppedOut: 0,
|
||||
createdAt: 1730838132,
|
||||
activeRosterUserIds: null,
|
||||
inviteCode: null,
|
||||
memberUserIds: [5106, 8297, 8830, 38176],
|
||||
ownerUserId: 5106,
|
||||
checkIns: [
|
||||
@@ -3239,7 +3220,6 @@ export const SWIM_OR_SINK_167 = (
|
||||
droppedOut: 0,
|
||||
createdAt: 1730917380,
|
||||
activeRosterUserIds: null,
|
||||
inviteCode: null,
|
||||
memberUserIds: [23731, 36215],
|
||||
ownerUserId: 23731,
|
||||
checkIns: [],
|
||||
@@ -3257,7 +3237,6 @@ export const SWIM_OR_SINK_167 = (
|
||||
droppedOut: 0,
|
||||
createdAt: 1730832667,
|
||||
activeRosterUserIds: null,
|
||||
inviteCode: null,
|
||||
memberUserIds: [16387, 6051, 22903, 23132],
|
||||
ownerUserId: 16387,
|
||||
checkIns: [
|
||||
@@ -3301,7 +3280,6 @@ export const SWIM_OR_SINK_167 = (
|
||||
droppedOut: 0,
|
||||
createdAt: 1730853887,
|
||||
activeRosterUserIds: [29120, 35225, 8587, 27440],
|
||||
inviteCode: null,
|
||||
memberUserIds: [27440, 29120, 35225, 8587, 23333],
|
||||
ownerUserId: 27440,
|
||||
checkIns: [
|
||||
@@ -3345,7 +3323,6 @@ export const SWIM_OR_SINK_167 = (
|
||||
droppedOut: 0,
|
||||
createdAt: 1730928986,
|
||||
activeRosterUserIds: [26103, 31395, 33402, 31195],
|
||||
inviteCode: null,
|
||||
memberUserIds: [26103, 31395, 31195, 28700, 33402],
|
||||
ownerUserId: 26103,
|
||||
checkIns: [
|
||||
@@ -3389,7 +3366,6 @@ export const SWIM_OR_SINK_167 = (
|
||||
droppedOut: 0,
|
||||
createdAt: 1730775625,
|
||||
activeRosterUserIds: null,
|
||||
inviteCode: null,
|
||||
memberUserIds: [30685, 30686, 22396, 1961],
|
||||
ownerUserId: 30685,
|
||||
checkIns: [
|
||||
@@ -3433,7 +3409,6 @@ export const SWIM_OR_SINK_167 = (
|
||||
droppedOut: 0,
|
||||
createdAt: 1730605037,
|
||||
activeRosterUserIds: [24514, 5187, 29823, 22744],
|
||||
inviteCode: null,
|
||||
memberUserIds: [24514, 5187, 10265, 22744, 29823],
|
||||
ownerUserId: 24514,
|
||||
checkIns: [
|
||||
@@ -3477,7 +3452,6 @@ export const SWIM_OR_SINK_167 = (
|
||||
droppedOut: 0,
|
||||
createdAt: 1730862741,
|
||||
activeRosterUserIds: [5584, 30612, 13671, 36898],
|
||||
inviteCode: null,
|
||||
memberUserIds: [5584, 13671, 30612, 36898, 31580],
|
||||
ownerUserId: 5584,
|
||||
checkIns: [
|
||||
@@ -3521,7 +3495,6 @@ export const SWIM_OR_SINK_167 = (
|
||||
droppedOut: 0,
|
||||
createdAt: 1730840221,
|
||||
activeRosterUserIds: null,
|
||||
inviteCode: null,
|
||||
memberUserIds: [29645, 31533, 29483, 42118, 36800],
|
||||
ownerUserId: 29645,
|
||||
checkIns: [],
|
||||
@@ -3539,7 +3512,6 @@ export const SWIM_OR_SINK_167 = (
|
||||
droppedOut: 0,
|
||||
createdAt: 1730770163,
|
||||
activeRosterUserIds: [25469, 3513, 26820, 30122],
|
||||
inviteCode: null,
|
||||
memberUserIds: [30122, 26820, 31154, 10297, 3513, 25469],
|
||||
ownerUserId: 30122,
|
||||
checkIns: [
|
||||
@@ -3583,7 +3555,6 @@ export const SWIM_OR_SINK_167 = (
|
||||
droppedOut: 0,
|
||||
createdAt: 1730753582,
|
||||
activeRosterUserIds: null,
|
||||
inviteCode: null,
|
||||
memberUserIds: [37477, 3635, 7433, 6647],
|
||||
ownerUserId: 37477,
|
||||
checkIns: [],
|
||||
@@ -3601,7 +3572,6 @@ export const SWIM_OR_SINK_167 = (
|
||||
droppedOut: 0,
|
||||
createdAt: 1730929508,
|
||||
activeRosterUserIds: null,
|
||||
inviteCode: null,
|
||||
memberUserIds: [20774, 20807, 42703, 42409],
|
||||
ownerUserId: 20774,
|
||||
checkIns: [
|
||||
@@ -3645,7 +3615,6 @@ export const SWIM_OR_SINK_167 = (
|
||||
droppedOut: 0,
|
||||
createdAt: 1730918770,
|
||||
activeRosterUserIds: null,
|
||||
inviteCode: null,
|
||||
memberUserIds: [28170, 11941, 715, 11409],
|
||||
ownerUserId: 28170,
|
||||
checkIns: [],
|
||||
@@ -3663,7 +3632,6 @@ export const SWIM_OR_SINK_167 = (
|
||||
droppedOut: 0,
|
||||
createdAt: 1730851475,
|
||||
activeRosterUserIds: null,
|
||||
inviteCode: null,
|
||||
memberUserIds: [26428, 29182, 9235, 30591],
|
||||
ownerUserId: 26428,
|
||||
checkIns: [
|
||||
@@ -3707,7 +3675,6 @@ export const SWIM_OR_SINK_167 = (
|
||||
droppedOut: 0,
|
||||
createdAt: 1730726309,
|
||||
activeRosterUserIds: null,
|
||||
inviteCode: null,
|
||||
memberUserIds: [25247, 2279, 26162, 4334, 24013],
|
||||
ownerUserId: 25247,
|
||||
checkIns: [],
|
||||
@@ -3725,7 +3692,6 @@ export const SWIM_OR_SINK_167 = (
|
||||
droppedOut: 0,
|
||||
createdAt: 1730840643,
|
||||
activeRosterUserIds: [41797, 29855, 34594, 26801],
|
||||
inviteCode: null,
|
||||
memberUserIds: [41797, 26801, 29855, 34594, 33825],
|
||||
ownerUserId: 41797,
|
||||
checkIns: [
|
||||
@@ -3769,7 +3735,6 @@ export const SWIM_OR_SINK_167 = (
|
||||
droppedOut: 0,
|
||||
createdAt: 1730827259,
|
||||
activeRosterUserIds: [25218, 26988, 26989, 12610],
|
||||
inviteCode: null,
|
||||
memberUserIds: [25218, 26988, 26989, 12610, 25755],
|
||||
ownerUserId: 25218,
|
||||
checkIns: [
|
||||
@@ -3813,7 +3778,6 @@ export const SWIM_OR_SINK_167 = (
|
||||
droppedOut: 0,
|
||||
createdAt: 1730863477,
|
||||
activeRosterUserIds: [37341, 30266, 22699, 39363],
|
||||
inviteCode: null,
|
||||
memberUserIds: [30266, 37341, 22699, 28145, 39363, 27113],
|
||||
ownerUserId: 30266,
|
||||
checkIns: [
|
||||
@@ -3857,7 +3821,6 @@ export const SWIM_OR_SINK_167 = (
|
||||
droppedOut: 0,
|
||||
createdAt: 1730907528,
|
||||
activeRosterUserIds: null,
|
||||
inviteCode: null,
|
||||
memberUserIds: [22026, 29467, 10611, 46099],
|
||||
ownerUserId: 22026,
|
||||
checkIns: [
|
||||
@@ -3901,7 +3864,6 @@ export const SWIM_OR_SINK_167 = (
|
||||
droppedOut: 0,
|
||||
createdAt: 1730932702,
|
||||
activeRosterUserIds: [45980, 45163, 32203, 46101],
|
||||
inviteCode: null,
|
||||
memberUserIds: [45980, 45163, 2620, 32203, 46101],
|
||||
ownerUserId: 45980,
|
||||
checkIns: [
|
||||
@@ -3945,7 +3907,6 @@ export const SWIM_OR_SINK_167 = (
|
||||
droppedOut: 0,
|
||||
createdAt: 1730832003,
|
||||
activeRosterUserIds: [40505, 29011, 23082, 33067],
|
||||
inviteCode: null,
|
||||
memberUserIds: [40505, 29011, 23082, 33067, 21549],
|
||||
ownerUserId: 40505,
|
||||
checkIns: [
|
||||
@@ -3989,7 +3950,6 @@ export const SWIM_OR_SINK_167 = (
|
||||
droppedOut: 0,
|
||||
createdAt: 1730938507,
|
||||
activeRosterUserIds: null,
|
||||
inviteCode: null,
|
||||
memberUserIds: [35724, 20240, 28097, 21249],
|
||||
ownerUserId: 35724,
|
||||
checkIns: [
|
||||
@@ -4033,7 +3993,6 @@ export const SWIM_OR_SINK_167 = (
|
||||
droppedOut: 0,
|
||||
createdAt: 1730739211,
|
||||
activeRosterUserIds: [23712, 8080, 7994, 20990],
|
||||
inviteCode: null,
|
||||
memberUserIds: [7994, 23712, 27474, 20990, 8080, 28671],
|
||||
ownerUserId: 7994,
|
||||
checkIns: [
|
||||
@@ -4077,7 +4036,6 @@ export const SWIM_OR_SINK_167 = (
|
||||
droppedOut: 0,
|
||||
createdAt: 1730857328,
|
||||
activeRosterUserIds: [29531, 3275, 35169, 7008],
|
||||
inviteCode: null,
|
||||
memberUserIds: [29531, 3275, 35169, 7008, 40169],
|
||||
ownerUserId: 29531,
|
||||
checkIns: [
|
||||
@@ -4121,7 +4079,6 @@ export const SWIM_OR_SINK_167 = (
|
||||
droppedOut: 0,
|
||||
createdAt: 1730591675,
|
||||
activeRosterUserIds: null,
|
||||
inviteCode: null,
|
||||
memberUserIds: [28703, 23183, 7664, 30237],
|
||||
ownerUserId: 28703,
|
||||
checkIns: [],
|
||||
@@ -4139,7 +4096,6 @@ export const SWIM_OR_SINK_167 = (
|
||||
droppedOut: 0,
|
||||
createdAt: 1730859986,
|
||||
activeRosterUserIds: null,
|
||||
inviteCode: null,
|
||||
memberUserIds: [10181, 8552, 29897, 1894],
|
||||
ownerUserId: 10181,
|
||||
checkIns: [
|
||||
@@ -4183,7 +4139,6 @@ export const SWIM_OR_SINK_167 = (
|
||||
droppedOut: 0,
|
||||
createdAt: 1730703689,
|
||||
activeRosterUserIds: null,
|
||||
inviteCode: null,
|
||||
memberUserIds: [43847, 43850, 45635, 46045],
|
||||
ownerUserId: 43847,
|
||||
checkIns: [
|
||||
|
||||
@@ -322,6 +322,13 @@ export const ZONES_WEEKLY_38 = (): TournamentData => ({
|
||||
customUrl: "puma",
|
||||
customAvatarUrl: null,
|
||||
},
|
||||
permissions: {
|
||||
ADMIN: [13370],
|
||||
ORGANIZE: [13370],
|
||||
MANAGE_MATCHES: [13370],
|
||||
EDIT_EVENT_INFO: [13370],
|
||||
EDIT_IN_GAME_NAMES: [],
|
||||
},
|
||||
staff: [
|
||||
{
|
||||
id: 1183,
|
||||
@@ -376,7 +383,6 @@ export const ZONES_WEEKLY_38 = (): TournamentData => ({
|
||||
activeRosterUserIds: [5662, 2899, 6114, 30176],
|
||||
startingBracketIdx: null,
|
||||
abDivision: null,
|
||||
inviteCode: null,
|
||||
memberUserIds: [5662, 2899, 6114, 33963, 30176],
|
||||
ownerUserId: 5662,
|
||||
checkIns: [
|
||||
@@ -400,7 +406,6 @@ export const ZONES_WEEKLY_38 = (): TournamentData => ({
|
||||
activeRosterUserIds: null,
|
||||
startingBracketIdx: null,
|
||||
abDivision: null,
|
||||
inviteCode: null,
|
||||
memberUserIds: [17855, 21689, 3147, 2072],
|
||||
ownerUserId: 17855,
|
||||
checkIns: [
|
||||
@@ -424,7 +429,6 @@ export const ZONES_WEEKLY_38 = (): TournamentData => ({
|
||||
activeRosterUserIds: null,
|
||||
startingBracketIdx: null,
|
||||
abDivision: null,
|
||||
inviteCode: null,
|
||||
memberUserIds: [11484, 13370, 45, 1843],
|
||||
ownerUserId: 11484,
|
||||
checkIns: [
|
||||
@@ -448,7 +452,6 @@ export const ZONES_WEEKLY_38 = (): TournamentData => ({
|
||||
activeRosterUserIds: [37632, 13590, 10757, 33047],
|
||||
startingBracketIdx: null,
|
||||
abDivision: null,
|
||||
inviteCode: null,
|
||||
memberUserIds: [37632, 13590, 10757, 33047, 41024],
|
||||
ownerUserId: 37632,
|
||||
checkIns: [
|
||||
@@ -472,7 +475,6 @@ export const ZONES_WEEKLY_38 = (): TournamentData => ({
|
||||
activeRosterUserIds: [11780, 46006, 43518, 33483],
|
||||
startingBracketIdx: null,
|
||||
abDivision: null,
|
||||
inviteCode: null,
|
||||
memberUserIds: [43518, 29665, 46006, 33483, 11780, 37901],
|
||||
ownerUserId: 43518,
|
||||
checkIns: [
|
||||
@@ -496,7 +498,6 @@ export const ZONES_WEEKLY_38 = (): TournamentData => ({
|
||||
activeRosterUserIds: [46467, 46813, 33491, 43662],
|
||||
startingBracketIdx: null,
|
||||
abDivision: null,
|
||||
inviteCode: null,
|
||||
memberUserIds: [45879, 43662, 33491, 46467, 46813],
|
||||
ownerUserId: 45879,
|
||||
checkIns: [
|
||||
@@ -520,7 +521,6 @@ export const ZONES_WEEKLY_38 = (): TournamentData => ({
|
||||
activeRosterUserIds: null,
|
||||
startingBracketIdx: null,
|
||||
abDivision: null,
|
||||
inviteCode: null,
|
||||
memberUserIds: [26992, 33611, 31148, 33578],
|
||||
ownerUserId: 26992,
|
||||
checkIns: [
|
||||
|
||||
@@ -1278,6 +1278,13 @@ export const PADDLING_POOL_257 = () =>
|
||||
customUrl: "alicetheto",
|
||||
customAvatarUrl: null,
|
||||
},
|
||||
permissions: {
|
||||
ADMIN: [860],
|
||||
ORGANIZE: [860],
|
||||
MANAGE_MATCHES: [860],
|
||||
EDIT_EVENT_INFO: [860],
|
||||
EDIT_IN_GAME_NAMES: [],
|
||||
},
|
||||
staff: [
|
||||
{
|
||||
id: 1536,
|
||||
@@ -1342,7 +1349,6 @@ export const PADDLING_POOL_257 = () =>
|
||||
abDivision: null,
|
||||
activeRosterUserIds: [],
|
||||
createdAt: 1709743534,
|
||||
inviteCode: null,
|
||||
memberUserIds: [10293, 5728, 185, 9925],
|
||||
ownerUserId: 10293,
|
||||
checkIns: [
|
||||
@@ -1371,7 +1377,6 @@ export const PADDLING_POOL_257 = () =>
|
||||
abDivision: null,
|
||||
activeRosterUserIds: [],
|
||||
createdAt: 1709737918,
|
||||
inviteCode: null,
|
||||
memberUserIds: [15916, 1300, 27958, 7265, 4603],
|
||||
ownerUserId: 15916,
|
||||
checkIns: [
|
||||
@@ -1400,7 +1405,6 @@ export const PADDLING_POOL_257 = () =>
|
||||
abDivision: null,
|
||||
activeRosterUserIds: [],
|
||||
createdAt: 1709743523,
|
||||
inviteCode: null,
|
||||
memberUserIds: [223, 343, 193, 10615],
|
||||
ownerUserId: 223,
|
||||
checkIns: [
|
||||
@@ -1429,7 +1433,6 @@ export const PADDLING_POOL_257 = () =>
|
||||
abDivision: null,
|
||||
activeRosterUserIds: [],
|
||||
createdAt: 1709743262,
|
||||
inviteCode: null,
|
||||
memberUserIds: [22661, 28485, 8734, 7094],
|
||||
ownerUserId: 22661,
|
||||
checkIns: [
|
||||
@@ -1458,7 +1461,6 @@ export const PADDLING_POOL_257 = () =>
|
||||
abDivision: null,
|
||||
activeRosterUserIds: [],
|
||||
createdAt: 1709741396,
|
||||
inviteCode: null,
|
||||
memberUserIds: [8157, 2622, 8139, 11199, 13227],
|
||||
ownerUserId: 8157,
|
||||
checkIns: [
|
||||
@@ -1487,7 +1489,6 @@ export const PADDLING_POOL_257 = () =>
|
||||
abDivision: null,
|
||||
activeRosterUserIds: [],
|
||||
createdAt: 1709711811,
|
||||
inviteCode: null,
|
||||
memberUserIds: [23746, 8258, 13562, 25721, 1447],
|
||||
ownerUserId: 23746,
|
||||
checkIns: [
|
||||
@@ -1516,7 +1517,6 @@ export const PADDLING_POOL_257 = () =>
|
||||
abDivision: null,
|
||||
activeRosterUserIds: [],
|
||||
createdAt: 1709738831,
|
||||
inviteCode: null,
|
||||
memberUserIds: [9125, 14489, 20755, 11056, 175],
|
||||
ownerUserId: 9125,
|
||||
checkIns: [
|
||||
@@ -1545,7 +1545,6 @@ export const PADDLING_POOL_257 = () =>
|
||||
abDivision: null,
|
||||
activeRosterUserIds: [],
|
||||
createdAt: 1709737837,
|
||||
inviteCode: null,
|
||||
memberUserIds: [22746, 21979, 11627, 12976],
|
||||
ownerUserId: 22746,
|
||||
checkIns: [
|
||||
@@ -1574,7 +1573,6 @@ export const PADDLING_POOL_257 = () =>
|
||||
abDivision: null,
|
||||
activeRosterUserIds: [],
|
||||
createdAt: 1709741719,
|
||||
inviteCode: null,
|
||||
memberUserIds: [30449, 6508, 32579, 24585, 27515, 11151],
|
||||
ownerUserId: 30449,
|
||||
checkIns: [
|
||||
@@ -1603,7 +1601,6 @@ export const PADDLING_POOL_257 = () =>
|
||||
abDivision: null,
|
||||
activeRosterUserIds: [],
|
||||
createdAt: 1709730354,
|
||||
inviteCode: null,
|
||||
memberUserIds: [3161, 379, 8480, 5229, 7336],
|
||||
ownerUserId: 3161,
|
||||
checkIns: [
|
||||
@@ -1632,7 +1629,6 @@ export const PADDLING_POOL_257 = () =>
|
||||
abDivision: null,
|
||||
activeRosterUserIds: [],
|
||||
createdAt: 1709745630,
|
||||
inviteCode: null,
|
||||
memberUserIds: [28482, 9403, 18039, 38062, 25210],
|
||||
ownerUserId: 28482,
|
||||
checkIns: [
|
||||
@@ -1659,7 +1655,6 @@ export const PADDLING_POOL_257 = () =>
|
||||
abDivision: null,
|
||||
activeRosterUserIds: [],
|
||||
createdAt: 1709592381,
|
||||
inviteCode: null,
|
||||
memberUserIds: [30582, 29321, 3930, 1740, 5368, 29433],
|
||||
ownerUserId: 30582,
|
||||
checkIns: [
|
||||
@@ -1688,7 +1683,6 @@ export const PADDLING_POOL_257 = () =>
|
||||
abDivision: null,
|
||||
activeRosterUserIds: [],
|
||||
createdAt: 1709723749,
|
||||
inviteCode: null,
|
||||
memberUserIds: [32155, 35118, 30730, 30758, 76],
|
||||
ownerUserId: 32155,
|
||||
checkIns: [
|
||||
@@ -1717,7 +1711,6 @@ export const PADDLING_POOL_257 = () =>
|
||||
abDivision: null,
|
||||
activeRosterUserIds: [],
|
||||
createdAt: 1709668399,
|
||||
inviteCode: null,
|
||||
memberUserIds: [18734, 28851, 13832, 23307, 20143, 27108],
|
||||
ownerUserId: 18734,
|
||||
checkIns: [
|
||||
@@ -1746,7 +1739,6 @@ export const PADDLING_POOL_257 = () =>
|
||||
abDivision: null,
|
||||
activeRosterUserIds: [],
|
||||
createdAt: 1709735267,
|
||||
inviteCode: null,
|
||||
memberUserIds: [19035, 23016, 6053, 23858],
|
||||
ownerUserId: 19035,
|
||||
checkIns: [
|
||||
@@ -1780,7 +1772,6 @@ export const PADDLING_POOL_257 = () =>
|
||||
abDivision: null,
|
||||
activeRosterUserIds: [],
|
||||
createdAt: 1709745849,
|
||||
inviteCode: null,
|
||||
memberUserIds: [6870, 29904, 20698, 23820],
|
||||
ownerUserId: 6870,
|
||||
checkIns: [
|
||||
@@ -1814,7 +1805,6 @@ export const PADDLING_POOL_257 = () =>
|
||||
abDivision: null,
|
||||
activeRosterUserIds: [],
|
||||
createdAt: 1709742258,
|
||||
inviteCode: null,
|
||||
memberUserIds: [7007, 30220, 7440, 26665, 27686],
|
||||
ownerUserId: 7007,
|
||||
checkIns: [
|
||||
@@ -1841,7 +1831,6 @@ export const PADDLING_POOL_257 = () =>
|
||||
abDivision: null,
|
||||
activeRosterUserIds: [],
|
||||
createdAt: 1709738744,
|
||||
inviteCode: null,
|
||||
memberUserIds: [11017, 7270, 18703, 27578, 8715],
|
||||
ownerUserId: 11017,
|
||||
checkIns: [
|
||||
@@ -1870,7 +1859,6 @@ export const PADDLING_POOL_257 = () =>
|
||||
abDivision: null,
|
||||
activeRosterUserIds: [],
|
||||
createdAt: 1709746054,
|
||||
inviteCode: null,
|
||||
memberUserIds: [9874, 22434, 10992, 2302],
|
||||
ownerUserId: 9874,
|
||||
checkIns: [
|
||||
@@ -1902,7 +1890,6 @@ export const PADDLING_POOL_257 = () =>
|
||||
abDivision: null,
|
||||
activeRosterUserIds: [],
|
||||
createdAt: 1709744894,
|
||||
inviteCode: null,
|
||||
memberUserIds: [22948, 35807, 23505, 271],
|
||||
ownerUserId: 22948,
|
||||
checkIns: [
|
||||
@@ -1931,7 +1918,6 @@ export const PADDLING_POOL_257 = () =>
|
||||
abDivision: null,
|
||||
activeRosterUserIds: [],
|
||||
createdAt: 1709728278,
|
||||
inviteCode: null,
|
||||
memberUserIds: [8868, 22452, 11625, 11716],
|
||||
ownerUserId: 8868,
|
||||
checkIns: [
|
||||
@@ -1960,7 +1946,6 @@ export const PADDLING_POOL_257 = () =>
|
||||
abDivision: null,
|
||||
activeRosterUserIds: [],
|
||||
createdAt: 1709715006,
|
||||
inviteCode: null,
|
||||
memberUserIds: [3929, 10883, 27664, 2137],
|
||||
ownerUserId: 3929,
|
||||
checkIns: [
|
||||
@@ -1989,7 +1974,6 @@ export const PADDLING_POOL_257 = () =>
|
||||
abDivision: null,
|
||||
activeRosterUserIds: [],
|
||||
createdAt: 1709660578,
|
||||
inviteCode: null,
|
||||
memberUserIds: [21698, 12214, 29723, 28602],
|
||||
ownerUserId: 21698,
|
||||
checkIns: [
|
||||
@@ -2023,7 +2007,6 @@ export const PADDLING_POOL_257 = () =>
|
||||
abDivision: null,
|
||||
activeRosterUserIds: [],
|
||||
createdAt: 1709721869,
|
||||
inviteCode: null,
|
||||
memberUserIds: [24705, 9367, 28191, 34238, 2781],
|
||||
ownerUserId: 24705,
|
||||
checkIns: [
|
||||
@@ -2055,7 +2038,6 @@ export const PADDLING_POOL_257 = () =>
|
||||
abDivision: null,
|
||||
activeRosterUserIds: [],
|
||||
createdAt: 1709743633,
|
||||
inviteCode: null,
|
||||
memberUserIds: [5528, 6935, 8243, 19002],
|
||||
ownerUserId: 5528,
|
||||
checkIns: [
|
||||
@@ -2089,7 +2071,6 @@ export const PADDLING_POOL_257 = () =>
|
||||
abDivision: null,
|
||||
activeRosterUserIds: [],
|
||||
createdAt: 1709738747,
|
||||
inviteCode: null,
|
||||
memberUserIds: [12526, 28822, 24441, 28629, 9211],
|
||||
ownerUserId: 12526,
|
||||
checkIns: [
|
||||
@@ -2123,7 +2104,6 @@ export const PADDLING_POOL_257 = () =>
|
||||
abDivision: null,
|
||||
activeRosterUserIds: [],
|
||||
createdAt: 1709626047,
|
||||
inviteCode: null,
|
||||
memberUserIds: [25545, 368, 1624, 25088, 15526],
|
||||
ownerUserId: 25545,
|
||||
checkIns: [
|
||||
@@ -2152,7 +2132,6 @@ export const PADDLING_POOL_257 = () =>
|
||||
abDivision: null,
|
||||
activeRosterUserIds: [],
|
||||
createdAt: 1709727951,
|
||||
inviteCode: null,
|
||||
memberUserIds: [9411, 18143, 30942, 21583],
|
||||
ownerUserId: 9411,
|
||||
checkIns: [
|
||||
@@ -2181,7 +2160,6 @@ export const PADDLING_POOL_257 = () =>
|
||||
abDivision: null,
|
||||
activeRosterUserIds: [],
|
||||
createdAt: 1709741482,
|
||||
inviteCode: null,
|
||||
memberUserIds: [27529, 9307, 5735, 10010, 17798, 11635],
|
||||
ownerUserId: 27529,
|
||||
checkIns: [
|
||||
@@ -2208,7 +2186,6 @@ export const PADDLING_POOL_257 = () =>
|
||||
abDivision: null,
|
||||
activeRosterUserIds: [],
|
||||
createdAt: 1709744451,
|
||||
inviteCode: null,
|
||||
memberUserIds: [28834, 28271, 34715, 12188],
|
||||
ownerUserId: 28834,
|
||||
checkIns: [
|
||||
@@ -2242,7 +2219,6 @@ export const PADDLING_POOL_257 = () =>
|
||||
abDivision: null,
|
||||
activeRosterUserIds: [],
|
||||
createdAt: 1709726536,
|
||||
inviteCode: null,
|
||||
memberUserIds: [19231, 26454, 5740, 27468],
|
||||
ownerUserId: 19231,
|
||||
checkIns: [
|
||||
@@ -2272,7 +2248,6 @@ export const PADDLING_POOL_257 = () =>
|
||||
abDivision: null,
|
||||
activeRosterUserIds: [],
|
||||
createdAt: 1709558706,
|
||||
inviteCode: null,
|
||||
memberUserIds: [32688, 28194, 11226, 10370],
|
||||
ownerUserId: 32688,
|
||||
checkIns: [
|
||||
@@ -2306,7 +2281,6 @@ export const PADDLING_POOL_257 = () =>
|
||||
abDivision: null,
|
||||
activeRosterUserIds: [],
|
||||
createdAt: 1709744323,
|
||||
inviteCode: null,
|
||||
memberUserIds: [20063, 35382, 23037, 27768, 38120],
|
||||
ownerUserId: 20063,
|
||||
checkIns: [
|
||||
@@ -2340,7 +2314,6 @@ export const PADDLING_POOL_257 = () =>
|
||||
abDivision: null,
|
||||
activeRosterUserIds: [],
|
||||
createdAt: 1709677397,
|
||||
inviteCode: null,
|
||||
memberUserIds: [23009, 28178, 23353, 31658],
|
||||
ownerUserId: 23009,
|
||||
checkIns: [
|
||||
@@ -2369,7 +2342,6 @@ export const PADDLING_POOL_257 = () =>
|
||||
abDivision: null,
|
||||
activeRosterUserIds: [],
|
||||
createdAt: 1709618711,
|
||||
inviteCode: null,
|
||||
memberUserIds: [17734, 28338, 30928, 37658, 26758],
|
||||
ownerUserId: 17734,
|
||||
checkIns: [
|
||||
@@ -3922,6 +3894,13 @@ export const PADDLING_POOL_255 = () =>
|
||||
customUrl: "alicetheto",
|
||||
customAvatarUrl: null,
|
||||
},
|
||||
permissions: {
|
||||
ADMIN: [860],
|
||||
ORGANIZE: [860],
|
||||
MANAGE_MATCHES: [860],
|
||||
EDIT_EVENT_INFO: [860],
|
||||
EDIT_IN_GAME_NAMES: [],
|
||||
},
|
||||
staff: [
|
||||
{
|
||||
id: 1536,
|
||||
@@ -3984,7 +3963,6 @@ export const PADDLING_POOL_255 = () =>
|
||||
abDivision: null,
|
||||
activeRosterUserIds: [],
|
||||
createdAt: 1708476597,
|
||||
inviteCode: null,
|
||||
memberUserIds: [11517, 62, 4275, 11593],
|
||||
ownerUserId: 11517,
|
||||
checkIns: [
|
||||
@@ -4013,7 +3991,6 @@ export const PADDLING_POOL_255 = () =>
|
||||
abDivision: null,
|
||||
activeRosterUserIds: [],
|
||||
createdAt: 1708535137,
|
||||
inviteCode: null,
|
||||
memberUserIds: [1447, 31358, 32579, 27515],
|
||||
ownerUserId: 1447,
|
||||
checkIns: [
|
||||
@@ -4042,7 +4019,6 @@ export const PADDLING_POOL_255 = () =>
|
||||
abDivision: null,
|
||||
activeRosterUserIds: [],
|
||||
createdAt: 1708533764,
|
||||
inviteCode: null,
|
||||
memberUserIds: [10812, 27958, 4061, 38062, 2414],
|
||||
ownerUserId: 10812,
|
||||
checkIns: [
|
||||
@@ -4069,7 +4045,6 @@ export const PADDLING_POOL_255 = () =>
|
||||
abDivision: null,
|
||||
activeRosterUserIds: [],
|
||||
createdAt: 1708537512,
|
||||
inviteCode: null,
|
||||
memberUserIds: [9535, 4234, 331, 205, 319],
|
||||
ownerUserId: 9535,
|
||||
checkIns: [
|
||||
@@ -4098,7 +4073,6 @@ export const PADDLING_POOL_255 = () =>
|
||||
abDivision: null,
|
||||
activeRosterUserIds: [],
|
||||
createdAt: 1708533309,
|
||||
inviteCode: null,
|
||||
memberUserIds: [22661, 3400, 7094, 3161],
|
||||
ownerUserId: 22661,
|
||||
checkIns: [
|
||||
@@ -4127,7 +4101,6 @@ export const PADDLING_POOL_255 = () =>
|
||||
abDivision: null,
|
||||
activeRosterUserIds: [],
|
||||
createdAt: 1708430641,
|
||||
inviteCode: null,
|
||||
memberUserIds: [15326, 13562, 8852, 23746, 28485],
|
||||
ownerUserId: 15326,
|
||||
checkIns: [
|
||||
@@ -4156,7 +4129,6 @@ export const PADDLING_POOL_255 = () =>
|
||||
abDivision: null,
|
||||
activeRosterUserIds: [],
|
||||
createdAt: 1708536306,
|
||||
inviteCode: null,
|
||||
memberUserIds: [115, 10578, 11151, 222],
|
||||
ownerUserId: 115,
|
||||
checkIns: [
|
||||
@@ -4183,7 +4155,6 @@ export const PADDLING_POOL_255 = () =>
|
||||
abDivision: null,
|
||||
activeRosterUserIds: [],
|
||||
createdAt: 1708526368,
|
||||
inviteCode: null,
|
||||
memberUserIds: [15880, 10615, 31690, 6710],
|
||||
ownerUserId: 15880,
|
||||
checkIns: [
|
||||
@@ -4212,7 +4183,6 @@ export const PADDLING_POOL_255 = () =>
|
||||
abDivision: null,
|
||||
activeRosterUserIds: [],
|
||||
createdAt: 1708506060,
|
||||
inviteCode: null,
|
||||
memberUserIds: [9125, 14489, 28725, 16, 20755, 4606],
|
||||
ownerUserId: 9125,
|
||||
checkIns: [
|
||||
@@ -4241,7 +4211,6 @@ export const PADDLING_POOL_255 = () =>
|
||||
abDivision: null,
|
||||
activeRosterUserIds: [],
|
||||
createdAt: 1708526814,
|
||||
inviteCode: null,
|
||||
memberUserIds: [22746, 12976, 21979, 11627],
|
||||
ownerUserId: 22746,
|
||||
checkIns: [
|
||||
@@ -4268,7 +4237,6 @@ export const PADDLING_POOL_255 = () =>
|
||||
abDivision: null,
|
||||
activeRosterUserIds: [],
|
||||
createdAt: 1708466421,
|
||||
inviteCode: null,
|
||||
memberUserIds: [22948, 38080, 35807, 23128, 26392],
|
||||
ownerUserId: 22948,
|
||||
checkIns: [
|
||||
@@ -4297,7 +4265,6 @@ export const PADDLING_POOL_255 = () =>
|
||||
abDivision: null,
|
||||
activeRosterUserIds: [],
|
||||
createdAt: 1708377426,
|
||||
inviteCode: null,
|
||||
memberUserIds: [18734, 23307, 28851, 27108, 23820],
|
||||
ownerUserId: 18734,
|
||||
checkIns: [
|
||||
@@ -4324,7 +4291,6 @@ export const PADDLING_POOL_255 = () =>
|
||||
abDivision: null,
|
||||
activeRosterUserIds: [],
|
||||
createdAt: 1708448289,
|
||||
inviteCode: null,
|
||||
memberUserIds: [30582, 1740, 3930, 29321, 29433, 5368],
|
||||
ownerUserId: 30582,
|
||||
checkIns: [
|
||||
@@ -4353,7 +4319,6 @@ export const PADDLING_POOL_255 = () =>
|
||||
abDivision: null,
|
||||
activeRosterUserIds: [],
|
||||
createdAt: 1708532602,
|
||||
inviteCode: null,
|
||||
memberUserIds: [25400, 15346, 13296, 13832],
|
||||
ownerUserId: 25400,
|
||||
checkIns: [
|
||||
@@ -4382,7 +4347,6 @@ export const PADDLING_POOL_255 = () =>
|
||||
abDivision: null,
|
||||
activeRosterUserIds: [],
|
||||
createdAt: 1708535205,
|
||||
inviteCode: null,
|
||||
memberUserIds: [24585, 30449, 28441, 12619, 31131],
|
||||
ownerUserId: 24585,
|
||||
checkIns: [
|
||||
@@ -4411,7 +4375,6 @@ export const PADDLING_POOL_255 = () =>
|
||||
abDivision: null,
|
||||
activeRosterUserIds: [],
|
||||
createdAt: 1708515945,
|
||||
inviteCode: null,
|
||||
memberUserIds: [6508, 418, 23858, 28662],
|
||||
ownerUserId: 6508,
|
||||
checkIns: [
|
||||
@@ -4440,7 +4403,6 @@ export const PADDLING_POOL_255 = () =>
|
||||
abDivision: null,
|
||||
activeRosterUserIds: [],
|
||||
createdAt: 1708453334,
|
||||
inviteCode: null,
|
||||
memberUserIds: [12351, 5072, 13997, 7735],
|
||||
ownerUserId: 12351,
|
||||
checkIns: [
|
||||
@@ -4467,7 +4429,6 @@ export const PADDLING_POOL_255 = () =>
|
||||
abDivision: null,
|
||||
activeRosterUserIds: [],
|
||||
createdAt: 1708522730,
|
||||
inviteCode: null,
|
||||
memberUserIds: [11017, 7270, 18703, 27578, 8715],
|
||||
ownerUserId: 11017,
|
||||
checkIns: [
|
||||
@@ -4496,7 +4457,6 @@ export const PADDLING_POOL_255 = () =>
|
||||
abDivision: null,
|
||||
activeRosterUserIds: [],
|
||||
createdAt: 1708375443,
|
||||
inviteCode: null,
|
||||
memberUserIds: [15526, 27686, 10841, 23784, 9342],
|
||||
ownerUserId: 15526,
|
||||
checkIns: [
|
||||
@@ -4525,7 +4485,6 @@ export const PADDLING_POOL_255 = () =>
|
||||
abDivision: null,
|
||||
activeRosterUserIds: [],
|
||||
createdAt: 1708532665,
|
||||
inviteCode: null,
|
||||
memberUserIds: [6870, 29904, 11506, 32328],
|
||||
ownerUserId: 6870,
|
||||
checkIns: [
|
||||
@@ -4559,7 +4518,6 @@ export const PADDLING_POOL_255 = () =>
|
||||
abDivision: null,
|
||||
activeRosterUserIds: [],
|
||||
createdAt: 1708364254,
|
||||
inviteCode: null,
|
||||
memberUserIds: [3929, 2137, 10883, 27664, 342],
|
||||
ownerUserId: 3929,
|
||||
checkIns: [
|
||||
@@ -4593,7 +4551,6 @@ export const PADDLING_POOL_255 = () =>
|
||||
abDivision: null,
|
||||
activeRosterUserIds: [],
|
||||
createdAt: 1708464101,
|
||||
inviteCode: null,
|
||||
memberUserIds: [8169, 2535, 2670, 6202, 9547],
|
||||
ownerUserId: 8169,
|
||||
checkIns: [
|
||||
@@ -4625,7 +4582,6 @@ export const PADDLING_POOL_255 = () =>
|
||||
abDivision: null,
|
||||
activeRosterUserIds: [],
|
||||
createdAt: 1708520249,
|
||||
inviteCode: null,
|
||||
memberUserIds: [3060, 5844, 9654, 461, 10819],
|
||||
ownerUserId: 3060,
|
||||
checkIns: [
|
||||
@@ -4654,7 +4610,6 @@ export const PADDLING_POOL_255 = () =>
|
||||
abDivision: null,
|
||||
activeRosterUserIds: [],
|
||||
createdAt: 1708535804,
|
||||
inviteCode: null,
|
||||
memberUserIds: [2302, 9874, 10992, 22434],
|
||||
ownerUserId: 2302,
|
||||
checkIns: [
|
||||
@@ -4683,7 +4638,6 @@ export const PADDLING_POOL_255 = () =>
|
||||
abDivision: null,
|
||||
activeRosterUserIds: [],
|
||||
createdAt: 1708535891,
|
||||
inviteCode: null,
|
||||
memberUserIds: [8868, 11625, 22452, 12642, 11716],
|
||||
ownerUserId: 8868,
|
||||
checkIns: [
|
||||
@@ -4712,7 +4666,6 @@ export const PADDLING_POOL_255 = () =>
|
||||
abDivision: null,
|
||||
activeRosterUserIds: [],
|
||||
createdAt: 1708521749,
|
||||
inviteCode: null,
|
||||
memberUserIds: [7007, 7440, 26665, 9719, 30220],
|
||||
ownerUserId: 7007,
|
||||
checkIns: [
|
||||
@@ -4741,7 +4694,6 @@ export const PADDLING_POOL_255 = () =>
|
||||
abDivision: null,
|
||||
activeRosterUserIds: [],
|
||||
createdAt: 1708536584,
|
||||
inviteCode: null,
|
||||
memberUserIds: [2589, 28745, 32688, 26792],
|
||||
ownerUserId: 2589,
|
||||
checkIns: [
|
||||
@@ -4770,7 +4722,6 @@ export const PADDLING_POOL_255 = () =>
|
||||
abDivision: null,
|
||||
activeRosterUserIds: [],
|
||||
createdAt: 1708537772,
|
||||
inviteCode: null,
|
||||
memberUserIds: [6935, 5528, 12358, 19002, 306, 8243],
|
||||
ownerUserId: 6935,
|
||||
checkIns: [
|
||||
@@ -4805,7 +4756,6 @@ export const PADDLING_POOL_255 = () =>
|
||||
abDivision: null,
|
||||
activeRosterUserIds: [],
|
||||
createdAt: 1708379916,
|
||||
inviteCode: null,
|
||||
memberUserIds: [25242, 13736, 27960, 25814, 31311],
|
||||
ownerUserId: 25242,
|
||||
checkIns: [
|
||||
@@ -4834,7 +4784,6 @@ export const PADDLING_POOL_255 = () =>
|
||||
abDivision: null,
|
||||
activeRosterUserIds: [],
|
||||
createdAt: 1708519753,
|
||||
inviteCode: null,
|
||||
memberUserIds: [12526, 24441, 28629, 28822, 9211],
|
||||
ownerUserId: 12526,
|
||||
checkIns: [
|
||||
@@ -4868,7 +4817,6 @@ export const PADDLING_POOL_255 = () =>
|
||||
abDivision: null,
|
||||
activeRosterUserIds: [],
|
||||
createdAt: 1708534312,
|
||||
inviteCode: null,
|
||||
memberUserIds: [21989, 30234, 35118, 32156, 30751],
|
||||
ownerUserId: 21989,
|
||||
checkIns: [
|
||||
@@ -4900,7 +4848,6 @@ export const PADDLING_POOL_255 = () =>
|
||||
abDivision: null,
|
||||
activeRosterUserIds: [],
|
||||
createdAt: 1708531929,
|
||||
inviteCode: null,
|
||||
memberUserIds: [4807, 16634, 2392, 26726],
|
||||
ownerUserId: 4807,
|
||||
checkIns: [
|
||||
@@ -4929,7 +4876,6 @@ export const PADDLING_POOL_255 = () =>
|
||||
abDivision: null,
|
||||
activeRosterUserIds: [],
|
||||
createdAt: 1708477155,
|
||||
inviteCode: null,
|
||||
memberUserIds: [25954, 14973, 17137, 8391],
|
||||
ownerUserId: 25954,
|
||||
checkIns: [
|
||||
@@ -4963,7 +4909,6 @@ export const PADDLING_POOL_255 = () =>
|
||||
abDivision: null,
|
||||
activeRosterUserIds: [],
|
||||
createdAt: 1708531564,
|
||||
inviteCode: null,
|
||||
memberUserIds: [18969, 17940, 30972, 38902, 29099, 26683, 30767],
|
||||
ownerUserId: 18969,
|
||||
checkIns: [
|
||||
@@ -4997,7 +4942,6 @@ export const PADDLING_POOL_255 = () =>
|
||||
abDivision: null,
|
||||
activeRosterUserIds: [],
|
||||
createdAt: 1708503356,
|
||||
inviteCode: null,
|
||||
memberUserIds: [18290, 15135, 31597, 34933, 14927, 7461],
|
||||
ownerUserId: 18290,
|
||||
checkIns: [
|
||||
@@ -6439,6 +6383,13 @@ export const IN_THE_ZONE_32 = ({
|
||||
customUrl: "sendou",
|
||||
customAvatarUrl: null,
|
||||
},
|
||||
permissions: {
|
||||
ADMIN: [274],
|
||||
ORGANIZE: [274],
|
||||
MANAGE_MATCHES: [274],
|
||||
EDIT_EVENT_INFO: [274],
|
||||
EDIT_IN_GAME_NAMES: [],
|
||||
},
|
||||
staff: [
|
||||
{
|
||||
id: 860,
|
||||
@@ -6483,7 +6434,6 @@ export const IN_THE_ZONE_32 = ({
|
||||
abDivision: null,
|
||||
activeRosterUserIds: [],
|
||||
createdAt: 1707443313,
|
||||
inviteCode: null,
|
||||
memberUserIds: [147, 118, 133, 257],
|
||||
ownerUserId: 147,
|
||||
checkIns: [
|
||||
@@ -6507,7 +6457,6 @@ export const IN_THE_ZONE_32 = ({
|
||||
abDivision: null,
|
||||
activeRosterUserIds: [],
|
||||
createdAt: 1707366405,
|
||||
inviteCode: null,
|
||||
memberUserIds: [100, 164, 138, 305],
|
||||
ownerUserId: 100,
|
||||
checkIns: [
|
||||
@@ -6531,7 +6480,6 @@ export const IN_THE_ZONE_32 = ({
|
||||
abDivision: null,
|
||||
activeRosterUserIds: [],
|
||||
createdAt: 1706912643,
|
||||
inviteCode: null,
|
||||
memberUserIds: [32, 123, 1043, 702],
|
||||
ownerUserId: 32,
|
||||
checkIns: [
|
||||
@@ -6555,7 +6503,6 @@ export const IN_THE_ZONE_32 = ({
|
||||
abDivision: null,
|
||||
activeRosterUserIds: [],
|
||||
createdAt: 1707359335,
|
||||
inviteCode: null,
|
||||
memberUserIds: [11517, 439, 81, 23381, 26278, 104],
|
||||
ownerUserId: 11517,
|
||||
checkIns: [
|
||||
@@ -6579,7 +6526,6 @@ export const IN_THE_ZONE_32 = ({
|
||||
abDivision: null,
|
||||
activeRosterUserIds: [],
|
||||
createdAt: 1707171426,
|
||||
inviteCode: null,
|
||||
memberUserIds: [185, 10293, 5728, 35, 99],
|
||||
ownerUserId: 185,
|
||||
checkIns: [
|
||||
@@ -6603,7 +6549,6 @@ export const IN_THE_ZONE_32 = ({
|
||||
abDivision: null,
|
||||
activeRosterUserIds: [],
|
||||
createdAt: 1707342696,
|
||||
inviteCode: null,
|
||||
memberUserIds: [145, 48, 172, 126, 160],
|
||||
ownerUserId: 145,
|
||||
checkIns: [
|
||||
@@ -6627,7 +6572,6 @@ export const IN_THE_ZONE_32 = ({
|
||||
abDivision: null,
|
||||
activeRosterUserIds: [],
|
||||
createdAt: 1707513942,
|
||||
inviteCode: null,
|
||||
memberUserIds: [22893, 957, 25423, 20583, 1105, 14],
|
||||
ownerUserId: 22893,
|
||||
checkIns: [
|
||||
@@ -6651,7 +6595,6 @@ export const IN_THE_ZONE_32 = ({
|
||||
abDivision: null,
|
||||
activeRosterUserIds: [],
|
||||
createdAt: 1707526815,
|
||||
inviteCode: null,
|
||||
memberUserIds: [249, 451, 141, 260, 202, 276],
|
||||
ownerUserId: 249,
|
||||
checkIns: [
|
||||
@@ -6675,7 +6618,6 @@ export const IN_THE_ZONE_32 = ({
|
||||
abDivision: null,
|
||||
activeRosterUserIds: [],
|
||||
createdAt: 1707583385,
|
||||
inviteCode: null,
|
||||
memberUserIds: [70, 124, 255, 252],
|
||||
ownerUserId: 70,
|
||||
checkIns: [
|
||||
@@ -6699,7 +6641,6 @@ export const IN_THE_ZONE_32 = ({
|
||||
abDivision: null,
|
||||
activeRosterUserIds: [],
|
||||
createdAt: 1707486395,
|
||||
inviteCode: null,
|
||||
memberUserIds: [18, 151, 15418, 411],
|
||||
ownerUserId: 18,
|
||||
checkIns: [
|
||||
@@ -6723,7 +6664,6 @@ export const IN_THE_ZONE_32 = ({
|
||||
abDivision: null,
|
||||
activeRosterUserIds: [],
|
||||
createdAt: 1707513290,
|
||||
inviteCode: null,
|
||||
memberUserIds: [76, 3298, 5337, 6557],
|
||||
ownerUserId: 76,
|
||||
checkIns: [
|
||||
@@ -6747,7 +6687,6 @@ export const IN_THE_ZONE_32 = ({
|
||||
abDivision: null,
|
||||
activeRosterUserIds: [],
|
||||
createdAt: 1707531084,
|
||||
inviteCode: null,
|
||||
memberUserIds: [112, 57, 1658, 2801],
|
||||
ownerUserId: 112,
|
||||
checkIns: [
|
||||
@@ -6771,7 +6710,6 @@ export const IN_THE_ZONE_32 = ({
|
||||
abDivision: null,
|
||||
activeRosterUserIds: [],
|
||||
createdAt: 1707568466,
|
||||
inviteCode: null,
|
||||
memberUserIds: [319, 291, 372, 392, 7326],
|
||||
ownerUserId: 319,
|
||||
checkIns: [
|
||||
@@ -6795,7 +6733,6 @@ export const IN_THE_ZONE_32 = ({
|
||||
abDivision: null,
|
||||
activeRosterUserIds: [],
|
||||
createdAt: 1707481625,
|
||||
inviteCode: null,
|
||||
memberUserIds: [211, 286, 206, 11010],
|
||||
ownerUserId: 211,
|
||||
checkIns: [
|
||||
@@ -6819,7 +6756,6 @@ export const IN_THE_ZONE_32 = ({
|
||||
abDivision: null,
|
||||
activeRosterUserIds: [],
|
||||
createdAt: 1707530166,
|
||||
inviteCode: null,
|
||||
memberUserIds: [41, 94, 232, 233],
|
||||
ownerUserId: 41,
|
||||
checkIns: [
|
||||
@@ -6843,7 +6779,6 @@ export const IN_THE_ZONE_32 = ({
|
||||
abDivision: null,
|
||||
activeRosterUserIds: [],
|
||||
createdAt: 1707181792,
|
||||
inviteCode: null,
|
||||
memberUserIds: [1300, 12976, 15916, 27958, 4603],
|
||||
ownerUserId: 1300,
|
||||
checkIns: [
|
||||
@@ -6867,7 +6802,6 @@ export const IN_THE_ZONE_32 = ({
|
||||
abDivision: null,
|
||||
activeRosterUserIds: [],
|
||||
createdAt: 1707550321,
|
||||
inviteCode: null,
|
||||
memberUserIds: [22344, 10200, 229, 246, 14564],
|
||||
ownerUserId: 22344,
|
||||
checkIns: [
|
||||
@@ -6896,7 +6830,6 @@ export const IN_THE_ZONE_32 = ({
|
||||
abDivision: null,
|
||||
activeRosterUserIds: [],
|
||||
createdAt: 1707575096,
|
||||
inviteCode: null,
|
||||
memberUserIds: [236, 8734, 16980, 272, 6710],
|
||||
ownerUserId: 236,
|
||||
checkIns: [
|
||||
@@ -6920,7 +6853,6 @@ export const IN_THE_ZONE_32 = ({
|
||||
abDivision: null,
|
||||
activeRosterUserIds: [],
|
||||
createdAt: 1707569490,
|
||||
inviteCode: null,
|
||||
memberUserIds: [321, 11593, 50, 8, 331, 132],
|
||||
ownerUserId: 321,
|
||||
checkIns: [
|
||||
@@ -6944,7 +6876,6 @@ export const IN_THE_ZONE_32 = ({
|
||||
abDivision: null,
|
||||
activeRosterUserIds: [],
|
||||
createdAt: 1707537425,
|
||||
inviteCode: null,
|
||||
memberUserIds: [1117, 36, 335, 292],
|
||||
ownerUserId: 1117,
|
||||
checkIns: [
|
||||
@@ -6968,7 +6899,6 @@ export const IN_THE_ZONE_32 = ({
|
||||
abDivision: null,
|
||||
activeRosterUserIds: [],
|
||||
createdAt: 1707564691,
|
||||
inviteCode: null,
|
||||
memberUserIds: [2414, 4061, 10592, 10812, 115, 12395],
|
||||
ownerUserId: 2414,
|
||||
checkIns: [
|
||||
@@ -6997,7 +6927,6 @@ export const IN_THE_ZONE_32 = ({
|
||||
abDivision: null,
|
||||
activeRosterUserIds: [],
|
||||
createdAt: 1707145818,
|
||||
inviteCode: null,
|
||||
memberUserIds: [11007, 71, 204, 4762, 6048],
|
||||
ownerUserId: 11007,
|
||||
checkIns: [],
|
||||
@@ -7015,7 +6944,6 @@ export const IN_THE_ZONE_32 = ({
|
||||
abDivision: null,
|
||||
activeRosterUserIds: [],
|
||||
createdAt: 1707558330,
|
||||
inviteCode: null,
|
||||
memberUserIds: [9125, 14489, 26392, 28725],
|
||||
ownerUserId: 9125,
|
||||
checkIns: [
|
||||
@@ -7039,7 +6967,6 @@ export const IN_THE_ZONE_32 = ({
|
||||
abDivision: null,
|
||||
activeRosterUserIds: [],
|
||||
createdAt: 1707586842,
|
||||
inviteCode: null,
|
||||
memberUserIds: [6307, 15403, 9718, 18039],
|
||||
ownerUserId: 6307,
|
||||
checkIns: [
|
||||
@@ -7063,7 +6990,6 @@ export const IN_THE_ZONE_32 = ({
|
||||
abDivision: null,
|
||||
activeRosterUserIds: [],
|
||||
createdAt: 1707583597,
|
||||
inviteCode: null,
|
||||
memberUserIds: [18734, 3258, 27108, 20143, 23820, 28851],
|
||||
ownerUserId: 18734,
|
||||
checkIns: [
|
||||
@@ -7087,7 +7013,6 @@ export const IN_THE_ZONE_32 = ({
|
||||
abDivision: null,
|
||||
activeRosterUserIds: [],
|
||||
createdAt: 1707429804,
|
||||
inviteCode: null,
|
||||
memberUserIds: [25400, 13296, 15346, 21099, 4099],
|
||||
ownerUserId: 25400,
|
||||
checkIns: [
|
||||
@@ -7111,7 +7036,6 @@ export const IN_THE_ZONE_32 = ({
|
||||
abDivision: null,
|
||||
activeRosterUserIds: [],
|
||||
createdAt: 1707539973,
|
||||
inviteCode: null,
|
||||
memberUserIds: [405, 20859, 13614, 22041, 20731],
|
||||
ownerUserId: 405,
|
||||
checkIns: [
|
||||
@@ -7140,7 +7064,6 @@ export const IN_THE_ZONE_32 = ({
|
||||
abDivision: null,
|
||||
activeRosterUserIds: [],
|
||||
createdAt: 1707507831,
|
||||
inviteCode: null,
|
||||
memberUserIds: [11467, 346, 1038, 6255, 4097],
|
||||
ownerUserId: 11467,
|
||||
checkIns: [
|
||||
@@ -7164,7 +7087,6 @@ export const IN_THE_ZONE_32 = ({
|
||||
abDivision: null,
|
||||
activeRosterUserIds: [],
|
||||
createdAt: 1707586297,
|
||||
inviteCode: null,
|
||||
memberUserIds: [14605, 9674, 28482, 10841, 20154],
|
||||
ownerUserId: 14605,
|
||||
checkIns: [
|
||||
@@ -7186,7 +7108,6 @@ export const IN_THE_ZONE_32 = ({
|
||||
abDivision: null,
|
||||
activeRosterUserIds: [],
|
||||
createdAt: 1707583885,
|
||||
inviteCode: null,
|
||||
memberUserIds: [37105, 4307, 21339, 1136, 1441, 25117],
|
||||
ownerUserId: 37105,
|
||||
checkIns: [
|
||||
@@ -7210,7 +7131,6 @@ export const IN_THE_ZONE_32 = ({
|
||||
abDivision: null,
|
||||
activeRosterUserIds: [],
|
||||
createdAt: 1707578076,
|
||||
inviteCode: null,
|
||||
memberUserIds: [34238, 168, 1178, 9064, 11148],
|
||||
ownerUserId: 34238,
|
||||
checkIns: [
|
||||
@@ -7237,7 +7157,6 @@ export const IN_THE_ZONE_32 = ({
|
||||
abDivision: null,
|
||||
activeRosterUserIds: [],
|
||||
createdAt: 1707582953,
|
||||
inviteCode: null,
|
||||
memberUserIds: [22948, 23128, 38080, 35807],
|
||||
ownerUserId: 22948,
|
||||
checkIns: [
|
||||
@@ -7261,7 +7180,6 @@ export const IN_THE_ZONE_32 = ({
|
||||
abDivision: null,
|
||||
activeRosterUserIds: [],
|
||||
createdAt: 1707575330,
|
||||
inviteCode: null,
|
||||
memberUserIds: [10192, 24705, 9367, 28191, 13342],
|
||||
ownerUserId: 10192,
|
||||
checkIns: [
|
||||
@@ -7292,7 +7210,6 @@ export const IN_THE_ZONE_32 = ({
|
||||
abDivision: null,
|
||||
activeRosterUserIds: [],
|
||||
createdAt: 1707527645,
|
||||
inviteCode: null,
|
||||
memberUserIds: [26942, 8927, 4434],
|
||||
ownerUserId: 26942,
|
||||
checkIns: [],
|
||||
|
||||
@@ -16,7 +16,6 @@ export const tournamentCtxTeam = (
|
||||
startingBracketIdx: null,
|
||||
abDivision: null,
|
||||
hasMapPool: false,
|
||||
inviteCode: null,
|
||||
memberUserIds: [],
|
||||
ownerUserId: null,
|
||||
activeRosterUserIds: [],
|
||||
@@ -89,6 +88,13 @@ export const testTournament = ({
|
||||
],
|
||||
},
|
||||
castedMatchesInfo: null,
|
||||
permissions: {
|
||||
ADMIN: [1],
|
||||
ORGANIZE: [1],
|
||||
MANAGE_MATCHES: [1],
|
||||
EDIT_EVENT_INFO: [1],
|
||||
EDIT_IN_GAME_NAMES: [],
|
||||
},
|
||||
teams: nTeams(participant.length, Math.min(...participant)),
|
||||
author: {
|
||||
customUrl: null,
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { LoaderFunctionArgs } from "react-router";
|
||||
import { resolveNotifications } from "~/features/notifications/core/resolve.server";
|
||||
import * as TournamentTeamRepository from "~/features/tournament/TournamentTeamRepository.server";
|
||||
import type { SerializeFrom } from "~/utils/remix";
|
||||
import type { Bracket } from "../core/Bracket";
|
||||
import type { Tournament } from "../core/Tournament";
|
||||
@@ -36,6 +37,8 @@ export const loader = async ({ params, request }: LoaderFunctionArgs) => {
|
||||
});
|
||||
}
|
||||
|
||||
const ownedTeam = tournament.ownedTeamByUser(user);
|
||||
|
||||
return {
|
||||
bracketIdx,
|
||||
groupId,
|
||||
@@ -45,6 +48,10 @@ export const loader = async ({ params, request }: LoaderFunctionArgs) => {
|
||||
groupId: bracket.preview ? null : groupId,
|
||||
})
|
||||
: null,
|
||||
// the invite link of the add subs popover, only the team's own captain sees it
|
||||
ownTeamInviteCode: ownedTeam
|
||||
? await TournamentTeamRepository.findInviteCodeById(ownedTeam.id)
|
||||
: null,
|
||||
// the layout does not ship these, standings derived in the view need them
|
||||
participatedUserIds: tournament.participatedUserIds,
|
||||
teamProgressStatus: tournament.teamMemberOfProgressStatus(user),
|
||||
|
||||
@@ -32,10 +32,7 @@ const tournamentDivisionsCache = new Map<
|
||||
|
||||
async function divisionsCached(tournamentId: number) {
|
||||
if (!tournamentDivisionsCache.has(tournamentId)) {
|
||||
const tournament = await tournamentFromDB({
|
||||
tournamentId,
|
||||
user: undefined,
|
||||
});
|
||||
const tournament = await tournamentFromDB(tournamentId);
|
||||
|
||||
if (!tournament.isLeagueSignup) {
|
||||
return null;
|
||||
|
||||
@@ -468,9 +468,10 @@ function AddSubsPopOver() {
|
||||
const { copyToClipboard, copySuccess } = useCopyToClipboard();
|
||||
const tournament = useTournament();
|
||||
const user = useUser();
|
||||
const data = useLoaderData<TournamentBracketsLoaderData>();
|
||||
|
||||
const ownedTeam = tournament.ownedTeamByUser(user);
|
||||
if (!ownedTeam) {
|
||||
if (!ownedTeam || !data.ownTeamInviteCode) {
|
||||
const teamMemberOf = tournament.teamMemberOfByUser(user);
|
||||
if (!teamMemberOf) return null;
|
||||
|
||||
@@ -482,7 +483,7 @@ function AddSubsPopOver() {
|
||||
|
||||
const inviteLink = `${SENDOU_INK_BASE_URL}${tournamentJoinPage({
|
||||
tournamentId: tournament.ctx.id,
|
||||
inviteCode: ownedTeam.inviteCode!,
|
||||
inviteCode: data.ownTeamInviteCode,
|
||||
})}`;
|
||||
|
||||
return (
|
||||
|
||||
@@ -3,8 +3,8 @@ import * as R from "remeda";
|
||||
import type { getUser } from "~/features/auth/core/user.server";
|
||||
import { resolveNotifications } from "~/features/notifications/core/resolve.server";
|
||||
import {
|
||||
tournamentFromDBCached,
|
||||
tournamentFromParams,
|
||||
tournamentSharedCached,
|
||||
tournamentTeamsFullCached,
|
||||
} from "~/features/tournament-bracket/core/Tournament.server";
|
||||
import * as UserCardRepository from "~/features/user-card/UserCardRepository.server";
|
||||
@@ -24,7 +24,7 @@ export type LookingLoaderData = SerializeFrom<typeof loader>;
|
||||
export const loader = async ({ params }: LoaderFunctionArgs) => {
|
||||
const { tournament, tournamentId, user } = await tournamentFromParams(
|
||||
params,
|
||||
{ for: "view", personalized: true },
|
||||
{ for: "view" },
|
||||
);
|
||||
|
||||
if (!tournament.lfgEnabled) {
|
||||
@@ -171,10 +171,7 @@ async function resolveOwnTeam({
|
||||
if (!user) return null;
|
||||
if (ownGroup) return null;
|
||||
|
||||
const tournament = await tournamentFromDBCached({
|
||||
tournamentId,
|
||||
user,
|
||||
});
|
||||
const tournament = await tournamentSharedCached(tournamentId);
|
||||
|
||||
const teamLite = tournament.teamMemberOfByUser(user);
|
||||
if (!teamLite) return null;
|
||||
|
||||
@@ -680,7 +680,7 @@ export const action: ActionFunction = async ({ params, request }) => {
|
||||
// update RunningTournaments to make sure sidebar is not showing stale matches at the end
|
||||
// of the tournament in case the TO is not finalizing the tournament right away
|
||||
if (setIsOver) {
|
||||
const refreshedTournament = await tournamentFromDB({ tournamentId, user });
|
||||
const refreshedTournament = await tournamentFromDB(tournamentId);
|
||||
// the teams that just advanced now populate following matches, so their
|
||||
// "waiting for teams" pages need to revalidate too
|
||||
followingMatchIds = refreshedTournament
|
||||
|
||||
175
app/features/tournament/TournamentRepository.permissions.test.ts
Normal file
175
app/features/tournament/TournamentRepository.permissions.test.ts
Normal file
@@ -0,0 +1,175 @@
|
||||
import { beforeEach, describe, expect, test } from "vitest";
|
||||
import * as TournamentFactory from "~/db/seed/factories/TournamentFactory";
|
||||
import * as TournamentOrganizationFactory from "~/db/seed/factories/TournamentOrganizationFactory";
|
||||
import * as UserFactory from "~/db/seed/factories/UserFactory";
|
||||
import invariant from "~/utils/invariant";
|
||||
import * as TournamentRepository from "./TournamentRepository.server";
|
||||
|
||||
const users = UserFactory.pool();
|
||||
|
||||
const authorId = () => users.id(1);
|
||||
const orgAdminId = () => users.id(2);
|
||||
const orgOrganizerId = () => users.id(3);
|
||||
const orgStreamerId = () => users.id(4);
|
||||
const staffOrganizerId = () => users.id(5);
|
||||
const staffStreamerId = () => users.id(6);
|
||||
const outsiderId = () => users.id(7);
|
||||
|
||||
type Members = NonNullable<
|
||||
Parameters<typeof TournamentOrganizationFactory.create>[1]
|
||||
>["members"];
|
||||
type Staff = Parameters<typeof TournamentRepository.setStaff>[0]["staff"];
|
||||
type Grant = Parameters<typeof UserFactory.grant>[1];
|
||||
|
||||
const permissionsOfTournament = async ({
|
||||
members,
|
||||
staff,
|
||||
isEstablished = false,
|
||||
withOrganization = true,
|
||||
}: {
|
||||
members?: Members;
|
||||
staff?: Staff;
|
||||
isEstablished?: boolean;
|
||||
withOrganization?: boolean;
|
||||
} = {}) => {
|
||||
const organization = withOrganization
|
||||
? await TournamentOrganizationFactory.create(
|
||||
{ ownerId: orgAdminId() },
|
||||
{ members, isEstablished },
|
||||
)
|
||||
: null;
|
||||
|
||||
const tournament = await TournamentFactory.create({
|
||||
authorId: authorId(),
|
||||
organizationId: organization?.id ?? null,
|
||||
});
|
||||
|
||||
if (staff?.length) {
|
||||
await TournamentRepository.setStaff({
|
||||
tournamentId: tournament.id,
|
||||
staff,
|
||||
});
|
||||
}
|
||||
|
||||
const found = await TournamentRepository.findById(tournament.id);
|
||||
invariant(found, "Expected to find the tournament");
|
||||
|
||||
return found.permissions;
|
||||
};
|
||||
|
||||
describe("TournamentRepository.findById", () => {
|
||||
beforeEach(async () => {
|
||||
await users.create(7);
|
||||
});
|
||||
|
||||
test("author of an organization-less tournament holds every permission but the in-game names one", async () => {
|
||||
const permissions = await permissionsOfTournament({
|
||||
withOrganization: false,
|
||||
});
|
||||
|
||||
expect(permissions).toEqual({
|
||||
ADMIN: [authorId()],
|
||||
ORGANIZE: [authorId()],
|
||||
MANAGE_MATCHES: [authorId()],
|
||||
EDIT_EVENT_INFO: [authorId()],
|
||||
EDIT_IN_GAME_NAMES: [],
|
||||
});
|
||||
});
|
||||
|
||||
test("organization and staff roles cascade into the wider permissions", async () => {
|
||||
const permissions = await permissionsOfTournament({
|
||||
members: [
|
||||
{ userId: orgOrganizerId(), role: "ORGANIZER" },
|
||||
{ userId: orgStreamerId(), role: "STREAMER" },
|
||||
],
|
||||
staff: [
|
||||
{ userId: staffOrganizerId(), role: "ORGANIZER" },
|
||||
{ userId: staffStreamerId(), role: "STREAMER" },
|
||||
],
|
||||
});
|
||||
|
||||
expect(permissions.ADMIN.sort()).toEqual([authorId(), orgAdminId()].sort());
|
||||
expect(permissions.ORGANIZE.sort()).toEqual(
|
||||
[authorId(), orgAdminId(), orgOrganizerId(), staffOrganizerId()].sort(),
|
||||
);
|
||||
expect(permissions.MANAGE_MATCHES.sort()).toEqual(
|
||||
[
|
||||
authorId(),
|
||||
orgAdminId(),
|
||||
orgOrganizerId(),
|
||||
staffOrganizerId(),
|
||||
orgStreamerId(),
|
||||
staffStreamerId(),
|
||||
].sort(),
|
||||
);
|
||||
expect(permissions.MANAGE_MATCHES).not.toContain(outsiderId());
|
||||
});
|
||||
|
||||
test.each<{ why: string; isEstablished: boolean; grant: Grant }>([
|
||||
{
|
||||
why: "the organization is established",
|
||||
isEstablished: true,
|
||||
grant: {},
|
||||
},
|
||||
{
|
||||
why: "they may add tournaments of their own anyway",
|
||||
isEstablished: false,
|
||||
grant: { roles: ["TOURNAMENT_ORGANIZER"] },
|
||||
},
|
||||
{
|
||||
why: "they are a supporter",
|
||||
isEstablished: false,
|
||||
grant: { patronTier: 2 },
|
||||
},
|
||||
])(
|
||||
"organization admin may edit the event info when $why",
|
||||
async ({ isEstablished, grant }) => {
|
||||
await UserFactory.grant(orgAdminId(), grant);
|
||||
|
||||
const permissions = await permissionsOfTournament({ isEstablished });
|
||||
|
||||
expect(permissions.EDIT_EVENT_INFO.sort()).toEqual(
|
||||
[authorId(), orgAdminId()].sort(),
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
test("organization admin of an unestablished organization may not edit the event info", async () => {
|
||||
const permissions = await permissionsOfTournament({ isEstablished: false });
|
||||
|
||||
expect(permissions.EDIT_EVENT_INFO).toEqual([authorId()]);
|
||||
});
|
||||
|
||||
test("organization organizer may not edit the event info", async () => {
|
||||
const permissions = await permissionsOfTournament({
|
||||
isEstablished: true,
|
||||
members: [{ userId: orgOrganizerId(), role: "ORGANIZER" }],
|
||||
});
|
||||
|
||||
expect(permissions.EDIT_EVENT_INFO).not.toContain(orgOrganizerId());
|
||||
});
|
||||
|
||||
test("in-game names may be edited by the admins and organizers of an established organization", async () => {
|
||||
const permissions = await permissionsOfTournament({
|
||||
isEstablished: true,
|
||||
members: [
|
||||
{ userId: orgOrganizerId(), role: "ORGANIZER" },
|
||||
{ userId: orgStreamerId(), role: "STREAMER" },
|
||||
],
|
||||
staff: [{ userId: staffOrganizerId(), role: "ORGANIZER" }],
|
||||
});
|
||||
|
||||
expect(permissions.EDIT_IN_GAME_NAMES.sort()).toEqual(
|
||||
[orgAdminId(), orgOrganizerId()].sort(),
|
||||
);
|
||||
});
|
||||
|
||||
test("in-game names may not be edited when the organization is not established", async () => {
|
||||
const permissions = await permissionsOfTournament({
|
||||
isEstablished: false,
|
||||
members: [{ userId: orgOrganizerId(), role: "ORGANIZER" }],
|
||||
});
|
||||
|
||||
expect(permissions.EDIT_IN_GAME_NAMES).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -3,7 +3,7 @@ import { type Insertable, type NotNull, sql, type Transaction } from "kysely";
|
||||
import { ordinal } from "openskill";
|
||||
import * as R from "remeda";
|
||||
import { db } from "~/db/sql";
|
||||
import type { DB, Tables } from "~/db/tables";
|
||||
import type { DB, DBBoolean, Tables } from "~/db/tables";
|
||||
import type {
|
||||
CastedMatchesInfo,
|
||||
PreparedMaps,
|
||||
@@ -17,7 +17,9 @@ import type {
|
||||
TournamentBadgeReceivers,
|
||||
TournamentTrophyReceiver,
|
||||
} from "~/features/tournament-bracket/tournament-bracket-schemas";
|
||||
import type { TournamentOrganizationRole } from "~/features/tournament-organization/tournament-organization-constants";
|
||||
import { modesShort } from "~/modules/in-game-lists/modes";
|
||||
import { isSupporter } from "~/modules/permissions/utils";
|
||||
import { nullFilledArray, nullifyingAvg } from "~/utils/arrays";
|
||||
import { databaseTimestampNow, dateToDatabaseTimestamp } from "~/utils/dates";
|
||||
import invariant from "~/utils/invariant";
|
||||
@@ -33,6 +35,7 @@ import {
|
||||
} from "~/utils/kysely.server";
|
||||
import type { Unwrapped } from "~/utils/types";
|
||||
import type { TournamentTierNumber } from "./core/tiering";
|
||||
import type { TournamentStaffRole } from "./tournament-constants";
|
||||
import { updatedCastedMatchesInfo } from "./tournament-utils";
|
||||
|
||||
export type FindById = NonNullable<Unwrapped<typeof findById>>;
|
||||
@@ -98,6 +101,8 @@ export async function findById(id: number) {
|
||||
"TournamentOrganizationMember.role",
|
||||
...commonUserSelect(eb),
|
||||
"User.pronouns",
|
||||
"User.isTournamentOrganizer",
|
||||
"User.patronTier",
|
||||
])
|
||||
.whereRef(
|
||||
"TournamentOrganizationMember.organizationId",
|
||||
@@ -275,8 +280,19 @@ export async function findById(id: number) {
|
||||
|
||||
if (!result) return null;
|
||||
|
||||
const { organization, ...rest } = result;
|
||||
|
||||
return {
|
||||
...result,
|
||||
...rest,
|
||||
organization: organization
|
||||
? {
|
||||
...organization,
|
||||
members: organization.members.map(
|
||||
({ isTournamentOrganizer, patronTier, ...member }) => member,
|
||||
),
|
||||
}
|
||||
: organization,
|
||||
permissions: permissionsOf(result),
|
||||
teams: result.teams.map(({ members, ...team }) => ({
|
||||
...team,
|
||||
avgSeedingSkillOrdinal:
|
||||
@@ -293,6 +309,78 @@ export async function findById(id: number) {
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Who may act on the tournament, following the convention in docs/dev/permissions.md.
|
||||
*
|
||||
* - `ADMIN`: full control of the tournament
|
||||
* - `ORGANIZE`: running the tournament
|
||||
* - `MANAGE_MATCHES`: casting, locking and admining individual matches
|
||||
* - `EDIT_EVENT_INFO`: editing the calendar event the tournament belongs to. Organization
|
||||
* admins only qualify when the organization is established or they may add tournaments
|
||||
* of their own anyway.
|
||||
* - `EDIT_IN_GAME_NAMES`: setting the in-game names of the tournament's players. Restricted
|
||||
* to members of an established organization because the name they set is shown in every
|
||||
* tournament from then on, not only in this one.
|
||||
*/
|
||||
function permissionsOf(tournament: {
|
||||
author: { id: number };
|
||||
staff: Array<{ id: number; role: TournamentStaffRole }>;
|
||||
organization: {
|
||||
isEstablished: DBBoolean;
|
||||
members: Array<{
|
||||
userId: number;
|
||||
role: TournamentOrganizationRole;
|
||||
isTournamentOrganizer: DBBoolean;
|
||||
patronTier: number | null;
|
||||
}>;
|
||||
} | null;
|
||||
}) {
|
||||
const organizationMembers = tournament.organization?.members ?? [];
|
||||
const isEstablished = Boolean(tournament.organization?.isEstablished);
|
||||
|
||||
const membersWithRole = (roles: Array<TournamentOrganizationRole>) =>
|
||||
organizationMembers
|
||||
.filter((member) => roles.includes(member.role))
|
||||
.map((member) => member.userId);
|
||||
const staffWithRole = (roles: Array<TournamentStaffRole>) =>
|
||||
tournament.staff
|
||||
.filter((staff) => roles.includes(staff.role))
|
||||
.map((staff) => staff.id);
|
||||
|
||||
const ADMIN = R.unique([tournament.author.id, ...membersWithRole(["ADMIN"])]);
|
||||
const ORGANIZE = R.unique([
|
||||
...ADMIN,
|
||||
...membersWithRole(["ORGANIZER"]),
|
||||
...staffWithRole(["ORGANIZER"]),
|
||||
]);
|
||||
const MANAGE_MATCHES = R.unique([
|
||||
...ORGANIZE,
|
||||
...membersWithRole(["STREAMER"]),
|
||||
...staffWithRole(["STREAMER"]),
|
||||
]);
|
||||
|
||||
return {
|
||||
ADMIN,
|
||||
ORGANIZE,
|
||||
MANAGE_MATCHES,
|
||||
EDIT_EVENT_INFO: R.unique([
|
||||
tournament.author.id,
|
||||
...organizationMembers
|
||||
.filter(
|
||||
(member) =>
|
||||
member.role === "ADMIN" &&
|
||||
(isEstablished ||
|
||||
Boolean(member.isTournamentOrganizer) ||
|
||||
isSupporter(member)),
|
||||
)
|
||||
.map((member) => member.userId),
|
||||
]),
|
||||
EDIT_IN_GAME_NAMES: isEstablished
|
||||
? membersWithRole(["ADMIN", "ORGANIZER"])
|
||||
: [],
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* User ids of everyone on multiple teams' rosters mapped to the team they joined
|
||||
* most recently. Nearly always empty, allowing the teams to drop per member join
|
||||
|
||||
@@ -1022,6 +1022,17 @@ async function findTeamRecentMaps(
|
||||
.execute();
|
||||
}
|
||||
|
||||
/** Invite code of one team, the secret the tournament layout data does not carry. */
|
||||
export async function findInviteCodeById(tournamentTeamId: number) {
|
||||
const row = await db
|
||||
.selectFrom("TournamentTeam")
|
||||
.select("TournamentTeam.inviteCode")
|
||||
.where("TournamentTeam.id", "=", tournamentTeamId)
|
||||
.executeTakeFirst();
|
||||
|
||||
return row?.inviteCode ?? null;
|
||||
}
|
||||
|
||||
export function findByInviteCode(inviteCode: string) {
|
||||
return db
|
||||
.selectFrom("TournamentTeam")
|
||||
|
||||
@@ -7,7 +7,6 @@ import type { TournamentLoaderData } from "../loaders/to.$id.server";
|
||||
*/
|
||||
const NULL_COMPACTED_TEAM_KEYS = [
|
||||
"seed",
|
||||
"inviteCode",
|
||||
"logoUrl",
|
||||
"activeRosterUserIds",
|
||||
"startingBracketIdx",
|
||||
|
||||
@@ -10,7 +10,7 @@ import {
|
||||
export const loader = async ({ params }: LoaderFunctionArgs) => {
|
||||
const { tournament, tournamentId, user } = await tournamentFromParams(
|
||||
params,
|
||||
{ for: "view", personalized: true },
|
||||
{ for: "view" },
|
||||
);
|
||||
if (!user) return null;
|
||||
|
||||
|
||||
@@ -6,7 +6,6 @@ import {
|
||||
LEAGUES,
|
||||
TOURNAMENT,
|
||||
} from "~/features/tournament/tournament-constants";
|
||||
import { isTournamentOrganizer } from "~/features/tournament-bracket/core/Tournament";
|
||||
import {
|
||||
bracketsMetaCached,
|
||||
requireTournamentVisible,
|
||||
@@ -14,6 +13,7 @@ import {
|
||||
tournamentDataCached,
|
||||
} from "~/features/tournament-bracket/core/Tournament.server";
|
||||
import * as TournamentMatchVodRepository from "~/features/tournament-bracket/TournamentMatchVodRepository.server";
|
||||
import { hasPermission } from "~/modules/permissions/utils";
|
||||
import { databaseTimestampToDate } from "~/utils/dates";
|
||||
import { parseParams } from "~/utils/remix.server";
|
||||
import { idObject } from "~/utils/zod";
|
||||
@@ -42,7 +42,7 @@ export const loader = async ({ params }: LoaderFunctionArgs) => {
|
||||
schema: idObject,
|
||||
});
|
||||
|
||||
const tournament = await tournamentDataCached({ tournamentId, user });
|
||||
const tournament = await tournamentDataCached(tournamentId);
|
||||
requireTournamentVisible({ ctx: tournament.ctx, user });
|
||||
|
||||
const friendCodeVisibilityDays = tournament.ctx.parentTournamentId ? 120 : 30;
|
||||
@@ -52,7 +52,7 @@ export const loader = async ({ params }: LoaderFunctionArgs) => {
|
||||
);
|
||||
const showFriendCodes =
|
||||
tournamentStartedRecently &&
|
||||
isTournamentOrganizer({ ctx: tournament.ctx, user });
|
||||
hasPermission(tournament.ctx, "ORGANIZE", user);
|
||||
|
||||
const isLeagueSignup = Object.values(LEAGUES)
|
||||
.flat()
|
||||
@@ -79,7 +79,7 @@ export const loader = async ({ params }: LoaderFunctionArgs) => {
|
||||
? await TournamentRepository.findFriendCodesByTournamentId(tournamentId)
|
||||
: undefined,
|
||||
preparedMaps:
|
||||
isTournamentOrganizer({ ctx: tournament.ctx, user }) &&
|
||||
hasPermission(tournament.ctx, "ORGANIZE", user) &&
|
||||
!tournament.ctx.isFinalized
|
||||
? await TournamentRepository.findPreparedMapsById(tournamentId)
|
||||
: undefined,
|
||||
|
||||
@@ -19,7 +19,7 @@ export const loader = async ({ params }: LoaderFunctionArgs) => {
|
||||
schema: tournamentTeamPageParamsSchema,
|
||||
});
|
||||
|
||||
const tournament = await tournamentDataCached({ tournamentId });
|
||||
const tournament = await tournamentDataCached(tournamentId);
|
||||
const team = tournament?.ctx.teams.find(
|
||||
(team) => team.id === tournamentTeamId,
|
||||
);
|
||||
|
||||
@@ -29,7 +29,7 @@ export const loader = async ({ params }: LoaderFunctionArgs) => {
|
||||
tournamentId,
|
||||
user,
|
||||
} = await tournamentFromParams(params, { for: "view" });
|
||||
const { data, ctx } = await tournamentDataCached({ tournamentId });
|
||||
const { data, ctx } = await tournamentDataCached(tournamentId);
|
||||
|
||||
const team = (await tournamentTeamsFullCached({ tournamentId, user })).find(
|
||||
(t) => t.id === tournamentTeamId,
|
||||
|
||||
@@ -285,7 +285,13 @@ export async function findById(trophyId: number) {
|
||||
|
||||
const { specialOwners, ...trophy } = row;
|
||||
|
||||
return { ...trophy, owners: [...trophy.owners, ...specialOwners] };
|
||||
return {
|
||||
...trophy,
|
||||
owners: [...trophy.owners, ...specialOwners],
|
||||
permissions: {
|
||||
EDIT: trophy.manager ? [trophy.manager.id] : [],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export async function findTournamentsByTrophyId(trophyId: number) {
|
||||
|
||||
@@ -9,6 +9,7 @@ import { resolveNotifications } from "~/features/notifications/core/resolve.serv
|
||||
import { clearTrophiesCache } from "~/features/trophies/loaders/trophies.server";
|
||||
import * as UserRepository from "~/features/user-page/UserRepository.server";
|
||||
import { parseFormData } from "~/form/parse.server";
|
||||
import { requirePermission } from "~/modules/permissions/guards.server";
|
||||
import { errorToastIfFalsy, parseRequestPayload } from "~/utils/remix.server";
|
||||
import { assertUnreachable } from "~/utils/types";
|
||||
import * as TrophyRepository from "../TrophyRepository.server";
|
||||
@@ -17,11 +18,7 @@ import {
|
||||
pendingTrophyActionSchema,
|
||||
trophyFormSchema,
|
||||
} from "../trophies-schemas";
|
||||
import {
|
||||
canEditTrophy,
|
||||
canReviewTrophies,
|
||||
compressTrophyModel,
|
||||
} from "../trophies-utils";
|
||||
import { canReviewTrophies, compressTrophyModel } from "../trophies-utils";
|
||||
|
||||
export const action: ActionFunction = async ({ request }) => {
|
||||
const user = requireUser();
|
||||
@@ -51,10 +48,7 @@ export const action: ActionFunction = async ({ request }) => {
|
||||
if (data._action === "UPDATE") {
|
||||
const trophy = await TrophyRepository.findById(data.targetTrophyId);
|
||||
errorToastIfFalsy(trophy, "Trophy not found");
|
||||
errorToastIfFalsy(
|
||||
canEditTrophy(user, { managerId: trophy.manager?.id ?? null }),
|
||||
"Not allowed",
|
||||
);
|
||||
requirePermission(trophy, "EDIT");
|
||||
|
||||
const nameExists = await TrophyRepository.existsByName({
|
||||
name: data.name,
|
||||
|
||||
@@ -50,15 +50,6 @@ export function canEditAnyTrophy(user?: { roles: Array<Role> } | null) {
|
||||
return user.roles.includes("ADMIN");
|
||||
}
|
||||
|
||||
export function canEditTrophy(
|
||||
user: { id: number; roles: Array<Role> } | null | undefined,
|
||||
trophy: { managerId: number | null },
|
||||
) {
|
||||
if (!user) return false;
|
||||
if (canEditAnyTrophy(user)) return true;
|
||||
return trophy.managerId === user.id;
|
||||
}
|
||||
|
||||
export function hasUpcomingTournamentSoon(
|
||||
upcomingTournamentAt: number | null | undefined,
|
||||
) {
|
||||
|
||||
@@ -2,9 +2,10 @@ import type { ActionFunction } from "react-router";
|
||||
import * as ArtRepository from "~/features/art/ArtRepository.server";
|
||||
import { userArtPageActionSchema } from "~/features/art/art-schemas.server";
|
||||
import { requireUser } from "~/features/auth/core/user.server";
|
||||
import { requirePermission } from "~/modules/permissions/guards.server";
|
||||
import { logger } from "~/utils/logger";
|
||||
import {
|
||||
errorToastIfFalsy,
|
||||
badRequestIfFalsy,
|
||||
parseRequestPayload,
|
||||
successToast,
|
||||
} from "~/utils/remix.server";
|
||||
@@ -22,11 +23,10 @@ export const action: ActionFunction = async ({ request }) => {
|
||||
// this actually doesn't delete the image itself from the static hosting
|
||||
// but the idea is that storage is cheap anyway and if needed later
|
||||
// then we can have a routine that checks all the images still current and nukes the rest
|
||||
const userArts = await ArtRepository.findArtsByUserId(user.id, {
|
||||
includeTagged: false,
|
||||
});
|
||||
const artToDelete = userArts.find((art) => art.id === data.id);
|
||||
errorToastIfFalsy(artToDelete, "Insufficient permissions");
|
||||
const artToDelete = badRequestIfFalsy(
|
||||
await ArtRepository.findById(data.id),
|
||||
);
|
||||
requirePermission(artToDelete, "EDIT");
|
||||
|
||||
await ArtRepository.deleteById(data.id);
|
||||
|
||||
|
||||
@@ -26,6 +26,7 @@ const mockBuild = (
|
||||
title: "",
|
||||
updatedAt: databaseTimestampNow(),
|
||||
weapons: [{ weaponSplId: 0, isTop500: 0 }],
|
||||
permissions: { EDIT: [] },
|
||||
...partialBuild,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useLoaderData, useMatches } from "react-router";
|
||||
import { ArtGrid } from "~/features/art/components/ArtGrid";
|
||||
import { useUser } from "~/features/auth/core/user";
|
||||
import { useSearchParam } from "~/modules/search-params/hooks";
|
||||
import invariant from "~/utils/invariant";
|
||||
import type { SendouRouteHandle } from "~/utils/remix.server";
|
||||
@@ -21,7 +20,6 @@ export const handle: SendouRouteHandle = {
|
||||
const ALL_TAGS_KEY = "ALL";
|
||||
export default function UserArtPage() {
|
||||
const { t } = useTranslation(["art"]);
|
||||
const user = useUser();
|
||||
const data = useLoaderData<typeof loader>();
|
||||
const [type, setType] = useSearchParam(userArtSearchParams, "source");
|
||||
const [tagParam, setFilteredTag] = useSearchParam(userArtSearchParams, "tag");
|
||||
@@ -136,11 +134,7 @@ export default function UserArtPage() {
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<ArtGrid
|
||||
arts={arts}
|
||||
enablePreview
|
||||
canEdit={layoutData.user.id === user?.id}
|
||||
/>
|
||||
<ArtGrid arts={arts} enablePreview />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -17,6 +17,7 @@ import {
|
||||
import { buildsActionSchema } from "~/features/user-page/user-page-schemas";
|
||||
import type { MainWeaponId } from "~/modules/in-game-lists/types";
|
||||
import { mainWeaponIds } from "~/modules/in-game-lists/weapon-ids";
|
||||
import { hasPermission } from "~/modules/permissions/utils";
|
||||
import { useSearchParam } from "~/modules/search-params/hooks";
|
||||
import type { SendouRouteHandle } from "~/utils/remix.server";
|
||||
import { userPage, weaponCategoryUrl } from "~/utils/urls";
|
||||
@@ -109,7 +110,7 @@ export default function UserBuildsPage() {
|
||||
build={build}
|
||||
owner={layoutData.user}
|
||||
showOwner={false}
|
||||
canEdit={isOwnPage}
|
||||
canEdit={hasPermission(build, "EDIT", user)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -210,10 +210,19 @@ export async function findVodById(id: Tables["Video"]["id"]) {
|
||||
|
||||
const matches = await videoMatchQuery.execute();
|
||||
|
||||
const pov = resolvePov(matches);
|
||||
const povUserId = typeof pov === "string" ? undefined : pov?.id;
|
||||
|
||||
return {
|
||||
...video,
|
||||
pov: resolvePov(matches),
|
||||
pov,
|
||||
matches: R.map(matches, R.omit(["players", "playerNames"])),
|
||||
permissions: {
|
||||
EDIT:
|
||||
povUserId === undefined
|
||||
? [video.submitterUserId]
|
||||
: [video.submitterUserId, povUserId],
|
||||
},
|
||||
};
|
||||
}
|
||||
return null;
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { type ActionFunctionArgs, redirect } from "react-router";
|
||||
import { requireUser } from "~/features/auth/core/user.server";
|
||||
import { badRequestIfFalsy, unauthorizedIfFalsy } from "~/utils/remix.server";
|
||||
import { requirePermission } from "~/modules/permissions/guards.server";
|
||||
import { badRequestIfFalsy } from "~/utils/remix.server";
|
||||
import { userVodsPage } from "~/utils/urls";
|
||||
import * as VodRepository from "../VodRepository.server";
|
||||
import { canEditVideo } from "../vods-utils";
|
||||
|
||||
export const action = async ({ params }: ActionFunctionArgs) => {
|
||||
const user = requireUser();
|
||||
@@ -12,13 +12,7 @@ export const action = async ({ params }: ActionFunctionArgs) => {
|
||||
await VodRepository.findVodById(Number(params.id)),
|
||||
);
|
||||
|
||||
unauthorizedIfFalsy(
|
||||
canEditVideo({
|
||||
userId: user.id,
|
||||
submitterUserId: vod.submitterUserId,
|
||||
povUserId: typeof vod.pov === "string" ? undefined : vod.pov?.id,
|
||||
}),
|
||||
);
|
||||
requirePermission(vod, "EDIT");
|
||||
|
||||
await VodRepository.deleteById(vod.id);
|
||||
|
||||
|
||||
@@ -5,12 +5,12 @@ import {
|
||||
prefillVodMatches,
|
||||
} from "~/features/scanner-ingest/core/VodMatches";
|
||||
import type { IngestVodPrefill } from "~/features/scanner-ingest/scanner-ingest-vod-schemas";
|
||||
import { hasPermission } from "~/modules/permissions/utils";
|
||||
import { notFoundIfNullish } from "~/utils/remix.server";
|
||||
import * as VodRepository from "../VodRepository.server";
|
||||
import type { videoMatchTypes } from "../vods-constants";
|
||||
import { vodsNewSearchParams } from "../vods-search-params";
|
||||
import {
|
||||
canEditVideo,
|
||||
secondsToHoursMinutesSecondString,
|
||||
vodToVideoBeingAdded,
|
||||
} from "../vods-utils";
|
||||
@@ -27,14 +27,7 @@ export const loader = async ({ url }: LoaderFunctionArgs) => {
|
||||
const vod = notFoundIfNullish(await VodRepository.findVodById(vodId));
|
||||
const vodToEdit = vodToVideoBeingAdded(vod);
|
||||
|
||||
if (
|
||||
!canEditVideo({
|
||||
submitterUserId: vod.submitterUserId,
|
||||
userId: user.id,
|
||||
povUserId:
|
||||
vodToEdit.pov?.type === "USER" ? vodToEdit.pov.userId : undefined,
|
||||
})
|
||||
) {
|
||||
if (!hasPermission(vod, "EDIT", user)) {
|
||||
return { vodToEdit: null, vodPrefill: null };
|
||||
}
|
||||
|
||||
|
||||
@@ -14,6 +14,7 @@ import { YouTubeEmbed } from "~/components/YouTubeEmbed";
|
||||
import { useUser } from "~/features/auth/core/user";
|
||||
import { useCopyToClipboard } from "~/hooks/useCopyToClipboard";
|
||||
import { shortStageName } from "~/modules/in-game-lists/stage-ids";
|
||||
import { useHasPermission } from "~/modules/permissions/hooks";
|
||||
import { useSearchParam } from "~/modules/search-params/hooks";
|
||||
import { metaTags, type SerializeFrom } from "~/utils/remix";
|
||||
import type { SendouRouteHandle } from "~/utils/remix.server";
|
||||
@@ -33,7 +34,6 @@ import { loader } from "../loaders/vods.$id.server";
|
||||
import { vodsVodSearchParams } from "../vods-search-params";
|
||||
import type { Vod } from "../vods-types";
|
||||
import {
|
||||
canEditVideo,
|
||||
generateYoutubeTimestamps,
|
||||
secondsToHoursMinutesSecondString,
|
||||
} from "../vods-utils";
|
||||
@@ -80,6 +80,7 @@ export default function VodPage() {
|
||||
const data = useLoaderData<typeof loader>();
|
||||
const { t } = useTranslation(["common", "vods"]);
|
||||
const user = useUser();
|
||||
const canEdit = useHasPermission(data.vod, "EDIT");
|
||||
|
||||
return (
|
||||
<Main className="stack lg">
|
||||
@@ -105,12 +106,7 @@ export default function VodPage() {
|
||||
/>
|
||||
</div>
|
||||
|
||||
{canEditVideo({
|
||||
submitterUserId: data.vod.submitterUserId,
|
||||
userId: user?.id,
|
||||
povUserId:
|
||||
typeof data.vod.pov === "string" ? undefined : data.vod.pov?.id,
|
||||
}) ? (
|
||||
{canEdit ? (
|
||||
<div className="stack horizontal md">
|
||||
{user?.id === data.vod.submitterUserId ? (
|
||||
<CopyTimestampsButton
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { requireUser } from "~/features/auth/core/user.server";
|
||||
import { hasPermission } from "~/modules/permissions/utils";
|
||||
import * as VodRepository from "./VodRepository.server";
|
||||
import { vodFormBaseSchema } from "./vods-schemas";
|
||||
import { canEditVideo } from "./vods-utils";
|
||||
|
||||
export const vodFormSchemaServer = vodFormBaseSchema.refine(
|
||||
async (data) => {
|
||||
@@ -11,11 +11,7 @@ export const vodFormSchemaServer = vodFormBaseSchema.refine(
|
||||
const vod = await VodRepository.findVodById(data.vodToEditId);
|
||||
if (!vod) return false;
|
||||
|
||||
return canEditVideo({
|
||||
userId: user.id,
|
||||
submitterUserId: vod.submitterUserId,
|
||||
povUserId: typeof vod.pov === "string" ? undefined : vod.pov?.id,
|
||||
});
|
||||
return hasPermission(vod, "EDIT", user);
|
||||
},
|
||||
{ message: "No permissions to edit this VOD", path: ["vodToEditId"] },
|
||||
);
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
import type { Tables } from "~/db/tables";
|
||||
import { isAdmin } from "~/modules/permissions/utils";
|
||||
import { databaseTimestampToDate } from "../../utils/dates";
|
||||
import { HOURS_MINUTES_SECONDS_REGEX } from "./vods-schemas";
|
||||
import type { VideoBeingAdded, Vod } from "./vods-types";
|
||||
@@ -32,24 +30,6 @@ export function vodToVideoBeingAdded(vod: Vod): VideoBeingAdded {
|
||||
};
|
||||
}
|
||||
|
||||
export function canEditVideo({
|
||||
userId,
|
||||
submitterUserId,
|
||||
povUserId,
|
||||
}: {
|
||||
userId?: Tables["User"]["id"];
|
||||
submitterUserId: Tables["User"]["id"];
|
||||
povUserId?: Tables["User"]["id"];
|
||||
}) {
|
||||
if (!userId) return false;
|
||||
|
||||
return (
|
||||
isAdmin({ id: userId }) ||
|
||||
userId === submitterUserId ||
|
||||
userId === povUserId
|
||||
);
|
||||
}
|
||||
|
||||
export function extractYoutubeIdFromVideoUrl(url: string): string | null {
|
||||
const match = url.match(
|
||||
/^(?:https?:\/\/)?(?:www\.)?(?:youtube\.com\/(?:watch\?v=|live\/)|youtu\.be\/)([^&/?#]+)/,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { requireUser } from "~/features/auth/core/user.server";
|
||||
import type { EntityWithPermissions, Role } from "~/modules/permissions/types";
|
||||
import { isAdmin } from "./utils";
|
||||
import { hasPermission } from "./utils";
|
||||
|
||||
/**
|
||||
* Checks if a user has the required global role.
|
||||
@@ -24,14 +24,8 @@ export function requirePermission<
|
||||
K extends keyof T["permissions"],
|
||||
>(obj: T, permission: K) {
|
||||
const user = requireUser();
|
||||
// admin can do anything in production but not in development for better testing
|
||||
if (process.env.NODE_ENV === "production" && isAdmin(user)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const permissions = obj.permissions as Record<K, number[]>;
|
||||
|
||||
if (permissions[permission].includes(user.id)) {
|
||||
if (hasPermission(obj, permission, user)) {
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { useUser } from "~/features/auth/core/user";
|
||||
import type { EntityWithPermissions, Role } from "~/modules/permissions/types";
|
||||
import { IS_E2E_TEST_RUN } from "~/utils/e2e";
|
||||
import { isAdmin } from "./utils";
|
||||
import { hasPermission } from "./utils";
|
||||
|
||||
/**
|
||||
* Determines whether a user has a specific global role.
|
||||
@@ -27,16 +26,5 @@ export function useHasPermission<
|
||||
>(obj: T, permission: K) {
|
||||
const user = useUser();
|
||||
|
||||
if (!user) return false;
|
||||
|
||||
// admin can do anything in production but not in development for better testing
|
||||
if (
|
||||
process.env.NODE_ENV === "production" &&
|
||||
!IS_E2E_TEST_RUN &&
|
||||
isAdmin(user)
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return (obj.permissions as Record<K, number[]>)[permission].includes(user.id);
|
||||
return hasPermission(obj, permission, user);
|
||||
}
|
||||
|
||||
53
app/modules/permissions/utils.test.ts
Normal file
53
app/modules/permissions/utils.test.ts
Normal file
@@ -0,0 +1,53 @@
|
||||
import { beforeEach, describe, expect, test, vi } from "vitest";
|
||||
import { ADMIN_ID } from "~/features/admin/admin-constants";
|
||||
|
||||
const e2e = vi.hoisted(() => ({ isTestRun: false }));
|
||||
|
||||
vi.mock("~/utils/e2e", () => ({
|
||||
get IS_E2E_TEST_RUN() {
|
||||
return e2e.isTestRun;
|
||||
},
|
||||
}));
|
||||
|
||||
import { hasPermission } from "./utils";
|
||||
|
||||
const REGULAR_USER_ID = ADMIN_ID + 1;
|
||||
const OTHER_USER_ID = ADMIN_ID + 2;
|
||||
|
||||
const entity = { permissions: { EDIT: [REGULAR_USER_ID] } };
|
||||
|
||||
describe("hasPermission", () => {
|
||||
beforeEach(() => {
|
||||
e2e.isTestRun = false;
|
||||
vi.unstubAllEnvs();
|
||||
});
|
||||
|
||||
test("returns false for a logged out user", () => {
|
||||
expect(hasPermission(entity, "EDIT", null)).toBe(false);
|
||||
});
|
||||
|
||||
test("returns true for a user holding the permission", () => {
|
||||
expect(hasPermission(entity, "EDIT", { id: REGULAR_USER_ID })).toBe(true);
|
||||
});
|
||||
|
||||
test("returns false for a user not holding the permission", () => {
|
||||
expect(hasPermission(entity, "EDIT", { id: OTHER_USER_ID })).toBe(false);
|
||||
});
|
||||
|
||||
test("admin does not bypass the permission outside production", () => {
|
||||
expect(hasPermission(entity, "EDIT", { id: ADMIN_ID })).toBe(false);
|
||||
});
|
||||
|
||||
test("admin bypasses the permission in production", () => {
|
||||
vi.stubEnv("NODE_ENV", "production");
|
||||
|
||||
expect(hasPermission(entity, "EDIT", { id: ADMIN_ID })).toBe(true);
|
||||
});
|
||||
|
||||
test("admin does not bypass the permission in an e2e test run", () => {
|
||||
vi.stubEnv("NODE_ENV", "production");
|
||||
e2e.isTestRun = true;
|
||||
|
||||
expect(hasPermission(entity, "EDIT", { id: ADMIN_ID })).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -5,6 +5,32 @@ import {
|
||||
SCANNER_TESTER_IDS,
|
||||
STAFF_IDS,
|
||||
} from "~/features/admin/admin-constants";
|
||||
import { IS_E2E_TEST_RUN } from "~/utils/e2e";
|
||||
import type { EntityWithPermissions } from "./types";
|
||||
|
||||
/**
|
||||
* Determines whether a user has a specific permission for a given entity.
|
||||
* Single source of truth shared by `requirePermission` and `useHasPermission`.
|
||||
*
|
||||
* @returns A boolean indicating whether the user has the specified permission. Always false if user is not logged in.
|
||||
*/
|
||||
export function hasPermission<
|
||||
T extends EntityWithPermissions,
|
||||
K extends keyof T["permissions"],
|
||||
>(obj: T, permission: K, user?: { id: number } | null) {
|
||||
if (!user) return false;
|
||||
|
||||
// admin can do anything in production but not in development or e2e tests for better testing
|
||||
if (
|
||||
process.env.NODE_ENV === "production" &&
|
||||
!IS_E2E_TEST_RUN &&
|
||||
isAdmin(user)
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return (obj.permissions as Record<K, number[]>)[permission].includes(user.id);
|
||||
}
|
||||
|
||||
export function isAdmin(user?: { id: number }) {
|
||||
return user?.id === ADMIN_ID;
|
||||
|
||||
@@ -15,10 +15,7 @@ export const NotifyCheckInStartRoutine = new Routine({
|
||||
});
|
||||
|
||||
for (const { tournamentId } of tournaments) {
|
||||
const tournament = await tournamentDataCached({
|
||||
tournamentId: tournamentId!,
|
||||
user: undefined,
|
||||
});
|
||||
const tournament = await tournamentDataCached(tournamentId!);
|
||||
|
||||
if (tournament.ctx.settings.isTest || tournament.ctx.settings.isDraft) {
|
||||
continue;
|
||||
|
||||
37
docs/dev/permissions.md
Normal file
37
docs/dev/permissions.md
Normal file
@@ -0,0 +1,37 @@
|
||||
# Permissions
|
||||
|
||||
How authorization works, and where a new check should go. Two axes exist:
|
||||
|
||||
1. **Global roles** (`Role` in `app/modules/permissions/types.ts`): "may this user do this kind of thing at all" — gated with `requireRole()` on the server and `useHasRole()` in components.
|
||||
2. **Per-object permissions**: "may this user act on _this_ entity" — the subject of this doc.
|
||||
|
||||
## Per-object permissions
|
||||
|
||||
An entity that can be acted on carries a server-computed `permissions` object: a record from permission name to the list of user ids holding it.
|
||||
|
||||
```ts
|
||||
// in the Repository read function
|
||||
return {
|
||||
...row,
|
||||
permissions: {
|
||||
EDIT: [row.authorId],
|
||||
DELETE: startTimeIsInTheFuture ? [row.authorId] : [],
|
||||
},
|
||||
};
|
||||
```
|
||||
|
||||
Rules:
|
||||
|
||||
- **Permissions objects are built in Repositories**, at read time, next to the query that loads the entity. They serialize to the client with the rest of the loader data, so server and client check the same values.
|
||||
- **Checked only via the central helpers**: `requirePermission(entity, "EDIT")` in actions/loaders (throws 403), `useHasPermission(entity, "EDIT")` in components, and the pure `hasPermission(entity, "EDIT", user)` where a hook doesn't fit (non-throwing server checks, checks inside a render loop).
|
||||
- **Time and state conditions are baked into the list at read time.** A calendar event that has started gets `DELETE: []`, a scrim without an accepted request gets `MANAGE_TRACKING: []`. Don't add predicates or re-check conditions at the call site.
|
||||
- **No feature-level admin checks for object authorization.** The admin bypass lives in `hasPermission()` alone (production only, so tests and development exercise the real lists). Feature code calling `isAdmin()`/`useHasRole("ADMIN")` to authorize an action on an object is a bug.
|
||||
|
||||
## What stays outside the system
|
||||
|
||||
Two documented boundaries:
|
||||
|
||||
1. **Non-enumerable grants**: permissions held by an open class of users can't be expressed as an id list. Example: any high-enough plus tier member may comment on a suggestion (`canAddCommentToSuggestion*` in plus-suggestions). These stay as plain helper functions.
|
||||
2. **Derived-state checks**: a grant that depends on state the repository cannot see at read time. In the `Tournament` class, `canFinalize`, `canCheckInToBracket`, `canAddNewSubPost` and friends all read bracket state that only exists once the bracket engine has run, so they stay methods. Their authorization half still goes through the permissions object: `hasPermission(this.ctx, "ORGANIZE", user) && everyBracketOver`, never a re-implemented organizer check.
|
||||
|
||||
Also not authorization: membership/domain logic like `isTeamMember`, `isTeamFull`, `resolveNewOwner` — these describe domain facts, not grants, and remain ordinary helpers.
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
Repositories are the only place database queries are written. One per feature (`app/features/<feature>/FeatureRepository.server.ts`), imported as a module: `import * as VodRepository from "~/features/vods/VodRepository.server"`.
|
||||
|
||||
See [database-schemas.md](./database-schemas.md) for how columns are typed and [database-relations.md](./database-relations.md) for how the tables relate.
|
||||
See [database-schemas.md](./database-schemas.md) for how columns are typed and [database-relations.md](./database-relations.md) for how the tables relate. Entities that can be acted on get their `permissions` object built in the Repository read function — see [permissions.md](./permissions.md).
|
||||
|
||||
Note: plenty of older repositories don't follow this yet. Fix them as you touch them rather than leaving a new style behind.
|
||||
|
||||
|
||||
@@ -213,7 +213,7 @@ test.describe("Team page", () => {
|
||||
memberUserIds: [secondaryTeamOwner.id],
|
||||
});
|
||||
|
||||
await impersonate(page, ADMIN_ID);
|
||||
await impersonate(page, secondaryTeamOwner.id);
|
||||
|
||||
const secondaryTeam = new TeamPage(page);
|
||||
await secondaryTeam.goto(secondaryCustomUrl);
|
||||
@@ -221,6 +221,8 @@ test.describe("Team page", () => {
|
||||
const roster = await secondaryTeam.openManageRoster();
|
||||
const inviteLink = await roster.inviteLink();
|
||||
|
||||
await impersonate(page, ADMIN_ID);
|
||||
|
||||
const join = new JoinTeamPage(page);
|
||||
await join.goto(inviteLink);
|
||||
await join.join();
|
||||
@@ -285,8 +287,8 @@ test.describe("Team page", () => {
|
||||
);
|
||||
await team.confirmLeaving();
|
||||
|
||||
await team.openActionsMenu();
|
||||
await isNotVisible(team.locators.leaveTeamButton);
|
||||
await expect(team.ownerBadge(NZAP_TEST_ID)).toBeVisible();
|
||||
await isNotVisible(team.locators.actionsMenuButton);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -119,6 +119,9 @@ export function buildCases(fx: Fixtures): {
|
||||
add("ArtRepository.findArtsByUserId", fx.heavyArtUserId, (userId) =>
|
||||
ArtRepository.findArtsByUserId(userId),
|
||||
);
|
||||
add("ArtRepository.findById", fx.heavyArtId, (artId) =>
|
||||
ArtRepository.findById(artId),
|
||||
);
|
||||
|
||||
// AssociationRepository
|
||||
add("AssociationRepository.findById", fx.heavyAssociation, (association) =>
|
||||
@@ -1133,6 +1136,12 @@ export function buildCases(fx: Fixtures): {
|
||||
fx.tournamentTeamInviteCode,
|
||||
(inviteCode) => TournamentTeamRepository.findByInviteCode(inviteCode),
|
||||
);
|
||||
add(
|
||||
"TournamentTeamRepository.findInviteCodeById",
|
||||
fx.heavyTournamentTeamId,
|
||||
(tournamentTeamId) =>
|
||||
TournamentTeamRepository.findInviteCodeById(tournamentTeamId),
|
||||
);
|
||||
add(
|
||||
"TournamentTeamRepository.findRecentlyPlayedMapsByIds",
|
||||
fx.tournamentTeamPair,
|
||||
|
||||
@@ -92,6 +92,7 @@ export interface Fixtures {
|
||||
} | null;
|
||||
heavyArtUserId: number | null;
|
||||
heavyArtTagId: number | null;
|
||||
heavyArtId: number | null;
|
||||
imageSubmitterId: number | null;
|
||||
imageId: number | null;
|
||||
vod: { userId: number; videoId: number } | null;
|
||||
@@ -177,6 +178,7 @@ export async function resolveFixtures(): Promise<Fixtures> {
|
||||
xrank: await resolveXRank(),
|
||||
heavyArtUserId: await resolveHeavyArtUserId(),
|
||||
heavyArtTagId: await resolveHeavyArtTagId(),
|
||||
heavyArtId: await resolveHeavyArtId(),
|
||||
imageSubmitterId: await resolveImageSubmitterId(),
|
||||
imageId: await resolveImageId(),
|
||||
vod: await resolveVod(),
|
||||
@@ -1066,6 +1068,18 @@ async function resolveHeavyArtTagId() {
|
||||
return row?.tagId ?? null;
|
||||
}
|
||||
|
||||
async function resolveHeavyArtId() {
|
||||
const row = await db
|
||||
.selectFrom("ArtUserMetadata")
|
||||
.select(({ fn }) => ["artId", fn.countAll<number>().as("count")])
|
||||
.groupBy("artId")
|
||||
.orderBy("count", "desc")
|
||||
.limit(1)
|
||||
.executeTakeFirst();
|
||||
|
||||
return row?.artId ?? null;
|
||||
}
|
||||
|
||||
async function resolveImageSubmitterId() {
|
||||
const row = await db
|
||||
.selectFrom("UnvalidatedUserSubmittedImage")
|
||||
|
||||
Reference in New Issue
Block a user