mirror of
https://github.com/Sendouc/sendou.ink.git
synced 2026-03-21 18:04:39 -05:00
Toasts (#2132)
* 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:
parent
38a6ed56ee
commit
aea1e9ce35
85
app/components/elements/Toast.module.css
Normal file
85
app/components/elements/Toast.module.css
Normal 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);
|
||||
}
|
||||
70
app/components/elements/Toast.tsx
Normal file
70
app/components/elements/Toast.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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([
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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": {
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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) ?? [];
|
||||
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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 },
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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 = () =>
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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([
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
),
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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 =
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
||||
|
|
|
|||
42
app/root.tsx
42
app/root.tsx
|
|
@ -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();
|
||||
|
||||
|
|
|
|||
|
|
@ -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 [];
|
||||
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
|
|
|
|||
|
|
@ -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
1712
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
|
|
@ -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",
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user