From 0adb27560623594b1c94a0dede779163209ad362 Mon Sep 17 00:00:00 2001 From: Cody <36463298+ugcodrr@users.noreply.github.com> Date: Wed, 21 Oct 2026 03:16:56 -0400 Subject: [PATCH 1/9] fix(leaderboards): use a Set and a Map for rankings lookups @IsEnum(fields, { each: true }) rebuilt the list of all 2,718 player leaderboard fields for every value, and getLeaderboardField searched the metadata array for each field. Validate against a Set with IsEnum's message, and look fields up in a Map built once per class. A test checks that scanMetadata returns each Player and Guild key once, since a Map keeps the last entry for a repeated key. --- apps/api/src/dtos/player-rankings.dto.ts | 21 +++++++++++++++++-- .../schemas/src/metadata/metadata-scanner.ts | 12 +++++++++++ .../schemas/src/util/leaderboard-scanner.ts | 14 ++++++++++--- 3 files changed, 42 insertions(+), 5 deletions(-) diff --git a/apps/api/src/dtos/player-rankings.dto.ts b/apps/api/src/dtos/player-rankings.dto.ts index 8f29c9010..8bf984f2f 100644 --- a/apps/api/src/dtos/player-rankings.dto.ts +++ b/apps/api/src/dtos/player-rankings.dto.ts @@ -7,14 +7,31 @@ */ import { ApiProperty } from "@nestjs/swagger"; -import { IsEnum } from "class-validator"; +import { IS_ENUM, ValidateBy, buildMessage } from "class-validator"; import { Player, getLeaderboardFields } from "@statsify/schemas"; import { UuidDto } from "./uuid.dto.js"; const fields = getLeaderboardFields(Player).map(([key]) => key); +const fieldSet = new Set(fields); + +// @IsEnum(fields, { each: true }) rebuilt the list of every field for each value. This checks a +// Set instead and keeps IsEnum's name and message, which lists no values for an array +const IsLeaderboardField = ValidateBy( + { + name: IS_ENUM, + validator: { + validate: (value) => fieldSet.has(value), + defaultMessage: buildMessage( + (eachPrefix) => `${eachPrefix}$property must be one of the following values: `, + { each: true } + ), + }, + }, + { each: true } +); export class PlayerRankingsDto extends UuidDto { @ApiProperty({ enum: fields, type: [String] }) - @IsEnum(fields, { each: true }) + @IsLeaderboardField public fields: string[]; } diff --git a/packages/schemas/src/metadata/metadata-scanner.ts b/packages/schemas/src/metadata/metadata-scanner.ts index 5a7d3ef1c..abc642eed 100644 --- a/packages/schemas/src/metadata/metadata-scanner.ts +++ b/packages/schemas/src/metadata/metadata-scanner.ts @@ -343,5 +343,17 @@ if (import.meta.vitest) { resetEvery: undefined, }); }); + + it("should return each key once for Player and Guild", async () => { + // getLeaderboardField keeps these in a Map, which would keep the last entry for a repeated key + const { Player } = await import("../player/index.js"); + const { Guild } = await import("../guild/index.js"); + + for (const constructor of [Player, Guild]) { + const keys = scanMetadata(constructor).map(([key]) => key); + expect(keys.length).toBeGreaterThan(0); + expect(keys.filter((key, index) => keys.indexOf(key) !== index)).toEqual([]); + } + }); }); } diff --git a/packages/schemas/src/util/leaderboard-scanner.ts b/packages/schemas/src/util/leaderboard-scanner.ts index 4260ace0b..e4ed16ad1 100644 --- a/packages/schemas/src/util/leaderboard-scanner.ts +++ b/packages/schemas/src/util/leaderboard-scanner.ts @@ -7,6 +7,7 @@ */ import { + type FieldMetadata, type LeaderboardEnabledMetadata, type LeaderboardMetadata, scanMetadata, @@ -14,6 +15,8 @@ import { import { parseAdditionalFields } from "./parse-fields.js"; import type { Constructor } from "@statsify/util"; +const fieldMaps = new Map>(); + export function getLeaderboardFields(constructor: Constructor) { const metadata = scanMetadata(constructor); @@ -37,13 +40,18 @@ export function getLeaderboardField( key: string, leaderboardMustBeEnabled = true, ): LeaderboardMetadata { - const metadata = scanMetadata(constructor); + let fields = fieldMaps.get(constructor); + + if (!fields) { + fields = new Map(scanMetadata(constructor)); + fieldMaps.set(constructor, fields); + } - const field = metadata.find(([k]) => k === key); + const field = fields.get(key); if (!field) throw new Error(`${key} is not a field for ${constructor.name}`); - const [, { store, leaderboard }] = field; + const { store, leaderboard } = field; if (!leaderboard.enabled && leaderboardMustBeEnabled) throw new Error( From 197af533c50ae533bcc82c5446580ff0b794fd3c Mon Sep 17 00:00:00 2001 From: Cody <36463298+ugcodrr@users.noreply.github.com> Date: Wed, 21 Oct 2026 09:24:24 -0400 Subject: [PATCH 2/9] fix(api): name the unknown fields in rankings errors A bad field in a player or guild rankings request got "each value in fields must be one of the following values: " with nothing after it, since IsEnum reads an array's indexes as enum keys. A valid field sent as a string instead of an array passed validation, was ranked letter by letter and threw a 500. - Name the values that aren't leaderboard fields, e.g. unknown leaderboard field: "stats.bedwars.foo" - Answer anything but an array with a 400: fields must be an array of leaderboard fields - Give guild rankings the same check - Run apps/api's in-source tests, for the message test --- apps/api/src/dtos/guild-rankings.dto.ts | 5 +- .../src/dtos/leaderboard-field.decorator.ts | 60 +++++++++++++++++++ apps/api/src/dtos/player-rankings.dto.ts | 21 +------ apps/api/tsconfig.json | 2 +- apps/api/vitest.config.ts | 11 ++++ 5 files changed, 77 insertions(+), 22 deletions(-) create mode 100644 apps/api/src/dtos/leaderboard-field.decorator.ts create mode 100644 apps/api/vitest.config.ts diff --git a/apps/api/src/dtos/guild-rankings.dto.ts b/apps/api/src/dtos/guild-rankings.dto.ts index bb3b8f798..0859127b8 100644 --- a/apps/api/src/dtos/guild-rankings.dto.ts +++ b/apps/api/src/dtos/guild-rankings.dto.ts @@ -8,13 +8,14 @@ import { ApiProperty } from "@nestjs/swagger"; import { Guild, getLeaderboardFields } from "@statsify/schemas"; -import { IsEnum, IsString, MaxLength, MinLength } from "class-validator"; +import { IsLeaderboardField } from "./leaderboard-field.decorator.js"; +import { IsString, MaxLength, MinLength } from "class-validator"; const fields = getLeaderboardFields(Guild).map(([key]) => key); export class GuildRankingDto { @ApiProperty({ enum: fields, type: [String] }) - @IsEnum(fields, { each: true }) + @IsLeaderboardField(fields) public fields: string[]; @IsString() diff --git a/apps/api/src/dtos/leaderboard-field.decorator.ts b/apps/api/src/dtos/leaderboard-field.decorator.ts new file mode 100644 index 000000000..279e3bca7 --- /dev/null +++ b/apps/api/src/dtos/leaderboard-field.decorator.ts @@ -0,0 +1,60 @@ +/** + * Copyright (c) Statsify + * + * This source code is licensed under the GNU GPL v3 license found in the + * LICENSE file in the root directory of this source tree. + * https://github.com/Statsify/statsify/blob/main/LICENSE + */ + +import { ValidateBy } from "class-validator"; + +// Replaces @IsEnum(fields, { each: true }), which rebuilt the list of every field for each value and, +// since it reads an array's indexes as enum keys, listed no values in its message. This checks a Set, +// requires an array (a lone string passed validation and was ranked letter by letter) and names the +// values that aren't fields, quoted so an empty string or a stray space still shows +export const IsLeaderboardField = (fields: string[]) => { + const fieldSet = new Set(fields); + + return ValidateBy({ + name: "isLeaderboardField", + validator: { + validate: (value) => Array.isArray(value) && value.every((field) => fieldSet.has(field)), + defaultMessage: (args) => { + const value: unknown = args?.value; + if (!Array.isArray(value)) return `${args?.property} must be an array of leaderboard fields`; + + const unknown = new Set( + value.filter((field) => !fieldSet.has(field)).map((field) => JSON.stringify(field)) + ); + + return `unknown leaderboard field${unknown.size === 1 ? "" : "s"}: ${[...unknown].join(", ")}`; + }, + }, + }); +}; + +if (import.meta.vitest) { + const { it, expect } = import.meta.vitest; + const { validate } = await import("class-validator"); + + class Dto { + @IsLeaderboardField(["wins", "kills"]) + public fields: unknown; + } + + const messages = async (fields: unknown) => { + const errors = await validate(Object.assign(new Dto(), { fields })); + return errors.flatMap((error) => Object.values(error.constraints ?? {})); + }; + + it("names the values that aren't leaderboard fields", async () => { + await expect(messages(["wins", "kills"])).resolves.toEqual([]); + await expect(messages(["wins", "nope"])).resolves.toEqual(['unknown leaderboard field: "nope"']); + await expect(messages(["nope", 5, "nope", ""])).resolves.toEqual([ + 'unknown leaderboard fields: "nope", 5, ""', + ]); + await expect(messages("wins")).resolves.toEqual([ + "fields must be an array of leaderboard fields", + ]); + }); +} diff --git a/apps/api/src/dtos/player-rankings.dto.ts b/apps/api/src/dtos/player-rankings.dto.ts index 8bf984f2f..0414cfaf2 100644 --- a/apps/api/src/dtos/player-rankings.dto.ts +++ b/apps/api/src/dtos/player-rankings.dto.ts @@ -7,31 +7,14 @@ */ import { ApiProperty } from "@nestjs/swagger"; -import { IS_ENUM, ValidateBy, buildMessage } from "class-validator"; +import { IsLeaderboardField } from "./leaderboard-field.decorator.js"; import { Player, getLeaderboardFields } from "@statsify/schemas"; import { UuidDto } from "./uuid.dto.js"; const fields = getLeaderboardFields(Player).map(([key]) => key); -const fieldSet = new Set(fields); - -// @IsEnum(fields, { each: true }) rebuilt the list of every field for each value. This checks a -// Set instead and keeps IsEnum's name and message, which lists no values for an array -const IsLeaderboardField = ValidateBy( - { - name: IS_ENUM, - validator: { - validate: (value) => fieldSet.has(value), - defaultMessage: buildMessage( - (eachPrefix) => `${eachPrefix}$property must be one of the following values: `, - { each: true } - ), - }, - }, - { each: true } -); export class PlayerRankingsDto extends UuidDto { @ApiProperty({ enum: fields, type: [String] }) - @IsLeaderboardField + @IsLeaderboardField(fields) public fields: string[]; } diff --git a/apps/api/tsconfig.json b/apps/api/tsconfig.json index dd3fcc5f4..4331b10e2 100644 --- a/apps/api/tsconfig.json +++ b/apps/api/tsconfig.json @@ -1,4 +1,4 @@ { "extends": "../../tsconfig.base.json", - "include": ["src"] + "include": ["src", "vitest.config.ts"] } \ No newline at end of file diff --git a/apps/api/vitest.config.ts b/apps/api/vitest.config.ts new file mode 100644 index 000000000..2f7603e59 --- /dev/null +++ b/apps/api/vitest.config.ts @@ -0,0 +1,11 @@ +/** + * Copyright (c) Statsify + * + * This source code is licensed under the GNU GPL v3 license found in the + * LICENSE file in the root directory of this source tree. + * https://github.com/Statsify/statsify/blob/main/LICENSE + */ + +import { config } from "../../vitest.shared.js"; + +export default await config(); From efd6d74ce35da20d337299a4edf78fc50de133a4 Mon Sep 17 00:00:00 2001 From: Cody <36463298+ugcodrr@users.noreply.github.com> Date: Thu, 22 Oct 2026 00:23:22 -0400 Subject: [PATCH 3/9] test(schemas): drop the one-off scanMetadata unique-keys test --- packages/schemas/src/metadata/metadata-scanner.ts | 12 ------------ 1 file changed, 12 deletions(-) diff --git a/packages/schemas/src/metadata/metadata-scanner.ts b/packages/schemas/src/metadata/metadata-scanner.ts index abc642eed..5a7d3ef1c 100644 --- a/packages/schemas/src/metadata/metadata-scanner.ts +++ b/packages/schemas/src/metadata/metadata-scanner.ts @@ -343,17 +343,5 @@ if (import.meta.vitest) { resetEvery: undefined, }); }); - - it("should return each key once for Player and Guild", async () => { - // getLeaderboardField keeps these in a Map, which would keep the last entry for a repeated key - const { Player } = await import("../player/index.js"); - const { Guild } = await import("../guild/index.js"); - - for (const constructor of [Player, Guild]) { - const keys = scanMetadata(constructor).map(([key]) => key); - expect(keys.length).toBeGreaterThan(0); - expect(keys.filter((key, index) => keys.indexOf(key) !== index)).toEqual([]); - } - }); }); } From 633a5c9d794e5ec4c1844af58fe0cfbcc38d6338 Mon Sep 17 00:00:00 2001 From: Cody <36463298+ugcodrr@users.noreply.github.com> Date: Thu, 22 Oct 2026 00:29:26 -0400 Subject: [PATCH 4/9] refactor(api): trim the IsLeaderboardField comment and rename its unknown set --- apps/api/src/dtos/leaderboard-field.decorator.ts | 9 +++------ 1 file changed, 3 insertions(+), 6 deletions(-) diff --git a/apps/api/src/dtos/leaderboard-field.decorator.ts b/apps/api/src/dtos/leaderboard-field.decorator.ts index 279e3bca7..03154139d 100644 --- a/apps/api/src/dtos/leaderboard-field.decorator.ts +++ b/apps/api/src/dtos/leaderboard-field.decorator.ts @@ -8,10 +8,6 @@ import { ValidateBy } from "class-validator"; -// Replaces @IsEnum(fields, { each: true }), which rebuilt the list of every field for each value and, -// since it reads an array's indexes as enum keys, listed no values in its message. This checks a Set, -// requires an array (a lone string passed validation and was ranked letter by letter) and names the -// values that aren't fields, quoted so an empty string or a stray space still shows export const IsLeaderboardField = (fields: string[]) => { const fieldSet = new Set(fields); @@ -23,11 +19,12 @@ export const IsLeaderboardField = (fields: string[]) => { const value: unknown = args?.value; if (!Array.isArray(value)) return `${args?.property} must be an array of leaderboard fields`; - const unknown = new Set( + // Quoted so an empty string or a stray space still shows + const invalid = new Set( value.filter((field) => !fieldSet.has(field)).map((field) => JSON.stringify(field)) ); - return `unknown leaderboard field${unknown.size === 1 ? "" : "s"}: ${[...unknown].join(", ")}`; + return `unknown leaderboard field${invalid.size === 1 ? "" : "s"}: ${[...invalid].join(", ")}`; }, }, }); From 911d7296a07d949c5e0125798193aec277cee361 Mon Sep 17 00:00:00 2001 From: Cody <36463298+ugcodrr@users.noreply.github.com> Date: Thu, 22 Oct 2026 00:35:34 -0400 Subject: [PATCH 5/9] test(api): drop the one-off rankings error message test --- .../src/dtos/leaderboard-field.decorator.ts | 26 ------------------- apps/api/tsconfig.json | 2 +- apps/api/vitest.config.ts | 11 -------- 3 files changed, 1 insertion(+), 38 deletions(-) delete mode 100644 apps/api/vitest.config.ts diff --git a/apps/api/src/dtos/leaderboard-field.decorator.ts b/apps/api/src/dtos/leaderboard-field.decorator.ts index 03154139d..1ab1e9d9f 100644 --- a/apps/api/src/dtos/leaderboard-field.decorator.ts +++ b/apps/api/src/dtos/leaderboard-field.decorator.ts @@ -29,29 +29,3 @@ export const IsLeaderboardField = (fields: string[]) => { }, }); }; - -if (import.meta.vitest) { - const { it, expect } = import.meta.vitest; - const { validate } = await import("class-validator"); - - class Dto { - @IsLeaderboardField(["wins", "kills"]) - public fields: unknown; - } - - const messages = async (fields: unknown) => { - const errors = await validate(Object.assign(new Dto(), { fields })); - return errors.flatMap((error) => Object.values(error.constraints ?? {})); - }; - - it("names the values that aren't leaderboard fields", async () => { - await expect(messages(["wins", "kills"])).resolves.toEqual([]); - await expect(messages(["wins", "nope"])).resolves.toEqual(['unknown leaderboard field: "nope"']); - await expect(messages(["nope", 5, "nope", ""])).resolves.toEqual([ - 'unknown leaderboard fields: "nope", 5, ""', - ]); - await expect(messages("wins")).resolves.toEqual([ - "fields must be an array of leaderboard fields", - ]); - }); -} diff --git a/apps/api/tsconfig.json b/apps/api/tsconfig.json index 4331b10e2..dd3fcc5f4 100644 --- a/apps/api/tsconfig.json +++ b/apps/api/tsconfig.json @@ -1,4 +1,4 @@ { "extends": "../../tsconfig.base.json", - "include": ["src", "vitest.config.ts"] + "include": ["src"] } \ No newline at end of file diff --git a/apps/api/vitest.config.ts b/apps/api/vitest.config.ts deleted file mode 100644 index 2f7603e59..000000000 --- a/apps/api/vitest.config.ts +++ /dev/null @@ -1,11 +0,0 @@ -/** - * Copyright (c) Statsify - * - * This source code is licensed under the GNU GPL v3 license found in the - * LICENSE file in the root directory of this source tree. - * https://github.com/Statsify/statsify/blob/main/LICENSE - */ - -import { config } from "../../vitest.shared.js"; - -export default await config(); From 49f2e7a6740d7330db861003d562fb0d9e10c3c4 Mon Sep 17 00:00:00 2001 From: Cody <36463298+ugcodrr@users.noreply.github.com> Date: Thu, 22 Oct 2026 01:55:04 -0400 Subject: [PATCH 6/9] refactor(schemas): leave the getLeaderboardField Map to #1008 --- packages/schemas/src/util/leaderboard-scanner.ts | 14 +++----------- 1 file changed, 3 insertions(+), 11 deletions(-) diff --git a/packages/schemas/src/util/leaderboard-scanner.ts b/packages/schemas/src/util/leaderboard-scanner.ts index e4ed16ad1..4260ace0b 100644 --- a/packages/schemas/src/util/leaderboard-scanner.ts +++ b/packages/schemas/src/util/leaderboard-scanner.ts @@ -7,7 +7,6 @@ */ import { - type FieldMetadata, type LeaderboardEnabledMetadata, type LeaderboardMetadata, scanMetadata, @@ -15,8 +14,6 @@ import { import { parseAdditionalFields } from "./parse-fields.js"; import type { Constructor } from "@statsify/util"; -const fieldMaps = new Map>(); - export function getLeaderboardFields(constructor: Constructor) { const metadata = scanMetadata(constructor); @@ -40,18 +37,13 @@ export function getLeaderboardField( key: string, leaderboardMustBeEnabled = true, ): LeaderboardMetadata { - let fields = fieldMaps.get(constructor); - - if (!fields) { - fields = new Map(scanMetadata(constructor)); - fieldMaps.set(constructor, fields); - } + const metadata = scanMetadata(constructor); - const field = fields.get(key); + const field = metadata.find(([k]) => k === key); if (!field) throw new Error(`${key} is not a field for ${constructor.name}`); - const { store, leaderboard } = field; + const [, { store, leaderboard }] = field; if (!leaderboard.enabled && leaderboardMustBeEnabled) throw new Error( From 649e8dc7ab0ac3b41345519a574778ff39381fad Mon Sep 17 00:00:00 2001 From: Cody <36463298+ugcodrr@users.noreply.github.com> Date: Thu, 5 Nov 2026 00:31:25 -0500 Subject: [PATCH 7/9] refactor(api): drop the quoting comment in IsLeaderboardField --- apps/api/src/dtos/leaderboard-field.decorator.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/apps/api/src/dtos/leaderboard-field.decorator.ts b/apps/api/src/dtos/leaderboard-field.decorator.ts index 1ab1e9d9f..ceed19a78 100644 --- a/apps/api/src/dtos/leaderboard-field.decorator.ts +++ b/apps/api/src/dtos/leaderboard-field.decorator.ts @@ -19,7 +19,6 @@ export const IsLeaderboardField = (fields: string[]) => { const value: unknown = args?.value; if (!Array.isArray(value)) return `${args?.property} must be an array of leaderboard fields`; - // Quoted so an empty string or a stray space still shows const invalid = new Set( value.filter((field) => !fieldSet.has(field)).map((field) => JSON.stringify(field)) ); From a9fccf6e74a2a5e3735dd251aade288eb5d562d8 Mon Sep 17 00:00:00 2001 From: Cody <36463298+ugcodrr@users.noreply.github.com> Date: Thu, 5 Nov 2026 00:42:14 -0500 Subject: [PATCH 8/9] refactor(api): validate one leaderboard field per IsLeaderboardField check --- apps/api/src/dtos/guild-rankings.dto.ts | 5 +-- .../src/dtos/leaderboard-field.decorator.ts | 32 +++++++++++-------- apps/api/src/dtos/player-rankings.dto.ts | 4 ++- 3 files changed, 24 insertions(+), 17 deletions(-) diff --git a/apps/api/src/dtos/guild-rankings.dto.ts b/apps/api/src/dtos/guild-rankings.dto.ts index 0859127b8..60fc81dc1 100644 --- a/apps/api/src/dtos/guild-rankings.dto.ts +++ b/apps/api/src/dtos/guild-rankings.dto.ts @@ -8,14 +8,15 @@ import { ApiProperty } from "@nestjs/swagger"; import { Guild, getLeaderboardFields } from "@statsify/schemas"; +import { IsArray, IsString, MaxLength, MinLength } from "class-validator"; import { IsLeaderboardField } from "./leaderboard-field.decorator.js"; -import { IsString, MaxLength, MinLength } from "class-validator"; const fields = getLeaderboardFields(Guild).map(([key]) => key); export class GuildRankingDto { @ApiProperty({ enum: fields, type: [String] }) - @IsLeaderboardField(fields) + @IsArray() + @IsLeaderboardField(fields, { each: true }) public fields: string[]; @IsString() diff --git a/apps/api/src/dtos/leaderboard-field.decorator.ts b/apps/api/src/dtos/leaderboard-field.decorator.ts index ceed19a78..db6428d8a 100644 --- a/apps/api/src/dtos/leaderboard-field.decorator.ts +++ b/apps/api/src/dtos/leaderboard-field.decorator.ts @@ -6,25 +6,29 @@ * https://github.com/Statsify/statsify/blob/main/LICENSE */ -import { ValidateBy } from "class-validator"; +import { ValidateBy, type ValidationOptions } from "class-validator"; -export const IsLeaderboardField = (fields: string[]) => { +export const IsLeaderboardField = (fields: string[], options?: ValidationOptions) => { const fieldSet = new Set(fields); - return ValidateBy({ - name: "isLeaderboardField", - validator: { - validate: (value) => Array.isArray(value) && value.every((field) => fieldSet.has(field)), - defaultMessage: (args) => { - const value: unknown = args?.value; - if (!Array.isArray(value)) return `${args?.property} must be an array of leaderboard fields`; + return ValidateBy( + { + name: "isLeaderboardField", + validator: { + validate: (value) => fieldSet.has(value), + defaultMessage: (args) => { + const value = args?.value; + if (value === undefined) return `${args?.property} is required`; - const invalid = new Set( - value.filter((field) => !fieldSet.has(field)).map((field) => JSON.stringify(field)) - ); + const values = options?.each && Array.isArray(value) ? value : [value]; + const invalid = new Set( + values.filter((field) => !fieldSet.has(field)).map((field) => JSON.stringify(field)) + ); - return `unknown leaderboard field${invalid.size === 1 ? "" : "s"}: ${[...invalid].join(", ")}`; + return `unknown leaderboard field${invalid.size === 1 ? "" : "s"}: ${[...invalid].join(", ")}`; + }, }, }, - }); + options + ); }; diff --git a/apps/api/src/dtos/player-rankings.dto.ts b/apps/api/src/dtos/player-rankings.dto.ts index 0414cfaf2..f627bcc00 100644 --- a/apps/api/src/dtos/player-rankings.dto.ts +++ b/apps/api/src/dtos/player-rankings.dto.ts @@ -7,6 +7,7 @@ */ import { ApiProperty } from "@nestjs/swagger"; +import { IsArray } from "class-validator"; import { IsLeaderboardField } from "./leaderboard-field.decorator.js"; import { Player, getLeaderboardFields } from "@statsify/schemas"; import { UuidDto } from "./uuid.dto.js"; @@ -15,6 +16,7 @@ const fields = getLeaderboardFields(Player).map(([key]) => key); export class PlayerRankingsDto extends UuidDto { @ApiProperty({ enum: fields, type: [String] }) - @IsLeaderboardField(fields) + @IsArray() + @IsLeaderboardField(fields, { each: true }) public fields: string[]; } From 4687a554b64d2c346b9638d12b4ef709da337ae4 Mon Sep 17 00:00:00 2001 From: Cody <36463298+ugcodrr@users.noreply.github.com> Date: Thu, 5 Nov 2026 00:42:14 -0500 Subject: [PATCH 9/9] fix(api): name the unknown field in leaderboard errors --- apps/api/src/dtos/guild-leaderboard.dto.ts | 4 ++-- apps/api/src/dtos/player-leaderboard.dto.ts | 5 +++-- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/apps/api/src/dtos/guild-leaderboard.dto.ts b/apps/api/src/dtos/guild-leaderboard.dto.ts index 483977c3a..6b98ee790 100644 --- a/apps/api/src/dtos/guild-leaderboard.dto.ts +++ b/apps/api/src/dtos/guild-leaderboard.dto.ts @@ -9,7 +9,6 @@ import { ApiProperty } from "@nestjs/swagger"; import { Guild, getLeaderboardFields } from "@statsify/schemas"; import { - IsEnum, IsInt, IsOptional, IsString, @@ -17,12 +16,13 @@ import { Min, MinLength, } from "class-validator"; +import { IsLeaderboardField } from "./leaderboard-field.decorator.js"; import { Transform } from "class-transformer"; const fields = getLeaderboardFields(Guild).map(([key]) => key); export class GuildLeaderboardDto { - @IsEnum(fields) + @IsLeaderboardField(fields) @ApiProperty({ enum: fields }) public field: string; diff --git a/apps/api/src/dtos/player-leaderboard.dto.ts b/apps/api/src/dtos/player-leaderboard.dto.ts index 0e39b70ed..8ef1d3e35 100644 --- a/apps/api/src/dtos/player-leaderboard.dto.ts +++ b/apps/api/src/dtos/player-leaderboard.dto.ts @@ -7,7 +7,8 @@ */ import { ApiProperty, PartialType } from "@nestjs/swagger"; -import { IsEnum, IsInt, IsOptional, Min } from "class-validator"; +import { IsInt, IsOptional, Min } from "class-validator"; +import { IsLeaderboardField } from "./leaderboard-field.decorator.js"; import { Player, getLeaderboardFields } from "@statsify/schemas"; import { PlayerDto } from "./player.dto.js"; import { Transform } from "class-transformer"; @@ -15,7 +16,7 @@ import { Transform } from "class-transformer"; const fields = getLeaderboardFields(Player).map(([key]) => key); export class PlayerLeaderboardDto extends PartialType(PlayerDto) { - @IsEnum(fields) + @IsLeaderboardField(fields) @ApiProperty({ enum: fields }) public field: string;