zod -> valibot (#3364)

This commit is contained in:
Kalle
2026-08-21 17:36:48 +03:00
committed by GitHub
parent a21e43c6b2
commit 4f64b9ec01
246 changed files with 4173 additions and 3455 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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[] = [

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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