impr: use ts-rest for results endpoint (@fehmer) (#5758)

!nuf
This commit is contained in:
Christian Fehmer 2024-08-23 12:13:50 +02:00 committed by GitHub
parent abdd66a27b
commit e03a25fb92
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
51 changed files with 1453 additions and 1214 deletions

View file

@ -1,11 +1,14 @@
import request from "supertest";
import app from "../../../src/app";
import _ from "lodash";
import _, { omit } from "lodash";
import * as Configuration from "../../../src/init/configuration";
import * as ResultDal from "../../../src/dal/result";
import * as UserDal from "../../../src/dal/user";
import * as LogsDal from "../../../src/dal/logs";
import * as AuthUtils from "../../../src/utils/auth";
import { DecodedIdToken } from "firebase-admin/lib/auth/token-verifier";
import { ObjectId } from "mongodb";
import { mockAuthenticateWithApeKey } from "../../__testData__/auth";
const uid = "123456";
const mockDecodedToken: DecodedIdToken = {
@ -14,30 +17,69 @@ const mockDecodedToken: DecodedIdToken = {
iat: 0,
} as DecodedIdToken;
vi.spyOn(AuthUtils, "verifyIdToken").mockResolvedValue(mockDecodedToken);
const resultMock = vi.spyOn(ResultDal, "getResults");
const mockApp = request(app);
const configuration = Configuration.getCachedConfiguration();
describe("result controller test", () => {
const verifyIdTokenMock = vi.spyOn(AuthUtils, "verifyIdToken");
beforeEach(() => {
verifyIdTokenMock.mockReset();
verifyIdTokenMock.mockResolvedValue(mockDecodedToken);
});
describe("getResults", () => {
const resultMock = vi.spyOn(ResultDal, "getResults");
beforeEach(async () => {
resultMock.mockResolvedValue([]);
await enablePremiumFeatures(true);
vi.spyOn(UserDal, "checkIfUserIsPremium").mockResolvedValue(false);
});
afterEach(() => {
resultMock.mockReset();
});
it("should get latest 1000 results for regular user", async () => {
it("should get results", async () => {
//GIVEN
vi.spyOn(UserDal, "checkIfUserIsPremium").mockResolvedValue(false);
const resultOne = givenDbResult(uid);
const resultTwo = givenDbResult(uid);
resultMock.mockResolvedValue([resultOne, resultTwo]);
//WHEN
const { body } = await mockApp
.get("/results")
.set("Authorization", `Bearer ${uid}`)
.send()
.expect(200);
//THEN
expect(body.message).toEqual("Results retrieved");
expect(body.data).toEqual([
{ ...resultOne, _id: resultOne._id.toHexString() },
{ ...resultTwo, _id: resultTwo._id.toHexString() },
]);
});
it("should get results with ape key", async () => {
//GIVEN
await acceptApeKeys(true);
const apeKey = await mockAuthenticateWithApeKey(uid, await configuration);
//WHEN
await mockApp
.get("/results")
.set("Authorization", "Bearer 123456789")
.set("Authorization", `ApeKey ${apeKey}`)
.send()
.expect(200);
});
it("should get latest 1000 results for regular user", async () => {
//WHEN
await mockApp
.get("/results")
.set("Authorization", `Bearer ${uid}`)
.send()
.expect(200);
@ -51,12 +93,11 @@ describe("result controller test", () => {
it("should get results filter by onOrAfterTimestamp", async () => {
//GIVEN
const now = Date.now();
vi.spyOn(UserDal, "checkIfUserIsPremium").mockResolvedValue(false);
//WHEN
await mockApp
.get("/results")
.query({ onOrAfterTimestamp: now })
.set("Authorization", "Bearer 123456789")
.set("Authorization", `Bearer ${uid}`)
.send()
.expect(200);
@ -69,14 +110,11 @@ describe("result controller test", () => {
});
});
it("should get with limit and offset", async () => {
//GIVEN
vi.spyOn(UserDal, "checkIfUserIsPremium").mockResolvedValue(false);
//WHEN
await mockApp
.get("/results")
.query({ limit: 250, offset: 500 })
.set("Authorization", "Bearer 123456789")
.set("Authorization", `Bearer ${uid}`)
.send()
.expect(200);
@ -88,27 +126,20 @@ describe("result controller test", () => {
});
});
it("should fail exceeding max limit for regular user", async () => {
//GIVEN
vi.spyOn(UserDal, "checkIfUserIsPremium").mockResolvedValue(false);
//WHEN
await mockApp
const { body } = await mockApp
.get("/results")
.query({ limit: 100, offset: 1000 })
.set("Authorization", "Bearer 123456789")
.set("Authorization", `Bearer ${uid}`)
.send()
.expect(422)
.expect(
expectErrorMessage(
`Max results limit of ${
(
await configuration
).results.limits.regularUser
} exceeded.`
)
);
.expect(422);
//THEN
expect(body.message).toEqual(
`Max results limit of ${
(await configuration).results.limits.regularUser
} exceeded.`
);
});
it("should get with higher max limit for premium user", async () => {
//GIVEN
@ -118,7 +149,7 @@ describe("result controller test", () => {
await mockApp
.get("/results")
.query({ limit: 800, offset: 600 })
.set("Authorization", "Bearer 123456789")
.set("Authorization", `Bearer ${uid}`)
.send()
.expect(200);
@ -131,14 +162,11 @@ describe("result controller test", () => {
});
});
it("should get results if offset/limit is partly outside the max limit", async () => {
//GIVEN
vi.spyOn(UserDal, "checkIfUserIsPremium").mockResolvedValue(false);
//WHEN
await mockApp
.get("/results")
.query({ limit: 20, offset: 990 })
.set("Authorization", "Bearer 123456789")
.set("Authorization", `Bearer ${uid}`)
.send()
.expect(200);
@ -155,42 +183,36 @@ describe("result controller test", () => {
vi.spyOn(UserDal, "checkIfUserIsPremium").mockResolvedValue(false);
//WHEN
await mockApp
const { body } = await mockApp
.get("/results")
.query({ limit: 2000 })
.set("Authorization", "Bearer 123456789")
.set("Authorization", `Bearer ${uid}`)
.send()
.expect(422)
.expect(
expectErrorMessage(
'"limit" must be less than or equal to 1000 (2000)'
)
);
.expect(422);
//THEN
expect(body).toEqual({
message: "Invalid query schema",
validationErrors: ['"limit" Number must be less than or equal to 1000'],
});
});
it("should fail exceeding maxlimit for premium user", async () => {
//GIVEN
vi.spyOn(UserDal, "checkIfUserIsPremium").mockResolvedValue(true);
//WHEN
await mockApp
const { body } = await mockApp
.get("/results")
.query({ limit: 1000, offset: 25000 })
.set("Authorization", "Bearer 123456789")
.set("Authorization", `Bearer ${uid}`)
.send()
.expect(422)
.expect(
expectErrorMessage(
`Max results limit of ${
(
await configuration
).results.limits.premiumUser
} exceeded.`
)
);
.expect(422);
//THEN
expect(body.message).toEqual(
`Max results limit of ${
(await configuration).results.limits.premiumUser
} exceeded.`
);
});
it("should get results within regular limits for premium users even if premium is globally disabled", async () => {
//GIVEN
@ -201,7 +223,7 @@ describe("result controller test", () => {
await mockApp
.get("/results")
.query({ limit: 100, offset: 900 })
.set("Authorization", "Bearer 123456789")
.set("Authorization", `Bearer ${uid}`)
.send()
.expect(200);
@ -218,15 +240,15 @@ describe("result controller test", () => {
enablePremiumFeatures(false);
//WHEN
await mockApp
const { body } = await mockApp
.get("/results")
.query({ limit: 200, offset: 900 })
.set("Authorization", "Bearer 123456789")
.set("Authorization", `Bearer ${uid}`)
.send()
.expect(503)
.expect(expectErrorMessage("Premium feature disabled."));
.expect(503);
//THEN
expect(body.message).toEqual("Premium feature disabled.");
});
it("should get results with regular limit as default for premium users if premium is globally disabled", async () => {
//GIVEN
@ -236,7 +258,7 @@ describe("result controller test", () => {
//WHEN
await mockApp
.get("/results")
.set("Authorization", "Bearer 123456789")
.set("Authorization", `Bearer ${uid}`)
.send()
.expect(200);
@ -247,13 +269,525 @@ describe("result controller test", () => {
onOrAfterTimestamp: NaN,
});
});
it("should fail with unknown query parameters", async () => {
//WHEN
const { body } = await mockApp
.get("/results")
.query({ extra: "value" })
.set("Authorization", `Bearer ${uid}`)
.send()
.expect(422);
//THEN
expect(body).toEqual({
message: "Invalid query schema",
validationErrors: ["Unrecognized key(s) in object: 'extra'"],
});
});
it("should get results with legacy values", async () => {
//GIVEN
const resultOne = givenDbResult(uid, {
charStats: undefined,
incorrectChars: 5,
correctChars: 12,
});
const resultTwo = givenDbResult(uid, {
charStats: undefined,
incorrectChars: 7,
correctChars: 15,
});
resultMock.mockResolvedValue([resultOne, resultTwo]);
//WHEN
const { body } = await mockApp
.get("/results")
.set("Authorization", `Bearer ${uid}`)
.send()
.expect(200);
//THEN
expect(body.message).toEqual("Results retrieved");
expect(body.data[0]).toMatchObject({
_id: resultOne._id.toHexString(),
charStats: [12, 5, 0, 0],
});
expect(body.data[0]).not.toHaveProperty("correctChars");
expect(body.data[0]).not.toHaveProperty("incorrectChars");
expect(body.data[1]).toMatchObject({
_id: resultTwo._id.toHexString(),
charStats: [15, 7, 0, 0],
});
expect(body.data[1]).not.toHaveProperty("correctChars");
expect(body.data[1]).not.toHaveProperty("incorrectChars");
});
});
describe("getLastResult", () => {
const getLastResultMock = vi.spyOn(ResultDal, "getLastResult");
afterEach(() => {
getLastResultMock.mockReset();
});
it("should get last result", async () => {
//GIVEN
const result = givenDbResult(uid);
getLastResultMock.mockResolvedValue(result);
//WHEN
const { body } = await mockApp
.get("/results/last")
.set("Authorization", `Bearer ${uid}`)
.send()
.expect(200);
//THEN
expect(body.message).toEqual("Result retrieved");
expect(body.data).toEqual({ ...result, _id: result._id.toHexString() });
});
it("should get last result with ape key", async () => {
//GIVEN
await acceptApeKeys(true);
const apeKey = await mockAuthenticateWithApeKey(uid, await configuration);
const result = givenDbResult(uid);
getLastResultMock.mockResolvedValue(result);
//WHEN
await mockApp
.get("/results/last")
.set("Authorization", `ApeKey ${apeKey}`)
.send()
.expect(200);
});
it("should get last result with legacy values", async () => {
//GIVEN
const result = givenDbResult(uid, {
charStats: undefined,
incorrectChars: 5,
correctChars: 12,
});
getLastResultMock.mockResolvedValue(result);
//WHEN
const { body } = await mockApp
.get("/results/last")
.set("Authorization", `Bearer ${uid}`)
.send()
.expect(200);
//THEN
expect(body.message).toEqual("Result retrieved");
expect(body.data).toMatchObject({
_id: result._id.toHexString(),
charStats: [12, 5, 0, 0],
});
expect(body.data).not.toHaveProperty("correctChars");
expect(body.data).not.toHaveProperty("incorrectChars");
});
});
describe("deleteAll", () => {
const deleteAllMock = vi.spyOn(ResultDal, "deleteAll");
const logToDbMock = vi.spyOn(LogsDal, "addLog");
afterEach(() => {
deleteAllMock.mockReset();
logToDbMock.mockReset();
});
it("should delete", async () => {
//GIVEN
verifyIdTokenMock.mockResolvedValue({
...mockDecodedToken,
iat: Date.now() - 1000,
});
//WHEN
const { body } = await mockApp
.delete("/results")
.set("Authorization", `Bearer ${uid}`)
.send()
.expect(200);
//THEN
expect(body.message).toEqual("All results deleted");
expect(body.data).toBeNull();
expect(deleteAllMock).toHaveBeenCalledWith(uid);
expect(logToDbMock).toHaveBeenCalledWith("user_results_deleted", "", uid);
});
it("should fail to delete with non-fresh token", async () => {
await mockApp
.delete("/results")
.set("Authorization", `Bearer ${uid}`)
.send()
.expect(401);
});
});
describe("updateTags", () => {
const getResultMock = vi.spyOn(ResultDal, "getResult");
const updateTagsMock = vi.spyOn(ResultDal, "updateTags");
const getUserPartialMock = vi.spyOn(UserDal, "getPartialUser");
const checkIfTagPbMock = vi.spyOn(UserDal, "checkIfTagPb");
afterEach(() => {
[
getResultMock,
updateTagsMock,
getUserPartialMock,
checkIfTagPbMock,
].forEach((it) => it.mockReset());
});
it("should update tags", async () => {
//GIVEN
const result = givenDbResult(uid);
const resultIdString = result._id.toHexString();
const tagIds = [
new ObjectId().toHexString(),
new ObjectId().toHexString(),
];
const partialUser = { tags: [] };
getResultMock.mockResolvedValue(result);
updateTagsMock.mockResolvedValue({} as any);
getUserPartialMock.mockResolvedValue(partialUser as any);
checkIfTagPbMock.mockResolvedValue([]);
//WHEN
const { body } = await mockApp
.patch("/results/tags")
.set("Authorization", `Bearer ${uid}`)
.send({ resultId: resultIdString, tagIds })
.expect(200);
//THEN
expect(body.message).toEqual("Result tags updated");
expect(body.data).toEqual({
tagPbs: [],
});
expect(updateTagsMock).toHaveBeenCalledWith(uid, resultIdString, tagIds);
expect(getResultMock).toHaveBeenCalledWith(uid, resultIdString);
expect(getUserPartialMock).toHaveBeenCalledWith(uid, "update tags", [
"tags",
]);
expect(checkIfTagPbMock).toHaveBeenCalledWith(uid, partialUser, result);
});
it("should apply defaults on missing data", async () => {
//GIVEN
const result = givenDbResult(uid);
const partialResult = omit(
result,
"difficulty",
"language",
"funbox",
"lazyMode",
"punctuation",
"numbers"
);
const resultIdString = result._id.toHexString();
const tagIds = [
new ObjectId().toHexString(),
new ObjectId().toHexString(),
];
const partialUser = { tags: [] };
getResultMock.mockResolvedValue(partialResult);
updateTagsMock.mockResolvedValue({} as any);
getUserPartialMock.mockResolvedValue(partialUser as any);
checkIfTagPbMock.mockResolvedValue([]);
//WHEN
const { body } = await mockApp
.patch("/results/tags")
.set("Authorization", `Bearer ${uid}`)
.send({ resultId: resultIdString, tagIds })
.expect(200);
//THEN
expect(body.message).toEqual("Result tags updated");
expect(body.data).toEqual({
tagPbs: [],
});
expect(updateTagsMock).toHaveBeenCalledWith(uid, resultIdString, tagIds);
expect(getResultMock).toHaveBeenCalledWith(uid, resultIdString);
expect(getUserPartialMock).toHaveBeenCalledWith(uid, "update tags", [
"tags",
]);
expect(checkIfTagPbMock).toHaveBeenCalledWith(uid, partialUser, {
...result,
difficulty: "normal",
language: "english",
funbox: "none",
lazyMode: false,
punctuation: false,
numbers: false,
});
});
it("should fail with missing mandatory properties", async () => {
//GIVEN
//WHEN
const { body } = await mockApp
.patch("/results/tags")
.set("Authorization", `Bearer ${uid}`)
.send({})
.expect(422);
//THEN
expect(body).toEqual({
message: "Invalid request data schema",
validationErrors: ['"tagIds" Required', '"resultId" Required'],
});
});
it("should fail with unknown properties", async () => {
//GIVEN
//WHEN
const { body } = await mockApp
.patch("/results/tags")
.set("Authorization", `Bearer ${uid}`)
.send({ extra: "value" })
.expect(422);
//THEN
expect(body).toEqual({
message: "Invalid request data schema",
validationErrors: [
'"tagIds" Required',
'"resultId" Required',
"Unrecognized key(s) in object: 'extra'",
],
});
});
});
describe("addResult", () => {
//TODO improve test coverage for addResult
const insertedId = new ObjectId();
const getUserMock = vi.spyOn(UserDal, "getUser");
const updateStreakMock = vi.spyOn(UserDal, "updateStreak");
const checkIfTagPbMock = vi.spyOn(UserDal, "checkIfTagPb");
const addResultMock = vi.spyOn(ResultDal, "addResult");
beforeEach(async () => {
await enableResultsSaving(true);
[getUserMock, updateStreakMock, checkIfTagPbMock, addResultMock].forEach(
(it) => it.mockReset()
);
getUserMock.mockResolvedValue({ name: "bob" } as any);
updateStreakMock.mockResolvedValue(0);
checkIfTagPbMock.mockResolvedValue([]);
addResultMock.mockResolvedValue({ insertedId });
});
it("should add result", async () => {
//GIVEN
//WHEN
const { body } = await mockApp
.post("/results")
.set("Authorization", `Bearer ${uid}`)
.send({
result: {
acc: 86,
afkDuration: 5,
bailedOut: false,
blindMode: false,
charStats: [100, 2, 3, 5],
chartData: { wpm: [1, 2, 3], raw: [50, 55, 56], err: [0, 2, 0] },
consistency: 23.5,
difficulty: "normal",
funbox: "none",
hash: "hash",
incompleteTestSeconds: 2,
incompleteTests: [{ acc: 75, seconds: 10 }],
keyConsistency: 12,
keyDuration: [0, 3, 5],
keySpacing: [0, 2, 4],
language: "english",
lazyMode: false,
mode: "time",
mode2: "15",
numbers: false,
punctuation: false,
rawWpm: 99,
restartCount: 4,
tags: ["tagOneId", "tagTwoId"],
testDuration: 15.1,
timestamp: 1000,
uid,
wpmConsistency: 55,
wpm: 80,
stopOnLetter: false,
//new required
charTotal: 5,
keyOverlap: 7,
lastKeyToEnd: 9,
startToFirstKey: 11,
},
})
.expect(200);
expect(body.message).toEqual("Result saved");
expect(body.data).toEqual({
isPb: true,
tagPbs: [],
xp: 0,
dailyXpBonus: false,
xpBreakdown: {},
streak: 0,
insertedId: insertedId.toHexString(),
});
expect(addResultMock).toHaveBeenCalledWith(
uid,
expect.objectContaining({
acc: 86,
afkDuration: 5,
charStats: [100, 2, 3, 5],
chartData: {
err: [0, 2, 0],
raw: [50, 55, 56],
wpm: [1, 2, 3],
},
consistency: 23.5,
incompleteTestSeconds: 2,
isPb: true,
keyConsistency: 12,
keyDurationStats: {
average: 2.67,
sd: 2.05,
},
keySpacingStats: {
average: 2,
sd: 1.63,
},
mode: "time",
mode2: "15",
name: "bob",
rawWpm: 99,
restartCount: 4,
tags: ["tagOneId", "tagTwoId"],
testDuration: 15.1,
uid: "123456",
wpm: 80,
})
);
});
it("should fail if result saving is disabled", async () => {
//GIVEN
await enableResultsSaving(false);
//WHEN
const { body } = await mockApp
.post("/results")
.set("Authorization", `Bearer ${uid}`)
.send({})
.expect(503);
//THEN
expect(body.message).toEqual("Results are not being saved at this time.");
});
it("should fail without mandatory properties", async () => {
//GIVEN
//WHEN
const { body } = await mockApp
.post("/results")
.set("Authorization", `Bearer ${uid}`)
.send({})
.expect(422);
//THEN
expect(body).toEqual({
message: "Invalid request data schema",
validationErrors: ['"result" Required'],
});
});
it("should fail with unknown properties", async () => {
//GIVEN
//WHEN
const { body } = await mockApp
.post("/results")
.set("Authorization", `Bearer ${uid}`)
.send({
result: {
acc: 86,
afkDuration: 5,
bailedOut: false,
blindMode: false,
charStats: [100, 2, 3, 5],
chartData: { wpm: [1, 2, 3], raw: [50, 55, 56], err: [0, 2, 0] },
consistency: 23.5,
difficulty: "normal",
funbox: "none",
hash: "hash",
incompleteTestSeconds: 2,
incompleteTests: [{ acc: 75, seconds: 10 }],
keyConsistency: 12,
keyDuration: [0, 3, 5],
keySpacing: [0, 2, 4],
language: "english",
lazyMode: false,
mode: "time",
mode2: "15",
numbers: false,
punctuation: false,
rawWpm: 99,
restartCount: 4,
tags: ["tagOneId", "tagTwoId"],
testDuration: 15.1,
timestamp: 1000,
uid,
wpmConsistency: 55,
wpm: 80,
stopOnLetter: false,
//new required
charTotal: 5,
keyOverlap: 7,
lastKeyToEnd: 9,
startToFirstKey: 11,
extra2: "value",
},
extra: "value",
})
.expect(422);
//THEN
expect(body).toEqual({
message: "Invalid request data schema",
validationErrors: [
`"result" Unrecognized key(s) in object: 'extra2'`,
"Unrecognized key(s) in object: 'extra'",
],
});
});
it("should fail invalid properties", async () => {
//GIVEN
//WHEN
const { body } = await mockApp
.post("/results")
.set("Authorization", `Bearer ${uid}`)
//TODO add all properties
.send({ result: { acc: 25 } })
.expect(422);
//THEN
/*
expect(body).toEqual({
message: "Invalid request data schema",
validationErrors: [
],
});
*/
});
});
});
function expectErrorMessage(message: string): (res: request.Response) => void {
return (res) => expect(res.body).toHaveProperty("message", message);
}
async function enablePremiumFeatures(premium: boolean): Promise<void> {
const mockConfig = _.merge(await configuration, {
users: { premium: { enabled: premium } },
@ -263,3 +797,57 @@ async function enablePremiumFeatures(premium: boolean): Promise<void> {
mockConfig
);
}
function givenDbResult(
uid: string,
customize?: Partial<MonkeyTypes.DBResult>
): MonkeyTypes.DBResult {
return {
_id: new ObjectId(),
wpm: Math.random() * 100,
rawWpm: Math.random() * 100,
charStats: [
Math.round(Math.random() * 10),
Math.round(Math.random() * 10),
Math.round(Math.random() * 10),
Math.round(Math.random() * 10),
],
acc: 80 + Math.random() * 20, //min accuracy is 75%
mode: "time",
mode2: "60",
timestamp: Math.round(Math.random() * 100),
testDuration: 1 + Math.random() * 100,
consistency: Math.random() * 100,
keyConsistency: Math.random() * 100,
uid,
keySpacingStats: { average: Math.random() * 100, sd: Math.random() },
keyDurationStats: { average: Math.random() * 100, sd: Math.random() },
isPb: true,
chartData: {
wpm: [Math.random() * 100],
raw: [Math.random() * 100],
err: [Math.random() * 100],
},
name: "testName",
...customize,
};
}
async function acceptApeKeys(enabled: boolean): Promise<void> {
const mockConfig = _.merge(await configuration, {
apeKeys: { acceptKeys: enabled },
});
vi.spyOn(Configuration, "getCachedConfiguration").mockResolvedValue(
mockConfig
);
}
async function enableResultsSaving(enabled: boolean): Promise<void> {
const mockConfig = _.merge(await configuration, {
results: { savingEnabled: enabled },
});
vi.spyOn(Configuration, "getCachedConfiguration").mockResolvedValue(
mockConfig
);
}

View file

@ -16,7 +16,7 @@
"knip": "knip",
"docker-db-only": "docker compose -f docker/compose.db-only.yml up",
"docker": "docker compose -f docker/compose.yml up",
"gen-docs": "tsx scripts/openapi.ts dist/static/api/openapi.json && openapi-recursive-tagging dist/static/api/openapi.json dist/static/api/openapi-tagged.json && redocly build-docs -o dist/static/api/internal.html internal@v2 && redocly bundle -o dist/static/api/public.json public-filter && redocly build-docs -o dist/static/api/public.html public@v2"
"gen-docs": "tsx scripts/openapi.ts dist/static/api/openapi.json && redocly build-docs -o dist/static/api/internal.html internal@v2 && redocly bundle -o dist/static/api/public.json public-filter && redocly build-docs -o dist/static/api/public.html public@v2"
},
"engines": {
"node": "20.16.0"
@ -24,9 +24,9 @@
"dependencies": {
"@date-fns/utc": "1.2.0",
"@monkeytype/contracts": "workspace:*",
"@ts-rest/core": "3.45.2",
"@ts-rest/express": "3.45.2",
"@ts-rest/open-api": "3.45.2",
"@ts-rest/core": "3.49.3",
"@ts-rest/express": "3.49.3",
"@ts-rest/open-api": "3.49.3",
"bcrypt": "5.1.1",
"bullmq": "1.91.1",
"chalk": "4.1.2",
@ -90,7 +90,6 @@
"eslint": "8.57.0",
"eslint-watch": "8.0.0",
"ioredis-mock": "7.4.0",
"openapi-recursive-tagging": "0.0.6",
"readline-sync": "1.4.10",
"supertest": "6.2.3",
"tsx": "4.16.2",

View file

@ -5,7 +5,7 @@ apis:
internal@v2:
root: dist/static/api/openapi.json
public-filter:
root: dist/static/api/openapi-tagged.json
root: dist/static/api/openapi.json
decorators:
filter-in:
property: x-public

View file

@ -55,21 +55,31 @@ export function getOpenApi(): OpenAPIObject {
description:
"User specific configurations like test settings, theme or tags.",
"x-displayName": "User configuration",
"x-public": "no",
},
{
name: "presets",
description: "User specific configuration presets.",
"x-displayName": "User presets",
"x-public": "no",
},
{
name: "results",
description: "User test results",
"x-displayName": "Test results",
"x-public": "yes",
},
{
name: "ape-keys",
description: "Ape keys provide access to certain API endpoints.",
"x-displayName": "Ape Keys",
"x-public": "no",
},
{
name: "public",
description: "Public endpoints such as typing stats.",
"x-displayName": "public",
"x-displayName": "Public",
"x-public": "yes",
},
{
name: "leaderboards",
@ -80,12 +90,14 @@ export function getOpenApi(): OpenAPIObject {
name: "psas",
description: "Public service announcements.",
"x-displayName": "PSAs",
"x-public": "yes",
},
{
name: "admin",
description:
"Various administrative endpoints. Require user to have admin permissions.",
"x-displayName": "Admin",
"x-public": "no",
},
],
},

View file

@ -1,3 +1,8 @@
import {
CompletedEvent,
KeyStats,
} from "@monkeytype/contracts/schemas/results";
export function implemented(): boolean {
return false;
}
@ -11,6 +16,11 @@ export function validateResult(
return true;
}
export function validateKeys(_result: object, _uid: string): boolean {
export function validateKeys(
_result: CompletedEvent,
_keySpacingStats: KeyStats,
_keyDurationStats: KeyStats,
_uid: string
): boolean {
return true;
}

View file

@ -5,14 +5,14 @@ import {
getStartOfDayTimestamp,
isDevEnvironment,
mapRange,
replaceObjectId,
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 {
@ -31,17 +31,30 @@ import AutoRoleList from "../../constants/auto-roles";
import * as UserDAL from "../../dal/user";
import { buildMonkeyMail } from "../../utils/monkey-mail";
import FunboxList from "../../constants/funbox-list";
import _ from "lodash";
import _, { omit } from "lodash";
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 { buildDbResult, replaceLegacyValues } from "../../utils/result";
import { Configuration } from "@monkeytype/shared-types";
import { addLog } from "../../dal/logs";
import {
AddResultRequest,
AddResultResponse,
GetLastResultResponse,
GetResultsQuery,
GetResultsResponse,
UpdateResultTagsRequest,
UpdateResultTagsResponse,
} from "@monkeytype/contracts/results";
import {
CompletedEvent,
Configuration,
KeyStats,
Result,
PostResultResponse,
} from "@monkeytype/shared-types";
import { addLog } from "../../dal/logs";
XpBreakdown,
} from "@monkeytype/contracts/schemas/results";
import { Mode } from "@monkeytype/contracts/schemas/shared";
try {
if (!anticheatImplemented()) throw new Error("undefined");
@ -60,10 +73,11 @@ try {
}
export async function getResults(
req: MonkeyTypes.Request
): Promise<MonkeyResponse> {
req: MonkeyTypes.Request2<GetResultsQuery>
): Promise<GetResultsResponse> {
const { uid } = req.ctx.decodedToken;
const premiumFeaturesEnabled = req.ctx.configuration.users.premium.enabled;
const { onOrAfterTimestamp = NaN, offset = 0 } = req.query;
const userHasPremium = await UserDAL.checkIfUserIsPremium(uid);
const maxLimit =
@ -71,15 +85,9 @@ 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);
let limit =
req.query.limit ??
Math.min(req.ctx.configuration.results.maxBatchSize, maxLimit);
//check if premium features are disabled and current call exceeds the limit for regular users
if (
@ -114,30 +122,30 @@ export async function getResults(
},
uid
);
return new MonkeyResponse("Results retrieved", results);
return new MonkeyResponse2("Results retrieved", results.map(convertResult));
}
export async function getLastResult(
req: MonkeyTypes.Request
): Promise<MonkeyResponse> {
req: MonkeyTypes.Request2
): Promise<GetLastResultResponse> {
const { uid } = req.ctx.decodedToken;
const results = await ResultDAL.getLastResult(uid);
return new MonkeyResponse("Result retrieved", results);
return new MonkeyResponse2("Result retrieved", convertResult(results));
}
export async function deleteAll(
req: MonkeyTypes.Request
): Promise<MonkeyResponse> {
req: MonkeyTypes.Request2
): Promise<MonkeyResponse2> {
const { uid } = req.ctx.decodedToken;
await ResultDAL.deleteAll(uid);
void addLog("user_results_deleted", "", uid);
return new MonkeyResponse("All results deleted");
return new MonkeyResponse2("All results deleted", null);
}
export async function updateTags(
req: MonkeyTypes.Request
): Promise<MonkeyResponse> {
req: MonkeyTypes.Request2<undefined, UpdateResultTagsRequest>
): Promise<UpdateResultTagsResponse> {
const { uid } = req.ctx.decodedToken;
const { tagIds, resultId } = req.body;
@ -165,14 +173,14 @@ export async function updateTags(
const user = await UserDAL.getPartialUser(uid, "update tags", ["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<undefined, AddResultRequest>
): Promise<AddResultResponse> {
const { uid } = req.ctx.decodedToken;
const user = await UserDAL.getUser(uid, "add result");
@ -184,27 +192,18 @@ export async function addResult(
);
}
const completedEvent = Object.assign({}, req.body.result) as CompletedEvent;
if (!user.lbOptOut && completedEvent.acc < 75) {
throw new MonkeyError(
400,
"Cannot submit a result with less than 75% accuracy"
);
}
const completedEvent = req.body.result;
completedEvent.uid = uid;
if (isTestTooShort(completedEvent)) {
const status = MonkeyStatusCodes.TEST_TOO_SHORT;
throw new MonkeyError(status.code, status.message);
}
const resulthash = completedEvent.hash;
if (resulthash === undefined || resulthash === "") {
throw new MonkeyError(400, "Missing result hash");
}
delete completedEvent.hash;
delete completedEvent.stringified;
if (req.ctx.configuration.results.objectHashCheckEnabled) {
const serverhash = objectHash(completedEvent);
const objectToHash = omit(completedEvent, "hash");
const serverhash = objectHash(objectToHash);
if (serverhash !== resulthash) {
void addLog(
"incorrect_result_hash",
@ -233,11 +232,12 @@ export async function addResult(
throw new MonkeyError(400, "Impossible funbox combination");
}
let keySpacingStats: KeyStats | undefined = undefined;
if (
completedEvent.keySpacing !== "toolong" &&
completedEvent.keySpacing.length > 0
) {
completedEvent.keySpacingStats = {
keySpacingStats = {
average:
completedEvent.keySpacing.reduce(
(previous, current) => (current += previous)
@ -246,11 +246,12 @@ export async function addResult(
};
}
let keyDurationStats: KeyStats | undefined = undefined;
if (
completedEvent.keyDuration !== "toolong" &&
completedEvent.keyDuration.length > 0
) {
completedEvent.keyDurationStats = {
keyDurationStats = {
average:
completedEvent.keyDuration.reduce(
(previous, current) => (current += previous)
@ -263,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
)
) {
@ -332,7 +333,7 @@ export async function addResult(
(user.verified === false || user.verified === undefined) &&
user.lbOptOut !== true
) {
if (!completedEvent.keySpacingStats || !completedEvent.keyDurationStats) {
if (!keySpacingStats || !keyDurationStats) {
const status = MonkeyStatusCodes.MISSING_KEY_DATA;
throw new MonkeyError(status.code, "Missing key data");
}
@ -340,7 +341,9 @@ export async function addResult(
throw new MonkeyError(400, "Old key data format");
}
if (anticheatImplemented()) {
if (!validateKeys(completedEvent, uid)) {
if (
!validateKeys(completedEvent, keySpacingStats, keyDurationStats, uid)
) {
//autoban
const autoBanConfig = req.ctx.configuration.users.autoBan;
if (autoBanConfig.enabled) {
@ -399,21 +402,13 @@ export async function addResult(
}
}
if (completedEvent.keyDurationStats) {
completedEvent.keyDurationStats.average = roundTo2(
completedEvent.keyDurationStats.average
);
completedEvent.keyDurationStats.sd = roundTo2(
completedEvent.keyDurationStats.sd
);
if (keyDurationStats) {
keyDurationStats.average = roundTo2(keyDurationStats.average);
keyDurationStats.sd = roundTo2(keyDurationStats.sd);
}
if (completedEvent.keySpacingStats) {
completedEvent.keySpacingStats.average = roundTo2(
completedEvent.keySpacingStats.average
);
completedEvent.keySpacingStats.sd = roundTo2(
completedEvent.keySpacingStats.sd
);
if (keySpacingStats) {
keySpacingStats.average = roundTo2(keySpacingStats.average);
keySpacingStats.sd = roundTo2(keySpacingStats.sd);
}
let isPb = false;
@ -587,6 +582,13 @@ export async function addResult(
}
const dbresult = buildDbResult(completedEvent, user.name, isPb);
if (keySpacingStats !== undefined) {
dbresult.keySpacingStats = keySpacingStats;
}
if (keyDurationStats !== undefined) {
dbresult.keyDurationStats = keyDurationStats;
}
const addedResult = await ResultDAL.addResult(uid, dbresult);
await UserDAL.incrementXp(uid, xpGained.xp);
@ -604,12 +606,10 @@ export async function addResult(
);
}
const data: Omit<PostResultResponse, "insertedId"> & {
insertedId: ObjectId;
} = {
const data: PostResultResponse = {
isPb,
tagPbs,
insertedId: addedResult.insertedId,
insertedId: addedResult.insertedId.toHexString(),
xp: xpGained.xp,
dailyXpBonus: xpGained.dailyBonus ?? false,
xpBreakdown: xpGained.breakdown ?? {},
@ -624,15 +624,15 @@ export async function addResult(
data.weeklyXpLeaderboardRank = weeklyXpLeaderboardRank;
}
incrementResult(completedEvent);
incrementResult(completedEvent, dbresult.isPb);
return new MonkeyResponse("Result saved", data);
return new MonkeyResponse2("Result saved", data);
}
type XpResult = {
xp: number;
dailyBonus?: boolean;
breakdown?: Record<string, number>;
breakdown?: XpBreakdown;
};
async function calculateXp(
@ -669,10 +669,10 @@ async function calculateXp(
};
}
const breakdown: Record<string, number> = {};
const breakdown: XpBreakdown = {};
const baseXp = Math.round((testDuration - afkDuration) * 2);
breakdown["base"] = baseXp;
breakdown.base = baseXp;
let modifier = 1;
@ -682,7 +682,7 @@ async function calculateXp(
if (acc === 100) {
modifier += 0.5;
breakdown["100%"] = Math.round(baseXp * 0.5);
breakdown.fullAccuracy = Math.round(baseXp * 0.5);
} else if (correctedEverything) {
// corrected everything bonus
modifier += 0.25;
@ -692,16 +692,16 @@ async function calculateXp(
if (mode === "quote") {
// real sentences bonus
modifier += 0.5;
breakdown["quote"] = Math.round(baseXp * 0.5);
breakdown.quote = Math.round(baseXp * 0.5);
} else {
// punctuation bonus
if (punctuation) {
modifier += 0.4;
breakdown["punctuation"] = Math.round(baseXp * 0.4);
breakdown.punctuation = Math.round(baseXp * 0.4);
}
if (numbers) {
modifier += 0.1;
breakdown["numbers"] = Math.round(baseXp * 0.1);
breakdown.numbers = Math.round(baseXp * 0.1);
}
}
@ -713,7 +713,7 @@ async function calculateXp(
});
if (funboxModifier > 0) {
modifier += funboxModifier;
breakdown["funbox"] = Math.round(baseXp * funboxModifier);
breakdown.funbox = Math.round(baseXp * funboxModifier);
}
}
@ -731,7 +731,7 @@ async function calculateXp(
if (streakModifier > 0) {
modifier += streakModifier;
breakdown["streak"] = Math.round(baseXp * streakModifier);
breakdown.streak = Math.round(baseXp * streakModifier);
}
}
@ -742,10 +742,10 @@ async function calculateXp(
if (modifier < 0) modifier = 0;
incompleteXp += Math.round(it.seconds * modifier);
});
breakdown["incomplete"] = incompleteXp;
breakdown.incomplete = incompleteXp;
} else if (incompleteTestSeconds && incompleteTestSeconds > 0) {
incompleteXp = Math.round(incompleteTestSeconds);
breakdown["incomplete"] = incompleteXp;
breakdown.incomplete = incompleteXp;
}
const accuracyModifier = (acc - 50) / 50;
@ -769,14 +769,14 @@ async function calculateXp(
Math.min(maxDailyBonus, proportionalXp),
minDailyBonus
);
breakdown["daily"] = dailyBonus;
breakdown.daily = dailyBonus;
}
}
const xpWithModifiers = Math.round(baseXp * modifier);
const xpAfterAccuracy = Math.round(xpWithModifiers * accuracyModifier);
breakdown["accPenalty"] = xpWithModifiers - xpAfterAccuracy;
breakdown.accPenalty = xpWithModifiers - xpAfterAccuracy;
const totalXp =
Math.round((xpAfterAccuracy + incompleteXp) * gainMultiplier) + dailyBonus;
@ -786,7 +786,7 @@ async function calculateXp(
// "configMultiplier",
// Math.round((xpAfterAccuracy + incompleteXp) * (gainMultiplier - 1)),
// ]);
breakdown["configMultiplier"] = gainMultiplier;
breakdown.configMultiplier = gainMultiplier;
}
const isAwardingDailyBonus = dailyBonus > 0;
@ -797,3 +797,7 @@ async function calculateXp(
breakdown,
};
}
function convertResult(db: MonkeyTypes.DBResult): Result<Mode> {
return replaceObjectId(replaceLegacyValues(db));
}

View file

@ -41,7 +41,6 @@ const APP_START_TIME = Date.now();
const API_ROUTE_MAP = {
"/users": users,
"/results": results,
"/quotes": quotes,
"/webhooks": webhooks,
"/docs": docs,
@ -56,6 +55,7 @@ const router = s.router(contract, {
psas,
public: publicStats,
leaderboards,
results,
});
export function addApiRoutes(app: Application): void {

View file

@ -1,85 +1,40 @@
import * as ResultController from "../controllers/result";
import resultSchema from "../schemas/result-schema";
import * as RateLimit from "../../middlewares/rate-limit";
import { Router } from "express";
import { authenticateRequest } from "../../middlewares/auth";
import joi from "joi";
import { withApeRateLimiter } from "../../middlewares/ape-rate-limit";
import { validateRequest } from "../../middlewares/validation";
import { asyncHandler } from "../../middlewares/utility";
import { resultsContract } from "@monkeytype/contracts/results";
import { initServer } from "@ts-rest/express";
import { withApeRateLimiter2 as withApeRateLimiter } from "../../middlewares/ape-rate-limit";
import { validate } from "../../middlewares/configuration";
import * as RateLimit from "../../middlewares/rate-limit";
import * as ResultController from "../controllers/result";
import { callController } from "../ts-rest-adapter";
const router = Router();
const validateResultSavingEnabled = validate({
criteria: (configuration) => {
return configuration.results.savingEnabled;
},
invalidMessage: "Results are not being saved at this time.",
});
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(
"/",
validate({
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;
const s = initServer();
export default s.router(resultsContract, {
get: {
middleware: [
withApeRateLimiter(RateLimit.resultsGet, RateLimit.resultsGetApe),
],
handler: async (r) => callController(ResultController.getResults)(r),
},
add: {
middleware: [validateResultSavingEnabled, RateLimit.resultsTagsUpdate],
handler: async (r) => callController(ResultController.addResult)(r),
},
updateTags: {
middleware: [RateLimit.resultsTagsUpdate],
handler: async (r) => callController(ResultController.updateTags)(r),
},
deleteAll: {
middleware: [RateLimit.resultsDeleteAll],
handler: async (r) => callController(ResultController.deleteAll)(r),
},
getLast: {
middleware: [withApeRateLimiter(RateLimit.resultsGet)],
handler: async (r) => callController(ResultController.getLastResult)(r),
},
});

View file

@ -1,98 +0,0 @@
import joi from "joi";
const RESULT_SCHEMA = joi
.object({
acc: joi.number().min(50).max(100).required(),
afkDuration: joi.number().min(0).required(),
bailedOut: joi.boolean().required(),
blindMode: joi.boolean().required(),
challenge: joi.string().max(100).token(),
charStats: joi.array().items(joi.number().min(0)).length(4).required(),
charTotal: joi.number().min(0),
chartData: joi
.alternatives()
.try(
joi.object({
wpm: joi.array().max(122).items(joi.number().min(0)).required(),
raw: joi.array().max(122).items(joi.number().min(0)).required(),
err: joi.array().max(122).items(joi.number().min(0)).required(),
}),
joi.string().valid("toolong")
)
.required(),
consistency: joi.number().min(0).max(100).required(),
customText: joi.object({
textLen: joi.number().required(),
mode: joi.string().valid("repeat", "random", "shuffle").required(),
pipeDelimiter: joi.boolean().required(),
limit: joi.object({
mode: joi.string().valid("word", "time", "section").required(),
value: joi.number().min(0).required(),
}),
}),
difficulty: joi.string().valid("normal", "expert", "master").required(),
funbox: joi
.string()
.max(100)
.regex(/[\w#]+/)
.required(),
hash: joi.string().max(100).token().required(),
incompleteTestSeconds: joi.number().min(0).required(),
incompleteTests: joi
.array()
.items(
joi.object({
acc: joi.number().min(0).max(100).required(),
seconds: joi.number().min(0).required(),
})
)
.required(),
keyConsistency: joi.number().min(0).max(100).required(),
keyDuration: joi
.alternatives()
.try(
joi.array().items(joi.number().min(0)),
joi.string().valid("toolong")
),
keySpacing: joi
.alternatives()
.try(
joi.array().items(joi.number().min(0)),
joi.string().valid("toolong")
),
keyOverlap: joi.number().min(0),
lastKeyToEnd: joi.number().min(0),
startToFirstKey: joi.number().min(0),
language: joi
.string()
.max(100)
.regex(/[\w+]+/)
.required(),
lazyMode: joi.boolean().required(),
mode: joi
.string()
.valid("time", "words", "quote", "zen", "custom")
.required(),
mode2: joi
.string()
.regex(/^(\d)+|custom|zen/)
.required(),
numbers: joi.boolean().required(),
punctuation: joi.boolean().required(),
quoteLength: joi.number().min(0).max(3),
rawWpm: joi.number().min(0).max(420).required(),
restartCount: joi.number().required(),
tags: joi
.array()
.items(joi.string().regex(/^[a-f\d]{24}$/i))
.required(),
testDuration: joi.number().required().min(1),
timestamp: joi.date().timestamp().required(),
uid: joi.string().max(100).token().required(),
wpm: joi.number().min(0).max(420).required(),
wpmConsistency: joi.number().min(0).max(100).required(),
stopOnLetter: joi.boolean().required(),
})
.required();
export default RESULT_SCHEMA;

View file

@ -7,14 +7,11 @@ import {
} from "mongodb";
import MonkeyError from "../utils/error";
import * as db from "../init/db";
import { DBResult as SharedDBResult } from "@monkeytype/shared-types";
import { getUser, getTags } from "./user";
import { Mode } from "@monkeytype/contracts/schemas/shared";
type DBResult = MonkeyTypes.WithObjectId<SharedDBResult<Mode>>;
export const getResultCollection = (): Collection<DBResult> =>
db.collection<DBResult>("results");
export const getResultCollection = (): Collection<MonkeyTypes.DBResult> =>
db.collection<MonkeyTypes.DBResult>("results");
export async function addResult(
uid: string,
@ -78,14 +75,14 @@ export async function getResult(
export async function getLastResult(
uid: string
): Promise<Omit<MonkeyTypes.DBResult, "uid">> {
): Promise<MonkeyTypes.DBResult> {
const [lastResult] = await getResultCollection()
.find({ uid })
.sort({ timestamp: -1 })
.limit(1)
.toArray();
if (!lastResult) throw new MonkeyError(404, "No results found");
return _.omit(lastResult, "uid");
return lastResult;
}
export async function getResultByTimestamp(

View file

@ -19,7 +19,6 @@ import {
Badge,
Configuration,
CustomTheme,
DBResult,
MonkeyMail,
UserInventory,
UserProfileDetails,
@ -33,10 +32,11 @@ import {
} from "@monkeytype/contracts/schemas/shared";
import { addImportantLog } from "./logs";
import { ResultFilters } from "@monkeytype/contracts/schemas/users";
import { Result as ResultType } from "@monkeytype/contracts/schemas/results";
const SECONDS_PER_HOUR = 3600;
type Result = Omit<DBResult<Mode>, "_id" | "name">;
type Result = Omit<ResultType<Mode>, "_id" | "name">;
// Export for use in tests
export const getUsersCollection = (): Collection<MonkeyTypes.DBUser> =>

View file

@ -23,10 +23,6 @@
"name": "users",
"description": "User data and related operations"
},
{
"name": "results",
"description": "Result data and related operations"
},
{
"name": "quotes",
"description": "Quote data and related operations"
@ -408,94 +404,6 @@
}
}
},
"/results": {
"get": {
"tags": ["results"],
"summary": "Gets a history of a user's results",
"responses": {
"default": {
"description": "",
"schema": {
"$ref": "#/definitions/Response"
}
}
}
},
"post": {
"tags": ["results"],
"summary": "Save a user's result",
"parameters": [
{
"in": "body",
"name": "body",
"required": true,
"schema": {
"type": "object",
"properties": {
"result": {
"type": "object"
}
}
}
}
],
"responses": {
"default": {
"description": "",
"schema": {
"$ref": "#/definitions/Response"
}
}
}
},
"delete": {
"tags": ["results"],
"summary": "Deletes all results",
"responses": {
"default": {
"description": "",
"schema": {
"$ref": "#/definitions/Response"
}
}
}
}
},
"/results/tags": {
"patch": {
"tags": ["results"],
"summary": "Labels a result with the specified tags",
"parameters": [
{
"in": "body",
"name": "body",
"required": true,
"schema": {
"type": "object",
"properties": {
"tagIds": {
"type": "array",
"items": {
"type": "string"
}
},
"resultId": {
"type": "string"
}
}
}
}
],
"responses": {
"default": {
"description": "",
"schema": {
"$ref": "#/definitions/Response"
}
}
}
}
},
"/quotes": {
"get": {
"tags": ["quotes"],

View file

@ -19,10 +19,6 @@
{
"name": "users",
"description": "User data and related operations"
},
{
"name": "results",
"description": "User results data and related operations"
}
],
"paths": {
@ -143,60 +139,6 @@
}
}
}
},
"/results": {
"get": {
"tags": ["results"],
"summary": "Gets up to 1000 results (endpoint limited to 30 requests per day)",
"parameters": [
{
"name": "onOrAfterTimestamp",
"in": "query",
"description": "Get results on or after a Unix timestamp in milliseconds. Must be no earlier than Thu May 14 2020 04:00:00 GMT+0000 (i.e., 1670454228000). If omitted, defaults to the most recent results.",
"required": false,
"type": "number"
},
{
"name": "limit",
"in": "query",
"description": "The maximum number of items to return per page.",
"required": false,
"type": "number",
"minimum": 0,
"maximum": 1000
},
{
"name": "offset",
"in": "query",
"description": "The offset of the item at which to begin the response.",
"required": false,
"type": "number",
"minimum": 0
}
],
"responses": {
"200": {
"description": "",
"schema": {
"$ref": "#/definitions/Results"
}
}
}
}
},
"/results/last": {
"get": {
"tags": ["results"],
"summary": "Gets a user's last saved result",
"responses": {
"200": {
"description": "",
"schema": {
"$ref": "#/definitions/Result"
}
}
}
}
}
},
"definitions": {
@ -511,151 +453,6 @@
}
}
},
"Results": {
"type": "array",
"items": {
"$ref": "#/definitions/Result"
}
},
"Result": {
"type": "object",
"properties": {
"_id": {
"type": "string",
"example": "6226b17aebc27a4a8d1ce04b"
},
"wpm": {
"type": "number",
"format": "double",
"example": 154.84
},
"rawWpm": {
"type": "number",
"format": "double",
"example": 154.84
},
"charStats": {
"type": "array",
"items": {
"type": "number"
},
"example": [44, 0, 0, 0]
},
"acc": {
"type": "number",
"format": "double",
"example": 100
},
"mode": {
"type": "string",
"example": "words"
},
"mode2": {
"type": "string",
"example": "10"
},
"quoteLength": {
"type": "integer",
"example": -1
},
"timestamp": {
"type": "integer",
"example": 1651141719000
},
"restartCount": {
"type": "integer",
"example": 0
},
"incompleteTestSeconds": {
"type": "number",
"format": "double",
"example": 14.5
},
"tags": {
"type": "array",
"items": {
"type": "string"
},
"example": ["6210edbfc4fdc8a1700e648b"]
},
"consistency": {
"type": "number",
"format": "double",
"example": 78.68
},
"keyConsistency": {
"type": "number",
"format": "double",
"example": 60.22
},
"chartData": {
"type": "object",
"properties": {
"wpm": {
"type": "array",
"items": {
"type": "number"
},
"example": [144, 144, 144, 154]
},
"raw": {
"type": "array",
"items": {
"type": "number"
},
"example": [150, 148, 124, 114]
},
"err": {
"type": "array",
"items": {
"type": "number"
},
"example": [0, 0, 0, 0]
}
}
},
"testDuration": {
"type": "number",
"format": "double",
"example": 3.41
},
"afkDuration": {
"type": "number",
"format": "double",
"example": 0
},
"keySpacingStats": {
"type": "object",
"properties": {
"average": {
"type": "number",
"format": "double",
"example": 77.61
},
"sd": {
"type": "number",
"format": "double",
"example": 33.31
}
}
},
"keyDurationStats": {
"type": "object",
"properties": {
"average": {
"type": "number",
"format": "double",
"example": 42.01
},
"sd": {
"type": "number",
"format": "double",
"example": 19.65
}
}
}
}
},
"CurrentTestActivity": {
"type": "object",
"properties": {

View file

@ -6,6 +6,8 @@ import rateLimit, {
type Options,
} from "express-rate-limit";
import { isDevEnvironment } from "../utils/misc";
import { TsRestRequestHandler } from "@ts-rest/express";
import { TsRestRequestWithCtx } from "./auth";
const REQUEST_MULTIPLIER = isDevEnvironment() ? 1 : 1;
@ -51,3 +53,20 @@ export function withApeRateLimiter(
return defaultRateLimiter(req, res, next);
};
}
export function withApeRateLimiter2<T extends AppRouter | AppRoute>(
defaultRateLimiter: RateLimitRequestHandler,
apeRateLimiterOverride?: RateLimitRequestHandler
): TsRestRequestHandler<T> {
return (req: TsRestRequestWithCtx, res: Response, next: NextFunction) => {
if (req.ctx.decodedToken.type === "ApeKey") {
const rateLimiter = apeRateLimiterOverride ?? apeRateLimiter;
// TODO: bump version?
// eslint-disable-next-line @typescript-eslint/no-unsafe-return
return rateLimiter(req, res, next);
}
// eslint-disable-next-line @typescript-eslint/no-unsafe-return
return defaultRateLimiter(req, res, next);
};
}

View file

@ -125,10 +125,14 @@ declare namespace MonkeyTypes {
};
type DBResult = MonkeyTypes.WithObjectId<
import("@monkeytype/shared-types").DBResult<
import("@monkeytype/contracts/schemas/results").Result<
import("@monkeytype/contracts/schemas/shared").Mode
>
>;
> & {
//legacy values
correctChars?: number;
incorrectChars?: number;
};
type BlocklistEntry = {
_id: string;

View file

@ -1,11 +1,12 @@
import _ from "lodash";
import FunboxList from "../constants/funbox-list";
import { DBResult } from "@monkeytype/shared-types";
import {
Mode,
PersonalBest,
PersonalBests,
} from "@monkeytype/contracts/schemas/shared";
import { Result as ResultType } from "@monkeytype/contracts/schemas/results";
type CheckAndUpdatePbResult = {
isPb: boolean;
@ -13,7 +14,7 @@ type CheckAndUpdatePbResult = {
lbPersonalBests?: MonkeyTypes.LbPersonalBests;
};
type Result = Omit<DBResult<Mode>, "_id" | "name">;
type Result = Omit<ResultType<Mode>, "_id" | "name">;
export function canFunboxGetPb(result: Result): boolean {
const funbox = result.funbox;

View file

@ -1,8 +1,7 @@
import { Result } from "@monkeytype/shared-types";
import { Mode } from "@monkeytype/contracts/schemas/shared";
import "dotenv/config";
import { Counter, Histogram, Gauge } from "prom-client";
import { TsRestRequestWithCtx } from "../middlewares/auth";
import { CompletedEvent } from "@monkeytype/contracts/schemas/results";
const auth = new Counter({
name: "api_request_auth_total",
@ -91,11 +90,10 @@ export function setLeaderboard(
leaderboardUpdate.set({ language, mode, mode2, step: "index" }, times[3]);
}
export function incrementResult(res: Result<Mode>): void {
export function incrementResult(res: CompletedEvent, isPb?: boolean): void {
const {
mode,
mode2,
isPb,
blindMode,
lazyMode,
difficulty,

View file

@ -1,16 +1,13 @@
import { CompletedEvent, DBResult } from "@monkeytype/shared-types";
import { Mode } from "@monkeytype/contracts/schemas/shared";
import { CompletedEvent } from "@monkeytype/contracts/schemas/results";
import { ObjectId } from "mongodb";
type Result = MonkeyTypes.WithObjectId<DBResult<Mode>>;
export function buildDbResult(
completedEvent: CompletedEvent,
userName: string,
isPb: boolean
): Result {
): MonkeyTypes.DBResult {
const ce = completedEvent;
const res: Result = {
const res: MonkeyTypes.DBResult = {
_id: new ObjectId(),
uid: ce.uid,
wpm: ce.wpm,
@ -35,14 +32,14 @@ export function buildDbResult(
funbox: ce.funbox,
numbers: ce.numbers,
punctuation: ce.punctuation,
keySpacingStats: ce.keySpacingStats,
keyDurationStats: ce.keyDurationStats,
isPb: isPb,
bailedOut: ce.bailedOut,
blindMode: ce.blindMode,
name: userName,
};
//compress object by omitting default values. Frontend will add them back after reading
//reduces object size on the database and on the rest api
if (!ce.bailedOut) delete res.bailedOut;
if (!ce.blindMode) delete res.blindMode;
if (!ce.lazyMode) delete res.lazyMode;
@ -51,17 +48,32 @@ export function buildDbResult(
if (ce.language === "english") delete res.language;
if (!ce.numbers) delete res.numbers;
if (!ce.punctuation) delete res.punctuation;
if (ce.mode !== "custom") delete res.customText;
if (ce.mode !== "quote") delete res.quoteLength;
if (ce.restartCount === 0) delete res.restartCount;
if (ce.incompleteTestSeconds === 0) delete res.incompleteTestSeconds;
if (ce.afkDuration === 0) delete res.afkDuration;
if (ce.tags.length === 0) delete res.tags;
if (ce.keySpacingStats === undefined) delete res.keySpacingStats;
if (ce.keyDurationStats === undefined) delete res.keyDurationStats;
if (res.isPb === false) delete res.isPb;
return res;
}
/**
* Convert legacy values
* @param result
* @returns
*/
export function replaceLegacyValues(
result: MonkeyTypes.DBResult
): MonkeyTypes.DBResult {
//convert legacy values
if (
result.correctChars !== undefined &&
result.incorrectChars !== undefined
) {
result.charStats = [result.correctChars, result.incorrectChars, 0, 0];
delete result.correctChars;
delete result.incorrectChars;
}
return result;
}

View file

@ -3,7 +3,7 @@ import { replaceHomoglyphs } from "../constants/homoglyphs";
import { profanities } from "../constants/profanities";
import { intersect, sanitizeString } from "./misc";
import { default as FunboxList } from "../constants/funbox-list";
import { CompletedEvent } from "@monkeytype/shared-types";
import { CompletedEvent } from "@monkeytype/contracts/schemas/results";
export function inRange(value: number, min: number, max: number): boolean {
return value >= min && value <= max;

View file

@ -71,7 +71,7 @@
"dependencies": {
"@date-fns/utc": "1.2.0",
"@monkeytype/contracts": "workspace:*",
"@ts-rest/core": "3.45.2",
"@ts-rest/core": "3.49.3",
"axios": "1.7.4",
"canvas-confetti": "1.5.1",
"chart.js": "3.7.1",

View file

@ -1,12 +1,10 @@
import Quotes from "./quotes";
import Results from "./results";
import Users from "./users";
import Configuration from "./configuration";
import Dev from "./dev";
export default {
Quotes,
Results,
Users,
Configuration,
Dev,

View file

@ -1,35 +0,0 @@
import { DBResult, Result } from "@monkeytype/shared-types";
import { Mode } from "@monkeytype/contracts/schemas/shared";
const BASE_PATH = "/results";
export default class Results {
constructor(private httpClient: Ape.HttpClient) {
this.httpClient = httpClient;
}
async get(offset?: number): Ape.EndpointResponse<DBResult<Mode>[]> {
return await this.httpClient.get(BASE_PATH, { searchQuery: { offset } });
}
async save(
result: Result<Mode>
): Ape.EndpointResponse<Ape.Results.PostResult> {
return await this.httpClient.post(BASE_PATH, {
payload: { result },
});
}
async updateTags(
resultId: string,
tagIds: string[]
): Ape.EndpointResponse<Ape.Results.PatchResult> {
return await this.httpClient.patch(`${BASE_PATH}/tags`, {
payload: { resultId, tagIds },
});
}
async deleteAll(): Ape.EndpointResponse<Ape.Results.DeleteAll> {
return await this.httpClient.delete(BASE_PATH);
}
}

View file

@ -15,7 +15,6 @@ const tsRestClient = buildClient(contract, BASE_URL, 10_000);
const Ape = {
...tsRestClient,
users: new endpoints.Users(httpClient),
results: new endpoints.Results(httpClient),
quotes: new endpoints.Quotes(httpClient),
configuration: new endpoints.Configuration(httpClient),
dev: new endpoints.Dev(buildHttpClient(API_URL, 240_000)),

View file

@ -1,9 +0,0 @@
/* eslint-disable @typescript-eslint/no-unused-vars */
// for some reason when using the dot notaion, the types are not being recognized as used
declare namespace Ape.Results {
type PostResult = import("@monkeytype/shared-types").PostResultResponse;
type PatchResult = {
tagPbs: string[];
};
type DeleteAll = null;
}

View file

@ -185,7 +185,7 @@ async function getDataAndInit(): Promise<boolean> {
return true;
}
export async function loadUser(user: UserType): Promise<void> {
export async function loadUser(_user: UserType): Promise<void> {
// User is signed in.
PageTransition.set(false);
AccountButton.loading(true);
@ -205,7 +205,6 @@ export async function loadUser(user: UserType): Promise<void> {
// showFavouriteThemesAtTheTop();
if (TestLogic.notSignedInLastResult !== null) {
TestLogic.setNotSignedInUid(user.uid);
LastSignedOutResultModal.show();
}
}
@ -578,22 +577,7 @@ async function signUp(): Promise<void> {
await sendVerificationEmail();
LoginPage.hidePreloader();
await loadUser(createdAuthUser.user);
if (TestLogic.notSignedInLastResult !== null) {
TestLogic.setNotSignedInUid(createdAuthUser.user.uid);
const response = await Ape.results.save(TestLogic.notSignedInLastResult);
if (response.status === 200) {
const result = TestLogic.notSignedInLastResult;
DB.saveLocalResult(result);
DB.updateLocalStats(
1,
result.testDuration +
result.incompleteTestSeconds -
result.afkDuration
);
}
}
Notifications.add("Account created", 1);
} catch (e) {
let message = Misc.createErrorMessage(e, "Failed to create account");

View file

@ -12,13 +12,13 @@ import * as Loader from "../elements/loader";
import {
CustomTextLimitMode,
CustomTextMode,
Result,
} from "@monkeytype/shared-types";
} from "@monkeytype/contracts/schemas/util";
import {
Config as ConfigType,
Difficulty,
} from "@monkeytype/contracts/schemas/configs";
import { Mode } from "@monkeytype/contracts/schemas/shared";
import { CompletedEvent } from "@monkeytype/contracts/schemas/results";
let challengeLoading = false;
@ -33,7 +33,7 @@ export function clearActive(): void {
}
}
export function verify(result: Result<Mode>): string | null {
export function verify(result: CompletedEvent): string | null {
try {
if (TestState.activeChallenge) {
const afk = (result.afkDuration / result.testDuration) * 100;
@ -123,7 +123,8 @@ export function verify(result: Result<Mode>): string | null {
requirementsMet = false;
for (const f of funboxMode.split("#")) {
if (
result.funbox?.split("#").find((rf) => rf === f) === undefined
result.funbox?.split("#").find((rf: string) => rf === f) ===
undefined
) {
failReasons.push(`${f} funbox not active`);
}

View file

@ -15,7 +15,7 @@ import {
} from "./elements/test-activity-calendar";
import * as Loader from "./elements/loader";
import { Badge, DBResult, Result } from "@monkeytype/shared-types";
import { Badge } from "@monkeytype/shared-types";
import { Config, Difficulty } from "@monkeytype/contracts/schemas/configs";
import {
Mode,
@ -283,42 +283,37 @@ 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 } });
if (response.status !== 200) {
Notifications.add("Error getting results: " + response.message, -1);
Notifications.add("Error getting results: " + response.body.message, -1);
return false;
}
const results = response.data as DBResult<Mode>[];
const results: MonkeyTypes.FullResult<Mode>[] = response.body.data.map(
(result) => {
if (result.bailedOut === undefined) result.bailedOut = false;
if (result.blindMode === undefined) result.blindMode = false;
if (result.lazyMode === undefined) result.lazyMode = false;
if (result.difficulty === undefined) result.difficulty = "normal";
if (result.funbox === undefined) result.funbox = "none";
if (result.language === undefined || result.language === null) {
result.language = "english";
}
if (result.numbers === undefined) result.numbers = false;
if (result.punctuation === undefined) result.punctuation = false;
if (result.numbers === undefined) result.numbers = false;
if (result.quoteLength === undefined) result.quoteLength = -1;
if (result.restartCount === undefined) result.restartCount = 0;
if (result.incompleteTestSeconds === undefined) {
result.incompleteTestSeconds = 0;
}
if (result.afkDuration === undefined) result.afkDuration = 0;
if (result.tags === undefined) result.tags = [];
return result as MonkeyTypes.FullResult<Mode>;
}
);
results?.sort((a, b) => b.timestamp - a.timestamp);
results.forEach((result) => {
if (result.bailedOut === undefined) result.bailedOut = false;
if (result.blindMode === undefined) result.blindMode = false;
if (result.lazyMode === undefined) result.lazyMode = false;
if (result.difficulty === undefined) result.difficulty = "normal";
if (result.funbox === undefined) result.funbox = "none";
if (result.language === undefined || result.language === null) {
result.language = "english";
}
if (result.numbers === undefined) result.numbers = false;
if (result.punctuation === undefined) result.punctuation = false;
if (result.numbers === undefined) result.numbers = false;
if (result.quoteLength === undefined) result.quoteLength = -1;
if (result.restartCount === undefined) result.restartCount = 0;
if (result.incompleteTestSeconds === undefined) {
result.incompleteTestSeconds = 0;
}
if (result.afkDuration === undefined) result.afkDuration = 0;
if (result.tags === undefined) result.tags = [];
if (
result.correctChars !== undefined &&
result.incorrectChars !== undefined
) {
result.charStats = [result.correctChars, result.incorrectChars, 0, 0];
}
});
if (dbSnapshot.results !== undefined && dbSnapshot.results.length > 0) {
//merge
@ -327,11 +322,9 @@ export async function getUserResults(offset?: number): Promise<boolean> {
const resultsWithoutDuplicates = results.filter(
(it) => it.timestamp < oldestTimestamp
);
dbSnapshot.results.push(
...(resultsWithoutDuplicates as unknown as Result<Mode>[])
);
dbSnapshot.results.push(...resultsWithoutDuplicates);
} else {
dbSnapshot.results = results as unknown as Result<Mode>[];
dbSnapshot.results = results;
}
return true;
}
@ -939,7 +932,7 @@ export async function resetConfig(): Promise<void> {
}
}
export function saveLocalResult(result: Result<Mode>): void {
export function saveLocalResult(result: MonkeyTypes.FullResult<Mode>): void {
const snapshot = getSnapshot();
if (!snapshot) return;

View file

@ -2,6 +2,7 @@ import * as Misc from "../utils/misc";
import * as Levels from "../utils/levels";
import { getAll } from "./theme-colors";
import * as SlowTimer from "../states/slow-timer";
import { XpBreakdown } from "@monkeytype/contracts/schemas/results";
import {
getHtmlByUserFlags,
SupportsFlags,
@ -219,7 +220,7 @@ export async function updateXpBar(
async function animateXpBreakdown(
addedXp: number,
breakdown?: Record<string, number>
breakdown?: XpBreakdown
): Promise<void> {
if (!breakdown) {
$("nav .xpBar .xpGain").text(`+${addedXp}`);
@ -289,84 +290,84 @@ async function animateXpBreakdown(
xpGain.text(`+0`);
xpBreakdown.append(
`<div class='text next'>time typing +${breakdown["base"]}</div>`
`<div class='text next'>time typing +${breakdown.base}</div>`
);
total += breakdown["base"] ?? 0;
if (breakdown["100%"]) {
if (breakdown.fullAccuracy) {
await Misc.sleep(delay);
await append(`perfect +${breakdown["100%"]}`);
total += breakdown["100%"];
} else if (breakdown["corrected"]) {
await append(`perfect +${breakdown.fullAccuracy}`);
total += breakdown.fullAccuracy;
} else if (breakdown.corrected) {
await Misc.sleep(delay);
await append(`clean +${breakdown["corrected"]}`);
total += breakdown["corrected"];
await append(`clean +${breakdown.corrected}`);
total += breakdown.corrected;
}
if (skipBreakdown) return;
if (breakdown["quote"]) {
if (breakdown.quote) {
await Misc.sleep(delay);
await append(`quote +${breakdown["quote"]}`);
total += breakdown["quote"];
await append(`quote +${breakdown.quote}`);
total += breakdown.quote;
} else {
if (breakdown["punctuation"]) {
if (breakdown.punctuation) {
await Misc.sleep(delay);
await append(`punctuation +${breakdown["punctuation"]}`);
total += breakdown["punctuation"];
await append(`punctuation +${breakdown.punctuation}`);
total += breakdown.punctuation;
}
if (breakdown["numbers"]) {
if (breakdown.numbers) {
await Misc.sleep(delay);
await append(`numbers +${breakdown["numbers"]}`);
total += breakdown["numbers"];
await append(`numbers +${breakdown.numbers}`);
total += breakdown.numbers;
}
}
if (skipBreakdown) return;
if (breakdown["funbox"]) {
if (breakdown.funbox) {
await Misc.sleep(delay);
await append(`funbox +${breakdown["funbox"]}`);
total += breakdown["funbox"];
await append(`funbox +${breakdown.funbox}`);
total += breakdown.funbox;
}
if (skipBreakdown) return;
if (breakdown["streak"]) {
if (breakdown.streak) {
await Misc.sleep(delay);
await append(`streak +${breakdown["streak"]}`);
total += breakdown["streak"];
await append(`streak +${breakdown.streak}`);
total += breakdown.streak;
}
if (skipBreakdown) return;
if (breakdown["accPenalty"]) {
if (breakdown.accPenalty) {
await Misc.sleep(delay);
await append(`accuracy penalty -${breakdown["accPenalty"]}`);
total -= breakdown["accPenalty"];
await append(`accuracy penalty -${breakdown.accPenalty}`);
total -= breakdown.accPenalty;
}
if (skipBreakdown) return;
if (breakdown["incomplete"]) {
if (breakdown.incomplete) {
await Misc.sleep(delay);
await append(`incomplete tests +${breakdown["incomplete"]}`);
total += breakdown["incomplete"];
await append(`incomplete tests +${breakdown.incomplete}`);
total += breakdown.incomplete;
}
if (skipBreakdown) return;
if (breakdown["configMultiplier"]) {
if (breakdown.configMultiplier) {
await Misc.sleep(delay);
await append(`global multiplier x${breakdown["configMultiplier"]}`);
total *= breakdown["configMultiplier"];
await append(`global multiplier x${breakdown.configMultiplier}`);
total *= breakdown.configMultiplier;
}
if (skipBreakdown) return;
if (breakdown["daily"]) {
if (breakdown.daily) {
await Misc.sleep(delay);
await append(`daily bonus +${breakdown["daily"]}`);
total += breakdown["daily"];
await append(`daily bonus +${breakdown.daily}`);
total += breakdown.daily;
}
if (skipBreakdown) return;

View file

@ -152,7 +152,7 @@ export async function update(
);
console.debug("profile.streakHourOffset", streakOffset);
if (lastResult) {
if (lastResult !== undefined) {
//check if the last result is from today
const isToday = DateTime.isToday(lastResult.timestamp, streakOffset);
const isYesterday = DateTime.isYesterday(

View file

@ -10,7 +10,7 @@ import * as Notifications from "../elements/notifications";
import * as SavedTextsPopup from "./saved-texts";
import * as SaveCustomTextPopup from "./save-custom-text";
import AnimatedModal, { ShowOptions } from "../utils/animated-modal";
import { CustomTextMode } from "@monkeytype/shared-types";
import { CustomTextMode } from "@monkeytype/contracts/schemas/util";
const popup = "#customTextModal .modal";

View file

@ -8,7 +8,7 @@ import { areUnsortedArraysEqual } from "../utils/arrays";
import * as TestResult from "../test/result";
import AnimatedModal from "../utils/animated-modal";
import { Mode } from "@monkeytype/contracts/schemas/shared";
import { Result } from "@monkeytype/shared-types";
import { Result } from "@monkeytype/contracts/schemas/results";
type State = {
resultId: string;
@ -112,7 +112,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
@ -121,12 +123,15 @@ async function save(): Promise<void> {
state.tags = state.tags.filter((el) => el !== undefined);
if (response.status !== 200) {
Notifications.add("Failed to update result tags: " + response.message, -1);
Notifications.add(
"Failed to update result tags: " + response.body.message,
-1
);
return;
}
//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

@ -10,9 +10,7 @@ import Ape from "../ape";
import { createErrorMessage } from "../utils/misc";
import * as LoginPage from "../pages/login";
import * as AccountController from "../controllers/account-controller";
import * as TestLogic from "../test/test-logic";
import * as CaptchaController from "../controllers/captcha-controller";
import * as DB from "../db";
import * as Loader from "../elements/loader";
import { subscribe as subscribeToSignUpEvent } from "../observables/google-sign-up-event";
import { InputIndicator } from "../elements/input-indicator";
@ -91,24 +89,7 @@ async function apply(): Promise<void> {
LoginPage.enableInputs();
LoginPage.hidePreloader();
await AccountController.loadUser(signedInUser.user);
if (TestLogic.notSignedInLastResult !== null) {
TestLogic.setNotSignedInUid(signedInUser.user.uid);
const resultsSaveResponse = await Ape.results.save(
TestLogic.notSignedInLastResult
);
if (resultsSaveResponse.status === 200) {
const result = TestLogic.notSignedInLastResult;
DB.saveLocalResult(result);
DB.updateLocalStats(
1,
result.testDuration +
result.incompleteTestSeconds -
result.afkDuration
);
}
}
signedInUser = undefined;
Loader.hide();
void hide();

View file

@ -1,8 +1,10 @@
import AnimatedModal from "../utils/animated-modal";
import Ape from "../ape";
import * as TestLogic from "../test/test-logic";
import * as Notifications from "../elements/notifications";
import { CompletedEvent } from "@monkeytype/shared-types";
import { CompletedEvent } from "@monkeytype/contracts/schemas/results";
import { Auth } from "../firebase";
import { syncNotSignedInLastResult } from "../utils/results";
function reset(): void {
(modal.getModal().querySelector(".result") as HTMLElement).innerHTML = `
@ -109,30 +111,13 @@ function hide(): void {
void modal.hide();
}
async function saveLastResult(): Promise<void> {
//safe because we check if it exists before showing the modal
const response = await Ape.results.save(
TestLogic.notSignedInLastResult as CompletedEvent
);
if (response.status !== 200) {
Notifications.add("Failed to save last result: " + response.message, -1);
return;
}
TestLogic.clearNotSignedInResult();
Notifications.add(
`Last test result saved ${response.data?.isPb ? `(new pb!)` : ""}`,
1
);
}
const modal = new AnimatedModal({
dialogId: "lastSignedOutResult",
setup: async (modalEl): Promise<void> => {
modalEl
.querySelector("button.save")
?.addEventListener("click", async (e) => {
void saveLastResult();
void syncNotSignedInLastResult(Auth?.currentUser?.uid as string);
hide();
});
modalEl.querySelector("button.discard")?.addEventListener("click", (e) => {

View file

@ -1,4 +1,4 @@
import { ChartData } from "@monkeytype/shared-types";
import { ChartData } from "@monkeytype/contracts/schemas/results";
import AnimatedModal from "../utils/animated-modal";
import * as ChartController from "../controllers/chart-controller";
import Config from "../config";

View file

@ -27,13 +27,8 @@ import * as Loader from "../elements/loader";
import * as ResultBatches from "../elements/result-batches";
import Format from "../utils/format";
import * as TestActivity from "../elements/test-activity";
import { ChartData, Result } from "@monkeytype/shared-types";
import {
Mode,
Mode2,
Mode2Custom,
PersonalBests,
} from "@monkeytype/contracts/schemas/shared";
import { ChartData } from "@monkeytype/contracts/schemas/results";
import { Mode, Mode2, Mode2Custom } from "@monkeytype/contracts/schemas/shared";
import { ResultFiltersGroupItem } from "@monkeytype/contracts/schemas/users";
let filterDebug = false;
@ -45,7 +40,7 @@ export function toggleFilterDebug(): void {
}
}
let filteredResults: Result<Mode>[] = [];
let filteredResults: MonkeyTypes.FullResult<Mode>[] = [];
let visibleTableLines = 0;
function loadMoreLines(lineIndex?: number): void {
@ -276,7 +271,7 @@ async function fillContent(): Promise<void> {
filteredResults = [];
$(".pageAccount .history table tbody").empty();
DB.getSnapshot()?.results?.forEach((result: Result<Mode>) => {
DB.getSnapshot()?.results?.forEach((result) => {
// totalSeconds += tt;
//apply filters
@ -1069,7 +1064,7 @@ function sortAndRefreshHistory(
$(headerClass).append('<i class="fas fa-sort-up", aria-hidden="true"></i>');
}
const temp = [];
const temp: MonkeyTypes.FullResult<Mode>[] = [];
const parsedIndexes: number[] = [];
while (temp.length < filteredResults.length) {
@ -1093,10 +1088,11 @@ function sortAndRefreshHistory(
}
}
//@ts-expect-error
temp.push(filteredResults[idx]);
parsedIndexes.push(idx);
}
filteredResults = temp as Result<keyof PersonalBests>[];
filteredResults = temp;
$(".pageAccount .history table tbody").empty();
visibleTableLines = 0;

View file

@ -1,9 +1,8 @@
import { CustomTextData, CustomTextLimit } from "@monkeytype/shared-types";
import {
CustomTextData,
CustomTextLimit,
CustomTextLimitMode,
CustomTextMode,
} from "@monkeytype/shared-types";
} from "@monkeytype/contracts/schemas/util";
import { LocalStorageWithSchema } from "../utils/local-storage-with-schema";
import { z } from "zod";

View file

@ -39,10 +39,9 @@ import type {
LabelPosition,
} from "chartjs-plugin-annotation";
import Ape from "../ape";
import { Result } from "@monkeytype/shared-types";
import { Mode } from "@monkeytype/contracts/schemas/shared";
import { CompletedEvent } from "@monkeytype/contracts/schemas/results";
let result: Result<Mode>;
let result: CompletedEvent;
let maxChartVal: number;
let useUnsmoothedRaw = false;
@ -830,7 +829,7 @@ function updateQuoteSource(randomQuote: MonkeyTypes.Quote | null): void {
}
export async function update(
res: Result<Mode>,
res: CompletedEvent,
difficultyFailed: boolean,
failReason: string,
afkDetected: boolean,

View file

@ -58,12 +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 { QuoteLength } from "@monkeytype/contracts/schemas/configs";
import { Mode } from "@monkeytype/contracts/schemas/shared";
import {
CompletedEvent,
CustomTextDataWithTextLen,
} from "@monkeytype/shared-types";
import { QuoteLength } from "@monkeytype/contracts/schemas/configs";
import { Mode } from "@monkeytype/contracts/schemas/shared";
} from "@monkeytype/contracts/schemas/results";
let failReason = "";
const koInputVisual = document.getElementById("koInputVisual") as HTMLElement;
@ -77,6 +77,7 @@ export function clearNotSignedInResult(): void {
export function setNotSignedInUid(uid: string): void {
if (notSignedInLastResult === null) return;
notSignedInLastResult.uid = uid;
//@ts-expect-error
delete notSignedInLastResult.hash;
notSignedInLastResult.hash = objectHash(notSignedInLastResult);
}
@ -645,7 +646,9 @@ export async function retrySavingResult(): Promise<void> {
await saveResult(completedEvent, true);
}
function buildCompletedEvent(difficultyFailed: boolean): CompletedEvent {
function buildCompletedEvent(
difficultyFailed: boolean
): Omit<CompletedEvent, "hash" | "uid"> {
//build completed event object
let stfk = Numbers.roundTo2(
TestInput.keypressTimings.spacing.first - TestStats.start
@ -737,7 +740,7 @@ function buildCompletedEvent(difficultyFailed: boolean): CompletedEvent {
const wpmCons = Numbers.roundTo2(Misc.kogasa(stddev3 / avg3));
const wpmConsistency = isNaN(wpmCons) ? 0 : wpmCons;
let customText: CustomTextDataWithTextLen | null = null;
let customText: CustomTextDataWithTextLen | undefined = undefined;
if (Config.mode === "custom") {
const temp = CustomText.getData();
customText = {
@ -765,7 +768,7 @@ function buildCompletedEvent(difficultyFailed: boolean): CompletedEvent {
const quoteLength = TestWords.currentQuote?.group ?? -1;
const completedEvent = {
const completedEvent: Omit<CompletedEvent, "hash" | "uid"> = {
wpm: stats.wpm,
rawWpm: stats.wpmRaw,
charStats: [
@ -808,7 +811,7 @@ function buildCompletedEvent(difficultyFailed: boolean): CompletedEvent {
testDuration: duration,
afkDuration: afkDuration,
stopOnLetter: Config.stopOnError === "letter",
} as CompletedEvent;
};
if (completedEvent.mode !== "custom") delete completedEvent.customText;
if (completedEvent.mode !== "quote") delete completedEvent.quoteLength;
@ -1050,9 +1053,7 @@ export async function finish(difficultyFailed = false): Promise<void> {
$("#result .stats .dailyLeaderboard").addClass("hidden");
TestStats.setLastResult(
JSON.parse(JSON.stringify(completedEvent)) as CompletedEvent
);
TestStats.setLastResult(JSON.parse(JSON.stringify(completedEvent)));
if (!ConnectionState.get()) {
ConnectionState.showOfflineBanner();
@ -1086,18 +1087,17 @@ export async function finish(difficultyFailed = false): Promise<void> {
}
// user is logged in
TestStats.resetIncomplete();
completedEvent.uid = Auth?.currentUser?.uid as string;
Result.updateRateQuote(TestWords.currentQuote);
AccountButton.loading(true);
if (!completedEvent.bailedOut) {
completedEvent.challenge = ChallengeContoller.verify(completedEvent);
}
if (completedEvent.challenge === null) delete completedEvent?.challenge;
if (!completedEvent.bailedOut) {
const challenge = ChallengeContoller.verify(completedEvent);
if (challenge !== null) completedEvent.challenge = challenge;
}
completedEvent.hash = objectHash(completedEvent);
@ -1133,7 +1133,7 @@ async function saveResult(
return;
}
const response = await Ape.results.save(completedEvent);
const response = await Ape.results.add({ body: { result: completedEvent } });
AccountButton.loading(false);
@ -1147,44 +1147,46 @@ async function saveResult(
}
}
console.log("Error saving result", completedEvent);
if (response.message === "Old key data format") {
response.message =
if (response.body.message === "Old key data format") {
response.body.message =
"Old key data format. Please refresh the page to download the new update. If the problem persists, please contact support.";
}
if (/"result\..+" is (not allowed|required)/gi.test(response.message)) {
response.message =
if (
/"result\..+" is (not allowed|required)/gi.test(response.body.message)
) {
response.body.message =
"Looks like your result data is using an incorrect schema. Please refresh the page to download the new update. If the problem persists, please contact support.";
}
Notifications.add("Failed to save result: " + response.message, -1);
Notifications.add("Failed to save result: " + response.body.message, -1);
return;
}
const data = response.body.data;
$("#result .stats .tags .editTagsButton").attr(
"data-result-id",
response.data?.insertedId as string //if status is 200 then response.data is not null or undefined
data.insertedId
);
$("#result .stats .tags .editTagsButton").removeClass("invisible");
if (response?.data?.xp !== undefined) {
if (data.xp !== undefined) {
const snapxp = DB.getSnapshot()?.xp ?? 0;
void AccountButton.updateXpBar(
snapxp,
response.data.xp,
response.data.xpBreakdown
void AccountButton.updateXpBar(snapxp, data.xp, data.xpBreakdown);
DB.addXp(data.xp);
}
if (data.streak !== undefined) {
DB.setStreak(data.streak);
}
if (data.insertedId !== undefined) {
const result: MonkeyTypes.FullResult<Mode> = JSON.parse(
JSON.stringify(completedEvent)
);
DB.addXp(response.data.xp);
}
if (response?.data?.streak !== undefined) {
DB.setStreak(response.data.streak);
}
if (response?.data?.insertedId !== undefined) {
completedEvent._id = response.data.insertedId;
if (response?.data?.isPb !== undefined && response.data.isPb) {
completedEvent.isPb = true;
result._id = data.insertedId;
if (data.isPb !== undefined && data.isPb) {
result.isPb = true;
}
DB.saveLocalResult(completedEvent);
DB.saveLocalResult(result);
DB.updateLocalStats(
completedEvent.incompleteTests.length + 1,
completedEvent.testDuration +
@ -1195,7 +1197,7 @@ async function saveResult(
void AnalyticsController.log("testCompleted");
if (response?.data?.isPb !== undefined && response.data.isPb) {
if (data.isPb !== undefined && data.isPb) {
//new pb
const localPb = await DB.getLocalPB(
completedEvent.mode,
@ -1241,7 +1243,7 @@ async function saveResult(
// );
// }
if (response?.data?.dailyLeaderboardRank === undefined) {
if (data.dailyLeaderboardRank === undefined) {
$("#result .stats .dailyLeaderboard").addClass("hidden");
} else {
$("#result .stats .dailyLeaderboard")
@ -1258,7 +1260,7 @@ async function saveResult(
500
);
$("#result .stats .dailyLeaderboard .bottom").html(
Format.rank(response.data.dailyLeaderboardRank, { fallback: "" })
Format.rank(data.dailyLeaderboardRank, { fallback: "" })
);
}

View file

@ -6,8 +6,10 @@ import * as TestWords from "./test-words";
import * as FunboxList from "./funbox/funbox-list";
import * as TestState from "./test-state";
import * as Numbers from "../utils/numbers";
import { IncompleteTest, Result } from "@monkeytype/shared-types";
import { Mode } from "@monkeytype/contracts/schemas/shared";
import {
CompletedEvent,
IncompleteTest,
} from "@monkeytype/contracts/schemas/results";
type CharCount = {
spaces: number;
@ -39,9 +41,9 @@ export let start2: number, end2: number;
export let start3: number, end3: number;
export let lastSecondNotRound = false;
export let lastResult: Result<Mode>;
export let lastResult: Omit<CompletedEvent, "hash" | "uid">;
export function setLastResult(result: Result<Mode>): void {
export function setLastResult(result: CompletedEvent): void {
lastResult = result;
}

View file

@ -1,4 +1,8 @@
type ConfigValue = import("@monkeytype/contracts/schemas/configs").ConfigValue;
type Mode = import("@monkeytype/contracts/schemas/shared").Mode;
type Result<M extends Mode> =
import("@monkeytype/contracts/schemas/results").Result<M>;
type IncompleteTest =
import("@monkeytype/contracts/schemas/results").IncompleteTest;
declare namespace MonkeyTypes {
type PageName =
@ -237,9 +241,7 @@ declare namespace MonkeyTypes {
config: import("@monkeytype/contracts/schemas/configs").Config;
tags: UserTag[];
presets: SnapshotPreset[];
results?: import("@monkeytype/shared-types").Result<
import("@monkeytype/contracts/schemas/shared").Mode
>[];
results?: MonkeyTypes.FullResult<Mode>[];
xp: number;
testActivity?: ModifiableTestActivityCalendar;
testActivityByYear?: { [key: string]: TestActivityCalendar };
@ -466,4 +468,36 @@ declare namespace MonkeyTypes {
text: string;
weeks: number;
};
/**
* Result from the rest api but all omittable default values are set (and non optional)
*/
type FullResult<M extends Mode> = Omit<
Result<M>,
| "restartCount"
| "incompleteTestSeconds"
| "afkDuration"
| "tags"
| "bailedOut"
| "blindMode"
| "lazyMode"
| "funbox"
| "language"
| "difficulty"
| "numbers"
| "punctuation"
> & {
restartCount: number;
incompleteTestSeconds: number;
afkDuration: number;
tags: string[];
bailedOut: boolean;
blindMode: boolean;
lazyMode: boolean;
funbox: string;
language: string;
difficulty: import("@monkeytype/contracts/schemas/shared").Difficulty;
numbers: boolean;
punctuation: boolean;
};
}

View file

@ -2,7 +2,7 @@ import * as Loader from "../elements/loader";
import { envConfig } from "../constants/env-config";
import { lastElementFromArray } from "./arrays";
import * as JSONData from "./json-data";
import { CustomTextData, Result } from "@monkeytype/shared-types";
import { CustomTextData } from "@monkeytype/shared-types";
import { Config } from "@monkeytype/contracts/schemas/configs";
import {
Mode,
@ -406,7 +406,9 @@ export function getMode2<M extends keyof PersonalBests>(
return retVal as Mode2<M>;
}
export async function downloadResultsCSV(array: Result<Mode>[]): Promise<void> {
export async function downloadResultsCSV(
array: MonkeyTypes.FullResult<Mode>[]
): Promise<void> {
Loader.show();
const csvString = [
[
@ -435,7 +437,7 @@ export async function downloadResultsCSV(array: Result<Mode>[]): Promise<void> {
"tags",
"timestamp",
],
...array.map((item: Result<Mode>) => [
...array.map((item: MonkeyTypes.FullResult<Mode>) => [
item._id,
item.isPb,
item.wpm,

View file

@ -0,0 +1,39 @@
import Ape from "../ape";
import * as Notifications from "../elements/notifications";
import * as DB from "../db";
import * as TestLogic from "../test/test-logic";
export async function syncNotSignedInLastResult(uid: string): Promise<void> {
const notSignedInLastResult = TestLogic.notSignedInLastResult;
if (notSignedInLastResult === null) return;
TestLogic.setNotSignedInUid(uid);
const response = await Ape.results.add({
body: { result: notSignedInLastResult },
});
if (response.status !== 200) {
Notifications.add(
"Failed to save last result: " + response.body.message,
-1
);
return;
}
const result: MonkeyTypes.FullResult<Mode> = JSON.parse(
JSON.stringify(notSignedInLastResult)
);
result._id = response.body.data.insertedId;
if (response.body.data.isPb) {
result.isPb = true;
}
DB.saveLocalResult(result);
DB.updateLocalStats(
1,
result.testDuration + result.incompleteTestSeconds - result.afkDuration
);
TestLogic.clearNotSignedInResult();
Notifications.add(
`Last test result saved ${response.body.data.isPb ? `(new pb!)` : ""}`,
1
);
}

View file

@ -45,7 +45,10 @@
"editor.defaultFormatter": "esbenp.prettier-vscode",
"editor.formatOnSaveMode": "file",
"editor.formatOnSave": true,
"testing.openTesting": "neverOpen"
"testing.openTesting": "neverOpen",
"[json]": {
"editor.defaultFormatter": "esbenp.prettier-vscode"
}
},
"launch": {

View file

@ -9,7 +9,7 @@
"lint": "eslint \"./**/*.ts\""
},
"dependencies": {
"@ts-rest/core": "3.45.2",
"@ts-rest/core": "3.49.3",
"zod": "3.23.8"
},
"devDependencies": {

View file

@ -6,6 +6,7 @@ import { presetsContract } from "./presets";
import { psasContract } from "./psas";
import { publicContract } from "./public";
import { leaderboardsContract } from "./leaderboards";
import { resultsContract } from "./results";
const c = initContract();
@ -17,4 +18,5 @@ export const contract = c.router({
psas: psasContract,
public: publicContract,
leaderboards: leaderboardsContract,
results: resultsContract,
});

View file

@ -0,0 +1,150 @@
import { initContract } from "@ts-rest/core";
import { z } from "zod";
import {
CommonResponses,
EndpointMetadata,
MonkeyResponseSchema,
responseWithData,
} from "./schemas/api";
import {
CompletedEventSchema,
PostResultResponseSchema,
ResultSchema,
} from "./schemas/results";
import { IdSchema } from "./schemas/util";
export const GetResultsQuerySchema = z.object({
onOrAfterTimestamp: z
.number()
.int()
.min(1589428800000)
.optional()
.describe(
"Timestamp of the earliest result to fetch. If omitted the most recent results are fetched."
),
offset: z
.number()
.int()
.nonnegative()
.optional()
.describe("Offset of the item at which to begin the response."),
limit: z
.number()
.int()
.nonnegative()
.max(1000)
.optional()
.describe("Limit results to the given amount."),
});
export type GetResultsQuery = z.infer<typeof GetResultsQuerySchema>;
export const GetResultsResponseSchema = responseWithData(z.array(ResultSchema));
export type GetResultsResponse = z.infer<typeof GetResultsResponseSchema>;
export const AddResultRequestSchema = z.object({
result: CompletedEventSchema,
});
export type AddResultRequest = z.infer<typeof AddResultRequestSchema>;
export const AddResultResponseSchema = responseWithData(
PostResultResponseSchema
);
export type AddResultResponse = z.infer<typeof AddResultResponseSchema>;
export const UpdateResultTagsRequestSchema = z.object({
tagIds: z.array(IdSchema),
resultId: IdSchema,
});
export type UpdateResultTagsRequest = z.infer<
typeof UpdateResultTagsRequestSchema
>;
export const UpdateResultTagsResponseSchema = responseWithData(
z.object({
tagPbs: z.array(IdSchema),
})
);
export type UpdateResultTagsResponse = z.infer<
typeof UpdateResultTagsResponseSchema
>;
export const GetLastResultResponseSchema = responseWithData(ResultSchema);
export type GetLastResultResponse = z.infer<typeof GetLastResultResponseSchema>;
const c = initContract();
export const resultsContract = c.router(
{
get: {
summary: "get results",
description:
"Gets up to 1000 results (endpoint limited to 30 requests per day for ape keys)",
method: "GET",
path: "",
query: GetResultsQuerySchema.strict(),
responses: {
200: GetResultsResponseSchema,
},
metadata: {
authenticationOptions: {
acceptApeKeys: true,
},
} as EndpointMetadata,
},
add: {
summary: "add result",
description: "Add a test result for the current user",
method: "POST",
path: "",
body: AddResultRequestSchema.strict(),
responses: {
200: AddResultResponseSchema,
},
},
updateTags: {
summary: "update result tags",
description: "Labels a result with the specified tags",
method: "PATCH",
path: "/tags",
body: UpdateResultTagsRequestSchema.strict(),
responses: {
200: UpdateResultTagsResponseSchema,
},
},
deleteAll: {
summary: "delete all results",
description: "Delete all results for the current user",
method: "DELETE",
path: "",
body: c.noBody(),
responses: {
200: MonkeyResponseSchema,
},
metadata: {
authenticationOptions: {
requireFreshToken: true,
},
} as EndpointMetadata,
},
getLast: {
summary: "get last result",
description: "Gets a user's last saved result",
path: "/last",
method: "GET",
responses: {
200: GetLastResultResponseSchema,
},
metadata: {
authenticationOptions: {
acceptApeKeys: true,
},
} as EndpointMetadata,
},
},
{
pathPrefix: "/results",
strictStatusCodes: true,
metadata: {
openApiTags: "results",
} as EndpointMetadata,
commonResponses: CommonResponses,
}
);

View file

@ -7,7 +7,8 @@ export type OpenApiTag =
| "admin"
| "psas"
| "public"
| "leaderboards";
| "leaderboards"
| "results";
export type EndpointMetadata = {
/** Authentication options, by default a bearer token is required. */

View file

@ -0,0 +1,150 @@
import { z } from "zod";
import {
CustomTextLimitModeSchema,
CustomTextModeSchema,
IdSchema,
PercentageSchema,
token,
WpmSchema,
LanguageSchema,
} from "./util";
import { Mode, Mode2, Mode2Schema, ModeSchema } from "./shared";
import { DifficultySchema, FunboxSchema } from "./configs";
export const IncompleteTestSchema = z.object({
acc: PercentageSchema,
seconds: z.number().nonnegative(),
});
export type IncompleteTest = z.infer<typeof IncompleteTestSchema>;
export const ChartDataSchema = z.object({
wpm: z.array(z.number().nonnegative()).max(122),
raw: z.array(z.number().nonnegative()).max(122),
err: z.array(z.number().nonnegative()).max(122),
});
export type ChartData = z.infer<typeof ChartDataSchema>;
export const KeyStatsSchema = z.object({
average: z.number().nonnegative(),
sd: z.number().nonnegative(),
});
export type KeyStats = z.infer<typeof KeyStatsSchema>;
export const CustomTextSchema = z.object({
textLen: z.number().int().nonnegative(),
mode: CustomTextModeSchema,
pipeDelimiter: z.boolean(),
limit: z.object({
mode: CustomTextLimitModeSchema,
value: z.number().nonnegative(),
}),
});
export type CustomTextDataWithTextLen = z.infer<typeof CustomTextSchema>;
export const CharStatsSchema = z.tuple([
z.number().int().nonnegative(),
z.number().int().nonnegative(),
z.number().int().nonnegative(),
z.number().int().nonnegative(),
]);
export type CharStats = z.infer<typeof CharStatsSchema>;
const ResultBaseSchema = z.object({
wpm: WpmSchema,
rawWpm: WpmSchema,
charStats: CharStatsSchema,
acc: PercentageSchema.min(75),
mode: ModeSchema,
mode2: Mode2Schema,
quoteLength: z.number().int().nonnegative().max(3).optional(),
timestamp: z.number().int().nonnegative(),
testDuration: z.number().min(1),
consistency: PercentageSchema,
keyConsistency: PercentageSchema,
chartData: ChartDataSchema.or(z.literal("toolong")),
uid: IdSchema,
});
//required on POST but optional in the database and might be removed to save space
const ResultOmittableDefaultPropertiesSchema = z.object({
restartCount: z.number().int().nonnegative(),
incompleteTestSeconds: z.number().nonnegative(),
afkDuration: z.number().nonnegative(),
tags: z.array(IdSchema),
bailedOut: z.boolean(),
blindMode: z.boolean(),
lazyMode: z.boolean(),
funbox: FunboxSchema,
language: LanguageSchema,
difficulty: DifficultySchema,
numbers: z.boolean(),
punctuation: z.boolean(),
});
export const ResultSchema = ResultBaseSchema.merge(
ResultOmittableDefaultPropertiesSchema.partial() //missing on GET if the values are the default values
).extend({
_id: IdSchema,
keySpacingStats: KeyStatsSchema.optional(),
keyDurationStats: KeyStatsSchema.optional(),
name: z.string(),
isPb: z.boolean().optional(), //true or undefined
});
export type Result<M extends Mode> = Omit<
z.infer<typeof ResultSchema>,
"mode" | "mode2"
> & {
mode: M;
mode2: Mode2<M>;
};
export const CompletedEventSchema = ResultBaseSchema.merge(
ResultOmittableDefaultPropertiesSchema //mandatory on POST
)
.extend({
charTotal: z.number().int().nonnegative(),
challenge: token().max(100).optional(),
customText: CustomTextSchema.optional(),
hash: token().max(100),
keyDuration: z.array(z.number().nonnegative()).or(z.literal("toolong")),
keySpacing: z.array(z.number().nonnegative()).or(z.literal("toolong")),
keyOverlap: z.number().nonnegative(),
lastKeyToEnd: z.number().nonnegative(),
startToFirstKey: z.number().nonnegative(),
wpmConsistency: PercentageSchema,
stopOnLetter: z.boolean(),
incompleteTests: z.array(IncompleteTestSchema),
})
.strict();
export type CompletedEvent = z.infer<typeof CompletedEventSchema>;
export const XpBreakdownSchema = z.object({
base: z.number().int().optional(),
fullAccuracy: z.number().int().optional(),
quote: z.number().int().optional(),
corrected: z.number().int().optional(),
punctuation: z.number().int().optional(),
numbers: z.number().int().optional(),
funbox: z.number().int().optional(),
streak: z.number().int().optional(),
incomplete: z.number().int().optional(),
daily: z.number().int().optional(),
accPenalty: z.number().int().optional(),
configMultiplier: z.number().int().optional(),
});
export type XpBreakdown = z.infer<typeof XpBreakdownSchema>;
export const PostResultResponseSchema = z.object({
insertedId: IdSchema,
isPb: z.boolean(),
tagPbs: z.array(IdSchema),
dailyLeaderboardRank: z.number().int().nonnegative().optional(),
weeklyXpLeaderboardRank: z.number().int().nonnegative().optional(),
xp: z.number().int().nonnegative(),
dailyXpBonus: z.boolean(),
xpBreakdown: XpBreakdownSchema,
streak: z.number().int().nonnegative(),
});
export type PostResultResponse = z.infer<typeof PostResultResponseSchema>;

View file

@ -22,3 +22,15 @@ export const LanguageSchema = z
.max(50)
.regex(/^[a-zA-Z0-9_+]+$/, "Can only contain letters [a-zA-Z0-9_+]");
export type Language = z.infer<typeof LanguageSchema>;
export const PercentageSchema = z.number().nonnegative().max(100);
export type Percentage = z.infer<typeof PercentageSchema>;
export const WpmSchema = z.number().nonnegative().max(420);
export type Wpm = z.infer<typeof WpmSchema>;
export const CustomTextModeSchema = z.enum(["repeat", "random", "shuffle"]);
export type CustomTextMode = z.infer<typeof CustomTextModeSchema>;
export const CustomTextLimitModeSchema = z.enum(["word", "time", "section"]);
export type CustomTextLimitMode = z.infer<typeof CustomTextLimitModeSchema>;

View file

@ -1,7 +1,3 @@
type Difficulty = import("@monkeytype/contracts/schemas/configs").Difficulty;
type Mode = import("@monkeytype/contracts/schemas/shared").Mode;
type Mode2<M extends Mode> =
import("@monkeytype/contracts/schemas/shared").Mode2<M>;
type PersonalBest = import("@monkeytype/contracts/schemas/shared").PersonalBest;
type PersonalBests =
import("@monkeytype/contracts/schemas/shared").PersonalBests;
@ -117,139 +113,16 @@ export type Configuration = {
};
};
export type IncompleteTest = {
acc: number;
seconds: number;
};
export type ChartData = {
wpm: number[];
raw: number[];
err: number[];
};
export type KeyStats = {
average: number;
sd: number;
};
export type Result<M extends Mode> = {
_id: string;
wpm: number;
rawWpm: number;
charStats: [number, number, number, number];
acc: number;
mode: M;
mode2: Mode2<M>;
quoteLength?: number;
timestamp: number;
restartCount: number;
incompleteTestSeconds: number;
incompleteTests: IncompleteTest[];
testDuration: number;
afkDuration: number;
tags: string[];
consistency: number;
keyConsistency: number;
chartData: ChartData | "toolong";
uid: string;
keySpacingStats?: KeyStats;
keyDurationStats?: KeyStats;
isPb: boolean;
bailedOut: boolean;
blindMode: boolean;
lazyMode: boolean;
difficulty: Difficulty;
funbox: string;
language: string;
numbers: boolean;
punctuation: boolean;
};
export type DBResult<T extends Mode> = Omit<
Result<T>,
| "bailedOut"
| "blindMode"
| "lazyMode"
| "difficulty"
| "funbox"
| "language"
| "numbers"
| "punctuation"
| "restartCount"
| "incompleteTestSeconds"
| "afkDuration"
| "tags"
| "incompleteTests"
| "customText"
| "quoteLength"
| "isPb"
> & {
correctChars?: number; // --------------
incorrectChars?: number; // legacy results
// --------------
name: string;
// -------------- fields that might be removed to save space
bailedOut?: boolean;
blindMode?: boolean;
lazyMode?: boolean;
difficulty?: Difficulty;
funbox?: string;
language?: string;
numbers?: boolean;
punctuation?: boolean;
restartCount?: number;
incompleteTestSeconds?: number;
afkDuration?: number;
tags?: string[];
customText?: CustomTextDataWithTextLen;
quoteLength?: number;
isPb?: boolean;
};
export type CompletedEvent = Result<Mode> & {
keySpacing: number[] | "toolong";
keyDuration: number[] | "toolong";
customText?: CustomTextDataWithTextLen;
wpmConsistency: number;
challenge?: string | null;
keyOverlap: number;
lastKeyToEnd: number;
startToFirstKey: number;
charTotal: number;
stringified?: string;
hash?: string;
stopOnLetter: boolean;
};
export type CustomTextMode = "repeat" | "random" | "shuffle";
export type CustomTextLimitMode = "word" | "time" | "section";
export type CustomTextLimit = {
value: number;
mode: CustomTextLimitMode;
mode: import("@monkeytype/contracts/schemas/util").CustomTextLimitMode;
};
export type CustomTextData = {
export type CustomTextData = Omit<
import("@monkeytype/contracts/schemas/results").CustomTextDataWithTextLen,
"textLen"
> & {
text: string[];
mode: CustomTextMode;
limit: CustomTextLimit;
pipeDelimiter: boolean;
};
export type CustomTextDataWithTextLen = Omit<CustomTextData, "text"> & {
textLen: number;
};
export type PostResultResponse = {
isPb: boolean;
tagPbs: string[];
insertedId: string;
dailyLeaderboardRank?: number;
weeklyXpLeaderboardRank?: number;
xp: number;
dailyXpBonus: boolean;
xpBreakdown: Record<string, number>;
streak: number;
};
export type UserStreak = {

View file

@ -54,14 +54,14 @@ importers:
specifier: workspace:*
version: link:../packages/contracts
'@ts-rest/core':
specifier: 3.45.2
version: 3.45.2(zod@3.23.8)
specifier: 3.49.3
version: 3.49.3(zod@3.23.8)
'@ts-rest/express':
specifier: 3.45.2
version: 3.45.2(express@4.19.2)(zod@3.23.8)
specifier: 3.49.3
version: 3.49.3(@ts-rest/core@3.49.3(zod@3.23.8))(express@4.19.2)(zod@3.23.8)
'@ts-rest/open-api':
specifier: 3.45.2
version: 3.45.2(zod@3.23.8)
specifier: 3.49.3
version: 3.49.3(@ts-rest/core@3.49.3(zod@3.23.8))(zod@3.23.8)
bcrypt:
specifier: 5.1.1
version: 5.1.1(encoding@0.1.13)
@ -246,9 +246,6 @@ importers:
ioredis-mock:
specifier: 7.4.0
version: 7.4.0(ioredis@4.28.5)
openapi-recursive-tagging:
specifier: 0.0.6
version: 0.0.6
readline-sync:
specifier: 1.4.10
version: 1.4.10
@ -277,8 +274,8 @@ importers:
specifier: workspace:*
version: link:../packages/contracts
'@ts-rest/core':
specifier: 3.45.2
version: 3.45.2(zod@3.23.8)
specifier: 3.49.3
version: 3.49.3(zod@3.23.8)
axios:
specifier: 1.7.4
version: 1.7.4(debug@4.3.6)
@ -467,8 +464,8 @@ importers:
packages/contracts:
dependencies:
'@ts-rest/core':
specifier: 3.45.2
version: 3.45.2(zod@3.23.8)
specifier: 3.49.3
version: 3.49.3(zod@3.23.8)
zod:
specifier: 3.23.8
version: 3.23.8
@ -2524,26 +2521,28 @@ packages:
resolution: {integrity: sha512-EZ+XlSwjdLtscoBOnA/Ba6QBrmoxAR73tJFjnWxaJQsZxWBQv6bLUrDgZUdXkXRAOSkRHn0uXY6Wq/3SsV2WtQ==}
engines: {node: '>=18'}
'@ts-rest/core@3.45.2':
resolution: {integrity: sha512-Eiv+Sa23MbsAd1Gx9vNJ+IFCDyLZNdJ+UuGMKbFvb+/NmgcBR1VL1UIVtEkd5DJxpYMMd8SLvW91RgB2TS8iPw==}
'@ts-rest/core@3.49.3':
resolution: {integrity: sha512-h/4aSH7SGsQfBZ5LcF2k8+TVtFSITYG4qI91tdf0YMddPsSZJho2OV9jhvycNEt+sosPsw/FDV2QFKBAUEr22w==}
peerDependencies:
zod: ^3.22.3
peerDependenciesMeta:
zod:
optional: true
'@ts-rest/express@3.45.2':
resolution: {integrity: sha512-GypL5auYuh3WS2xCc58J9OpgpYYJoPY2NvBxJxHOLdhMOoGHhYI8oIUtxfxIxWZcPJOAokrrQJ2zb2hrPUuR1A==}
'@ts-rest/express@3.49.3':
resolution: {integrity: sha512-+ybB1yvzyPclxlSLMoZGuEL26DqxH/bLbWShWC2C28kRq0k768xYe8EvDD9VRrZMJqmnO+sndXdcGq40hvhcKA==}
peerDependencies:
'@ts-rest/core': ~3.49.0
express: ^4.0.0
zod: ^3.22.3
peerDependenciesMeta:
zod:
optional: true
'@ts-rest/open-api@3.45.2':
resolution: {integrity: sha512-TG5GvD0eyicMR3HjEAMTO2ulO7x/ADSn5bfLF/ZDdVXla48PiQdwhG4NsjVbsUDCghwx+6y5u2aTYjypzdiXQg==}
'@ts-rest/open-api@3.49.3':
resolution: {integrity: sha512-5N71UP/5KtjOyagc076arPwGkemDBlqGv7c42AbV2ca4dj1dlveis41VIpHsfWOdsFS548X+C9b0td7YVCdpqA==}
peerDependencies:
'@ts-rest/core': ~3.49.0
zod: ^3.22.3
'@tsconfig/node10@1.0.11':
@ -2972,10 +2971,6 @@ packages:
resolution: {integrity: sha512-TIGnTpdo+E3+pCyAluZvtED5p5wCqLdezCyhPZzKPcxvFplEt4i+W7OONCKgeZFT3+y5NZZfOOS/Bdcanm1MYA==}
engines: {node: '>=0.10.0'}
ansi-regex@4.1.1:
resolution: {integrity: sha512-ILlv4k/3f6vfQ4OoP2AGvirOktlQ98ZEL1k9FaQjxa3L1abBgbuTDAdPOpvbGncC0BTVQrl+OM8xZGK6tWXt7g==}
engines: {node: '>=6'}
ansi-regex@5.0.1:
resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==}
engines: {node: '>=8'}
@ -3574,9 +3569,6 @@ packages:
cliui@3.2.0:
resolution: {integrity: sha512-0yayqDxWQbqk3ojkYqUKqaAQ6AfNKeKWRNA8kR0WXzAsdHpP4BIaOmMAG87JGuO6qcobyW4GjxHd9PmhEd+T9w==}
cliui@5.0.0:
resolution: {integrity: sha512-PYeGSEmmHM6zvoef2w8TPzlrnNpXIjTipYK780YswmIP9vjxmd6Y2a3CB2Ks6/AU8NHjZugXvo8w3oWM2qnwXA==}
cliui@7.0.4:
resolution: {integrity: sha512-OcRE68cOsVMXp1Yvonl/fzkQOyjLSu/8bhPDfQt0e0/Eb283TKP20Fs2MqoPsr9SwA595rRCA+QMzYc9nBP+JQ==}
@ -4285,9 +4277,6 @@ packages:
electron-to-chromium@1.5.5:
resolution: {integrity: sha512-QR7/A7ZkMS8tZuoftC/jfqNkZLQO779SSW3YuZHP4eXpj3EffGLFcB/Xu9AAZQzLccTiCV+EmUo3ha4mQ9wnlA==}
emoji-regex@7.0.3:
resolution: {integrity: sha512-CwBLREIQ7LvYFB0WyRvwhq5N5qPhc6PMjD6bYggFlI5YyDgl+0vxq5VHbMOFqLg7hfWzmu8T5Z1QofhmTIhItA==}
emoji-regex@8.0.0:
resolution: {integrity: sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==}
@ -4765,10 +4754,6 @@ packages:
resolution: {integrity: sha512-jvElSjyuo4EMQGoTwo1uJU5pQMwTW5lS1x05zzfJuTIyLR3zwO27LYrxNg+dlvKpGOuGy/MzBdXh80g0ve5+HA==}
engines: {node: '>=0.10.0'}
find-up@3.0.0:
resolution: {integrity: sha512-1yD6RmLI1XBfxugvORwlck6f75tYL+iR0jqwsOrOxMZyGYqUuDhJ0l4AXdO1iX/FTs9cBAMEk1gWSEx1kSbylg==}
engines: {node: '>=6'}
find-up@4.1.0:
resolution: {integrity: sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==}
engines: {node: '>=8'}
@ -5592,10 +5577,6 @@ packages:
resolution: {integrity: sha512-1pqUqRjkhPJ9miNq9SwMfdvi6lBJcd6eFxvfaivQhaH3SgisfiuudvFntdKOmxuee/77l+FPjKrQjWvmPjWrRw==}
engines: {node: '>=0.10.0'}
is-fullwidth-code-point@2.0.0:
resolution: {integrity: sha512-VHskAKYM8RfSFXwee5t5cbN5PZeq1Wrh6qd5bkyiXIf6UQcN6w/A0eXM9r6t8d+GYOh+o6ZhiEnb88LN/Y8m2w==}
engines: {node: '>=4'}
is-fullwidth-code-point@3.0.0:
resolution: {integrity: sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==}
engines: {node: '>=8'}
@ -6096,10 +6077,6 @@ packages:
resolution: {integrity: sha512-cy7ZdNRXdablkXYNI049pthVeXFurRyb9+hA/dZzerZ0pGTx42z+y+ssxBaVV2l70t1muq5IdKhn4UtcoGUY9A==}
engines: {node: '>=0.10.0'}
locate-path@3.0.0:
resolution: {integrity: sha512-7AO748wWnIhNqAuaty2ZWHkQHRSNfPVIsPIfwEOWO22AmaoVrWavlOcMR5nzTLNYvp36X220/maaRsrec1G65A==}
engines: {node: '>=6'}
locate-path@5.0.0:
resolution: {integrity: sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==}
engines: {node: '>=8'}
@ -7055,10 +7032,6 @@ packages:
resolution: {integrity: sha512-7x81NCL719oNbsq/3mh+hVrAWmFuEYUqrq/Iw3kUzH8ReypT9QQ0BLoJS7/G9k6N81XjW4qHWtjWwe/9eLy1EQ==}
engines: {node: '>=12'}
openapi-recursive-tagging@0.0.6:
resolution: {integrity: sha512-ZtHR0jHeIMTuyeenbee7j0fK58Uf9ZMFGTXv3abQvsdjImfdEXfmmNlMNgpW27DmoomOgwwpGNGldUUQd0oJ4g==}
hasBin: true
openapi-sampler@1.5.1:
resolution: {integrity: sha512-tIWIrZUKNAsbqf3bd9U1oH6JEXo8LNYuDlXw26By67EygpjT+ArFnsxxyTMjFWRfbqo5ozkvgSQDK69Gd8CddA==}
@ -7099,10 +7072,6 @@ packages:
resolution: {integrity: sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==}
engines: {node: '>=10'}
p-locate@3.0.0:
resolution: {integrity: sha512-x+12w/To+4GFfgJhBEpiDcLozRJGegY+Ei7/z0tSLkMmxGZNybVMSfWj9aJn8Z5Fc7dBUNJOOVgPv2H7IwulSQ==}
engines: {node: '>=6'}
p-locate@4.1.0:
resolution: {integrity: sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==}
engines: {node: '>=8'}
@ -7213,10 +7182,6 @@ packages:
resolution: {integrity: sha512-yTltuKuhtNeFJKa1PiRzfLAU5182q1y4Eb4XCJ3PBqyzEDkAZRzBrKKBct682ls9reBVHf9udYLN5Nd+K1B9BQ==}
engines: {node: '>=0.10.0'}
path-exists@3.0.0:
resolution: {integrity: sha512-bpC7GYwiDYQ4wYLe+FA8lhRjhQCMcQGuSgGGqDkg/QerRWw9CmGRT0iSOVRSZJ29NMLZgIzqaljJ63oaL4NIJQ==}
engines: {node: '>=4'}
path-exists@4.0.0:
resolution: {integrity: sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==}
engines: {node: '>=8'}
@ -7789,9 +7754,6 @@ packages:
require-main-filename@1.0.1:
resolution: {integrity: sha512-IqSUtOVP4ksd1C/ej5zeEh/BIP2ajqpn8c5x+q99gvcIG/Qf0cud5raVnE/Dwd0ua9TXYDoDc0RE5hBSdz22Ug==}
require-main-filename@2.0.0:
resolution: {integrity: sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg==}
requirejs-config-file@4.0.0:
resolution: {integrity: sha512-jnIre8cbWOyvr8a5F2KuqBnY+SDA4NXr/hzEZJG79Mxm2WiFQz2dzhC8ibtPJS7zkmBEl1mxSwp5HhC1W4qpxw==}
engines: {node: '>=10.13.0'}
@ -8329,10 +8291,6 @@ packages:
resolution: {integrity: sha512-0XsVpQLnVCXHJfyEs8tC0zpTVIr5PKKsQtkT29IwupnPTjtPmQ3xT/4yCREF9hYkV/3M3kzcUTSAZT6a6h81tw==}
engines: {node: '>=0.10.0'}
string-width@3.1.0:
resolution: {integrity: sha512-vafcv6KjVZKSgz06oM/H6GDBrAtz8vdhQakGjFIvNrHA6y3HCF1CInLy+QLq8dTJPQ1b+KDUqDFctkdRW44e1w==}
engines: {node: '>=6'}
string-width@4.2.3:
resolution: {integrity: sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==}
engines: {node: '>=8'}
@ -8373,10 +8331,6 @@ packages:
resolution: {integrity: sha512-VhumSSbBqDTP8p2ZLKj40UjBCV4+v8bUSEpUb4KjRgWk9pbqGF4REFj6KEagidb2f/M6AzC0EmFyDNGaw9OCzg==}
engines: {node: '>=0.10.0'}
strip-ansi@5.2.0:
resolution: {integrity: sha512-DuRs1gKbBqsMKIZlrffwlug8MHkcnpjs5VPmL1PAh+mA30U0DTotfDZ0d2UUsXpPmPmMMJ6W773MaA3J+lbiWA==}
engines: {node: '>=6'}
strip-ansi@6.0.1:
resolution: {integrity: sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==}
engines: {node: '>=8'}
@ -9303,9 +9257,6 @@ packages:
which-module@1.0.0:
resolution: {integrity: sha512-F6+WgncZi/mJDrammbTuHe1q0R5hOXv/mBaiNA2TCNT/LTHusX0V+CJnj9XT8ki5ln2UZyyddDgHfCzyrOH7MQ==}
which-module@2.0.1:
resolution: {integrity: sha512-iBdZ57RDvnOR9AGBhML2vFZf7h8vmBjhoaZqODJBFWHVtKkDmKuHai3cx5PgVMrX5YDNp27AofYbAwctSS+vhQ==}
which-pm-runs@1.1.0:
resolution: {integrity: sha512-n1brCuqClxfFfq/Rb0ICg9giSZqCS+pLtccdag6C2HyufBrh3fBOiy9nb6ggRMvWOVH5GrdJskj5iGTZNxd7SA==}
engines: {node: '>=4'}
@ -9411,10 +9362,6 @@ packages:
resolution: {integrity: sha512-vAaEaDM946gbNpH5pLVNR+vX2ht6n0Bt3GXwVB1AuAqZosOvHNF3P7wDnh8KLkSqgUh0uh77le7Owgoz+Z9XBw==}
engines: {node: '>=0.10.0'}
wrap-ansi@5.1.0:
resolution: {integrity: sha512-QC1/iN/2/RPVJ5jYK8BGttj5z83LmSKmvbvrXPNCLZSEb32KKVDJDl/MOt2N01qU2H/FkzEa9PKto1BqDjtd7Q==}
engines: {node: '>=6'}
wrap-ansi@6.2.0:
resolution: {integrity: sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==}
engines: {node: '>=8'}
@ -9456,9 +9403,6 @@ packages:
y18n@3.2.2:
resolution: {integrity: sha512-uGZHXkHnhF0XeeAPgnKfPv1bgKAYyVvmNL1xlKsPYZPaIHxGti2hHqvOCQv71XMsLxu1QjergkqogUnms5D3YQ==}
y18n@4.0.3:
resolution: {integrity: sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ==}
y18n@5.0.8:
resolution: {integrity: sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==}
engines: {node: '>=10'}
@ -9481,9 +9425,6 @@ packages:
engines: {node: '>= 14'}
hasBin: true
yargs-parser@15.0.3:
resolution: {integrity: sha512-/MVEVjTXy/cGAjdtQf8dW3V9b97bPN7rNn8ETj6BmAQL7ibC7O1Q9SPJbGjgh3SlwoBNXMzj/ZGIj8mBgl12YA==}
yargs-parser@20.2.9:
resolution: {integrity: sha512-y11nGElTIV+CT3Zv9t7VKl+Q3hTQoT9a1Qzezhhl6Rp21gJ/IVTW7Z3y9EWXhuUBC2Shnf+DX0antecpAwSP8w==}
engines: {node: '>=10'}
@ -9495,9 +9436,6 @@ packages:
yargs-parser@5.0.1:
resolution: {integrity: sha512-wpav5XYiddjXxirPoCTUPbqM0PXvJ9hiBMvuJgInvo4/lAOTZzUprArw17q2O1P2+GHhbBr18/iQwjL5Z9BqfA==}
yargs@14.2.3:
resolution: {integrity: sha512-ZbotRWhF+lkjijC/VhmOT9wSgyBQ7+zr13+YLkhfsSiTriYsMzkTUFP18pFhWwBeMa5gUc1MzbhrO6/VB7c9Xg==}
yargs@16.2.0:
resolution: {integrity: sha512-D1mvvtDG0L5ft/jGWkLpG1+m0eQxOfaBvTNELraWj22wSVUMWxZUvYgJYcKh6jGGIkJFhH4IZPQhR4TKpc8mBw==}
engines: {node: '>=10'}
@ -11801,19 +11739,21 @@ snapshots:
'@ts-graphviz/ast': 2.0.3
'@ts-graphviz/common': 2.1.2
'@ts-rest/core@3.45.2(zod@3.23.8)':
'@ts-rest/core@3.49.3(zod@3.23.8)':
optionalDependencies:
zod: 3.23.8
'@ts-rest/express@3.45.2(express@4.19.2)(zod@3.23.8)':
'@ts-rest/express@3.49.3(@ts-rest/core@3.49.3(zod@3.23.8))(express@4.19.2)(zod@3.23.8)':
dependencies:
'@ts-rest/core': 3.49.3(zod@3.23.8)
express: 4.19.2
optionalDependencies:
zod: 3.23.8
'@ts-rest/open-api@3.45.2(zod@3.23.8)':
'@ts-rest/open-api@3.49.3(@ts-rest/core@3.49.3(zod@3.23.8))(zod@3.23.8)':
dependencies:
'@anatine/zod-openapi': 1.14.2(openapi3-ts@2.0.2)(zod@3.23.8)
'@ts-rest/core': 3.49.3(zod@3.23.8)
openapi3-ts: 2.0.2
zod: 3.23.8
@ -12353,8 +12293,6 @@ snapshots:
ansi-regex@2.1.1: {}
ansi-regex@4.1.1: {}
ansi-regex@5.0.1: {}
ansi-regex@6.0.1: {}
@ -13058,12 +12996,6 @@ snapshots:
strip-ansi: 3.0.1
wrap-ansi: 2.1.0
cliui@5.0.0:
dependencies:
string-width: 3.1.0
strip-ansi: 5.2.0
wrap-ansi: 5.1.0
cliui@7.0.4:
dependencies:
string-width: 4.2.3
@ -13783,8 +13715,6 @@ snapshots:
electron-to-chromium@1.5.5: {}
emoji-regex@7.0.3: {}
emoji-regex@8.0.0: {}
emoji-regex@9.2.2: {}
@ -14595,10 +14525,6 @@ snapshots:
path-exists: 2.1.0
pinkie-promise: 2.0.1
find-up@3.0.0:
dependencies:
locate-path: 3.0.0
find-up@4.1.0:
dependencies:
locate-path: 5.0.0
@ -15715,8 +15641,6 @@ snapshots:
dependencies:
number-is-nan: 1.0.1
is-fullwidth-code-point@2.0.0: {}
is-fullwidth-code-point@3.0.0: {}
is-fullwidth-code-point@4.0.0: {}
@ -16230,11 +16154,6 @@ snapshots:
pinkie-promise: 2.0.1
strip-bom: 2.0.0
locate-path@3.0.0:
dependencies:
p-locate: 3.0.0
path-exists: 3.0.0
locate-path@5.0.0:
dependencies:
p-locate: 4.1.0
@ -17422,12 +17341,6 @@ snapshots:
is-docker: 2.2.1
is-wsl: 2.2.0
openapi-recursive-tagging@0.0.6:
dependencies:
reftools: 1.1.9
yaml: 1.10.2
yargs: 14.2.3
openapi-sampler@1.5.1:
dependencies:
'@types/json-schema': 7.0.15
@ -17482,10 +17395,6 @@ snapshots:
dependencies:
yocto-queue: 0.1.0
p-locate@3.0.0:
dependencies:
p-limit: 2.3.0
p-locate@4.1.0:
dependencies:
p-limit: 2.3.0
@ -17594,8 +17503,6 @@ snapshots:
dependencies:
pinkie-promise: 2.0.1
path-exists@3.0.0: {}
path-exists@4.0.0: {}
path-is-absolute@1.0.1: {}
@ -18223,8 +18130,6 @@ snapshots:
require-main-filename@1.0.1: {}
require-main-filename@2.0.0: {}
requirejs-config-file@4.0.0:
dependencies:
esprima: 4.0.1
@ -18811,12 +18716,6 @@ snapshots:
is-fullwidth-code-point: 1.0.0
strip-ansi: 3.0.1
string-width@3.1.0:
dependencies:
emoji-regex: 7.0.3
is-fullwidth-code-point: 2.0.0
strip-ansi: 5.2.0
string-width@4.2.3:
dependencies:
emoji-regex: 8.0.0
@ -18883,10 +18782,6 @@ snapshots:
dependencies:
ansi-regex: 2.1.1
strip-ansi@5.2.0:
dependencies:
ansi-regex: 4.1.1
strip-ansi@6.0.1:
dependencies:
ansi-regex: 5.0.1
@ -19996,8 +19891,6 @@ snapshots:
which-module@1.0.0: {}
which-module@2.0.1: {}
which-pm-runs@1.1.0: {}
which-typed-array@1.1.15:
@ -20179,12 +20072,6 @@ snapshots:
string-width: 1.0.2
strip-ansi: 3.0.1
wrap-ansi@5.1.0:
dependencies:
ansi-styles: 3.2.1
string-width: 3.1.0
strip-ansi: 5.2.0
wrap-ansi@6.2.0:
dependencies:
ansi-styles: 4.3.0
@ -20220,8 +20107,6 @@ snapshots:
y18n@3.2.2: {}
y18n@4.0.3: {}
y18n@5.0.8: {}
yallist@3.1.1: {}
@ -20234,11 +20119,6 @@ snapshots:
yaml@2.5.0: {}
yargs-parser@15.0.3:
dependencies:
camelcase: 5.3.1
decamelize: 1.2.0
yargs-parser@20.2.9: {}
yargs-parser@21.1.1: {}
@ -20248,20 +20128,6 @@ snapshots:
camelcase: 3.0.0
object.assign: 4.1.5
yargs@14.2.3:
dependencies:
cliui: 5.0.0
decamelize: 1.2.0
find-up: 3.0.0
get-caller-file: 2.0.5
require-directory: 2.1.1
require-main-filename: 2.0.0
set-blocking: 2.0.0
string-width: 3.1.0
which-module: 2.0.1
y18n: 4.0.3
yargs-parser: 15.0.3
yargs@16.2.0:
dependencies:
cliui: 7.0.4