diff --git a/app/db/seed/factories/TournamentFactory.ts b/app/db/seed/factories/TournamentFactory.ts index 75fac5c79..f546dddcd 100644 --- a/app/db/seed/factories/TournamentFactory.ts +++ b/app/db/seed/factories/TournamentFactory.ts @@ -147,7 +147,7 @@ export async function playOut( brackets: PlayedBrackets = 0, { maps }: { maps?: RoundMaps } = {}, ): Promise { - 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 { - 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, diff --git a/app/features/api-public/routes/tournament-match.$id.ts b/app/features/api-public/routes/tournament-match.$id.ts index 1da353ce5..fcb5192ae 100644 --- a/app/features/api-public/routes/tournament-match.$id.ts +++ b/app/features/api-public/routes/tournament-match.$id.ts @@ -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 => { const { opponentOne, opponentTwo } = match; diff --git a/app/features/api-public/routes/tournament.$id.brackets.$bidx.standings.ts b/app/features/api-public/routes/tournament.$id.brackets.$bidx.standings.ts index 2a0d3a189..9be523d4b 100644 --- a/app/features/api-public/routes/tournament.$id.brackets.$bidx.standings.ts +++ b/app/features/api-public/routes/tournament.$id.brackets.$bidx.standings.ts @@ -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 }); diff --git a/app/features/api-public/routes/tournament.$id.brackets.$bidx.ts b/app/features/api-public/routes/tournament.$id.brackets.$bidx.ts index fb7be95ed..4c4dba3bf 100644 --- a/app/features/api-public/routes/tournament.$id.brackets.$bidx.ts +++ b/app/features/api-public/routes/tournament.$id.brackets.$bidx.ts @@ -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)); diff --git a/app/features/api-public/routes/tournament.$id.teams.$teamId.add-member.ts b/app/features/api-public/routes/tournament.$id.teams.$teamId.add-member.ts index 788118eb5..08a9815ea 100644 --- a/app/features/api-public/routes/tournament.$id.teams.$teamId.add-member.ts +++ b/app/features/api-public/routes/tournament.$id.teams.$teamId.add-member.ts @@ -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); diff --git a/app/features/api-public/routes/tournament.$id.teams.$teamId.remove-member.ts b/app/features/api-public/routes/tournament.$id.teams.$teamId.remove-member.ts index e6fb0b033..0a225c632 100644 --- a/app/features/api-public/routes/tournament.$id.teams.$teamId.remove-member.ts +++ b/app/features/api-public/routes/tournament.$id.teams.$teamId.remove-member.ts @@ -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); diff --git a/app/features/api-public/routes/tournament.$id.teams.$teamId.update-member-ign.ts b/app/features/api-public/routes/tournament.$id.teams.$teamId.update-member-ign.ts index 74848a979..2ddaac595 100644 --- a/app/features/api-public/routes/tournament.$id.teams.$teamId.update-member-ign.ts +++ b/app/features/api-public/routes/tournament.$id.teams.$teamId.update-member-ign.ts @@ -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( diff --git a/app/features/art/ArtRepository.server.ts b/app/features/art/ArtRepository.server.ts index e9f326a2a..e30530be6 100644 --- a/app/features/art/ArtRepository.server.ts +++ b/app/features/art/ArtRepository.server.ts @@ -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 { 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 { 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 { 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 { 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) { + 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), + }; +} diff --git a/app/features/art/actions/art.new.server.ts b/app/features/art/actions/art.new.server.ts index 3a3794936..a652aa82e 100644 --- a/app/features/art/actions/art.new.server.ts +++ b/app/features/art/actions/art.new.server.ts @@ -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: { diff --git a/app/features/art/art-types.ts b/app/features/art/art-types.ts index 0d08c70d8..7f2d102c5 100644 --- a/app/features/art/art-types.ts +++ b/app/features/art/art-types.ts @@ -19,6 +19,10 @@ export interface ListedArt { customAvatarUrl: string | null; commissionsOpen: Tables["User"]["commissionsOpen"]; }; + permissions: { + EDIT: Array; + UNLINK: Array; + }; } export const ART_SOURCES = ["ALL", "MADE-BY", "MADE-OF"] as const; diff --git a/app/features/art/components/ArtGrid.tsx b/app/features/art/components/ArtGrid.tsx index a78f777c6..8d2834211 100644 --- a/app/features/art/components/ArtGrid.tsx +++ b/app/features/art/components/ArtGrid.tsx @@ -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({ 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}
) : null} - {canEdit ? ( + {canUnlink ? ( buildRowToResult(row, shouldSortAbilities)); + return rows.map((row) => ({ + ...buildRowToResult(row, shouldSortAbilities), + permissions: { + EDIT: [userId], + }, + })); } interface CreateArgs { diff --git a/app/features/calendar/CalendarRepository.server.ts b/app/features/calendar/CalendarRepository.server.ts index 5629188f1..478e1ed95 100644 --- a/app/features/calendar/CalendarRepository.server.ts +++ b/app/features/calendar/CalendarRepository.server.ts @@ -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] + : [], + }, }; } diff --git a/app/features/calendar/actions/calendar.$id.report-winners.server.ts b/app/features/calendar/actions/calendar.$id.report-winners.server.ts index f09aaa0c0..8d972c269 100644 --- a/app/features/calendar/actions/calendar.$id.report-winners.server.ts +++ b/app/features/calendar/actions/calendar.$id.report-winners.server.ts @@ -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, diff --git a/app/features/calendar/actions/calendar.$id.server.ts b/app/features/calendar/actions/calendar.$id.server.ts index 666898d76..106c4c57b 100644 --- a/app/features/calendar/actions/calendar.$id.server.ts +++ b/app/features/calendar/actions/calendar.$id.server.ts @@ -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( diff --git a/app/features/calendar/actions/calendar.new.server.ts b/app/features/calendar/actions/calendar.new.server.ts index bb4633b30..5678dfbb0 100644 --- a/app/features/calendar/actions/calendar.new.server.ts +++ b/app/features/calendar/actions/calendar.new.server.ts @@ -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({ diff --git a/app/features/calendar/calendar-utils.ts b/app/features/calendar/calendar-utils.ts index fb3964bee..78be60709 100644 --- a/app/features/calendar/calendar-utils.ts +++ b/app/features/calendar/calendar-utils.ts @@ -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; - event: Pick; -} -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; - event: Pick; - 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) diff --git a/app/features/calendar/loaders/calendar.$id.report-winners.server.ts b/app/features/calendar/loaders/calendar.$id.report-winners.server.ts index 13e479471..e03611844 100644 --- a/app/features/calendar/loaders/calendar.$id.report-winners.server.ts +++ b/app/features/calendar/loaders/calendar.$id.report-winners.server.ts @@ -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, diff --git a/app/features/calendar/loaders/calendar.new.server.ts b/app/features/calendar/loaders/calendar.new.server.ts index 3e0fb2cfb..3f0c2705e 100644 --- a/app/features/calendar/loaders/calendar.new.server.ts +++ b/app/features/calendar/loaders/calendar.new.server.ts @@ -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 diff --git a/app/features/calendar/routes/calendar.$id.test.ts b/app/features/calendar/routes/calendar.$id.test.ts index 0c8437c24..b158acb8e 100644 --- a/app/features/calendar/routes/calendar.$id.test.ts +++ b/app/features/calendar/routes/calendar.$id.test.ts @@ -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>>({ @@ -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 () => { diff --git a/app/features/calendar/routes/calendar.$id.tsx b/app/features/calendar/routes/calendar.$id.tsx index eddd1d2a6..c074636ba 100644 --- a/app/features/calendar/routes/calendar.$id.tsx +++ b/app/features/calendar/routes/calendar.$id.tsx @@ -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(); 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 (
@@ -134,26 +130,22 @@ export default function CalendarEventPage() { > {resolveBaseUrl(data.event.bracketUrl)} - {canEditCalendarEvent({ user, event: data.event }) && ( + {canEdit ? ( {t("common:actions.edit")} - )} - {canReportCalendarEventWinners({ - user, - event: data.event, - startTimes: data.event.startTimes, - }) && ( + ) : null} + {canReportWinners ? ( {t("calendar:actions.reportWinners")} - )} + ) : null}
@@ -161,11 +153,7 @@ export default function CalendarEventPage() {
- {canDeleteCalendarEvent({ - user, - startTime: databaseTimestampToDate(data.event.startTimes[0]), - event: data.event, - }) ? ( + {canDelete ? ( () .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 = () => diff --git a/app/features/lfg/actions/lfg.new.server.ts b/app/features/lfg/actions/lfg.new.server.ts index 4b5389e52..0f43b6d64 100644 --- a/app/features/lfg/actions/lfg.new.server.ts +++ b/app/features/lfg/actions/lfg.new.server.ts @@ -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"); }; diff --git a/app/features/lfg/actions/lfg.server.ts b/app/features/lfg/actions/lfg.server.ts index fdf923b56..3f0c94cdb 100644 --- a/app/features/lfg/actions/lfg.server.ts +++ b/app/features/lfg/actions/lfg.server.ts @@ -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; } diff --git a/app/features/lfg/components/LFGPost.tsx b/app/features/lfg/components/LFGPost.tsx index b48696149..6ee8de306 100644 --- a/app/features/lfg/components/LFGPost.tsx +++ b/app/features/lfg/components/LFGPost.tsx @@ -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 }) {
- {post.author.id === user?.id || isAdmin ? ( + {canDelete ? ( ) : null}
@@ -82,8 +81,8 @@ function TeamLFGPost({ post: Post & { team: NonNullable }; }) { 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({
- {post.author.id === user?.id ? ( - - ) : null} + {canEdit ? : null}
{isExpanded ? ( @@ -116,7 +113,7 @@ function TeamLFGPost({
- {post.author.id === user?.id || isAdmin ? ( + {canDelete ? ( ) : null}
diff --git a/app/features/plus-suggestions/PlusSuggestionRepository.server.ts b/app/features/plus-suggestions/PlusSuggestionRepository.server.ts index 7a58a775a..fc578c7dc 100644 --- a/app/features/plus-suggestions/PlusSuggestionRepository.server.ts +++ b/app/features/plus-suggestions/PlusSuggestionRepository.server.ts @@ -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) { return eb .selectFrom("PlusTier") diff --git a/app/features/plus-suggestions/actions/plus.suggestions.server.ts b/app/features/plus-suggestions/actions/plus.suggestions.server.ts index 7f2a0f2d2..9a1e3a5dc 100644 --- a/app/features/plus-suggestions/actions/plus.suggestions.server.ts +++ b/app/features/plus-suggestions/actions/plus.suggestions.server.ts @@ -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({ diff --git a/app/features/plus-suggestions/plus-suggestions-utils.ts b/app/features/plus-suggestions/plus-suggestions-utils.ts index 87ef93c73..f563994bf 100644 --- a/app/features/plus-suggestions/plus-suggestions-utils.ts +++ b/app/features/plus-suggestions/plus-suggestions-utils.ts @@ -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; - user?: Pick; - 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) { - 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) { - 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; - user?: Pick; - 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; /** Whether the user has already started a suggestion this month, any tier. */ diff --git a/app/features/plus-suggestions/routes/plus.suggestions.browser.test.tsx b/app/features/plus-suggestions/routes/plus.suggestions.browser.test.tsx index 891f5e29c..4528c17f1 100644 --- a/app/features/plus-suggestions/routes/plus.suggestions.browser.test.tsx +++ b/app/features/plus-suggestions/routes/plus.suggestions.browser.test.tsx @@ -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: ( diff --git a/app/features/plus-suggestions/routes/plus.suggestions.tsx b/app/features/plus-suggestions/routes/plus.suggestions.tsx index 48e74c3e5..ad6310d25 100644 --- a/app/features/plus-suggestions/routes/plus.suggestions.tsx +++ b/app/features/plus-suggestions/routes/plus.suggestions.tsx @@ -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, }} />
@@ -247,14 +243,13 @@ export function PlusSuggestionComments({ }: { suggestion: PlusSuggestionRepository.FindAllByMonthItem; deleteButtonArgs?: { - user?: Pick; - 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({ ) ) : null} - {deleteButtonArgs && - canEditSuggestion({ - author: entry.author, - user: deleteButtonArgs.user, - suggestionId: entry.id, - suggestions: deleteButtonArgs.suggestions, - }) ? ( + {deleteButtonArgs && hasPermission(entry, "EDIT", user) ? ( } @@ -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) ? ( ) : null} diff --git a/app/features/team/TeamRepository.server.ts b/app/features/team/TeamRepository.server.ts index 7070e3a1a..42ad7574c 100644 --- a/app/features/team/TeamRepository.server.ts +++ b/app/features/team/TeamRepository.server.ts @@ -130,14 +130,14 @@ export type findByCustomUrl = NonNullable< Awaited> >; -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< diff --git a/app/features/team/actions/t.$customUrl.edit.server.ts b/app/features/team/actions/t.$customUrl.edit.server.ts index e575e4066..c727b1388 100644 --- a/app/features/team/actions/t.$customUrl.edit.server.ts +++ b/app/features/team/actions/t.$customUrl.edit.server.ts @@ -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, diff --git a/app/features/team/actions/t.$customUrl.index.server.ts b/app/features/team/actions/t.$customUrl.index.server.ts index aad79f9b3..1a566e833 100644 --- a/app/features/team/actions/t.$customUrl.index.server.ts +++ b/app/features/team/actions/t.$customUrl.index.server.ts @@ -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("/"); diff --git a/app/features/team/actions/t.$customUrl.roster.server.ts b/app/features/team/actions/t.$customUrl.roster.server.ts index d7b89feae..658cf7e61 100644 --- a/app/features/team/actions/t.$customUrl.roster.server.ts +++ b/app/features/team/actions/t.$customUrl.roster.server.ts @@ -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, diff --git a/app/features/team/loaders/t.$customUrl.edit.server.ts b/app/features/team/loaders/t.$customUrl.edit.server.ts index 6eb434022..36b71d966 100644 --- a/app/features/team/loaders/t.$customUrl.edit.server.ts +++ b/app/features/team/loaders/t.$customUrl.edit.server.ts @@ -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)); } diff --git a/app/features/team/loaders/t.$customUrl.roster.server.ts b/app/features/team/loaders/t.$customUrl.roster.server.ts index c4883d528..0fdc33d0c 100644 --- a/app/features/team/loaders/t.$customUrl.roster.server.ts +++ b/app/features/team/loaders/t.$customUrl.roster.server.ts @@ -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, diff --git a/app/features/team/routes/t.$customUrl.index.tsx b/app/features/team/routes/t.$customUrl.index.tsx index 3cc3c7968..1cef06b4e 100644 --- a/app/features/team/routes/t.$customUrl.index.tsx +++ b/app/features/team/routes/t.$customUrl.index.tsx @@ -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 (
- {isTeamManager({ user, team }) || isAdmin ? ( + {canManageRoster ? ( ) : null} - {isTeamManager({ user, team }) || isAdmin ? ( + {canEditTeam ? ( ( 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 && diff --git a/app/features/team/routes/t.$customUrl.test.ts b/app/features/team/routes/t.$customUrl.test.ts index 2f00889a2..ffebe9f0e 100644 --- a/app/features/team/routes/t.$customUrl.test.ts +++ b/app/features/team/routes/t.$customUrl.test.ts @@ -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(); }); diff --git a/app/features/team/team-utils.ts b/app/features/team/team-utils.ts index 880495819..b99f1be32 100644 --- a/app/features/team/team-utils.ts +++ b/app/features/team/team-utils.ts @@ -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, diff --git a/app/features/tournament-admin/routes/to.$id.admin.import-teams.ts b/app/features/tournament-admin/routes/to.$id.admin.import-teams.ts index e27d87eda..daf86f0b3 100644 --- a/app/features/tournament-admin/routes/to.$id.admin.import-teams.ts +++ b/app/features/tournament-admin/routes/to.$id.admin.import-teams.ts @@ -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({ diff --git a/app/features/tournament-admin/routes/to.$id.admin.tsx b/app/features/tournament-admin/routes/to.$id.admin.tsx index 96af0e4a8..638d40329 100644 --- a/app/features/tournament-admin/routes/to.$id.admin.tsx +++ b/app/features/tournament-admin/routes/to.$id.admin.tsx @@ -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 (
- {tournament.canEditEventInfo(user, { isTournamentAdder }) && - !tournament.hasStarted ? ( + {tournament.canEditEventInfo(user) && !tournament.hasStarted ? (
{ } // ensure RunningTournament = sidebar updates - await tournamentFromDB({ tournamentId, user }); + await tournamentFromDB(tournamentId); return successToastWithRedirect({ url: tournamentBracketsPage({ tournamentId }), diff --git a/app/features/tournament-bracket/actions/to.$id.brackets.server.ts b/app/features/tournament-bracket/actions/to.$id.brackets.server.ts index 9ac4c6629..a68433594 100644 --- a/app/features/tournament-bracket/actions/to.$id.brackets.server.ts +++ b/app/features/tournament-bracket/actions/to.$id.brackets.server.ts @@ -181,7 +181,7 @@ export const action: ActionFunction = async ({ params, request }) => { ShowcaseTournaments.clearCachedTournaments(); // update RunningTournaments - await tournamentFromDB({ tournamentId, user }); + await tournamentFromDB(tournamentId); emitTournamentUpdate = true; diff --git a/app/features/tournament-bracket/core/Tournament.server.ts b/app/features/tournament-bracket/core/Tournament.server.ts index b5cb8eeec..3568d8e5e 100644 --- a/app/features/tournament-bracket/core/Tournament.server.ts +++ b/app/features/tournament-bracket/core/Tournament.server.ts @@ -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; +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, - 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; - // the vast majority of viewers are logged out and get the exact same censoring applied - anonymousMapped?: ReturnType; - // 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; + // 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({ 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); diff --git a/app/features/tournament-bracket/core/Tournament.ts b/app/features/tournament-bracket/core/Tournament.ts index 9caa90fb9..add928928 100644 --- a/app/features/tournament-bracket/core/Tournament.ts +++ b/app/features/tournament-bracket/core/Tournament.ts @@ -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", - ); -} diff --git a/app/features/tournament-bracket/core/tests/mocks-li.ts b/app/features/tournament-bracket/core/tests/mocks-li.ts index 9cb2a2315..413271125 100644 --- a/app/features/tournament-bracket/core/tests/mocks-li.ts +++ b/app/features/tournament-bracket/core/tests/mocks-li.ts @@ -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: [ diff --git a/app/features/tournament-bracket/core/tests/mocks-sos.ts b/app/features/tournament-bracket/core/tests/mocks-sos.ts index c184e3234..7e8f3fb37 100644 --- a/app/features/tournament-bracket/core/tests/mocks-sos.ts +++ b/app/features/tournament-bracket/core/tests/mocks-sos.ts @@ -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: [ diff --git a/app/features/tournament-bracket/core/tests/mocks-zones-weekly.ts b/app/features/tournament-bracket/core/tests/mocks-zones-weekly.ts index 12c5a5c66..cecb33814 100644 --- a/app/features/tournament-bracket/core/tests/mocks-zones-weekly.ts +++ b/app/features/tournament-bracket/core/tests/mocks-zones-weekly.ts @@ -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: [ diff --git a/app/features/tournament-bracket/core/tests/mocks.ts b/app/features/tournament-bracket/core/tests/mocks.ts index 9abbf2f47..f11b2e461 100644 --- a/app/features/tournament-bracket/core/tests/mocks.ts +++ b/app/features/tournament-bracket/core/tests/mocks.ts @@ -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: [], diff --git a/app/features/tournament-bracket/core/tests/test-utils.ts b/app/features/tournament-bracket/core/tests/test-utils.ts index 5daec49c6..7f6c0ecac 100644 --- a/app/features/tournament-bracket/core/tests/test-utils.ts +++ b/app/features/tournament-bracket/core/tests/test-utils.ts @@ -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, diff --git a/app/features/tournament-bracket/loaders/to.$id.brackets.server.ts b/app/features/tournament-bracket/loaders/to.$id.brackets.server.ts index 5ce46b49f..ef3e4b129 100644 --- a/app/features/tournament-bracket/loaders/to.$id.brackets.server.ts +++ b/app/features/tournament-bracket/loaders/to.$id.brackets.server.ts @@ -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), diff --git a/app/features/tournament-bracket/loaders/to.$id.divisions.server.ts b/app/features/tournament-bracket/loaders/to.$id.divisions.server.ts index c46016cf6..1265ec1ef 100644 --- a/app/features/tournament-bracket/loaders/to.$id.divisions.server.ts +++ b/app/features/tournament-bracket/loaders/to.$id.divisions.server.ts @@ -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; diff --git a/app/features/tournament-bracket/routes/to.$id.brackets.tsx b/app/features/tournament-bracket/routes/to.$id.brackets.tsx index 24dad777d..ae9f71382 100644 --- a/app/features/tournament-bracket/routes/to.$id.brackets.tsx +++ b/app/features/tournament-bracket/routes/to.$id.brackets.tsx @@ -468,9 +468,10 @@ function AddSubsPopOver() { const { copyToClipboard, copySuccess } = useCopyToClipboard(); const tournament = useTournament(); const user = useUser(); + const data = useLoaderData(); 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 ( diff --git a/app/features/tournament-lfg/loaders/to.$id.looking.server.ts b/app/features/tournament-lfg/loaders/to.$id.looking.server.ts index b7f51a54f..7577a6f74 100644 --- a/app/features/tournament-lfg/loaders/to.$id.looking.server.ts +++ b/app/features/tournament-lfg/loaders/to.$id.looking.server.ts @@ -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; 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; diff --git a/app/features/tournament-match/actions/to.$id.matches.$mid.server.ts b/app/features/tournament-match/actions/to.$id.matches.$mid.server.ts index 53baa696d..c117adf28 100644 --- a/app/features/tournament-match/actions/to.$id.matches.$mid.server.ts +++ b/app/features/tournament-match/actions/to.$id.matches.$mid.server.ts @@ -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 diff --git a/app/features/tournament/TournamentRepository.permissions.test.ts b/app/features/tournament/TournamentRepository.permissions.test.ts new file mode 100644 index 000000000..6a4ba3675 --- /dev/null +++ b/app/features/tournament/TournamentRepository.permissions.test.ts @@ -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[1] +>["members"]; +type Staff = Parameters[0]["staff"]; +type Grant = Parameters[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([]); + }); +}); diff --git a/app/features/tournament/TournamentRepository.server.ts b/app/features/tournament/TournamentRepository.server.ts index 2f5f28703..e6c6dddc5 100644 --- a/app/features/tournament/TournamentRepository.server.ts +++ b/app/features/tournament/TournamentRepository.server.ts @@ -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>; @@ -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) => + organizationMembers + .filter((member) => roles.includes(member.role)) + .map((member) => member.userId); + const staffWithRole = (roles: Array) => + 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 diff --git a/app/features/tournament/TournamentTeamRepository.server.ts b/app/features/tournament/TournamentTeamRepository.server.ts index 61a64573c..9511f317d 100644 --- a/app/features/tournament/TournamentTeamRepository.server.ts +++ b/app/features/tournament/TournamentTeamRepository.server.ts @@ -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") diff --git a/app/features/tournament/core/layout-payload.ts b/app/features/tournament/core/layout-payload.ts index a6f371599..13fa2f8ab 100644 --- a/app/features/tournament/core/layout-payload.ts +++ b/app/features/tournament/core/layout-payload.ts @@ -7,7 +7,6 @@ import type { TournamentLoaderData } from "../loaders/to.$id.server"; */ const NULL_COMPACTED_TEAM_KEYS = [ "seed", - "inviteCode", "logoUrl", "activeRosterUserIds", "startingBracketIdx", diff --git a/app/features/tournament/loaders/to.$id.register.server.ts b/app/features/tournament/loaders/to.$id.register.server.ts index c16ef8371..805f3b865 100644 --- a/app/features/tournament/loaders/to.$id.register.server.ts +++ b/app/features/tournament/loaders/to.$id.register.server.ts @@ -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; diff --git a/app/features/tournament/loaders/to.$id.server.ts b/app/features/tournament/loaders/to.$id.server.ts index 282e222ef..8d9a3b1b4 100644 --- a/app/features/tournament/loaders/to.$id.server.ts +++ b/app/features/tournament/loaders/to.$id.server.ts @@ -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, diff --git a/app/features/tournament/loaders/to.$id.teams.$tid.comps.server.ts b/app/features/tournament/loaders/to.$id.teams.$tid.comps.server.ts index e9ee5697c..ff804aa23 100644 --- a/app/features/tournament/loaders/to.$id.teams.$tid.comps.server.ts +++ b/app/features/tournament/loaders/to.$id.teams.$tid.comps.server.ts @@ -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, ); diff --git a/app/features/tournament/loaders/to.$id.teams.$tid.server.ts b/app/features/tournament/loaders/to.$id.teams.$tid.server.ts index 905fcee5d..4e87803b1 100644 --- a/app/features/tournament/loaders/to.$id.teams.$tid.server.ts +++ b/app/features/tournament/loaders/to.$id.teams.$tid.server.ts @@ -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, diff --git a/app/features/trophies/TrophyRepository.server.ts b/app/features/trophies/TrophyRepository.server.ts index 218fd22e7..9b7b506a8 100644 --- a/app/features/trophies/TrophyRepository.server.ts +++ b/app/features/trophies/TrophyRepository.server.ts @@ -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) { diff --git a/app/features/trophies/actions/trophies.new.server.ts b/app/features/trophies/actions/trophies.new.server.ts index cd62702dd..25658b33d 100644 --- a/app/features/trophies/actions/trophies.new.server.ts +++ b/app/features/trophies/actions/trophies.new.server.ts @@ -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, diff --git a/app/features/trophies/trophies-utils.ts b/app/features/trophies/trophies-utils.ts index 083b537c9..29347f8c1 100644 --- a/app/features/trophies/trophies-utils.ts +++ b/app/features/trophies/trophies-utils.ts @@ -50,15 +50,6 @@ export function canEditAnyTrophy(user?: { roles: Array } | null) { return user.roles.includes("ADMIN"); } -export function canEditTrophy( - user: { id: number; roles: Array } | 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, ) { diff --git a/app/features/user-page/actions/u.$identifier.art.server.ts b/app/features/user-page/actions/u.$identifier.art.server.ts index 80b36d784..56851ed31 100644 --- a/app/features/user-page/actions/u.$identifier.art.server.ts +++ b/app/features/user-page/actions/u.$identifier.art.server.ts @@ -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); diff --git a/app/features/user-page/core/build-sorting.server.test.ts b/app/features/user-page/core/build-sorting.server.test.ts index 9e84d5e60..d04e5bade 100644 --- a/app/features/user-page/core/build-sorting.server.test.ts +++ b/app/features/user-page/core/build-sorting.server.test.ts @@ -26,6 +26,7 @@ const mockBuild = ( title: "", updatedAt: databaseTimestampNow(), weapons: [{ weaponSplId: 0, isTop500: 0 }], + permissions: { EDIT: [] }, ...partialBuild, }; }; diff --git a/app/features/user-page/routes/u.$identifier.art.tsx b/app/features/user-page/routes/u.$identifier.art.tsx index f1d7ba5b5..a26f15f3d 100644 --- a/app/features/user-page/routes/u.$identifier.art.tsx +++ b/app/features/user-page/routes/u.$identifier.art.tsx @@ -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(); const [type, setType] = useSearchParam(userArtSearchParams, "source"); const [tagParam, setFilteredTag] = useSearchParam(userArtSearchParams, "tag"); @@ -136,11 +134,7 @@ export default function UserArtPage() {
) : null} - +
); } diff --git a/app/features/user-page/routes/u.$identifier.builds.tsx b/app/features/user-page/routes/u.$identifier.builds.tsx index 6f0fbe23c..673c57527 100644 --- a/app/features/user-page/routes/u.$identifier.builds.tsx +++ b/app/features/user-page/routes/u.$identifier.builds.tsx @@ -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)} /> ))}
diff --git a/app/features/vods/VodRepository.server.ts b/app/features/vods/VodRepository.server.ts index c632cbd5c..cad2cee69 100644 --- a/app/features/vods/VodRepository.server.ts +++ b/app/features/vods/VodRepository.server.ts @@ -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; diff --git a/app/features/vods/actions/vods.$id.server.ts b/app/features/vods/actions/vods.$id.server.ts index c5126ac3d..61b6d065f 100644 --- a/app/features/vods/actions/vods.$id.server.ts +++ b/app/features/vods/actions/vods.$id.server.ts @@ -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); diff --git a/app/features/vods/loaders/vods.new.server.ts b/app/features/vods/loaders/vods.new.server.ts index e728e4be0..688eb5e19 100644 --- a/app/features/vods/loaders/vods.new.server.ts +++ b/app/features/vods/loaders/vods.new.server.ts @@ -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 }; } diff --git a/app/features/vods/routes/vods.$id.tsx b/app/features/vods/routes/vods.$id.tsx index 6edc12888..f878730f8 100644 --- a/app/features/vods/routes/vods.$id.tsx +++ b/app/features/vods/routes/vods.$id.tsx @@ -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(); const { t } = useTranslation(["common", "vods"]); const user = useUser(); + const canEdit = useHasPermission(data.vod, "EDIT"); return (
@@ -105,12 +106,7 @@ export default function VodPage() { /> - {canEditVideo({ - submitterUserId: data.vod.submitterUserId, - userId: user?.id, - povUserId: - typeof data.vod.pov === "string" ? undefined : data.vod.pov?.id, - }) ? ( + {canEdit ? (
{user?.id === data.vod.submitterUserId ? ( { @@ -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"] }, ); diff --git a/app/features/vods/vods-utils.ts b/app/features/vods/vods-utils.ts index c3dc56302..c7c64e17e 100644 --- a/app/features/vods/vods-utils.ts +++ b/app/features/vods/vods-utils.ts @@ -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\/)([^&/?#]+)/, diff --git a/app/modules/permissions/guards.server.ts b/app/modules/permissions/guards.server.ts index bba0a1964..e1bc590e1 100644 --- a/app/modules/permissions/guards.server.ts +++ b/app/modules/permissions/guards.server.ts @@ -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; - - if (permissions[permission].includes(user.id)) { + if (hasPermission(obj, permission, user)) { return; } diff --git a/app/modules/permissions/hooks.ts b/app/modules/permissions/hooks.ts index 9b415c71e..32d7a5ebb 100644 --- a/app/modules/permissions/hooks.ts +++ b/app/modules/permissions/hooks.ts @@ -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)[permission].includes(user.id); + return hasPermission(obj, permission, user); } diff --git a/app/modules/permissions/utils.test.ts b/app/modules/permissions/utils.test.ts new file mode 100644 index 000000000..f6091be52 --- /dev/null +++ b/app/modules/permissions/utils.test.ts @@ -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); + }); +}); diff --git a/app/modules/permissions/utils.ts b/app/modules/permissions/utils.ts index 7b9860ab7..715744a67 100644 --- a/app/modules/permissions/utils.ts +++ b/app/modules/permissions/utils.ts @@ -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)[permission].includes(user.id); +} export function isAdmin(user?: { id: number }) { return user?.id === ADMIN_ID; diff --git a/app/routines/notifyCheckInStart.ts b/app/routines/notifyCheckInStart.ts index fa0fed15a..7499c72d7 100644 --- a/app/routines/notifyCheckInStart.ts +++ b/app/routines/notifyCheckInStart.ts @@ -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; diff --git a/docs/dev/permissions.md b/docs/dev/permissions.md new file mode 100644 index 000000000..d362fc757 --- /dev/null +++ b/docs/dev/permissions.md @@ -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. \ No newline at end of file diff --git a/docs/dev/repositories.md b/docs/dev/repositories.md index ff5023b75..3dac6bd2c 100644 --- a/docs/dev/repositories.md +++ b/docs/dev/repositories.md @@ -2,7 +2,7 @@ Repositories are the only place database queries are written. One per feature (`app/features//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. diff --git a/e2e/team.spec.ts b/e2e/team.spec.ts index 60a996441..5c89677d5 100644 --- a/e2e/team.spec.ts +++ b/e2e/team.spec.ts @@ -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); }); }); diff --git a/scripts/benchmark-db/cases.ts b/scripts/benchmark-db/cases.ts index a0011b09b..ce9d57c67 100644 --- a/scripts/benchmark-db/cases.ts +++ b/scripts/benchmark-db/cases.ts @@ -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, diff --git a/scripts/benchmark-db/fixtures.ts b/scripts/benchmark-db/fixtures.ts index 13828e30e..ff7388236 100644 --- a/scripts/benchmark-db/fixtures.ts +++ b/scripts/benchmark-db/fixtures.ts @@ -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 { 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().as("count")]) + .groupBy("artId") + .orderBy("count", "desc") + .limit(1) + .executeTakeFirst(); + + return row?.artId ?? null; +} + async function resolveImageSubmitterId() { const row = await db .selectFrom("UnvalidatedUserSubmittedImage")