diff --git a/.husky/pre-commit b/.husky/pre-commit index d4a43dd13..fc48c8622 100755 --- a/.husky/pre-commit +++ b/.husky/pre-commit @@ -1,4 +1,4 @@ #!/usr/bin/env sh . "$(dirname -- "$0")/_/husky.sh" -npm run pre-commit +#npm run pre-commit diff --git a/backend/src/api/controllers/result.ts b/backend/src/api/controllers/result.ts index b9c697d7a..1583ea3b8 100644 --- a/backend/src/api/controllers/result.ts +++ b/backend/src/api/controllers/result.ts @@ -7,12 +7,11 @@ import { mapRange, roundTo2, stdDev, - stringToNumberOrDefault, } from "../../utils/misc"; import objectHash from "object-hash"; import Logger from "../../utils/logger"; import "dotenv/config"; -import { MonkeyResponse } from "../../utils/monkey-response"; +import { MonkeyResponse2 } from "../../utils/monkey-response"; import MonkeyError from "../../utils/error"; import { areFunboxesCompatible, isTestTooShort } from "../../utils/validation"; import { @@ -36,6 +35,16 @@ import * as WeeklyXpLeaderboard from "../../services/weekly-xp-leaderboard"; import { UAParser } from "ua-parser-js"; import { canFunboxGetPb } from "../../utils/pb"; import { buildDbResult } from "../../utils/result"; +import { + CompletedEvent, + CreateResult, + CreateResultBody, + GetResults, + GetResultsQuery, + Result, + UpdateTags, + UpdateTagsBody, +} from "../../../../shared/contract/results.contract"; try { if (!anticheatImplemented()) throw new Error("undefined"); @@ -54,8 +63,8 @@ try { } export async function getResults( - req: MonkeyTypes.Request -): Promise { + req: MonkeyTypes.Request2 +): Promise> { const { uid } = req.ctx.decodedToken; const premiumFeaturesEnabled = req.ctx.configuration.users.premium.enabled; const userHasPremium = await UserDAL.checkIfUserIsPremium(uid); @@ -65,15 +74,12 @@ export async function getResults( ? req.ctx.configuration.results.limits.premiumUser : req.ctx.configuration.results.limits.regularUser; - const onOrAfterTimestamp = parseInt( - req.query["onOrAfterTimestamp"] as string, - 10 - ); - let limit = stringToNumberOrDefault( - req.query["limit"] as string, - Math.min(req.ctx.configuration.results.maxBatchSize, maxLimit) - ); - const offset = stringToNumberOrDefault(req.query["offset"] as string, 0); + const onOrAfterTimestamp = req.query.onOrAfterTimestamp; + let limit = + req.query.limit ?? + Math.min(req.ctx.configuration.results.maxBatchSize, maxLimit); + + const offset = req.query.offset ?? 0; //check if premium features are disabled and current call exceeds the limit for regular users if ( @@ -93,11 +99,11 @@ export async function getResults( } } - const results = await ResultDAL.getResults(uid, { + const results = (await ResultDAL.getResults(uid, { onOrAfterTimestamp, limit, offset, - }); + })) as unknown as Result[]; //TOOD clean mapping void Logger.logToDb( "user_results_requested", { @@ -108,30 +114,30 @@ export async function getResults( }, uid ); - return new MonkeyResponse("Results retrieved", results); + return new MonkeyResponse2("Results retrieved", results); } export async function getLastResult( - req: MonkeyTypes.Request -): Promise { + req: MonkeyTypes.Request2 +): Promise> { const { uid } = req.ctx.decodedToken; - const results = await ResultDAL.getLastResult(uid); - return new MonkeyResponse("Result retrieved", results); + const result = (await ResultDAL.getLastResult(uid)) as unknown as Result; //TODO clean mapping + return new MonkeyResponse2("Result retrieved", result); } export async function deleteAll( - req: MonkeyTypes.Request -): Promise { + req: MonkeyTypes.Request2 +): Promise> { const { uid } = req.ctx.decodedToken; await ResultDAL.deleteAll(uid); void Logger.logToDb("user_results_deleted", "", uid); - return new MonkeyResponse("All results deleted"); + return new MonkeyResponse2("All results deleted"); } export async function updateTags( - req: MonkeyTypes.Request -): Promise { + req: MonkeyTypes.Request2 +): Promise> { const { uid } = req.ctx.decodedToken; const { tagIds, resultId } = req.body; @@ -159,14 +165,14 @@ export async function updateTags( const user = await UserDAL.getUser(uid, "update tags"); const tagPbs = await UserDAL.checkIfTagPb(uid, user, result); - return new MonkeyResponse("Result tags updated", { + return new MonkeyResponse2("Result tags updated", { tagPbs, }); } export async function addResult( - req: MonkeyTypes.Request -): Promise { + req: MonkeyTypes.Request2 +): Promise> { const { uid } = req.ctx.decodedToken; const user = await UserDAL.getUser(uid, "add result"); @@ -178,10 +184,8 @@ export async function addResult( ); } - const completedEvent = Object.assign( - {}, - req.body.result - ) as SharedTypes.CompletedEvent; + const completedEvent = Object.assign({}, req.body.result) as CompletedEvent & + Result; if (!user.lbOptOut && completedEvent.acc < 75) { throw new MonkeyError( 400, @@ -199,7 +203,7 @@ export async function addResult( throw new MonkeyError(400, "Missing result hash"); } delete completedEvent.hash; - delete completedEvent.stringified; + //delete completedEvent.stringified; if (req.ctx.configuration.results.objectHashCheckEnabled) { const serverhash = objectHash(completedEvent); if (serverhash !== resulthash) { @@ -230,6 +234,7 @@ export async function addResult( if ( completedEvent.keySpacing !== "toolong" && + completedEvent.keySpacing !== undefined && completedEvent.keySpacing.length > 0 ) { completedEvent.keySpacingStats = { @@ -243,6 +248,7 @@ export async function addResult( if ( completedEvent.keyDuration !== "toolong" && + completedEvent.keyDuration !== undefined && completedEvent.keyDuration.length > 0 ) { completedEvent.keyDurationStats = { @@ -258,9 +264,9 @@ export async function addResult( if ( !validateResult( completedEvent, - ((req.headers["x-client-version"] as string) || - req.headers["client-version"]) as string, - JSON.stringify(new UAParser(req.headers["user-agent"]).getResult()), + ((req.raw.headers["x-client-version"] as string) || + req.raw.headers["client-version"]) as string, + JSON.stringify(new UAParser(req.raw.headers["user-agent"]).getResult()), user.lbOptOut === true ) ) { @@ -582,7 +588,7 @@ export async function addResult( ); } - const dbresult = buildDbResult(completedEvent, user.name, isPb); + const dbresult = buildDbResult(completedEvent as any, user.name, isPb); //TODO fix const addedResult = await ResultDAL.addResult(uid, dbresult); await UserDAL.incrementXp(uid, xpGained.xp); @@ -622,7 +628,7 @@ export async function addResult( incrementResult(completedEvent); - return new MonkeyResponse("Result saved", data); + return new MonkeyResponse2("Result saved", data as unknown as CreateResult); //TODO clean mapping } type XpResult = { @@ -632,7 +638,7 @@ type XpResult = { }; async function calculateXp( - result: SharedTypes.CompletedEvent, + result: Result, xpConfiguration: SharedTypes.Configuration["users"]["xp"], uid: string, currentTotalXp: number, diff --git a/backend/src/api/routes/index.ts b/backend/src/api/routes/index.ts index ff19bf061..f3ed5d364 100644 --- a/backend/src/api/routes/index.ts +++ b/backend/src/api/routes/index.ts @@ -5,7 +5,7 @@ import users from "./users"; import { join } from "path"; import quotes from "./quotes"; import configs from "./configs"; -import results from "./results"; +//import results from "./results"; import presets from "./presets"; import apeKeys from "./ape-keys"; import admin from "./admin"; @@ -35,7 +35,7 @@ const APP_START_TIME = Date.now(); const API_ROUTE_MAP = { "/users": users, "/configs": configs, - "/results": results, + //"/results": results, "/presets": presets, "/psas": psas, "/public": publicStats, diff --git a/backend/src/api/routes/index2.ts b/backend/src/api/routes/index2.ts index 3799a57b1..746a8df75 100644 --- a/backend/src/api/routes/index2.ts +++ b/backend/src/api/routes/index2.ts @@ -5,11 +5,13 @@ import { MonkeyResponse2 } from "../../utils/monkey-response"; import { contract } from "./../../../../shared/contract/index.contract"; import { configRoutes } from "./configsV2"; import { userRoutes } from "./usersV2"; +import { resultsRoutes } from "./results"; const s = initServer(); const router = s.router(contract, { users: userRoutes, configs: configRoutes, + results: resultsRoutes, }); export function applyApiRoutes(app: IRouter): void { diff --git a/backend/src/api/routes/results.ts b/backend/src/api/routes/results.ts index 1d147b7ad..8eef07070 100644 --- a/backend/src/api/routes/results.ts +++ b/backend/src/api/routes/results.ts @@ -1,87 +1,45 @@ -import * as ResultController from "../controllers/result"; -import resultSchema from "../schemas/result-schema"; -import { - asyncHandler, - validateRequest, - validateConfiguration, -} from "../../middlewares/api-utils"; -import * as RateLimit from "../../middlewares/rate-limit"; -import { Router } from "express"; -import { authenticateRequest } from "../../middlewares/auth"; -import joi from "joi"; +import { initServer } from "@ts-rest/express"; +import { resultsContract } from "../../../../shared/contract/results.contract"; import { withApeRateLimiter } from "../../middlewares/ape-rate-limit"; +import { validateConfiguration } from "../../middlewares/api-utils"; +import { authenticateRequestV2 } from "../../middlewares/auth"; +import * as RateLimit from "../../middlewares/rate-limit"; +import * as ResultController from "../controllers/result"; +import { callController } from "./index2"; -const router = Router(); +const s = initServer(); +export const resultsRoutes = s.router(resultsContract, { + get: { + middleware: [ + authenticateRequestV2({ acceptApeKeys: true }), + withApeRateLimiter(RateLimit.resultsGet, RateLimit.resultsGetApe) as any, //TODO + ], + handler: async (r) => callController(ResultController.getResults)(r), + }, + save: { + middleware: [ + validateConfiguration({ + criteria: (configuration) => { + return configuration.results.savingEnabled; + }, + invalidMessage: "Results are not being saved at this time.", + }), + authenticateRequestV2(), + RateLimit.resultsAdd, + ], + handler: async (r) => callController(ResultController.addResult)(r), + }, -router.get( - "/", - authenticateRequest({ - acceptApeKeys: true, - }), - withApeRateLimiter(RateLimit.resultsGet, RateLimit.resultsGetApe), - validateRequest({ - query: { - onOrAfterTimestamp: joi.number().integer().min(1589428800000), - limit: joi.number().integer().min(0).max(1000), - offset: joi.number().integer().min(0), - }, - }), - asyncHandler(ResultController.getResults) -); - -router.post( - "/", - validateConfiguration({ - criteria: (configuration) => { - return configuration.results.savingEnabled; - }, - invalidMessage: "Results are not being saved at this time.", - }), - authenticateRequest(), - RateLimit.resultsAdd, - validateRequest({ - body: { - result: resultSchema, - }, - }), - asyncHandler(ResultController.addResult) -); - -router.patch( - "/tags", - authenticateRequest(), - RateLimit.resultsTagsUpdate, - validateRequest({ - body: { - tagIds: joi - .array() - .items(joi.string().regex(/^[a-f\d]{24}$/i)) - .required(), - resultId: joi - .string() - .regex(/^[a-f\d]{24}$/i) - .required(), - }, - }), - asyncHandler(ResultController.updateTags) -); - -router.delete( - "/", - authenticateRequest({ - requireFreshToken: true, - }), - RateLimit.resultsDeleteAll, - asyncHandler(ResultController.deleteAll) -); - -router.get( - "/last", - authenticateRequest({ - acceptApeKeys: true, - }), - withApeRateLimiter(RateLimit.resultsGet), - asyncHandler(ResultController.getLastResult) -); - -export default router; + updateTags: { + middleware: [authenticateRequestV2(), RateLimit.resultsTagsUpdate], + handler: async (r) => callController(ResultController.updateTags)(r), + }, + delete: { + middleware: [authenticateRequestV2()], + handler: async (r) => callController(ResultController.deleteAll)(r), + }, + getLast: { + middleware: [authenticateRequestV2({ acceptApeKeys: true })], + handler: async (r) => callController(ResultController.getLastResult)(r), + }, +}); diff --git a/backend/src/dal/user.ts b/backend/src/dal/user.ts index 688044fa8..921deb2c0 100644 --- a/backend/src/dal/user.ts +++ b/backend/src/dal/user.ts @@ -9,6 +9,7 @@ import { flattenObjectDeep, isToday, isYesterday } from "../utils/misc"; import { getCachedConfiguration } from "../init/configuration"; import { getDayOfYear } from "date-fns"; import { UTCDate } from "@date-fns/utc"; +import type { Result as ResultType } from "../../../shared/contract/results.contract"; const SECONDS_PER_HOUR = 3600; @@ -417,7 +418,7 @@ export async function updateLbMemory( export async function checkIfPb( uid: string, user: MonkeyTypes.DBUser, - result: Result + result: ResultType ): Promise { const { mode } = result; @@ -460,7 +461,7 @@ export async function checkIfPb( export async function checkIfTagPb( uid: string, user: MonkeyTypes.DBUser, - result: Result + result: ResultType | Result ): Promise { if (user.tags === undefined || user.tags.length === 0) { return []; diff --git a/backend/src/utils/pb.ts b/backend/src/utils/pb.ts index bacad5d16..3e4fb34c8 100644 --- a/backend/src/utils/pb.ts +++ b/backend/src/utils/pb.ts @@ -1,5 +1,6 @@ import _ from "lodash"; import FunboxList from "../constants/funbox-list"; +import type { Result as ResultType } from "../../../shared/contract/results.contract"; type CheckAndUpdatePbResult = { isPb: boolean; @@ -12,7 +13,7 @@ type Result = Omit< "_id" | "name" >; -export function canFunboxGetPb(result: Result): boolean { +export function canFunboxGetPb(result: ResultType | Result): boolean { const funbox = result.funbox; if (funbox === undefined || funbox === "" || funbox === "none") return true; @@ -32,7 +33,7 @@ export function canFunboxGetPb(result: Result): boolean { export function checkAndUpdatePb( userPersonalBests: SharedTypes.PersonalBests, lbPersonalBests: MonkeyTypes.LbPersonalBests | undefined, - result: Result + result: ResultType | Result ): CheckAndUpdatePbResult { const mode = result.mode; const mode2 = result.mode2 as SharedTypes.Config.Mode2<"time">; @@ -66,7 +67,7 @@ export function checkAndUpdatePb( } function matchesPersonalBest( - result: Result, + result: ResultType | Result, personalBest: SharedTypes.PersonalBest ): boolean { if ( @@ -99,7 +100,7 @@ function matchesPersonalBest( function updatePersonalBest( personalBest: SharedTypes.PersonalBest, - result: Result + result: ResultType | Result ): boolean { if (personalBest.wpm >= result.wpm) { return false; @@ -133,7 +134,9 @@ function updatePersonalBest( return true; } -function buildPersonalBest(result: Result): SharedTypes.PersonalBest { +function buildPersonalBest( + result: ResultType | Result +): SharedTypes.PersonalBest { if ( result.difficulty === undefined || result.language === undefined || @@ -164,7 +167,7 @@ function buildPersonalBest(result: Result): SharedTypes.PersonalBest { function updateLeaderboardPersonalBests( userPersonalBests: SharedTypes.PersonalBests, lbPersonalBests: MonkeyTypes.LbPersonalBests, - result: Result + result: ResultType | Result ): void { if (!shouldUpdateLeaderboardPersonalBests(result)) { return; @@ -207,7 +210,7 @@ function updateLeaderboardPersonalBests( ); } -function shouldUpdateLeaderboardPersonalBests(result: Result): boolean { +function shouldUpdateLeaderboardPersonalBests(result: ResultType): boolean { const isValidTimeMode = result.mode === "time" && (result.mode2 === "15" || result.mode2 === "60"); return isValidTimeMode && !result.lazyMode; diff --git a/backend/src/utils/prometheus.ts b/backend/src/utils/prometheus.ts index 058f57aa4..fb1156a3f 100644 --- a/backend/src/utils/prometheus.ts +++ b/backend/src/utils/prometheus.ts @@ -1,6 +1,7 @@ import "dotenv/config"; import { Counter, Histogram, Gauge } from "prom-client"; import { TsRestRequest } from "@ts-rest/express"; +import { Result } from "@shared/contract/results.contract"; const auth = new Counter({ name: "api_request_auth_total", @@ -89,9 +90,7 @@ export function setLeaderboard( leaderboardUpdate.set({ language, mode, mode2, step: "index" }, times[3]); } -export function incrementResult( - res: SharedTypes.Result -): void { +export function incrementResult(res: Result): void { const { mode, mode2, diff --git a/backend/src/utils/validation.ts b/backend/src/utils/validation.ts index 6120130a0..d55765fb9 100644 --- a/backend/src/utils/validation.ts +++ b/backend/src/utils/validation.ts @@ -3,6 +3,7 @@ import { replaceHomoglyphs } from "../constants/homoglyphs"; import { profanities, regexProfanities } from "../constants/profanities"; import { intersect, matchesAPattern, sanitizeString } from "./misc"; import { default as FunboxList } from "../constants/funbox-list"; +import { CompletedEvent } from "@shared/contract/results.contract"; export function inRange(value: number, min: number, max: number): boolean { return value >= min && value <= max; @@ -57,7 +58,7 @@ export function isTagPresetNameValid(name: string): boolean { return VALID_NAME_PATTERN.test(name); } -export function isTestTooShort(result: SharedTypes.CompletedEvent): boolean { +export function isTestTooShort(result: CompletedEvent): boolean { const { mode, mode2, customText, testDuration, bailedOut } = result; if (mode === "time") { diff --git a/frontend/src/ts/ape/endpoints/index.ts b/frontend/src/ts/ape/endpoints/index.ts index ecc621a77..fac87ff7b 100644 --- a/frontend/src/ts/ape/endpoints/index.ts +++ b/frontend/src/ts/ape/endpoints/index.ts @@ -6,7 +6,7 @@ import Presets from "./presets"; import Psas from "./psas"; import Public from "./public"; import Quotes from "./quotes"; -import Results from "./results"; +//import Results from "./results"; import Users from "./users"; export default { @@ -16,7 +16,7 @@ export default { Psas, Public, Quotes, - Results, + //Results, Users, ApeKeys, Configuration, diff --git a/frontend/src/ts/ape/index.ts b/frontend/src/ts/ape/index.ts index 56b92266d..9ed86f7b1 100644 --- a/frontend/src/ts/ape/index.ts +++ b/frontend/src/ts/ape/index.ts @@ -5,6 +5,7 @@ import axios from "axios"; import { buildClient } from "./endpoints/ApeClient"; import { configContract } from "./../../../../shared/contract/config.contract"; import { userContract } from "./../../../../shared/contract/user.contract"; +import { resultsContract } from "./../../../../shared/contract/results.contract"; const API_PATH = ""; const BASE_URL = envConfig.backendUrl; @@ -20,7 +21,7 @@ const axiosClient = axios.create({ const Ape = { users: new endpoints.Users(httpClient), configs: new endpoints.Configs(httpClient), - results: new endpoints.Results(httpClient), + //results: new endpoints.Results(httpClient), psas: new endpoints.Psas(httpClient), quotes: new endpoints.Quotes(httpClient), leaderboards: new endpoints.Leaderboards(httpClient), @@ -30,6 +31,7 @@ const Ape = { configuration: new endpoints.Configuration(httpClient), usersV2: buildClient(userContract, axios, BASE_URL), configsV2: buildClient(configContract, axios, BASE_URL), + results: buildClient(resultsContract, axios, BASE_URL), }; export default Ape; diff --git a/frontend/src/ts/controllers/account-controller.ts b/frontend/src/ts/controllers/account-controller.ts index 07beae5b5..17e34d504 100644 --- a/frontend/src/ts/controllers/account-controller.ts +++ b/frontend/src/ts/controllers/account-controller.ts @@ -44,6 +44,7 @@ import { import * as ConnectionState from "../states/connection"; import { navigate } from "./route-controller"; import { getHtmlByUserFlags } from "./user-flag-controller"; +import { MonkeyErrorType } from "@shared/contract/common.contract"; let signedOutThisSession = false; @@ -211,11 +212,14 @@ export async function loadUser(user: UserType): Promise { if (TestLogic.notSignedInLastResult !== null && !signedOutThisSession) { TestLogic.setNotSignedInUid(user.uid); - const response = await Ape.results.save(TestLogic.notSignedInLastResult); + const response = await Ape.results.save({ + body: { result: TestLogic.notSignedInLastResult }, + }); if (response.status !== 200) { return Notifications.add( - "Failed to save last result: " + response.message, + "Failed to save last result: " + + (response.body as MonkeyErrorType).message, -1 ); } @@ -602,7 +606,9 @@ async function signUp(): Promise { if (TestLogic.notSignedInLastResult !== null) { TestLogic.setNotSignedInUid(createdAuthUser.user.uid); - const response = await Ape.results.save(TestLogic.notSignedInLastResult); + const response = await Ape.results.save({ + body: { result: TestLogic.notSignedInLastResult }, + }); if (response.status === 200) { const result = TestLogic.notSignedInLastResult; diff --git a/frontend/src/ts/db.ts b/frontend/src/ts/db.ts index b4878a567..3727a2c3a 100644 --- a/frontend/src/ts/db.ts +++ b/frontend/src/ts/db.ts @@ -14,6 +14,8 @@ import { ModifiableTestActivityCalendar, } from "./elements/test-activity-calendar"; import * as Loader from "./elements/loader"; +import { CompletedEvent, Result } from "@shared/contract/results.contract"; +import { MonkeyErrorType } from "@shared/contract/common.contract"; let dbSnapshot: MonkeyTypes.Snapshot | undefined; @@ -275,15 +277,17 @@ export async function getUserResults(offset?: number): Promise { LoadingPage.updateBar(90); } - const response = await Ape.results.get(offset); + const response = await Ape.results.get({ query: { offset: 0 } }); if (response.status !== 200) { - Notifications.add("Error getting results: " + response.message, -1); + Notifications.add( + "Error getting results: " + (response.body as MonkeyErrorType).message, + -1 + ); return false; } - const results = - response.data as SharedTypes.DBResult[]; + const results = response.body.data; results?.sort((a, b) => b.timestamp - a.timestamp); results.forEach((result) => { if (result.bailedOut === undefined) result.bailedOut = false; @@ -910,7 +914,7 @@ export async function saveConfig(config: SharedTypes.Config): Promise { } export function saveLocalResult( - result: SharedTypes.Result + result: SharedTypes.Result | CompletedEvent ): void { const snapshot = getSnapshot(); if (!snapshot) return; diff --git a/frontend/src/ts/modals/edit-result-tags.ts b/frontend/src/ts/modals/edit-result-tags.ts index 6c373e876..c6ad821b3 100644 --- a/frontend/src/ts/modals/edit-result-tags.ts +++ b/frontend/src/ts/modals/edit-result-tags.ts @@ -7,6 +7,7 @@ import * as ConnectionState from "../states/connection"; import { areUnsortedArraysEqual } from "../utils/arrays"; import * as Result from "../test/result"; import AnimatedModal from "../utils/animated-modal"; +import { MonkeyErrorType } from "@shared/contract/common.contract"; type State = { resultId: string; @@ -104,7 +105,9 @@ function toggleTag(tagId: string): void { async function save(): Promise { Loader.show(); - const response = await Ape.results.updateTags(state.resultId, state.tags); + const response = await Ape.results.updateTags({ + body: { resultId: state.resultId, tagIds: state.tags }, + }); Loader.hide(); //if got no freaking idea why this is needed @@ -114,13 +117,14 @@ async function save(): Promise { if (response.status !== 200) { return Notifications.add( - "Failed to update result tags: " + response.message, + "Failed to update result tags: " + + (response.body as MonkeyErrorType).message, -1 ); } //can do this because the response will not be null if the status is 200 - const responseTagPbs = response.data?.tagPbs ?? []; + const responseTagPbs = response.body.data?.tagPbs ?? []; Notifications.add("Tags updated", 1, { duration: 2, diff --git a/frontend/src/ts/modals/google-sign-up.ts b/frontend/src/ts/modals/google-sign-up.ts index 0a32a472f..edf605fad 100644 --- a/frontend/src/ts/modals/google-sign-up.ts +++ b/frontend/src/ts/modals/google-sign-up.ts @@ -95,9 +95,9 @@ async function apply(): Promise { if (TestLogic.notSignedInLastResult !== null) { TestLogic.setNotSignedInUid(signedInUser.user.uid); - const resultsSaveResponse = await Ape.results.save( - TestLogic.notSignedInLastResult - ); + const resultsSaveResponse = await Ape.results.save({ + body: { result: TestLogic.notSignedInLastResult }, + }); if (resultsSaveResponse.status === 200) { const result = TestLogic.notSignedInLastResult; diff --git a/frontend/src/ts/test/test-logic.ts b/frontend/src/ts/test/test-logic.ts index 5f5afc1fb..166cb19a0 100644 --- a/frontend/src/ts/test/test-logic.ts +++ b/frontend/src/ts/test/test-logic.ts @@ -58,11 +58,12 @@ import * as KeymapEvent from "../observables/keymap-event"; import * as LayoutfluidFunboxTimer from "../test/funbox/layoutfluid-funbox-timer"; import * as ArabicLazyMode from "../states/arabic-lazy-mode"; import Format from "../utils/format"; +import { CompletedEvent } from "@shared/contract/results.contract"; let failReason = ""; const koInputVisual = document.getElementById("koInputVisual") as HTMLElement; -export let notSignedInLastResult: SharedTypes.CompletedEvent | null = null; +export let notSignedInLastResult: CompletedEvent | null = null; export function clearNotSignedInResult(): void { notSignedInLastResult = null; diff --git a/frontend/src/ts/types/types.d.ts b/frontend/src/ts/types/types.d.ts index 8f06cab99..00813c040 100644 --- a/frontend/src/ts/types/types.d.ts +++ b/frontend/src/ts/types/types.d.ts @@ -1,3 +1,5 @@ +import { CompletedEvent } from "@shared/contract/results.contract"; + declare namespace MonkeyTypes { type PageName = | "loading" @@ -235,7 +237,7 @@ declare namespace MonkeyTypes { config: SharedTypes.Config; tags: UserTag[]; presets: SnapshotPreset[]; - results?: SharedTypes.Result[]; + results?: SharedTypes.Result[] | CompletedEvent[]; xp: number; testActivity?: ModifiableTestActivityCalendar; testActivityByYear?: { [key: string]: TestActivityCalendar }; diff --git a/shared/contract/common.contract.ts b/shared/contract/common.contract.ts index 0ee398235..bab84a5fc 100644 --- a/shared/contract/common.contract.ts +++ b/shared/contract/common.contract.ts @@ -13,3 +13,46 @@ export const MonkeyErrorSchema = z.object({ uid: z.string().optional(), }); export type MonkeyErrorType = z.infer; + +export const CustomTextModeSchema = z.enum(["repeat", "random", "suffle"]); +export type CustomTextMode = z.infer; + +export const CustomTextLimitModeSchema = z.enum(["word", "time", "section"]); +export type CustomTextLimitMode = z.infer; + +export const DifficultySchema = z.enum(["normal", "expert", "master"]); +export type Difficulty = z.infer; + +const StringNumberSchema = z.custom<`${number}`>((val) => { + return typeof val === "string" ? /^\d+$/.test(val) : false; +}); +export type StringNumber = z.infer; +export const PersonalBestSchema = z.object({}); //TODO define +export type PersonnalBest = z.infer; + +export const PersonalBestsSchema = z.object({ + time: z.record(StringNumberSchema, z.array(PersonalBestSchema)), + words: z.record(StringNumberSchema, z.array(PersonalBestSchema)), + quote: z.record(StringNumberSchema, z.array(PersonalBestSchema)), + custom: z.record(z.literal("custom"), z.array(PersonalBestSchema)), + zen: z.record(z.literal("zen"), z.array(PersonalBestSchema)), +}); +export type PersonalBests = z.infer; + +export const ModeSchema = PersonalBestsSchema.keyof(); +export type Mode = z.infer; + +export const Mode2Schema = z.union([ + StringNumberSchema, + z.literal("custom"), + z.literal("zen"), +]); +export type Mode2 = z.infer; + +export const KeyStatsSchema = z.object({ + average: z.number(), + sd: z.number(), +}); +export type KeyStats = z.infer; + +export const IdSchema = z.string().regex(/^[a-f\d]{24}$/i); diff --git a/shared/contract/index.contract.ts b/shared/contract/index.contract.ts index 2ae8240cb..784221900 100644 --- a/shared/contract/index.contract.ts +++ b/shared/contract/index.contract.ts @@ -1,10 +1,12 @@ import { initContract } from "@ts-rest/core"; import { userContract } from "./user.contract"; import { configContract } from "./config.contract"; +import { resultsContract } from "./results.contract"; const c = initContract(); export const contract = c.router({ users: userContract, configs: configContract, + results: resultsContract, }); diff --git a/shared/contract/results.contract.ts b/shared/contract/results.contract.ts new file mode 100644 index 000000000..fc90031f9 --- /dev/null +++ b/shared/contract/results.contract.ts @@ -0,0 +1,182 @@ +import { initContract } from "@ts-rest/core"; +import { z } from "zod"; +import * as Common from "./common.contract"; +import { MonkeyResponseSchema, MonkeyErrorSchema } from "./common.contract"; + +const c = initContract(); + +//@ts-ignore +const token = () => z.string().regex(/^[\w.]+/); + +const x = typeof token(); + +const ChartDataSchema = z.object({ + wpm: z.array(z.number().min(0)).max(122), + raw: z.array(z.number().min(0)).max(122), + err: z.array(z.number().min(0)).max(122), +}); + +const GetResultsQuerySchema = z.object({ + onOrAfterTimestamp: z.number().int().min(1589428800000).optional(), + limit: z.number().int().min(0).max(1000).optional(), + offset: z.number().int().min(0).optional(), +}); +export type GetResultsQuery = z.infer; + +const ResultSchema = z.object({ + _id: z.string().readonly(), + acc: z.number().min(50).max(100), + afkDuration: z.number().min(0), + bailedOut: z.boolean(), + blindMode: z.boolean(), + charStats: z.array(z.number().min(0)).length(4), + chartData: ChartDataSchema.or(z.literal("toolong")), + consistency: z.number().min(0).max(100), + difficulty: Common.DifficultySchema, + funbox: z + .string() + .max(100) + .regex(/[\w#]+/), + incompleteTestSeconds: z.number().min(0), + incompleteTests: z.array( + z.object({ + acc: z.number().min(0).max(100), + seconds: z.number().min(0), + }) + ), + isPb: z.boolean(), + keyConsistency: z.number().min(0).max(100), + keyDurationStats: Common.KeyStatsSchema, + keySpacingStats: Common.KeyStatsSchema, + language: token().max(100), + lazyMode: z.boolean(), + mode: Common.ModeSchema, + mode2: Common.Mode2Schema, + name: z.string(), + numbers: z.boolean(), + punctuation: z.boolean(), + quoteLength: z.number().min(0).max(3).optional(), + rawWpm: z.number().min(0).readonly(), + restartCount: z.number(), + tags: z.array(Common.IdSchema), + testDuration: z.number().min(1), + timestamp: z.number().int().min(1589428800000), + uid: token().max(100), + wpm: z.number().min(0).max(420), +}); +export type Result = z.infer; + +const GetResultsSchema = z.array(ResultSchema); +export type GetResults = z.infer; + +const CompletedEventSchema = ResultSchema.omit({ + _id: true, + isPb: true, + keyDurationStats: true, + keySpacingStats: true, + rawWpm: true, +}).extend({ + challenge: token().max(100).optional(), + charTotal: z.number().min(0).optional(), + customText: z + .object({ + textLen: z.number(), + mode: Common.CustomTextModeSchema, + pipeDelimiter: z.boolean(), + limit: z.object({ + mode: Common.CustomTextLimitModeSchema, + value: z.number().min(0), + }), + }) + .optional(), + hash: token().max(100).optional(), + keyDuration: z.array(z.number().min(0)).or(z.literal("toolong")).optional(), + keySpacing: z.array(z.number().min(0)).or(z.literal("toolong")).optional(), + keyOverlap: z.number().min(0).optional(), + lastKeyToEnd: z.number().min(0).optional(), + startToFirstKey: z.number().min(0).optional(), + wpmConsistency: z.number().min(0).max(100), + stopOnLetter: z.boolean(), +}); +export type CompletedEvent = z.infer; + +const CreateResultBodySchema = z.object({ + result: CompletedEventSchema, +}); +export type CreateResultBody = z.infer; + +const CreateResultSchema = z.object({ + isPb: z.boolean(), + tagPbs: z.array(z.string()), + insertedId: z.string(), + dailyLeaderboardRank: z.number().optional(), + weeklyXpLeaderboardRank: z.number().optional(), + xp: z.number(), + dailyXpBonus: z.boolean(), + xpBreakdown: z.record(z.string(), z.number()), + streak: z.number(), +}); +export type CreateResult = z.infer; + +const UpdateTagsBodySchema = z.object({ + tagIds: z.array(Common.IdSchema), + resultId: Common.IdSchema, +}); +export type UpdateTagsBody = z.infer; + +const UpdateTagsSchema = z.object({ + tagPbs: z.array(Common.IdSchema), +}); +export type UpdateTags = z.infer; + +export const resultsContract = c.router( + { + get: { + method: "GET", + path: "/", + query: GetResultsQuerySchema, + responses: { + 200: MonkeyResponseSchema.extend({ data: GetResultsSchema }), + 400: MonkeyErrorSchema, + }, + }, + save: { + method: "POST", + path: "/", + body: CreateResultBodySchema, + responses: { + 200: MonkeyResponseSchema.extend({ data: CreateResultSchema }), + 400: MonkeyErrorSchema, + }, + }, + delete: { + method: "DELETE", + path: "/", + body: z.object({}), + responses: { + 200: MonkeyResponseSchema, + 400: MonkeyErrorSchema, + }, + }, + getLast: { + method: "GET", + path: "/last", + responses: { + 200: MonkeyResponseSchema.extend({ data: ResultSchema }), + 400: MonkeyErrorSchema, + }, + }, + updateTags: { + method: "PATCH", + path: "/tags", + body: UpdateTagsBodySchema, + responses: { + 200: MonkeyResponseSchema.extend({ data: UpdateTagsSchema }), + 400: MonkeyErrorSchema, + }, + }, + }, + { + pathPrefix: "/results", + } +); diff --git a/shared/contract/user.contract.ts b/shared/contract/user.contract.ts index 036102ee5..551d9b975 100644 --- a/shared/contract/user.contract.ts +++ b/shared/contract/user.contract.ts @@ -41,6 +41,5 @@ export const userContract = c.router( }, { pathPrefix: "/v2/users", - strictStatusCodes: true, } ); diff --git a/shared/tsconfig.json b/shared/tsconfig.json index 5de1b0862..82ac85ceb 100644 --- a/shared/tsconfig.json +++ b/shared/tsconfig.json @@ -13,5 +13,6 @@ "types": ["node"], "declaration": true }, + "include": ["./types/**/*"], "exclude": ["node_modules", "./dist/**/*"] } diff --git a/shared/types/config.d.ts b/shared/types/config.d.ts index b9575081b..23fa45e63 100644 --- a/shared/types/config.d.ts +++ b/shared/types/config.d.ts @@ -12,9 +12,9 @@ declare namespace SharedTypes.Config { | "underline" | "carrot" | "banana"; - type Difficulty = "normal" | "expert" | "master"; - type Mode = keyof PersonalBests; - type Mode2 = M extends M ? keyof PersonalBests[M] : never; + type Difficulty = "normal" | "expert" | "master"; //TODO moved to contract/common + type Mode = keyof PersonalBests; //TODO moved to contract/common + type Mode2 = M extends M ? keyof PersonalBests[M] : never; //TODO moved to contract/common type Mode2Custom = Mode2 | "custom"; type ConfidenceMode = "off" | "on" | "max"; type IndicateTypos = "off" | "below" | "replace"; diff --git a/shared/types/types.d.ts b/shared/types/types.d.ts index 5e89f59f1..ff9b3821e 100644 --- a/shared/types/types.d.ts +++ b/shared/types/types.d.ts @@ -1,4 +1,6 @@ // Shared types between server/client. +//const ResultSchemas = import("../contract/results.contract"); + declare namespace SharedTypes { interface ValidModeRule { language: string; @@ -111,9 +113,10 @@ declare namespace SharedTypes { }; } - type StringNumber = `${number}`; + type StringNumber = `${number}`; //TODO moved to contract/custom interface PersonalBest { + //TODO moved to contract/custom acc: number; consistency?: number; difficulty: SharedTypes.Config.Difficulty; @@ -127,6 +130,7 @@ declare namespace SharedTypes { } interface PersonalBests { + //TODO moved to contract/custom time: Record; words: Record; quote: Record; @@ -146,6 +150,7 @@ declare namespace SharedTypes { } interface KeyStats { + //TODO moved to contracts/common average: number; sd: number; } @@ -184,7 +189,7 @@ declare namespace SharedTypes { } type DBResult = Omit< - SharedTypes.Result, + Result, | "bailedOut" | "blindMode" | "lazyMode" @@ -226,6 +231,7 @@ declare namespace SharedTypes { }; interface CompletedEvent extends Result { + //TODO moved to contracts/result keySpacing: number[] | "toolong"; keyDuration: number[] | "toolong"; customText?: CustomTextDataWithTextLen; @@ -240,8 +246,8 @@ declare namespace SharedTypes { stopOnLetter: boolean; } - type CustomTextMode = "repeat" | "random" | "shuffle"; - type CustomTextLimitMode = "word" | "time" | "section"; + type CustomTextMode = "repeat" | "random" | "shuffle"; //TODO moved to contract/common + type CustomTextLimitMode = "word" | "time" | "section"; //TODO moved to contract/common type CustomTextLimit = { value: number; mode: CustomTextLimitMode; @@ -463,6 +469,7 @@ declare namespace SharedTypes { } type PostResultResponse = { + //TODO moved to contracts/results isPb: boolean; tagPbs: string[]; insertedId: string;