* Fix unexpected server error when trying to access non-existing tournament team page

* Fix Catcher textarea usage

* Fix unexpected server error on LFG page

* Validator for duplicate tournament team name

* initial

* Fix tests

* Success toast

* Done?

* Fix leftover
This commit is contained in:
Kalle 2025-03-07 22:20:29 +02:00 committed by GitHub
parent 38a6ed56ee
commit aea1e9ce35
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
49 changed files with 1555 additions and 1162 deletions

View File

@ -0,0 +1,85 @@
.toastRegion {
flex-direction: column;
gap: 8px;
display: flex;
position: fixed;
top: 55px;
right: 8px;
z-index: 1;
}
.toast {
background: var(--bg-lighter-solid);
border-radius: var(--rounded);
padding: var(--s-1-5) var(--s-3);
align-items: center;
justify-content: center;
font-size: var(--fonts-xsm);
font-weight: var(--semi-bold);
gap: var(--s-1);
border: 2px solid var(--border);
width: 250px;
outline: none;
/* biome-ignore lint/correctness/noUnknownProperty: it exists */
view-transition-class: toast;
}
::view-transition-new(.toast):only-child {
animation: appear 400ms;
}
::view-transition-old(.toast):only-child {
animation: disappear 400ms;
}
@keyframes disappear {
to {
opacity: 0;
}
}
@keyframes appear {
from {
opacity: 0;
}
}
.errorToast {
border-color: var(--theme-error);
}
.topRow {
display: flex;
text-transform: uppercase;
color: var(--text-lighter);
font-size: var(--fonts-xxsm);
gap: var(--s-2);
align-items: center;
margin-block-end: var(--s-1-5);
}
.closeButton {
margin-inline-start: auto;
outline-color: var(--text-lighter) !important;
}
.closeButton svg {
width: 1rem;
fill: var(--text-lighter);
}
.alertIcon {
width: 1.25rem;
}
.errorToast .alertIcon {
fill: var(--theme-error);
}
.infoToast .alertIcon {
fill: var(--theme-info);
}
.successToast .alertIcon {
fill: var(--theme-success);
}

View File

@ -0,0 +1,70 @@
import clsx from "clsx";
import {
Text,
UNSTABLE_Toast as Toast,
UNSTABLE_ToastContent as ToastContent,
UNSTABLE_ToastQueue as ToastQueue,
UNSTABLE_ToastRegion as ToastRegion,
} from "react-aria-components";
import { flushSync } from "react-dom";
import { useTranslation } from "react-i18next";
import { AlertIcon } from "../icons/Alert";
import { CheckmarkIcon } from "../icons/Checkmark";
import { CrossIcon } from "../icons/Cross";
import { SendouButton } from "./Button";
import styles from "./Toast.module.css";
export interface SendouToast {
message: string;
variant: "error" | "success" | "info";
}
export const toastQueue = new ToastQueue<SendouToast>({
wrapUpdate(fn) {
if ("startViewTransition" in document) {
document.startViewTransition(() => {
flushSync(fn);
});
} else {
fn();
}
},
});
export function SendouToastRegion() {
const { t } = useTranslation(["common"]);
return (
<ToastRegion queue={toastQueue} className={styles.toastRegion}>
{({ toast }) => (
<Toast
style={{ viewTransitionName: toast.key }}
toast={toast}
className={clsx(styles.toast, {
[styles.errorToast]: toast.content.variant === "error",
[styles.successToast]: toast.content.variant === "success",
[styles.infoToast]: toast.content.variant === "info",
})}
>
<ToastContent>
<div className={styles.topRow}>
{toast.content.variant === "success" ? (
<CheckmarkIcon className={styles.alertIcon} />
) : (
<AlertIcon className={styles.alertIcon} />
)}
{t(`common:toasts.${toast.content.variant}`)}
<SendouButton
variant="minimal-destructive"
icon={<CrossIcon />}
className={styles.closeButton}
slot="close"
/>
</div>
<Text slot="title">{toast.content.message}</Text>
</ToastContent>
</Toast>
)}
</ToastRegion>
);
}

View File

@ -7,7 +7,11 @@ import { refreshBannedCache } from "~/features/ban/core/banned.server";
import * as UserRepository from "~/features/user-page/UserRepository.server";
import { isAdmin, isMod } from "~/permissions";
import { logger } from "~/utils/logger";
import { parseRequestPayload, validate } from "~/utils/remix.server";
import {
errorToastIfFalsy,
parseRequestPayload,
successToast,
} from "~/utils/remix.server";
import { assertUnreachable } from "~/utils/types";
import { _action, actualNumber, friendCode } from "~/utils/zod";
import { plusTiersFromVotingAndLeaderboard } from "../core/plus-tier.server";
@ -19,26 +23,31 @@ export const action = async ({ request }: ActionFunctionArgs) => {
});
const user = await requireUserId(request);
let message: string;
switch (data._action) {
case "MIGRATE": {
validate(isMod(user), "Admin needed", 401);
errorToastIfFalsy(isMod(user), "Admin needed");
await AdminRepository.migrate({
oldUserId: data["old-user"],
newUserId: data["new-user"],
});
message = "Account migrated";
break;
}
case "REFRESH": {
validate(isAdmin(user));
errorToastIfFalsy(isAdmin(user), "Admin needed");
await AdminRepository.replacePlusTiers(
await plusTiersFromVotingAndLeaderboard(),
);
message = "Plus tiers refreshed";
break;
}
case "FORCE_PATRON": {
validate(isAdmin(user), "Admin needed", 401);
errorToastIfFalsy(isAdmin(user), "Admin needed");
await AdminRepository.forcePatron({
id: data.user,
@ -46,45 +55,56 @@ export const action = async ({ request }: ActionFunctionArgs) => {
patronTier: data.patronTier,
patronTill: new Date(data.patronTill),
});
message = "Patron status updated";
break;
}
case "CLEAN_UP": {
validate(isAdmin(user), "Admin needed", 401);
errorToastIfFalsy(isAdmin(user), "Admin needed");
// on purpose sync
AdminRepository.cleanUp();
message = "Clean up done";
break;
}
case "ARTIST": {
validate(isMod(user), "Mod needed", 401);
errorToastIfFalsy(isMod(user), "Mod needed");
makeArtist(data.user);
message = "Artist permissions given";
break;
}
case "VIDEO_ADDER": {
validate(isMod(user), "Mod needed", 401);
errorToastIfFalsy(isMod(user), "Mod needed");
await AdminRepository.makeVideoAdderByUserId(data.user);
message = "VoD adder permissions given";
break;
}
case "TOURNAMENT_ORGANIZER": {
validate(isMod(user), "Mod needed", 401);
errorToastIfFalsy(isMod(user), "Mod needed");
await AdminRepository.makeTournamentOrganizerByUserId(data.user);
message = "Tournament permissions given";
break;
}
case "LINK_PLAYER": {
validate(isMod(user), "Mod needed", 401);
errorToastIfFalsy(isMod(user), "Mod needed");
await AdminRepository.linkUserAndPlayer({
userId: data.user,
playerId: data.playerId,
});
message = "Linked user and player";
break;
}
case "BAN_USER": {
validate(isMod(user), "Mod needed", 401);
errorToastIfFalsy(isMod(user), "Mod needed");
await AdminRepository.banUser({
bannedReason: data.reason ?? null,
@ -103,10 +123,11 @@ export const action = async ({ request }: ActionFunctionArgs) => {
: undefined,
});
message = "User banned";
break;
}
case "UNBAN_USER": {
validate(isMod(user), "Mod needed", 401);
errorToastIfFalsy(isMod(user), "Mod needed");
await AdminRepository.unbanUser(data.user);
@ -117,10 +138,11 @@ export const action = async ({ request }: ActionFunctionArgs) => {
byUserId: user.id,
});
message = "User unbanned";
break;
}
case "UPDATE_FRIEND_CODE": {
validate(isMod(user), "Mod needed", 401);
errorToastIfFalsy(isMod(user), "Mod needed");
await UserRepository.insertFriendCode({
friendCode: data.friendCode,
@ -128,6 +150,7 @@ export const action = async ({ request }: ActionFunctionArgs) => {
userId: data.user,
});
message = "Friend code updated";
break;
}
default: {
@ -135,7 +158,7 @@ export const action = async ({ request }: ActionFunctionArgs) => {
}
}
return { ok: true };
return successToast(message);
};
export const adminActionSchema = z.union([

View File

@ -3,7 +3,7 @@ import { getUserId } from "~/features/auth/core/user.server";
import * as UserRepository from "~/features/user-page/UserRepository.server";
import { updatePatreonData } from "~/modules/patreon";
import { canAccessLohiEndpoint, canPerformAdminActions } from "~/permissions";
import { validate } from "~/utils/remix.server";
import { unauthorizedIfFalsy } from "~/utils/remix.server";
export const action: ActionFunction = async ({ request }) => {
const user = await getUserId(request);
@ -18,7 +18,7 @@ export const action: ActionFunction = async ({ request }) => {
};
export const loader = ({ request }: LoaderFunctionArgs) => {
validate(canAccessLohiEndpoint(request), "Invalid token", 403);
unauthorizedIfFalsy(canAccessLohiEndpoint(request));
return UserRepository.findAllPatrons();
};

View File

@ -33,9 +33,10 @@ import { dateToDatabaseTimestamp } from "~/utils/dates";
import invariant from "~/utils/invariant";
import {
type SendouRouteHandle,
errorToastIfFalsy,
parseFormData,
parseRequestPayload,
validate,
unauthorizedIfFalsy,
} from "~/utils/remix.server";
import {
artPage,
@ -68,7 +69,7 @@ export const meta: MetaFunction = () => {
export const action: ActionFunction = async ({ request }) => {
const user = await requireUser(request);
validate(user.isArtist, "Lacking artist role", 403);
errorToastIfFalsy(user.isArtist, "Lacking artist role");
const searchParams = new URL(request.url).searchParams;
const artIdRaw = searchParams.get(NEW_ART_EXISTING_SEARCH_PARAM_KEY);
@ -78,10 +79,9 @@ export const action: ActionFunction = async ({ request }) => {
const artId = Number(artIdRaw);
const existingArt = findArtById(artId);
validate(
errorToastIfFalsy(
existingArt?.authorId === user.id,
"Insufficient permissions",
401,
"Art author is someone else",
);
const data = await parseRequestPayload({
@ -158,7 +158,7 @@ export const action: ActionFunction = async ({ request }) => {
export const loader = async ({ request }: LoaderFunctionArgs) => {
const user = await requireUser(request);
validate(user.isArtist, "Lacking artist role", 403);
unauthorizedIfFalsy(user.isArtist);
const artIdRaw = new URL(request.url).searchParams.get(
NEW_ART_EXISTING_SEARCH_PARAM_KEY,

View File

@ -5,7 +5,7 @@ import { z } from "zod";
import * as UserRepository from "~/features/user-page/UserRepository.server";
import { canAccessLohiEndpoint, canPerformAdminActions } from "~/permissions";
import { logger } from "~/utils/logger";
import { parseSearchParams, validate } from "~/utils/remix.server";
import { errorToastIfFalsy, parseSearchParams } from "~/utils/remix.server";
import { ADMIN_PAGE, authErrorUrl } from "~/utils/urls";
import { createLogInLink } from "../queries/createLogInLink.server";
import { deleteLogInLinkByCode } from "../queries/deleteLogInLinkByCode.server";
@ -61,7 +61,7 @@ export const logOutAction: ActionFunction = async ({ request }) => {
};
export const logInAction: ActionFunction = async ({ request }) => {
validate(
errorToastIfFalsy(
process.env.LOGIN_DISABLED !== "true",
"Login is temporarily disabled",
);

View File

@ -15,9 +15,9 @@ import { notify } from "~/features/notifications/core/notify.server";
import { canEditBadgeManagers, canEditBadgeOwners } from "~/permissions";
import { atOrError, diff } from "~/utils/arrays";
import {
errorToastIfFalsy,
notFoundIfFalsy,
parseRequestPayload,
validate,
} from "~/utils/remix.server";
import { assertUnreachable } from "~/utils/types";
import { badgePage } from "~/utils/urls";
@ -38,7 +38,10 @@ export const action: ActionFunction = async ({ request, params }) => {
switch (data._action) {
case "MANAGERS": {
validate(canEditBadgeManagers(user));
errorToastIfFalsy(
canEditBadgeManagers(user),
"No permissions to edit managers",
);
const oldManagers = await BadgeRepository.findManagersByBadgeId(badgeId);
@ -65,11 +68,12 @@ export const action: ActionFunction = async ({ request, params }) => {
break;
}
case "OWNERS": {
validate(
errorToastIfFalsy(
canEditBadgeOwners({
user,
managers: await BadgeRepository.findManagersByBadgeId(badgeId),
}),
"No permissions to edit owners",
);
const oldOwners = await BadgeRepository.findOwnersByBadgeId(badgeId);

View File

@ -21,9 +21,9 @@ import {
} from "~/utils/dates";
import {
badRequestIfFalsy,
errorToastIfFalsy,
parseFormData,
uploadImageIfSubmitted,
validate,
} from "~/utils/remix.server";
import { calendarEventPage } from "~/utils/urls";
import {
@ -58,7 +58,7 @@ export const action: ActionFunction = async ({ request }) => {
parseAsync: true,
});
validate(canAddNewEvent(user), "Not authorized", 401);
errorToastIfFalsy(canAddNewEvent(user), "Not authorized");
const startTimes = data.date.map((date) => dateToDatabaseTimestamp(date));
const commonArgs = {
@ -109,7 +109,7 @@ export const action: ActionFunction = async ({ request }) => {
)
: undefined,
};
validate(
errorToastIfFalsy(
!commonArgs.toToolsEnabled || commonArgs.bracketProgression,
"Bracket progression must be set for tournaments",
);
@ -129,15 +129,17 @@ export const action: ActionFunction = async ({ request }) => {
tournamentId: eventToEdit.tournamentId,
user,
});
validate(!tournament.hasStarted, "Tournament has already started", 400);
errorToastIfFalsy(
!tournament.hasStarted,
"Tournament has already started",
);
validate(tournament.isAdmin(user), "Not authorized", 401);
errorToastIfFalsy(tournament.isAdmin(user), "Not authorized");
} else {
// editing regular calendar event
validate(
errorToastIfFalsy(
canEditCalendarEvent({ user, event: eventToEdit }),
"Not authorized",
401,
);
}

View File

@ -6,7 +6,7 @@ import * as CalendarRepository from "~/features/calendar/CalendarRepository.serv
import { tournamentData } from "~/features/tournament-bracket/core/Tournament.server";
import * as TournamentOrganizationRepository from "~/features/tournament-organization/TournamentOrganizationRepository.server";
import { canEditCalendarEvent } from "~/permissions";
import { validate } from "~/utils/remix.server";
import { unauthorizedIfFalsy } from "~/utils/remix.server";
import { tournamentBracketsPage } from "~/utils/urls";
import { canAddNewEvent } from "../calendar-utils";
@ -14,7 +14,7 @@ export const loader = async ({ request }: LoaderFunctionArgs) => {
const user = await requireUser(request);
const url = new URL(request.url);
validate(canAddNewEvent(user), "Not authorized", 401);
unauthorizedIfFalsy(canAddNewEvent(user));
const eventWithTournament = async (key: string) => {
const eventId = Number(url.searchParams.get(key));

View File

@ -21,9 +21,10 @@ import * as CalendarRepository from "~/features/calendar/CalendarRepository.serv
import { canReportCalendarEventWinners } from "~/permissions";
import {
type SendouRouteHandle,
errorToastIfFalsy,
notFoundIfFalsy,
safeParseRequestFormData,
validate,
unauthorizedIfFalsy,
} from "~/utils/remix.server";
import type { Unpacked } from "~/utils/types";
import { calendarEventPage } from "~/utils/urls";
@ -111,14 +112,13 @@ export const action: ActionFunction = async ({ request, params }) => {
const event = notFoundIfFalsy(
await CalendarRepository.findById({ id: parsedParams.id }),
);
validate(
errorToastIfFalsy(
canReportCalendarEventWinners({
user,
event,
startTimes: event.startTimes,
}),
"Unauthorized",
401,
);
await CalendarRepository.upsertReportedScores({
@ -148,7 +148,7 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => {
await CalendarRepository.findById({ id: parsedParams.id }),
);
validate(
unauthorizedIfFalsy(
canReportCalendarEventWinners({
user,
event,

View File

@ -38,8 +38,8 @@ import {
import { databaseTimestampToDate } from "~/utils/dates";
import {
type SendouRouteHandle,
errorToastIfFalsy,
notFoundIfFalsy,
validate,
} from "~/utils/remix.server";
import {
CALENDAR_PAGE,
@ -69,17 +69,18 @@ export const action: ActionFunction = async ({ params, request }) => {
);
if (event.tournamentId) {
validate(
errorToastIfFalsy(
tournamentManagerData(event.tournamentId).stage.length === 0,
"Tournament has already started",
);
} else {
validate(
errorToastIfFalsy(
canDeleteCalendarEvent({
user,
event,
startTime: databaseTimestampToDate(event.startTimes[0]),
}),
"Cannot delete event",
);
}

View File

@ -15,9 +15,9 @@ import { dateToDatabaseTimestamp } from "~/utils/dates";
import invariant from "~/utils/invariant";
import {
badRequestIfFalsy,
errorToastIfFalsy,
parseSearchParams,
unauthorizedIfFalsy,
validate,
} from "~/utils/remix.server";
import { teamPage, tournamentOrganizationPage } from "~/utils/urls";
import { addNewImage } from "../queries/addNewImage";
@ -30,7 +30,7 @@ export const action = async ({ request }: ActionFunctionArgs) => {
const user = await requireUser(request);
const validatedType = requestToImgType(request);
validate(validatedType, "Invalid image type");
errorToastIfFalsy(validatedType, "Invalid image type");
const team =
validatedType === "team-pfp" || validatedType === "team-banner"
@ -41,8 +41,7 @@ export const action = async ({ request }: ActionFunctionArgs) => {
? await validatedOrg({ user, request })
: undefined;
// TODO: graceful error handling when uploading many images
validate(
errorToastIfFalsy(
countUnvalidatedImg(user.id) < MAX_UNVALIDATED_IMG_COUNT,
"Too many unvalidated images",
);
@ -97,8 +96,8 @@ async function validatedTeam({
});
const team = await TeamRepository.findByCustomUrl(teamCustomUrl);
validate(team, "Team not found");
validate(
errorToastIfFalsy(team, "Team not found");
errorToastIfFalsy(
isTeamManager({ team, user }),
"You must be the team manager to upload images",
);

View File

@ -7,9 +7,9 @@ import { clearTournamentDataCache } from "~/features/tournament-bracket/core/Tou
import { isMod } from "~/permissions";
import {
badRequestIfFalsy,
errorToastIfFalsy,
notFoundIfFalsy,
parseRequestPayload,
validate,
} from "~/utils/remix.server";
import { userSubmittedImage } from "~/utils/urls";
import * as ImageRepository from "../ImageRepository.server";
@ -25,7 +25,7 @@ export const action: ActionFunction = async ({ request }) => {
request,
});
validate(isMod(user), "Only admins can validate images");
errorToastIfFalsy(isMod(user), "Only admins can validate images");
const image = badRequestIfFalsy(await ImageRepository.findById(data.imageId));

View File

@ -3,7 +3,7 @@ import { redirect } from "@remix-run/node";
import { z } from "zod";
import { requireUser } from "~/features/auth/core/user.server";
import * as UserRepository from "~/features/user-page/UserRepository.server";
import { parseRequestPayload, validate } from "~/utils/remix.server";
import { errorToastIfFalsy, parseRequestPayload } from "~/utils/remix.server";
import { LFG_PAGE } from "~/utils/urls";
import { falsyToNull, id } from "~/utils/zod";
import * as LFGRepository from "../LFGRepository.server";
@ -22,7 +22,7 @@ export const action = async ({ request }: ActionFunctionArgs) => {
const shouldIncludeTeam = TEAM_POST_TYPES.includes(data.type);
validate(
errorToastIfFalsy(
!shouldIncludeTeam || team,
"Team needs to be set for this type of post",
);
@ -74,6 +74,9 @@ const validateCanUpdatePost = async ({
}) => {
const posts = await LFGRepository.posts(user);
const post = posts.find((post) => post.id === postId);
validate(post, "Post to update not found");
validate(post.author.id === user.id, "You can only update your own posts");
errorToastIfFalsy(post, "Post to update not found");
errorToastIfFalsy(
post.author.id === user.id,
"You can only update your own posts",
);
};

View File

@ -2,7 +2,7 @@ import type { ActionFunctionArgs } from "@remix-run/node";
import { z } from "zod";
import { requireUser } from "~/features/auth/core/user.server";
import { isAdmin } from "~/permissions";
import { parseRequestPayload, validate } from "~/utils/remix.server";
import { errorToastIfFalsy, parseRequestPayload } from "~/utils/remix.server";
import { _action, id } from "~/utils/zod";
import * as LFGRepository from "../LFGRepository.server";
@ -15,8 +15,11 @@ export const action = async ({ request }: ActionFunctionArgs) => {
const posts = await LFGRepository.posts(user);
const post = posts.find((post) => post.id === data.id);
validate(post, "Post not found");
validate(isAdmin(user) || post.author.id === user.id, "Not your own post");
errorToastIfFalsy(post, "Post not found");
errorToastIfFalsy(
isAdmin(user) || post.author.id === user.id,
"Not your own post",
);
switch (data._action) {
case "DELETE_POST": {

View File

@ -20,8 +20,8 @@ import {
import { atOrError } from "~/utils/arrays";
import {
badRequestIfFalsy,
errorToastIfFalsy,
parseRequestPayload,
validate,
} from "~/utils/remix.server";
import { plusSuggestionPage } from "~/utils/urls";
import { actualNumber, trimmedString } from "~/utils/zod";
@ -57,14 +57,14 @@ export const action: ActionFunction = async ({ request }) => {
const suggestions =
await PlusSuggestionRepository.findAllByMonth(votingMonthYear);
validate(suggestions);
validate(
errorToastIfFalsy(
canAddCommentToSuggestionBE({
suggestions,
user,
suggested: { id: data.suggestedId },
targetPlusTier: data.tier,
}),
"No permissions to add this comment",
);
await PlusSuggestionRepository.create({

View File

@ -33,8 +33,8 @@ import {
import { atOrError } from "~/utils/arrays";
import {
badRequestIfFalsy,
errorToastIfFalsy,
parseRequestPayload,
validate,
} from "~/utils/remix.server";
import { plusSuggestionPage } from "~/utils/urls";
import { actualNumber, trimmedString } from "~/utils/zod";
@ -73,14 +73,14 @@ export const action: ActionFunction = async ({ request }) => {
const suggestions =
await PlusSuggestionRepository.findAllByMonth(votingMonthYear);
validate(suggestions);
validate(
errorToastIfFalsy(
canSuggestNewUserBE({
user,
suggested,
targetPlusTier: data.tier,
suggestions,
}),
"No permissions to make this suggestion",
);
await PlusSuggestionRepository.create({

View File

@ -35,8 +35,8 @@ import invariant from "~/utils/invariant";
import { metaTags } from "~/utils/remix";
import {
badRequestIfFalsy,
errorToastIfFalsy,
parseRequestPayload,
validate,
} from "~/utils/remix.server";
import { assertUnreachable } from "~/utils/types";
import { userPage } from "~/utils/urls";
@ -96,14 +96,14 @@ export const action: ActionFunction = async ({ request }) => {
);
invariant(subSuggestion);
validate(suggestionToDelete);
validate(
errorToastIfFalsy(
canDeleteComment({
user,
author: subSuggestion.author,
suggestionId: data.suggestionId,
suggestions,
}),
"No permissions to delete this comment",
);
const suggestionHasComments = suggestionToDelete.suggestions.length > 1;

View File

@ -31,8 +31,8 @@ import invariant from "~/utils/invariant";
import { metaTags } from "~/utils/remix";
import {
type SendouRouteHandle,
errorToastIfFalsy,
parseRequestPayload,
validate,
} from "~/utils/remix.server";
import { errorIsSqliteForeignKeyConstraintFailure } from "~/utils/sql";
import { assertUnreachable } from "~/utils/types";
@ -117,7 +117,7 @@ export const action: ActionFunction = async ({ request }) => {
// this throws because there should normally be no way user loses ownership by the action of some other user
const validateIsGroupOwner = () =>
validate(currentGroup.role === "OWNER", "Not owner");
errorToastIfFalsy(currentGroup.role === "OWNER", "Not owner");
const isGroupManager = () =>
currentGroup.role === "MANAGER" || currentGroup.role === "OWNER";
@ -268,17 +268,20 @@ export const action: ActionFunction = async ({ request }) => {
);
if (!theirGroup) return null;
validate(
errorToastIfFalsy(
ourGroup.members.length === FULL_GROUP_SIZE,
"'ourGroup' is not full",
"Our group is not full",
);
validate(
errorToastIfFalsy(
theirGroup.members.length === FULL_GROUP_SIZE,
"'theirGroup' is not full",
"Their group is not full",
);
validate(!groupHasMatch(ourGroup.id), "Our group already has a match");
validate(
errorToastIfFalsy(
!groupHasMatch(ourGroup.id),
"Our group already has a match",
);
errorToastIfFalsy(
!groupHasMatch(theirGroup.id),
"Their group already has a match",
);
@ -364,7 +367,10 @@ export const action: ActionFunction = async ({ request }) => {
break;
}
case "LEAVE_GROUP": {
validate(!currentGroup.matchId, "Can't leave group while in a match");
errorToastIfFalsy(
!currentGroup.matchId,
"Can't leave group while in a match",
);
let newOwnerId: number | null = null;
if (currentGroup.role === "OWNER") {
newOwnerId = groupSuccessorOwner(currentGroup.id);
@ -390,7 +396,7 @@ export const action: ActionFunction = async ({ request }) => {
}
case "KICK_FROM_GROUP": {
validateIsGroupOwner();
validate(data.userId !== user.id, "Can't kick yourself");
errorToastIfFalsy(data.userId !== user.id, "Can't kick yourself");
leaveGroup({
groupId: currentGroup.id,

View File

@ -63,10 +63,10 @@ import { logger } from "~/utils/logger";
import { safeNumberParse } from "~/utils/number";
import type { SendouRouteHandle } from "~/utils/remix.server";
import {
errorToastIfFalsy,
notFoundIfFalsy,
parseParams,
parseRequestPayload,
validate,
} from "~/utils/remix.server";
import { inGameNameWithoutDiscriminator } from "~/utils/strings";
import type { Unpacked } from "~/utils/types";
@ -177,7 +177,7 @@ export const action = async ({ request, params }: ActionFunctionArgs) => {
return null;
}
validate(
errorToastIfFalsy(
!data.adminReport || isMod(user),
"Only mods can report scores as admin",
);
@ -339,18 +339,18 @@ export const action = async ({ request, params }: ActionFunctionArgs) => {
}
case "LOOK_AGAIN": {
const season = currentSeason(new Date());
validate(season, "Season is not active");
errorToastIfFalsy(season, "Season is not active");
const previousGroup = await QMatchRepository.findGroupById({
groupId: data.previousGroupId,
});
validate(previousGroup, "Previous group not found");
errorToastIfFalsy(previousGroup, "Previous group not found");
for (const member of previousGroup.members) {
const currentGroup = findCurrentGroupByUserId(member.id);
validate(!currentGroup, "Member is already in a group");
errorToastIfFalsy(!currentGroup, "Member is already in a group");
if (member.id === user.id) {
validate(
errorToastIfFalsy(
member.role === "OWNER",
"You are not the owner of the group",
);
@ -366,7 +366,7 @@ export const action = async ({ request, params }: ActionFunctionArgs) => {
}
case "REPORT_WEAPONS": {
const match = notFoundIfFalsy(findMatchById(matchId));
validate(match.reportedAt, "Match has not been reported yet");
errorToastIfFalsy(match.reportedAt, "Match has not been reported yet");
const oldReportedWeapons = reportedWeaponsByMatchId(matchId) ?? [];

View File

@ -17,7 +17,7 @@ import { useAutoRefresh } from "~/hooks/useAutoRefresh";
import invariant from "~/utils/invariant";
import { metaTags } from "~/utils/remix";
import type { SendouRouteHandle } from "~/utils/remix.server";
import { parseRequestPayload, validate } from "~/utils/remix.server";
import { errorToastIfFalsy, parseRequestPayload } from "~/utils/remix.server";
import { assertUnreachable } from "~/utils/types";
import {
SENDOUQ_LOOKING_PAGE,
@ -65,14 +65,14 @@ export const action = async ({ request }: ActionFunctionArgs) => {
});
const currentGroup = findCurrentGroupByUserId(user.id);
validate(currentGroup, "No group found");
errorToastIfFalsy(currentGroup, "No group found");
if (!hasGroupManagerPerms(currentGroup.role)) {
return null;
}
const season = currentSeason(new Date());
validate(season, "Season is not active");
errorToastIfFalsy(season, "Season is not active");
switch (data._action) {
case "JOIN_QUEUE": {
@ -91,7 +91,7 @@ export const action = async ({ request }: ActionFunctionArgs) => {
return { error: "taken" } as const;
}
validate(
errorToastIfFalsy(
(await QRepository.usersThatTrusted(user.id)).trusters.some(
(trusterUser) => trusterUser.id === data.id,
),
@ -102,7 +102,7 @@ export const action = async ({ request }: ActionFunctionArgs) => {
groupId: currentGroup.id,
});
invariant(ownGroupWithMembers, "No own group found");
validate(
errorToastIfFalsy(
ownGroupWithMembers.members.length < FULL_GROUP_SIZE,
"Group is full",
);

View File

@ -35,8 +35,8 @@ import { joinListToNaturalString } from "~/utils/arrays";
import invariant from "~/utils/invariant";
import {
type SendouRouteHandle,
errorToastIfFalsy,
parseRequestPayload,
validate,
} from "~/utils/remix.server";
import { assertUnreachable } from "~/utils/types";
import {
@ -89,12 +89,12 @@ export const meta: MetaFunction = (args) => {
const validateCanJoinQ = async (user: { id: number; discordId: string }) => {
const friendCode = await UserRepository.currentFriendCodeByUserId(user.id);
validate(friendCode, "No friend code");
errorToastIfFalsy(friendCode, "No friend code");
const canJoinQueue = userCanJoinQueueAt(user, friendCode) === "NOW";
validate(currentSeason(new Date()), "Season is not active");
validate(!findCurrentGroupByUserId(user.id), "Already in a group");
validate(canJoinQueue, "Can't join queue right now");
errorToastIfFalsy(currentSeason(new Date()), "Season is not active");
errorToastIfFalsy(!findCurrentGroupByUserId(user.id), "Already in a group");
errorToastIfFalsy(canJoinQueue, "Can't join queue right now");
};
export const action: ActionFunction = async ({ request }) => {
@ -127,8 +127,14 @@ export const action: ActionFunction = async ({ request }) => {
const groupInvitedTo =
code && user ? findGroupByInviteCode(code) : undefined;
validate(groupInvitedTo, "Invite code doesn't match any active team");
validate(groupInvitedTo.members.length < FULL_GROUP_SIZE, "Team is full");
errorToastIfFalsy(
groupInvitedTo,
"Invite code doesn't match any active team",
);
errorToastIfFalsy(
groupInvitedTo.members.length < FULL_GROUP_SIZE,
"Team is full",
);
sql.transaction(() => {
addMember({
@ -156,7 +162,7 @@ export const action: ActionFunction = async ({ request }) => {
);
}
case "ADD_FRIEND_CODE": {
validate(
errorToastIfFalsy(
!(await UserRepository.currentFriendCodeByUserId(user.id)),
"Friend code already set",
);

View File

@ -1,5 +1,10 @@
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { dbInsertUsers, dbReset, wrappedAction } from "~/utils/Test";
import {
assertResponseErrored,
dbInsertUsers,
dbReset,
wrappedAction,
} from "~/utils/Test";
import { action as teamIndexPageAction } from "../actions/t.server";
import type { editTeamSchema } from "../team-schemas.server";
import type { createTeamSchema } from "../team-schemas.server";
@ -42,30 +47,30 @@ describe("team page editing", () => {
});
it("prevents adding custom css var of unknown property", async () => {
await expect(
editTeamProfileAction(
{
css: JSON.stringify({
"backdrop-filter": "#fff",
}),
...DEFAULT_FIELDS,
},
{ user: "regular", params: { customUrl: "team-1" } },
),
).rejects.toThrow("status code: 400");
const response = await editTeamProfileAction(
{
css: JSON.stringify({
"backdrop-filter": "#fff",
}),
...DEFAULT_FIELDS,
},
{ user: "regular", params: { customUrl: "team-1" } },
);
assertResponseErrored(response);
});
it("prevents adding custom css var of unknown value", async () => {
await expect(
editTeamProfileAction(
{
css: JSON.stringify({
bg: "url(https://sendou.ink/u?q=1&_data=features%2Fuser-search%2Froutes%2Fu)",
}),
...DEFAULT_FIELDS,
},
{ user: "regular", params: { customUrl: "team-1" } },
),
).rejects.toThrow("status code: 400");
const response = await editTeamProfileAction(
{
css: JSON.stringify({
bg: "url(https://sendou.ink/u?q=1&_data=features%2Fuser-search%2Froutes%2Fu)",
}),
...DEFAULT_FIELDS,
},
{ user: "regular", params: { customUrl: "team-1" } },
);
assertResponseErrored(response);
});
});

View File

@ -3,9 +3,9 @@ import { redirect } from "@remix-run/node";
import { requireUserId } from "~/features/auth/core/user.server";
import { isAdmin } from "~/permissions";
import {
errorToastIfFalsy,
notFoundIfFalsy,
parseRequestPayload,
validate,
} from "~/utils/remix.server";
import { assertUnreachable } from "~/utils/types";
import { TEAM_SEARCH_PAGE, mySlugify, teamPage } from "~/utils/urls";
@ -19,7 +19,7 @@ export const action: ActionFunction = async ({ request, params }) => {
const team = notFoundIfFalsy(await TeamRepository.findByCustomUrl(customUrl));
validate(
errorToastIfFalsy(
isTeamManager({ team, user }) || isAdmin(user),
"You are not a team manager",
);
@ -31,7 +31,10 @@ export const action: ActionFunction = async ({ request, params }) => {
switch (data._action) {
case "DELETE": {
validate(isTeamOwner({ team, user }), "You are not the team owner");
errorToastIfFalsy(
isTeamOwner({ team, user }),
"You are not the team owner",
);
await TeamRepository.del(team.id);
@ -41,7 +44,7 @@ export const action: ActionFunction = async ({ request, params }) => {
const newCustomUrl = mySlugify(data.name);
const existingTeam = await TeamRepository.findByCustomUrl(newCustomUrl);
validate(
errorToastIfFalsy(
newCustomUrl.length > 0,
"Team name can't be only special characters",
);

View File

@ -1,6 +1,6 @@
import { type ActionFunction, redirect } from "@remix-run/node";
import { requireUser } from "~/features/auth/core/user.server";
import { notFoundIfFalsy, validate } from "~/utils/remix.server";
import { errorToastIfFalsy, notFoundIfFalsy } from "~/utils/remix.server";
import { teamPage } from "~/utils/urls";
import * as TeamRepository from "../TeamRepository.server";
import { validateInviteCode } from "../loaders/t.$customUrl.join.server";
@ -20,7 +20,7 @@ export const action: ActionFunction = async ({ request, params }) => {
const inviteCode = new URL(request.url).searchParams.get("code") ?? "";
const realInviteCode = team.inviteCode!;
validate(
errorToastIfFalsy(
validateInviteCode({
inviteCode,
realInviteCode,

View File

@ -2,9 +2,9 @@ import type { ActionFunction } from "@remix-run/node";
import { requireUserId } from "~/features/auth/core/user.server";
import { isAdmin } from "~/permissions";
import {
errorToastIfFalsy,
notFoundIfFalsy,
parseRequestPayload,
validate,
} from "~/utils/remix.server";
import { assertUnreachable } from "~/utils/types";
import * as TeamMemberRepository from "../TeamMemberRepository.server";
@ -17,7 +17,7 @@ export const action: ActionFunction = async ({ request, params }) => {
const { customUrl } = teamParamsSchema.parse(params);
const team = notFoundIfFalsy(await TeamRepository.findByCustomUrl(customUrl));
validate(
errorToastIfFalsy(
isTeamManager({ team, user }) || isAdmin(user),
"Only team manager or owner can manage roster",
);
@ -31,9 +31,9 @@ export const action: ActionFunction = async ({ request, params }) => {
case "DELETE_MEMBER": {
const member = team.members.find((m) => m.id === data.userId);
validate(member, "Member not found");
validate(member.id !== user.id, "Can't delete yourself");
validate(!member.isOwner, "Can't delete owner");
errorToastIfFalsy(member, "Member not found");
errorToastIfFalsy(member.id !== user.id, "Can't delete yourself");
errorToastIfFalsy(!member.isOwner, "Can't delete owner");
await TeamRepository.handleMemberLeaving({
teamId: team.id,
@ -58,8 +58,11 @@ export const action: ActionFunction = async ({ request, params }) => {
}
case "REMOVE_MANAGER": {
const member = team.members.find((m) => m.id === data.userId);
validate(member, "Member not found");
validate(member.id !== user.id, "Can't remove yourself as manager");
errorToastIfFalsy(member, "Member not found");
errorToastIfFalsy(
member.id !== user.id,
"Can't remove yourself as manager",
);
await TeamMemberRepository.update(
{ teamId: team.id, userId: data.userId },

View File

@ -1,9 +1,9 @@
import type { ActionFunction } from "@remix-run/node";
import { requireUserId } from "~/features/auth/core/user.server";
import {
errorToastIfFalsy,
notFoundIfFalsy,
parseRequestPayload,
validate,
} from "~/utils/remix.server";
import { assertUnreachable } from "~/utils/types";
import * as TeamRepository from "../TeamRepository.server";
@ -25,7 +25,7 @@ export const action: ActionFunction = async ({ request, params }) => {
switch (data._action) {
case "LEAVE_TEAM": {
validate(
errorToastIfFalsy(
isTeamMember({ user, team }),
"You are not a member of this team",
);
@ -33,7 +33,7 @@ export const action: ActionFunction = async ({ request, params }) => {
const newOwner = isTeamOwner({ user, team })
? resolveNewOwner(team.members)
: null;
validate(
errorToastIfFalsy(
!isTeamOwner({ user, team }) || newOwner,
"You can't leave the team if you are the owner and there is no other member to become the owner",
);

View File

@ -1,5 +1,10 @@
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { dbInsertUsers, dbReset, wrappedAction } from "~/utils/Test";
import {
assertResponseErrored,
dbInsertUsers,
dbReset,
wrappedAction,
} from "~/utils/Test";
import { action as teamIndexPageAction } from "../actions/t.server";
import type { createTeamSchema } from "../team-schemas.server";
@ -23,8 +28,8 @@ describe("team creation", () => {
});
it("prevents creating a team whose name is only special characters", async () => {
await expect(action({ name: "𝓢𝓲𝓵" }, { user: "regular" })).rejects.toThrow(
"status code: 400",
);
const response = await action({ name: "𝓢𝓲𝓵" }, { user: "regular" });
assertResponseErrored(response);
});
});

View File

@ -1,7 +1,7 @@
import type { ActionFunction } from "@remix-run/node";
import { redirect } from "@remix-run/node";
import { requireUser } from "~/features/auth/core/user.server";
import { parseRequestPayload, validate } from "~/utils/remix.server";
import { errorToastIfFalsy, parseRequestPayload } from "~/utils/remix.server";
import { mySlugify, teamPage } from "~/utils/urls";
import { isAtLeastFiveDollarTierPatreon } from "~/utils/users";
import * as TeamRepository from "../TeamRepository.server";
@ -17,22 +17,25 @@ export const action: ActionFunction = async ({ request }) => {
const teams = await TeamRepository.findAllUndisbanded();
const teamMemberOfCount = teams.filter((team) =>
const currentTeamCount = teams.filter((team) =>
team.members.some((m) => m.id === user.id),
).length;
const maxMemberCount = isAtLeastFiveDollarTierPatreon(user)
const maxTeamCount = isAtLeastFiveDollarTierPatreon(user)
? TEAM.MAX_TEAM_COUNT_PATRON
: TEAM.MAX_TEAM_COUNT_NON_PATRON;
validate(
teamMemberOfCount < maxMemberCount,
errorToastIfFalsy(
currentTeamCount < maxTeamCount,
"Already in max amount of teams",
);
// two teams can't have same customUrl
const customUrl = mySlugify(data.name);
validate(customUrl.length > 0, "Team name can't be only special characters");
errorToastIfFalsy(
customUrl.length > 0,
"Team name can't be only special characters",
);
if (teams.some((team) => team.customUrl === customUrl)) {
return {
@ -44,7 +47,7 @@ export const action: ActionFunction = async ({ request }) => {
ownerUserId: user.id,
name: data.name,
customUrl,
isMainTeam: teamMemberOfCount === 0,
isMainTeam: currentTeamCount === 0,
});
throw redirect(teamPage(customUrl));

View File

@ -1,5 +1,10 @@
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { dbInsertUsers, dbReset, wrappedAction } from "~/utils/Test";
import {
assertResponseErrored,
dbInsertUsers,
dbReset,
wrappedAction,
} from "~/utils/Test";
import { action as teamIndexPageAction } from "../actions/t.server";
import { action as _editTeamAction } from "../routes/t.$customUrl.edit";
import type { createTeamSchema, editTeamSchema } from "../team-schemas.server";
@ -43,15 +48,15 @@ describe("team creation", () => {
it("prevents editing team name to only special characters", async () => {
await createTeamAction({ name: "Team 1" }, { user: "regular" });
await expect(
editTeamAction(
{
_action: "EDIT",
name: "𝓢𝓲𝓵",
...DEFAULT_FIELDS,
},
{ user: "regular", params: { customUrl: "team-1" } },
),
).rejects.toThrow("status code: 400");
const response = await editTeamAction(
{
_action: "EDIT",
name: "𝓢𝓲𝓵",
...DEFAULT_FIELDS,
},
{ user: "regular", params: { customUrl: "team-1" } },
);
assertResponseErrored(response);
});
});

View File

@ -3,6 +3,7 @@ import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { REGULAR_USER_TEST_ID } from "~/db/seed/constants";
import { db } from "~/db/sql";
import {
assertResponseErrored,
dbInsertUsers,
dbReset,
wrappedAction,
@ -159,9 +160,12 @@ describe("Secondary teams", () => {
await createTeamAction({ name: "Team 1" }, { user: "regular" });
await createTeamAction({ name: "Team 2" }, { user: "regular" });
await expect(
createTeamAction({ name: "Team 3" }, { user: "regular" }),
).rejects.toThrow("status code: 400");
const response = await createTeamAction(
{ name: "Team 3" },
{ user: "regular" },
);
assertResponseErrored(response);
});
const makeUserPatron = () =>

View File

@ -40,7 +40,7 @@ import { useSearchParamState } from "~/hooks/useSearchParamState";
import { useVisibilityChange } from "~/hooks/useVisibilityChange";
import invariant from "~/utils/invariant";
import { logger } from "~/utils/logger";
import { parseRequestPayload, validate } from "~/utils/remix.server";
import { errorToastIfFalsy, parseRequestPayload } from "~/utils/remix.server";
import { assertUnreachable } from "~/utils/types";
import {
SENDOU_INK_BASE_URL,
@ -87,7 +87,7 @@ export const action: ActionFunction = async ({ params, request }) => {
switch (data._action) {
case "START_BRACKET": {
validate(tournament.isOrganizer(user));
errorToastIfFalsy(tournament.isOrganizer(user), "Not an organizer");
const bracket = tournament.bracketByIdx(data.bracketIdx);
invariant(bracket, "Bracket not found");
@ -95,12 +95,15 @@ export const action: ActionFunction = async ({ params, request }) => {
const seeding = bracket.seeding;
invariant(seeding, "Seeding not found");
validate(bracket.canBeStarted, "Bracket is not ready to be started");
errorToastIfFalsy(
bracket.canBeStarted,
"Bracket is not ready to be started",
);
const groupCount = new Set(bracket.data.round.map((r) => r.group_id))
.size;
validate(
errorToastIfFalsy(
bracket.type === "round_robin" || bracket.type === "swiss"
? bracket.data.round.length / groupCount === data.maps.length
: bracket.data.round.length === data.maps.length,
@ -176,16 +179,16 @@ export const action: ActionFunction = async ({ params, request }) => {
break;
}
case "PREPARE_MAPS": {
validate(tournament.isOrganizer(user));
errorToastIfFalsy(tournament.isOrganizer(user), "Not an organizer");
const bracket = tournament.bracketByIdx(data.bracketIdx);
invariant(bracket, "Bracket not found");
validate(
errorToastIfFalsy(
!bracket.canBeStarted,
"Bracket can already be started, preparing maps no longer possible",
);
validate(
errorToastIfFalsy(
bracket.preview,
"Bracket has started, preparing maps no longer possible",
);
@ -203,11 +206,14 @@ export const action: ActionFunction = async ({ params, request }) => {
break;
}
case "ADVANCE_BRACKET": {
validate(tournament.isOrganizer(user));
errorToastIfFalsy(tournament.isOrganizer(user), "Not an organizer");
const bracket = tournament.bracketByIdx(data.bracketIdx);
validate(bracket, "Bracket not found");
validate(bracket.type === "swiss", "Can't advance non-swiss bracket");
errorToastIfFalsy(bracket, "Bracket not found");
errorToastIfFalsy(
bracket.type === "swiss",
"Can't advance non-swiss bracket",
);
const matches = Swiss.generateMatchUps({
bracket,
@ -219,11 +225,14 @@ export const action: ActionFunction = async ({ params, request }) => {
break;
}
case "UNADVANCE_BRACKET": {
validate(tournament.isOrganizer(user));
errorToastIfFalsy(tournament.isOrganizer(user), "Not an organizer");
const bracket = tournament.bracketByIdx(data.bracketIdx);
validate(bracket, "Bracket not found");
validate(bracket.type === "swiss", "Can't unadvance non-swiss bracket");
errorToastIfFalsy(bracket, "Bracket not found");
errorToastIfFalsy(
bracket.type === "swiss",
"Can't unadvance non-swiss bracket",
);
validateNoFollowUpBrackets(tournament);
await TournamentRepository.deleteSwissMatches({
@ -234,7 +243,10 @@ export const action: ActionFunction = async ({ params, request }) => {
break;
}
case "FINALIZE_TOURNAMENT": {
validate(tournament.canFinalize(user), "Can't finalize tournament");
errorToastIfFalsy(
tournament.canFinalize(user),
"Can't finalize tournament",
);
const _finalStandings = tournament.standings;
@ -293,7 +305,10 @@ export const action: ActionFunction = async ({ params, request }) => {
const ownTeam = tournament.ownedTeamByUser(user);
invariant(ownTeam, "User doesn't have owned team");
validate(bracket.canCheckIn(user));
errorToastIfFalsy(
bracket.canCheckIn(user),
"Can't check in to this bracket right now",
);
await TournamentRepository.checkIn({
bracketIdx: data.bracketIdx,
@ -302,18 +317,18 @@ export const action: ActionFunction = async ({ params, request }) => {
break;
}
case "OVERRIDE_BRACKET_PROGRESSION": {
validate(tournament.isOrganizer(user));
errorToastIfFalsy(tournament.isOrganizer(user), "Not an organizer");
const allDestinationBrackets = Progression.destinationsFromBracketIdx(
data.sourceBracketIdx,
tournament.ctx.settings.bracketProgression,
);
validate(
errorToastIfFalsy(
data.destinationBracketIdx === -1 ||
allDestinationBrackets.includes(data.destinationBracketIdx),
"Invalid destination bracket",
);
validate(
errorToastIfFalsy(
allDestinationBrackets.every(
(bracketIdx) => tournament.bracketByIdx(bracketIdx)!.preview,
),
@ -343,7 +358,7 @@ function validateNoFollowUpBrackets(tournament: Tournament) {
b.sources?.some((source) => source.bracketIdx === 0),
);
validate(
errorToastIfFalsy(
followUpBrackets.every((b) => b.preview),
"Follow-up brackets are already started",
);

View File

@ -21,10 +21,10 @@ import { canReportTournamentScore } from "~/permissions";
import invariant from "~/utils/invariant";
import { logger } from "~/utils/logger";
import {
errorToastIfFalsy,
notFoundIfFalsy,
parseParams,
parseRequestPayload,
validate,
} from "~/utils/remix.server";
import { assertUnreachable } from "~/utils/types";
import {
@ -88,14 +88,13 @@ export const action: ActionFunction = async ({ params, request }) => {
(p) => p.id === user?.id,
);
validate(
errorToastIfFalsy(
canReportTournamentScore({
match,
isMemberOfATeamInTheMatch,
isOrganizer: tournament.isOrganizer(user),
}),
"Unauthorized",
401,
);
};
@ -134,13 +133,16 @@ export const action: ActionFunction = async ({ params, request }) => {
}
validateCanReportScore();
validate(
errorToastIfFalsy(
match.opponentOne?.id === data.winnerTeamId ||
match.opponentTwo?.id === data.winnerTeamId,
"Winner team id is invalid",
);
validate(match.opponentOne && match.opponentTwo, "Teams are missing");
validate(
errorToastIfFalsy(
match.opponentOne && match.opponentTwo,
"Teams are missing",
);
errorToastIfFalsy(
!matchIsLocked({ matchId: match.id, tournament, scores }),
"Match is locked",
);
@ -154,10 +156,10 @@ export const action: ActionFunction = async ({ params, request }) => {
if (data.winnerTeamId === match.opponentOne?.id) return 0;
if (data.winnerTeamId === match.opponentTwo?.id) return 1;
validate(false, "Winner team id is invalid");
errorToastIfFalsy(false, "Winner team id is invalid");
};
validate(
errorToastIfFalsy(
!data.points ||
(scoreToIncrement() === 0 && data.points[0] > data.points[1]) ||
(scoreToIncrement() === 1 && data.points[1] > data.points[0]),
@ -183,10 +185,10 @@ export const action: ActionFunction = async ({ params, request }) => {
tournament.minMembersPerTeam,
);
validate(teamOneRoster, "Team one has no active roster");
validate(teamTwoRoster, "Team two has no active roster");
errorToastIfFalsy(teamOneRoster, "Team one has no active roster");
errorToastIfFalsy(teamTwoRoster, "Team two has no active roster");
validate(
errorToastIfFalsy(
new Set([...teamOneRoster, ...teamTwoRoster]).size ===
tournament.minMembersPerTeam * 2,
"Duplicate user in rosters",
@ -239,20 +241,19 @@ export const action: ActionFunction = async ({ params, request }) => {
break;
}
case "SET_ACTIVE_ROSTER": {
validate(!tournament.everyBracketOver, "Tournament is over");
validate(
errorToastIfFalsy(!tournament.everyBracketOver, "Tournament is over");
errorToastIfFalsy(
tournament.isOrganizer(user) ||
tournament.teamMemberOfByUser(user)?.id === data.teamId,
"Unauthorized",
401,
);
validate(
errorToastIfFalsy(
data.roster.length === tournament.minMembersPerTeam,
"Invalid roster length",
);
const team = tournament.teamById(data.teamId)!;
validate(
errorToastIfFalsy(
data.roster.every((userId) =>
team.members.some((m) => m.userId === userId),
),
@ -339,14 +340,14 @@ export const action: ActionFunction = async ({ params, request }) => {
break;
}
case "UPDATE_REPORTED_SCORE": {
validate(tournament.isOrganizer(user));
validate(!tournament.ctx.isFinalized, "Tournament is finalized");
errorToastIfFalsy(tournament.isOrganizer(user), "Not an organizer");
errorToastIfFalsy(!tournament.ctx.isFinalized, "Tournament is finalized");
const result = await TournamentMatchRepository.findResultById(
data.resultId,
);
validate(result, "Result not found");
validate(
errorToastIfFalsy(result, "Result not found");
errorToastIfFalsy(
data.rosters[0].length === tournament.minMembersPerTeam &&
data.rosters[1].length === tournament.minMembersPerTeam,
"Invalid roster length",
@ -354,7 +355,7 @@ export const action: ActionFunction = async ({ params, request }) => {
const hadPoints = typeof result.opponentOnePoints === "number";
const willHavePoints = typeof data.points?.[0] === "number";
validate(
errorToastIfFalsy(
(hadPoints && willHavePoints) || (!hadPoints && !willHavePoints),
"Points mismatch",
);
@ -362,19 +363,19 @@ export const action: ActionFunction = async ({ params, request }) => {
if (data.points) {
if (data.points[0] !== result.opponentOnePoints) {
// changing points at this point could retroactively change who advanced from the group
validate(
errorToastIfFalsy(
tournament.matchCanBeReopened(match.id),
"Bracket has progressed",
);
}
if (result.opponentOnePoints! > result.opponentTwoPoints!) {
validate(
errorToastIfFalsy(
data.points[0] > data.points[1],
"Winner must have more points than loser",
);
} else {
validate(
errorToastIfFalsy(
data.points[0] < data.points[1],
"Winner must have more points than loser",
);
@ -434,15 +435,14 @@ export const action: ActionFunction = async ({ params, request }) => {
teams: [match.opponentOne.id, match.opponentTwo.id],
mapList,
});
validate(pickerTeamId, "Not time to pick/ban");
validate(
errorToastIfFalsy(pickerTeamId, "Not time to pick/ban");
errorToastIfFalsy(
tournament.isOrganizer(user) ||
tournament.ownedTeamByUser(user)?.id === pickerTeamId,
"Unauthorized",
401,
);
validate(
errorToastIfFalsy(
PickBan.isLegal({
results,
map: data,
@ -482,8 +482,8 @@ export const action: ActionFunction = async ({ params, request }) => {
invariant(typeof scoreTwo === "number", "Score two is missing");
invariant(scoreOne !== scoreTwo, "Scores are equal");
validate(tournament.isOrganizer(user));
validate(
errorToastIfFalsy(tournament.isOrganizer(user), "Not an organizer");
errorToastIfFalsy(
tournament.matchCanBeReopened(match.id),
"Match can't be reopened, bracket has progressed",
);
@ -527,7 +527,10 @@ export const action: ActionFunction = async ({ params, request }) => {
break;
}
case "SET_AS_CASTED": {
validate(tournament.isOrganizerOrStreamer(user));
errorToastIfFalsy(
tournament.isOrganizerOrStreamer(user),
"Not an organizer or streamer",
);
await TournamentRepository.setMatchAsCasted({
matchId: match.id,
@ -540,7 +543,10 @@ export const action: ActionFunction = async ({ params, request }) => {
break;
}
case "LOCK": {
validate(tournament.isOrganizerOrStreamer(user));
errorToastIfFalsy(
tournament.isOrganizerOrStreamer(user),
"Not an organizer or streamer",
);
// can't lock, let's update their view to reflect that
if (match.opponentOne?.id && match.opponentTwo?.id) {
@ -557,7 +563,10 @@ export const action: ActionFunction = async ({ params, request }) => {
break;
}
case "UNLOCK": {
validate(tournament.isOrganizerOrStreamer(user));
errorToastIfFalsy(
tournament.isOrganizerOrStreamer(user),
"Not an organizer or streamer",
);
await TournamentRepository.unlockMatch({
matchId: match.id,

View File

@ -24,8 +24,8 @@ import {
import type { MainWeaponId } from "~/modules/in-game-lists";
import {
type SendouRouteHandle,
errorToastIfFalsy,
parseRequestPayload,
validate,
} from "~/utils/remix.server";
import { tournamentSubsPage } from "~/utils/urls";
import { findSubsByTournamentId } from "../queries/findSubsByTournamentId.server";
@ -48,12 +48,12 @@ export const action: ActionFunction = async ({ params, request }) => {
const tournamentId = tournamentIdFromParams(params);
const tournament = await tournamentFromDB({ tournamentId, user });
validate(!tournament.everyBracketOver, "Tournament is over");
validate(
errorToastIfFalsy(!tournament.everyBracketOver, "Tournament is over");
errorToastIfFalsy(
tournament.canAddNewSubPost,
"Registration is closed or subs feature disabled",
);
validate(
errorToastIfFalsy(
!tournament.teamMemberOfByUser(user),
"Can't register as a sub and be in a team at the same time",
);

View File

@ -24,7 +24,7 @@ import {
tournamentFromDB,
} from "~/features/tournament-bracket/core/Tournament.server";
import { useTournament } from "~/features/tournament/routes/to.$id";
import { parseRequestPayload, validate } from "~/utils/remix.server";
import { errorToastIfFalsy, parseRequestPayload } from "~/utils/remix.server";
import { assertUnreachable } from "~/utils/types";
import { tournamentRegisterPage, userPage } from "~/utils/urls";
import { deleteSub } from "../queries/deleteSub.server";
@ -45,10 +45,9 @@ export const action: ActionFunction = async ({ request, params }) => {
schema: deleteSubSchema,
});
validate(
errorToastIfFalsy(
user.id === data.userId || tournament.isOrganizer(user),
"You can only delete your own sub post",
401,
);
deleteSub({

View File

@ -14,8 +14,9 @@ import invariant from "~/utils/invariant";
import { logger } from "~/utils/logger";
import {
badRequestIfFalsy,
errorToastIfFalsy,
parseRequestPayload,
validate,
successToast,
} from "~/utils/remix.server";
import { assertUnreachable } from "~/utils/types";
import { USER } from "../../../constants";
@ -41,18 +42,19 @@ export const action: ActionFunction = async ({ request, params }) => {
const tournament = await tournamentFromDB({ tournamentId, user });
const validateIsTournamentAdmin = () =>
validate(tournament.isAdmin(user), "Unauthorized", 401);
errorToastIfFalsy(tournament.isAdmin(user), "Unauthorized");
const validateIsTournamentOrganizer = () =>
validate(tournament.isOrganizer(user), "Unauthorized", 401);
errorToastIfFalsy(tournament.isOrganizer(user), "Unauthorized");
let message: string;
switch (data._action) {
case "ADD_TEAM": {
validateIsTournamentOrganizer();
validate(
errorToastIfFalsy(
tournament.ctx.teams.every((t) => t.name !== data.teamName),
"Team name taken",
);
validate(
errorToastIfFalsy(
!tournament.teamMemberOfByUser({ id: data.userId }),
"User already on a team",
);
@ -78,16 +80,17 @@ export const action: ActionFunction = async ({ request, params }) => {
userId: data.userId,
});
message = "Team added";
break;
}
case "CHANGE_TEAM_OWNER": {
validateIsTournamentOrganizer();
const team = tournament.teamById(data.teamId);
validate(team, "Invalid team id");
errorToastIfFalsy(team, "Invalid team id");
const oldCaptain = team.members.find((m) => m.isOwner);
invariant(oldCaptain, "Team has no captain");
const newCaptain = team.members.find((m) => m.userId === data.memberId);
validate(newCaptain, "Invalid member id");
errorToastIfFalsy(newCaptain, "Invalid member id");
changeTeamOwner({
newCaptainId: data.memberId,
@ -95,56 +98,61 @@ export const action: ActionFunction = async ({ request, params }) => {
tournamentTeamId: data.teamId,
});
message = "Team owner changed";
break;
}
case "CHANGE_TEAM_NAME": {
validateIsTournamentOrganizer();
const team = tournament.teamById(data.teamId);
validate(team, "Invalid team id");
errorToastIfFalsy(team, "Invalid team id");
await TournamentRepository.updateTeamName({
tournamentTeamId: data.teamId,
name: data.teamName,
});
message = "Team name changed";
break;
}
case "CHECK_IN": {
validateIsTournamentOrganizer();
const team = tournament.teamById(data.teamId);
validate(team, "Invalid team id");
validate(
errorToastIfFalsy(team, "Invalid team id");
errorToastIfFalsy(
data.bracketIdx !== 0 ||
tournament.checkInConditionsFulfilledByTeamId(team.id),
"Can't check-in",
);
validate(
errorToastIfFalsy(
team.checkIns.length > 0 || data.bracketIdx === 0,
"Can't check-in to follow up bracket if not checked in for the event itself",
);
const bracket = tournament.bracketByIdx(data.bracketIdx);
invariant(bracket, "Invalid bracket idx");
validate(bracket.preview, "Bracket has been started");
errorToastIfFalsy(bracket.preview, "Bracket has been started");
await TournamentRepository.checkIn({
tournamentTeamId: data.teamId,
// no sources = regular check in
bracketIdx: !bracket.sources ? null : data.bracketIdx,
});
message = "Checked team in";
break;
}
case "CHECK_OUT": {
validateIsTournamentOrganizer();
const team = tournament.teamById(data.teamId);
validate(team, "Invalid team id");
validate(
errorToastIfFalsy(team, "Invalid team id");
errorToastIfFalsy(
data.bracketIdx !== 0 || !tournament.hasStarted,
"Tournament has started",
);
const bracket = tournament.bracketByIdx(data.bracketIdx);
invariant(bracket, "Invalid bracket idx");
validate(bracket.preview, "Bracket has been started");
errorToastIfFalsy(bracket.preview, "Bracket has been started");
await TournamentRepository.checkOut({
tournamentTeamId: data.teamId,
@ -154,22 +162,24 @@ export const action: ActionFunction = async ({ request, params }) => {
logger.info(
`Checked out: tournament team id: ${data.teamId} - user id: ${user.id} - tournament id: ${tournamentId} - bracket idx: ${data.bracketIdx}`,
);
message = "Checked team out";
break;
}
case "REMOVE_MEMBER": {
validateIsTournamentOrganizer();
const team = tournament.teamById(data.teamId);
validate(team, "Invalid team id");
validate(
errorToastIfFalsy(team, "Invalid team id");
errorToastIfFalsy(
team.checkIns.length === 0 ||
team.members.length > tournament.minMembersPerTeam,
"Can't remove last member from checked in team",
);
validate(
errorToastIfFalsy(
!team.members.find((m) => m.userId === data.memberId)?.isOwner,
"Cannot remove team owner",
);
validate(
errorToastIfFalsy(
!tournament.hasStarted ||
!tournament
.participatedPlayersByTeamId(data.teamId)
@ -188,21 +198,22 @@ export const action: ActionFunction = async ({ request, params }) => {
userId: data.memberId,
});
message = "Member removed";
break;
}
case "ADD_MEMBER": {
validateIsTournamentOrganizer();
const team = tournament.teamById(data.teamId);
validate(team, "Invalid team id");
errorToastIfFalsy(team, "Invalid team id");
const previousTeam = tournament.teamMemberOfByUser({ id: data.userId });
validate(
errorToastIfFalsy(
tournament.hasStarted || !previousTeam,
"User is already in a team",
);
validate(
errorToastIfFalsy(
!userIsBanned(data.userId),
"User trying to be added currently has an active ban from sendou.ink",
);
@ -247,18 +258,20 @@ export const action: ActionFunction = async ({ request, params }) => {
},
});
message = "Member added";
break;
}
case "DELETE_TEAM": {
validateIsTournamentOrganizer();
const team = tournament.teamById(data.teamId);
validate(team, "Invalid team id");
validate(!tournament.hasStarted, "Tournament has started");
errorToastIfFalsy(team, "Invalid team id");
errorToastIfFalsy(!tournament.hasStarted, "Tournament has started");
deleteTeam(team.id);
ShowcaseTournaments.clearParticipationInfoMap();
message = "Team deleted from tournament";
break;
}
case "ADD_STAFF": {
@ -278,6 +291,7 @@ export const action: ActionFunction = async ({ request, params }) => {
});
}
message = "Staff member added";
break;
}
case "REMOVE_STAFF": {
@ -294,6 +308,7 @@ export const action: ActionFunction = async ({ request, params }) => {
userId: data.userId,
});
message = "Staff member removed";
break;
}
case "UPDATE_CAST_TWITCH_ACCOUNTS": {
@ -302,6 +317,8 @@ export const action: ActionFunction = async ({ request, params }) => {
tournamentId: tournament.ctx.id,
castTwitchAccounts: data.castTwitchAccounts,
});
message = "Cast account updated";
break;
}
case "DROP_TEAM_OUT": {
@ -312,27 +329,31 @@ export const action: ActionFunction = async ({ request, params }) => {
b.preview ? idx : [],
),
});
message = "Team dropped out";
break;
}
case "UNDO_DROP_TEAM_OUT": {
validateIsTournamentOrganizer();
await TournamentRepository.undoDropTeamOut(data.teamId);
message = "Team drop out undone";
break;
}
case "RESET_BRACKET": {
validateIsTournamentOrganizer();
validate(!tournament.ctx.isFinalized, "Tournament is finalized");
errorToastIfFalsy(!tournament.ctx.isFinalized, "Tournament is finalized");
const bracketToResetIdx = tournament.brackets.findIndex(
(b) => b.id === data.stageId,
);
const bracketToReset = tournament.brackets[bracketToResetIdx];
validate(bracketToReset, "Invalid bracket id");
validate(!bracketToReset.preview, "Bracket has not started");
errorToastIfFalsy(bracketToReset, "Invalid bracket id");
errorToastIfFalsy(!bracketToReset.preview, "Bracket has not started");
const inProgressBrackets = tournament.brackets.filter((b) => !b.preview);
validate(
errorToastIfFalsy(
inProgressBrackets.every(
(b) =>
!b.sources ||
@ -343,6 +364,7 @@ export const action: ActionFunction = async ({ request, params }) => {
await TournamentRepository.resetBracket(data.stageId);
message = "Bracket reset";
break;
}
case "UPDATE_IN_GAME_NAME": {
@ -357,6 +379,8 @@ export const action: ActionFunction = async ({ request, params }) => {
inGameName: `${data.inGameNameText}#${data.inGameNameDiscriminator}`,
tournamentTeamId: teamMemberOf.id,
});
message = "Player in-game name updated";
break;
}
case "DELETE_LOGO": {
@ -364,13 +388,14 @@ export const action: ActionFunction = async ({ request, params }) => {
await TournamentTeamRepository.deleteLogo(data.teamId);
message = "Logo deleted";
break;
}
case "UPDATE_TOURNAMENT_PROGRESSION": {
validateIsTournamentOrganizer();
validate(!tournament.ctx.isFinalized, "Tournament is finalized");
errorToastIfFalsy(!tournament.ctx.isFinalized, "Tournament is finalized");
validate(
errorToastIfFalsy(
Progression.changedBracketProgression(
tournament.ctx.settings.bracketProgression,
data.bracketProgression,
@ -386,6 +411,7 @@ export const action: ActionFunction = async ({ request, params }) => {
bracketProgression: data.bracketProgression,
});
message = "Tournament progression updated";
break;
}
default: {
@ -395,7 +421,7 @@ export const action: ActionFunction = async ({ request, params }) => {
clearTournamentDataCache(tournamentId);
return null;
return successToast(message);
};
export const adminActionSchema = z.union([

View File

@ -13,10 +13,10 @@ import * as TournamentTeamRepository from "~/features/tournament/TournamentTeamR
import * as UserRepository from "~/features/user-page/UserRepository.server";
import { logger } from "~/utils/logger";
import {
errorToastIfFalsy,
notFoundIfFalsy,
parseFormData,
uploadImageIfSubmitted,
validate,
} from "~/utils/remix.server";
import { booleanToInt } from "~/utils/sql";
import { assertUnreachable } from "~/utils/types";
@ -50,7 +50,7 @@ export const action: ActionFunction = async ({ request, params }) => {
const tournament = await tournamentFromDB({ tournamentId, user });
const event = notFoundIfFalsy(findByIdentifier(tournamentId));
validate(
errorToastIfFalsy(
!tournament.hasStarted,
"Tournament has started, cannot make edits to registration",
);
@ -60,7 +60,7 @@ export const action: ActionFunction = async ({ request, params }) => {
switch (data._action) {
case "UPSERT_TEAM": {
validate(
errorToastIfFalsy(
!data.teamId ||
(await TeamRepository.findAllMemberOfByUserId(user.id)).some(
(team) => team.id === data.teamId,
@ -68,7 +68,7 @@ export const action: ActionFunction = async ({ request, params }) => {
"Team id does not match any of the teams you are in",
);
validate(
errorToastIfFalsy(
!tournament.ctx.teams.some(
(team) => team.name === data.teamName && team.id !== data.teamId,
),
@ -76,7 +76,7 @@ export const action: ActionFunction = async ({ request, params }) => {
);
if (ownTeam) {
validate(
errorToastIfFalsy(
tournament.registrationOpen || data.teamName === ownTeam.name,
"Can't change team name after registration has closed",
);
@ -93,16 +93,19 @@ export const action: ActionFunction = async ({ request, params }) => {
},
});
} else {
validate(!tournament.isInvitational, "Event is invite only");
validate(
errorToastIfFalsy(!tournament.isInvitational, "Event is invite only");
errorToastIfFalsy(
(await UserRepository.findLeanById(user.id))?.friendCode,
"No friend code",
);
validate(
errorToastIfFalsy(
!tournament.teamMemberOfByUser(user),
"You are already in a team that you aren't captain of",
);
validate(tournament.registrationOpen, "Registration is closed");
errorToastIfFalsy(
tournament.registrationOpen,
"Registration is closed",
);
await TournamentTeamRepository.create({
ownerInGameName: await inGameNameIfNeeded({
@ -129,9 +132,12 @@ export const action: ActionFunction = async ({ request, params }) => {
break;
}
case "DELETE_TEAM_MEMBER": {
validate(ownTeam);
validate(ownTeam.members.some((member) => member.userId === data.userId));
validate(data.userId !== user.id);
errorToastIfFalsy(ownTeam, "You are not registered to this tournament");
errorToastIfFalsy(
ownTeam.members.some((member) => member.userId === data.userId),
"User is not in your team",
);
errorToastIfFalsy(data.userId !== user.id, "Can't kick yourself");
const detailedOwnTeam = findOwnTournamentTeam({
tournamentId,
@ -139,10 +145,11 @@ export const action: ActionFunction = async ({ request, params }) => {
});
// making sure they aren't unfilling one checking in condition i.e. having full roster
// and then having members kicked without it affecting the checking in status
validate(
errorToastIfFalsy(
detailedOwnTeam &&
(!detailedOwnTeam.checkedInAt ||
ownTeam.members.length > tournament.minMembersPerTeam),
"Can't kick a member after checking in",
);
deleteTeamMember({ tournamentTeamId: ownTeam.id, userId: data.userId });
@ -155,11 +162,11 @@ export const action: ActionFunction = async ({ request, params }) => {
break;
}
case "LEAVE_TEAM": {
validate(!ownTeam, "Can't leave a team as the owner");
errorToastIfFalsy(!ownTeam, "Can't leave a team as the owner");
const teamMemberOf = tournament.teamMemberOfByUser(user);
validate(teamMemberOf, "You are not in a team");
validate(
errorToastIfFalsy(teamMemberOf, "You are not in a team");
errorToastIfFalsy(
teamMemberOf.checkIns.length === 0,
"You cannot leave after checking in",
);
@ -179,13 +186,14 @@ export const action: ActionFunction = async ({ request, params }) => {
}
case "UPDATE_MAP_POOL": {
const mapPool = new MapPool(data.mapPool);
validate(ownTeam);
validate(
errorToastIfFalsy(ownTeam, "You are not registered to this tournament");
errorToastIfFalsy(
validateCounterPickMapPool(
mapPool,
isOneModeTournamentOf(event),
tournament.ctx.tieBreakerMapPool,
) === "VALID",
"Invalid map pool",
);
upsertCounterpickMaps({
@ -200,14 +208,17 @@ export const action: ActionFunction = async ({ request, params }) => {
);
const teamMemberOf = tournament.teamMemberOfByUser(user);
validate(teamMemberOf, "You are not in a team");
validate(
errorToastIfFalsy(teamMemberOf, "You are not in a team");
errorToastIfFalsy(
teamMemberOf.checkIns.length === 0,
"You have already checked in",
);
validate(tournament.regularCheckInIsOpen, "Check in is not open");
validate(
errorToastIfFalsy(
tournament.regularCheckInIsOpen,
"Check in is not open",
);
errorToastIfFalsy(
tournament.checkInConditionsFulfilledByTeamId(teamMemberOf.id),
"Check in conditions not fulfilled",
);
@ -219,24 +230,24 @@ export const action: ActionFunction = async ({ request, params }) => {
break;
}
case "ADD_PLAYER": {
validate(
errorToastIfFalsy(
tournament.ctx.teams.every((team) =>
team.members.every((member) => member.userId !== data.userId),
),
"User is already in a team",
);
validate(ownTeam);
validate(
errorToastIfFalsy(ownTeam, "You are not registered to this tournament");
errorToastIfFalsy(
(await QRepository.usersThatTrusted(user.id)).trusters.some(
(trusterPlayer) => trusterPlayer.id === data.userId,
),
"No trust given from this user",
);
validate(
errorToastIfFalsy(
(await UserRepository.findLeanById(user.id))?.friendCode,
"No friend code",
);
validate(tournament.registrationOpen, "Registration is closed");
errorToastIfFalsy(tournament.registrationOpen, "Registration is closed");
joinTeam({
userId: data.userId,
@ -276,9 +287,12 @@ export const action: ActionFunction = async ({ request, params }) => {
break;
}
case "UNREGISTER": {
validate(ownTeam, "You are not registered to this tournament");
validate(!ownTeamCheckedIn, "You cannot unregister after checking in");
validate(
errorToastIfFalsy(ownTeam, "You are not registered to this tournament");
errorToastIfFalsy(
!ownTeamCheckedIn,
"You cannot unregister after checking in",
);
errorToastIfFalsy(
!tournament.isLeagueSignup || tournament.registrationOpen,
"Unregistering from leagues is not possible after registration has closed",
);
@ -290,7 +304,7 @@ export const action: ActionFunction = async ({ request, params }) => {
break;
}
case "DELETE_LOGO": {
validate(ownTeam, "You are not registered to this tournament");
errorToastIfFalsy(ownTeam, "You are not registered to this tournament");
await TournamentTeamRepository.deleteLogo(ownTeam.id);

View File

@ -18,9 +18,9 @@ import {
import * as UserRepository from "~/features/user-page/UserRepository.server";
import invariant from "~/utils/invariant";
import {
errorToastIfFalsy,
notFoundIfFalsy,
parseRequestPayload,
validate,
} from "~/utils/remix.server";
import { assertUnreachable } from "~/utils/types";
import { tournamentPage, userEditProfilePage } from "~/utils/urls";
@ -52,16 +52,16 @@ export const action: ActionFunction = async ({ request, params }) => {
);
if (tournament.hasStarted) {
validate(
errorToastIfFalsy(
!previousTeam || previousTeam.checkIns.length === 0,
"Can't leave checked in team mid tournament",
);
validate(tournament.autonomousSubs, "Subs are not allowed");
errorToastIfFalsy(tournament.autonomousSubs, "Subs are not allowed");
} else {
validate(tournament.registrationOpen, "Registration is closed");
errorToastIfFalsy(tournament.registrationOpen, "Registration is closed");
}
validate(teamToJoin, "Not team of this tournament");
validate(
errorToastIfFalsy(teamToJoin, "Not team of this tournament");
errorToastIfFalsy(
validateCanJoin({
inviteCode,
teamToJoin,
@ -70,7 +70,7 @@ export const action: ActionFunction = async ({ request, params }) => {
}) === "VALID",
"Cannot join this team or invite code is invalid",
);
validate(
errorToastIfFalsy(
(await UserRepository.findLeanById(user.id))?.friendCode,
"No friend code",
);

View File

@ -34,7 +34,7 @@ import {
} from "~/features/tournament-bracket/core/Tournament.server";
import { useTimeoutState } from "~/hooks/useTimeoutState";
import invariant from "~/utils/invariant";
import { parseRequestPayload, validate } from "~/utils/remix.server";
import { errorToastIfFalsy, parseRequestPayload } from "~/utils/remix.server";
import { tournamentBracketsPage, userResultsPage } from "~/utils/urls";
import { Avatar } from "../../../components/Avatar";
import { InfoPopover } from "../../../components/InfoPopover";
@ -54,8 +54,8 @@ export const action: ActionFunction = async ({ request, params }) => {
const tournamentId = tournamentIdFromParams(params);
const tournament = await tournamentFromDB({ tournamentId, user });
validate(tournament.isOrganizer(user));
validate(!tournament.hasStarted, "Tournament has started");
errorToastIfFalsy(tournament.isOrganizer(user), "Not an organizer");
errorToastIfFalsy(!tournament.hasStarted, "Tournament has started");
switch (data._action) {
case "UPDATE_SEEDS": {
@ -68,7 +68,7 @@ export const action: ActionFunction = async ({ request, params }) => {
(bracket, bracketIdx) => (!bracket.sources ? [bracketIdx] : []),
);
validate(
errorToastIfFalsy(
data.startingBrackets.every((t) =>
validBracketIdxs.includes(t.startingBracketIdx),
),

View File

@ -1,5 +1,5 @@
import * as UserRepository from "~/features/user-page/UserRepository.server";
import { validate } from "~/utils/remix.server";
import { errorToastIfFalsy } from "~/utils/remix.server";
import type { Tournament } from "../tournament-bracket/core/Tournament";
export const inGameNameIfNeeded = async ({
@ -13,7 +13,7 @@ export const inGameNameIfNeeded = async ({
const inGameName = await UserRepository.inGameNameByUserId(userId);
validate(inGameName, "No in-game name");
errorToastIfFalsy(inGameName, "No in-game name");
return inGameName;
};

View File

@ -18,7 +18,7 @@ import type {
import { removeDuplicates } from "~/utils/arrays";
import { unJsonify } from "~/utils/kysely.server";
import { logger } from "~/utils/logger";
import { parseRequestPayload, validate } from "~/utils/remix.server";
import { errorToastIfFalsy, parseRequestPayload } from "~/utils/remix.server";
import type { Nullish } from "~/utils/types";
import { userBuildsPage } from "~/utils/urls";
import {
@ -54,9 +54,10 @@ export const action: ActionFunction = async ({ request }) => {
if (usersBuilds.length >= BUILD.MAX_COUNT) {
throw new Response("Max amount of builds reached", { status: 400 });
}
validate(
errorToastIfFalsy(
!data.buildToEditId ||
usersBuilds.some((build) => build.id === data.buildToEditId),
"Build to edit not found",
);
const someGearIsMissing =

View File

@ -6,7 +6,7 @@ import * as BuildRepository from "~/features/builds/BuildRepository.server";
import { refreshBuildsCacheByWeaponSplIds } from "~/features/builds/core/cached-builds.server";
import * as UserRepository from "~/features/user-page/UserRepository.server";
import { logger } from "~/utils/logger";
import { parseRequestPayload, validate } from "~/utils/remix.server";
import { errorToastIfFalsy, parseRequestPayload } from "~/utils/remix.server";
import { assertUnreachable } from "~/utils/types";
import { userBuildsPage } from "~/utils/urls";
import {
@ -37,7 +37,7 @@ export const action: ActionFunction = async ({ request }) => {
(build) => build.id === data.buildToDeleteId,
);
validate(buildToDelete);
errorToastIfFalsy(buildToDelete, "Build to delete not found");
await BuildRepository.deleteById(data.buildToDeleteId);

View File

@ -15,9 +15,9 @@ import { useSearchParamState } from "~/hooks/useSearchParamState";
import invariant from "~/utils/invariant";
import {
type SendouRouteHandle,
errorToastIfFalsy,
notFoundIfFalsy,
parseRequestPayload,
validate,
} from "~/utils/remix.server";
import { userParamsSchema } from "../user-page-schemas.server";
import type { UserPageLoaderData } from "./u.$identifier";
@ -37,7 +37,10 @@ export const action: ActionFunction = async ({ request }) => {
// 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 artToDelete = findArtById(data.id);
validate(artToDelete?.authorId === user.id, "Insufficient permissions", 401);
errorToastIfFalsy(
artToDelete?.authorId === user.id,
"Insufficient permissions",
);
deleteArt(data.id);

View File

@ -13,8 +13,10 @@ import {
type ShouldRevalidateFunction,
useLoaderData,
useMatches,
useNavigate,
useNavigation,
useRevalidator,
useSearchParams,
} from "@remix-run/react";
import generalI18next from "i18next";
import NProgress from "nprogress";
@ -25,6 +27,7 @@ import { useTranslation } from "react-i18next";
import { useChangeLanguage } from "remix-i18next/react";
import type { SendouRouteHandle } from "~/utils/remix.server";
import { Catcher } from "./components/Catcher";
import { SendouToastRegion, toastQueue } from "./components/elements/Toast";
import { Layout } from "./components/layout";
import { Ramp } from "./components/ramp/Ramp";
import { CUSTOMIZED_CSS_VARS_NAME } from "./constants";
@ -142,6 +145,7 @@ function Document({
useChangeLanguage(locale);
usePreloadTranslation();
useLoadingIndicator();
useTriggerToasts();
const customizedCSSVars = useCustomizedCSSVars();
return (
@ -170,19 +174,55 @@ function Document({
{process.env.NODE_ENV === "development" && <HydrationTestIndicator />}
<React.StrictMode>
<I18nProvider locale={i18n.language}>
<SendouToastRegion />
<MyRamp data={data} />
<Layout data={data} isErrored={isErrored}>
{children}
</Layout>
</I18nProvider>
</React.StrictMode>
<ScrollRestoration />
<ScrollRestoration
getKey={(location) => {
return location.pathname;
}}
/>
<Scripts />
</body>
</html>
);
}
function useTriggerToasts() {
const [searchParams] = useSearchParams();
const navigate = useNavigate();
const error = searchParams.get("__error");
const success = searchParams.get("__success");
React.useEffect(() => {
if (!error && !success) return;
if (error) {
toastQueue.add({
message: error,
variant: "error",
});
} else if (success) {
toastQueue.add(
{
message: success,
variant: "success",
},
{
timeout: 5000,
},
);
}
navigate({ search: "" }, { replace: true });
}, [error, success, navigate]);
}
function useLoadingIndicator() {
const transition = useNavigation();

View File

@ -1,5 +1,6 @@
import type { ActionFunctionArgs, LoaderFunctionArgs } from "@remix-run/node";
import type { Params } from "@remix-run/react";
import { expect } from "vitest";
import type { z } from "zod";
import { ADMIN_ID } from "~/constants";
import { REGULAR_USER_TEST_ID } from "~/db/seed/constants";
@ -87,6 +88,13 @@ export function wrappedLoader<T>({
};
}
/**
* Asserts that the given response errored out (with a toast message, via `validate(cond)` call)
*/
export function assertResponseErrored(response: Response) {
expect(response.headers.get("Location")).toContain("?__error=");
}
async function authHeader(user?: "admin" | "regular"): Promise<HeadersInit> {
if (!user) return [];

View File

@ -1,4 +1,4 @@
import { json } from "@remix-run/node";
import { json, redirect } from "@remix-run/node";
import {
unstable_composeUploadHandlers as composeUploadHandlers,
unstable_createMemoryUploadHandler as createMemoryUploadHandler,
@ -7,10 +7,11 @@ import {
import type { Params, UIMatch } from "@remix-run/react";
import type { Namespace, TFunction } from "i18next";
import { nanoid } from "nanoid";
import { z } from "zod";
import type { z } from "zod";
import type { navItems } from "~/components/layout/nav-items";
import { s3UploadHandler } from "~/features/img-upload";
import invariant from "./invariant";
import { logger } from "./logger";
export function notFoundIfFalsy<T>(value: T | null | undefined): T {
if (!value) throw new Response(null, { status: 404 });
@ -52,12 +53,9 @@ export function parseSearchParams<T extends z.ZodTypeAny>({
try {
return schema.parse(searchParams);
} catch (e) {
if (e instanceof z.ZodError) {
console.error(e);
throw new Response(JSON.stringify(e), { status: 400 });
}
logger.error("Error parsing search params", e);
throw e;
throw errorToast("Validation failed");
}
}
@ -93,16 +91,13 @@ export async function parseRequestPayload<T extends z.ZodTypeAny>({
return parsed;
} catch (e) {
if (e instanceof z.ZodError) {
console.error(e);
throw new Response(JSON.stringify(e), { status: 400 });
}
logger.error("Error parsing request payload", e);
throw e;
throw errorToast("Validation failed");
}
}
/** Parse formData with the given schema. Throws HTTP 400 response if fails. */
/** Parse formData with the given schema. Throws a request to show an error toast if it fails. */
export async function parseFormData<T extends z.ZodTypeAny>({
formData,
schema,
@ -120,12 +115,9 @@ export async function parseFormData<T extends z.ZodTypeAny>({
return parsed;
} catch (e) {
if (e instanceof z.ZodError) {
console.error(e);
throw new Response(JSON.stringify(e), { status: 400 });
}
logger.error("Error parsing form data", e);
throw e;
throw errorToast("Validation failed");
}
}
@ -189,20 +181,24 @@ function formDataToObject(formData: FormData) {
return result;
}
/** Asserts condition is truthy. Throws a new `Response` with given status code if falsy. */
export function validate(
// TODO: investigate better solution to toasts when middlewares land (current one has a problem of clearing search params)
export function errorToast(message: string) {
return redirect(`?__error=${message}`);
}
/** Asserts condition is truthy. Throws a redirect triggering an error toast with given message otherwise. */
export function errorToastIfFalsy(
condition: any,
message?: string,
status = 400,
message: string,
): asserts condition {
if (condition) return;
throw new Response(
message ? JSON.stringify({ validationError: message }) : undefined,
{
status,
},
);
throw errorToast(message);
}
export function successToast(message: string) {
return redirect(`?__success=${message}`);
}
export type ActionError = { field: string; msg: string; isError: true };

View File

@ -75,6 +75,10 @@
"auth.errors.discordPermissions": "For your sendou.ink profile, the site needs access to your Discord profile's name, avatar and social connections.",
"auth.errors.unknown": "Unknown error, try again a bit later. Verify also that your Discord account has a verified email. For help please reach out to us via the #helpdesk channel on our Discord:",
"toasts.error": "Error",
"toasts.success": "Success",
"toasts.info": "Info",
"footer.github.subtitle": "Source code",
"footer.discord.subtitle": "Help & feedback",
"footer.patreon.subtitle": "Support",

1712
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -64,7 +64,7 @@
"openskill": "^4.1.0",
"p-limit": "^6.2.0",
"react": "^18.3.1",
"react-aria-components": "^1.6.0",
"react-aria-components": "^1.7.0",
"react-charts": "^3.0.0-beta.57",
"react-dom": "^18.3.1",
"react-error-boundary": "^5.0.0",