mirror of
https://github.com/Sendouc/sendou.ink.git
synced 2026-08-22 11:05:09 -05:00
zod -> valibot (#3364)
This commit is contained in:
@@ -38,7 +38,7 @@
|
||||
- one file can have many components
|
||||
- all texts should be provided translations via the i18next library's `useTranslations` hook's `t` function
|
||||
- instead of `&&` operator for conditional rendering, use the ternary operator
|
||||
- fixed-field mutations (an `_action` plus hidden inputs) use `<ActionButton>` which type checks the action and fields against the route's zod action schema; real multi-input forms instead pass `schema` alongside `_action` to `SubmitButton`; enforced by the `no-raw-action-forms` Biome plugin
|
||||
- fixed-field mutations (an `_action` plus hidden inputs) use `<ActionButton>` which type checks the action and fields against the route's action schema; real multi-input forms instead pass `schema` alongside `_action` to `SubmitButton`; enforced by the `no-raw-action-forms` Biome plugin
|
||||
- for localized user-readable time strings use `<LocaleTime />`, `<LocaleTimeRange>` or `useFormatDistanceToNow`. If needed use `useDateTimeFormat` directly. NEVER use e.g. `toLocaleString` directly as it does not include users' language selection.
|
||||
|
||||
## Remix/React Router
|
||||
|
||||
@@ -149,7 +149,7 @@ For developers reading the [architecture.md](./docs/dev/architecture.md) file is
|
||||
- **UI Library**: React Aria Components
|
||||
- **Database**: SQLite3 (via Kysely)
|
||||
- **Styling**: CSS Modules
|
||||
- **Validation**: Zod
|
||||
- **Validation**: Valibot
|
||||
- **Internationalization**: i18next
|
||||
- **Testing**:
|
||||
- End-to-End (E2E): Playwright
|
||||
|
||||
@@ -1,17 +1,17 @@
|
||||
import type * as React from "react";
|
||||
import { type FetcherWithComponents, useFetcher } from "react-router";
|
||||
import type { z } from "zod";
|
||||
import {
|
||||
type ActionsOf,
|
||||
type FieldsOf,
|
||||
serializeFieldValue,
|
||||
} from "~/utils/action-schemas";
|
||||
import type { AnySchema } from "~/utils/schema";
|
||||
import { SendouButton, type SendouButtonProps } from "./elements/Button";
|
||||
import { FormWithConfirm } from "./FormWithConfirm";
|
||||
import { SubmitButton } from "./SubmitButton";
|
||||
|
||||
interface ActionButtonBaseProps<
|
||||
TSchema extends z.ZodTypeAny,
|
||||
TSchema extends AnySchema,
|
||||
TAction extends ActionsOf<TSchema>,
|
||||
> extends Omit<SendouButtonProps, "type" | "name" | "value" | "form"> {
|
||||
/** Action schema of the route the button submits to. Only used for typing `action` and `fields`. */
|
||||
@@ -33,7 +33,7 @@ interface ActionButtonBaseProps<
|
||||
}
|
||||
|
||||
type ActionButtonProps<
|
||||
TSchema extends z.ZodTypeAny,
|
||||
TSchema extends AnySchema,
|
||||
TAction extends ActionsOf<TSchema>,
|
||||
> = ActionButtonBaseProps<TSchema, TAction> &
|
||||
// biome-ignore lint/complexity/noBannedTypes: {} models "branch with no extra fields"
|
||||
@@ -43,7 +43,7 @@ type ActionButtonProps<
|
||||
|
||||
/**
|
||||
* Button that submits a mutation to a route action as `_action` + hidden fields,
|
||||
* type checked against the route's zod action schema.
|
||||
* type checked against the route's action schema.
|
||||
*
|
||||
* @example
|
||||
* <ActionButton
|
||||
@@ -55,7 +55,7 @@ type ActionButtonProps<
|
||||
* </ActionButton>
|
||||
*/
|
||||
export function ActionButton<
|
||||
TSchema extends z.ZodTypeAny,
|
||||
TSchema extends AnySchema,
|
||||
const TAction extends ActionsOf<TSchema>,
|
||||
>({
|
||||
schema,
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { Check, Clipboard, PencilLine } from "lucide-react";
|
||||
import * as React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import * as v from "valibot";
|
||||
import type { CustomTheme } from "~/db/tables-json";
|
||||
import {
|
||||
CUSTOM_THEME_VARS,
|
||||
@@ -13,7 +14,7 @@ import {
|
||||
clampThemeToGamut,
|
||||
type ThemeInput,
|
||||
} from "~/utils/oklch-gamut";
|
||||
import { THEME_INPUT_LIMITS, themeInputSchema } from "~/utils/zod";
|
||||
import { THEME_INPUT_LIMITS, themeInputSchema } from "~/utils/schema";
|
||||
import styles from "./CustomThemeSelector.module.css";
|
||||
import { Divider } from "./Divider";
|
||||
import { LinkButton, SendouButton } from "./elements/Button";
|
||||
@@ -165,8 +166,8 @@ function themeInputFromString(str: string): ThemeInput | null {
|
||||
raw[key] = num;
|
||||
}
|
||||
|
||||
const parsed = themeInputSchema.safeParse(raw);
|
||||
return parsed.success ? parsed.data : null;
|
||||
const parsed = v.safeParse(themeInputSchema, raw);
|
||||
return parsed.success ? parsed.output : null;
|
||||
}
|
||||
|
||||
const DEFAULT_THEME_INPUT: ThemeInput = {
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { type FetcherWithComponents, useNavigation } from "react-router";
|
||||
import type { z } from "zod";
|
||||
import type { ActionsOf } from "~/utils/action-schemas";
|
||||
import type { AnySchema } from "~/utils/schema";
|
||||
import { SendouButton, type SendouButtonProps } from "./elements/Button";
|
||||
|
||||
type SubmitButtonProps<TSchema extends z.ZodTypeAny> = SendouButtonProps & {
|
||||
type SubmitButtonProps<TSchema extends AnySchema> = SendouButtonProps & {
|
||||
/** If the page has multiple forms you can pass in fetcher.state to differentiate when this SubmitButton should be in submitting state */
|
||||
state?: FetcherWithComponents<any>["state"];
|
||||
testId?: string;
|
||||
@@ -17,7 +17,7 @@ type SubmitButtonProps<TSchema extends z.ZodTypeAny> = SendouButtonProps & {
|
||||
| { schema?: never; _action?: never }
|
||||
);
|
||||
|
||||
export function SubmitButton<TSchema extends z.ZodTypeAny>({
|
||||
export function SubmitButton<TSchema extends AnySchema>({
|
||||
children,
|
||||
state,
|
||||
schema: _schema,
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { z } from "zod";
|
||||
import * as v from "valibot";
|
||||
import type { MainWeaponId } from "~/modules/in-game-lists/types";
|
||||
import { mainWeaponIds } from "~/modules/in-game-lists/weapon-ids";
|
||||
import { usePersistedState } from "~/modules/persisted-state/hooks";
|
||||
import * as PersistedState from "~/modules/persisted-state/persisted-state";
|
||||
import { numericEnum } from "~/utils/zod";
|
||||
import { numericEnum } from "~/utils/schema";
|
||||
import { GLOBAL_SEARCH_TYPES } from "./global-search-search-params";
|
||||
|
||||
const MAX_RECENT_WEAPONS = 5;
|
||||
@@ -11,14 +11,14 @@ const MAX_RECENT_WEAPONS = 5;
|
||||
export const searchTypePersisted = PersistedState.define({
|
||||
key: "global-search-search-type",
|
||||
storage: "local",
|
||||
schema: z.enum(GLOBAL_SEARCH_TYPES),
|
||||
schema: v.picklist(GLOBAL_SEARCH_TYPES),
|
||||
default: "weapons",
|
||||
});
|
||||
|
||||
export const recentWeaponsPersisted = PersistedState.define({
|
||||
key: "command-palette-recent-weapons",
|
||||
storage: "local",
|
||||
schema: z.array(numericEnum(mainWeaponIds)),
|
||||
schema: v.array(numericEnum(mainWeaponIds)),
|
||||
default: [],
|
||||
});
|
||||
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { z } from "zod";
|
||||
import * as v from "valibot";
|
||||
import { mainWeaponIds } from "~/modules/in-game-lists/weapon-ids";
|
||||
import * as SearchParams from "~/modules/search-params/search-params";
|
||||
import { SP } from "~/modules/search-params/search-params";
|
||||
import { numericEnum } from "~/utils/zod";
|
||||
import { numericEnum } from "~/utils/schema";
|
||||
|
||||
export const GLOBAL_SEARCH_TYPES = [
|
||||
"weapons",
|
||||
@@ -14,7 +14,9 @@ export const GLOBAL_SEARCH_TYPES = [
|
||||
export type GlobalSearchType = (typeof GLOBAL_SEARCH_TYPES)[number];
|
||||
|
||||
export const globalSearchSearchParams = SearchParams.define({
|
||||
search: SP.param(z.enum(["open"]).nullable(), { loader: false }),
|
||||
type: SP.param(z.enum(GLOBAL_SEARCH_TYPES).nullable(), { loader: false }),
|
||||
weapon: SP.param(numericEnum(mainWeaponIds).nullable(), { loader: false }),
|
||||
search: SP.param(v.nullable(v.picklist(["open"])), { loader: false }),
|
||||
type: SP.param(v.nullable(v.picklist(GLOBAL_SEARCH_TYPES)), {
|
||||
loader: false,
|
||||
}),
|
||||
weapon: SP.param(v.nullable(numericEnum(mainWeaponIds)), { loader: false }),
|
||||
});
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { z } from "zod";
|
||||
import * as v from "valibot";
|
||||
import * as SearchParams from "~/modules/search-params/search-params";
|
||||
import { SP } from "~/modules/search-params/search-params";
|
||||
|
||||
export const authErrorSearchParams = SearchParams.define({
|
||||
authError: SP.param(z.string().nullable(), { loader: false }),
|
||||
authError: SP.param(v.nullable(v.string()), { loader: false }),
|
||||
});
|
||||
|
||||
@@ -1,21 +1,21 @@
|
||||
import { z } from "zod";
|
||||
import { _action, weaponSplId } from "~/utils/zod";
|
||||
import * as v from "valibot";
|
||||
import { _action, coerceNumber, weaponSplId } from "~/utils/schema";
|
||||
|
||||
const reportedMapIndex = z.coerce.number().int().nonnegative();
|
||||
const reportedMapIndex = v.pipe(coerceNumber(), v.integer(), v.minValue(0));
|
||||
|
||||
export const reportWeaponSchema = z.object({
|
||||
export const reportWeaponSchema = v.object({
|
||||
_action: _action("REPORT_WEAPON"),
|
||||
weaponSplId,
|
||||
mapIndex: reportedMapIndex,
|
||||
});
|
||||
|
||||
export const undoWeaponReportSchema = z.object({
|
||||
export const undoWeaponReportSchema = v.object({
|
||||
_action: _action("UNDO_WEAPON_REPORT"),
|
||||
mapIndex: reportedMapIndex,
|
||||
});
|
||||
|
||||
/** Weapon reporting actions shared by every match page route action schema. */
|
||||
export const weaponReportActionSchema = z.union([
|
||||
export const weaponReportActionSchema = v.union([
|
||||
reportWeaponSchema,
|
||||
undoWeaponReportSchema,
|
||||
]);
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { z } from "zod";
|
||||
import * as v from "valibot";
|
||||
import * as SearchParams from "~/modules/search-params/search-params";
|
||||
import { SP } from "~/modules/search-params/search-params";
|
||||
|
||||
@@ -11,5 +11,5 @@ const MATCH_PAGE_TABS = [
|
||||
] as const;
|
||||
|
||||
export const matchPageSearchParams = SearchParams.define({
|
||||
tab: SP.param(z.enum(MATCH_PAGE_TABS).nullable(), { loader: false }),
|
||||
tab: SP.param(v.nullable(v.picklist(MATCH_PAGE_TABS)), { loader: false }),
|
||||
});
|
||||
|
||||
@@ -1,35 +0,0 @@
|
||||
import { z } from "zod";
|
||||
|
||||
/**
|
||||
* Builds an `Error` with a readable, multi-line message describing every invalid
|
||||
* environment variable. Schemas are keyed by the literal env var name so the
|
||||
* issue path points straight at the variable a contributor needs to fix.
|
||||
*/
|
||||
export function formatEnvErrors(
|
||||
scope: "client" | "server",
|
||||
error: z.ZodError,
|
||||
): Error {
|
||||
const lines = error.issues.map((issue) => {
|
||||
const name = issue.path.join(".") || "(unknown)";
|
||||
return ` - ${name}: ${issue.message}`;
|
||||
});
|
||||
|
||||
return new Error(
|
||||
`Invalid ${scope} environment configuration:\n${lines.join(
|
||||
"\n",
|
||||
)}\n\nSee .env.example for the full list of variables and how to set them.`,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* String schema that must be set to a non-empty value in production, but falls
|
||||
* back to `devFallback` outside of production so contributors can run the app
|
||||
* without configuring every integration.
|
||||
*/
|
||||
export function requiredInProd(isProd: boolean, devFallback: string) {
|
||||
return isProd
|
||||
? z.string({ message: "required in production" }).min(1, {
|
||||
message: "required in production (cannot be empty)",
|
||||
})
|
||||
: z.string().default(devFallback);
|
||||
}
|
||||
60
app/config-helpers.ts
Normal file
60
app/config-helpers.ts
Normal file
@@ -0,0 +1,60 @@
|
||||
import * as v from "valibot";
|
||||
|
||||
const TRUTHY_ENV_VALUES = ["true", "1", "yes", "on", "y", "enabled"];
|
||||
const FALSY_ENV_VALUES = ["false", "0", "no", "off", "n", "disabled"];
|
||||
|
||||
/**
|
||||
* Builds an `Error` with a readable, multi-line message describing every invalid
|
||||
* environment variable. Schemas are keyed by the literal env var name so the
|
||||
* issue path points straight at the variable a contributor needs to fix.
|
||||
*/
|
||||
export function formatEnvErrors(
|
||||
scope: "client" | "server",
|
||||
issues: readonly v.BaseIssue<unknown>[],
|
||||
): Error {
|
||||
const lines = issues.map((issue) => {
|
||||
const name = issue.path?.map((item) => item.key).join(".") || "(unknown)";
|
||||
return ` - ${name}: ${issue.message}`;
|
||||
});
|
||||
|
||||
return new Error(
|
||||
`Invalid ${scope} environment configuration:\n${lines.join(
|
||||
"\n",
|
||||
)}\n\nSee .env.example for the full list of variables and how to set them.`,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* String schema that must be set to a non-empty value in production, but falls
|
||||
* back to `devFallback` outside of production so contributors can run the app
|
||||
* without configuring every integration.
|
||||
*/
|
||||
export function requiredInProd(isProd: boolean, devFallback: string) {
|
||||
// The production branch defaults to `""` rather than being required outright
|
||||
// so that a missing variable reaches `minLength` and reports the same
|
||||
// actionable message an empty one does, instead of valibot's "Invalid key".
|
||||
return isProd
|
||||
? v.pipe(
|
||||
v.optional(v.string(), ""),
|
||||
v.minLength(1, "required in production"),
|
||||
)
|
||||
: v.optional(v.string(), devFallback);
|
||||
}
|
||||
|
||||
/** Boolean parsed from a boolean-like string environment variable (e.g. `"true"`, `"0"`, `"on"`). */
|
||||
export const envBoolean = v.pipe(
|
||||
v.string(),
|
||||
v.check(
|
||||
(value) => isTruthyEnvValue(value) || isFalsyEnvValue(value),
|
||||
'must be a boolean-like string (e.g. "true" or "false")',
|
||||
),
|
||||
v.transform(isTruthyEnvValue),
|
||||
);
|
||||
|
||||
function isTruthyEnvValue(value: string) {
|
||||
return TRUTHY_ENV_VALUES.includes(value.toLowerCase());
|
||||
}
|
||||
|
||||
function isFalsyEnvValue(value: string) {
|
||||
return FALSY_ENV_VALUES.includes(value.toLowerCase());
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import { z } from "zod";
|
||||
import { formatEnvErrors, requiredInProd } from "./config-helpers.server";
|
||||
import * as v from "valibot";
|
||||
import { envBoolean, formatEnvErrors, requiredInProd } from "./config-helpers";
|
||||
import { IS_E2E_TEST_RUN } from "./utils/e2e";
|
||||
import { superRefine, type ValidationCtx } from "./utils/schema";
|
||||
|
||||
/**
|
||||
* Server (`process.env`) configuration. Import with
|
||||
@@ -14,16 +15,17 @@ import { IS_E2E_TEST_RUN } from "./utils/e2e";
|
||||
|
||||
const isProd = process.env.NODE_ENV === "production" && !IS_E2E_TEST_RUN;
|
||||
|
||||
const schema = z
|
||||
.object({
|
||||
NODE_ENV: z
|
||||
.enum(["development", "production", "test"])
|
||||
.default("development"),
|
||||
const schema = v.pipe(
|
||||
v.object({
|
||||
NODE_ENV: v.optional(
|
||||
v.picklist(["development", "production", "test"]),
|
||||
"development",
|
||||
),
|
||||
DB_PATH: requiredInProd(isProd, "db.sqlite3"),
|
||||
SESSION_SECRET: requiredInProd(isProd, "secret"),
|
||||
LOHI_TOKEN: requiredInProd(isProd, "salmon"),
|
||||
SQL_LOG: z.enum(["none", "trunc", "full"]).default("none"),
|
||||
DISABLE_CACHE: z.stringbool().default(false),
|
||||
SQL_LOG: v.optional(v.picklist(["none", "trunc", "full"]), "none"),
|
||||
DISABLE_CACHE: v.optional(envBoolean, "false"),
|
||||
|
||||
DISCORD_CLIENT_ID: requiredInProd(isProd, ""),
|
||||
DISCORD_CLIENT_SECRET: requiredInProd(isProd, ""),
|
||||
@@ -34,30 +36,31 @@ const schema = z
|
||||
STORAGE_REGION: requiredInProd(isProd, "us-east-1"),
|
||||
STORAGE_BUCKET: requiredInProd(isProd, "sendou"),
|
||||
|
||||
SKALOP_SYSTEM_MESSAGE_URL: z.string().optional(),
|
||||
SKALOP_TOKEN: z.string().optional(),
|
||||
SKALOP_SYSTEM_MESSAGE_URL: v.optional(v.string()),
|
||||
SKALOP_TOKEN: v.optional(v.string()),
|
||||
|
||||
TWITCH_CLIENT_ID: z.string().optional(),
|
||||
TWITCH_CLIENT_SECRET: z.string().optional(),
|
||||
TWITCH_CLIENT_ID: v.optional(v.string()),
|
||||
TWITCH_CLIENT_SECRET: v.optional(v.string()),
|
||||
|
||||
PATREON_ACCESS_TOKEN: z.string().optional(),
|
||||
PATREON_ACCESS_TOKEN: v.optional(v.string()),
|
||||
|
||||
// The VAPID public key (VITE_VAPID_PUBLIC_KEY) lives in `~/config` since
|
||||
// it is client-readable; the full three-var coupling is completed by the
|
||||
// runtime check in webPush.server.ts.
|
||||
VAPID_PRIVATE_KEY: z.string().optional(),
|
||||
VAPID_EMAIL: z.string().optional(),
|
||||
})
|
||||
.superRefine((val, ctx) => {
|
||||
VAPID_PRIVATE_KEY: v.optional(v.string()),
|
||||
VAPID_EMAIL: v.optional(v.string()),
|
||||
}),
|
||||
superRefine((val, ctx) => {
|
||||
requireTogether(ctx, val, "TWITCH_CLIENT_ID", "TWITCH_CLIENT_SECRET");
|
||||
requireTogether(ctx, val, "VAPID_EMAIL", "VAPID_PRIVATE_KEY");
|
||||
});
|
||||
}),
|
||||
);
|
||||
|
||||
const parsed = schema.safeParse(process.env);
|
||||
const parsed = v.safeParse(schema, process.env);
|
||||
if (!parsed.success) {
|
||||
throw formatEnvErrors("server", parsed.error);
|
||||
throw formatEnvErrors("server", parsed.issues);
|
||||
}
|
||||
const values = parsed.data;
|
||||
const values = parsed.output;
|
||||
|
||||
export const ServerConfig = {
|
||||
/**
|
||||
@@ -122,7 +125,7 @@ export const ServerConfig = {
|
||||
|
||||
/** Adds a validation issue unless `a` and `b` are both set or both unset. */
|
||||
function requireTogether(
|
||||
ctx: z.RefinementCtx,
|
||||
ctx: ValidationCtx,
|
||||
values: Record<string, unknown>,
|
||||
a: string,
|
||||
b: string,
|
||||
@@ -134,7 +137,6 @@ function requireTogether(
|
||||
const present = aSet ? a : b;
|
||||
const missing = aSet ? b : a;
|
||||
ctx.addIssue({
|
||||
code: "custom",
|
||||
path: [missing],
|
||||
message: `must be set together with ${present}`,
|
||||
});
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import * as v from "valibot";
|
||||
import { envBoolean, formatEnvErrors, requiredInProd } from "./config-helpers";
|
||||
import { IS_E2E_TEST_RUN } from "./utils/e2e";
|
||||
|
||||
/**
|
||||
@@ -7,9 +9,6 @@ import { IS_E2E_TEST_RUN } from "./utils/e2e";
|
||||
* Values are validated once when this module is first imported, surfacing a
|
||||
* single clear error for any misconfigured variable. Variables required in
|
||||
* production fall back to development defaults outside of production.
|
||||
*
|
||||
* Note: this module ships in the critical client bundle so it must stay free of
|
||||
* heavy dependencies (e.g. zod, which the server config uses).
|
||||
*/
|
||||
|
||||
// `import.meta.env` is undefined when Playwright bundles test code, so guard the
|
||||
@@ -24,34 +23,37 @@ const isProd =
|
||||
import.meta.env.PROD === true &&
|
||||
!IS_E2E_TEST_RUN;
|
||||
|
||||
const TRUTHY_STRINGS = ["true", "1", "yes", "on", "y", "enabled"];
|
||||
const FALSY_STRINGS = ["false", "0", "no", "off", "n", "disabled"];
|
||||
|
||||
const issues: Array<{ name: string; message: string }> = [];
|
||||
|
||||
const values = {
|
||||
VITE_SITE_DOMAIN: requiredInProd("VITE_SITE_DOMAIN", "http://localhost:5173"),
|
||||
const schema = v.object({
|
||||
VITE_SITE_DOMAIN: requiredInProd(isProd, "http://localhost:5173"),
|
||||
VITE_TOURNAMENT_DEFAULT_LOGO: requiredInProd(
|
||||
"VITE_TOURNAMENT_DEFAULT_LOGO",
|
||||
isProd,
|
||||
"tournament-logo-default.avif",
|
||||
),
|
||||
VITE_STATIC_ASSETS_URL: withDefault(
|
||||
"VITE_STATIC_ASSETS_URL",
|
||||
VITE_STATIC_ASSETS_URL: v.optional(
|
||||
v.string(),
|
||||
"https://sendou-assets.nyc3.cdn.digitaloceanspaces.com",
|
||||
),
|
||||
VITE_PROD_MODE: stringBool("VITE_PROD_MODE"),
|
||||
VITE_SHOW_LUTI_NAV_ITEM: stringBool("VITE_SHOW_LUTI_NAV_ITEM"),
|
||||
VITE_FUSE_ENABLED: stringBool("VITE_FUSE_ENABLED"),
|
||||
VITE_SCANNER_ENABLED: stringBool("VITE_SCANNER_ENABLED"),
|
||||
VITE_LEAGUE_GOOGLE_FORM_URL: env.VITE_LEAGUE_GOOGLE_FORM_URL,
|
||||
VITE_SHOW_BANNER_FOR_SEASON: env.VITE_SHOW_BANNER_FOR_SEASON,
|
||||
VITE_SKALOP_WS_URL: env.VITE_SKALOP_WS_URL,
|
||||
VITE_VAPID_PUBLIC_KEY: env.VITE_VAPID_PUBLIC_KEY,
|
||||
};
|
||||
|
||||
if (issues.length > 0) {
|
||||
throw envError(issues);
|
||||
VITE_PROD_MODE: v.optional(envBoolean, "false"),
|
||||
VITE_SHOW_LUTI_NAV_ITEM: v.optional(envBoolean, "false"),
|
||||
VITE_FUSE_ENABLED: v.optional(envBoolean, "false"),
|
||||
VITE_SCANNER_ENABLED: v.optional(envBoolean, "false"),
|
||||
|
||||
VITE_LEAGUE_GOOGLE_FORM_URL: v.optional(v.string()),
|
||||
VITE_SHOW_BANNER_FOR_SEASON: v.optional(v.string()),
|
||||
VITE_SKALOP_WS_URL: v.optional(v.string()),
|
||||
|
||||
// The VAPID private key and email live in `~/config.server` since they are
|
||||
// server-only; the full three-var coupling is completed by the runtime check
|
||||
// in webPush.server.ts.
|
||||
VITE_VAPID_PUBLIC_KEY: v.optional(v.string()),
|
||||
});
|
||||
|
||||
const parsed = v.safeParse(schema, env);
|
||||
if (!parsed.success) {
|
||||
throw formatEnvErrors("client", parsed.issues);
|
||||
}
|
||||
const values = parsed.output;
|
||||
|
||||
export const Config = {
|
||||
/** Base URL of the site, e.g. `https://sendou.ink`. */
|
||||
@@ -80,51 +82,3 @@ export const Config = {
|
||||
publicKey: values.VITE_VAPID_PUBLIC_KEY,
|
||||
},
|
||||
};
|
||||
|
||||
function requiredInProd(name: string, devFallback: string): string {
|
||||
const value = env[name];
|
||||
|
||||
if (!isProd) {
|
||||
return value ?? devFallback;
|
||||
}
|
||||
|
||||
if (value === undefined) {
|
||||
issues.push({ name, message: "required in production" });
|
||||
return "";
|
||||
}
|
||||
if (value.length === 0) {
|
||||
issues.push({ name, message: "required in production (cannot be empty)" });
|
||||
return "";
|
||||
}
|
||||
|
||||
return value;
|
||||
}
|
||||
|
||||
function withDefault(name: string, defaultValue: string): string {
|
||||
return env[name] ?? defaultValue;
|
||||
}
|
||||
|
||||
function stringBool(name: string): boolean {
|
||||
const value = env[name];
|
||||
if (value === undefined) return false;
|
||||
|
||||
const normalized = value.toLowerCase();
|
||||
if (TRUTHY_STRINGS.includes(normalized)) return true;
|
||||
if (FALSY_STRINGS.includes(normalized)) return false;
|
||||
|
||||
issues.push({
|
||||
name,
|
||||
message: `must be a boolean-like string (e.g. "true" or "false"), got "${value}"`,
|
||||
});
|
||||
return false;
|
||||
}
|
||||
|
||||
function envError(issues: Array<{ name: string; message: string }>): Error {
|
||||
const lines = issues.map((issue) => ` - ${issue.name}: ${issue.message}`);
|
||||
|
||||
return new Error(
|
||||
`Invalid client environment configuration:\n${lines.join(
|
||||
"\n",
|
||||
)}\n\nSee .env.example for the full list of variables and how to set them.`,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -10,9 +10,9 @@ import {
|
||||
notFoundIfNullish,
|
||||
successToast,
|
||||
} from "~/utils/remix.server";
|
||||
import { normalizeFriendCode } from "~/utils/schema";
|
||||
import { errorIsSqliteForeignKeyConstraintFailure } from "~/utils/sql";
|
||||
import { assertUnreachable } from "~/utils/types";
|
||||
import { normalizeFriendCode } from "~/utils/zod";
|
||||
import { adminActionSchema } from "../admin-schemas";
|
||||
import {
|
||||
sendUserBannedWebhook,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { z } from "zod";
|
||||
import * as v from "valibot";
|
||||
import { friendCodeField } from "~/features/sendouq/q-schemas";
|
||||
import {
|
||||
datetime,
|
||||
@@ -11,16 +11,16 @@ import {
|
||||
textFieldOptional,
|
||||
userSearch,
|
||||
} from "~/form/fields";
|
||||
import { id } from "~/utils/zod";
|
||||
import { id } from "~/utils/schema";
|
||||
import { BAN_REASON_MAX_LENGTH } from "./admin-constants";
|
||||
|
||||
const userField = userSearch({ label: "labels.user" });
|
||||
|
||||
export const friendCodeSearchSchema = z.object({
|
||||
export const friendCodeSearchSchema = v.object({
|
||||
friendCode: friendCodeField,
|
||||
});
|
||||
|
||||
export const migrateUserSchema = z.object({
|
||||
export const migrateUserSchema = v.object({
|
||||
_action: stringConstant("MIGRATE"),
|
||||
oldUser: userSearch({ label: "labels.adminOldUser" }),
|
||||
newUser: userSearch({
|
||||
@@ -29,39 +29,39 @@ export const migrateUserSchema = z.object({
|
||||
}),
|
||||
});
|
||||
|
||||
export const linkPlayerSchema = z.object({
|
||||
export const linkPlayerSchema = v.object({
|
||||
_action: stringConstant("LINK_PLAYER"),
|
||||
user: userField,
|
||||
playerId: numberField({ label: "labels.adminPlayerId", min: 1 }),
|
||||
});
|
||||
|
||||
export const giveArtistSchema = z.object({
|
||||
export const giveArtistSchema = v.object({
|
||||
_action: stringConstant("ARTIST"),
|
||||
user: userField,
|
||||
});
|
||||
|
||||
export const giveVideoAdderSchema = z.object({
|
||||
export const giveVideoAdderSchema = v.object({
|
||||
_action: stringConstant("VIDEO_ADDER"),
|
||||
user: userField,
|
||||
});
|
||||
|
||||
export const giveTournamentOrganizerSchema = z.object({
|
||||
export const giveTournamentOrganizerSchema = v.object({
|
||||
_action: stringConstant("TOURNAMENT_ORGANIZER"),
|
||||
user: userField,
|
||||
});
|
||||
|
||||
export const giveApiAccessSchema = z.object({
|
||||
export const giveApiAccessSchema = v.object({
|
||||
_action: stringConstant("API_ACCESS"),
|
||||
user: userField,
|
||||
});
|
||||
|
||||
export const updateFriendCodeSchema = z.object({
|
||||
export const updateFriendCodeSchema = v.object({
|
||||
_action: stringConstant("UPDATE_FRIEND_CODE"),
|
||||
user: userField,
|
||||
friendCode: friendCodeField,
|
||||
});
|
||||
|
||||
export const forcePatronSchema = z.object({
|
||||
export const forcePatronSchema = v.object({
|
||||
_action: stringConstant("FORCE_PATRON"),
|
||||
user: userField,
|
||||
patronTier: select({
|
||||
@@ -75,7 +75,7 @@ export const forcePatronSchema = z.object({
|
||||
patronExpiresAt: datetime({ label: "labels.patronExpiresAt" }),
|
||||
});
|
||||
|
||||
export const banUserSchema = z.object({
|
||||
export const banUserSchema = v.object({
|
||||
_action: stringConstant("BAN_USER"),
|
||||
user: userField,
|
||||
expiresAt: datetimeOptional({
|
||||
@@ -90,16 +90,16 @@ export const banUserSchema = z.object({
|
||||
}),
|
||||
});
|
||||
|
||||
export const unbanUserSchema = z.object({
|
||||
export const unbanUserSchema = v.object({
|
||||
_action: stringConstant("UNBAN_USER"),
|
||||
user: userField,
|
||||
});
|
||||
|
||||
export const refreshPlusTiersSchema = z.object({
|
||||
export const refreshPlusTiersSchema = v.object({
|
||||
_action: stringConstant("REFRESH"),
|
||||
});
|
||||
|
||||
export const adminActionSchema = z.union([
|
||||
export const adminActionSchema = v.union([
|
||||
migrateUserSchema,
|
||||
linkPlayerSchema,
|
||||
giveArtistSchema,
|
||||
@@ -113,7 +113,7 @@ export const adminActionSchema = z.union([
|
||||
refreshPlusTiersSchema,
|
||||
]);
|
||||
|
||||
export const createExternalStreamSchema = z.object({
|
||||
export const createExternalStreamSchema = v.object({
|
||||
_action: stringConstant("CREATE"),
|
||||
name: textField({ label: "labels.name", maxLength: 64 }),
|
||||
url: textField({
|
||||
@@ -125,12 +125,12 @@ export const createExternalStreamSchema = z.object({
|
||||
startTime: datetime({ label: "labels.startTime" }),
|
||||
});
|
||||
|
||||
const deleteExternalStreamSchema = z.object({
|
||||
const deleteExternalStreamSchema = v.object({
|
||||
_action: stringConstant("DELETE"),
|
||||
id,
|
||||
});
|
||||
|
||||
export const externalStreamActionSchema = z.union([
|
||||
export const externalStreamActionSchema = v.union([
|
||||
createExternalStreamSchema,
|
||||
deleteExternalStreamSchema,
|
||||
]);
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
import { z } from "zod";
|
||||
import * as v from "valibot";
|
||||
import { FRIEND_CODE_REGEXP } from "~/features/sendouq/q-constants";
|
||||
import * as SearchParams from "~/modules/search-params/search-params";
|
||||
import { SP } from "~/modules/search-params/search-params";
|
||||
|
||||
export const adminSearchParams = SearchParams.define({
|
||||
friendCode: SP.param(z.string().regex(FRIEND_CODE_REGEXP).nullable(), {
|
||||
loader: true,
|
||||
}),
|
||||
friendCode: SP.param(
|
||||
v.nullable(v.pipe(v.string(), v.regex(FRIEND_CODE_REGEXP))),
|
||||
{
|
||||
loader: true,
|
||||
},
|
||||
),
|
||||
});
|
||||
|
||||
@@ -6,7 +6,7 @@ import {
|
||||
} from "~/features/auth/core/user.server";
|
||||
import * as UserRepository from "~/features/user-page/UserRepository.server";
|
||||
import { isAdmin, isDev, isStaff } from "~/modules/permissions/utils";
|
||||
import { normalizeFriendCode } from "~/utils/zod";
|
||||
import { normalizeFriendCode } from "~/utils/schema";
|
||||
import { adminSearchParams } from "../admin-search-params";
|
||||
import { DANGEROUS_CAN_ACCESS_DEV_CONTROLS } from "../core/dev-controls";
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { LoaderFunctionArgs } from "react-router";
|
||||
import { z } from "zod";
|
||||
import * as v from "valibot";
|
||||
import { db } from "~/db/sql";
|
||||
import {
|
||||
databaseTimestampToDate,
|
||||
@@ -7,11 +7,12 @@ import {
|
||||
weekNumberToDateRange,
|
||||
} from "~/utils/dates";
|
||||
import { parseParams } from "~/utils/remix.server";
|
||||
import { coerceNumber } from "~/utils/schema";
|
||||
import type { GetCalendarWeekResponse } from "../schema";
|
||||
|
||||
const paramsSchema = z.object({
|
||||
year: z.coerce.number().int().min(2020).max(2100),
|
||||
week: z.coerce.number().int().min(1).max(53),
|
||||
const paramsSchema = v.object({
|
||||
year: v.pipe(coerceNumber(), v.integer(), v.minValue(2020), v.maxValue(2100)),
|
||||
week: v.pipe(coerceNumber(), v.integer(), v.minValue(1), v.maxValue(53)),
|
||||
});
|
||||
|
||||
export const loader = async ({ params }: LoaderFunctionArgs) => {
|
||||
|
||||
@@ -1,15 +1,15 @@
|
||||
import type { LoaderFunctionArgs } from "react-router";
|
||||
import { z } from "zod";
|
||||
import * as v from "valibot";
|
||||
import { db } from "~/db/sql";
|
||||
import {
|
||||
concatUserSubmittedImagePrefix,
|
||||
jsonArrayFrom,
|
||||
} from "~/utils/kysely.server";
|
||||
import { notFoundIfNullish, parseParams } from "~/utils/remix.server";
|
||||
import { id } from "~/utils/zod";
|
||||
import { id } from "~/utils/schema";
|
||||
import type { GetTournamentOrganizationResponse } from "../schema";
|
||||
|
||||
const paramsSchema = z.object({
|
||||
const paramsSchema = v.object({
|
||||
id,
|
||||
});
|
||||
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import type { LoaderFunctionArgs } from "react-router";
|
||||
import { z } from "zod";
|
||||
import * as v from "valibot";
|
||||
import { SendouQ } from "~/features/sendouq/core/SendouQ.server";
|
||||
import { parseParams } from "~/utils/remix.server";
|
||||
import { id } from "~/utils/zod";
|
||||
import { id } from "~/utils/schema";
|
||||
import type { GetUsersActiveSendouqMatchResponse } from "../schema";
|
||||
|
||||
const paramsSchema = z.object({
|
||||
const paramsSchema = v.object({
|
||||
userId: id,
|
||||
});
|
||||
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
import type { LoaderFunctionArgs } from "react-router";
|
||||
import { z } from "zod";
|
||||
import * as v from "valibot";
|
||||
import * as SQMatchRepository from "~/features/sendouq-match/SQMatchRepository.server";
|
||||
import { getFixedTForLanguage } from "~/modules/i18n/i18next.server";
|
||||
import { notFoundIfNullish, parseParams } from "~/utils/remix.server";
|
||||
import { id } from "~/utils/zod";
|
||||
import { id } from "~/utils/schema";
|
||||
import type { GetSendouqMatchResponse, MapListMap } from "../schema";
|
||||
|
||||
const paramsSchema = z.object({
|
||||
const paramsSchema = v.object({
|
||||
matchId: id,
|
||||
});
|
||||
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
import type { LoaderFunctionArgs } from "react-router";
|
||||
import { z } from "zod";
|
||||
import * as v from "valibot";
|
||||
import { db } from "~/db/sql";
|
||||
import { concatUserSubmittedImagePrefix } from "~/utils/kysely.server";
|
||||
import { notFoundIfNullish, parseParams } from "~/utils/remix.server";
|
||||
import { id } from "~/utils/zod";
|
||||
import { id } from "~/utils/schema";
|
||||
import type { GetTeamResponse } from "../schema";
|
||||
|
||||
const paramsSchema = z.object({
|
||||
const paramsSchema = v.object({
|
||||
id,
|
||||
});
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { LoaderFunctionArgs } from "react-router";
|
||||
import { z } from "zod";
|
||||
import * as v from "valibot";
|
||||
import { db } from "~/db/sql";
|
||||
import * as TournamentRepository from "~/features/tournament/TournamentRepository.server";
|
||||
import * as TournamentTeamRepository from "~/features/tournament/TournamentTeamRepository.server";
|
||||
@@ -10,10 +10,10 @@ import { parseMaplistSource } from "~/modules/tournament-map-list-generator/sour
|
||||
import { jsonArrayFrom } from "~/utils/kysely.server";
|
||||
import { logger } from "~/utils/logger";
|
||||
import { notFoundIfNullish, parseParams } from "~/utils/remix.server";
|
||||
import { id } from "~/utils/zod";
|
||||
import { id } from "~/utils/schema";
|
||||
import type { GetTournamentMatchResponse } from "../schema";
|
||||
|
||||
const paramsSchema = z.object({
|
||||
const paramsSchema = v.object({
|
||||
id,
|
||||
});
|
||||
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
import type { LoaderFunctionArgs } from "react-router";
|
||||
import { z } from "zod";
|
||||
import * as v from "valibot";
|
||||
import { tournamentFromDB } from "~/features/tournament-bracket/core/Tournament.server";
|
||||
import { notFoundIfNullish, parseParams } from "~/utils/remix.server";
|
||||
import { id } from "~/utils/zod";
|
||||
import { coerceNumber, id } from "~/utils/schema";
|
||||
import type { GetTournamentBracketStandingsResponse } from "../schema";
|
||||
|
||||
const paramsSchema = z.object({
|
||||
const paramsSchema = v.object({
|
||||
id,
|
||||
bidx: z.coerce.number().int(),
|
||||
bidx: v.pipe(coerceNumber(), v.integer()),
|
||||
});
|
||||
|
||||
export const loader = async ({ params }: LoaderFunctionArgs) => {
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
import type { LoaderFunctionArgs } from "react-router";
|
||||
import { z } from "zod";
|
||||
import * as v from "valibot";
|
||||
import type { Bracket } from "~/features/tournament-bracket/core/Bracket";
|
||||
import { tournamentFromDB } from "~/features/tournament-bracket/core/Tournament.server";
|
||||
import { notFoundIfNullish, parseParams } from "~/utils/remix.server";
|
||||
import { id } from "~/utils/zod";
|
||||
import { coerceNumber, id } from "~/utils/schema";
|
||||
import type { GetTournamentBracketResponse } from "../schema";
|
||||
|
||||
const paramsSchema = z.object({
|
||||
const paramsSchema = v.object({
|
||||
id,
|
||||
bidx: z.coerce.number().int(),
|
||||
bidx: v.pipe(coerceNumber(), v.integer()),
|
||||
});
|
||||
|
||||
export const loader = async ({ params }: LoaderFunctionArgs) => {
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import type { LoaderFunctionArgs } from "react-router";
|
||||
import { z } from "zod";
|
||||
import * as v from "valibot";
|
||||
import { db } from "~/db/sql";
|
||||
import { notFoundIfNullish, parseParams } from "~/utils/remix.server";
|
||||
import { id } from "~/utils/zod";
|
||||
import { id } from "~/utils/schema";
|
||||
import type { GetCastedTournamentMatchesResponse } from "../schema";
|
||||
|
||||
const paramsSchema = z.object({
|
||||
const paramsSchema = v.object({
|
||||
id,
|
||||
});
|
||||
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import type { LoaderFunctionArgs } from "react-router";
|
||||
import { z } from "zod";
|
||||
import * as v from "valibot";
|
||||
import * as TournamentMatchRepository from "~/features/tournament-match/TournamentMatchRepository.server";
|
||||
import { parseParams } from "~/utils/remix.server";
|
||||
import { id } from "~/utils/zod";
|
||||
import { id } from "~/utils/schema";
|
||||
import type { GetTournamentPlayersResponse } from "../schema";
|
||||
|
||||
const paramsSchema = z.object({
|
||||
const paramsSchema = v.object({
|
||||
id,
|
||||
});
|
||||
|
||||
|
||||
@@ -1,16 +1,16 @@
|
||||
import type { ActionFunctionArgs } from "react-router";
|
||||
import { z } from "zod";
|
||||
import * as v from "valibot";
|
||||
import { action as adminAction } from "~/features/tournament-admin/actions/to.$id.admin.seeds.server";
|
||||
import { parseBody, parseParams } from "~/utils/remix.server";
|
||||
import { id } from "~/utils/zod";
|
||||
import { id } from "~/utils/schema";
|
||||
import { wrapActionForApi } from "../api-action-wrapper.server";
|
||||
|
||||
const paramsSchema = z.object({
|
||||
const paramsSchema = v.object({
|
||||
id,
|
||||
});
|
||||
|
||||
const bodySchema = z.object({
|
||||
tournamentTeamIds: z.array(id),
|
||||
const bodySchema = v.object({
|
||||
tournamentTeamIds: v.array(id),
|
||||
});
|
||||
|
||||
export const action = async (args: ActionFunctionArgs) => {
|
||||
|
||||
@@ -1,19 +1,19 @@
|
||||
import type { ActionFunctionArgs } from "react-router";
|
||||
import { z } from "zod";
|
||||
import * as v from "valibot";
|
||||
import { action as adminAction } from "~/features/tournament-admin/actions/to.$id.admin.seeds.server";
|
||||
import { parseBody, parseParams } from "~/utils/remix.server";
|
||||
import { id } from "~/utils/zod";
|
||||
import { id } from "~/utils/schema";
|
||||
import { wrapActionForApi } from "../api-action-wrapper.server";
|
||||
|
||||
const paramsSchema = z.object({
|
||||
const paramsSchema = v.object({
|
||||
id,
|
||||
});
|
||||
|
||||
const bodySchema = z.object({
|
||||
startingBrackets: z.array(
|
||||
z.object({
|
||||
const bodySchema = v.object({
|
||||
startingBrackets: v.array(
|
||||
v.object({
|
||||
tournamentTeamId: id,
|
||||
startingBracketIdx: z.number().int().min(0),
|
||||
startingBracketIdx: v.pipe(v.number(), v.integer(), v.minValue(0)),
|
||||
}),
|
||||
),
|
||||
});
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
import { sql } from "kysely";
|
||||
import type { LoaderFunctionArgs } from "react-router";
|
||||
import { z } from "zod";
|
||||
import * as v from "valibot";
|
||||
import { db } from "~/db/sql";
|
||||
import { jsonArrayFrom } from "~/utils/kysely.server";
|
||||
import { notFoundIfNullish, parseParams } from "~/utils/remix.server";
|
||||
import { id } from "~/utils/zod";
|
||||
import { id } from "~/utils/schema";
|
||||
import type { GetTournamentStreamsResponse } from "../schema";
|
||||
|
||||
const paramsSchema = z.object({
|
||||
const paramsSchema = v.object({
|
||||
id,
|
||||
});
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { ActionFunctionArgs } from "react-router";
|
||||
import { z } from "zod";
|
||||
import * as v from "valibot";
|
||||
import { requireUser } from "~/features/auth/core/user.server";
|
||||
import { userIsBanned } from "~/features/ban/core/banned.server";
|
||||
import * as ChatSystemMessage from "~/features/chat/ChatSystemMessage.server";
|
||||
@@ -19,15 +19,15 @@ import {
|
||||
parseBody,
|
||||
parseParams,
|
||||
} from "~/utils/remix.server";
|
||||
import { id } from "~/utils/zod";
|
||||
import { id } from "~/utils/schema";
|
||||
import { wrapActionForApi } from "../api-action-wrapper.server";
|
||||
|
||||
const paramsSchema = z.object({
|
||||
const paramsSchema = v.object({
|
||||
id,
|
||||
teamId: id,
|
||||
});
|
||||
|
||||
const bodySchema = z.object({
|
||||
const bodySchema = v.object({
|
||||
userId: id,
|
||||
});
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { ActionFunctionArgs } from "react-router";
|
||||
import { z } from "zod";
|
||||
import * as v from "valibot";
|
||||
import { requireUser } from "~/features/auth/core/user.server";
|
||||
import * as ShowcaseTournaments from "~/features/front-page/core/ShowcaseTournaments.server";
|
||||
import * as TournamentTeamRepository from "~/features/tournament/TournamentTeamRepository.server";
|
||||
@@ -14,15 +14,15 @@ import {
|
||||
parseBody,
|
||||
parseParams,
|
||||
} from "~/utils/remix.server";
|
||||
import { id } from "~/utils/zod";
|
||||
import { id } from "~/utils/schema";
|
||||
import { wrapActionForApi } from "../api-action-wrapper.server";
|
||||
|
||||
const paramsSchema = z.object({
|
||||
const paramsSchema = v.object({
|
||||
id,
|
||||
teamId: id,
|
||||
});
|
||||
|
||||
const bodySchema = z.object({
|
||||
const bodySchema = v.object({
|
||||
userId: id,
|
||||
});
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { ActionFunctionArgs } from "react-router";
|
||||
import { z } from "zod";
|
||||
import * as v from "valibot";
|
||||
import { requireUser } from "~/features/auth/core/user.server";
|
||||
import * as TournamentTeamRepository from "~/features/tournament/TournamentTeamRepository.server";
|
||||
import {
|
||||
@@ -13,17 +13,17 @@ import {
|
||||
parseBody,
|
||||
parseParams,
|
||||
} from "~/utils/remix.server";
|
||||
import { id } from "~/utils/zod";
|
||||
import { id } from "~/utils/schema";
|
||||
import { wrapActionForApi } from "../api-action-wrapper.server";
|
||||
|
||||
const paramsSchema = z.object({
|
||||
const paramsSchema = v.object({
|
||||
id,
|
||||
teamId: id,
|
||||
});
|
||||
|
||||
const bodySchema = z.object({
|
||||
const bodySchema = v.object({
|
||||
userId: id,
|
||||
inGameName: z.string().refine(inGameNameIsValid),
|
||||
inGameName: v.pipe(v.string(), v.check(inGameNameIsValid)),
|
||||
});
|
||||
|
||||
export const action = async (args: ActionFunctionArgs) => {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { sql } from "kysely";
|
||||
import type { LoaderFunctionArgs } from "react-router";
|
||||
import { z } from "zod";
|
||||
import * as v from "valibot";
|
||||
import { db } from "~/db/sql";
|
||||
import { ordinalToSp } from "~/features/mmr/mmr-utils";
|
||||
import * as TournamentRepository from "~/features/tournament/TournamentRepository.server";
|
||||
@@ -14,10 +14,10 @@ import {
|
||||
tournamentUsername,
|
||||
} from "~/utils/kysely.server";
|
||||
import { parseParams } from "~/utils/remix.server";
|
||||
import { id } from "~/utils/zod";
|
||||
import { id } from "~/utils/schema";
|
||||
import type { GetTournamentTeamsResponse } from "../schema";
|
||||
|
||||
const paramsSchema = z.object({
|
||||
const paramsSchema = v.object({
|
||||
id,
|
||||
});
|
||||
|
||||
|
||||
@@ -1,32 +1,35 @@
|
||||
import type { ActionFunctionArgs } from "react-router";
|
||||
import { z } from "zod";
|
||||
import * as v from "valibot";
|
||||
import * as TournamentRepository from "~/features/tournament/TournamentRepository.server";
|
||||
import { TOURNAMENT } from "~/features/tournament/tournament-constants";
|
||||
import { upsertRegistrationAction } from "~/features/tournament-admin/actions/to.$id.admin.registration.server";
|
||||
import { ADMIN_REGISTRATION_MAX_MEMBERS } from "~/features/tournament-admin/tournament-admin-registration-schemas";
|
||||
import { existingImage } from "~/form/image-field";
|
||||
import { parseBody, parseParams } from "~/utils/remix.server";
|
||||
import { id } from "~/utils/zod";
|
||||
import { id } from "~/utils/schema";
|
||||
import { wrapActionForApi } from "../api-action-wrapper.server";
|
||||
|
||||
const paramsSchema = z.object({
|
||||
const paramsSchema = v.object({
|
||||
id,
|
||||
});
|
||||
|
||||
const bodySchema = z.object({
|
||||
tournamentTeamId: id.optional(),
|
||||
name: z.string().max(TOURNAMENT.TEAM_NAME_MAX_LENGTH).optional(),
|
||||
teamId: id.optional(),
|
||||
const bodySchema = v.object({
|
||||
tournamentTeamId: v.optional(id),
|
||||
name: v.optional(
|
||||
v.pipe(v.string(), v.maxLength(TOURNAMENT.TEAM_NAME_MAX_LENGTH)),
|
||||
),
|
||||
teamId: v.optional(id),
|
||||
ownerUserId: id,
|
||||
members: z
|
||||
.array(
|
||||
z.object({
|
||||
members: v.pipe(
|
||||
v.array(
|
||||
v.object({
|
||||
userId: id,
|
||||
inGameName: z.string().optional(),
|
||||
inGameName: v.optional(v.string()),
|
||||
}),
|
||||
)
|
||||
.min(1)
|
||||
.max(ADMIN_REGISTRATION_MAX_MEMBERS),
|
||||
),
|
||||
v.minLength(1),
|
||||
v.maxLength(ADMIN_REGISTRATION_MAX_MEMBERS),
|
||||
),
|
||||
});
|
||||
|
||||
export const action = async (args: ActionFunctionArgs) => {
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
import type { LoaderFunctionArgs } from "react-router";
|
||||
import { z } from "zod";
|
||||
import * as v from "valibot";
|
||||
import { db } from "~/db/sql";
|
||||
import { databaseTimestampToDate } from "~/utils/dates";
|
||||
import { jsonArrayFrom } from "~/utils/kysely.server";
|
||||
import { notFoundIfNullish, parseParams } from "~/utils/remix.server";
|
||||
import { id } from "~/utils/zod";
|
||||
import { id } from "~/utils/schema";
|
||||
import type { GetTournamentResponse } from "../schema";
|
||||
|
||||
const paramsSchema = z.object({
|
||||
const paramsSchema = v.object({
|
||||
id,
|
||||
});
|
||||
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import type { LoaderFunctionArgs } from "react-router";
|
||||
import { z } from "zod/v4";
|
||||
import * as v from "valibot";
|
||||
import { userByIdentifierQuery } from "~/utils/kysely.server";
|
||||
import { notFoundIfNullish, parseParams } from "~/utils/remix.server";
|
||||
import type { GetUserIdsResponse } from "../schema";
|
||||
|
||||
const paramsSchema = z.object({
|
||||
identifier: z.string(),
|
||||
const paramsSchema = v.object({
|
||||
identifier: v.string(),
|
||||
});
|
||||
|
||||
export const loader = async ({ params }: LoaderFunctionArgs) => {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { LoaderFunctionArgs } from "react-router";
|
||||
import { z } from "zod";
|
||||
import * as v from "valibot";
|
||||
import { db } from "~/db/sql";
|
||||
import * as Seasons from "~/features/mmr/core/Seasons";
|
||||
import { userSkills as _userSkills } from "~/features/mmr/tiered.server";
|
||||
@@ -11,8 +11,8 @@ import { notFoundIfNullish, parseParams } from "~/utils/remix.server";
|
||||
import { badgeUrl } from "~/utils/urls";
|
||||
import type { GetUserResponse } from "../schema";
|
||||
|
||||
const paramsSchema = z.object({
|
||||
identifier: z.string(),
|
||||
const paramsSchema = v.object({
|
||||
identifier: v.string(),
|
||||
});
|
||||
|
||||
export const loader = async ({ params }: LoaderFunctionArgs) => {
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
import type { LoaderFunctionArgs } from "react-router";
|
||||
import { z } from "zod";
|
||||
import * as v from "valibot";
|
||||
import { SendouQ } from "~/features/sendouq/core/SendouQ.server";
|
||||
import { RunningTournaments } from "~/features/tournament-bracket/core/RunningTournaments.server";
|
||||
import { parseParams } from "~/utils/remix.server";
|
||||
import { id } from "~/utils/zod";
|
||||
import { id } from "~/utils/schema";
|
||||
import type { GetUsersActiveMatchResponse } from "../schema";
|
||||
|
||||
const paramsSchema = z.object({
|
||||
const paramsSchema = v.object({
|
||||
userId: id,
|
||||
});
|
||||
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import { z } from "zod";
|
||||
import { _action } from "~/utils/zod";
|
||||
import * as v from "valibot";
|
||||
import { _action } from "~/utils/schema";
|
||||
|
||||
export const apiActionSchema = z.union([
|
||||
z.object({
|
||||
export const apiActionSchema = v.union([
|
||||
v.object({
|
||||
_action: _action("GENERATE_READ"),
|
||||
}),
|
||||
z.object({
|
||||
v.object({
|
||||
_action: _action("GENERATE_WRITE"),
|
||||
}),
|
||||
]);
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { z } from "zod";
|
||||
import * as v from "valibot";
|
||||
|
||||
/**
|
||||
* Allowed prefixes for an art data URL. Unlike the generic `image()` form field, art keeps the
|
||||
@@ -30,10 +30,11 @@ export const ART_FORM_MAX_BODY_BYTES =
|
||||
export const ART_IMAGE_TOO_LARGE_ERROR = "forms:errors.imageTooLarge";
|
||||
|
||||
const artImageDataUrl = (maxBytes: number) =>
|
||||
z
|
||||
.string()
|
||||
.max(maxDataUrlLength(maxBytes), ART_IMAGE_TOO_LARGE_ERROR)
|
||||
.regex(ART_IMAGE_DATA_URL_PREFIX_REGEX);
|
||||
v.pipe(
|
||||
v.string(),
|
||||
v.maxLength(maxDataUrlLength(maxBytes), ART_IMAGE_TOO_LARGE_ERROR),
|
||||
v.regex(ART_IMAGE_DATA_URL_PREFIX_REGEX),
|
||||
);
|
||||
|
||||
/**
|
||||
* JSON-serializable value of the art image form field. Art can't use the generic `image()` field:
|
||||
@@ -42,21 +43,21 @@ const artImageDataUrl = (maxBytes: number) =>
|
||||
* uploaded (only the preview url rides along, never bytes) — art images can't be swapped after
|
||||
* upload.
|
||||
*/
|
||||
export const artImageValue = z
|
||||
.union([
|
||||
z.object({
|
||||
type: z.literal("EXISTING"),
|
||||
url: z.string(),
|
||||
export const artImageValue = v.nullable(
|
||||
v.union([
|
||||
v.object({
|
||||
type: v.literal("EXISTING"),
|
||||
url: v.string(),
|
||||
}),
|
||||
z.object({
|
||||
type: z.literal("NEW"),
|
||||
v.object({
|
||||
type: v.literal("NEW"),
|
||||
dataUrl: artImageDataUrl(ART_IMAGE_MAX_BYTES),
|
||||
thumbnailDataUrl: artImageDataUrl(ART_THUMBNAIL_MAX_BYTES),
|
||||
}),
|
||||
])
|
||||
.nullable();
|
||||
]),
|
||||
);
|
||||
|
||||
export type ArtImageValue = z.infer<typeof artImageValue>;
|
||||
export type ArtImageValue = v.InferOutput<typeof artImageValue>;
|
||||
|
||||
/**
|
||||
* Does a freshly compressed art image exceed what the schema accepts? Lets the form field reject
|
||||
|
||||
@@ -1,17 +1,17 @@
|
||||
import { z } from "zod";
|
||||
import { _action, id } from "~/utils/zod";
|
||||
import * as v from "valibot";
|
||||
import { _action, id } from "~/utils/schema";
|
||||
|
||||
const deleteArtSchema = z.object({
|
||||
const deleteArtSchema = v.object({
|
||||
_action: _action("DELETE_ART"),
|
||||
id,
|
||||
});
|
||||
|
||||
const unlinkArtSchema = z.object({
|
||||
const unlinkArtSchema = v.object({
|
||||
_action: _action("UNLINK_ART"),
|
||||
id,
|
||||
});
|
||||
|
||||
export const userArtPageActionSchema = z.union([
|
||||
export const userArtPageActionSchema = v.union([
|
||||
deleteArtSchema,
|
||||
unlinkArtSchema,
|
||||
]);
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { z } from "zod";
|
||||
import * as v from "valibot";
|
||||
import {
|
||||
array,
|
||||
customField,
|
||||
@@ -7,46 +7,51 @@ import {
|
||||
toggle,
|
||||
userSearchOptional,
|
||||
} from "~/form/fields";
|
||||
import { id } from "~/utils/zod";
|
||||
import { id, superRefine } from "~/utils/schema";
|
||||
import { ART } from "./art-constants";
|
||||
import { artImageValue } from "./art-image";
|
||||
|
||||
const artTags = z
|
||||
.array(
|
||||
z.object({
|
||||
name: z.string().min(1).max(ART.TAG_MAX_LENGTH).optional(),
|
||||
id: id.optional(),
|
||||
const artTags = v.pipe(
|
||||
v.array(
|
||||
v.object({
|
||||
name: v.optional(
|
||||
v.pipe(v.string(), v.minLength(1), v.maxLength(ART.TAG_MAX_LENGTH)),
|
||||
),
|
||||
id: v.optional(id),
|
||||
}),
|
||||
)
|
||||
.max(ART.TAGS_MAX_LENGTH);
|
||||
),
|
||||
v.maxLength(ART.TAGS_MAX_LENGTH),
|
||||
);
|
||||
|
||||
export const artFormSchema = z
|
||||
.object({
|
||||
artId: idConstantOptional(),
|
||||
img: customField({ initialValue: null }, artImageValue),
|
||||
description: textAreaOptional({
|
||||
label: "labels.description",
|
||||
maxLength: ART.DESCRIPTION_MAX_LENGTH,
|
||||
}),
|
||||
tags: customField({ initialValue: [] }, artTags),
|
||||
linkedUsers: array({
|
||||
label: "labels.linkedUsers",
|
||||
bottomText: "bottomTexts.linkedUsers",
|
||||
max: ART.LINKED_USERS_MAX_LENGTH,
|
||||
field: userSearchOptional({ label: "labels.user" }),
|
||||
}),
|
||||
isShowcase: toggle({
|
||||
label: "labels.showcase",
|
||||
bottomText: "bottomTexts.showcase",
|
||||
}),
|
||||
})
|
||||
.superRefine((data, ctx) => {
|
||||
const artFormFields = {
|
||||
artId: idConstantOptional(),
|
||||
img: customField({ initialValue: null }, artImageValue),
|
||||
description: textAreaOptional({
|
||||
label: "labels.description",
|
||||
maxLength: ART.DESCRIPTION_MAX_LENGTH,
|
||||
}),
|
||||
tags: customField({ initialValue: [] }, artTags),
|
||||
linkedUsers: array({
|
||||
label: "labels.linkedUsers",
|
||||
bottomText: "bottomTexts.linkedUsers",
|
||||
max: ART.LINKED_USERS_MAX_LENGTH,
|
||||
field: userSearchOptional({ label: "labels.user" }),
|
||||
}),
|
||||
isShowcase: toggle({
|
||||
label: "labels.showcase",
|
||||
bottomText: "bottomTexts.showcase",
|
||||
}),
|
||||
};
|
||||
|
||||
export const artFormSchema = v.pipe(
|
||||
v.object(artFormFields),
|
||||
superRefine((data, ctx) => {
|
||||
// existing art keeps its image, new art must bring one
|
||||
if (!data.artId && data.img?.type !== "NEW") {
|
||||
ctx.addIssue({
|
||||
path: ["img"],
|
||||
code: "custom",
|
||||
message: "forms:errors.required",
|
||||
});
|
||||
}
|
||||
});
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { z } from "zod";
|
||||
import * as v from "valibot";
|
||||
import * as SearchParams from "~/modules/search-params/search-params";
|
||||
import { SP } from "~/modules/search-params/search-params";
|
||||
|
||||
@@ -8,18 +8,22 @@ export const ART_TABS = {
|
||||
} as const;
|
||||
|
||||
export const artSearchParams = SearchParams.define({
|
||||
tag: SP.param(z.string().nullable(), { loader: true }),
|
||||
tab: SP.param(z.enum([ART_TABS.RECENTLY_UPLOADED, ART_TABS.SHOWCASE]), {
|
||||
tag: SP.param(v.nullable(v.string()), { loader: true }),
|
||||
tab: SP.param(v.picklist([ART_TABS.RECENTLY_UPLOADED, ART_TABS.SHOWCASE]), {
|
||||
default: ART_TABS.RECENTLY_UPLOADED,
|
||||
loader: false,
|
||||
}),
|
||||
open: SP.param(z.boolean(), { default: false, loader: false }),
|
||||
open: SP.param(v.boolean(), { default: false, loader: false }),
|
||||
});
|
||||
|
||||
export const artGridSearchParams = SearchParams.define({
|
||||
big: SP.param(z.number().int().positive().nullable(), { loader: false }),
|
||||
big: SP.param(v.nullable(v.pipe(v.number(), v.integer(), v.gtValue(0))), {
|
||||
loader: false,
|
||||
}),
|
||||
});
|
||||
|
||||
export const artNewSearchParams = SearchParams.define({
|
||||
art: SP.param(z.number().int().positive().nullable(), { loader: true }),
|
||||
art: SP.param(v.nullable(v.pipe(v.number(), v.integer(), v.gtValue(0))), {
|
||||
loader: true,
|
||||
}),
|
||||
});
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
import { z } from "zod";
|
||||
import * as v from "valibot";
|
||||
|
||||
const authorName = z.string().min(1);
|
||||
const authorName = v.pipe(v.string(), v.minLength(1));
|
||||
|
||||
const author = z.union([
|
||||
const author = v.union([
|
||||
authorName,
|
||||
z.object({ name: authorName, link: z.string().url() }),
|
||||
v.object({ name: authorName, link: v.pipe(v.string(), v.url()) }),
|
||||
]);
|
||||
|
||||
export const articleDataSchema = z.object({
|
||||
title: z.string().min(1),
|
||||
author: z.union([author, z.array(author)]),
|
||||
date: z.date(),
|
||||
export const articleDataSchema = v.object({
|
||||
title: v.pipe(v.string(), v.minLength(1)),
|
||||
author: v.union([author, v.array(author)]),
|
||||
date: v.date(),
|
||||
});
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import matter from "gray-matter";
|
||||
import { ZodError, type z } from "zod";
|
||||
import * as v from "valibot";
|
||||
import { ARTICLES_FOLDER_PATH } from "../articles-constants";
|
||||
import { articleDataSchema } from "../articles-schemas.server";
|
||||
|
||||
@@ -18,7 +18,7 @@ export function articleBySlug(slug: string) {
|
||||
);
|
||||
const { content, data } = matter(rawMarkdown);
|
||||
|
||||
const { date, ...restParsed } = articleDataSchema.parse(data);
|
||||
const { date, ...restParsed } = v.parse(articleDataSchema, data);
|
||||
|
||||
return {
|
||||
content,
|
||||
@@ -29,7 +29,7 @@ export function articleBySlug(slug: string) {
|
||||
} catch (e) {
|
||||
if (!(e instanceof Error)) throw e;
|
||||
|
||||
if (e.message.includes("ENOENT") || e instanceof ZodError) {
|
||||
if (e.message.includes("ENOENT") || e instanceof v.ValiError) {
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -38,7 +38,7 @@ export function articleBySlug(slug: string) {
|
||||
}
|
||||
|
||||
export function normalizeAuthors(
|
||||
authors: z.infer<typeof articleDataSchema>["author"],
|
||||
authors: v.InferOutput<typeof articleDataSchema>["author"],
|
||||
): Array<{ name: string; link: string | null }> {
|
||||
if (Array.isArray(authors)) {
|
||||
return authors.map((author) => {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import matter from "gray-matter";
|
||||
import * as v from "valibot";
|
||||
import { ARTICLES_FOLDER_PATH } from "../articles-constants";
|
||||
import { articleDataSchema } from "../articles-schemas.server";
|
||||
import { type articleBySlug, normalizeAuthors } from "./bySlug.server";
|
||||
@@ -20,7 +21,7 @@ export async function mostRecentArticles(count: number) {
|
||||
);
|
||||
const { data } = matter(rawMarkdown);
|
||||
|
||||
const { date, ...restParsed } = articleDataSchema.parse(data);
|
||||
const { date, ...restParsed } = v.parse(articleDataSchema, data);
|
||||
articles.push({
|
||||
date,
|
||||
slug: file.replace(".md", ""),
|
||||
|
||||
@@ -1,42 +1,42 @@
|
||||
import { z } from "zod";
|
||||
import * as v from "valibot";
|
||||
import { textField } from "~/form/fields";
|
||||
import { _action, id, inviteCode } from "~/utils/zod";
|
||||
import { _action, id, inviteCode } from "~/utils/schema";
|
||||
import { ASSOCIATION } from "./associations-constants";
|
||||
|
||||
export const createNewAssociationSchema = z.object({
|
||||
export const createNewAssociationSchema = v.object({
|
||||
name: textField({
|
||||
label: "labels.name",
|
||||
maxLength: 100,
|
||||
}),
|
||||
});
|
||||
|
||||
const removeMemberSchema = z.object({
|
||||
const removeMemberSchema = v.object({
|
||||
_action: _action("REMOVE_MEMBER"),
|
||||
associationId: id,
|
||||
userId: id,
|
||||
});
|
||||
|
||||
const deleteAssociationSchema = z.object({
|
||||
const deleteAssociationSchema = v.object({
|
||||
_action: _action("DELETE_ASSOCIATION"),
|
||||
associationId: id,
|
||||
});
|
||||
|
||||
const refreshInviteCodeSchema = z.object({
|
||||
const refreshInviteCodeSchema = v.object({
|
||||
_action: _action("REFRESH_INVITE_CODE"),
|
||||
associationId: id,
|
||||
});
|
||||
|
||||
const joinAssociationSchema = z.object({
|
||||
const joinAssociationSchema = v.object({
|
||||
_action: _action("JOIN_ASSOCIATION"),
|
||||
inviteCode,
|
||||
});
|
||||
|
||||
const leaveAssociationSchema = z.object({
|
||||
const leaveAssociationSchema = v.object({
|
||||
_action: _action("LEAVE_ASSOCIATION"),
|
||||
associationId: id,
|
||||
});
|
||||
|
||||
export const associationsPageActionSchema = z.union([
|
||||
export const associationsPageActionSchema = v.union([
|
||||
removeMemberSchema,
|
||||
deleteAssociationSchema,
|
||||
refreshInviteCodeSchema,
|
||||
@@ -44,12 +44,12 @@ export const associationsPageActionSchema = z.union([
|
||||
leaveAssociationSchema,
|
||||
]);
|
||||
|
||||
const virtualAssociationIdentifierSchema = z.enum(
|
||||
const virtualAssociationIdentifierSchema = v.picklist(
|
||||
ASSOCIATION.VIRTUAL_IDENTIFIERS,
|
||||
);
|
||||
|
||||
export const associationIdentifierSchema = z.union([
|
||||
export const associationIdentifierSchema = v.union([
|
||||
virtualAssociationIdentifierSchema,
|
||||
id,
|
||||
z.literal("PUBLIC"), // null in DB
|
||||
v.literal("PUBLIC"),
|
||||
]);
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
import { z } from "zod";
|
||||
import * as v from "valibot";
|
||||
import * as SearchParams from "~/modules/search-params/search-params";
|
||||
import { SP } from "~/modules/search-params/search-params";
|
||||
import { SHORT_NANOID_LENGTH } from "~/utils/id";
|
||||
|
||||
export const associationsSearchParams = SearchParams.define({
|
||||
inviteCode: SP.param(z.string().length(SHORT_NANOID_LENGTH).nullable(), {
|
||||
loader: true,
|
||||
}),
|
||||
inviteCode: SP.param(
|
||||
v.nullable(v.pipe(v.string(), v.length(SHORT_NANOID_LENGTH))),
|
||||
{
|
||||
loader: true,
|
||||
},
|
||||
),
|
||||
});
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { add } from "date-fns";
|
||||
import { OAuth2Strategy } from "remix-auth-oauth2";
|
||||
import { z } from "zod";
|
||||
import * as v from "valibot";
|
||||
import { Config } from "~/config";
|
||||
import { ServerConfig } from "~/config.server";
|
||||
import * as UserRepository from "~/features/user-page/UserRepository.server";
|
||||
@@ -8,36 +8,39 @@ import { logger } from "~/utils/logger";
|
||||
|
||||
let discordApiCooldownUntil: number | null = null;
|
||||
|
||||
const partialDiscordUserSchema = z.object({
|
||||
avatar: z.string().nullish(),
|
||||
discriminator: z.string(),
|
||||
id: z.string(),
|
||||
username: z.string(),
|
||||
global_name: z.string().nullish(),
|
||||
verified: z.boolean().nullish(),
|
||||
const partialDiscordUserSchema = v.object({
|
||||
avatar: v.optional(v.nullable(v.string())),
|
||||
discriminator: v.string(),
|
||||
id: v.string(),
|
||||
username: v.string(),
|
||||
global_name: v.optional(v.nullable(v.string())),
|
||||
verified: v.optional(v.nullable(v.boolean())),
|
||||
});
|
||||
const partialDiscordConnectionsSchema = z.array(
|
||||
z.object({
|
||||
visibility: z.number(),
|
||||
verified: z.boolean(),
|
||||
name: z.string(),
|
||||
id: z.string(),
|
||||
type: z.string(),
|
||||
const partialDiscordConnectionsSchema = v.array(
|
||||
v.object({
|
||||
visibility: v.number(),
|
||||
verified: v.boolean(),
|
||||
name: v.string(),
|
||||
id: v.string(),
|
||||
type: v.string(),
|
||||
}),
|
||||
);
|
||||
const discordUserDetailsSchema = z.tuple([
|
||||
const discordUserDetailsSchema = v.tuple([
|
||||
partialDiscordUserSchema,
|
||||
partialDiscordConnectionsSchema,
|
||||
]);
|
||||
const discordRateLimitSchema = z.object({
|
||||
retry_after: z.number(),
|
||||
const discordRateLimitSchema = v.object({
|
||||
retry_after: v.number(),
|
||||
});
|
||||
|
||||
export const DiscordStrategy = () => {
|
||||
const jsonIfOk = async (res: Response) => {
|
||||
if (res.status === 429) {
|
||||
const body = discordRateLimitSchema.safeParse(await res.clone().json());
|
||||
const retryAfterSeconds = body.success ? body.data.retry_after : 60;
|
||||
const body = v.safeParse(
|
||||
discordRateLimitSchema,
|
||||
await res.clone().json(),
|
||||
);
|
||||
const retryAfterSeconds = body.success ? body.output.retry_after : 60;
|
||||
discordApiCooldownUntil = add(new Date(), {
|
||||
seconds: retryAfterSeconds,
|
||||
}).getTime();
|
||||
@@ -89,8 +92,10 @@ export const DiscordStrategy = () => {
|
||||
tokens.accessToken(),
|
||||
);
|
||||
|
||||
const [user, connections] =
|
||||
discordUserDetailsSchema.parse(discordResponses);
|
||||
const [user, connections] = v.parse(
|
||||
discordUserDetailsSchema,
|
||||
discordResponses,
|
||||
);
|
||||
|
||||
const isAlreadyRegistered = Boolean(
|
||||
await UserRepository.findIdByIdentifier(user.id),
|
||||
@@ -119,7 +124,7 @@ export const DiscordStrategy = () => {
|
||||
};
|
||||
|
||||
function parseConnections(
|
||||
connections: z.infer<typeof partialDiscordConnectionsSchema>,
|
||||
connections: v.InferOutput<typeof partialDiscordConnectionsSchema>,
|
||||
) {
|
||||
if (!connections) throw new Error("No connections");
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { isbot } from "isbot";
|
||||
import type { ActionFunction, LoaderFunction } from "react-router";
|
||||
import { redirect } from "react-router";
|
||||
import { z } from "zod";
|
||||
import * as v from "valibot";
|
||||
import { DANGEROUS_CAN_ACCESS_DEV_CONTROLS } from "~/features/admin/core/dev-controls";
|
||||
import { requireUser } from "~/features/auth/core/user.server";
|
||||
import * as UserRepository from "~/features/user-page/UserRepository.server";
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
canAccessLohiEndpoint,
|
||||
errorToastRedirect,
|
||||
} from "~/utils/remix.server";
|
||||
import type { AnySyncSchema } from "~/utils/schema";
|
||||
import { ADMIN_PAGE, authErrorUrl } from "~/utils/urls";
|
||||
import * as LogInLinkRepository from "../LogInLinkRepository.server";
|
||||
import {
|
||||
@@ -151,17 +152,17 @@ async function safeReturnTo(request: Request): Promise<string | null> {
|
||||
// only light validation here as we generally trust Lohi
|
||||
// auth flow params are infrastructure conventions and intentionally do not go
|
||||
// through app/modules/search-params/
|
||||
function parseSearchParams<T extends z.ZodTypeAny>({
|
||||
function parseSearchParams<T extends AnySyncSchema>({
|
||||
request,
|
||||
schema,
|
||||
}: {
|
||||
request: Request;
|
||||
schema: T;
|
||||
}): z.infer<T> {
|
||||
}): v.InferOutput<T> {
|
||||
const searchParams = Object.fromEntries(new URL(request.url).searchParams);
|
||||
|
||||
try {
|
||||
return schema.parse(searchParams);
|
||||
return v.parse(schema, searchParams);
|
||||
} catch (e) {
|
||||
logger.error("Error parsing search params", e);
|
||||
|
||||
@@ -169,12 +170,12 @@ function parseSearchParams<T extends z.ZodTypeAny>({
|
||||
}
|
||||
}
|
||||
|
||||
const createLogInLinkActionSchema = z.object({
|
||||
discordId: z.string(),
|
||||
discordAvatar: z.string().nullish(),
|
||||
discordName: z.string(),
|
||||
discordUniqueName: z.string(),
|
||||
updateOnly: z.enum(["true", "false"]),
|
||||
const createLogInLinkActionSchema = v.object({
|
||||
discordId: v.string(),
|
||||
discordAvatar: v.optional(v.nullable(v.string())),
|
||||
discordName: v.string(),
|
||||
discordUniqueName: v.string(),
|
||||
updateOnly: v.picklist(["true", "false"]),
|
||||
});
|
||||
|
||||
export const createLogInLinkAction: ActionFunction = async ({ request }) => {
|
||||
@@ -203,8 +204,8 @@ export const createLogInLinkAction: ActionFunction = async ({ request }) => {
|
||||
};
|
||||
};
|
||||
|
||||
const logInViaLinkActionSchema = z.object({
|
||||
code: z.string(),
|
||||
const logInViaLinkActionSchema = v.object({
|
||||
code: v.string(),
|
||||
});
|
||||
|
||||
export const logInViaLinkLoader: LoaderFunction = async ({ request }) => {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { ActionFunction } from "react-router";
|
||||
import { redirect } from "react-router";
|
||||
import { z } from "zod";
|
||||
import * as v from "valibot";
|
||||
import { notify } from "~/features/notifications/core/notify.server";
|
||||
import {
|
||||
requirePermission,
|
||||
@@ -8,9 +8,9 @@ import {
|
||||
} from "~/modules/permissions/guards.server";
|
||||
import { diff } from "~/utils/arrays";
|
||||
import { notFoundIfNullish, parseRequestPayload } from "~/utils/remix.server";
|
||||
import { actualNumber, preprocess } from "~/utils/schema";
|
||||
import { assertUnreachable } from "~/utils/types";
|
||||
import { badgePage } from "~/utils/urls";
|
||||
import { actualNumber } from "~/utils/zod";
|
||||
import * as BadgeRepository from "../BadgeRepository.server";
|
||||
import { editBadgeActionSchema } from "../badges-schemas";
|
||||
|
||||
@@ -19,7 +19,7 @@ export const action: ActionFunction = async ({ request, params }) => {
|
||||
request,
|
||||
schema: editBadgeActionSchema,
|
||||
});
|
||||
const badgeId = z.preprocess(actualNumber, z.number()).parse(params.id);
|
||||
const badgeId = v.parse(preprocess(actualNumber, v.number()), params.id);
|
||||
const badge = notFoundIfNullish(await BadgeRepository.findById(badgeId));
|
||||
|
||||
switch (data._action) {
|
||||
|
||||
@@ -1,17 +1,29 @@
|
||||
import { z } from "zod";
|
||||
import { _action, id, noDuplicates, safeJSONParse } from "~/utils/zod";
|
||||
import * as v from "valibot";
|
||||
import {
|
||||
_action,
|
||||
id,
|
||||
noDuplicates,
|
||||
preprocess,
|
||||
safeJSONParse,
|
||||
} from "~/utils/schema";
|
||||
import { BADGE } from "./badges-constants";
|
||||
|
||||
export const editBadgeActionSchema = z.union([
|
||||
z.object({
|
||||
export const editBadgeActionSchema = v.union([
|
||||
v.object({
|
||||
_action: _action("MANAGERS"),
|
||||
managerIds: z.preprocess(safeJSONParse, z.array(id).refine(noDuplicates)),
|
||||
}),
|
||||
z.object({
|
||||
_action: _action("OWNERS"),
|
||||
ownerIds: z.preprocess(
|
||||
managerIds: preprocess(
|
||||
safeJSONParse,
|
||||
z.array(id).max(BADGE.OWNERS_MAX_LENGTH),
|
||||
v.pipe(
|
||||
v.array(id),
|
||||
v.check((managerIds) => noDuplicates(managerIds)),
|
||||
),
|
||||
),
|
||||
}),
|
||||
v.object({
|
||||
_action: _action("OWNERS"),
|
||||
ownerIds: preprocess(
|
||||
safeJSONParse,
|
||||
v.pipe(v.array(id), v.maxLength(BADGE.OWNERS_MAX_LENGTH)),
|
||||
),
|
||||
}),
|
||||
]);
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import type { LoaderFunctionArgs } from "react-router";
|
||||
import type { SerializeFrom } from "~/utils/remix";
|
||||
import { notFoundIfNullish, parseParams } from "~/utils/remix.server";
|
||||
import { idObject } from "~/utils/zod";
|
||||
import { idObject } from "~/utils/schema";
|
||||
import * as BadgeRepository from "../BadgeRepository.server";
|
||||
|
||||
export type BadgeDetailsLoaderData = SerializeFrom<typeof loader>;
|
||||
|
||||
@@ -1,27 +1,21 @@
|
||||
import { z } from "zod";
|
||||
import * as v from "valibot";
|
||||
import { EMPTY_BUILD } from "~/features/builds/builds-constants";
|
||||
import { mainWeaponIds } from "~/modules/in-game-lists/weapon-ids";
|
||||
import * as SearchParams from "~/modules/search-params/search-params";
|
||||
import { SP } from "~/modules/search-params/search-params";
|
||||
import { numericEnum } from "~/utils/zod";
|
||||
import { codec, SP } from "~/modules/search-params/search-params";
|
||||
import { numericEnum } from "~/utils/schema";
|
||||
import { MAX_LDE_INTENSITY } from "./analyzer-constants";
|
||||
import type { SpecialEffectType } from "./analyzer-types";
|
||||
import { deserializeBuild, serializeBuild } from "./core/serializer";
|
||||
import { SPECIAL_EFFECTS } from "./core/specialEffects";
|
||||
|
||||
export const serializedBuildCodec = z.codec(
|
||||
z.string(),
|
||||
z.custom<NonNullable<ReturnType<typeof deserializeBuild>>>(),
|
||||
export const serializedBuildCodec = codec(
|
||||
v.custom<NonNullable<ReturnType<typeof deserializeBuild>>>(() => true),
|
||||
{
|
||||
decode: (value, payload) => {
|
||||
decode: (value) => {
|
||||
const build = deserializeBuild(value);
|
||||
if (!build) {
|
||||
payload.issues.push({
|
||||
code: "custom",
|
||||
message: "Invalid serialized build",
|
||||
input: value,
|
||||
});
|
||||
return z.NEVER;
|
||||
return undefined;
|
||||
}
|
||||
return build;
|
||||
},
|
||||
@@ -44,13 +38,21 @@ export const analyzerSearchParams = SearchParams.define({
|
||||
default: EMPTY_BUILD,
|
||||
loader: false,
|
||||
}),
|
||||
lde: SP.param(z.number().int().min(0).max(MAX_LDE_INTENSITY), {
|
||||
default: 0,
|
||||
loader: false,
|
||||
}),
|
||||
effect: SP.param(z.array(z.enum(specialEffectTypes)), {
|
||||
lde: SP.param(
|
||||
v.pipe(
|
||||
v.number(),
|
||||
v.integer(),
|
||||
v.minValue(0),
|
||||
v.maxValue(MAX_LDE_INTENSITY),
|
||||
),
|
||||
{
|
||||
default: 0,
|
||||
loader: false,
|
||||
},
|
||||
),
|
||||
effect: SP.param(v.array(v.picklist(specialEffectTypes)), {
|
||||
default: [],
|
||||
loader: false,
|
||||
}),
|
||||
focused: SP.param(z.literal([1, 2, 3]), { default: 1, loader: false }),
|
||||
focused: SP.param(v.picklist([1, 2, 3]), { default: 1, loader: false }),
|
||||
});
|
||||
|
||||
@@ -1,24 +1,27 @@
|
||||
import { z } from "zod";
|
||||
import * as v from "valibot";
|
||||
import { MAX_AP } from "~/features/build-analyzer/analyzer-constants";
|
||||
import { isValidDate } from "~/utils/dates";
|
||||
import { ability } from "~/utils/zod";
|
||||
import { ability } from "~/utils/schema";
|
||||
import { MAX_BUILD_FILTERS } from "./builds-constants";
|
||||
|
||||
const abilityConditionSchema = z.object({
|
||||
ability: z.string().toUpperCase().pipe(ability),
|
||||
value: z.union([z.int().min(0).max(MAX_AP), z.boolean()]),
|
||||
comparison: z
|
||||
.string()
|
||||
.toUpperCase()
|
||||
.pipe(z.enum(["AT_LEAST", "AT_MOST"]))
|
||||
.optional(),
|
||||
const abilityConditionSchema = v.object({
|
||||
ability: v.pipe(v.string(), v.toUpperCase(), ability),
|
||||
value: v.union([
|
||||
v.pipe(v.number(), v.integer(), v.minValue(0), v.maxValue(MAX_AP)),
|
||||
v.boolean(),
|
||||
]),
|
||||
comparison: v.optional(
|
||||
v.pipe(v.string(), v.toUpperCase(), v.picklist(["AT_LEAST", "AT_MOST"])),
|
||||
),
|
||||
});
|
||||
|
||||
export const abilityConditionsSchema = z
|
||||
.array(abilityConditionSchema)
|
||||
.max(MAX_BUILD_FILTERS);
|
||||
export const abilityConditionsSchema = v.pipe(
|
||||
v.array(abilityConditionSchema),
|
||||
v.maxLength(MAX_BUILD_FILTERS),
|
||||
);
|
||||
|
||||
export const buildsDateFilterSchema = z
|
||||
.string()
|
||||
.regex(/^\d{4}-\d{2}-\d{2}$/)
|
||||
.refine((value) => isValidDate(new Date(value)));
|
||||
export const buildsDateFilterSchema = v.pipe(
|
||||
v.string(),
|
||||
v.regex(/^\d{4}-\d{2}-\d{2}$/),
|
||||
v.check((value) => isValidDate(new Date(value))),
|
||||
);
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { z } from "zod";
|
||||
import * as v from "valibot";
|
||||
import * as SearchParams from "~/modules/search-params/search-params";
|
||||
import { SP } from "~/modules/search-params/search-params";
|
||||
import { modeShort } from "~/utils/zod";
|
||||
import { modeShort } from "~/utils/schema";
|
||||
import {
|
||||
BUILDS_PAGE_BATCH_SIZE,
|
||||
BUILDS_PAGE_MAX_BUILDS,
|
||||
@@ -12,20 +12,28 @@ import {
|
||||
} from "./builds-schemas";
|
||||
|
||||
export const buildsSearchParams = SearchParams.define({
|
||||
limit: SP.param(z.number().int().min(1).max(BUILDS_PAGE_MAX_BUILDS), {
|
||||
default: BUILDS_PAGE_BATCH_SIZE,
|
||||
loader: true,
|
||||
}),
|
||||
limit: SP.param(
|
||||
v.pipe(
|
||||
v.number(),
|
||||
v.integer(),
|
||||
v.minValue(1),
|
||||
v.maxValue(BUILDS_PAGE_MAX_BUILDS),
|
||||
),
|
||||
{
|
||||
default: BUILDS_PAGE_BATCH_SIZE,
|
||||
loader: true,
|
||||
},
|
||||
),
|
||||
abilities: SP.json(abilityConditionsSchema, {
|
||||
default: [],
|
||||
resets: ["limit"],
|
||||
loader: true,
|
||||
}),
|
||||
mode: SP.param(modeShort.nullable(), {
|
||||
mode: SP.param(v.nullable(modeShort), {
|
||||
resets: ["limit"],
|
||||
loader: true,
|
||||
}),
|
||||
date: SP.param(buildsDateFilterSchema.nullable(), {
|
||||
date: SP.param(v.nullable(buildsDateFilterSchema), {
|
||||
resets: ["limit"],
|
||||
loader: true,
|
||||
}),
|
||||
|
||||
@@ -4,8 +4,8 @@ import * as CalendarRepository from "~/features/calendar/CalendarRepository.serv
|
||||
import { parseFormData } from "~/form/parse.server";
|
||||
import { requirePermission } from "~/modules/permissions/guards.server";
|
||||
import { notFoundIfNullish, parseParams } from "~/utils/remix.server";
|
||||
import { idObject } from "~/utils/schema";
|
||||
import { calendarEventPage } from "~/utils/urls";
|
||||
import { idObject } from "~/utils/zod";
|
||||
import { reportWinnersFormSchema } from "../calendar-report-winners-schemas";
|
||||
|
||||
export const action: ActionFunction = async (args) => {
|
||||
|
||||
@@ -1,19 +1,20 @@
|
||||
import type { ActionFunction } from "react-router";
|
||||
import { redirect } from "react-router";
|
||||
import { z } from "zod";
|
||||
import * as v from "valibot";
|
||||
import * as CalendarRepository from "~/features/calendar/CalendarRepository.server";
|
||||
import * as ShowcaseTournaments from "~/features/front-page/core/ShowcaseTournaments.server";
|
||||
import * as BracketRepository from "~/features/tournament-bracket/BracketRepository.server";
|
||||
import { clearTournamentDataCache } from "~/features/tournament-bracket/core/Tournament.server";
|
||||
import { requirePermission } from "~/modules/permissions/guards.server";
|
||||
import { errorToastIfFalsy, notFoundIfNullish } from "~/utils/remix.server";
|
||||
import { actualNumber, id, preprocess } from "~/utils/schema";
|
||||
import { CALENDAR_PAGE } from "~/utils/urls";
|
||||
import { actualNumber, id } from "~/utils/zod";
|
||||
|
||||
export const action: ActionFunction = async ({ params }) => {
|
||||
const parsedParams = z
|
||||
.object({ id: z.preprocess(actualNumber, id) })
|
||||
.parse(params);
|
||||
const parsedParams = v.parse(
|
||||
v.object({ id: preprocess(actualNumber, id) }),
|
||||
params,
|
||||
);
|
||||
const event = notFoundIfNullish(
|
||||
await CalendarRepository.findById(parsedParams.id),
|
||||
);
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
import * as v from "valibot";
|
||||
import { superRefine } from "~/utils/schema";
|
||||
import {
|
||||
calendarNewBaseSchema,
|
||||
calendarNewSyncRefine,
|
||||
} from "./calendar-new-schemas";
|
||||
|
||||
export const calendarNewSchemaServer = calendarNewBaseSchema.superRefine(
|
||||
calendarNewSyncRefine,
|
||||
export const calendarNewSchemaServer = v.pipe(
|
||||
calendarNewBaseSchema,
|
||||
superRefine(calendarNewSyncRefine),
|
||||
);
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { z } from "zod";
|
||||
import * as v from "valibot";
|
||||
import { MapPool } from "~/features/map-list-generator/core/map-pool";
|
||||
import {
|
||||
array,
|
||||
@@ -19,7 +19,7 @@ import {
|
||||
toggle,
|
||||
} from "~/form/fields";
|
||||
import { rankedModesShort } from "~/modules/in-game-lists/modes";
|
||||
import { id } from "~/utils/zod";
|
||||
import { id, type ValidationCtx } from "~/utils/schema";
|
||||
import { CALENDAR_EVENT, REG_CLOSES_AT_OPTIONS } from "./calendar-constants";
|
||||
import {
|
||||
bracketsFormField,
|
||||
@@ -35,9 +35,23 @@ const calendarEventDateField = datetime({
|
||||
max: calendarEventMaxDate,
|
||||
});
|
||||
|
||||
export const calendarNewBaseSchema = z.object({
|
||||
// extracted so its literal item values don't widen to `string` in the
|
||||
// object's inferred value type
|
||||
const toToolsModeField = select({
|
||||
label: "labels.mapPickingStyle",
|
||||
items: [
|
||||
{ value: "ALL", label: "options.toToolsMode.ALL" },
|
||||
{ value: "SZ", label: "options.toToolsMode.SZ" },
|
||||
{ value: "TC", label: "options.toToolsMode.TC" },
|
||||
{ value: "RM", label: "options.toToolsMode.RM" },
|
||||
{ value: "CB", label: "options.toToolsMode.CB" },
|
||||
{ value: "TO", label: "options.toToolsMode.TO" },
|
||||
],
|
||||
});
|
||||
|
||||
export const calendarNewBaseSchema = v.object({
|
||||
// discriminates between a calendar event and a tournament; seeded from the loader, no visible control
|
||||
toToolsEnabled: hidden(z.boolean(), false),
|
||||
toToolsEnabled: hidden(v.boolean(), false),
|
||||
eventToEditId: idConstantOptional(),
|
||||
tournamentToCopyId: idConstantOptional(),
|
||||
name: textField({
|
||||
@@ -87,7 +101,7 @@ export const calendarNewBaseSchema = z.object({
|
||||
})),
|
||||
}),
|
||||
badges: badges({ label: "labels.badges", maxCount: 50 }),
|
||||
trophyId: customField({ initialValue: null }, id.nullish()),
|
||||
trophyId: customField({ initialValue: null }, v.nullish(id)),
|
||||
avatarImgId: image({
|
||||
label: "labels.logo",
|
||||
bottomText: "bottomTexts.avatarValidation",
|
||||
@@ -112,18 +126,8 @@ export const calendarNewBaseSchema = z.object({
|
||||
label: "labels.maxTeamSize",
|
||||
bottomText: "bottomTexts.maxTeamSize",
|
||||
}),
|
||||
toToolsMode: select({
|
||||
label: "labels.mapPickingStyle",
|
||||
items: [
|
||||
{ value: "ALL", label: "options.toToolsMode.ALL" },
|
||||
{ value: "SZ", label: "options.toToolsMode.SZ" },
|
||||
{ value: "TC", label: "options.toToolsMode.TC" },
|
||||
{ value: "RM", label: "options.toToolsMode.RM" },
|
||||
{ value: "CB", label: "options.toToolsMode.CB" },
|
||||
{ value: "TO", label: "options.toToolsMode.TO" },
|
||||
],
|
||||
}),
|
||||
pool: customField({ initialValue: "" }, z.string().optional()),
|
||||
toToolsMode: toToolsModeField,
|
||||
pool: customField({ initialValue: "" }, v.optional(v.string())),
|
||||
// the two bracket progression fields are only rendered (and validated) for
|
||||
// tournaments; for calendar events both stay at their empty initial value
|
||||
brackets: bracketsFormField,
|
||||
@@ -165,14 +169,13 @@ export const calendarNewBaseSchema = z.object({
|
||||
|
||||
/** Shared sync cross-field rules, reused by the server schema (see `*.server.ts`). */
|
||||
export function calendarNewSyncRefine(
|
||||
data: z.infer<typeof calendarNewBaseSchema>,
|
||||
ctx: z.RefinementCtx,
|
||||
data: v.InferOutput<typeof calendarNewBaseSchema>,
|
||||
ctx: ValidationCtx,
|
||||
) {
|
||||
// a calendar event needs at least one date; a tournament needs its single start time
|
||||
if (!data.toToolsEnabled && data.date.length < 1) {
|
||||
ctx.addIssue({
|
||||
path: ["date"],
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: "forms:errors.required",
|
||||
});
|
||||
}
|
||||
@@ -180,7 +183,6 @@ export function calendarNewSyncRefine(
|
||||
if (data.toToolsEnabled && !data.startTime) {
|
||||
ctx.addIssue({
|
||||
path: ["startTime"],
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: "forms:errors.required",
|
||||
});
|
||||
}
|
||||
@@ -189,7 +191,6 @@ export function calendarNewSyncRefine(
|
||||
if (!data.toToolsEnabled && !data.bracketUrl) {
|
||||
ctx.addIssue({
|
||||
path: ["bracketUrl"],
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: "forms:errors.bracketUrlRequired",
|
||||
});
|
||||
}
|
||||
@@ -198,7 +199,6 @@ export function calendarNewSyncRefine(
|
||||
if (data.brackets.length === 0) {
|
||||
ctx.addIssue({
|
||||
path: ["brackets"],
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: "forms:errors.bracketProgressionRequired",
|
||||
});
|
||||
} else {
|
||||
@@ -220,7 +220,6 @@ export function calendarNewSyncRefine(
|
||||
if (!isValid) {
|
||||
ctx.addIssue({
|
||||
path: ["pool"],
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: "forms:errors.allModePool",
|
||||
});
|
||||
}
|
||||
@@ -229,7 +228,6 @@ export function calendarNewSyncRefine(
|
||||
if (data.trophyId && data.badges.length > 0) {
|
||||
ctx.addIssue({
|
||||
path: ["badges"],
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: "forms:errors.trophyWithBadges",
|
||||
});
|
||||
}
|
||||
@@ -242,7 +240,6 @@ export function calendarNewSyncRefine(
|
||||
) {
|
||||
ctx.addIssue({
|
||||
path: ["maxMembersPerTeam"],
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: "forms:errors.maxMembersRange",
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
import type { z } from "zod";
|
||||
import * as Progression from "~/features/tournament-bracket/core/Progression";
|
||||
import type { ValidationCtx } from "~/utils/schema";
|
||||
import {
|
||||
defaultBracketsFormValues,
|
||||
formValuesToInputBrackets,
|
||||
@@ -67,11 +67,10 @@ function validationIssues(formValues: {
|
||||
brackets: Parameters<typeof validateBracketProgressionFormValues>[0];
|
||||
progression: Parameters<typeof validateBracketProgressionFormValues>[1];
|
||||
}) {
|
||||
const issues: z.ZodIssue[] = [];
|
||||
const ctx = {
|
||||
addIssue: (issue: z.ZodIssue) => issues.push(issue),
|
||||
path: [],
|
||||
} as unknown as z.RefinementCtx;
|
||||
const issues: Parameters<ValidationCtx["addIssue"]>[0][] = [];
|
||||
const ctx: ValidationCtx = {
|
||||
addIssue: (issue) => issues.push(issue),
|
||||
};
|
||||
|
||||
validateBracketProgressionFormValues(
|
||||
formValues.brackets,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { z } from "zod";
|
||||
import * as v from "valibot";
|
||||
import type { Tables } from "~/db/tables";
|
||||
import type { TournamentStageSettings } from "~/db/tables-json";
|
||||
import { TOURNAMENT } from "~/features/tournament/tournament-constants";
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
textFieldOptional,
|
||||
toggle,
|
||||
} from "~/form/fields";
|
||||
import { superRefine, type ValidationCtx } from "~/utils/schema";
|
||||
import { assertUnreachable } from "~/utils/types";
|
||||
|
||||
const SWISS_DEFAULT_ADVANCE_THRESHOLD = 3;
|
||||
@@ -71,7 +72,7 @@ const progressionSourceField = radioGroup({
|
||||
});
|
||||
|
||||
const bracketFieldset = fieldset({
|
||||
fields: z.object({
|
||||
fields: v.object({
|
||||
name: textField({
|
||||
label: "labels.bracketName",
|
||||
maxLength: TOURNAMENT.BRACKET_NAME_MAX_LENGTH,
|
||||
@@ -126,7 +127,7 @@ const bracketFieldset = fieldset({
|
||||
});
|
||||
|
||||
const progressionSourceFieldset = fieldset({
|
||||
fields: z.object({
|
||||
fields: v.object({
|
||||
bracketIdx: selectDynamic({
|
||||
label: "labels.sourceBracket",
|
||||
initialValue: "0",
|
||||
@@ -140,7 +141,7 @@ const progressionSourceFieldset = fieldset({
|
||||
});
|
||||
|
||||
const progressionEntryFieldset = fieldset({
|
||||
fields: z.object({
|
||||
fields: v.object({
|
||||
source: progressionSourceField,
|
||||
sources: array({
|
||||
min: 1,
|
||||
@@ -164,14 +165,15 @@ export const progressionFormField = array({
|
||||
});
|
||||
|
||||
/** Standalone schema for forms that edit only the bracket progression (tournament admin page). */
|
||||
export const bracketProgressionFormSchema = z
|
||||
.object({
|
||||
export const bracketProgressionFormSchema = v.pipe(
|
||||
v.object({
|
||||
brackets: bracketsFormField,
|
||||
progression: progressionFormField,
|
||||
})
|
||||
.superRefine((data, ctx) => {
|
||||
}),
|
||||
superRefine((data, ctx) => {
|
||||
validateBracketProgressionFormValues(data.brackets, data.progression, ctx);
|
||||
});
|
||||
}),
|
||||
);
|
||||
|
||||
/** Form field values of a new tournament's single starting bracket. Used to seed form default values. */
|
||||
export function defaultBracketsFormValues(): {
|
||||
@@ -306,7 +308,7 @@ export function sourceBracketHasEarlyAdvance(
|
||||
export function validateBracketProgressionFormValues(
|
||||
brackets: BracketFormValue[],
|
||||
progression: ProgressionFormValue[],
|
||||
ctx: z.RefinementCtx,
|
||||
ctx: ValidationCtx,
|
||||
) {
|
||||
for (const [entryIdx, entry] of progression.entries()) {
|
||||
if (entryIdx === 0 || entry.source !== "BRACKET") continue;
|
||||
@@ -321,7 +323,6 @@ export function validateBracketProgressionFormValues(
|
||||
sourceIdx === entryIdx
|
||||
) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: "forms:errors.invalidSourceBracket",
|
||||
path: [
|
||||
"progression",
|
||||
@@ -343,7 +344,6 @@ export function validateBracketProgressionFormValues(
|
||||
|
||||
for (const path of progressionErrorPaths(validated)) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message:
|
||||
validated.type === "PLACEMENT_TOO_HIGH"
|
||||
? "forms:errors.placementTooHigh"
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { z } from "zod";
|
||||
import * as v from "valibot";
|
||||
import {
|
||||
array,
|
||||
customField,
|
||||
@@ -6,30 +6,32 @@ import {
|
||||
numberField,
|
||||
textField,
|
||||
} from "~/form/fields";
|
||||
import { id } from "~/utils/zod";
|
||||
import { id, superRefine } from "~/utils/schema";
|
||||
import { CALENDAR_EVENT_RESULT } from "./calendar-constants";
|
||||
|
||||
const reportedPlayerSchema = z.discriminatedUnion("type", [
|
||||
z.object({ type: z.literal("USER"), id: id.nullable() }),
|
||||
z.object({
|
||||
type: z.literal("NAME"),
|
||||
name: z
|
||||
.string()
|
||||
.max(CALENDAR_EVENT_RESULT.MAX_PLAYER_NAME_LENGTH)
|
||||
.nullable(),
|
||||
const reportedPlayerSchema = v.variant("type", [
|
||||
v.object({ type: v.literal("USER"), id: v.nullable(id) }),
|
||||
v.object({
|
||||
type: v.literal("NAME"),
|
||||
name: v.nullable(
|
||||
v.pipe(
|
||||
v.string(),
|
||||
v.maxLength(CALENDAR_EVENT_RESULT.MAX_PLAYER_NAME_LENGTH),
|
||||
),
|
||||
),
|
||||
}),
|
||||
]);
|
||||
|
||||
export type ReportedPlayer = z.infer<typeof reportedPlayerSchema>;
|
||||
export type ReportedPlayer = v.InferOutput<typeof reportedPlayerSchema>;
|
||||
|
||||
export const EMPTY_REPORTED_PLAYER: ReportedPlayer = { type: "USER", id: null };
|
||||
|
||||
type StoredReportedPlayer = { userId: number | null; name: string | null };
|
||||
|
||||
const reportedPlayersSchema = z
|
||||
.array(reportedPlayerSchema)
|
||||
.max(CALENDAR_EVENT_RESULT.MAX_PLAYERS_LENGTH)
|
||||
.transform((players) =>
|
||||
const reportedPlayersSchema = v.pipe(
|
||||
v.array(reportedPlayerSchema),
|
||||
v.maxLength(CALENDAR_EVENT_RESULT.MAX_PLAYERS_LENGTH),
|
||||
v.transform((players) =>
|
||||
players.flatMap((player): Array<StoredReportedPlayer> => {
|
||||
if (player.type === "USER") {
|
||||
return player.id === null ? [] : [{ userId: player.id, name: null }];
|
||||
@@ -37,21 +39,17 @@ const reportedPlayersSchema = z
|
||||
|
||||
return player.name ? [{ userId: null, name: player.name }] : [];
|
||||
}),
|
||||
)
|
||||
.refine((players) => players.length > 0, {
|
||||
message: "forms:errors.emptyTeam",
|
||||
})
|
||||
.refine(
|
||||
(players) => {
|
||||
const userIds = players.flatMap((player) => player.userId ?? []);
|
||||
),
|
||||
v.check((players) => players.length > 0, "forms:errors.emptyTeam"),
|
||||
v.check((players) => {
|
||||
const userIds = players.flatMap((player) => player.userId ?? []);
|
||||
|
||||
return userIds.length === new Set(userIds).size;
|
||||
},
|
||||
{ message: "forms:errors.duplicatePlayer" },
|
||||
);
|
||||
return userIds.length === new Set(userIds).size;
|
||||
}, "forms:errors.duplicatePlayer"),
|
||||
);
|
||||
|
||||
const reportedTeamFieldset = fieldset({
|
||||
fields: z.object({
|
||||
fields: v.object({
|
||||
teamName: textField({
|
||||
label: "labels.teamName",
|
||||
maxLength: CALENDAR_EVENT_RESULT.MAX_TEAM_NAME_LENGTH,
|
||||
@@ -71,8 +69,8 @@ const reportedTeamFieldset = fieldset({
|
||||
}),
|
||||
});
|
||||
|
||||
export const reportWinnersFormSchema = z
|
||||
.object({
|
||||
export const reportWinnersFormSchema = v.pipe(
|
||||
v.object({
|
||||
participantCount: numberField({
|
||||
label: "labels.participantCount",
|
||||
maxLength: String(CALENDAR_EVENT_RESULT.MAX_PARTICIPANTS_COUNT).length,
|
||||
@@ -83,14 +81,13 @@ export const reportWinnersFormSchema = z
|
||||
max: CALENDAR_EVENT_RESULT.MAX_TEAMS_COUNT,
|
||||
field: reportedTeamFieldset,
|
||||
}),
|
||||
})
|
||||
.superRefine((data, ctx) => {
|
||||
}),
|
||||
superRefine((data, ctx) => {
|
||||
if (
|
||||
data.participantCount < 1 ||
|
||||
data.participantCount > CALENDAR_EVENT_RESULT.MAX_PARTICIPANTS_COUNT
|
||||
) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: "forms:errors.numberOutOfRange",
|
||||
path: ["participantCount"],
|
||||
});
|
||||
@@ -102,7 +99,6 @@ export const reportWinnersFormSchema = z
|
||||
team.placement > CALENDAR_EVENT_RESULT.MAX_TEAM_PLACEMENT
|
||||
) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: "forms:errors.numberOutOfRange",
|
||||
path: ["teams", index, "placement"],
|
||||
});
|
||||
@@ -112,9 +108,9 @@ export const reportWinnersFormSchema = z
|
||||
const teamNames = data.teams.map((team) => team.teamName);
|
||||
if (teamNames.length !== new Set(teamNames).size) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: "forms:errors.uniqueTeamName",
|
||||
path: ["teams"],
|
||||
});
|
||||
}
|
||||
});
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { z } from "zod";
|
||||
import * as v from "valibot";
|
||||
import type { CalendarEventTag } from "~/features/calendar/calendar-types";
|
||||
import {
|
||||
BEST_TIER_NUMBER,
|
||||
@@ -6,51 +6,71 @@ import {
|
||||
} from "~/features/tournament/core/tiering";
|
||||
import { gamesShort, versusShort } from "~/modules/in-game-lists/games";
|
||||
import { modesShortWithSpecial } from "~/modules/in-game-lists/modes";
|
||||
import { gamesShortSchema, id, modeShortWithSpecial } from "~/utils/zod";
|
||||
import {
|
||||
coerceNumber,
|
||||
gamesShortSchema,
|
||||
id,
|
||||
modeShortWithSpecial,
|
||||
} from "~/utils/schema";
|
||||
import { CALENDAR_EVENT } from "./calendar-constants";
|
||||
|
||||
const calendarEventTagSchema = z
|
||||
.string()
|
||||
.refine((val) => CALENDAR_EVENT.TAGS.includes(val as CalendarEventTag));
|
||||
const calendarEventTagSchema = v.pipe(
|
||||
v.string(),
|
||||
v.check((val) => CALENDAR_EVENT.TAGS.includes(val as CalendarEventTag)),
|
||||
);
|
||||
|
||||
export const calendarFilterTagsArr = z
|
||||
.array(calendarEventTagSchema)
|
||||
.max(CALENDAR_EVENT.TAGS.length);
|
||||
export const calendarFilterTagsArr = v.pipe(
|
||||
v.array(calendarEventTagSchema),
|
||||
v.maxLength(CALENDAR_EVENT.TAGS.length),
|
||||
);
|
||||
|
||||
const calendarFiltersPlainStringArr = z.array(z.string().max(100)).max(10);
|
||||
const calendarFiltersIdsArr = z.array(id).max(10);
|
||||
const calendarFilterGamesArr = z.array(gamesShortSchema).min(1).max(3);
|
||||
const preferredStartTime = z.enum(["ANY", "EU", "NA", "AU"]);
|
||||
const preferredVersus = z
|
||||
.array(z.enum(versusShort))
|
||||
.min(1)
|
||||
.max(versusShort.length);
|
||||
const modeArr = z
|
||||
.array(modeShortWithSpecial)
|
||||
.min(1)
|
||||
.max(modesShortWithSpecial.length);
|
||||
const tierNumber = z.coerce
|
||||
.number()
|
||||
.int()
|
||||
.min(BEST_TIER_NUMBER)
|
||||
.max(WORST_TIER_NUMBER);
|
||||
const calendarFiltersPlainStringArr = v.pipe(
|
||||
v.array(v.pipe(v.string(), v.maxLength(100))),
|
||||
v.maxLength(10),
|
||||
);
|
||||
const calendarFiltersIdsArr = v.pipe(v.array(id), v.maxLength(10));
|
||||
const calendarFilterGamesArr = v.pipe(
|
||||
v.array(gamesShortSchema),
|
||||
v.minLength(1),
|
||||
v.maxLength(3),
|
||||
);
|
||||
const preferredStartTime = v.picklist(["ANY", "EU", "NA", "AU"]);
|
||||
const preferredVersus = v.pipe(
|
||||
v.array(v.picklist(versusShort)),
|
||||
v.minLength(1),
|
||||
v.maxLength(versusShort.length),
|
||||
);
|
||||
const modeArr = v.pipe(
|
||||
v.array(modeShortWithSpecial),
|
||||
v.minLength(1),
|
||||
v.maxLength(modesShortWithSpecial.length),
|
||||
);
|
||||
const tierNumber = v.pipe(
|
||||
coerceNumber(),
|
||||
v.integer(),
|
||||
v.minValue(BEST_TIER_NUMBER),
|
||||
v.maxValue(WORST_TIER_NUMBER),
|
||||
);
|
||||
|
||||
export const calendarFiltersSearchParamsSchema = z.object({
|
||||
preferredStartTime: preferredStartTime.catch("ANY"),
|
||||
tagsIncluded: calendarFilterTagsArr.catch([]),
|
||||
tagsExcluded: calendarFilterTagsArr.catch([]),
|
||||
isSendou: z.boolean().catch(false),
|
||||
isRanked: z.boolean().catch(false),
|
||||
orgsIncluded: calendarFiltersPlainStringArr.catch([]),
|
||||
orgsExcluded: calendarFiltersPlainStringArr.catch([]),
|
||||
authorIdsExcluded: calendarFiltersIdsArr.catch([]),
|
||||
games: calendarFilterGamesArr.catch([...gamesShort]),
|
||||
preferredVersus: preferredVersus.catch([...versusShort]),
|
||||
modes: modeArr.catch([...modesShortWithSpecial]),
|
||||
modesExact: z.boolean().catch(false),
|
||||
minTeamCount: z.coerce.number().int().nonnegative().catch(0),
|
||||
minTier: tierNumber.catch(BEST_TIER_NUMBER),
|
||||
maxTier: tierNumber.catch(WORST_TIER_NUMBER),
|
||||
export const calendarFiltersSearchParamsSchema = v.object({
|
||||
preferredStartTime: v.fallback(preferredStartTime, "ANY"),
|
||||
tagsIncluded: v.fallback(calendarFilterTagsArr, []),
|
||||
tagsExcluded: v.fallback(calendarFilterTagsArr, []),
|
||||
isSendou: v.fallback(v.boolean(), false),
|
||||
isRanked: v.fallback(v.boolean(), false),
|
||||
orgsIncluded: v.fallback(calendarFiltersPlainStringArr, []),
|
||||
orgsExcluded: v.fallback(calendarFiltersPlainStringArr, []),
|
||||
authorIdsExcluded: v.fallback(calendarFiltersIdsArr, []),
|
||||
games: v.fallback(calendarFilterGamesArr, [...gamesShort]),
|
||||
preferredVersus: v.fallback(preferredVersus, [...versusShort]),
|
||||
modes: v.fallback(modeArr, [...modesShortWithSpecial]),
|
||||
modesExact: v.fallback(v.boolean(), false),
|
||||
minTeamCount: v.fallback(
|
||||
v.pipe(coerceNumber(), v.integer(), v.minValue(0)),
|
||||
0,
|
||||
),
|
||||
minTier: v.fallback(tierNumber, BEST_TIER_NUMBER),
|
||||
maxTier: v.fallback(tierNumber, WORST_TIER_NUMBER),
|
||||
});
|
||||
|
||||
const TAGS_TO_OMIT: CalendarEventTag[] = [
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { z } from "zod";
|
||||
import * as v from "valibot";
|
||||
import {
|
||||
BEST_TIER_NUMBER,
|
||||
WORST_TIER_NUMBER,
|
||||
@@ -7,11 +7,7 @@ import { gamesShort, versusShort } from "~/modules/in-game-lists/games";
|
||||
import { modesShortWithSpecial } from "~/modules/in-game-lists/modes";
|
||||
import * as SearchParams from "~/modules/search-params/search-params";
|
||||
import { SP } from "~/modules/search-params/search-params";
|
||||
import {
|
||||
dayMonthYear,
|
||||
gamesShortSchema,
|
||||
modeShortWithSpecial,
|
||||
} from "~/utils/zod";
|
||||
import { gamesShortSchema, modeShortWithSpecial } from "~/utils/schema";
|
||||
import { calendarFilterTagsArr } from "./calendar-schemas";
|
||||
|
||||
export const VIEW_FILTERS = [
|
||||
@@ -23,27 +19,64 @@ export const VIEW_FILTERS = [
|
||||
] as const;
|
||||
export type ViewFilter = (typeof VIEW_FILTERS)[number];
|
||||
|
||||
const tierNumber = z
|
||||
.number()
|
||||
.int()
|
||||
.min(BEST_TIER_NUMBER)
|
||||
.max(WORST_TIER_NUMBER);
|
||||
const tierNumber = v.pipe(
|
||||
v.number(),
|
||||
v.integer(),
|
||||
v.minValue(BEST_TIER_NUMBER),
|
||||
v.maxValue(WORST_TIER_NUMBER),
|
||||
);
|
||||
|
||||
// plain number schemas (not `dayMonthYear`'s coercing ones) because SP.param
|
||||
// derives the URL encoding from the schema's base type and coerces itself
|
||||
const dayNumber = v.pipe(
|
||||
v.number(),
|
||||
v.integer(),
|
||||
v.minValue(1),
|
||||
v.maxValue(31),
|
||||
);
|
||||
const monthNumber = v.pipe(
|
||||
v.number(),
|
||||
v.integer(),
|
||||
v.minValue(0),
|
||||
v.maxValue(11),
|
||||
);
|
||||
const yearNumber = v.pipe(
|
||||
v.number(),
|
||||
v.integer(),
|
||||
v.minValue(2015),
|
||||
v.maxValue(2100),
|
||||
);
|
||||
|
||||
export const calendarSearchParams = SearchParams.define({
|
||||
modes: SP.param(
|
||||
z.array(modeShortWithSpecial).min(1).max(modesShortWithSpecial.length),
|
||||
v.pipe(
|
||||
v.array(modeShortWithSpecial),
|
||||
v.minLength(1),
|
||||
v.maxLength(modesShortWithSpecial.length),
|
||||
),
|
||||
{ default: [...modesShortWithSpecial], loader: true },
|
||||
),
|
||||
modesExact: SP.param(z.boolean(), { default: false, loader: true }),
|
||||
games: SP.param(z.array(gamesShortSchema).min(1).max(gamesShort.length), {
|
||||
default: [...gamesShort],
|
||||
loader: true,
|
||||
}),
|
||||
modesExact: SP.param(v.boolean(), { default: false, loader: true }),
|
||||
games: SP.param(
|
||||
v.pipe(
|
||||
v.array(gamesShortSchema),
|
||||
v.minLength(1),
|
||||
v.maxLength(gamesShort.length),
|
||||
),
|
||||
{
|
||||
default: [...gamesShort],
|
||||
loader: true,
|
||||
},
|
||||
),
|
||||
preferredVersus: SP.param(
|
||||
z.array(z.enum(versusShort)).min(1).max(versusShort.length),
|
||||
v.pipe(
|
||||
v.array(v.picklist(versusShort)),
|
||||
v.minLength(1),
|
||||
v.maxLength(versusShort.length),
|
||||
),
|
||||
{ default: [...versusShort], loader: true },
|
||||
),
|
||||
preferredStartTime: SP.param(z.enum(["ANY", "EU", "NA", "AU"]), {
|
||||
preferredStartTime: SP.param(v.picklist(["ANY", "EU", "NA", "AU"]), {
|
||||
default: "ANY",
|
||||
loader: true,
|
||||
}),
|
||||
@@ -55,43 +88,60 @@ export const calendarSearchParams = SearchParams.define({
|
||||
default: [],
|
||||
loader: true,
|
||||
}),
|
||||
isSendou: SP.param(z.boolean(), { default: false, loader: true }),
|
||||
isRanked: SP.param(z.boolean(), { default: false, loader: true }),
|
||||
minTeamCount: SP.param(z.number().int().nonnegative(), {
|
||||
isSendou: SP.param(v.boolean(), { default: false, loader: true }),
|
||||
isRanked: SP.param(v.boolean(), { default: false, loader: true }),
|
||||
minTeamCount: SP.param(v.pipe(v.number(), v.integer(), v.minValue(0)), {
|
||||
default: 0,
|
||||
loader: true,
|
||||
}),
|
||||
minTier: SP.param(tierNumber, { default: BEST_TIER_NUMBER, loader: true }),
|
||||
maxTier: SP.param(tierNumber, { default: WORST_TIER_NUMBER, loader: true }),
|
||||
orgsIncluded: SP.param(z.array(z.string().max(100)).max(10), {
|
||||
default: [],
|
||||
loader: true,
|
||||
}),
|
||||
orgsExcluded: SP.param(z.array(z.string().max(100)).max(10), {
|
||||
default: [],
|
||||
loader: true,
|
||||
}),
|
||||
authorIdsExcluded: SP.param(z.array(z.number().int().positive()).max(10), {
|
||||
default: [],
|
||||
loader: true,
|
||||
}),
|
||||
orgsIncluded: SP.param(
|
||||
v.pipe(v.array(v.pipe(v.string(), v.maxLength(100))), v.maxLength(10)),
|
||||
{
|
||||
default: [],
|
||||
loader: true,
|
||||
},
|
||||
),
|
||||
orgsExcluded: SP.param(
|
||||
v.pipe(v.array(v.pipe(v.string(), v.maxLength(100))), v.maxLength(10)),
|
||||
{
|
||||
default: [],
|
||||
loader: true,
|
||||
},
|
||||
),
|
||||
authorIdsExcluded: SP.param(
|
||||
v.pipe(
|
||||
v.array(v.pipe(v.number(), v.integer(), v.gtValue(0))),
|
||||
v.maxLength(10),
|
||||
),
|
||||
{
|
||||
default: [],
|
||||
loader: true,
|
||||
},
|
||||
),
|
||||
/** False once the user has edited the filters, making the URL win over their saved defaults. */
|
||||
useDefaults: SP.param(z.boolean(), { default: true, loader: true }),
|
||||
day: SP.param(dayMonthYear.shape.day.nullable(), { loader: true }),
|
||||
month: SP.param(dayMonthYear.shape.month.nullable(), { loader: true }),
|
||||
year: SP.param(dayMonthYear.shape.year.nullable(), { loader: true }),
|
||||
useDefaults: SP.param(v.boolean(), { default: true, loader: true }),
|
||||
day: SP.param(v.nullable(dayNumber), { loader: true }),
|
||||
month: SP.param(v.nullable(monthNumber), { loader: true }),
|
||||
year: SP.param(v.nullable(yearNumber), { loader: true }),
|
||||
});
|
||||
|
||||
export const calendarEventsSearchParams = SearchParams.define({
|
||||
view: SP.param(z.enum(VIEW_FILTERS).nullable(), { loader: false }),
|
||||
view: SP.param(v.nullable(v.picklist(VIEW_FILTERS)), { loader: false }),
|
||||
});
|
||||
|
||||
export const calendarNewSearchParams = SearchParams.define({
|
||||
eventId: SP.param(z.number().int().positive().nullable(), { loader: true }),
|
||||
copyEventId: SP.param(z.number().int().positive().nullable(), {
|
||||
eventId: SP.param(v.nullable(v.pipe(v.number(), v.integer(), v.gtValue(0))), {
|
||||
loader: true,
|
||||
}),
|
||||
tournament: SP.param(z.boolean(), {
|
||||
copyEventId: SP.param(
|
||||
v.nullable(v.pipe(v.number(), v.integer(), v.gtValue(0))),
|
||||
{
|
||||
loader: true,
|
||||
},
|
||||
),
|
||||
tournament: SP.param(v.boolean(), {
|
||||
default: false,
|
||||
loader: true,
|
||||
}),
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { z } from "zod";
|
||||
import type * as v from "valibot";
|
||||
import type { Tables } from "~/db/tables";
|
||||
import type { tags } from "~/features/calendar/calendar-constants";
|
||||
import type { calendarFiltersSearchParamsSchema } from "~/features/calendar/calendar-schemas";
|
||||
@@ -73,4 +73,6 @@ export interface GroupedCalendarEvents {
|
||||
};
|
||||
}
|
||||
|
||||
export type CalendarFilters = z.infer<typeof calendarFiltersSearchParamsSchema>;
|
||||
export type CalendarFilters = v.InferOutput<
|
||||
typeof calendarFiltersSearchParamsSchema
|
||||
>;
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { DayMonthYear } from "~/utils/schema";
|
||||
import { CALENDAR_PAGE, SENDOU_INK_BASE_URL } from "~/utils/urls";
|
||||
import type { DayMonthYear } from "~/utils/zod";
|
||||
import {
|
||||
calendarNewSearchParams,
|
||||
calendarSearchParams,
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { addDays, addWeeks, startOfWeek, subWeeks } from "date-fns";
|
||||
import { logger } from "~/utils/logger";
|
||||
import type { DayMonthYear } from "~/utils/schema";
|
||||
import { assertUnreachable } from "~/utils/types";
|
||||
import type { DayMonthYear } from "~/utils/zod";
|
||||
import {
|
||||
DAYS_SHOWN_AT_A_TIME,
|
||||
type RegClosesAtOption,
|
||||
|
||||
@@ -2,7 +2,7 @@ import type { LoaderFunctionArgs } from "react-router";
|
||||
import * as CalendarRepository from "~/features/calendar/CalendarRepository.server";
|
||||
import { requirePermission } from "~/modules/permissions/guards.server";
|
||||
import { notFoundIfNullish, parseParams } from "~/utils/remix.server";
|
||||
import { idObject } from "~/utils/zod";
|
||||
import { idObject } from "~/utils/schema";
|
||||
|
||||
export const loader = async (args: LoaderFunctionArgs) => {
|
||||
const params = parseParams({
|
||||
|
||||
@@ -2,8 +2,8 @@ import type { LoaderFunctionArgs } from "react-router";
|
||||
import { redirect } from "react-router";
|
||||
import * as CalendarRepository from "~/features/calendar/CalendarRepository.server";
|
||||
import { notFoundIfNullish, parseParams } from "~/utils/remix.server";
|
||||
import { idObject } from "~/utils/schema";
|
||||
import { tournamentPage } from "~/utils/urls";
|
||||
import { idObject } from "~/utils/zod";
|
||||
|
||||
export const loader = async (args: LoaderFunctionArgs) => {
|
||||
const params = parseParams({
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { add, startOfWeek, sub } from "date-fns";
|
||||
import type { LoaderFunctionArgs } from "react-router";
|
||||
import * as R from "remeda";
|
||||
import * as v from "valibot";
|
||||
import type { UserPreferences } from "~/db/tables-json";
|
||||
import { getUser } from "~/features/auth/core/user.server";
|
||||
import { DAYS_SHOWN_AT_A_TIME } from "~/features/calendar/calendar-constants";
|
||||
@@ -47,7 +48,8 @@ export const loader = async (args: LoaderFunctionArgs) => {
|
||||
!R.isDeepEqual(
|
||||
filters,
|
||||
user.preferences?.defaultCalendarFilters
|
||||
? calendarFiltersSearchParamsSchema.parse(
|
||||
? v.parse(
|
||||
calendarFiltersSearchParamsSchema,
|
||||
user.preferences.defaultCalendarFilters,
|
||||
)
|
||||
: CalendarEvent.defaultFilters(),
|
||||
@@ -96,7 +98,8 @@ function resolveFilters(
|
||||
|
||||
if (preferences?.defaultCalendarFilters) {
|
||||
// make sure the saved values still match current reality
|
||||
const parsedDefault = calendarFiltersSearchParamsSchema.parse(
|
||||
const parsedDefault = v.parse(
|
||||
calendarFiltersSearchParamsSchema,
|
||||
preferences.defaultCalendarFilters,
|
||||
);
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { LoaderFunctionArgs } from "react-router";
|
||||
import * as R from "remeda";
|
||||
import { safeJSONParse } from "~/utils/zod";
|
||||
import * as v from "valibot";
|
||||
import { safeJSONParse } from "~/utils/schema";
|
||||
import * as CalendarRepository from "../CalendarRepository.server";
|
||||
import { calendarFiltersSearchParamsSchema } from "../calendar-schemas";
|
||||
import { calendarSearchParams } from "../calendar-search-params";
|
||||
@@ -48,10 +49,11 @@ function resolveFilters(request: Request) {
|
||||
// biome-ignore lint/plugin: legacy param no current route produces
|
||||
const legacyFilters = new URL(request.url).searchParams.get("filters");
|
||||
if (legacyFilters !== null) {
|
||||
const parsed = calendarFiltersSearchParamsSchema.safeParse(
|
||||
const parsed = v.safeParse(
|
||||
calendarFiltersSearchParamsSchema,
|
||||
safeJSONParse(legacyFilters),
|
||||
);
|
||||
if (parsed.success) return parsed.data;
|
||||
if (parsed.success) return parsed.output;
|
||||
}
|
||||
|
||||
return R.pick(calendarSearchParams.parse(request), [
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { addDays } from "date-fns";
|
||||
import type * as v from "valibot";
|
||||
import { describe, expect, test } from "vitest";
|
||||
import type { z } from "zod";
|
||||
import * as TournamentFactory from "~/db/seed/factories/TournamentFactory";
|
||||
import * as UserFactory from "~/db/seed/factories/UserFactory";
|
||||
import * as CalendarRepository from "~/features/calendar/CalendarRepository.server";
|
||||
@@ -8,7 +8,7 @@ import { dateToDatabaseTimestamp } from "~/utils/dates";
|
||||
import { wrappedAction } from "~/utils/Test";
|
||||
import { action } from "./calendar.$id";
|
||||
|
||||
const deleteAction = wrappedAction<z.ZodType<Record<string, never>>>({
|
||||
const deleteAction = wrappedAction<v.GenericSchema<Record<string, never>>>({
|
||||
action,
|
||||
});
|
||||
|
||||
|
||||
@@ -30,8 +30,8 @@ import { useSearchParamsTyped } from "~/modules/search-params/hooks";
|
||||
import { dayMonthYearToDateValue } from "~/utils/dates";
|
||||
import { metaTags } from "~/utils/remix";
|
||||
import type { SendouRouteHandle } from "~/utils/remix.server";
|
||||
import type { DayMonthYear } from "~/utils/schema";
|
||||
import { CALENDAR_PAGE, navIconUrl } from "~/utils/urls";
|
||||
import type { DayMonthYear } from "~/utils/zod";
|
||||
import { action } from "../actions/calendar";
|
||||
import { daysForCalendar } from "../calendar-utils";
|
||||
import { FiltersBar } from "../components/FiltersBar";
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import { z } from "zod";
|
||||
import * as v from "valibot";
|
||||
import { usePersistedMapState } from "~/modules/persisted-state/hooks";
|
||||
import * as PersistedState from "~/modules/persisted-state/persisted-state";
|
||||
|
||||
export const lastReadCountsPersisted = PersistedState.defineMap({
|
||||
keyPrefix: "chat_read__",
|
||||
storage: "local",
|
||||
schema: z.number(),
|
||||
schema: v.number(),
|
||||
default: 0,
|
||||
});
|
||||
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { z } from "zod";
|
||||
import * as v from "valibot";
|
||||
import * as SearchParams from "~/modules/search-params/search-params";
|
||||
import { SP } from "~/modules/search-params/search-params";
|
||||
|
||||
export const chatUsersSearchParams = SearchParams.define({
|
||||
ids: SP.param(z.array(z.number().int().positive()), {
|
||||
ids: SP.param(v.array(v.pipe(v.number(), v.integer(), v.gtValue(0))), {
|
||||
default: [],
|
||||
loader: true,
|
||||
}),
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { z } from "zod";
|
||||
import * as v from "valibot";
|
||||
import { MAX_AP } from "~/features/build-analyzer/analyzer-constants";
|
||||
import { mainWeaponIds } from "~/modules/in-game-lists/weapon-ids";
|
||||
import * as SearchParams from "~/modules/search-params/search-params";
|
||||
import { SP } from "~/modules/search-params/search-params";
|
||||
import { numericEnum } from "~/utils/zod";
|
||||
import { numericEnum } from "~/utils/schema";
|
||||
import { MAX_WEAPONS } from "./comp-analyzer-constants";
|
||||
import type { CategorizationType } from "./comp-analyzer-types";
|
||||
|
||||
@@ -14,21 +14,30 @@ const CATEGORIZATION_TYPES = [
|
||||
] as const satisfies readonly CategorizationType[];
|
||||
|
||||
export const compAnalyzerSearchParams = SearchParams.define({
|
||||
categorization: SP.param(z.enum(CATEGORIZATION_TYPES), {
|
||||
categorization: SP.param(v.picklist(CATEGORIZATION_TYPES), {
|
||||
default: "category",
|
||||
loader: false,
|
||||
}),
|
||||
weapons: SP.param(z.array(numericEnum(mainWeaponIds)).max(MAX_WEAPONS), {
|
||||
default: [],
|
||||
loader: false,
|
||||
}),
|
||||
singleCombos: SP.param(z.boolean(), { default: false, loader: false }),
|
||||
subDef: SP.param(z.number().int().min(0).max(MAX_AP), {
|
||||
default: 0,
|
||||
loader: false,
|
||||
}),
|
||||
res: SP.param(z.number().int().min(0).max(MAX_AP), {
|
||||
default: 0,
|
||||
loader: false,
|
||||
}),
|
||||
weapons: SP.param(
|
||||
v.pipe(v.array(numericEnum(mainWeaponIds)), v.maxLength(MAX_WEAPONS)),
|
||||
{
|
||||
default: [],
|
||||
loader: false,
|
||||
},
|
||||
),
|
||||
singleCombos: SP.param(v.boolean(), { default: false, loader: false }),
|
||||
subDef: SP.param(
|
||||
v.pipe(v.number(), v.integer(), v.minValue(0), v.maxValue(MAX_AP)),
|
||||
{
|
||||
default: 0,
|
||||
loader: false,
|
||||
},
|
||||
),
|
||||
res: SP.param(
|
||||
v.pipe(v.number(), v.integer(), v.minValue(0), v.maxValue(MAX_AP)),
|
||||
{
|
||||
default: 0,
|
||||
loader: false,
|
||||
},
|
||||
),
|
||||
});
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { z } from "zod";
|
||||
import * as v from "valibot";
|
||||
import {
|
||||
checkboxGroup,
|
||||
customField,
|
||||
@@ -24,7 +24,7 @@ import {
|
||||
weaponSelectOptional,
|
||||
} from "~/form/fields";
|
||||
|
||||
export const formFieldsShowcaseSchema = z.object({
|
||||
export const formFieldsShowcaseSchema = v.object({
|
||||
// Text fields
|
||||
requiredText: textField({
|
||||
label: "labels.name",
|
||||
@@ -162,6 +162,6 @@ export const formFieldsShowcaseSchema = z.object({
|
||||
// Custom field
|
||||
customValue: customField(
|
||||
{ initialValue: "custom initial value" },
|
||||
z.string().optional(),
|
||||
v.optional(v.string()),
|
||||
),
|
||||
});
|
||||
|
||||
@@ -2746,7 +2746,7 @@ function FormFieldsSection({ id }: { id: string }) {
|
||||
<SectionTitle id={id}>Form Fields</SectionTitle>
|
||||
<p className="mb-4" style={{ fontSize: "var(--font-sm)", opacity: 0.8 }}>
|
||||
Schema-based form fields using SendouForm. Each field type is defined
|
||||
with Zod schemas that generate both UI and validation.
|
||||
with valibot schemas that generate both UI and validation.
|
||||
</p>
|
||||
|
||||
<SendouForm
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { z } from "zod";
|
||||
import * as v from "valibot";
|
||||
import { requireUser } from "~/features/auth/core/user.server";
|
||||
import { superRefineAsync } from "~/utils/schema";
|
||||
import * as FriendRepository from "./FriendRepository.server";
|
||||
import {
|
||||
acceptFriendRequestSchema,
|
||||
@@ -9,13 +10,13 @@ import {
|
||||
sendFriendRequestBaseSchema,
|
||||
} from "./friends-schemas";
|
||||
|
||||
const sendFriendRequestSchemaServer = sendFriendRequestBaseSchema.superRefine(
|
||||
async (data, ctx) => {
|
||||
const sendFriendRequestSchemaServer = v.pipeAsync(
|
||||
sendFriendRequestBaseSchema,
|
||||
superRefineAsync(async (data, ctx) => {
|
||||
const user = requireUser();
|
||||
|
||||
if (data.userId === user.id) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: "forms:errors.cannotFriendSelf",
|
||||
path: ["userId"],
|
||||
});
|
||||
@@ -28,7 +29,6 @@ const sendFriendRequestSchemaServer = sendFriendRequestBaseSchema.superRefine(
|
||||
});
|
||||
if (existingFriendship) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: "forms:errors.alreadyFriends",
|
||||
path: ["userId"],
|
||||
});
|
||||
@@ -41,15 +41,14 @@ const sendFriendRequestSchemaServer = sendFriendRequestBaseSchema.superRefine(
|
||||
});
|
||||
if (existingRequest) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: "forms:errors.friendRequestExists",
|
||||
path: ["userId"],
|
||||
});
|
||||
}
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
export const friendsActionSchema = z.union([
|
||||
export const friendsActionSchema = v.unionAsync([
|
||||
sendFriendRequestSchemaServer,
|
||||
cancelFriendRequestSchema,
|
||||
deleteFriendSchema,
|
||||
|
||||
@@ -1,28 +1,28 @@
|
||||
import { z } from "zod";
|
||||
import * as v from "valibot";
|
||||
import { stringConstant, userSearch } from "~/form/fields";
|
||||
import { _action, id } from "~/utils/zod";
|
||||
import { _action, id } from "~/utils/schema";
|
||||
|
||||
export const sendFriendRequestBaseSchema = z.object({
|
||||
export const sendFriendRequestBaseSchema = v.object({
|
||||
_action: stringConstant("SEND_REQUEST"),
|
||||
userId: userSearch({ label: "labels.friendUser" }),
|
||||
});
|
||||
|
||||
export const cancelFriendRequestSchema = z.object({
|
||||
export const cancelFriendRequestSchema = v.object({
|
||||
_action: _action("CANCEL_REQUEST"),
|
||||
friendRequestId: id,
|
||||
});
|
||||
|
||||
export const deleteFriendSchema = z.object({
|
||||
export const deleteFriendSchema = v.object({
|
||||
_action: _action("DELETE_FRIEND"),
|
||||
friendshipId: id,
|
||||
});
|
||||
|
||||
export const acceptFriendRequestSchema = z.object({
|
||||
export const acceptFriendRequestSchema = v.object({
|
||||
_action: _action("ACCEPT_REQUEST"),
|
||||
friendRequestId: id,
|
||||
});
|
||||
|
||||
export const declineFriendRequestSchema = z.object({
|
||||
export const declineFriendRequestSchema = v.object({
|
||||
_action: _action("DECLINE_REQUEST"),
|
||||
friendRequestId: id,
|
||||
});
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { z } from "zod";
|
||||
import * as v from "valibot";
|
||||
import * as SearchParams from "~/modules/search-params/search-params";
|
||||
import { SP } from "~/modules/search-params/search-params";
|
||||
|
||||
@@ -6,5 +6,5 @@ export const VIEW_FILTERS = ["friends", "team", "all"] as const;
|
||||
export type ViewFilter = (typeof VIEW_FILTERS)[number];
|
||||
|
||||
export const friendsSearchParams = SearchParams.define({
|
||||
view: SP.param(z.enum(VIEW_FILTERS).nullable(), { loader: false }),
|
||||
view: SP.param(v.nullable(v.picklist(VIEW_FILTERS)), { loader: false }),
|
||||
});
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { Download } from "lucide-react";
|
||||
import * as React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { z } from "zod";
|
||||
import * as v from "valibot";
|
||||
import { SendouButton } from "~/components/elements/Button";
|
||||
import { SendouDialog } from "~/components/elements/Dialog";
|
||||
import { usePersistedState } from "~/modules/persisted-state/hooks";
|
||||
@@ -20,7 +20,7 @@ const SAFARI_MIN_INSTALL_VERSION = 17;
|
||||
const bannerDismissedPersisted = PersistedState.define({
|
||||
key: "pwa-install-banner-dismissed",
|
||||
storage: "local",
|
||||
schema: z.boolean(),
|
||||
schema: v.boolean(),
|
||||
default: false,
|
||||
});
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { formatDistance } from "date-fns";
|
||||
import { z } from "zod";
|
||||
import * as v from "valibot";
|
||||
import { logger } from "~/utils/logger";
|
||||
|
||||
const BSKY_URL =
|
||||
@@ -7,48 +7,60 @@ const BSKY_URL =
|
||||
|
||||
const CHANGE_LOG_ITEMS_MAX = 6;
|
||||
|
||||
const postsSchema = z.object({
|
||||
feed: z.array(
|
||||
z.object({
|
||||
post: z.object({
|
||||
uri: z.string(),
|
||||
record: z.object({
|
||||
$type: z.string(),
|
||||
createdAt: z.string(),
|
||||
facets: z
|
||||
.array(
|
||||
z.object({
|
||||
features: z.array(
|
||||
z.object({ $type: z.string(), tag: z.string().nullish() }),
|
||||
),
|
||||
index: z.object({ byteEnd: z.number(), byteStart: z.number() }),
|
||||
}),
|
||||
)
|
||||
.nullish(),
|
||||
text: z.string(),
|
||||
}),
|
||||
embed: z
|
||||
.object({
|
||||
$type: z.string(),
|
||||
images: z
|
||||
.array(
|
||||
z.object({
|
||||
thumb: z.string(),
|
||||
fullsize: z.string(),
|
||||
alt: z.string(),
|
||||
aspectRatio: z.object({
|
||||
height: z.number(),
|
||||
width: z.number(),
|
||||
const postsSchema = v.object({
|
||||
feed: v.array(
|
||||
v.object({
|
||||
post: v.object({
|
||||
uri: v.string(),
|
||||
record: v.object({
|
||||
$type: v.string(),
|
||||
createdAt: v.string(),
|
||||
facets: v.optional(
|
||||
v.nullable(
|
||||
v.array(
|
||||
v.object({
|
||||
features: v.array(
|
||||
v.object({
|
||||
$type: v.string(),
|
||||
tag: v.nullish(v.string()),
|
||||
}),
|
||||
),
|
||||
index: v.object({
|
||||
byteEnd: v.number(),
|
||||
byteStart: v.number(),
|
||||
}),
|
||||
}),
|
||||
)
|
||||
.nullish(),
|
||||
})
|
||||
.nullish(),
|
||||
replyCount: z.number(),
|
||||
repostCount: z.number(),
|
||||
likeCount: z.number(),
|
||||
quoteCount: z.number(),
|
||||
),
|
||||
),
|
||||
),
|
||||
text: v.string(),
|
||||
}),
|
||||
embed: v.optional(
|
||||
v.nullable(
|
||||
v.object({
|
||||
$type: v.string(),
|
||||
images: v.optional(
|
||||
v.nullable(
|
||||
v.array(
|
||||
v.object({
|
||||
thumb: v.string(),
|
||||
fullsize: v.string(),
|
||||
alt: v.string(),
|
||||
aspectRatio: v.object({
|
||||
height: v.number(),
|
||||
width: v.number(),
|
||||
}),
|
||||
}),
|
||||
),
|
||||
),
|
||||
),
|
||||
}),
|
||||
),
|
||||
),
|
||||
replyCount: v.number(),
|
||||
repostCount: v.number(),
|
||||
likeCount: v.number(),
|
||||
quoteCount: v.number(),
|
||||
}),
|
||||
}),
|
||||
),
|
||||
@@ -73,7 +85,7 @@ export async function get() {
|
||||
return result;
|
||||
}
|
||||
|
||||
type RawPost = z.infer<typeof postsSchema>["feed"][number]["post"];
|
||||
type RawPost = v.InferOutput<typeof postsSchema>["feed"][number]["post"];
|
||||
|
||||
export interface ChangelogItem {
|
||||
id: string;
|
||||
@@ -107,12 +119,12 @@ async function fetchPosts() {
|
||||
}
|
||||
|
||||
function parsePosts(data: unknown) {
|
||||
const result = postsSchema.safeParse(data);
|
||||
const result = v.safeParse(postsSchema, data);
|
||||
if (!result.success) {
|
||||
throw new Error(`Failed to parse posts: ${result.error.message}`);
|
||||
throw new Error(`Failed to parse posts: ${v.summarize(result.issues)}`);
|
||||
}
|
||||
|
||||
return result.data.feed.map((feed) => feed.post);
|
||||
return result.output.feed.map((feed) => feed.post);
|
||||
}
|
||||
|
||||
function postHasSendouInkTag(post: RawPost) {
|
||||
|
||||
@@ -1,14 +1,17 @@
|
||||
import { z } from "zod";
|
||||
import { _action, id, safeJSONParse } from "~/utils/zod";
|
||||
import * as v from "valibot";
|
||||
import { _action, id, preprocess, safeJSONParse } from "~/utils/schema";
|
||||
|
||||
const validateManySchema = z.object({
|
||||
const validateManySchema = v.object({
|
||||
_action: _action("VALIDATE"),
|
||||
imageIds: z.preprocess(safeJSONParse, z.array(id).min(1).max(5)),
|
||||
imageIds: preprocess(
|
||||
safeJSONParse,
|
||||
v.pipe(v.array(id), v.minLength(1), v.maxLength(5)),
|
||||
),
|
||||
});
|
||||
|
||||
const rejectSchema = z.object({
|
||||
const rejectSchema = v.object({
|
||||
_action: _action("REJECT"),
|
||||
imageId: id,
|
||||
});
|
||||
|
||||
export const validateImageSchema = z.union([validateManySchema, rejectSchema]);
|
||||
export const validateImageSchema = v.union([validateManySchema, rejectSchema]);
|
||||
|
||||
@@ -1,21 +1,22 @@
|
||||
import { z } from "zod";
|
||||
import * as v from "valibot";
|
||||
import type { SkillTeamIdentifier } from "~/features/mmr/mmr-utils";
|
||||
import { _action } from "~/utils/zod";
|
||||
import { _action, coerceNumber } from "~/utils/schema";
|
||||
|
||||
const teamLeaderboardEntry = {
|
||||
season: z.coerce.number().int().nonnegative(),
|
||||
identifier: z
|
||||
.string()
|
||||
.regex(/^\d+-\d+-\d+-\d+$/)
|
||||
.pipe(z.custom<SkillTeamIdentifier>()),
|
||||
season: v.pipe(coerceNumber(), v.integer(), v.minValue(0)),
|
||||
identifier: v.pipe(
|
||||
v.string(),
|
||||
v.regex(/^\d+-\d+-\d+-\d+$/),
|
||||
v.custom<SkillTeamIdentifier>(() => true),
|
||||
),
|
||||
};
|
||||
|
||||
export const leaderboardsActionSchema = z.union([
|
||||
z.object({
|
||||
export const leaderboardsActionSchema = v.union([
|
||||
v.object({
|
||||
_action: _action("SKIP_TEAM"),
|
||||
...teamLeaderboardEntry,
|
||||
}),
|
||||
z.object({
|
||||
v.object({
|
||||
_action: _action("UNSKIP_TEAM"),
|
||||
...teamLeaderboardEntry,
|
||||
}),
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
import { z } from "zod";
|
||||
import * as v from "valibot";
|
||||
import * as SearchParams from "~/modules/search-params/search-params";
|
||||
import { SP } from "~/modules/search-params/search-params";
|
||||
import { LEADERBOARD_TYPES } from "./leaderboards-constants";
|
||||
|
||||
export const leaderboardsSearchParams = SearchParams.define({
|
||||
type: SP.param(z.enum(LEADERBOARD_TYPES), {
|
||||
type: SP.param(v.picklist(LEADERBOARD_TYPES), {
|
||||
default: LEADERBOARD_TYPES[0],
|
||||
loader: true,
|
||||
}),
|
||||
season: SP.param(z.number().int().nullable(), { loader: true }),
|
||||
season: SP.param(v.nullable(v.pipe(v.number(), v.integer())), {
|
||||
loader: true,
|
||||
}),
|
||||
});
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { z } from "zod";
|
||||
import * as v from "valibot";
|
||||
import { LANGUAGE_OPTIONS } from "~/features/settings/match-profile-schemas";
|
||||
import {
|
||||
checkboxGroup,
|
||||
@@ -7,11 +7,11 @@ import {
|
||||
selectDynamicOptional,
|
||||
textArea,
|
||||
} from "~/form/fields";
|
||||
import { _action, id } from "~/utils/zod";
|
||||
import { _action, id, superRefine } from "~/utils/schema";
|
||||
import { LFG, TIMEZONES } from "./lfg-constants";
|
||||
|
||||
export const lfgNewSchema = z
|
||||
.object({
|
||||
export const lfgNewSchema = v.pipe(
|
||||
v.object({
|
||||
postId: idConstantOptional(),
|
||||
type: selectDynamic({ label: "labels.type" }),
|
||||
timezone: selectDynamic({ label: "labels.timezone" }),
|
||||
@@ -26,25 +26,30 @@ export const lfgNewSchema = z
|
||||
label: "labels.languages",
|
||||
items: LANGUAGE_OPTIONS,
|
||||
}),
|
||||
})
|
||||
.refine(
|
||||
(data) => LFG.types.includes(data.type as (typeof LFG.types)[number]),
|
||||
{
|
||||
message: "Invalid LFG type",
|
||||
path: ["type"],
|
||||
},
|
||||
)
|
||||
.refine((data) => TIMEZONES.includes(data.timezone), {
|
||||
message: "Invalid timezone",
|
||||
path: ["timezone"],
|
||||
});
|
||||
}),
|
||||
superRefine((data, ctx) => {
|
||||
if (!LFG.types.includes(data.type as (typeof LFG.types)[number])) {
|
||||
ctx.addIssue({
|
||||
message: "Invalid LFG type",
|
||||
path: ["type"],
|
||||
});
|
||||
}
|
||||
|
||||
export const lfgActionSchema = z.union([
|
||||
z.object({
|
||||
if (!TIMEZONES.includes(data.timezone)) {
|
||||
ctx.addIssue({
|
||||
message: "Invalid timezone",
|
||||
path: ["timezone"],
|
||||
});
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
export const lfgActionSchema = v.union([
|
||||
v.object({
|
||||
_action: _action("DELETE_POST"),
|
||||
id,
|
||||
}),
|
||||
z.object({
|
||||
v.object({
|
||||
_action: _action("BUMP_POST"),
|
||||
id,
|
||||
}),
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { z } from "zod";
|
||||
import * as v from "valibot";
|
||||
import { TIERS, type TierName } from "~/features/mmr/mmr-constants";
|
||||
import {
|
||||
languagesUnified,
|
||||
@@ -7,7 +7,7 @@ import {
|
||||
import { mainWeaponIds } from "~/modules/in-game-lists/weapon-ids";
|
||||
import * as SearchParams from "~/modules/search-params/search-params";
|
||||
import { SP } from "~/modules/search-params/search-params";
|
||||
import { numericEnum } from "~/utils/zod";
|
||||
import { numericEnum } from "~/utils/schema";
|
||||
import { LFG, LFG_TYPES } from "./lfg-constants";
|
||||
|
||||
const LANGUAGE_CODES = languagesUnified.map((language) => language.code) as [
|
||||
@@ -21,22 +21,32 @@ const FILTER_OPTIONS = { loader: true, resets: ["page", "post"] };
|
||||
export const lfgSearchParams = SearchParams.define({
|
||||
page: SP.page({ resets: ["post"] }),
|
||||
/** Post to jump to: the loader serves the page containing it, overriding `page`. */
|
||||
post: SP.param(z.number().int().positive().nullable(), { loader: true }),
|
||||
post: SP.param(v.nullable(v.pipe(v.number(), v.integer(), v.gtValue(0))), {
|
||||
loader: true,
|
||||
}),
|
||||
weapons: SP.param(
|
||||
z.array(numericEnum(mainWeaponIds)).max(LFG.MAX_WEAPON_FILTERS),
|
||||
v.pipe(
|
||||
v.array(numericEnum(mainWeaponIds)),
|
||||
v.maxLength(LFG.MAX_WEAPON_FILTERS),
|
||||
),
|
||||
{ default: [], ...FILTER_OPTIONS },
|
||||
),
|
||||
type: SP.param(z.enum(LFG_TYPES).nullable(), FILTER_OPTIONS),
|
||||
type: SP.param(v.nullable(v.picklist(LFG_TYPES)), FILTER_OPTIONS),
|
||||
timezone: SP.param(
|
||||
z.number().int().min(0).max(12).nullable(),
|
||||
v.nullable(v.pipe(v.number(), v.integer(), v.minValue(0), v.maxValue(12))),
|
||||
FILTER_OPTIONS,
|
||||
),
|
||||
language: SP.param(z.enum(LANGUAGE_CODES).nullable(), FILTER_OPTIONS),
|
||||
plusTier: SP.param(z.number().int().min(1).max(3).nullable(), FILTER_OPTIONS),
|
||||
minTier: SP.param(z.enum(TIER_NAMES).nullable(), FILTER_OPTIONS),
|
||||
maxTier: SP.param(z.enum(TIER_NAMES).nullable(), FILTER_OPTIONS),
|
||||
language: SP.param(v.nullable(v.picklist(LANGUAGE_CODES)), FILTER_OPTIONS),
|
||||
plusTier: SP.param(
|
||||
v.nullable(v.pipe(v.number(), v.integer(), v.minValue(1), v.maxValue(3))),
|
||||
FILTER_OPTIONS,
|
||||
),
|
||||
minTier: SP.param(v.nullable(v.picklist(TIER_NAMES)), FILTER_OPTIONS),
|
||||
maxTier: SP.param(v.nullable(v.picklist(TIER_NAMES)), FILTER_OPTIONS),
|
||||
});
|
||||
|
||||
export const lfgNewSearchParams = SearchParams.define({
|
||||
postId: SP.param(z.number().int().positive().nullable(), { loader: true }),
|
||||
postId: SP.param(v.nullable(v.pipe(v.number(), v.integer(), v.gtValue(0))), {
|
||||
loader: true,
|
||||
}),
|
||||
});
|
||||
|
||||
@@ -1,17 +1,17 @@
|
||||
import { z } from "zod";
|
||||
import * as v from "valibot";
|
||||
import * as SearchParams from "~/modules/search-params/search-params";
|
||||
import { SP } from "~/modules/search-params/search-params";
|
||||
import { MapPool } from "./core/map-pool";
|
||||
|
||||
export const mapListGeneratorSearchParams = SearchParams.define({
|
||||
pool: SP.param(z.string(), {
|
||||
pool: SP.param(v.string(), {
|
||||
default: MapPool.ANARCHY.serialized,
|
||||
resets: ["eventId"],
|
||||
loader: false,
|
||||
}),
|
||||
eventId: SP.param(z.number().int().positive().nullable(), {
|
||||
eventId: SP.param(v.nullable(v.pipe(v.number(), v.integer(), v.gtValue(0))), {
|
||||
resets: ["pool"],
|
||||
loader: false,
|
||||
}),
|
||||
readonly: SP.param(z.boolean(), { default: false, loader: false }),
|
||||
readonly: SP.param(v.boolean(), { default: false, loader: false }),
|
||||
});
|
||||
|
||||
@@ -1,15 +1,24 @@
|
||||
import { z } from "zod";
|
||||
import { id } from "~/utils/zod";
|
||||
import * as v from "valibot";
|
||||
import { id } from "~/utils/schema";
|
||||
import { NOTIFICATIONS } from "./notifications-contants";
|
||||
|
||||
export const markAsSeenActionSchema = z.object({
|
||||
notificationIds: z.array(id).min(1).max(NOTIFICATIONS.MAX_SHOWN),
|
||||
export const markAsSeenActionSchema = v.object({
|
||||
notificationIds: v.pipe(
|
||||
v.array(id),
|
||||
v.minLength(1),
|
||||
v.maxLength(NOTIFICATIONS.MAX_SHOWN),
|
||||
),
|
||||
});
|
||||
|
||||
export const subscribeSchema = z.object({
|
||||
endpoint: z.string().url().startsWith("https://").max(2048),
|
||||
keys: z.object({
|
||||
auth: z.string().max(1024),
|
||||
p256dh: z.string().max(1024),
|
||||
export const subscribeSchema = v.object({
|
||||
endpoint: v.pipe(
|
||||
v.string(),
|
||||
v.url(),
|
||||
v.startsWith("https://"),
|
||||
v.maxLength(2048),
|
||||
),
|
||||
keys: v.object({
|
||||
auth: v.pipe(v.string(), v.maxLength(1024)),
|
||||
p256dh: v.pipe(v.string(), v.maxLength(1024)),
|
||||
}),
|
||||
});
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { z } from "zod";
|
||||
import * as v from "valibot";
|
||||
import {
|
||||
DAMAGE_TYPE,
|
||||
possibleApValues,
|
||||
@@ -18,40 +18,39 @@ import {
|
||||
weaponCategories,
|
||||
} from "~/modules/in-game-lists/weapon-ids";
|
||||
import * as SearchParams from "~/modules/search-params/search-params";
|
||||
import { SP } from "~/modules/search-params/search-params";
|
||||
import { codec, SP } from "~/modules/search-params/search-params";
|
||||
|
||||
const DEFAULT_ANY_WEAPON: AnyWeapon = {
|
||||
type: "MAIN",
|
||||
id: weaponCategories[0].weaponIds[0],
|
||||
};
|
||||
|
||||
const anyWeapon = z.codec(z.string(), z.custom<AnyWeapon>(), {
|
||||
decode: (value, payload) => {
|
||||
const decoded = decodeAnyWeapon(value);
|
||||
if (!decoded) {
|
||||
payload.issues.push({
|
||||
code: "custom",
|
||||
message: "Invalid weapon",
|
||||
input: value,
|
||||
});
|
||||
return z.NEVER;
|
||||
}
|
||||
return decoded;
|
||||
const anyWeapon = codec(
|
||||
v.custom<AnyWeapon>(() => true),
|
||||
{
|
||||
decode: (value) => {
|
||||
const decoded = decodeAnyWeapon(value);
|
||||
if (!decoded) {
|
||||
return undefined;
|
||||
}
|
||||
return decoded;
|
||||
},
|
||||
encode: (weapon) => `${weapon.type}_${weapon.id}`,
|
||||
},
|
||||
encode: (weapon) => `${weapon.type}_${weapon.id}`,
|
||||
});
|
||||
);
|
||||
|
||||
export const calculatorSearchParams = SearchParams.define({
|
||||
weapon: SP.custom(anyWeapon, { default: DEFAULT_ANY_WEAPON, loader: false }),
|
||||
ap: SP.param(
|
||||
z
|
||||
.number()
|
||||
.int()
|
||||
.refine((value) => possibleApValues().includes(value)),
|
||||
v.pipe(
|
||||
v.number(),
|
||||
v.integer(),
|
||||
v.check((value) => possibleApValues().includes(value)),
|
||||
),
|
||||
{ default: 0, loader: false },
|
||||
),
|
||||
dmg: SP.param(z.enum(DAMAGE_TYPE).nullable(), { loader: false }),
|
||||
multi: SP.param(z.boolean(), { default: true, loader: false }),
|
||||
dmg: SP.param(v.nullable(v.picklist(DAMAGE_TYPE)), { loader: false }),
|
||||
multi: SP.param(v.boolean(), { default: true, loader: false }),
|
||||
});
|
||||
|
||||
function decodeAnyWeapon(value: string): AnyWeapon | null {
|
||||
|
||||
@@ -1,18 +1,18 @@
|
||||
import { z } from "zod";
|
||||
import * as v from "valibot";
|
||||
import { mainWeaponIds } from "~/modules/in-game-lists/weapon-ids";
|
||||
import * as SearchParams from "~/modules/search-params/search-params";
|
||||
import { SP } from "~/modules/search-params/search-params";
|
||||
import { numericEnum } from "~/utils/zod";
|
||||
import { numericEnum } from "~/utils/schema";
|
||||
|
||||
export const weaponParamsSearchParams = SearchParams.define({
|
||||
tab: SP.param(z.enum(["params", "patches"]), {
|
||||
tab: SP.param(v.picklist(["params", "patches"]), {
|
||||
default: "params",
|
||||
loader: false,
|
||||
}),
|
||||
hidden: SP.param(z.array(z.number().int()), {
|
||||
hidden: SP.param(v.array(v.pipe(v.number(), v.integer())), {
|
||||
default: [],
|
||||
loader: false,
|
||||
}),
|
||||
kit: SP.param(numericEnum(mainWeaponIds).nullable(), { loader: false }),
|
||||
kitExtras: SP.param(z.boolean(), { default: true, loader: false }),
|
||||
kit: SP.param(v.nullable(numericEnum(mainWeaponIds)), { loader: false }),
|
||||
kitExtras: SP.param(v.boolean(), { default: true, loader: false }),
|
||||
});
|
||||
|
||||
@@ -1,14 +1,16 @@
|
||||
import { z } from "zod";
|
||||
import * as v from "valibot";
|
||||
import * as PlusSuggestionRepository from "~/features/plus-suggestions/PlusSuggestionRepository.server";
|
||||
import {
|
||||
nextNonCompletedVoting,
|
||||
rangeToMonthYear,
|
||||
} from "~/features/plus-voting/core";
|
||||
import * as UserRepository from "~/features/user-page/UserRepository.server";
|
||||
import { superRefineAsync } from "~/utils/schema";
|
||||
import { newSuggestionFormSchema } from "./plus-suggestions-schemas";
|
||||
|
||||
export const newSuggestionFormSchemaServer =
|
||||
newSuggestionFormSchema.superRefine(async (data, ctx) => {
|
||||
export const newSuggestionFormSchemaServer = v.pipeAsync(
|
||||
newSuggestionFormSchema,
|
||||
superRefineAsync(async (data, ctx) => {
|
||||
const suggested = await UserRepository.findLeanById(data.userId);
|
||||
if (!suggested) return;
|
||||
|
||||
@@ -16,7 +18,6 @@ export const newSuggestionFormSchemaServer =
|
||||
|
||||
if (suggested.plusTier && suggested.plusTier <= targetPlusTier) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: "forms:errors.plusAlreadyMember",
|
||||
path: ["userId"],
|
||||
});
|
||||
@@ -35,9 +36,9 @@ export const newSuggestionFormSchemaServer =
|
||||
);
|
||||
if (alreadySuggested) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: "forms:errors.plusAlreadySuggested",
|
||||
path: ["userId"],
|
||||
});
|
||||
}
|
||||
});
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { z } from "zod";
|
||||
import * as v from "valibot";
|
||||
import {
|
||||
idConstant,
|
||||
selectDynamic,
|
||||
@@ -6,10 +6,10 @@ import {
|
||||
textArea,
|
||||
userSearch,
|
||||
} from "~/form/fields";
|
||||
import { _action, actualNumber } from "~/utils/zod";
|
||||
import { _action, actualNumber, preprocess } from "~/utils/schema";
|
||||
import { PLUS_TIERS } from "./plus-suggestions-constants";
|
||||
|
||||
export const followUpCommentFormSchema = z.object({
|
||||
export const followUpCommentFormSchema = v.object({
|
||||
tier: idConstant(),
|
||||
suggestedId: idConstant(),
|
||||
comment: textArea({
|
||||
@@ -23,32 +23,33 @@ const suggestionTextFormFieldSchema = textArea({
|
||||
maxLength: 500,
|
||||
});
|
||||
|
||||
export const newSuggestionFormSchema = z.object({
|
||||
export const newSuggestionFormSchema = v.object({
|
||||
tier: selectDynamic({ label: "labels.plusTier" }),
|
||||
userId: userSearch({ label: "labels.user" }),
|
||||
comment: suggestionTextFormFieldSchema,
|
||||
});
|
||||
|
||||
export const editSuggestionFormSchema = z.object({
|
||||
export const editSuggestionFormSchema = v.object({
|
||||
_action: stringConstant("EDIT_SUGGESTION"),
|
||||
suggestionId: idConstant(),
|
||||
comment: suggestionTextFormFieldSchema,
|
||||
});
|
||||
|
||||
export const suggestionActionSchema = z.union([
|
||||
export const suggestionActionSchema = v.union([
|
||||
editSuggestionFormSchema,
|
||||
z.object({
|
||||
v.object({
|
||||
_action: _action("DELETE_COMMENT"),
|
||||
suggestionId: z.preprocess(actualNumber, z.number()),
|
||||
suggestionId: preprocess(actualNumber, v.number()),
|
||||
}),
|
||||
z.object({
|
||||
v.object({
|
||||
_action: _action("DELETE_SUGGESTION_OF_THEMSELVES"),
|
||||
tier: z.preprocess(
|
||||
tier: preprocess(
|
||||
actualNumber,
|
||||
z
|
||||
.number()
|
||||
.min(Math.min(...PLUS_TIERS))
|
||||
.max(Math.max(...PLUS_TIERS)),
|
||||
v.pipe(
|
||||
v.number(),
|
||||
v.minValue(Math.min(...PLUS_TIERS)),
|
||||
v.maxValue(Math.max(...PLUS_TIERS)),
|
||||
),
|
||||
),
|
||||
}),
|
||||
]);
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { z } from "zod";
|
||||
import * as v from "valibot";
|
||||
import * as SearchParams from "~/modules/search-params/search-params";
|
||||
import { SP } from "~/modules/search-params/search-params";
|
||||
|
||||
@@ -7,9 +7,12 @@ export const PLUS_TIER_PARAMS = ["1", "2", "3"] as const;
|
||||
export type PlusTierParam = (typeof PLUS_TIER_PARAMS)[number];
|
||||
|
||||
export const plusSuggestionsSearchParams = SearchParams.define({
|
||||
tier: SP.param(z.enum(PLUS_TIER_PARAMS), { default: "1", loader: true }),
|
||||
alert: SP.param(z.boolean(), { default: false, loader: false }),
|
||||
editingSuggestionId: SP.param(z.number().int().positive().nullable(), {
|
||||
loader: false,
|
||||
}),
|
||||
tier: SP.param(v.picklist(PLUS_TIER_PARAMS), { default: "1", loader: true }),
|
||||
alert: SP.param(v.boolean(), { default: false, loader: false }),
|
||||
editingSuggestionId: SP.param(
|
||||
v.nullable(v.pipe(v.number(), v.integer(), v.gtValue(0))),
|
||||
{
|
||||
loader: false,
|
||||
},
|
||||
),
|
||||
});
|
||||
|
||||
@@ -1,16 +1,19 @@
|
||||
import { z } from "zod";
|
||||
import * as v from "valibot";
|
||||
import type { PlusVoteFromFE } from "~/features/plus-voting/core";
|
||||
import { preprocess, safeJSONParse } from "~/utils/schema";
|
||||
import { assertType } from "~/utils/types";
|
||||
import { safeJSONParse } from "~/utils/zod";
|
||||
import { PLUS_DOWNVOTE, PLUS_UPVOTE } from "./plus-voting-constants";
|
||||
|
||||
const voteSchema = z.object({
|
||||
votedId: z.number(),
|
||||
score: z.number().refine((val) => [PLUS_DOWNVOTE, PLUS_UPVOTE].includes(val)),
|
||||
const voteSchema = v.object({
|
||||
votedId: v.number(),
|
||||
score: v.pipe(
|
||||
v.number(),
|
||||
v.check((val) => [PLUS_DOWNVOTE, PLUS_UPVOTE].includes(val)),
|
||||
),
|
||||
});
|
||||
|
||||
assertType<z.infer<typeof voteSchema>, PlusVoteFromFE>();
|
||||
assertType<v.InferOutput<typeof voteSchema>, PlusVoteFromFE>();
|
||||
|
||||
export const votingActionSchema = z.object({
|
||||
votes: z.preprocess(safeJSONParse, z.array(voteSchema)),
|
||||
export const votingActionSchema = v.object({
|
||||
votes: preprocess(safeJSONParse, v.array(voteSchema)),
|
||||
});
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import * as v from "valibot";
|
||||
import { describe, expect, test } from "vitest";
|
||||
import { vodsNewSearchParams } from "~/features/vods/vods-search-params";
|
||||
import {
|
||||
@@ -57,7 +58,7 @@ describe("prefillVodMatches", () => {
|
||||
});
|
||||
|
||||
test("rejects rows that are not sendou ids", () => {
|
||||
const parsed = ingestVodPrefillSchema.safeParse({
|
||||
const parsed = v.safeParse(ingestVodPrefillSchema, {
|
||||
matches: [{ ...testMatch(), stage: "Scorch Gorge" }],
|
||||
});
|
||||
expect(parsed.success).toBe(false);
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user