convert results, wip

This commit is contained in:
Christian Fehmer 2024-06-03 16:34:57 +02:00
parent 6fac5dd3a9
commit a711c891a7
No known key found for this signature in database
GPG key ID: FE53784A69964062
24 changed files with 389 additions and 166 deletions

View file

@ -1,4 +1,4 @@
#!/usr/bin/env sh
. "$(dirname -- "$0")/_/husky.sh"
npm run pre-commit
#npm run pre-commit

View file

@ -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<MonkeyResponse> {
req: MonkeyTypes.Request2<GetResultsQuery>
): Promise<MonkeyResponse2<GetResults>> {
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<MonkeyResponse> {
req: MonkeyTypes.Request2
): Promise<MonkeyResponse2<Result>> {
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<MonkeyResponse> {
req: MonkeyTypes.Request2
): Promise<MonkeyResponse2<UpdateTags>> {
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<MonkeyResponse> {
req: MonkeyTypes.Request2<never, UpdateTagsBody>
): Promise<MonkeyResponse2<UpdateTags>> {
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<MonkeyResponse> {
req: MonkeyTypes.Request2<never, CreateResultBody>
): Promise<MonkeyResponse2<CreateResult>> {
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,

View file

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

View file

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

View file

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

View file

@ -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<boolean> {
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<string[]> {
if (user.tags === undefined || user.tags.length === 0) {
return [];

View file

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

View file

@ -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<SharedTypes.Config.Mode>
): void {
export function incrementResult(res: Result): void {
const {
mode,
mode2,

View file

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

View file

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

View file

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

View file

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

View file

@ -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<boolean> {
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<SharedTypes.Config.Mode>[];
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<void> {
}
export function saveLocalResult(
result: SharedTypes.Result<SharedTypes.Config.Mode>
result: SharedTypes.Result<SharedTypes.Config.Mode> | CompletedEvent
): void {
const snapshot = getSnapshot();
if (!snapshot) return;

View file

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

View file

@ -95,9 +95,9 @@ async function apply(): Promise<void> {
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;

View file

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

View file

@ -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<SharedTypes.Config.Mode>[];
results?: SharedTypes.Result<SharedTypes.Config.Mode>[] | CompletedEvent[];
xp: number;
testActivity?: ModifiableTestActivityCalendar;
testActivityByYear?: { [key: string]: TestActivityCalendar };

View file

@ -13,3 +13,46 @@ export const MonkeyErrorSchema = z.object({
uid: z.string().optional(),
});
export type MonkeyErrorType = z.infer<typeof MonkeyErrorSchema>;
export const CustomTextModeSchema = z.enum(["repeat", "random", "suffle"]);
export type CustomTextMode = z.infer<typeof CustomTextModeSchema>;
export const CustomTextLimitModeSchema = z.enum(["word", "time", "section"]);
export type CustomTextLimitMode = z.infer<typeof CustomTextLimitModeSchema>;
export const DifficultySchema = z.enum(["normal", "expert", "master"]);
export type Difficulty = z.infer<typeof DifficultySchema>;
const StringNumberSchema = z.custom<`${number}`>((val) => {
return typeof val === "string" ? /^\d+$/.test(val) : false;
});
export type StringNumber = z.infer<typeof StringNumberSchema>;
export const PersonalBestSchema = z.object({}); //TODO define
export type PersonnalBest = z.infer<typeof PersonalBestsSchema>;
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<typeof PersonalBestsSchema>;
export const ModeSchema = PersonalBestsSchema.keyof();
export type Mode = z.infer<typeof ModeSchema>;
export const Mode2Schema = z.union([
StringNumberSchema,
z.literal("custom"),
z.literal("zen"),
]);
export type Mode2 = z.infer<typeof Mode2Schema>;
export const KeyStatsSchema = z.object({
average: z.number(),
sd: z.number(),
});
export type KeyStats = z.infer<typeof KeyStatsSchema>;
export const IdSchema = z.string().regex(/^[a-f\d]{24}$/i);

View file

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

View file

@ -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<typeof GetResultsQuerySchema>;
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<typeof ResultSchema>;
const GetResultsSchema = z.array(ResultSchema);
export type GetResults = z.infer<typeof GetResultsSchema>;
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<typeof CompletedEventSchema>;
const CreateResultBodySchema = z.object({
result: CompletedEventSchema,
});
export type CreateResultBody = z.infer<typeof CreateResultBodySchema>;
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<typeof CreateResultSchema>;
const UpdateTagsBodySchema = z.object({
tagIds: z.array(Common.IdSchema),
resultId: Common.IdSchema,
});
export type UpdateTagsBody = z.infer<typeof UpdateTagsBodySchema>;
const UpdateTagsSchema = z.object({
tagPbs: z.array(Common.IdSchema),
});
export type UpdateTags = z.infer<typeof UpdateTagsSchema>;
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",
}
);

View file

@ -41,6 +41,5 @@ export const userContract = c.router(
},
{
pathPrefix: "/v2/users",
strictStatusCodes: true,
}
);

View file

@ -13,5 +13,6 @@
"types": ["node"],
"declaration": true
},
"include": ["./types/**/*"],
"exclude": ["node_modules", "./dist/**/*"]
}

View file

@ -12,9 +12,9 @@ declare namespace SharedTypes.Config {
| "underline"
| "carrot"
| "banana";
type Difficulty = "normal" | "expert" | "master";
type Mode = keyof PersonalBests;
type Mode2<M extends Mode> = 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 Mode> = M extends M ? keyof PersonalBests[M] : never; //TODO moved to contract/common
type Mode2Custom<M extends Mode> = Mode2<M> | "custom";
type ConfidenceMode = "off" | "on" | "max";
type IndicateTypos = "off" | "below" | "replace";

View file

@ -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<StringNumber, PersonalBest[]>;
words: Record<StringNumber, PersonalBest[]>;
quote: Record<StringNumber, PersonalBest[]>;
@ -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<T extends SharedTypes.Config.Mode> = Omit<
SharedTypes.Result<T>,
Result<T>,
| "bailedOut"
| "blindMode"
| "lazyMode"
@ -226,6 +231,7 @@ declare namespace SharedTypes {
};
interface CompletedEvent extends Result<SharedTypes.Config.Mode> {
//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;