Refactor permissions and make tournament object fully cacheable (#3341)

This commit is contained in:
Kalle
2026-08-15 09:29:34 +03:00
committed by GitHub
parent 7a5c59b8cd
commit af7ef2fb08
86 changed files with 918 additions and 1066 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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: {

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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. */

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -181,7 +181,7 @@ export const action: ActionFunction = async ({ params, request }) => {
ShowcaseTournaments.clearCachedTournaments();
// update RunningTournaments
await tournamentFromDB({ tournamentId, user });
await tournamentFromDB(tournamentId);
emitTournamentUpdate = true;

View File

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

View File

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

View File

@@ -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: [

View File

@@ -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: [

View File

@@ -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: [

View File

@@ -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: [],

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View 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([]);
});
});

View File

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

View File

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

View File

@@ -7,7 +7,6 @@ import type { TournamentLoaderData } from "../loaders/to.$id.server";
*/
const NULL_COMPACTED_TEAM_KEYS = [
"seed",
"inviteCode",
"logoUrl",
"activeRosterUserIds",
"startingBracketIdx",

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -26,6 +26,7 @@ const mockBuild = (
title: "",
updatedAt: databaseTimestampNow(),
weapons: [{ weaponSplId: 0, isTop500: 0 }],
permissions: { EDIT: [] },
...partialBuild,
};
};

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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\/)([^&/?#]+)/,

View File

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

View File

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

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

View File

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

View File

@@ -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
View 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.

View File

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

View File

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

View File

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

View File

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