From e03a25fb92f5c4a8a4fb0b74387ef3f1b421b9b1 Mon Sep 17 00:00:00 2001 From: Christian Fehmer Date: Fri, 23 Aug 2024 12:13:50 +0200 Subject: [PATCH] impr: use ts-rest for results endpoint (@fehmer) (#5758) !nuf --- .../__tests__/api/controllers/result.spec.ts | 718 ++++++++++++++++-- backend/package.json | 9 +- backend/redocly.yaml | 2 +- backend/scripts/openapi.ts | 14 +- backend/src/anticheat/index.ts | 12 +- backend/src/api/controllers/result.ts | 172 +++-- backend/src/api/routes/index.ts | 2 +- backend/src/api/routes/results.ts | 119 +-- backend/src/api/schemas/result-schema.ts | 98 --- backend/src/dal/result.ts | 13 +- backend/src/dal/user.ts | 4 +- .../src/documentation/internal-swagger.json | 92 --- backend/src/documentation/public-swagger.json | 203 ----- backend/src/middlewares/ape-rate-limit.ts | 19 + backend/src/types/types.d.ts | 8 +- backend/src/utils/pb.ts | 5 +- backend/src/utils/prometheus.ts | 6 +- backend/src/utils/result.ts | 38 +- backend/src/utils/validation.ts | 2 +- frontend/package.json | 2 +- frontend/src/ts/ape/endpoints/index.ts | 2 - frontend/src/ts/ape/endpoints/results.ts | 35 - frontend/src/ts/ape/index.ts | 1 - frontend/src/ts/ape/types/results.d.ts | 9 - .../src/ts/controllers/account-controller.ts | 18 +- .../ts/controllers/challenge-controller.ts | 9 +- frontend/src/ts/db.ts | 65 +- frontend/src/ts/elements/account-button.ts | 71 +- frontend/src/ts/elements/profile.ts | 2 +- frontend/src/ts/modals/custom-text.ts | 2 +- frontend/src/ts/modals/edit-result-tags.ts | 13 +- frontend/src/ts/modals/google-sign-up.ts | 19 - .../src/ts/modals/last-signed-out-result.ts | 25 +- frontend/src/ts/modals/mini-result-chart.ts | 2 +- frontend/src/ts/pages/account.ts | 18 +- frontend/src/ts/test/custom-text.ts | 5 +- frontend/src/ts/test/result.ts | 7 +- frontend/src/ts/test/test-logic.ts | 86 ++- frontend/src/ts/test/test-stats.ts | 10 +- frontend/src/ts/types/types.d.ts | 42 +- frontend/src/ts/utils/misc.ts | 8 +- frontend/src/ts/utils/results.ts | 39 + monkeytype.code-workspace | 5 +- packages/contracts/package.json | 2 +- packages/contracts/src/index.ts | 2 + packages/contracts/src/results.ts | 150 ++++ packages/contracts/src/schemas/api.ts | 3 +- packages/contracts/src/schemas/results.ts | 150 ++++ packages/contracts/src/schemas/util.ts | 12 + packages/shared-types/src/index.ts | 137 +--- pnpm-lock.yaml | 180 +---- 51 files changed, 1453 insertions(+), 1214 deletions(-) delete mode 100644 backend/src/api/schemas/result-schema.ts delete mode 100644 frontend/src/ts/ape/endpoints/results.ts delete mode 100644 frontend/src/ts/ape/types/results.d.ts create mode 100644 frontend/src/ts/utils/results.ts create mode 100644 packages/contracts/src/results.ts create mode 100644 packages/contracts/src/schemas/results.ts diff --git a/backend/__tests__/api/controllers/result.spec.ts b/backend/__tests__/api/controllers/result.spec.ts index e656b09f1..9e1851442 100644 --- a/backend/__tests__/api/controllers/result.spec.ts +++ b/backend/__tests__/api/controllers/result.spec.ts @@ -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 { const mockConfig = _.merge(await configuration, { users: { premium: { enabled: premium } }, @@ -263,3 +797,57 @@ async function enablePremiumFeatures(premium: boolean): Promise { mockConfig ); } +function givenDbResult( + uid: string, + customize?: Partial +): 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 { + const mockConfig = _.merge(await configuration, { + apeKeys: { acceptKeys: enabled }, + }); + + vi.spyOn(Configuration, "getCachedConfiguration").mockResolvedValue( + mockConfig + ); +} + +async function enableResultsSaving(enabled: boolean): Promise { + const mockConfig = _.merge(await configuration, { + results: { savingEnabled: enabled }, + }); + + vi.spyOn(Configuration, "getCachedConfiguration").mockResolvedValue( + mockConfig + ); +} diff --git a/backend/package.json b/backend/package.json index 091849ca7..7704dfe4f 100644 --- a/backend/package.json +++ b/backend/package.json @@ -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", diff --git a/backend/redocly.yaml b/backend/redocly.yaml index 1898e3891..7bacf646a 100644 --- a/backend/redocly.yaml +++ b/backend/redocly.yaml @@ -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 diff --git a/backend/scripts/openapi.ts b/backend/scripts/openapi.ts index 38a1e327c..0eaa6cc8c 100644 --- a/backend/scripts/openapi.ts +++ b/backend/scripts/openapi.ts @@ -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", }, ], }, diff --git a/backend/src/anticheat/index.ts b/backend/src/anticheat/index.ts index 51f17d15c..57c52aa34 100644 --- a/backend/src/anticheat/index.ts +++ b/backend/src/anticheat/index.ts @@ -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; } diff --git a/backend/src/api/controllers/result.ts b/backend/src/api/controllers/result.ts index 55303acaa..4c3970f8a 100644 --- a/backend/src/api/controllers/result.ts +++ b/backend/src/api/controllers/result.ts @@ -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 { + req: MonkeyTypes.Request2 +): Promise { 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 { + req: MonkeyTypes.Request2 +): Promise { 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 { + req: MonkeyTypes.Request2 +): Promise { 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 { + req: MonkeyTypes.Request2 +): Promise { 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 { + req: MonkeyTypes.Request2 +): Promise { 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 & { - 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; + breakdown?: XpBreakdown; }; async function calculateXp( @@ -669,10 +669,10 @@ async function calculateXp( }; } - const breakdown: Record = {}; + 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 { + return replaceObjectId(replaceLegacyValues(db)); +} diff --git a/backend/src/api/routes/index.ts b/backend/src/api/routes/index.ts index 287b53f61..5fd58e335 100644 --- a/backend/src/api/routes/index.ts +++ b/backend/src/api/routes/index.ts @@ -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 { diff --git a/backend/src/api/routes/results.ts b/backend/src/api/routes/results.ts index 92328c072..d8d8dfaa0 100644 --- a/backend/src/api/routes/results.ts +++ b/backend/src/api/routes/results.ts @@ -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), + }, +}); diff --git a/backend/src/api/schemas/result-schema.ts b/backend/src/api/schemas/result-schema.ts deleted file mode 100644 index 233469922..000000000 --- a/backend/src/api/schemas/result-schema.ts +++ /dev/null @@ -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; diff --git a/backend/src/dal/result.ts b/backend/src/dal/result.ts index 6566c5811..57946bf7e 100644 --- a/backend/src/dal/result.ts +++ b/backend/src/dal/result.ts @@ -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>; - -export const getResultCollection = (): Collection => - db.collection("results"); +export const getResultCollection = (): Collection => + db.collection("results"); export async function addResult( uid: string, @@ -78,14 +75,14 @@ export async function getResult( export async function getLastResult( uid: string -): Promise> { +): Promise { 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( diff --git a/backend/src/dal/user.ts b/backend/src/dal/user.ts index a294417f6..8252b7e41 100644 --- a/backend/src/dal/user.ts +++ b/backend/src/dal/user.ts @@ -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, "_id" | "name">; +type Result = Omit, "_id" | "name">; // Export for use in tests export const getUsersCollection = (): Collection => diff --git a/backend/src/documentation/internal-swagger.json b/backend/src/documentation/internal-swagger.json index 7bcd21a78..be8f5d030 100644 --- a/backend/src/documentation/internal-swagger.json +++ b/backend/src/documentation/internal-swagger.json @@ -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"], diff --git a/backend/src/documentation/public-swagger.json b/backend/src/documentation/public-swagger.json index c5a2cdc90..30c9d8a0f 100644 --- a/backend/src/documentation/public-swagger.json +++ b/backend/src/documentation/public-swagger.json @@ -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": { diff --git a/backend/src/middlewares/ape-rate-limit.ts b/backend/src/middlewares/ape-rate-limit.ts index caf5a27d6..ea277a475 100644 --- a/backend/src/middlewares/ape-rate-limit.ts +++ b/backend/src/middlewares/ape-rate-limit.ts @@ -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( + defaultRateLimiter: RateLimitRequestHandler, + apeRateLimiterOverride?: RateLimitRequestHandler +): TsRestRequestHandler { + 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); + }; +} diff --git a/backend/src/types/types.d.ts b/backend/src/types/types.d.ts index 34345f393..d0165b0d0 100644 --- a/backend/src/types/types.d.ts +++ b/backend/src/types/types.d.ts @@ -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; diff --git a/backend/src/utils/pb.ts b/backend/src/utils/pb.ts index b00d747a5..b46f5e0f2 100644 --- a/backend/src/utils/pb.ts +++ b/backend/src/utils/pb.ts @@ -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, "_id" | "name">; +type Result = Omit, "_id" | "name">; export function canFunboxGetPb(result: Result): boolean { const funbox = result.funbox; diff --git a/backend/src/utils/prometheus.ts b/backend/src/utils/prometheus.ts index 040e1492e..1aaa39f4b 100644 --- a/backend/src/utils/prometheus.ts +++ b/backend/src/utils/prometheus.ts @@ -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): void { +export function incrementResult(res: CompletedEvent, isPb?: boolean): void { const { mode, mode2, - isPb, blindMode, lazyMode, difficulty, diff --git a/backend/src/utils/result.ts b/backend/src/utils/result.ts index 0cb11b58f..65abc1c73 100644 --- a/backend/src/utils/result.ts +++ b/backend/src/utils/result.ts @@ -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>; - 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; +} diff --git a/backend/src/utils/validation.ts b/backend/src/utils/validation.ts index 15b118105..424a92c74 100644 --- a/backend/src/utils/validation.ts +++ b/backend/src/utils/validation.ts @@ -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; diff --git a/frontend/package.json b/frontend/package.json index a2c1f367e..708289c01 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -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", diff --git a/frontend/src/ts/ape/endpoints/index.ts b/frontend/src/ts/ape/endpoints/index.ts index 2e31c23f9..0c8b813b4 100644 --- a/frontend/src/ts/ape/endpoints/index.ts +++ b/frontend/src/ts/ape/endpoints/index.ts @@ -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, diff --git a/frontend/src/ts/ape/endpoints/results.ts b/frontend/src/ts/ape/endpoints/results.ts deleted file mode 100644 index a7fa07071..000000000 --- a/frontend/src/ts/ape/endpoints/results.ts +++ /dev/null @@ -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[]> { - return await this.httpClient.get(BASE_PATH, { searchQuery: { offset } }); - } - - async save( - result: Result - ): Ape.EndpointResponse { - return await this.httpClient.post(BASE_PATH, { - payload: { result }, - }); - } - - async updateTags( - resultId: string, - tagIds: string[] - ): Ape.EndpointResponse { - return await this.httpClient.patch(`${BASE_PATH}/tags`, { - payload: { resultId, tagIds }, - }); - } - - async deleteAll(): Ape.EndpointResponse { - return await this.httpClient.delete(BASE_PATH); - } -} diff --git a/frontend/src/ts/ape/index.ts b/frontend/src/ts/ape/index.ts index 4244f6aa4..2434f3ae7 100644 --- a/frontend/src/ts/ape/index.ts +++ b/frontend/src/ts/ape/index.ts @@ -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)), diff --git a/frontend/src/ts/ape/types/results.d.ts b/frontend/src/ts/ape/types/results.d.ts deleted file mode 100644 index c8ac4bfb4..000000000 --- a/frontend/src/ts/ape/types/results.d.ts +++ /dev/null @@ -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; -} diff --git a/frontend/src/ts/controllers/account-controller.ts b/frontend/src/ts/controllers/account-controller.ts index a5e96317f..f74a52ee7 100644 --- a/frontend/src/ts/controllers/account-controller.ts +++ b/frontend/src/ts/controllers/account-controller.ts @@ -185,7 +185,7 @@ async function getDataAndInit(): Promise { return true; } -export async function loadUser(user: UserType): Promise { +export async function loadUser(_user: UserType): Promise { // User is signed in. PageTransition.set(false); AccountButton.loading(true); @@ -205,7 +205,6 @@ export async function loadUser(user: UserType): Promise { // showFavouriteThemesAtTheTop(); if (TestLogic.notSignedInLastResult !== null) { - TestLogic.setNotSignedInUid(user.uid); LastSignedOutResultModal.show(); } } @@ -578,22 +577,7 @@ async function signUp(): Promise { 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"); diff --git a/frontend/src/ts/controllers/challenge-controller.ts b/frontend/src/ts/controllers/challenge-controller.ts index 998e4f1d3..376e89872 100644 --- a/frontend/src/ts/controllers/challenge-controller.ts +++ b/frontend/src/ts/controllers/challenge-controller.ts @@ -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): 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): 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`); } diff --git a/frontend/src/ts/db.ts b/frontend/src/ts/db.ts index 69c79c4d7..a9dfecfd2 100644 --- a/frontend/src/ts/db.ts +++ b/frontend/src/ts/db.ts @@ -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 { 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[]; + const results: MonkeyTypes.FullResult[] = 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; + } + ); 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 { const resultsWithoutDuplicates = results.filter( (it) => it.timestamp < oldestTimestamp ); - dbSnapshot.results.push( - ...(resultsWithoutDuplicates as unknown as Result[]) - ); + dbSnapshot.results.push(...resultsWithoutDuplicates); } else { - dbSnapshot.results = results as unknown as Result[]; + dbSnapshot.results = results; } return true; } @@ -939,7 +932,7 @@ export async function resetConfig(): Promise { } } -export function saveLocalResult(result: Result): void { +export function saveLocalResult(result: MonkeyTypes.FullResult): void { const snapshot = getSnapshot(); if (!snapshot) return; diff --git a/frontend/src/ts/elements/account-button.ts b/frontend/src/ts/elements/account-button.ts index 811b916b7..9887b4dba 100644 --- a/frontend/src/ts/elements/account-button.ts +++ b/frontend/src/ts/elements/account-button.ts @@ -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 + breakdown?: XpBreakdown ): Promise { if (!breakdown) { $("nav .xpBar .xpGain").text(`+${addedXp}`); @@ -289,84 +290,84 @@ async function animateXpBreakdown( xpGain.text(`+0`); xpBreakdown.append( - `` + `` ); 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; diff --git a/frontend/src/ts/elements/profile.ts b/frontend/src/ts/elements/profile.ts index 24be4480a..dae149d25 100644 --- a/frontend/src/ts/elements/profile.ts +++ b/frontend/src/ts/elements/profile.ts @@ -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( diff --git a/frontend/src/ts/modals/custom-text.ts b/frontend/src/ts/modals/custom-text.ts index a6fbb5d05..3b3475399 100644 --- a/frontend/src/ts/modals/custom-text.ts +++ b/frontend/src/ts/modals/custom-text.ts @@ -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"; diff --git a/frontend/src/ts/modals/edit-result-tags.ts b/frontend/src/ts/modals/edit-result-tags.ts index eef7fc461..332e5eb9f 100644 --- a/frontend/src/ts/modals/edit-result-tags.ts +++ b/frontend/src/ts/modals/edit-result-tags.ts @@ -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 { 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 { 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, diff --git a/frontend/src/ts/modals/google-sign-up.ts b/frontend/src/ts/modals/google-sign-up.ts index 40e249acb..eca55df88 100644 --- a/frontend/src/ts/modals/google-sign-up.ts +++ b/frontend/src/ts/modals/google-sign-up.ts @@ -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 { 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(); diff --git a/frontend/src/ts/modals/last-signed-out-result.ts b/frontend/src/ts/modals/last-signed-out-result.ts index fe399323e..835c2a79d 100644 --- a/frontend/src/ts/modals/last-signed-out-result.ts +++ b/frontend/src/ts/modals/last-signed-out-result.ts @@ -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 { - //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 => { 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) => { diff --git a/frontend/src/ts/modals/mini-result-chart.ts b/frontend/src/ts/modals/mini-result-chart.ts index 0983e70ca..31ce28eb2 100644 --- a/frontend/src/ts/modals/mini-result-chart.ts +++ b/frontend/src/ts/modals/mini-result-chart.ts @@ -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"; diff --git a/frontend/src/ts/pages/account.ts b/frontend/src/ts/pages/account.ts index 915b7dbe9..1276b1bd9 100644 --- a/frontend/src/ts/pages/account.ts +++ b/frontend/src/ts/pages/account.ts @@ -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[] = []; +let filteredResults: MonkeyTypes.FullResult[] = []; let visibleTableLines = 0; function loadMoreLines(lineIndex?: number): void { @@ -276,7 +271,7 @@ async function fillContent(): Promise { filteredResults = []; $(".pageAccount .history table tbody").empty(); - DB.getSnapshot()?.results?.forEach((result: Result) => { + DB.getSnapshot()?.results?.forEach((result) => { // totalSeconds += tt; //apply filters @@ -1069,7 +1064,7 @@ function sortAndRefreshHistory( $(headerClass).append(''); } - const temp = []; + const temp: MonkeyTypes.FullResult[] = []; 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[]; + filteredResults = temp; $(".pageAccount .history table tbody").empty(); visibleTableLines = 0; diff --git a/frontend/src/ts/test/custom-text.ts b/frontend/src/ts/test/custom-text.ts index 3b428070b..748a0dee8 100644 --- a/frontend/src/ts/test/custom-text.ts +++ b/frontend/src/ts/test/custom-text.ts @@ -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"; diff --git a/frontend/src/ts/test/result.ts b/frontend/src/ts/test/result.ts index 6c88bad96..66cb7aaa7 100644 --- a/frontend/src/ts/test/result.ts +++ b/frontend/src/ts/test/result.ts @@ -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; +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, + res: CompletedEvent, difficultyFailed: boolean, failReason: string, afkDetected: boolean, diff --git a/frontend/src/ts/test/test-logic.ts b/frontend/src/ts/test/test-logic.ts index a53a14b33..4d238d9c4 100644 --- a/frontend/src/ts/test/test-logic.ts +++ b/frontend/src/ts/test/test-logic.ts @@ -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 { await saveResult(completedEvent, true); } -function buildCompletedEvent(difficultyFailed: boolean): CompletedEvent { +function buildCompletedEvent( + difficultyFailed: boolean +): Omit { //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 = { 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 { $("#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 { } // 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 = 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: "" }) ); } diff --git a/frontend/src/ts/test/test-stats.ts b/frontend/src/ts/test/test-stats.ts index 13abc5ed2..afcede7a0 100644 --- a/frontend/src/ts/test/test-stats.ts +++ b/frontend/src/ts/test/test-stats.ts @@ -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; +export let lastResult: Omit; -export function setLastResult(result: Result): void { +export function setLastResult(result: CompletedEvent): void { lastResult = result; } diff --git a/frontend/src/ts/types/types.d.ts b/frontend/src/ts/types/types.d.ts index b5c0722d3..b6e7dd6a6 100644 --- a/frontend/src/ts/types/types.d.ts +++ b/frontend/src/ts/types/types.d.ts @@ -1,4 +1,8 @@ -type ConfigValue = import("@monkeytype/contracts/schemas/configs").ConfigValue; +type Mode = import("@monkeytype/contracts/schemas/shared").Mode; +type Result = + import("@monkeytype/contracts/schemas/results").Result; +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[]; 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 = Omit< + Result, + | "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; + }; } diff --git a/frontend/src/ts/utils/misc.ts b/frontend/src/ts/utils/misc.ts index f27a6605e..4ce33b1fd 100644 --- a/frontend/src/ts/utils/misc.ts +++ b/frontend/src/ts/utils/misc.ts @@ -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( return retVal as Mode2; } -export async function downloadResultsCSV(array: Result[]): Promise { +export async function downloadResultsCSV( + array: MonkeyTypes.FullResult[] +): Promise { Loader.show(); const csvString = [ [ @@ -435,7 +437,7 @@ export async function downloadResultsCSV(array: Result[]): Promise { "tags", "timestamp", ], - ...array.map((item: Result) => [ + ...array.map((item: MonkeyTypes.FullResult) => [ item._id, item.isPb, item.wpm, diff --git a/frontend/src/ts/utils/results.ts b/frontend/src/ts/utils/results.ts new file mode 100644 index 000000000..e389eef9a --- /dev/null +++ b/frontend/src/ts/utils/results.ts @@ -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 { + 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 = 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 + ); +} diff --git a/monkeytype.code-workspace b/monkeytype.code-workspace index 708f36f9f..e892f740c 100644 --- a/monkeytype.code-workspace +++ b/monkeytype.code-workspace @@ -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": { diff --git a/packages/contracts/package.json b/packages/contracts/package.json index d7d54d5e7..7c9efed10 100644 --- a/packages/contracts/package.json +++ b/packages/contracts/package.json @@ -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": { diff --git a/packages/contracts/src/index.ts b/packages/contracts/src/index.ts index 0f7469639..398bd663b 100644 --- a/packages/contracts/src/index.ts +++ b/packages/contracts/src/index.ts @@ -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, }); diff --git a/packages/contracts/src/results.ts b/packages/contracts/src/results.ts new file mode 100644 index 000000000..d99664803 --- /dev/null +++ b/packages/contracts/src/results.ts @@ -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; + +export const GetResultsResponseSchema = responseWithData(z.array(ResultSchema)); +export type GetResultsResponse = z.infer; + +export const AddResultRequestSchema = z.object({ + result: CompletedEventSchema, +}); +export type AddResultRequest = z.infer; + +export const AddResultResponseSchema = responseWithData( + PostResultResponseSchema +); +export type AddResultResponse = z.infer; + +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; + +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, + } +); diff --git a/packages/contracts/src/schemas/api.ts b/packages/contracts/src/schemas/api.ts index 4795f6828..a7385ada4 100644 --- a/packages/contracts/src/schemas/api.ts +++ b/packages/contracts/src/schemas/api.ts @@ -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. */ diff --git a/packages/contracts/src/schemas/results.ts b/packages/contracts/src/schemas/results.ts new file mode 100644 index 000000000..c784f5da9 --- /dev/null +++ b/packages/contracts/src/schemas/results.ts @@ -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; + +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; + +export const KeyStatsSchema = z.object({ + average: z.number().nonnegative(), + sd: z.number().nonnegative(), +}); +export type KeyStats = z.infer; + +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; + +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; + +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 = Omit< + z.infer, + "mode" | "mode2" +> & { + mode: M; + mode2: Mode2; +}; + +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; + +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; + +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; diff --git a/packages/contracts/src/schemas/util.ts b/packages/contracts/src/schemas/util.ts index be640d318..169a7ec33 100644 --- a/packages/contracts/src/schemas/util.ts +++ b/packages/contracts/src/schemas/util.ts @@ -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; + +export const PercentageSchema = z.number().nonnegative().max(100); +export type Percentage = z.infer; + +export const WpmSchema = z.number().nonnegative().max(420); +export type Wpm = z.infer; + +export const CustomTextModeSchema = z.enum(["repeat", "random", "shuffle"]); +export type CustomTextMode = z.infer; + +export const CustomTextLimitModeSchema = z.enum(["word", "time", "section"]); +export type CustomTextLimitMode = z.infer; diff --git a/packages/shared-types/src/index.ts b/packages/shared-types/src/index.ts index d10093d20..2b9edfde6 100644 --- a/packages/shared-types/src/index.ts +++ b/packages/shared-types/src/index.ts @@ -1,7 +1,3 @@ -type Difficulty = import("@monkeytype/contracts/schemas/configs").Difficulty; -type Mode = import("@monkeytype/contracts/schemas/shared").Mode; -type Mode2 = - import("@monkeytype/contracts/schemas/shared").Mode2; 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 = { - _id: string; - wpm: number; - rawWpm: number; - charStats: [number, number, number, number]; - acc: number; - mode: M; - mode2: Mode2; - 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 = Omit< - Result, - | "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 & { - 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 & { - textLen: number; -}; - -export type PostResultResponse = { - isPb: boolean; - tagPbs: string[]; - insertedId: string; - dailyLeaderboardRank?: number; - weeklyXpLeaderboardRank?: number; - xp: number; - dailyXpBonus: boolean; - xpBreakdown: Record; - streak: number; }; export type UserStreak = { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 1517daabc..8200654fd 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -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