diff options
22 files changed, 349 insertions, 132 deletions
diff --git a/backend/__tests__/api/controllers/public.spec.ts b/backend/__tests__/api/controllers/public.spec.ts new file mode 100644 index 000000000..accc90a9a --- /dev/null +++ b/backend/__tests__/api/controllers/public.spec.ts @@ -0,0 +1,144 @@ +import request from "supertest"; +import app from "../../../src/app"; +import * as PublicDal from "../../../src/dal/public"; +const mockApp = request(app); + +describe("PublicController", () => { + describe("get speed histogram", () => { + const getSpeedHistogramMock = vi.spyOn(PublicDal, "getSpeedHistogram"); + + afterEach(() => { + getSpeedHistogramMock.mockReset(); + }); + + it("gets for english time 60", async () => { + //GIVEN + getSpeedHistogramMock.mockResolvedValue({ "0": 1, "10": 2 }); + + //WHEN + const { body } = await mockApp + .get("/public/speedHistogram") + .query({ language: "english", mode: "time", mode2: "60" }); + //.expect(200); + console.log(body); + + //THEN + expect(body).toEqual({ + message: "Public speed histogram retrieved", + data: { "0": 1, "10": 2 }, + }); + + expect(getSpeedHistogramMock).toHaveBeenCalledWith( + "english", + "time", + "60" + ); + }); + + it("gets for mode", async () => { + for (const mode of ["time", "words", "quote", "zen", "custom"]) { + const response = await mockApp + .get("/public/speedHistogram") + .query({ language: "english", mode, mode2: "custom" }); + expect(response.status, "for mode " + mode).toEqual(200); + } + }); + + it("gets for mode2", async () => { + for (const mode2 of [ + "10", + "25", + "50", + "100", + "15", + "30", + "60", + "120", + "zen", + "custom", + ]) { + const response = await mockApp + .get("/public/speedHistogram") + .query({ language: "english", mode: "words", mode2 }); + + expect(response.status, "for mode2 " + mode2).toEqual(200); + } + }); + it("fails for missing query", async () => { + const { body } = await mockApp.get("/public/speedHistogram").expect(422); + + expect(body).toEqual({ + message: "Invalid query schema", + validationErrors: [ + '"language" Required', + '"mode" Required', + '"mode2" Needs to be either a number, "zen" or "custom."', + ], + }); + }); + it("fails for invalid query", async () => { + const { body } = await mockApp + .get("/public/speedHistogram") + .query({ + language: "en?gli.sh", + mode: "unknownMode", + mode2: "unknownMode2", + }) + .expect(422); + + expect(body).toEqual({ + message: "Invalid query schema", + validationErrors: [ + '"language" Invalid', + `"mode" Invalid enum value. Expected 'time' | 'words' | 'quote' | 'custom' | 'zen', received 'unknownMode'`, + '"mode2" Needs to be either a number, "zen" or "custom."', + ], + }); + }); + it("fails for unknown query", async () => { + const { body } = await mockApp + .get("/public/speedHistogram") + .query({ + language: "english", + mode: "time", + mode2: "60", + extra: "value", + }) + .expect(422); + + expect(body).toEqual({ + message: "Invalid query schema", + validationErrors: ["Unrecognized key(s) in object: 'extra'"], + }); + }); + }); + describe("get typing stats", () => { + const getTypingStatsMock = vi.spyOn(PublicDal, "getTypingStats"); + + afterEach(() => { + getTypingStatsMock.mockReset(); + }); + + it("gets without authentication", async () => { + //GIVEN + getTypingStatsMock.mockResolvedValue({ + testsCompleted: 23, + testsStarted: 42, + timeTyping: 1000, + } as any); + + //WHEN + const { body } = await mockApp.get("/public/typingStats").expect(200); + + //THEN + expect(body).toEqual({ + message: "Public typing stats retrieved", + data: { + testsCompleted: 23, + testsStarted: 42, + timeTyping: 1000, + }, + }); + }); + }); +}); diff --git a/backend/scripts/openapi.ts b/backend/scripts/openapi.ts index 4b444caa1..b5140de6c 100644 --- a/backend/scripts/openapi.ts +++ b/backend/scripts/openapi.ts @@ -67,6 +67,11 @@ export function getOpenApi(): OpenAPIObject { "x-displayName": "Ape Keys", }, { + name: "public", + description: "Public endpoints such as typing stats.", + "x-displayName": "public", + }, + { name: "psas", description: "Public service announcements.", "x-displayName": "PSAs", diff --git a/backend/src/api/controllers/public.ts b/backend/src/api/controllers/public.ts index f49dca031..505f5caa9 100644 --- a/backend/src/api/controllers/public.ts +++ b/backend/src/api/controllers/public.ts @@ -1,21 +1,22 @@ +import { + GetSpeedHistogramQuery, + GetSpeedHistogramResponse, + GetTypingStatsResponse, +} from "@monkeytype/contracts/public"; import * as PublicDAL from "../../dal/public"; -import { MonkeyResponse } from "../../utils/monkey-response"; +import { MonkeyResponse2 } from "../../utils/monkey-response"; -export async function getPublicSpeedHistogram( - req: MonkeyTypes.Request -): Promise<MonkeyResponse> { +export async function getSpeedHistogram( + req: MonkeyTypes.Request2<GetSpeedHistogramQuery> +): Promise<GetSpeedHistogramResponse> { const { language, mode, mode2 } = req.query; - const data = await PublicDAL.getSpeedHistogram( - language as string, - mode as string, - mode2 as string - ); - return new MonkeyResponse("Public speed histogram retrieved", data); + const data = await PublicDAL.getSpeedHistogram(language, mode, mode2); + return new MonkeyResponse2("Public speed histogram retrieved", data); } -export async function getPublicTypingStats( - _req: MonkeyTypes.Request -): Promise<MonkeyResponse> { +export async function getTypingStats( + _req: MonkeyTypes.Request2 +): Promise<GetTypingStatsResponse> { const data = await PublicDAL.getTypingStats(); - return new MonkeyResponse("Public typing stats retrieved", data); + return new MonkeyResponse2("Public typing stats retrieved", data); } diff --git a/backend/src/api/routes/index.ts b/backend/src/api/routes/index.ts index c64ee8cfd..453a4c128 100644 --- a/backend/src/api/routes/index.ts +++ b/backend/src/api/routes/index.ts @@ -43,7 +43,6 @@ const APP_START_TIME = Date.now(); const API_ROUTE_MAP = { "/users": users, "/results": results, - "/public": publicStats, "/leaderboards": leaderboards, "/quotes": quotes, "/webhooks": webhooks, @@ -57,6 +56,7 @@ const router = s.router(contract, { configs, presets, psas, + public: publicStats, }); export function addApiRoutes(app: Application): void { @@ -78,16 +78,29 @@ export function addApiRoutes(app: Application): void { function applyTsRestApiRoutes(app: IRouter): void { createExpressEndpoints(contract, router, app, { jsonQuery: true, - requestValidationErrorHandler(err, req, res, next) { - if (err.body?.issues === undefined) { + requestValidationErrorHandler(err, _req, res, next) { + let message: string | undefined = undefined; + let validationErrors: string[] | undefined = undefined; + + if (err.pathParams?.issues !== undefined) { + message = "Invalid path parameter schema"; + validationErrors = err.pathParams.issues.map(prettyErrorMessage); + } else if (err.query?.issues !== undefined) { + message = "Invalid query schema"; + validationErrors = err.query.issues.map(prettyErrorMessage); + } else if (err.body?.issues !== undefined) { + message = "Invalid request data schema"; + validationErrors = err.body.issues.map(prettyErrorMessage); + } + + if (message !== undefined) { + res + .status(422) + .json({ message, validationErrors } as MonkeyValidationError); + } else { next(); return; } - const issues = err.body?.issues.map(prettyErrorMessage); - res.status(422).json({ - message: "Invalid request data schema", - validationErrors: issues, - } as MonkeyValidationError); }, globalMiddleware: [authenticateTsRestRequest()], }); diff --git a/backend/src/api/routes/public.ts b/backend/src/api/routes/public.ts index 86491c6b3..b5e310fb7 100644 --- a/backend/src/api/routes/public.ts +++ b/backend/src/api/routes/public.ts @@ -1,41 +1,17 @@ -import { Router } from "express"; -import * as PublicController from "../controllers/public"; +import { publicContract } from "@monkeytype/contracts/public"; +import { initServer } from "@ts-rest/express"; import * as RateLimit from "../../middlewares/rate-limit"; -import { asyncHandler } from "../../middlewares/utility"; -import joi from "joi"; -import { validateRequest } from "../../middlewares/validation"; - -const GET_MODE_STATS_VALIDATION_SCHEMA = { - language: joi - .string() - .max(50) - .pattern(/^[a-zA-Z0-9_+]+$/) - .required(), - mode: joi - .string() - .valid("time", "words", "quote", "zen", "custom") - .required(), - mode2: joi - .string() - .regex(/^(\d)+|custom|zen/) - .required(), -}; - -const router = Router(); - -router.get( - "/speedHistogram", - RateLimit.publicStatsGet, - validateRequest({ - query: GET_MODE_STATS_VALIDATION_SCHEMA, - }), - asyncHandler(PublicController.getPublicSpeedHistogram) -); - -router.get( - "/typingStats", - RateLimit.publicStatsGet, - asyncHandler(PublicController.getPublicTypingStats) -); +import * as PublicController from "../controllers/public"; +import { callController } from "../ts-rest-adapter"; -export default router; +const s = initServer(); +export default s.router(publicContract, { + getSpeedHistogram: { + middleware: [RateLimit.publicStatsGet], + handler: async (r) => callController(PublicController.getSpeedHistogram)(r), + }, + getTypingStats: { + middleware: [RateLimit.publicStatsGet], + handler: async (r) => callController(PublicController.getTypingStats)(r), + }, +}); diff --git a/backend/src/dal/public.ts b/backend/src/dal/public.ts index 08b72edd0..50a230a78 100644 --- a/backend/src/dal/public.ts +++ b/backend/src/dal/public.ts @@ -1,10 +1,13 @@ import * as db from "../init/db"; import { roundTo2 } from "../utils/misc"; import MonkeyError from "../utils/error"; -import { PublicTypingStats, SpeedHistogram } from "@monkeytype/shared-types"; +import { + TypingStats, + SpeedHistogram, +} from "@monkeytype/contracts/schemas/public"; -type PublicTypingStatsDB = PublicTypingStats & { _id: "stats" }; -type PublicSpeedStatsDB = { +export type PublicTypingStatsDB = TypingStats & { _id: "stats" }; +export type PublicSpeedStatsDB = { _id: "speedStatsHistogram"; english_time_15: SpeedHistogram; english_time_60: SpeedHistogram; diff --git a/backend/src/utils/pb.ts b/backend/src/utils/pb.ts index 76041605d..b00d747a5 100644 --- a/backend/src/utils/pb.ts +++ b/backend/src/utils/pb.ts @@ -3,7 +3,6 @@ import FunboxList from "../constants/funbox-list"; import { DBResult } from "@monkeytype/shared-types"; import { Mode, - Mode2, PersonalBest, PersonalBests, } from "@monkeytype/contracts/schemas/shared"; @@ -39,7 +38,7 @@ export function checkAndUpdatePb( result: Result ): CheckAndUpdatePbResult { const mode = result.mode; - const mode2 = result.mode2 as Mode2<"time">; + const mode2 = result.mode2; const userPb = userPersonalBests ?? {}; userPb[mode] ??= {}; @@ -175,7 +174,7 @@ function updateLeaderboardPersonalBests( } const mode = result.mode; - const mode2 = result.mode2 as Mode2<"time">; + const mode2 = result.mode2; lbPersonalBests[mode] = lbPersonalBests[mode] ?? {}; const lbMode2 = lbPersonalBests[mode][mode2] as MonkeyTypes.LbPersonalBests; diff --git a/backend/src/utils/prometheus.ts b/backend/src/utils/prometheus.ts index f98fdc90c..040e1492e 100644 --- a/backend/src/utils/prometheus.ts +++ b/backend/src/utils/prometheus.ts @@ -105,7 +105,7 @@ export function incrementResult(res: Result<Mode>): void { punctuation, } = res; - let m2 = mode2 as string; + let m2 = mode2; if (mode === "time" && !["15", "30", "60", "120"].includes(mode2)) { m2 = "custom"; } diff --git a/frontend/src/ts/ape/endpoints/index.ts b/frontend/src/ts/ape/endpoints/index.ts index 2dae7057b..609c791b9 100644 --- a/frontend/src/ts/ape/endpoints/index.ts +++ b/frontend/src/ts/ape/endpoints/index.ts @@ -2,13 +2,11 @@ import Leaderboards from "./leaderboards"; import Quotes from "./quotes"; import Results from "./results"; import Users from "./users"; -import Public from "./public"; import Configuration from "./configuration"; import Dev from "./dev"; export default { Leaderboards, - Public, Quotes, Results, Users, diff --git a/frontend/src/ts/ape/endpoints/public.ts b/frontend/src/ts/ape/endpoints/public.ts deleted file mode 100644 index 5f4b417d3..000000000 --- a/frontend/src/ts/ape/endpoints/public.ts +++ /dev/null @@ -1,27 +0,0 @@ -import { PublicTypingStats, SpeedHistogram } from "@monkeytype/shared-types"; - -const BASE_PATH = "/public"; - -type SpeedStatsQuery = { - language: string; - mode: string; - mode2: string; -}; - -export default class Public { - constructor(private httpClient: Ape.HttpClient) { - this.httpClient = httpClient; - } - - async getSpeedHistogram( - searchQuery: SpeedStatsQuery - ): Ape.EndpointResponse<SpeedHistogram> { - return await this.httpClient.get(`${BASE_PATH}/speedHistogram`, { - searchQuery, - }); - } - - async getTypingStats(): Ape.EndpointResponse<PublicTypingStats> { - return await this.httpClient.get(`${BASE_PATH}/typingStats`); - } -} diff --git a/frontend/src/ts/ape/index.ts b/frontend/src/ts/ape/index.ts index 6bebc1c3c..1d14030a0 100644 --- a/frontend/src/ts/ape/index.ts +++ b/frontend/src/ts/ape/index.ts @@ -6,6 +6,7 @@ import { configsContract } from "@monkeytype/contracts/configs"; import { presetsContract } from "@monkeytype/contracts/presets"; import { apeKeysContract } from "@monkeytype/contracts/ape-keys"; import { psasContract } from "@monkeytype/contracts/psas"; +import { publicContract } from "@monkeytype/contracts/public"; const API_PATH = ""; const BASE_URL = envConfig.backendUrl; @@ -22,7 +23,7 @@ const Ape = { quotes: new endpoints.Quotes(httpClient), leaderboards: new endpoints.Leaderboards(httpClient), presets: buildClient(presetsContract, BASE_URL, 10_000), - publicStats: new endpoints.Public(httpClient), + publicStats: buildClient(publicContract, BASE_URL, 10_000), apeKeys: buildClient(apeKeysContract, BASE_URL, 10_000), configuration: new endpoints.Configuration(httpClient), dev: new endpoints.Dev(buildHttpClient(API_URL, 240_000)), diff --git a/frontend/src/ts/modals/pb-tables.ts b/frontend/src/ts/modals/pb-tables.ts index 736d87666..a78a8c9c8 100644 --- a/frontend/src/ts/modals/pb-tables.ts +++ b/frontend/src/ts/modals/pb-tables.ts @@ -34,7 +34,7 @@ function update(mode: Mode): void { if (allmode2 === undefined) return; const list: PBWithMode2[] = []; - (Object.keys(allmode2) as Mode2<Mode>[]).forEach(function (key) { + Object.keys(allmode2).forEach(function (key) { let pbs = allmode2[key] ?? []; pbs = pbs.sort(function (a, b) { return b.wpm - a.wpm; diff --git a/frontend/src/ts/modals/share-test-settings.ts b/frontend/src/ts/modals/share-test-settings.ts index 14bed5c51..ca6285fe4 100644 --- a/frontend/src/ts/modals/share-test-settings.ts +++ b/frontend/src/ts/modals/share-test-settings.ts @@ -43,7 +43,7 @@ function updateURL(): void { } if (getCheckboxValue("mode2")) { - settings[1] = getMode2(Config, currentQuote) as Mode2<Mode>; + settings[1] = getMode2(Config, currentQuote); } if (getCheckboxValue("customText")) { diff --git a/frontend/src/ts/pages/about.ts b/frontend/src/ts/pages/about.ts index 6e93ebf05..af3328ca7 100644 --- a/frontend/src/ts/pages/about.ts +++ b/frontend/src/ts/pages/about.ts @@ -8,7 +8,10 @@ import * as ChartController from "../controllers/chart-controller"; import * as ConnectionState from "../states/connection"; import { intervalToDuration } from "date-fns/intervalToDuration"; import * as Skeleton from "../utils/skeleton"; -import { PublicTypingStats, SpeedHistogram } from "@monkeytype/shared-types"; +import { + TypingStats, + SpeedHistogram, +} from "@monkeytype/contracts/schemas/public"; function reset(): void { $(".pageAbout .contributors").empty(); @@ -19,7 +22,7 @@ function reset(): void { } let speedHistogramResponseData: SpeedHistogram | null; -let typingStatsResponseData: PublicTypingStats | null; +let typingStatsResponseData: TypingStats | null; function updateStatsAndHistogram(): void { if (speedHistogramResponseData) { @@ -98,24 +101,26 @@ async function getStatsAndHistogramData(): Promise<void> { } const speedStats = await Ape.publicStats.getSpeedHistogram({ - language: "english", - mode: "time", - mode2: "60", + query: { + language: "english", + mode: "time", + mode2: "60", + }, }); - if (speedStats.status >= 200 && speedStats.status < 300) { - speedHistogramResponseData = speedStats.data; + if (speedStats.status === 200) { + speedHistogramResponseData = speedStats.body.data; } else { Notifications.add( - `Failed to get global speed stats for histogram: ${speedStats.message}`, + `Failed to get global speed stats for histogram: ${speedStats.body.message}`, -1 ); } const typingStats = await Ape.publicStats.getTypingStats(); - if (typingStats.status >= 200 && typingStats.status < 300) { - typingStatsResponseData = typingStats.data; + if (typingStats.status === 200) { + typingStatsResponseData = typingStats.body.data; } else { Notifications.add( - `Failed to get global typing stats: ${speedStats.message}`, + `Failed to get global typing stats: ${speedStats.body.message}`, -1 ); } diff --git a/frontend/src/ts/test/pace-caret.ts b/frontend/src/ts/test/pace-caret.ts index 29a6fab97..76533d8dd 100644 --- a/frontend/src/ts/test/pace-caret.ts +++ b/frontend/src/ts/test/pace-caret.ts @@ -8,7 +8,6 @@ import * as Numbers from "../utils/numbers"; import * as JSONData from "../utils/json-data"; import * as TestState from "./test-state"; import * as ConfigEvent from "../observables/config-event"; -import { Mode2 } from "@monkeytype/contracts/schemas/shared"; type Settings = { wpm: number; @@ -67,9 +66,7 @@ async function resetCaretPosition(): Promise<void> { export async function init(): Promise<void> { $("#paceCaret").addClass("hidden"); - const mode2 = Misc.getMode2(Config, TestWords.currentQuote) as Mode2< - typeof Config.mode - >; + const mode2 = Misc.getMode2(Config, TestWords.currentQuote); let wpm; if (Config.paceCaret === "pb") { wpm = await DB.getLocalPB( diff --git a/packages/contracts/src/index.ts b/packages/contracts/src/index.ts index e7f383639..1224a0e19 100644 --- a/packages/contracts/src/index.ts +++ b/packages/contracts/src/index.ts @@ -4,6 +4,7 @@ import { apeKeysContract } from "./ape-keys"; import { configsContract } from "./configs"; import { presetsContract } from "./presets"; import { psasContract } from "./psas"; +import { publicContract } from "./public"; const c = initContract(); @@ -13,4 +14,5 @@ export const contract = c.router({ configs: configsContract, presets: presetsContract, psas: psasContract, + public: publicContract, }); diff --git a/packages/contracts/src/public.ts b/packages/contracts/src/public.ts new file mode 100644 index 000000000..93b2b81f9 --- /dev/null +++ b/packages/contracts/src/public.ts @@ -0,0 +1,70 @@ +import { initContract } from "@ts-rest/core"; +import { z } from "zod"; +import { + CommonResponses, + EndpointMetadata, + responseWithData, +} from "./schemas/api"; +import { SpeedHistogramSchema, TypingStatsSchema } from "./schemas/public"; +import { Mode2Schema, ModeSchema } from "./schemas/shared"; +import { LanguageSchema } from "./schemas/util"; + +export const GetSpeedHistogramQuerySchema = z + .object({ + language: LanguageSchema, + mode: ModeSchema, + mode2: Mode2Schema, + }) + .strict(); +export type GetSpeedHistogramQuery = z.infer< + typeof GetSpeedHistogramQuerySchema +>; + +export const GetSpeedHistogramResponseSchema = + responseWithData(SpeedHistogramSchema); +export type GetSpeedHistogramResponse = z.infer< + typeof GetSpeedHistogramResponseSchema +>; + +export const GetTypingStatsResponseSchema = responseWithData(TypingStatsSchema); +export type GetTypingStatsResponse = z.infer< + typeof GetTypingStatsResponseSchema +>; + +const c = initContract(); +export const publicContract = c.router( + { + getSpeedHistogram: { + summary: "get speed histogram", + description: + "get number of users personal bests grouped by wpm level (multiples of ten)", + method: "GET", + path: "/speedHistogram", + query: GetSpeedHistogramQuerySchema, + responses: { + 200: GetSpeedHistogramResponseSchema, + }, + }, + + getTypingStats: { + summary: "get typing stats", + description: "get number of tests and time users spend typing.", + method: "GET", + path: "/typingStats", + responses: { + 200: GetTypingStatsResponseSchema, + }, + }, + }, + { + pathPrefix: "/public", + strictStatusCodes: true, + metadata: { + openApiTags: "public", + authenticationOptions: { + isPublic: true, + }, + } as EndpointMetadata, + commonResponses: CommonResponses, + } +); diff --git a/packages/contracts/src/schemas/api.ts b/packages/contracts/src/schemas/api.ts index f90e47dbd..305d6100a 100644 --- a/packages/contracts/src/schemas/api.ts +++ b/packages/contracts/src/schemas/api.ts @@ -1,6 +1,12 @@ import { z, ZodSchema } from "zod"; -export type OpenApiTag = "configs" | "presets" | "ape-keys" | "admin" | "psas"; +export type OpenApiTag = + | "configs" + | "presets" + | "ape-keys" + | "admin" + | "psas" + | "public"; export type EndpointMetadata = { /** Authentication options, by default a bearer token is required. */ diff --git a/packages/contracts/src/schemas/public.ts b/packages/contracts/src/schemas/public.ts new file mode 100644 index 000000000..5a89122eb --- /dev/null +++ b/packages/contracts/src/schemas/public.ts @@ -0,0 +1,15 @@ +import { z } from "zod"; +import { StringNumberSchema } from "./util"; + +export const SpeedHistogramSchema = z.record( + StringNumberSchema, + z.number().int() +); +export type SpeedHistogram = z.infer<typeof SpeedHistogramSchema>; + +export const TypingStatsSchema = z.object({ + timeTyping: z.number().nonnegative(), + testsCompleted: z.number().int().nonnegative(), + testsStarted: z.number().int().nonnegative(), +}); +export type TypingStats = z.infer<typeof TypingStatsSchema>; diff --git a/packages/contracts/src/schemas/shared.ts b/packages/contracts/src/schemas/shared.ts index 0826839a8..5eb97b5d4 100644 --- a/packages/contracts/src/schemas/shared.ts +++ b/packages/contracts/src/schemas/shared.ts @@ -1,4 +1,4 @@ -import { z } from "zod"; +import { literal, z } from "zod"; import { StringNumberSchema } from "./util"; //used by config and shared @@ -33,9 +33,19 @@ export const PersonalBestsSchema = z.object({ }); export type PersonalBests = z.infer<typeof PersonalBestsSchema>; -//used by user and config +//used by user, config, public export const ModeSchema = PersonalBestsSchema.keyof(); export type Mode = z.infer<typeof ModeSchema>; + +export const Mode2Schema = z.union( + [StringNumberSchema, literal("zen"), literal("custom")], + { + errorMap: () => ({ + message: 'Needs to be either a number, "zen" or "custom."', + }), + } +); + export type Mode2<M extends Mode> = M extends M ? keyof PersonalBests[M] : never; diff --git a/packages/contracts/src/schemas/util.ts b/packages/contracts/src/schemas/util.ts index 74e5ed963..25d9a4b77 100644 --- a/packages/contracts/src/schemas/util.ts +++ b/packages/contracts/src/schemas/util.ts @@ -1,8 +1,12 @@ import { z, ZodString } from "zod"; -export const StringNumberSchema = z.custom<`${number}`>((val) => { - return typeof val === "string" ? /^\d+$/.test(val) : false; -}); +export const StringNumberSchema = z + + .custom<`${number}`>((val) => { + if (typeof val === "number") val = val.toString(); + return typeof val === "string" ? /^\d+$/.test(val) : false; + }, 'Needs to be a number or a number represented as a string e.g. "10".') + .transform(String); export type StringNumber = z.infer<typeof StringNumberSchema>; @@ -13,3 +17,9 @@ export type Id = z.infer<typeof IdSchema>; export const TagSchema = token().max(50); export type Tag = z.infer<typeof TagSchema>; + +export const LanguageSchema = z + .string() + .max(50) + .regex(/^[a-zA-Z0-9_+]+$/); +export type Language = z.infer<typeof LanguageSchema>; diff --git a/packages/shared-types/src/index.ts b/packages/shared-types/src/index.ts index fe8d50ea6..d069ecd3c 100644 --- a/packages/shared-types/src/index.ts +++ b/packages/shared-types/src/index.ts @@ -301,17 +301,6 @@ export type ResultFilters = { } & Record<string, boolean>; }; -export type SpeedHistogram = { - [key: string]: number; -}; - -export type PublicTypingStats = { - type: string; - timeTyping: number; - testsCompleted: number; - testsStarted: number; -}; - export type LeaderboardEntry = { _id: string; wpm: number; |