mirror of
				https://github.com/monkeytypegame/monkeytype.git
				synced 2025-10-29 18:27:33 +08:00 
			
		
		
		
	
							parent
							
								
									abdd66a27b
								
							
						
					
					
						commit
						e03a25fb92
					
				
					 51 changed files with 1453 additions and 1214 deletions
				
			
		|  | @ -1,11 +1,14 @@ | |||
| import request from "supertest"; | ||||
| import app from "../../../src/app"; | ||||
| import _ from "lodash"; | ||||
| import _, { omit } from "lodash"; | ||||
| import * as Configuration from "../../../src/init/configuration"; | ||||
| import * as ResultDal from "../../../src/dal/result"; | ||||
| import * as UserDal from "../../../src/dal/user"; | ||||
| import * as LogsDal from "../../../src/dal/logs"; | ||||
| import * as AuthUtils from "../../../src/utils/auth"; | ||||
| import { DecodedIdToken } from "firebase-admin/lib/auth/token-verifier"; | ||||
| import { ObjectId } from "mongodb"; | ||||
| import { mockAuthenticateWithApeKey } from "../../__testData__/auth"; | ||||
| const uid = "123456"; | ||||
| 
 | ||||
| const mockDecodedToken: DecodedIdToken = { | ||||
|  | @ -14,30 +17,69 @@ const mockDecodedToken: DecodedIdToken = { | |||
|   iat: 0, | ||||
| } as DecodedIdToken; | ||||
| 
 | ||||
| vi.spyOn(AuthUtils, "verifyIdToken").mockResolvedValue(mockDecodedToken); | ||||
| 
 | ||||
| const resultMock = vi.spyOn(ResultDal, "getResults"); | ||||
| 
 | ||||
| const mockApp = request(app); | ||||
| 
 | ||||
| const configuration = Configuration.getCachedConfiguration(); | ||||
| 
 | ||||
| describe("result controller test", () => { | ||||
|   const verifyIdTokenMock = vi.spyOn(AuthUtils, "verifyIdToken"); | ||||
| 
 | ||||
|   beforeEach(() => { | ||||
|     verifyIdTokenMock.mockReset(); | ||||
|     verifyIdTokenMock.mockResolvedValue(mockDecodedToken); | ||||
|   }); | ||||
| 
 | ||||
|   describe("getResults", () => { | ||||
|     const resultMock = vi.spyOn(ResultDal, "getResults"); | ||||
| 
 | ||||
|     beforeEach(async () => { | ||||
|       resultMock.mockResolvedValue([]); | ||||
|       await enablePremiumFeatures(true); | ||||
|       vi.spyOn(UserDal, "checkIfUserIsPremium").mockResolvedValue(false); | ||||
|     }); | ||||
| 
 | ||||
|     afterEach(() => { | ||||
|       resultMock.mockReset(); | ||||
|     }); | ||||
|     it("should get latest 1000 results for regular user", async () => { | ||||
| 
 | ||||
|     it("should get results", async () => { | ||||
|       //GIVEN
 | ||||
|       vi.spyOn(UserDal, "checkIfUserIsPremium").mockResolvedValue(false); | ||||
|       const resultOne = givenDbResult(uid); | ||||
|       const resultTwo = givenDbResult(uid); | ||||
|       resultMock.mockResolvedValue([resultOne, resultTwo]); | ||||
| 
 | ||||
|       //WHEN
 | ||||
|       const { body } = await mockApp | ||||
|         .get("/results") | ||||
|         .set("Authorization", `Bearer ${uid}`) | ||||
|         .send() | ||||
|         .expect(200); | ||||
| 
 | ||||
|       //THEN
 | ||||
| 
 | ||||
|       expect(body.message).toEqual("Results retrieved"); | ||||
|       expect(body.data).toEqual([ | ||||
|         { ...resultOne, _id: resultOne._id.toHexString() }, | ||||
|         { ...resultTwo, _id: resultTwo._id.toHexString() }, | ||||
|       ]); | ||||
|     }); | ||||
|     it("should get results with ape key", async () => { | ||||
|       //GIVEN
 | ||||
|       await acceptApeKeys(true); | ||||
|       const apeKey = await mockAuthenticateWithApeKey(uid, await configuration); | ||||
| 
 | ||||
|       //WHEN
 | ||||
|       await mockApp | ||||
|         .get("/results") | ||||
|         .set("Authorization", "Bearer 123456789") | ||||
|         .set("Authorization", `ApeKey ${apeKey}`) | ||||
|         .send() | ||||
|         .expect(200); | ||||
|     }); | ||||
|     it("should get latest 1000 results for regular user", async () => { | ||||
|       //WHEN
 | ||||
|       await mockApp | ||||
|         .get("/results") | ||||
|         .set("Authorization", `Bearer ${uid}`) | ||||
|         .send() | ||||
|         .expect(200); | ||||
| 
 | ||||
|  | @ -51,12 +93,11 @@ describe("result controller test", () => { | |||
|     it("should get results filter by onOrAfterTimestamp", async () => { | ||||
|       //GIVEN
 | ||||
|       const now = Date.now(); | ||||
|       vi.spyOn(UserDal, "checkIfUserIsPremium").mockResolvedValue(false); | ||||
|       //WHEN
 | ||||
|       await mockApp | ||||
|         .get("/results") | ||||
|         .query({ onOrAfterTimestamp: now }) | ||||
|         .set("Authorization", "Bearer 123456789") | ||||
|         .set("Authorization", `Bearer ${uid}`) | ||||
|         .send() | ||||
|         .expect(200); | ||||
| 
 | ||||
|  | @ -69,14 +110,11 @@ describe("result controller test", () => { | |||
|       }); | ||||
|     }); | ||||
|     it("should get with limit and offset", async () => { | ||||
|       //GIVEN
 | ||||
|       vi.spyOn(UserDal, "checkIfUserIsPremium").mockResolvedValue(false); | ||||
| 
 | ||||
|       //WHEN
 | ||||
|       await mockApp | ||||
|         .get("/results") | ||||
|         .query({ limit: 250, offset: 500 }) | ||||
|         .set("Authorization", "Bearer 123456789") | ||||
|         .set("Authorization", `Bearer ${uid}`) | ||||
|         .send() | ||||
|         .expect(200); | ||||
| 
 | ||||
|  | @ -88,27 +126,20 @@ describe("result controller test", () => { | |||
|       }); | ||||
|     }); | ||||
|     it("should fail exceeding max limit for regular user", async () => { | ||||
|       //GIVEN
 | ||||
|       vi.spyOn(UserDal, "checkIfUserIsPremium").mockResolvedValue(false); | ||||
| 
 | ||||
|       //WHEN
 | ||||
|       await mockApp | ||||
|       const { body } = await mockApp | ||||
|         .get("/results") | ||||
|         .query({ limit: 100, offset: 1000 }) | ||||
|         .set("Authorization", "Bearer 123456789") | ||||
|         .set("Authorization", `Bearer ${uid}`) | ||||
|         .send() | ||||
|         .expect(422) | ||||
|         .expect( | ||||
|           expectErrorMessage( | ||||
|             `Max results limit of ${ | ||||
|               ( | ||||
|                 await configuration | ||||
|               ).results.limits.regularUser | ||||
|             } exceeded.` | ||||
|           ) | ||||
|         ); | ||||
|         .expect(422); | ||||
| 
 | ||||
|       //THEN
 | ||||
|       expect(body.message).toEqual( | ||||
|         `Max results limit of ${ | ||||
|           (await configuration).results.limits.regularUser | ||||
|         } exceeded.` | ||||
|       ); | ||||
|     }); | ||||
|     it("should get with higher max limit for premium user", async () => { | ||||
|       //GIVEN
 | ||||
|  | @ -118,7 +149,7 @@ describe("result controller test", () => { | |||
|       await mockApp | ||||
|         .get("/results") | ||||
|         .query({ limit: 800, offset: 600 }) | ||||
|         .set("Authorization", "Bearer 123456789") | ||||
|         .set("Authorization", `Bearer ${uid}`) | ||||
|         .send() | ||||
|         .expect(200); | ||||
| 
 | ||||
|  | @ -131,14 +162,11 @@ describe("result controller test", () => { | |||
|       }); | ||||
|     }); | ||||
|     it("should get results if offset/limit is partly outside the max limit", async () => { | ||||
|       //GIVEN
 | ||||
|       vi.spyOn(UserDal, "checkIfUserIsPremium").mockResolvedValue(false); | ||||
| 
 | ||||
|       //WHEN
 | ||||
|       await mockApp | ||||
|         .get("/results") | ||||
|         .query({ limit: 20, offset: 990 }) | ||||
|         .set("Authorization", "Bearer 123456789") | ||||
|         .set("Authorization", `Bearer ${uid}`) | ||||
|         .send() | ||||
|         .expect(200); | ||||
| 
 | ||||
|  | @ -155,42 +183,36 @@ describe("result controller test", () => { | |||
|       vi.spyOn(UserDal, "checkIfUserIsPremium").mockResolvedValue(false); | ||||
| 
 | ||||
|       //WHEN
 | ||||
|       await mockApp | ||||
|       const { body } = await mockApp | ||||
|         .get("/results") | ||||
|         .query({ limit: 2000 }) | ||||
|         .set("Authorization", "Bearer 123456789") | ||||
|         .set("Authorization", `Bearer ${uid}`) | ||||
|         .send() | ||||
|         .expect(422) | ||||
|         .expect( | ||||
|           expectErrorMessage( | ||||
|             '"limit" must be less than or equal to 1000 (2000)' | ||||
|           ) | ||||
|         ); | ||||
|         .expect(422); | ||||
| 
 | ||||
|       //THEN
 | ||||
|       expect(body).toEqual({ | ||||
|         message: "Invalid query schema", | ||||
|         validationErrors: ['"limit" Number must be less than or equal to 1000'], | ||||
|       }); | ||||
|     }); | ||||
|     it("should fail exceeding maxlimit for premium user", async () => { | ||||
|       //GIVEN
 | ||||
|       vi.spyOn(UserDal, "checkIfUserIsPremium").mockResolvedValue(true); | ||||
| 
 | ||||
|       //WHEN
 | ||||
|       await mockApp | ||||
|       const { body } = await mockApp | ||||
|         .get("/results") | ||||
|         .query({ limit: 1000, offset: 25000 }) | ||||
|         .set("Authorization", "Bearer 123456789") | ||||
|         .set("Authorization", `Bearer ${uid}`) | ||||
|         .send() | ||||
|         .expect(422) | ||||
|         .expect( | ||||
|           expectErrorMessage( | ||||
|             `Max results limit of ${ | ||||
|               ( | ||||
|                 await configuration | ||||
|               ).results.limits.premiumUser | ||||
|             } exceeded.` | ||||
|           ) | ||||
|         ); | ||||
| 
 | ||||
|         .expect(422); | ||||
|       //THEN
 | ||||
|       expect(body.message).toEqual( | ||||
|         `Max results limit of ${ | ||||
|           (await configuration).results.limits.premiumUser | ||||
|         } exceeded.` | ||||
|       ); | ||||
|     }); | ||||
|     it("should get results within regular limits for premium users even if premium is globally disabled", async () => { | ||||
|       //GIVEN
 | ||||
|  | @ -201,7 +223,7 @@ describe("result controller test", () => { | |||
|       await mockApp | ||||
|         .get("/results") | ||||
|         .query({ limit: 100, offset: 900 }) | ||||
|         .set("Authorization", "Bearer 123456789") | ||||
|         .set("Authorization", `Bearer ${uid}`) | ||||
|         .send() | ||||
|         .expect(200); | ||||
| 
 | ||||
|  | @ -218,15 +240,15 @@ describe("result controller test", () => { | |||
|       enablePremiumFeatures(false); | ||||
| 
 | ||||
|       //WHEN
 | ||||
|       await mockApp | ||||
|       const { body } = await mockApp | ||||
|         .get("/results") | ||||
|         .query({ limit: 200, offset: 900 }) | ||||
|         .set("Authorization", "Bearer 123456789") | ||||
|         .set("Authorization", `Bearer ${uid}`) | ||||
|         .send() | ||||
|         .expect(503) | ||||
|         .expect(expectErrorMessage("Premium feature disabled.")); | ||||
|         .expect(503); | ||||
| 
 | ||||
|       //THEN
 | ||||
|       expect(body.message).toEqual("Premium feature disabled."); | ||||
|     }); | ||||
|     it("should get results with regular limit as default for premium users if premium is globally disabled", async () => { | ||||
|       //GIVEN
 | ||||
|  | @ -236,7 +258,7 @@ describe("result controller test", () => { | |||
|       //WHEN
 | ||||
|       await mockApp | ||||
|         .get("/results") | ||||
|         .set("Authorization", "Bearer 123456789") | ||||
|         .set("Authorization", `Bearer ${uid}`) | ||||
|         .send() | ||||
|         .expect(200); | ||||
| 
 | ||||
|  | @ -247,13 +269,525 @@ describe("result controller test", () => { | |||
|         onOrAfterTimestamp: NaN, | ||||
|       }); | ||||
|     }); | ||||
|     it("should fail with unknown query parameters", async () => { | ||||
|       //WHEN
 | ||||
|       const { body } = await mockApp | ||||
|         .get("/results") | ||||
|         .query({ extra: "value" }) | ||||
|         .set("Authorization", `Bearer ${uid}`) | ||||
|         .send() | ||||
|         .expect(422); | ||||
| 
 | ||||
|       //THEN
 | ||||
|       expect(body).toEqual({ | ||||
|         message: "Invalid query schema", | ||||
|         validationErrors: ["Unrecognized key(s) in object: 'extra'"], | ||||
|       }); | ||||
|     }); | ||||
|     it("should get results with legacy values", async () => { | ||||
|       //GIVEN
 | ||||
|       const resultOne = givenDbResult(uid, { | ||||
|         charStats: undefined, | ||||
|         incorrectChars: 5, | ||||
|         correctChars: 12, | ||||
|       }); | ||||
|       const resultTwo = givenDbResult(uid, { | ||||
|         charStats: undefined, | ||||
|         incorrectChars: 7, | ||||
|         correctChars: 15, | ||||
|       }); | ||||
|       resultMock.mockResolvedValue([resultOne, resultTwo]); | ||||
| 
 | ||||
|       //WHEN
 | ||||
|       const { body } = await mockApp | ||||
|         .get("/results") | ||||
|         .set("Authorization", `Bearer ${uid}`) | ||||
|         .send() | ||||
|         .expect(200); | ||||
| 
 | ||||
|       //THEN
 | ||||
| 
 | ||||
|       expect(body.message).toEqual("Results retrieved"); | ||||
|       expect(body.data[0]).toMatchObject({ | ||||
|         _id: resultOne._id.toHexString(), | ||||
|         charStats: [12, 5, 0, 0], | ||||
|       }); | ||||
|       expect(body.data[0]).not.toHaveProperty("correctChars"); | ||||
|       expect(body.data[0]).not.toHaveProperty("incorrectChars"); | ||||
| 
 | ||||
|       expect(body.data[1]).toMatchObject({ | ||||
|         _id: resultTwo._id.toHexString(), | ||||
|         charStats: [15, 7, 0, 0], | ||||
|       }); | ||||
|       expect(body.data[1]).not.toHaveProperty("correctChars"); | ||||
|       expect(body.data[1]).not.toHaveProperty("incorrectChars"); | ||||
|     }); | ||||
|   }); | ||||
|   describe("getLastResult", () => { | ||||
|     const getLastResultMock = vi.spyOn(ResultDal, "getLastResult"); | ||||
| 
 | ||||
|     afterEach(() => { | ||||
|       getLastResultMock.mockReset(); | ||||
|     }); | ||||
| 
 | ||||
|     it("should get last result", async () => { | ||||
|       //GIVEN
 | ||||
|       const result = givenDbResult(uid); | ||||
|       getLastResultMock.mockResolvedValue(result); | ||||
| 
 | ||||
|       //WHEN
 | ||||
|       const { body } = await mockApp | ||||
|         .get("/results/last") | ||||
|         .set("Authorization", `Bearer ${uid}`) | ||||
|         .send() | ||||
|         .expect(200); | ||||
| 
 | ||||
|       //THEN
 | ||||
|       expect(body.message).toEqual("Result retrieved"); | ||||
|       expect(body.data).toEqual({ ...result, _id: result._id.toHexString() }); | ||||
|     }); | ||||
|     it("should get last result with ape key", async () => { | ||||
|       //GIVEN
 | ||||
|       await acceptApeKeys(true); | ||||
|       const apeKey = await mockAuthenticateWithApeKey(uid, await configuration); | ||||
|       const result = givenDbResult(uid); | ||||
|       getLastResultMock.mockResolvedValue(result); | ||||
| 
 | ||||
|       //WHEN
 | ||||
|       await mockApp | ||||
|         .get("/results/last") | ||||
|         .set("Authorization", `ApeKey ${apeKey}`) | ||||
|         .send() | ||||
|         .expect(200); | ||||
|     }); | ||||
|     it("should get last result with legacy values", async () => { | ||||
|       //GIVEN
 | ||||
|       const result = givenDbResult(uid, { | ||||
|         charStats: undefined, | ||||
|         incorrectChars: 5, | ||||
|         correctChars: 12, | ||||
|       }); | ||||
|       getLastResultMock.mockResolvedValue(result); | ||||
| 
 | ||||
|       //WHEN
 | ||||
|       const { body } = await mockApp | ||||
|         .get("/results/last") | ||||
|         .set("Authorization", `Bearer ${uid}`) | ||||
|         .send() | ||||
|         .expect(200); | ||||
| 
 | ||||
|       //THEN
 | ||||
|       expect(body.message).toEqual("Result retrieved"); | ||||
|       expect(body.data).toMatchObject({ | ||||
|         _id: result._id.toHexString(), | ||||
|         charStats: [12, 5, 0, 0], | ||||
|       }); | ||||
|       expect(body.data).not.toHaveProperty("correctChars"); | ||||
|       expect(body.data).not.toHaveProperty("incorrectChars"); | ||||
|     }); | ||||
|   }); | ||||
|   describe("deleteAll", () => { | ||||
|     const deleteAllMock = vi.spyOn(ResultDal, "deleteAll"); | ||||
|     const logToDbMock = vi.spyOn(LogsDal, "addLog"); | ||||
|     afterEach(() => { | ||||
|       deleteAllMock.mockReset(); | ||||
|       logToDbMock.mockReset(); | ||||
|     }); | ||||
| 
 | ||||
|     it("should delete", async () => { | ||||
|       //GIVEN
 | ||||
|       verifyIdTokenMock.mockResolvedValue({ | ||||
|         ...mockDecodedToken, | ||||
|         iat: Date.now() - 1000, | ||||
|       }); | ||||
|       //WHEN
 | ||||
|       const { body } = await mockApp | ||||
|         .delete("/results") | ||||
|         .set("Authorization", `Bearer ${uid}`) | ||||
|         .send() | ||||
|         .expect(200); | ||||
| 
 | ||||
|       //THEN
 | ||||
|       expect(body.message).toEqual("All results deleted"); | ||||
|       expect(body.data).toBeNull(); | ||||
| 
 | ||||
|       expect(deleteAllMock).toHaveBeenCalledWith(uid); | ||||
|       expect(logToDbMock).toHaveBeenCalledWith("user_results_deleted", "", uid); | ||||
|     }); | ||||
|     it("should fail to delete with non-fresh token", async () => { | ||||
|       await mockApp | ||||
|         .delete("/results") | ||||
|         .set("Authorization", `Bearer ${uid}`) | ||||
|         .send() | ||||
|         .expect(401); | ||||
|     }); | ||||
|   }); | ||||
|   describe("updateTags", () => { | ||||
|     const getResultMock = vi.spyOn(ResultDal, "getResult"); | ||||
|     const updateTagsMock = vi.spyOn(ResultDal, "updateTags"); | ||||
|     const getUserPartialMock = vi.spyOn(UserDal, "getPartialUser"); | ||||
|     const checkIfTagPbMock = vi.spyOn(UserDal, "checkIfTagPb"); | ||||
| 
 | ||||
|     afterEach(() => { | ||||
|       [ | ||||
|         getResultMock, | ||||
|         updateTagsMock, | ||||
|         getUserPartialMock, | ||||
|         checkIfTagPbMock, | ||||
|       ].forEach((it) => it.mockReset()); | ||||
|     }); | ||||
| 
 | ||||
|     it("should update tags", async () => { | ||||
|       //GIVEN
 | ||||
|       const result = givenDbResult(uid); | ||||
|       const resultIdString = result._id.toHexString(); | ||||
|       const tagIds = [ | ||||
|         new ObjectId().toHexString(), | ||||
|         new ObjectId().toHexString(), | ||||
|       ]; | ||||
|       const partialUser = { tags: [] }; | ||||
|       getResultMock.mockResolvedValue(result); | ||||
|       updateTagsMock.mockResolvedValue({} as any); | ||||
|       getUserPartialMock.mockResolvedValue(partialUser as any); | ||||
|       checkIfTagPbMock.mockResolvedValue([]); | ||||
| 
 | ||||
|       //WHEN
 | ||||
|       const { body } = await mockApp | ||||
|         .patch("/results/tags") | ||||
|         .set("Authorization", `Bearer ${uid}`) | ||||
|         .send({ resultId: resultIdString, tagIds }) | ||||
|         .expect(200); | ||||
| 
 | ||||
|       //THEN
 | ||||
|       expect(body.message).toEqual("Result tags updated"); | ||||
|       expect(body.data).toEqual({ | ||||
|         tagPbs: [], | ||||
|       }); | ||||
| 
 | ||||
|       expect(updateTagsMock).toHaveBeenCalledWith(uid, resultIdString, tagIds); | ||||
|       expect(getResultMock).toHaveBeenCalledWith(uid, resultIdString); | ||||
|       expect(getUserPartialMock).toHaveBeenCalledWith(uid, "update tags", [ | ||||
|         "tags", | ||||
|       ]); | ||||
|       expect(checkIfTagPbMock).toHaveBeenCalledWith(uid, partialUser, result); | ||||
|     }); | ||||
|     it("should apply defaults on missing data", async () => { | ||||
|       //GIVEN
 | ||||
|       const result = givenDbResult(uid); | ||||
|       const partialResult = omit( | ||||
|         result, | ||||
|         "difficulty", | ||||
|         "language", | ||||
|         "funbox", | ||||
|         "lazyMode", | ||||
|         "punctuation", | ||||
|         "numbers" | ||||
|       ); | ||||
| 
 | ||||
|       const resultIdString = result._id.toHexString(); | ||||
|       const tagIds = [ | ||||
|         new ObjectId().toHexString(), | ||||
|         new ObjectId().toHexString(), | ||||
|       ]; | ||||
|       const partialUser = { tags: [] }; | ||||
|       getResultMock.mockResolvedValue(partialResult); | ||||
|       updateTagsMock.mockResolvedValue({} as any); | ||||
|       getUserPartialMock.mockResolvedValue(partialUser as any); | ||||
|       checkIfTagPbMock.mockResolvedValue([]); | ||||
| 
 | ||||
|       //WHEN
 | ||||
|       const { body } = await mockApp | ||||
|         .patch("/results/tags") | ||||
|         .set("Authorization", `Bearer ${uid}`) | ||||
|         .send({ resultId: resultIdString, tagIds }) | ||||
|         .expect(200); | ||||
| 
 | ||||
|       //THEN
 | ||||
|       expect(body.message).toEqual("Result tags updated"); | ||||
|       expect(body.data).toEqual({ | ||||
|         tagPbs: [], | ||||
|       }); | ||||
| 
 | ||||
|       expect(updateTagsMock).toHaveBeenCalledWith(uid, resultIdString, tagIds); | ||||
|       expect(getResultMock).toHaveBeenCalledWith(uid, resultIdString); | ||||
|       expect(getUserPartialMock).toHaveBeenCalledWith(uid, "update tags", [ | ||||
|         "tags", | ||||
|       ]); | ||||
|       expect(checkIfTagPbMock).toHaveBeenCalledWith(uid, partialUser, { | ||||
|         ...result, | ||||
|         difficulty: "normal", | ||||
|         language: "english", | ||||
|         funbox: "none", | ||||
|         lazyMode: false, | ||||
|         punctuation: false, | ||||
|         numbers: false, | ||||
|       }); | ||||
|     }); | ||||
|     it("should fail with missing mandatory properties", async () => { | ||||
|       //GIVEN
 | ||||
| 
 | ||||
|       //WHEN
 | ||||
|       const { body } = await mockApp | ||||
|         .patch("/results/tags") | ||||
|         .set("Authorization", `Bearer ${uid}`) | ||||
|         .send({}) | ||||
|         .expect(422); | ||||
| 
 | ||||
|       //THEN
 | ||||
|       expect(body).toEqual({ | ||||
|         message: "Invalid request data schema", | ||||
|         validationErrors: ['"tagIds" Required', '"resultId" Required'], | ||||
|       }); | ||||
|     }); | ||||
|     it("should fail with unknown properties", async () => { | ||||
|       //GIVEN
 | ||||
| 
 | ||||
|       //WHEN
 | ||||
|       const { body } = await mockApp | ||||
|         .patch("/results/tags") | ||||
|         .set("Authorization", `Bearer ${uid}`) | ||||
|         .send({ extra: "value" }) | ||||
|         .expect(422); | ||||
| 
 | ||||
|       //THEN
 | ||||
|       expect(body).toEqual({ | ||||
|         message: "Invalid request data schema", | ||||
|         validationErrors: [ | ||||
|           '"tagIds" Required', | ||||
|           '"resultId" Required', | ||||
|           "Unrecognized key(s) in object: 'extra'", | ||||
|         ], | ||||
|       }); | ||||
|     }); | ||||
|   }); | ||||
|   describe("addResult", () => { | ||||
|     //TODO improve test coverage for addResult
 | ||||
|     const insertedId = new ObjectId(); | ||||
|     const getUserMock = vi.spyOn(UserDal, "getUser"); | ||||
|     const updateStreakMock = vi.spyOn(UserDal, "updateStreak"); | ||||
|     const checkIfTagPbMock = vi.spyOn(UserDal, "checkIfTagPb"); | ||||
|     const addResultMock = vi.spyOn(ResultDal, "addResult"); | ||||
| 
 | ||||
|     beforeEach(async () => { | ||||
|       await enableResultsSaving(true); | ||||
| 
 | ||||
|       [getUserMock, updateStreakMock, checkIfTagPbMock, addResultMock].forEach( | ||||
|         (it) => it.mockReset() | ||||
|       ); | ||||
| 
 | ||||
|       getUserMock.mockResolvedValue({ name: "bob" } as any); | ||||
|       updateStreakMock.mockResolvedValue(0); | ||||
|       checkIfTagPbMock.mockResolvedValue([]); | ||||
|       addResultMock.mockResolvedValue({ insertedId }); | ||||
|     }); | ||||
| 
 | ||||
|     it("should add result", async () => { | ||||
|       //GIVEN
 | ||||
| 
 | ||||
|       //WHEN
 | ||||
|       const { body } = await mockApp | ||||
|         .post("/results") | ||||
|         .set("Authorization", `Bearer ${uid}`) | ||||
|         .send({ | ||||
|           result: { | ||||
|             acc: 86, | ||||
|             afkDuration: 5, | ||||
|             bailedOut: false, | ||||
|             blindMode: false, | ||||
|             charStats: [100, 2, 3, 5], | ||||
|             chartData: { wpm: [1, 2, 3], raw: [50, 55, 56], err: [0, 2, 0] }, | ||||
|             consistency: 23.5, | ||||
|             difficulty: "normal", | ||||
|             funbox: "none", | ||||
|             hash: "hash", | ||||
|             incompleteTestSeconds: 2, | ||||
|             incompleteTests: [{ acc: 75, seconds: 10 }], | ||||
|             keyConsistency: 12, | ||||
|             keyDuration: [0, 3, 5], | ||||
|             keySpacing: [0, 2, 4], | ||||
|             language: "english", | ||||
|             lazyMode: false, | ||||
|             mode: "time", | ||||
|             mode2: "15", | ||||
|             numbers: false, | ||||
|             punctuation: false, | ||||
|             rawWpm: 99, | ||||
|             restartCount: 4, | ||||
|             tags: ["tagOneId", "tagTwoId"], | ||||
|             testDuration: 15.1, | ||||
|             timestamp: 1000, | ||||
|             uid, | ||||
|             wpmConsistency: 55, | ||||
|             wpm: 80, | ||||
|             stopOnLetter: false, | ||||
|             //new required
 | ||||
|             charTotal: 5, | ||||
|             keyOverlap: 7, | ||||
|             lastKeyToEnd: 9, | ||||
|             startToFirstKey: 11, | ||||
|           }, | ||||
|         }) | ||||
|         .expect(200); | ||||
| 
 | ||||
|       expect(body.message).toEqual("Result saved"); | ||||
|       expect(body.data).toEqual({ | ||||
|         isPb: true, | ||||
|         tagPbs: [], | ||||
|         xp: 0, | ||||
|         dailyXpBonus: false, | ||||
|         xpBreakdown: {}, | ||||
|         streak: 0, | ||||
|         insertedId: insertedId.toHexString(), | ||||
|       }); | ||||
| 
 | ||||
|       expect(addResultMock).toHaveBeenCalledWith( | ||||
|         uid, | ||||
|         expect.objectContaining({ | ||||
|           acc: 86, | ||||
|           afkDuration: 5, | ||||
|           charStats: [100, 2, 3, 5], | ||||
|           chartData: { | ||||
|             err: [0, 2, 0], | ||||
|             raw: [50, 55, 56], | ||||
|             wpm: [1, 2, 3], | ||||
|           }, | ||||
|           consistency: 23.5, | ||||
|           incompleteTestSeconds: 2, | ||||
|           isPb: true, | ||||
|           keyConsistency: 12, | ||||
|           keyDurationStats: { | ||||
|             average: 2.67, | ||||
|             sd: 2.05, | ||||
|           }, | ||||
|           keySpacingStats: { | ||||
|             average: 2, | ||||
|             sd: 1.63, | ||||
|           }, | ||||
|           mode: "time", | ||||
|           mode2: "15", | ||||
|           name: "bob", | ||||
|           rawWpm: 99, | ||||
|           restartCount: 4, | ||||
|           tags: ["tagOneId", "tagTwoId"], | ||||
|           testDuration: 15.1, | ||||
|           uid: "123456", | ||||
|           wpm: 80, | ||||
|         }) | ||||
|       ); | ||||
|     }); | ||||
|     it("should fail if result saving is disabled", async () => { | ||||
|       //GIVEN
 | ||||
|       await enableResultsSaving(false); | ||||
| 
 | ||||
|       //WHEN
 | ||||
|       const { body } = await mockApp | ||||
|         .post("/results") | ||||
|         .set("Authorization", `Bearer ${uid}`) | ||||
|         .send({}) | ||||
|         .expect(503); | ||||
| 
 | ||||
|       //THEN
 | ||||
|       expect(body.message).toEqual("Results are not being saved at this time."); | ||||
|     }); | ||||
|     it("should fail without mandatory properties", async () => { | ||||
|       //GIVEN
 | ||||
| 
 | ||||
|       //WHEN
 | ||||
|       const { body } = await mockApp | ||||
|         .post("/results") | ||||
|         .set("Authorization", `Bearer ${uid}`) | ||||
|         .send({}) | ||||
|         .expect(422); | ||||
| 
 | ||||
|       //THEN
 | ||||
|       expect(body).toEqual({ | ||||
|         message: "Invalid request data schema", | ||||
|         validationErrors: ['"result" Required'], | ||||
|       }); | ||||
|     }); | ||||
|     it("should fail with unknown properties", async () => { | ||||
|       //GIVEN
 | ||||
| 
 | ||||
|       //WHEN
 | ||||
|       const { body } = await mockApp | ||||
|         .post("/results") | ||||
|         .set("Authorization", `Bearer ${uid}`) | ||||
|         .send({ | ||||
|           result: { | ||||
|             acc: 86, | ||||
|             afkDuration: 5, | ||||
|             bailedOut: false, | ||||
|             blindMode: false, | ||||
|             charStats: [100, 2, 3, 5], | ||||
|             chartData: { wpm: [1, 2, 3], raw: [50, 55, 56], err: [0, 2, 0] }, | ||||
|             consistency: 23.5, | ||||
|             difficulty: "normal", | ||||
|             funbox: "none", | ||||
|             hash: "hash", | ||||
|             incompleteTestSeconds: 2, | ||||
|             incompleteTests: [{ acc: 75, seconds: 10 }], | ||||
|             keyConsistency: 12, | ||||
|             keyDuration: [0, 3, 5], | ||||
|             keySpacing: [0, 2, 4], | ||||
|             language: "english", | ||||
|             lazyMode: false, | ||||
|             mode: "time", | ||||
|             mode2: "15", | ||||
|             numbers: false, | ||||
|             punctuation: false, | ||||
|             rawWpm: 99, | ||||
|             restartCount: 4, | ||||
|             tags: ["tagOneId", "tagTwoId"], | ||||
|             testDuration: 15.1, | ||||
|             timestamp: 1000, | ||||
|             uid, | ||||
|             wpmConsistency: 55, | ||||
|             wpm: 80, | ||||
|             stopOnLetter: false, | ||||
|             //new required
 | ||||
|             charTotal: 5, | ||||
|             keyOverlap: 7, | ||||
|             lastKeyToEnd: 9, | ||||
|             startToFirstKey: 11, | ||||
|             extra2: "value", | ||||
|           }, | ||||
|           extra: "value", | ||||
|         }) | ||||
|         .expect(422); | ||||
| 
 | ||||
|       //THEN
 | ||||
|       expect(body).toEqual({ | ||||
|         message: "Invalid request data schema", | ||||
|         validationErrors: [ | ||||
|           `"result" Unrecognized key(s) in object: 'extra2'`, | ||||
|           "Unrecognized key(s) in object: 'extra'", | ||||
|         ], | ||||
|       }); | ||||
|     }); | ||||
| 
 | ||||
|     it("should fail invalid properties", async () => { | ||||
|       //GIVEN
 | ||||
| 
 | ||||
|       //WHEN
 | ||||
|       const { body } = await mockApp | ||||
|         .post("/results") | ||||
|         .set("Authorization", `Bearer ${uid}`) | ||||
|         //TODO add all properties
 | ||||
|         .send({ result: { acc: 25 } }) | ||||
|         .expect(422); | ||||
| 
 | ||||
|       //THEN
 | ||||
|       /* | ||||
|       expect(body).toEqual({ | ||||
|         message: "Invalid request data schema", | ||||
|         validationErrors: [ | ||||
|         ], | ||||
|       }); | ||||
|       */ | ||||
|     }); | ||||
|   }); | ||||
| }); | ||||
| 
 | ||||
| function expectErrorMessage(message: string): (res: request.Response) => void { | ||||
|   return (res) => expect(res.body).toHaveProperty("message", message); | ||||
| } | ||||
| 
 | ||||
| async function enablePremiumFeatures(premium: boolean): Promise<void> { | ||||
|   const mockConfig = _.merge(await configuration, { | ||||
|     users: { premium: { enabled: premium } }, | ||||
|  | @ -263,3 +797,57 @@ async function enablePremiumFeatures(premium: boolean): Promise<void> { | |||
|     mockConfig | ||||
|   ); | ||||
| } | ||||
| function givenDbResult( | ||||
|   uid: string, | ||||
|   customize?: Partial<MonkeyTypes.DBResult> | ||||
| ): MonkeyTypes.DBResult { | ||||
|   return { | ||||
|     _id: new ObjectId(), | ||||
|     wpm: Math.random() * 100, | ||||
|     rawWpm: Math.random() * 100, | ||||
|     charStats: [ | ||||
|       Math.round(Math.random() * 10), | ||||
|       Math.round(Math.random() * 10), | ||||
|       Math.round(Math.random() * 10), | ||||
|       Math.round(Math.random() * 10), | ||||
|     ], | ||||
|     acc: 80 + Math.random() * 20, //min accuracy is 75%
 | ||||
|     mode: "time", | ||||
|     mode2: "60", | ||||
|     timestamp: Math.round(Math.random() * 100), | ||||
|     testDuration: 1 + Math.random() * 100, | ||||
|     consistency: Math.random() * 100, | ||||
|     keyConsistency: Math.random() * 100, | ||||
|     uid, | ||||
|     keySpacingStats: { average: Math.random() * 100, sd: Math.random() }, | ||||
|     keyDurationStats: { average: Math.random() * 100, sd: Math.random() }, | ||||
|     isPb: true, | ||||
|     chartData: { | ||||
|       wpm: [Math.random() * 100], | ||||
|       raw: [Math.random() * 100], | ||||
|       err: [Math.random() * 100], | ||||
|     }, | ||||
|     name: "testName", | ||||
|     ...customize, | ||||
|   }; | ||||
| } | ||||
| 
 | ||||
| async function acceptApeKeys(enabled: boolean): Promise<void> { | ||||
|   const mockConfig = _.merge(await configuration, { | ||||
|     apeKeys: { acceptKeys: enabled }, | ||||
|   }); | ||||
| 
 | ||||
|   vi.spyOn(Configuration, "getCachedConfiguration").mockResolvedValue( | ||||
|     mockConfig | ||||
|   ); | ||||
| } | ||||
| 
 | ||||
| async function enableResultsSaving(enabled: boolean): Promise<void> { | ||||
|   const mockConfig = _.merge(await configuration, { | ||||
|     results: { savingEnabled: enabled }, | ||||
|   }); | ||||
| 
 | ||||
|   vi.spyOn(Configuration, "getCachedConfiguration").mockResolvedValue( | ||||
|     mockConfig | ||||
|   ); | ||||
| } | ||||
|  |  | |||
|  | @ -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", | ||||
|  |  | |||
|  | @ -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 | ||||
|  |  | |||
|  | @ -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", | ||||
|         }, | ||||
|       ], | ||||
|     }, | ||||
|  |  | |||
|  | @ -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; | ||||
| } | ||||
|  |  | |||
|  | @ -5,14 +5,14 @@ import { | |||
|   getStartOfDayTimestamp, | ||||
|   isDevEnvironment, | ||||
|   mapRange, | ||||
|   replaceObjectId, | ||||
|   roundTo2, | ||||
|   stdDev, | ||||
|   stringToNumberOrDefault, | ||||
| } from "../../utils/misc"; | ||||
| import objectHash from "object-hash"; | ||||
| import Logger from "../../utils/logger"; | ||||
| import "dotenv/config"; | ||||
| import { MonkeyResponse } from "../../utils/monkey-response"; | ||||
| import { MonkeyResponse2 } from "../../utils/monkey-response"; | ||||
| import MonkeyError from "../../utils/error"; | ||||
| import { areFunboxesCompatible, isTestTooShort } from "../../utils/validation"; | ||||
| import { | ||||
|  | @ -31,17 +31,30 @@ import AutoRoleList from "../../constants/auto-roles"; | |||
| import * as UserDAL from "../../dal/user"; | ||||
| import { buildMonkeyMail } from "../../utils/monkey-mail"; | ||||
| import FunboxList from "../../constants/funbox-list"; | ||||
| import _ from "lodash"; | ||||
| import _, { omit } from "lodash"; | ||||
| import * as WeeklyXpLeaderboard from "../../services/weekly-xp-leaderboard"; | ||||
| import { UAParser } from "ua-parser-js"; | ||||
| import { canFunboxGetPb } from "../../utils/pb"; | ||||
| import { buildDbResult } from "../../utils/result"; | ||||
| import { buildDbResult, replaceLegacyValues } from "../../utils/result"; | ||||
| import { Configuration } from "@monkeytype/shared-types"; | ||||
| import { addLog } from "../../dal/logs"; | ||||
| import { | ||||
|   AddResultRequest, | ||||
|   AddResultResponse, | ||||
|   GetLastResultResponse, | ||||
|   GetResultsQuery, | ||||
|   GetResultsResponse, | ||||
|   UpdateResultTagsRequest, | ||||
|   UpdateResultTagsResponse, | ||||
| } from "@monkeytype/contracts/results"; | ||||
| import { | ||||
|   CompletedEvent, | ||||
|   Configuration, | ||||
|   KeyStats, | ||||
|   Result, | ||||
|   PostResultResponse, | ||||
| } from "@monkeytype/shared-types"; | ||||
| import { addLog } from "../../dal/logs"; | ||||
|   XpBreakdown, | ||||
| } from "@monkeytype/contracts/schemas/results"; | ||||
| import { Mode } from "@monkeytype/contracts/schemas/shared"; | ||||
| 
 | ||||
| try { | ||||
|   if (!anticheatImplemented()) throw new Error("undefined"); | ||||
|  | @ -60,10 +73,11 @@ try { | |||
| } | ||||
| 
 | ||||
| export async function getResults( | ||||
|   req: MonkeyTypes.Request | ||||
| ): Promise<MonkeyResponse> { | ||||
|   req: MonkeyTypes.Request2<GetResultsQuery> | ||||
| ): Promise<GetResultsResponse> { | ||||
|   const { uid } = req.ctx.decodedToken; | ||||
|   const premiumFeaturesEnabled = req.ctx.configuration.users.premium.enabled; | ||||
|   const { onOrAfterTimestamp = NaN, offset = 0 } = req.query; | ||||
|   const userHasPremium = await UserDAL.checkIfUserIsPremium(uid); | ||||
| 
 | ||||
|   const maxLimit = | ||||
|  | @ -71,15 +85,9 @@ export async function getResults( | |||
|       ? req.ctx.configuration.results.limits.premiumUser | ||||
|       : req.ctx.configuration.results.limits.regularUser; | ||||
| 
 | ||||
|   const onOrAfterTimestamp = parseInt( | ||||
|     req.query["onOrAfterTimestamp"] as string, | ||||
|     10 | ||||
|   ); | ||||
|   let limit = stringToNumberOrDefault( | ||||
|     req.query["limit"] as string, | ||||
|     Math.min(req.ctx.configuration.results.maxBatchSize, maxLimit) | ||||
|   ); | ||||
|   const offset = stringToNumberOrDefault(req.query["offset"] as string, 0); | ||||
|   let limit = | ||||
|     req.query.limit ?? | ||||
|     Math.min(req.ctx.configuration.results.maxBatchSize, maxLimit); | ||||
| 
 | ||||
|   //check if premium features are disabled and current call exceeds the limit for regular users
 | ||||
|   if ( | ||||
|  | @ -114,30 +122,30 @@ export async function getResults( | |||
|     }, | ||||
|     uid | ||||
|   ); | ||||
|   return new MonkeyResponse("Results retrieved", results); | ||||
|   return new MonkeyResponse2("Results retrieved", results.map(convertResult)); | ||||
| } | ||||
| 
 | ||||
| export async function getLastResult( | ||||
|   req: MonkeyTypes.Request | ||||
| ): Promise<MonkeyResponse> { | ||||
|   req: MonkeyTypes.Request2 | ||||
| ): Promise<GetLastResultResponse> { | ||||
|   const { uid } = req.ctx.decodedToken; | ||||
|   const results = await ResultDAL.getLastResult(uid); | ||||
|   return new MonkeyResponse("Result retrieved", results); | ||||
|   return new MonkeyResponse2("Result retrieved", convertResult(results)); | ||||
| } | ||||
| 
 | ||||
| export async function deleteAll( | ||||
|   req: MonkeyTypes.Request | ||||
| ): Promise<MonkeyResponse> { | ||||
|   req: MonkeyTypes.Request2 | ||||
| ): Promise<MonkeyResponse2> { | ||||
|   const { uid } = req.ctx.decodedToken; | ||||
| 
 | ||||
|   await ResultDAL.deleteAll(uid); | ||||
|   void addLog("user_results_deleted", "", uid); | ||||
|   return new MonkeyResponse("All results deleted"); | ||||
|   return new MonkeyResponse2("All results deleted", null); | ||||
| } | ||||
| 
 | ||||
| export async function updateTags( | ||||
|   req: MonkeyTypes.Request | ||||
| ): Promise<MonkeyResponse> { | ||||
|   req: MonkeyTypes.Request2<undefined, UpdateResultTagsRequest> | ||||
| ): Promise<UpdateResultTagsResponse> { | ||||
|   const { uid } = req.ctx.decodedToken; | ||||
|   const { tagIds, resultId } = req.body; | ||||
| 
 | ||||
|  | @ -165,14 +173,14 @@ export async function updateTags( | |||
| 
 | ||||
|   const user = await UserDAL.getPartialUser(uid, "update tags", ["tags"]); | ||||
|   const tagPbs = await UserDAL.checkIfTagPb(uid, user, result); | ||||
|   return new MonkeyResponse("Result tags updated", { | ||||
|   return new MonkeyResponse2("Result tags updated", { | ||||
|     tagPbs, | ||||
|   }); | ||||
| } | ||||
| 
 | ||||
| export async function addResult( | ||||
|   req: MonkeyTypes.Request | ||||
| ): Promise<MonkeyResponse> { | ||||
|   req: MonkeyTypes.Request2<undefined, AddResultRequest> | ||||
| ): Promise<AddResultResponse> { | ||||
|   const { uid } = req.ctx.decodedToken; | ||||
| 
 | ||||
|   const user = await UserDAL.getUser(uid, "add result"); | ||||
|  | @ -184,27 +192,18 @@ export async function addResult( | |||
|     ); | ||||
|   } | ||||
| 
 | ||||
|   const completedEvent = Object.assign({}, req.body.result) as CompletedEvent; | ||||
|   if (!user.lbOptOut && completedEvent.acc < 75) { | ||||
|     throw new MonkeyError( | ||||
|       400, | ||||
|       "Cannot submit a result with less than 75% accuracy" | ||||
|     ); | ||||
|   } | ||||
|   const completedEvent = req.body.result; | ||||
|   completedEvent.uid = uid; | ||||
| 
 | ||||
|   if (isTestTooShort(completedEvent)) { | ||||
|     const status = MonkeyStatusCodes.TEST_TOO_SHORT; | ||||
|     throw new MonkeyError(status.code, status.message); | ||||
|   } | ||||
| 
 | ||||
|   const resulthash = completedEvent.hash; | ||||
|   if (resulthash === undefined || resulthash === "") { | ||||
|     throw new MonkeyError(400, "Missing result hash"); | ||||
|   } | ||||
|   delete completedEvent.hash; | ||||
|   delete completedEvent.stringified; | ||||
|   if (req.ctx.configuration.results.objectHashCheckEnabled) { | ||||
|     const serverhash = objectHash(completedEvent); | ||||
|     const objectToHash = omit(completedEvent, "hash"); | ||||
|     const serverhash = objectHash(objectToHash); | ||||
|     if (serverhash !== resulthash) { | ||||
|       void addLog( | ||||
|         "incorrect_result_hash", | ||||
|  | @ -233,11 +232,12 @@ export async function addResult( | |||
|     throw new MonkeyError(400, "Impossible funbox combination"); | ||||
|   } | ||||
| 
 | ||||
|   let keySpacingStats: KeyStats | undefined = undefined; | ||||
|   if ( | ||||
|     completedEvent.keySpacing !== "toolong" && | ||||
|     completedEvent.keySpacing.length > 0 | ||||
|   ) { | ||||
|     completedEvent.keySpacingStats = { | ||||
|     keySpacingStats = { | ||||
|       average: | ||||
|         completedEvent.keySpacing.reduce( | ||||
|           (previous, current) => (current += previous) | ||||
|  | @ -246,11 +246,12 @@ export async function addResult( | |||
|     }; | ||||
|   } | ||||
| 
 | ||||
|   let keyDurationStats: KeyStats | undefined = undefined; | ||||
|   if ( | ||||
|     completedEvent.keyDuration !== "toolong" && | ||||
|     completedEvent.keyDuration.length > 0 | ||||
|   ) { | ||||
|     completedEvent.keyDurationStats = { | ||||
|     keyDurationStats = { | ||||
|       average: | ||||
|         completedEvent.keyDuration.reduce( | ||||
|           (previous, current) => (current += previous) | ||||
|  | @ -263,9 +264,9 @@ export async function addResult( | |||
|     if ( | ||||
|       !validateResult( | ||||
|         completedEvent, | ||||
|         ((req.headers["x-client-version"] as string) || | ||||
|           req.headers["client-version"]) as string, | ||||
|         JSON.stringify(new UAParser(req.headers["user-agent"]).getResult()), | ||||
|         ((req.raw.headers["x-client-version"] as string) || | ||||
|           req.raw.headers["client-version"]) as string, | ||||
|         JSON.stringify(new UAParser(req.raw.headers["user-agent"]).getResult()), | ||||
|         user.lbOptOut === true | ||||
|       ) | ||||
|     ) { | ||||
|  | @ -332,7 +333,7 @@ export async function addResult( | |||
|     (user.verified === false || user.verified === undefined) && | ||||
|     user.lbOptOut !== true | ||||
|   ) { | ||||
|     if (!completedEvent.keySpacingStats || !completedEvent.keyDurationStats) { | ||||
|     if (!keySpacingStats || !keyDurationStats) { | ||||
|       const status = MonkeyStatusCodes.MISSING_KEY_DATA; | ||||
|       throw new MonkeyError(status.code, "Missing key data"); | ||||
|     } | ||||
|  | @ -340,7 +341,9 @@ export async function addResult( | |||
|       throw new MonkeyError(400, "Old key data format"); | ||||
|     } | ||||
|     if (anticheatImplemented()) { | ||||
|       if (!validateKeys(completedEvent, uid)) { | ||||
|       if ( | ||||
|         !validateKeys(completedEvent, keySpacingStats, keyDurationStats, uid) | ||||
|       ) { | ||||
|         //autoban
 | ||||
|         const autoBanConfig = req.ctx.configuration.users.autoBan; | ||||
|         if (autoBanConfig.enabled) { | ||||
|  | @ -399,21 +402,13 @@ export async function addResult( | |||
|     } | ||||
|   } | ||||
| 
 | ||||
|   if (completedEvent.keyDurationStats) { | ||||
|     completedEvent.keyDurationStats.average = roundTo2( | ||||
|       completedEvent.keyDurationStats.average | ||||
|     ); | ||||
|     completedEvent.keyDurationStats.sd = roundTo2( | ||||
|       completedEvent.keyDurationStats.sd | ||||
|     ); | ||||
|   if (keyDurationStats) { | ||||
|     keyDurationStats.average = roundTo2(keyDurationStats.average); | ||||
|     keyDurationStats.sd = roundTo2(keyDurationStats.sd); | ||||
|   } | ||||
|   if (completedEvent.keySpacingStats) { | ||||
|     completedEvent.keySpacingStats.average = roundTo2( | ||||
|       completedEvent.keySpacingStats.average | ||||
|     ); | ||||
|     completedEvent.keySpacingStats.sd = roundTo2( | ||||
|       completedEvent.keySpacingStats.sd | ||||
|     ); | ||||
|   if (keySpacingStats) { | ||||
|     keySpacingStats.average = roundTo2(keySpacingStats.average); | ||||
|     keySpacingStats.sd = roundTo2(keySpacingStats.sd); | ||||
|   } | ||||
| 
 | ||||
|   let isPb = false; | ||||
|  | @ -587,6 +582,13 @@ export async function addResult( | |||
|   } | ||||
| 
 | ||||
|   const dbresult = buildDbResult(completedEvent, user.name, isPb); | ||||
|   if (keySpacingStats !== undefined) { | ||||
|     dbresult.keySpacingStats = keySpacingStats; | ||||
|   } | ||||
|   if (keyDurationStats !== undefined) { | ||||
|     dbresult.keyDurationStats = keyDurationStats; | ||||
|   } | ||||
| 
 | ||||
|   const addedResult = await ResultDAL.addResult(uid, dbresult); | ||||
| 
 | ||||
|   await UserDAL.incrementXp(uid, xpGained.xp); | ||||
|  | @ -604,12 +606,10 @@ export async function addResult( | |||
|     ); | ||||
|   } | ||||
| 
 | ||||
|   const data: Omit<PostResultResponse, "insertedId"> & { | ||||
|     insertedId: ObjectId; | ||||
|   } = { | ||||
|   const data: PostResultResponse = { | ||||
|     isPb, | ||||
|     tagPbs, | ||||
|     insertedId: addedResult.insertedId, | ||||
|     insertedId: addedResult.insertedId.toHexString(), | ||||
|     xp: xpGained.xp, | ||||
|     dailyXpBonus: xpGained.dailyBonus ?? false, | ||||
|     xpBreakdown: xpGained.breakdown ?? {}, | ||||
|  | @ -624,15 +624,15 @@ export async function addResult( | |||
|     data.weeklyXpLeaderboardRank = weeklyXpLeaderboardRank; | ||||
|   } | ||||
| 
 | ||||
|   incrementResult(completedEvent); | ||||
|   incrementResult(completedEvent, dbresult.isPb); | ||||
| 
 | ||||
|   return new MonkeyResponse("Result saved", data); | ||||
|   return new MonkeyResponse2("Result saved", data); | ||||
| } | ||||
| 
 | ||||
| type XpResult = { | ||||
|   xp: number; | ||||
|   dailyBonus?: boolean; | ||||
|   breakdown?: Record<string, number>; | ||||
|   breakdown?: XpBreakdown; | ||||
| }; | ||||
| 
 | ||||
| async function calculateXp( | ||||
|  | @ -669,10 +669,10 @@ async function calculateXp( | |||
|     }; | ||||
|   } | ||||
| 
 | ||||
|   const breakdown: Record<string, number> = {}; | ||||
|   const breakdown: XpBreakdown = {}; | ||||
| 
 | ||||
|   const baseXp = Math.round((testDuration - afkDuration) * 2); | ||||
|   breakdown["base"] = baseXp; | ||||
|   breakdown.base = baseXp; | ||||
| 
 | ||||
|   let modifier = 1; | ||||
| 
 | ||||
|  | @ -682,7 +682,7 @@ async function calculateXp( | |||
| 
 | ||||
|   if (acc === 100) { | ||||
|     modifier += 0.5; | ||||
|     breakdown["100%"] = Math.round(baseXp * 0.5); | ||||
|     breakdown.fullAccuracy = Math.round(baseXp * 0.5); | ||||
|   } else if (correctedEverything) { | ||||
|     // corrected everything bonus
 | ||||
|     modifier += 0.25; | ||||
|  | @ -692,16 +692,16 @@ async function calculateXp( | |||
|   if (mode === "quote") { | ||||
|     // real sentences bonus
 | ||||
|     modifier += 0.5; | ||||
|     breakdown["quote"] = Math.round(baseXp * 0.5); | ||||
|     breakdown.quote = Math.round(baseXp * 0.5); | ||||
|   } else { | ||||
|     // punctuation bonus
 | ||||
|     if (punctuation) { | ||||
|       modifier += 0.4; | ||||
|       breakdown["punctuation"] = Math.round(baseXp * 0.4); | ||||
|       breakdown.punctuation = Math.round(baseXp * 0.4); | ||||
|     } | ||||
|     if (numbers) { | ||||
|       modifier += 0.1; | ||||
|       breakdown["numbers"] = Math.round(baseXp * 0.1); | ||||
|       breakdown.numbers = Math.round(baseXp * 0.1); | ||||
|     } | ||||
|   } | ||||
| 
 | ||||
|  | @ -713,7 +713,7 @@ async function calculateXp( | |||
|     }); | ||||
|     if (funboxModifier > 0) { | ||||
|       modifier += funboxModifier; | ||||
|       breakdown["funbox"] = Math.round(baseXp * funboxModifier); | ||||
|       breakdown.funbox = Math.round(baseXp * funboxModifier); | ||||
|     } | ||||
|   } | ||||
| 
 | ||||
|  | @ -731,7 +731,7 @@ async function calculateXp( | |||
| 
 | ||||
|     if (streakModifier > 0) { | ||||
|       modifier += streakModifier; | ||||
|       breakdown["streak"] = Math.round(baseXp * streakModifier); | ||||
|       breakdown.streak = Math.round(baseXp * streakModifier); | ||||
|     } | ||||
|   } | ||||
| 
 | ||||
|  | @ -742,10 +742,10 @@ async function calculateXp( | |||
|       if (modifier < 0) modifier = 0; | ||||
|       incompleteXp += Math.round(it.seconds * modifier); | ||||
|     }); | ||||
|     breakdown["incomplete"] = incompleteXp; | ||||
|     breakdown.incomplete = incompleteXp; | ||||
|   } else if (incompleteTestSeconds && incompleteTestSeconds > 0) { | ||||
|     incompleteXp = Math.round(incompleteTestSeconds); | ||||
|     breakdown["incomplete"] = incompleteXp; | ||||
|     breakdown.incomplete = incompleteXp; | ||||
|   } | ||||
| 
 | ||||
|   const accuracyModifier = (acc - 50) / 50; | ||||
|  | @ -769,14 +769,14 @@ async function calculateXp( | |||
|         Math.min(maxDailyBonus, proportionalXp), | ||||
|         minDailyBonus | ||||
|       ); | ||||
|       breakdown["daily"] = dailyBonus; | ||||
|       breakdown.daily = dailyBonus; | ||||
|     } | ||||
|   } | ||||
| 
 | ||||
|   const xpWithModifiers = Math.round(baseXp * modifier); | ||||
| 
 | ||||
|   const xpAfterAccuracy = Math.round(xpWithModifiers * accuracyModifier); | ||||
|   breakdown["accPenalty"] = xpWithModifiers - xpAfterAccuracy; | ||||
|   breakdown.accPenalty = xpWithModifiers - xpAfterAccuracy; | ||||
| 
 | ||||
|   const totalXp = | ||||
|     Math.round((xpAfterAccuracy + incompleteXp) * gainMultiplier) + dailyBonus; | ||||
|  | @ -786,7 +786,7 @@ async function calculateXp( | |||
|     //   "configMultiplier",
 | ||||
|     //   Math.round((xpAfterAccuracy + incompleteXp) * (gainMultiplier - 1)),
 | ||||
|     // ]);
 | ||||
|     breakdown["configMultiplier"] = gainMultiplier; | ||||
|     breakdown.configMultiplier = gainMultiplier; | ||||
|   } | ||||
| 
 | ||||
|   const isAwardingDailyBonus = dailyBonus > 0; | ||||
|  | @ -797,3 +797,7 @@ async function calculateXp( | |||
|     breakdown, | ||||
|   }; | ||||
| } | ||||
| 
 | ||||
| function convertResult(db: MonkeyTypes.DBResult): Result<Mode> { | ||||
|   return replaceObjectId(replaceLegacyValues(db)); | ||||
| } | ||||
|  |  | |||
|  | @ -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 { | ||||
|  |  | |||
|  | @ -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), | ||||
|   }, | ||||
| }); | ||||
|  |  | |||
|  | @ -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; | ||||
|  | @ -7,14 +7,11 @@ import { | |||
| } from "mongodb"; | ||||
| import MonkeyError from "../utils/error"; | ||||
| import * as db from "../init/db"; | ||||
| import { DBResult as SharedDBResult } from "@monkeytype/shared-types"; | ||||
| 
 | ||||
| import { getUser, getTags } from "./user"; | ||||
| import { Mode } from "@monkeytype/contracts/schemas/shared"; | ||||
| 
 | ||||
| type DBResult = MonkeyTypes.WithObjectId<SharedDBResult<Mode>>; | ||||
| 
 | ||||
| export const getResultCollection = (): Collection<DBResult> => | ||||
|   db.collection<DBResult>("results"); | ||||
| export const getResultCollection = (): Collection<MonkeyTypes.DBResult> => | ||||
|   db.collection<MonkeyTypes.DBResult>("results"); | ||||
| 
 | ||||
| export async function addResult( | ||||
|   uid: string, | ||||
|  | @ -78,14 +75,14 @@ export async function getResult( | |||
| 
 | ||||
| export async function getLastResult( | ||||
|   uid: string | ||||
| ): Promise<Omit<MonkeyTypes.DBResult, "uid">> { | ||||
| ): Promise<MonkeyTypes.DBResult> { | ||||
|   const [lastResult] = await getResultCollection() | ||||
|     .find({ uid }) | ||||
|     .sort({ timestamp: -1 }) | ||||
|     .limit(1) | ||||
|     .toArray(); | ||||
|   if (!lastResult) throw new MonkeyError(404, "No results found"); | ||||
|   return _.omit(lastResult, "uid"); | ||||
|   return lastResult; | ||||
| } | ||||
| 
 | ||||
| export async function getResultByTimestamp( | ||||
|  |  | |||
|  | @ -19,7 +19,6 @@ import { | |||
|   Badge, | ||||
|   Configuration, | ||||
|   CustomTheme, | ||||
|   DBResult, | ||||
|   MonkeyMail, | ||||
|   UserInventory, | ||||
|   UserProfileDetails, | ||||
|  | @ -33,10 +32,11 @@ import { | |||
| } from "@monkeytype/contracts/schemas/shared"; | ||||
| import { addImportantLog } from "./logs"; | ||||
| import { ResultFilters } from "@monkeytype/contracts/schemas/users"; | ||||
| import { Result as ResultType } from "@monkeytype/contracts/schemas/results"; | ||||
| 
 | ||||
| const SECONDS_PER_HOUR = 3600; | ||||
| 
 | ||||
| type Result = Omit<DBResult<Mode>, "_id" | "name">; | ||||
| type Result = Omit<ResultType<Mode>, "_id" | "name">; | ||||
| 
 | ||||
| // Export for use in tests
 | ||||
| export const getUsersCollection = (): Collection<MonkeyTypes.DBUser> => | ||||
|  |  | |||
|  | @ -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"], | ||||
|  |  | |||
|  | @ -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": { | ||||
|  |  | |||
|  | @ -6,6 +6,8 @@ import rateLimit, { | |||
|   type Options, | ||||
| } from "express-rate-limit"; | ||||
| import { isDevEnvironment } from "../utils/misc"; | ||||
| import { TsRestRequestHandler } from "@ts-rest/express"; | ||||
| import { TsRestRequestWithCtx } from "./auth"; | ||||
| 
 | ||||
| const REQUEST_MULTIPLIER = isDevEnvironment() ? 1 : 1; | ||||
| 
 | ||||
|  | @ -51,3 +53,20 @@ export function withApeRateLimiter( | |||
|     return defaultRateLimiter(req, res, next); | ||||
|   }; | ||||
| } | ||||
| 
 | ||||
| export function withApeRateLimiter2<T extends AppRouter | AppRoute>( | ||||
|   defaultRateLimiter: RateLimitRequestHandler, | ||||
|   apeRateLimiterOverride?: RateLimitRequestHandler | ||||
| ): TsRestRequestHandler<T> { | ||||
|   return (req: TsRestRequestWithCtx, res: Response, next: NextFunction) => { | ||||
|     if (req.ctx.decodedToken.type === "ApeKey") { | ||||
|       const rateLimiter = apeRateLimiterOverride ?? apeRateLimiter; | ||||
|       // TODO: bump version?
 | ||||
|       // eslint-disable-next-line @typescript-eslint/no-unsafe-return
 | ||||
|       return rateLimiter(req, res, next); | ||||
|     } | ||||
| 
 | ||||
|     // eslint-disable-next-line @typescript-eslint/no-unsafe-return
 | ||||
|     return defaultRateLimiter(req, res, next); | ||||
|   }; | ||||
| } | ||||
|  |  | |||
							
								
								
									
										8
									
								
								backend/src/types/types.d.ts
									
										
									
									
										vendored
									
									
								
							
							
						
						
									
										8
									
								
								backend/src/types/types.d.ts
									
										
									
									
										vendored
									
									
								
							|  | @ -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; | ||||
|  |  | |||
|  | @ -1,11 +1,12 @@ | |||
| import _ from "lodash"; | ||||
| import FunboxList from "../constants/funbox-list"; | ||||
| import { DBResult } from "@monkeytype/shared-types"; | ||||
| 
 | ||||
| import { | ||||
|   Mode, | ||||
|   PersonalBest, | ||||
|   PersonalBests, | ||||
| } from "@monkeytype/contracts/schemas/shared"; | ||||
| import { Result as ResultType } from "@monkeytype/contracts/schemas/results"; | ||||
| 
 | ||||
| type CheckAndUpdatePbResult = { | ||||
|   isPb: boolean; | ||||
|  | @ -13,7 +14,7 @@ type CheckAndUpdatePbResult = { | |||
|   lbPersonalBests?: MonkeyTypes.LbPersonalBests; | ||||
| }; | ||||
| 
 | ||||
| type Result = Omit<DBResult<Mode>, "_id" | "name">; | ||||
| type Result = Omit<ResultType<Mode>, "_id" | "name">; | ||||
| 
 | ||||
| export function canFunboxGetPb(result: Result): boolean { | ||||
|   const funbox = result.funbox; | ||||
|  |  | |||
|  | @ -1,8 +1,7 @@ | |||
| import { Result } from "@monkeytype/shared-types"; | ||||
| import { Mode } from "@monkeytype/contracts/schemas/shared"; | ||||
| import "dotenv/config"; | ||||
| import { Counter, Histogram, Gauge } from "prom-client"; | ||||
| import { TsRestRequestWithCtx } from "../middlewares/auth"; | ||||
| import { CompletedEvent } from "@monkeytype/contracts/schemas/results"; | ||||
| 
 | ||||
| const auth = new Counter({ | ||||
|   name: "api_request_auth_total", | ||||
|  | @ -91,11 +90,10 @@ export function setLeaderboard( | |||
|   leaderboardUpdate.set({ language, mode, mode2, step: "index" }, times[3]); | ||||
| } | ||||
| 
 | ||||
| export function incrementResult(res: Result<Mode>): void { | ||||
| export function incrementResult(res: CompletedEvent, isPb?: boolean): void { | ||||
|   const { | ||||
|     mode, | ||||
|     mode2, | ||||
|     isPb, | ||||
|     blindMode, | ||||
|     lazyMode, | ||||
|     difficulty, | ||||
|  |  | |||
|  | @ -1,16 +1,13 @@ | |||
| import { CompletedEvent, DBResult } from "@monkeytype/shared-types"; | ||||
| import { Mode } from "@monkeytype/contracts/schemas/shared"; | ||||
| import { CompletedEvent } from "@monkeytype/contracts/schemas/results"; | ||||
| import { ObjectId } from "mongodb"; | ||||
| 
 | ||||
| type Result = MonkeyTypes.WithObjectId<DBResult<Mode>>; | ||||
| 
 | ||||
| export function buildDbResult( | ||||
|   completedEvent: CompletedEvent, | ||||
|   userName: string, | ||||
|   isPb: boolean | ||||
| ): Result { | ||||
| ): MonkeyTypes.DBResult { | ||||
|   const ce = completedEvent; | ||||
|   const res: Result = { | ||||
|   const res: MonkeyTypes.DBResult = { | ||||
|     _id: new ObjectId(), | ||||
|     uid: ce.uid, | ||||
|     wpm: ce.wpm, | ||||
|  | @ -35,14 +32,14 @@ export function buildDbResult( | |||
|     funbox: ce.funbox, | ||||
|     numbers: ce.numbers, | ||||
|     punctuation: ce.punctuation, | ||||
|     keySpacingStats: ce.keySpacingStats, | ||||
|     keyDurationStats: ce.keyDurationStats, | ||||
|     isPb: isPb, | ||||
|     bailedOut: ce.bailedOut, | ||||
|     blindMode: ce.blindMode, | ||||
|     name: userName, | ||||
|   }; | ||||
| 
 | ||||
|   //compress object by omitting default values. Frontend will add them back after reading
 | ||||
|   //reduces object size on the database and on the rest api
 | ||||
|   if (!ce.bailedOut) delete res.bailedOut; | ||||
|   if (!ce.blindMode) delete res.blindMode; | ||||
|   if (!ce.lazyMode) delete res.lazyMode; | ||||
|  | @ -51,17 +48,32 @@ export function buildDbResult( | |||
|   if (ce.language === "english") delete res.language; | ||||
|   if (!ce.numbers) delete res.numbers; | ||||
|   if (!ce.punctuation) delete res.punctuation; | ||||
|   if (ce.mode !== "custom") delete res.customText; | ||||
|   if (ce.mode !== "quote") delete res.quoteLength; | ||||
|   if (ce.restartCount === 0) delete res.restartCount; | ||||
|   if (ce.incompleteTestSeconds === 0) delete res.incompleteTestSeconds; | ||||
|   if (ce.afkDuration === 0) delete res.afkDuration; | ||||
|   if (ce.tags.length === 0) delete res.tags; | ||||
| 
 | ||||
|   if (ce.keySpacingStats === undefined) delete res.keySpacingStats; | ||||
|   if (ce.keyDurationStats === undefined) delete res.keyDurationStats; | ||||
| 
 | ||||
|   if (res.isPb === false) delete res.isPb; | ||||
| 
 | ||||
|   return res; | ||||
| } | ||||
| 
 | ||||
| /** | ||||
|  * Convert legacy values | ||||
|  * @param result | ||||
|  * @returns | ||||
|  */ | ||||
| export function replaceLegacyValues( | ||||
|   result: MonkeyTypes.DBResult | ||||
| ): MonkeyTypes.DBResult { | ||||
|   //convert legacy values
 | ||||
|   if ( | ||||
|     result.correctChars !== undefined && | ||||
|     result.incorrectChars !== undefined | ||||
|   ) { | ||||
|     result.charStats = [result.correctChars, result.incorrectChars, 0, 0]; | ||||
|     delete result.correctChars; | ||||
|     delete result.incorrectChars; | ||||
|   } | ||||
|   return result; | ||||
| } | ||||
|  |  | |||
|  | @ -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; | ||||
|  |  | |||
|  | @ -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", | ||||
|  |  | |||
|  | @ -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, | ||||
|  |  | |||
|  | @ -1,35 +0,0 @@ | |||
| import { DBResult, Result } from "@monkeytype/shared-types"; | ||||
| import { Mode } from "@monkeytype/contracts/schemas/shared"; | ||||
| 
 | ||||
| const BASE_PATH = "/results"; | ||||
| 
 | ||||
| export default class Results { | ||||
|   constructor(private httpClient: Ape.HttpClient) { | ||||
|     this.httpClient = httpClient; | ||||
|   } | ||||
| 
 | ||||
|   async get(offset?: number): Ape.EndpointResponse<DBResult<Mode>[]> { | ||||
|     return await this.httpClient.get(BASE_PATH, { searchQuery: { offset } }); | ||||
|   } | ||||
| 
 | ||||
|   async save( | ||||
|     result: Result<Mode> | ||||
|   ): Ape.EndpointResponse<Ape.Results.PostResult> { | ||||
|     return await this.httpClient.post(BASE_PATH, { | ||||
|       payload: { result }, | ||||
|     }); | ||||
|   } | ||||
| 
 | ||||
|   async updateTags( | ||||
|     resultId: string, | ||||
|     tagIds: string[] | ||||
|   ): Ape.EndpointResponse<Ape.Results.PatchResult> { | ||||
|     return await this.httpClient.patch(`${BASE_PATH}/tags`, { | ||||
|       payload: { resultId, tagIds }, | ||||
|     }); | ||||
|   } | ||||
| 
 | ||||
|   async deleteAll(): Ape.EndpointResponse<Ape.Results.DeleteAll> { | ||||
|     return await this.httpClient.delete(BASE_PATH); | ||||
|   } | ||||
| } | ||||
|  | @ -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)), | ||||
|  |  | |||
							
								
								
									
										9
									
								
								frontend/src/ts/ape/types/results.d.ts
									
										
									
									
										vendored
									
									
								
							
							
						
						
									
										9
									
								
								frontend/src/ts/ape/types/results.d.ts
									
										
									
									
										vendored
									
									
								
							|  | @ -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; | ||||
| } | ||||
|  | @ -185,7 +185,7 @@ async function getDataAndInit(): Promise<boolean> { | |||
|   return true; | ||||
| } | ||||
| 
 | ||||
| export async function loadUser(user: UserType): Promise<void> { | ||||
| export async function loadUser(_user: UserType): Promise<void> { | ||||
|   // User is signed in.
 | ||||
|   PageTransition.set(false); | ||||
|   AccountButton.loading(true); | ||||
|  | @ -205,7 +205,6 @@ export async function loadUser(user: UserType): Promise<void> { | |||
|   // showFavouriteThemesAtTheTop();
 | ||||
| 
 | ||||
|   if (TestLogic.notSignedInLastResult !== null) { | ||||
|     TestLogic.setNotSignedInUid(user.uid); | ||||
|     LastSignedOutResultModal.show(); | ||||
|   } | ||||
| } | ||||
|  | @ -578,22 +577,7 @@ async function signUp(): Promise<void> { | |||
|     await sendVerificationEmail(); | ||||
|     LoginPage.hidePreloader(); | ||||
|     await loadUser(createdAuthUser.user); | ||||
|     if (TestLogic.notSignedInLastResult !== null) { | ||||
|       TestLogic.setNotSignedInUid(createdAuthUser.user.uid); | ||||
| 
 | ||||
|       const response = await Ape.results.save(TestLogic.notSignedInLastResult); | ||||
| 
 | ||||
|       if (response.status === 200) { | ||||
|         const result = TestLogic.notSignedInLastResult; | ||||
|         DB.saveLocalResult(result); | ||||
|         DB.updateLocalStats( | ||||
|           1, | ||||
|           result.testDuration + | ||||
|             result.incompleteTestSeconds - | ||||
|             result.afkDuration | ||||
|         ); | ||||
|       } | ||||
|     } | ||||
|     Notifications.add("Account created", 1); | ||||
|   } catch (e) { | ||||
|     let message = Misc.createErrorMessage(e, "Failed to create account"); | ||||
|  |  | |||
|  | @ -12,13 +12,13 @@ import * as Loader from "../elements/loader"; | |||
| import { | ||||
|   CustomTextLimitMode, | ||||
|   CustomTextMode, | ||||
|   Result, | ||||
| } from "@monkeytype/shared-types"; | ||||
| } from "@monkeytype/contracts/schemas/util"; | ||||
| import { | ||||
|   Config as ConfigType, | ||||
|   Difficulty, | ||||
| } from "@monkeytype/contracts/schemas/configs"; | ||||
| import { Mode } from "@monkeytype/contracts/schemas/shared"; | ||||
| import { CompletedEvent } from "@monkeytype/contracts/schemas/results"; | ||||
| 
 | ||||
| let challengeLoading = false; | ||||
| 
 | ||||
|  | @ -33,7 +33,7 @@ export function clearActive(): void { | |||
|   } | ||||
| } | ||||
| 
 | ||||
| export function verify(result: Result<Mode>): string | null { | ||||
| export function verify(result: CompletedEvent): string | null { | ||||
|   try { | ||||
|     if (TestState.activeChallenge) { | ||||
|       const afk = (result.afkDuration / result.testDuration) * 100; | ||||
|  | @ -123,7 +123,8 @@ export function verify(result: Result<Mode>): string | null { | |||
|               requirementsMet = false; | ||||
|               for (const f of funboxMode.split("#")) { | ||||
|                 if ( | ||||
|                   result.funbox?.split("#").find((rf) => rf === f) === undefined | ||||
|                   result.funbox?.split("#").find((rf: string) => rf === f) === | ||||
|                   undefined | ||||
|                 ) { | ||||
|                   failReasons.push(`${f} funbox not active`); | ||||
|                 } | ||||
|  |  | |||
|  | @ -15,7 +15,7 @@ import { | |||
| } from "./elements/test-activity-calendar"; | ||||
| import * as Loader from "./elements/loader"; | ||||
| 
 | ||||
| import { Badge, DBResult, Result } from "@monkeytype/shared-types"; | ||||
| import { Badge } from "@monkeytype/shared-types"; | ||||
| import { Config, Difficulty } from "@monkeytype/contracts/schemas/configs"; | ||||
| import { | ||||
|   Mode, | ||||
|  | @ -283,42 +283,37 @@ export async function getUserResults(offset?: number): Promise<boolean> { | |||
|     LoadingPage.updateBar(90); | ||||
|   } | ||||
| 
 | ||||
|   const response = await Ape.results.get(offset); | ||||
|   const response = await Ape.results.get({ query: { offset } }); | ||||
| 
 | ||||
|   if (response.status !== 200) { | ||||
|     Notifications.add("Error getting results: " + response.message, -1); | ||||
|     Notifications.add("Error getting results: " + response.body.message, -1); | ||||
|     return false; | ||||
|   } | ||||
| 
 | ||||
|   const results = response.data as DBResult<Mode>[]; | ||||
|   const results: MonkeyTypes.FullResult<Mode>[] = response.body.data.map( | ||||
|     (result) => { | ||||
|       if (result.bailedOut === undefined) result.bailedOut = false; | ||||
|       if (result.blindMode === undefined) result.blindMode = false; | ||||
|       if (result.lazyMode === undefined) result.lazyMode = false; | ||||
|       if (result.difficulty === undefined) result.difficulty = "normal"; | ||||
|       if (result.funbox === undefined) result.funbox = "none"; | ||||
|       if (result.language === undefined || result.language === null) { | ||||
|         result.language = "english"; | ||||
|       } | ||||
|       if (result.numbers === undefined) result.numbers = false; | ||||
|       if (result.punctuation === undefined) result.punctuation = false; | ||||
|       if (result.numbers === undefined) result.numbers = false; | ||||
|       if (result.quoteLength === undefined) result.quoteLength = -1; | ||||
|       if (result.restartCount === undefined) result.restartCount = 0; | ||||
|       if (result.incompleteTestSeconds === undefined) { | ||||
|         result.incompleteTestSeconds = 0; | ||||
|       } | ||||
|       if (result.afkDuration === undefined) result.afkDuration = 0; | ||||
|       if (result.tags === undefined) result.tags = []; | ||||
|       return result as MonkeyTypes.FullResult<Mode>; | ||||
|     } | ||||
|   ); | ||||
|   results?.sort((a, b) => b.timestamp - a.timestamp); | ||||
|   results.forEach((result) => { | ||||
|     if (result.bailedOut === undefined) result.bailedOut = false; | ||||
|     if (result.blindMode === undefined) result.blindMode = false; | ||||
|     if (result.lazyMode === undefined) result.lazyMode = false; | ||||
|     if (result.difficulty === undefined) result.difficulty = "normal"; | ||||
|     if (result.funbox === undefined) result.funbox = "none"; | ||||
|     if (result.language === undefined || result.language === null) { | ||||
|       result.language = "english"; | ||||
|     } | ||||
|     if (result.numbers === undefined) result.numbers = false; | ||||
|     if (result.punctuation === undefined) result.punctuation = false; | ||||
|     if (result.numbers === undefined) result.numbers = false; | ||||
|     if (result.quoteLength === undefined) result.quoteLength = -1; | ||||
|     if (result.restartCount === undefined) result.restartCount = 0; | ||||
|     if (result.incompleteTestSeconds === undefined) { | ||||
|       result.incompleteTestSeconds = 0; | ||||
|     } | ||||
|     if (result.afkDuration === undefined) result.afkDuration = 0; | ||||
|     if (result.tags === undefined) result.tags = []; | ||||
| 
 | ||||
|     if ( | ||||
|       result.correctChars !== undefined && | ||||
|       result.incorrectChars !== undefined | ||||
|     ) { | ||||
|       result.charStats = [result.correctChars, result.incorrectChars, 0, 0]; | ||||
|     } | ||||
|   }); | ||||
| 
 | ||||
|   if (dbSnapshot.results !== undefined && dbSnapshot.results.length > 0) { | ||||
|     //merge
 | ||||
|  | @ -327,11 +322,9 @@ export async function getUserResults(offset?: number): Promise<boolean> { | |||
|     const resultsWithoutDuplicates = results.filter( | ||||
|       (it) => it.timestamp < oldestTimestamp | ||||
|     ); | ||||
|     dbSnapshot.results.push( | ||||
|       ...(resultsWithoutDuplicates as unknown as Result<Mode>[]) | ||||
|     ); | ||||
|     dbSnapshot.results.push(...resultsWithoutDuplicates); | ||||
|   } else { | ||||
|     dbSnapshot.results = results as unknown as Result<Mode>[]; | ||||
|     dbSnapshot.results = results; | ||||
|   } | ||||
|   return true; | ||||
| } | ||||
|  | @ -939,7 +932,7 @@ export async function resetConfig(): Promise<void> { | |||
|   } | ||||
| } | ||||
| 
 | ||||
| export function saveLocalResult(result: Result<Mode>): void { | ||||
| export function saveLocalResult(result: MonkeyTypes.FullResult<Mode>): void { | ||||
|   const snapshot = getSnapshot(); | ||||
|   if (!snapshot) return; | ||||
| 
 | ||||
|  |  | |||
|  | @ -2,6 +2,7 @@ import * as Misc from "../utils/misc"; | |||
| import * as Levels from "../utils/levels"; | ||||
| import { getAll } from "./theme-colors"; | ||||
| import * as SlowTimer from "../states/slow-timer"; | ||||
| import { XpBreakdown } from "@monkeytype/contracts/schemas/results"; | ||||
| import { | ||||
|   getHtmlByUserFlags, | ||||
|   SupportsFlags, | ||||
|  | @ -219,7 +220,7 @@ export async function updateXpBar( | |||
| 
 | ||||
| async function animateXpBreakdown( | ||||
|   addedXp: number, | ||||
|   breakdown?: Record<string, number> | ||||
|   breakdown?: XpBreakdown | ||||
| ): Promise<void> { | ||||
|   if (!breakdown) { | ||||
|     $("nav .xpBar .xpGain").text(`+${addedXp}`); | ||||
|  | @ -289,84 +290,84 @@ async function animateXpBreakdown( | |||
| 
 | ||||
|   xpGain.text(`+0`); | ||||
|   xpBreakdown.append( | ||||
|     `<div class='text next'>time typing +${breakdown["base"]}</div>` | ||||
|     `<div class='text next'>time typing +${breakdown.base}</div>` | ||||
|   ); | ||||
|   total += breakdown["base"] ?? 0; | ||||
|   if (breakdown["100%"]) { | ||||
|   if (breakdown.fullAccuracy) { | ||||
|     await Misc.sleep(delay); | ||||
|     await append(`perfect +${breakdown["100%"]}`); | ||||
|     total += breakdown["100%"]; | ||||
|   } else if (breakdown["corrected"]) { | ||||
|     await append(`perfect +${breakdown.fullAccuracy}`); | ||||
|     total += breakdown.fullAccuracy; | ||||
|   } else if (breakdown.corrected) { | ||||
|     await Misc.sleep(delay); | ||||
|     await append(`clean +${breakdown["corrected"]}`); | ||||
|     total += breakdown["corrected"]; | ||||
|     await append(`clean +${breakdown.corrected}`); | ||||
|     total += breakdown.corrected; | ||||
|   } | ||||
| 
 | ||||
|   if (skipBreakdown) return; | ||||
| 
 | ||||
|   if (breakdown["quote"]) { | ||||
|   if (breakdown.quote) { | ||||
|     await Misc.sleep(delay); | ||||
|     await append(`quote +${breakdown["quote"]}`); | ||||
|     total += breakdown["quote"]; | ||||
|     await append(`quote +${breakdown.quote}`); | ||||
|     total += breakdown.quote; | ||||
|   } else { | ||||
|     if (breakdown["punctuation"]) { | ||||
|     if (breakdown.punctuation) { | ||||
|       await Misc.sleep(delay); | ||||
|       await append(`punctuation +${breakdown["punctuation"]}`); | ||||
|       total += breakdown["punctuation"]; | ||||
|       await append(`punctuation +${breakdown.punctuation}`); | ||||
|       total += breakdown.punctuation; | ||||
|     } | ||||
|     if (breakdown["numbers"]) { | ||||
|     if (breakdown.numbers) { | ||||
|       await Misc.sleep(delay); | ||||
|       await append(`numbers +${breakdown["numbers"]}`); | ||||
|       total += breakdown["numbers"]; | ||||
|       await append(`numbers +${breakdown.numbers}`); | ||||
|       total += breakdown.numbers; | ||||
|     } | ||||
|   } | ||||
| 
 | ||||
|   if (skipBreakdown) return; | ||||
| 
 | ||||
|   if (breakdown["funbox"]) { | ||||
|   if (breakdown.funbox) { | ||||
|     await Misc.sleep(delay); | ||||
|     await append(`funbox +${breakdown["funbox"]}`); | ||||
|     total += breakdown["funbox"]; | ||||
|     await append(`funbox +${breakdown.funbox}`); | ||||
|     total += breakdown.funbox; | ||||
|   } | ||||
| 
 | ||||
|   if (skipBreakdown) return; | ||||
| 
 | ||||
|   if (breakdown["streak"]) { | ||||
|   if (breakdown.streak) { | ||||
|     await Misc.sleep(delay); | ||||
|     await append(`streak +${breakdown["streak"]}`); | ||||
|     total += breakdown["streak"]; | ||||
|     await append(`streak +${breakdown.streak}`); | ||||
|     total += breakdown.streak; | ||||
|   } | ||||
| 
 | ||||
|   if (skipBreakdown) return; | ||||
| 
 | ||||
|   if (breakdown["accPenalty"]) { | ||||
|   if (breakdown.accPenalty) { | ||||
|     await Misc.sleep(delay); | ||||
|     await append(`accuracy penalty -${breakdown["accPenalty"]}`); | ||||
|     total -= breakdown["accPenalty"]; | ||||
|     await append(`accuracy penalty -${breakdown.accPenalty}`); | ||||
|     total -= breakdown.accPenalty; | ||||
|   } | ||||
| 
 | ||||
|   if (skipBreakdown) return; | ||||
| 
 | ||||
|   if (breakdown["incomplete"]) { | ||||
|   if (breakdown.incomplete) { | ||||
|     await Misc.sleep(delay); | ||||
|     await append(`incomplete tests +${breakdown["incomplete"]}`); | ||||
|     total += breakdown["incomplete"]; | ||||
|     await append(`incomplete tests +${breakdown.incomplete}`); | ||||
|     total += breakdown.incomplete; | ||||
|   } | ||||
| 
 | ||||
|   if (skipBreakdown) return; | ||||
| 
 | ||||
|   if (breakdown["configMultiplier"]) { | ||||
|   if (breakdown.configMultiplier) { | ||||
|     await Misc.sleep(delay); | ||||
|     await append(`global multiplier x${breakdown["configMultiplier"]}`); | ||||
|     total *= breakdown["configMultiplier"]; | ||||
|     await append(`global multiplier x${breakdown.configMultiplier}`); | ||||
|     total *= breakdown.configMultiplier; | ||||
|   } | ||||
| 
 | ||||
|   if (skipBreakdown) return; | ||||
| 
 | ||||
|   if (breakdown["daily"]) { | ||||
|   if (breakdown.daily) { | ||||
|     await Misc.sleep(delay); | ||||
|     await append(`daily bonus +${breakdown["daily"]}`); | ||||
|     total += breakdown["daily"]; | ||||
|     await append(`daily bonus +${breakdown.daily}`); | ||||
|     total += breakdown.daily; | ||||
|   } | ||||
| 
 | ||||
|   if (skipBreakdown) return; | ||||
|  |  | |||
|  | @ -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( | ||||
|  |  | |||
|  | @ -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"; | ||||
| 
 | ||||
|  |  | |||
|  | @ -8,7 +8,7 @@ import { areUnsortedArraysEqual } from "../utils/arrays"; | |||
| import * as TestResult from "../test/result"; | ||||
| import AnimatedModal from "../utils/animated-modal"; | ||||
| import { Mode } from "@monkeytype/contracts/schemas/shared"; | ||||
| import { Result } from "@monkeytype/shared-types"; | ||||
| import { Result } from "@monkeytype/contracts/schemas/results"; | ||||
| 
 | ||||
| type State = { | ||||
|   resultId: string; | ||||
|  | @ -112,7 +112,9 @@ function toggleTag(tagId: string): void { | |||
| 
 | ||||
| async function save(): Promise<void> { | ||||
|   Loader.show(); | ||||
|   const response = await Ape.results.updateTags(state.resultId, state.tags); | ||||
|   const response = await Ape.results.updateTags({ | ||||
|     body: { resultId: state.resultId, tagIds: state.tags }, | ||||
|   }); | ||||
|   Loader.hide(); | ||||
| 
 | ||||
|   //if got no freaking idea why this is needed
 | ||||
|  | @ -121,12 +123,15 @@ async function save(): Promise<void> { | |||
|   state.tags = state.tags.filter((el) => el !== undefined); | ||||
| 
 | ||||
|   if (response.status !== 200) { | ||||
|     Notifications.add("Failed to update result tags: " + response.message, -1); | ||||
|     Notifications.add( | ||||
|       "Failed to update result tags: " + response.body.message, | ||||
|       -1 | ||||
|     ); | ||||
|     return; | ||||
|   } | ||||
| 
 | ||||
|   //can do this because the response will not be null if the status is 200
 | ||||
|   const responseTagPbs = response.data?.tagPbs ?? []; | ||||
|   const responseTagPbs = response.body.data?.tagPbs ?? []; | ||||
| 
 | ||||
|   Notifications.add("Tags updated", 1, { | ||||
|     duration: 2, | ||||
|  |  | |||
|  | @ -10,9 +10,7 @@ import Ape from "../ape"; | |||
| import { createErrorMessage } from "../utils/misc"; | ||||
| import * as LoginPage from "../pages/login"; | ||||
| import * as AccountController from "../controllers/account-controller"; | ||||
| import * as TestLogic from "../test/test-logic"; | ||||
| import * as CaptchaController from "../controllers/captcha-controller"; | ||||
| import * as DB from "../db"; | ||||
| import * as Loader from "../elements/loader"; | ||||
| import { subscribe as subscribeToSignUpEvent } from "../observables/google-sign-up-event"; | ||||
| import { InputIndicator } from "../elements/input-indicator"; | ||||
|  | @ -91,24 +89,7 @@ async function apply(): Promise<void> { | |||
|       LoginPage.enableInputs(); | ||||
|       LoginPage.hidePreloader(); | ||||
|       await AccountController.loadUser(signedInUser.user); | ||||
|       if (TestLogic.notSignedInLastResult !== null) { | ||||
|         TestLogic.setNotSignedInUid(signedInUser.user.uid); | ||||
| 
 | ||||
|         const resultsSaveResponse = await Ape.results.save( | ||||
|           TestLogic.notSignedInLastResult | ||||
|         ); | ||||
| 
 | ||||
|         if (resultsSaveResponse.status === 200) { | ||||
|           const result = TestLogic.notSignedInLastResult; | ||||
|           DB.saveLocalResult(result); | ||||
|           DB.updateLocalStats( | ||||
|             1, | ||||
|             result.testDuration + | ||||
|               result.incompleteTestSeconds - | ||||
|               result.afkDuration | ||||
|           ); | ||||
|         } | ||||
|       } | ||||
|       signedInUser = undefined; | ||||
|       Loader.hide(); | ||||
|       void hide(); | ||||
|  |  | |||
|  | @ -1,8 +1,10 @@ | |||
| import AnimatedModal from "../utils/animated-modal"; | ||||
| import Ape from "../ape"; | ||||
| 
 | ||||
| import * as TestLogic from "../test/test-logic"; | ||||
| import * as Notifications from "../elements/notifications"; | ||||
| import { CompletedEvent } from "@monkeytype/shared-types"; | ||||
| import { CompletedEvent } from "@monkeytype/contracts/schemas/results"; | ||||
| import { Auth } from "../firebase"; | ||||
| import { syncNotSignedInLastResult } from "../utils/results"; | ||||
| 
 | ||||
| function reset(): void { | ||||
|   (modal.getModal().querySelector(".result") as HTMLElement).innerHTML = ` | ||||
|  | @ -109,30 +111,13 @@ function hide(): void { | |||
|   void modal.hide(); | ||||
| } | ||||
| 
 | ||||
| async function saveLastResult(): Promise<void> { | ||||
|   //safe because we check if it exists before showing the modal
 | ||||
|   const response = await Ape.results.save( | ||||
|     TestLogic.notSignedInLastResult as CompletedEvent | ||||
|   ); | ||||
|   if (response.status !== 200) { | ||||
|     Notifications.add("Failed to save last result: " + response.message, -1); | ||||
|     return; | ||||
|   } | ||||
| 
 | ||||
|   TestLogic.clearNotSignedInResult(); | ||||
|   Notifications.add( | ||||
|     `Last test result saved ${response.data?.isPb ? `(new pb!)` : ""}`, | ||||
|     1 | ||||
|   ); | ||||
| } | ||||
| 
 | ||||
| const modal = new AnimatedModal({ | ||||
|   dialogId: "lastSignedOutResult", | ||||
|   setup: async (modalEl): Promise<void> => { | ||||
|     modalEl | ||||
|       .querySelector("button.save") | ||||
|       ?.addEventListener("click", async (e) => { | ||||
|         void saveLastResult(); | ||||
|         void syncNotSignedInLastResult(Auth?.currentUser?.uid as string); | ||||
|         hide(); | ||||
|       }); | ||||
|     modalEl.querySelector("button.discard")?.addEventListener("click", (e) => { | ||||
|  |  | |||
|  | @ -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"; | ||||
|  |  | |||
|  | @ -27,13 +27,8 @@ import * as Loader from "../elements/loader"; | |||
| import * as ResultBatches from "../elements/result-batches"; | ||||
| import Format from "../utils/format"; | ||||
| import * as TestActivity from "../elements/test-activity"; | ||||
| import { ChartData, Result } from "@monkeytype/shared-types"; | ||||
| import { | ||||
|   Mode, | ||||
|   Mode2, | ||||
|   Mode2Custom, | ||||
|   PersonalBests, | ||||
| } from "@monkeytype/contracts/schemas/shared"; | ||||
| import { ChartData } from "@monkeytype/contracts/schemas/results"; | ||||
| import { Mode, Mode2, Mode2Custom } from "@monkeytype/contracts/schemas/shared"; | ||||
| import { ResultFiltersGroupItem } from "@monkeytype/contracts/schemas/users"; | ||||
| 
 | ||||
| let filterDebug = false; | ||||
|  | @ -45,7 +40,7 @@ export function toggleFilterDebug(): void { | |||
|   } | ||||
| } | ||||
| 
 | ||||
| let filteredResults: Result<Mode>[] = []; | ||||
| let filteredResults: MonkeyTypes.FullResult<Mode>[] = []; | ||||
| let visibleTableLines = 0; | ||||
| 
 | ||||
| function loadMoreLines(lineIndex?: number): void { | ||||
|  | @ -276,7 +271,7 @@ async function fillContent(): Promise<void> { | |||
|   filteredResults = []; | ||||
|   $(".pageAccount .history table tbody").empty(); | ||||
| 
 | ||||
|   DB.getSnapshot()?.results?.forEach((result: Result<Mode>) => { | ||||
|   DB.getSnapshot()?.results?.forEach((result) => { | ||||
|     // totalSeconds += tt;
 | ||||
| 
 | ||||
|     //apply filters
 | ||||
|  | @ -1069,7 +1064,7 @@ function sortAndRefreshHistory( | |||
|     $(headerClass).append('<i class="fas fa-sort-up", aria-hidden="true"></i>'); | ||||
|   } | ||||
| 
 | ||||
|   const temp = []; | ||||
|   const temp: MonkeyTypes.FullResult<Mode>[] = []; | ||||
|   const parsedIndexes: number[] = []; | ||||
| 
 | ||||
|   while (temp.length < filteredResults.length) { | ||||
|  | @ -1093,10 +1088,11 @@ function sortAndRefreshHistory( | |||
|       } | ||||
|     } | ||||
| 
 | ||||
|     //@ts-expect-error
 | ||||
|     temp.push(filteredResults[idx]); | ||||
|     parsedIndexes.push(idx); | ||||
|   } | ||||
|   filteredResults = temp as Result<keyof PersonalBests>[]; | ||||
|   filteredResults = temp; | ||||
| 
 | ||||
|   $(".pageAccount .history table tbody").empty(); | ||||
|   visibleTableLines = 0; | ||||
|  |  | |||
|  | @ -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"; | ||||
| 
 | ||||
|  |  | |||
|  | @ -39,10 +39,9 @@ import type { | |||
|   LabelPosition, | ||||
| } from "chartjs-plugin-annotation"; | ||||
| import Ape from "../ape"; | ||||
| import { Result } from "@monkeytype/shared-types"; | ||||
| import { Mode } from "@monkeytype/contracts/schemas/shared"; | ||||
| import { CompletedEvent } from "@monkeytype/contracts/schemas/results"; | ||||
| 
 | ||||
| let result: Result<Mode>; | ||||
| let result: CompletedEvent; | ||||
| let maxChartVal: number; | ||||
| 
 | ||||
| let useUnsmoothedRaw = false; | ||||
|  | @ -830,7 +829,7 @@ function updateQuoteSource(randomQuote: MonkeyTypes.Quote | null): void { | |||
| } | ||||
| 
 | ||||
| export async function update( | ||||
|   res: Result<Mode>, | ||||
|   res: CompletedEvent, | ||||
|   difficultyFailed: boolean, | ||||
|   failReason: string, | ||||
|   afkDetected: boolean, | ||||
|  |  | |||
|  | @ -58,12 +58,12 @@ import * as KeymapEvent from "../observables/keymap-event"; | |||
| import * as LayoutfluidFunboxTimer from "../test/funbox/layoutfluid-funbox-timer"; | ||||
| import * as ArabicLazyMode from "../states/arabic-lazy-mode"; | ||||
| import Format from "../utils/format"; | ||||
| import { QuoteLength } from "@monkeytype/contracts/schemas/configs"; | ||||
| import { Mode } from "@monkeytype/contracts/schemas/shared"; | ||||
| import { | ||||
|   CompletedEvent, | ||||
|   CustomTextDataWithTextLen, | ||||
| } from "@monkeytype/shared-types"; | ||||
| import { QuoteLength } from "@monkeytype/contracts/schemas/configs"; | ||||
| import { Mode } from "@monkeytype/contracts/schemas/shared"; | ||||
| } from "@monkeytype/contracts/schemas/results"; | ||||
| 
 | ||||
| let failReason = ""; | ||||
| const koInputVisual = document.getElementById("koInputVisual") as HTMLElement; | ||||
|  | @ -77,6 +77,7 @@ export function clearNotSignedInResult(): void { | |||
| export function setNotSignedInUid(uid: string): void { | ||||
|   if (notSignedInLastResult === null) return; | ||||
|   notSignedInLastResult.uid = uid; | ||||
|   //@ts-expect-error
 | ||||
|   delete notSignedInLastResult.hash; | ||||
|   notSignedInLastResult.hash = objectHash(notSignedInLastResult); | ||||
| } | ||||
|  | @ -645,7 +646,9 @@ export async function retrySavingResult(): Promise<void> { | |||
|   await saveResult(completedEvent, true); | ||||
| } | ||||
| 
 | ||||
| function buildCompletedEvent(difficultyFailed: boolean): CompletedEvent { | ||||
| function buildCompletedEvent( | ||||
|   difficultyFailed: boolean | ||||
| ): Omit<CompletedEvent, "hash" | "uid"> { | ||||
|   //build completed event object
 | ||||
|   let stfk = Numbers.roundTo2( | ||||
|     TestInput.keypressTimings.spacing.first - TestStats.start | ||||
|  | @ -737,7 +740,7 @@ function buildCompletedEvent(difficultyFailed: boolean): CompletedEvent { | |||
|   const wpmCons = Numbers.roundTo2(Misc.kogasa(stddev3 / avg3)); | ||||
|   const wpmConsistency = isNaN(wpmCons) ? 0 : wpmCons; | ||||
| 
 | ||||
|   let customText: CustomTextDataWithTextLen | null = null; | ||||
|   let customText: CustomTextDataWithTextLen | undefined = undefined; | ||||
|   if (Config.mode === "custom") { | ||||
|     const temp = CustomText.getData(); | ||||
|     customText = { | ||||
|  | @ -765,7 +768,7 @@ function buildCompletedEvent(difficultyFailed: boolean): CompletedEvent { | |||
| 
 | ||||
|   const quoteLength = TestWords.currentQuote?.group ?? -1; | ||||
| 
 | ||||
|   const completedEvent = { | ||||
|   const completedEvent: Omit<CompletedEvent, "hash" | "uid"> = { | ||||
|     wpm: stats.wpm, | ||||
|     rawWpm: stats.wpmRaw, | ||||
|     charStats: [ | ||||
|  | @ -808,7 +811,7 @@ function buildCompletedEvent(difficultyFailed: boolean): CompletedEvent { | |||
|     testDuration: duration, | ||||
|     afkDuration: afkDuration, | ||||
|     stopOnLetter: Config.stopOnError === "letter", | ||||
|   } as CompletedEvent; | ||||
|   }; | ||||
| 
 | ||||
|   if (completedEvent.mode !== "custom") delete completedEvent.customText; | ||||
|   if (completedEvent.mode !== "quote") delete completedEvent.quoteLength; | ||||
|  | @ -1050,9 +1053,7 @@ export async function finish(difficultyFailed = false): Promise<void> { | |||
| 
 | ||||
|   $("#result .stats .dailyLeaderboard").addClass("hidden"); | ||||
| 
 | ||||
|   TestStats.setLastResult( | ||||
|     JSON.parse(JSON.stringify(completedEvent)) as CompletedEvent | ||||
|   ); | ||||
|   TestStats.setLastResult(JSON.parse(JSON.stringify(completedEvent))); | ||||
| 
 | ||||
|   if (!ConnectionState.get()) { | ||||
|     ConnectionState.showOfflineBanner(); | ||||
|  | @ -1086,18 +1087,17 @@ export async function finish(difficultyFailed = false): Promise<void> { | |||
|   } | ||||
| 
 | ||||
|   // user is logged in
 | ||||
| 
 | ||||
|   TestStats.resetIncomplete(); | ||||
| 
 | ||||
|   completedEvent.uid = Auth?.currentUser?.uid as string; | ||||
|   Result.updateRateQuote(TestWords.currentQuote); | ||||
| 
 | ||||
|   AccountButton.loading(true); | ||||
|   if (!completedEvent.bailedOut) { | ||||
|     completedEvent.challenge = ChallengeContoller.verify(completedEvent); | ||||
|   } | ||||
| 
 | ||||
|   if (completedEvent.challenge === null) delete completedEvent?.challenge; | ||||
|   if (!completedEvent.bailedOut) { | ||||
|     const challenge = ChallengeContoller.verify(completedEvent); | ||||
|     if (challenge !== null) completedEvent.challenge = challenge; | ||||
|   } | ||||
| 
 | ||||
|   completedEvent.hash = objectHash(completedEvent); | ||||
| 
 | ||||
|  | @ -1133,7 +1133,7 @@ async function saveResult( | |||
|     return; | ||||
|   } | ||||
| 
 | ||||
|   const response = await Ape.results.save(completedEvent); | ||||
|   const response = await Ape.results.add({ body: { result: completedEvent } }); | ||||
| 
 | ||||
|   AccountButton.loading(false); | ||||
| 
 | ||||
|  | @ -1147,44 +1147,46 @@ async function saveResult( | |||
|       } | ||||
|     } | ||||
|     console.log("Error saving result", completedEvent); | ||||
|     if (response.message === "Old key data format") { | ||||
|       response.message = | ||||
|     if (response.body.message === "Old key data format") { | ||||
|       response.body.message = | ||||
|         "Old key data format. Please refresh the page to download the new update. If the problem persists, please contact support."; | ||||
|     } | ||||
|     if (/"result\..+" is (not allowed|required)/gi.test(response.message)) { | ||||
|       response.message = | ||||
|     if ( | ||||
|       /"result\..+" is (not allowed|required)/gi.test(response.body.message) | ||||
|     ) { | ||||
|       response.body.message = | ||||
|         "Looks like your result data is using an incorrect schema. Please refresh the page to download the new update. If the problem persists, please contact support."; | ||||
|     } | ||||
|     Notifications.add("Failed to save result: " + response.message, -1); | ||||
|     Notifications.add("Failed to save result: " + response.body.message, -1); | ||||
|     return; | ||||
|   } | ||||
| 
 | ||||
|   const data = response.body.data; | ||||
|   $("#result .stats .tags .editTagsButton").attr( | ||||
|     "data-result-id", | ||||
|     response.data?.insertedId as string //if status is 200 then response.data is not null or undefined
 | ||||
|     data.insertedId | ||||
|   ); | ||||
|   $("#result .stats .tags .editTagsButton").removeClass("invisible"); | ||||
| 
 | ||||
|   if (response?.data?.xp !== undefined) { | ||||
|   if (data.xp !== undefined) { | ||||
|     const snapxp = DB.getSnapshot()?.xp ?? 0; | ||||
|     void AccountButton.updateXpBar( | ||||
|       snapxp, | ||||
|       response.data.xp, | ||||
|       response.data.xpBreakdown | ||||
|     void AccountButton.updateXpBar(snapxp, data.xp, data.xpBreakdown); | ||||
|     DB.addXp(data.xp); | ||||
|   } | ||||
| 
 | ||||
|   if (data.streak !== undefined) { | ||||
|     DB.setStreak(data.streak); | ||||
|   } | ||||
| 
 | ||||
|   if (data.insertedId !== undefined) { | ||||
|     const result: MonkeyTypes.FullResult<Mode> = JSON.parse( | ||||
|       JSON.stringify(completedEvent) | ||||
|     ); | ||||
|     DB.addXp(response.data.xp); | ||||
|   } | ||||
| 
 | ||||
|   if (response?.data?.streak !== undefined) { | ||||
|     DB.setStreak(response.data.streak); | ||||
|   } | ||||
| 
 | ||||
|   if (response?.data?.insertedId !== undefined) { | ||||
|     completedEvent._id = response.data.insertedId; | ||||
|     if (response?.data?.isPb !== undefined && response.data.isPb) { | ||||
|       completedEvent.isPb = true; | ||||
|     result._id = data.insertedId; | ||||
|     if (data.isPb !== undefined && data.isPb) { | ||||
|       result.isPb = true; | ||||
|     } | ||||
|     DB.saveLocalResult(completedEvent); | ||||
|     DB.saveLocalResult(result); | ||||
|     DB.updateLocalStats( | ||||
|       completedEvent.incompleteTests.length + 1, | ||||
|       completedEvent.testDuration + | ||||
|  | @ -1195,7 +1197,7 @@ async function saveResult( | |||
| 
 | ||||
|   void AnalyticsController.log("testCompleted"); | ||||
| 
 | ||||
|   if (response?.data?.isPb !== undefined && response.data.isPb) { | ||||
|   if (data.isPb !== undefined && data.isPb) { | ||||
|     //new pb
 | ||||
|     const localPb = await DB.getLocalPB( | ||||
|       completedEvent.mode, | ||||
|  | @ -1241,7 +1243,7 @@ async function saveResult( | |||
|   //   );
 | ||||
|   // }
 | ||||
| 
 | ||||
|   if (response?.data?.dailyLeaderboardRank === undefined) { | ||||
|   if (data.dailyLeaderboardRank === undefined) { | ||||
|     $("#result .stats .dailyLeaderboard").addClass("hidden"); | ||||
|   } else { | ||||
|     $("#result .stats .dailyLeaderboard") | ||||
|  | @ -1258,7 +1260,7 @@ async function saveResult( | |||
|         500 | ||||
|       ); | ||||
|     $("#result .stats .dailyLeaderboard .bottom").html( | ||||
|       Format.rank(response.data.dailyLeaderboardRank, { fallback: "" }) | ||||
|       Format.rank(data.dailyLeaderboardRank, { fallback: "" }) | ||||
|     ); | ||||
|   } | ||||
| 
 | ||||
|  |  | |||
|  | @ -6,8 +6,10 @@ import * as TestWords from "./test-words"; | |||
| import * as FunboxList from "./funbox/funbox-list"; | ||||
| import * as TestState from "./test-state"; | ||||
| import * as Numbers from "../utils/numbers"; | ||||
| import { IncompleteTest, Result } from "@monkeytype/shared-types"; | ||||
| import { Mode } from "@monkeytype/contracts/schemas/shared"; | ||||
| import { | ||||
|   CompletedEvent, | ||||
|   IncompleteTest, | ||||
| } from "@monkeytype/contracts/schemas/results"; | ||||
| 
 | ||||
| type CharCount = { | ||||
|   spaces: number; | ||||
|  | @ -39,9 +41,9 @@ export let start2: number, end2: number; | |||
| export let start3: number, end3: number; | ||||
| export let lastSecondNotRound = false; | ||||
| 
 | ||||
| export let lastResult: Result<Mode>; | ||||
| export let lastResult: Omit<CompletedEvent, "hash" | "uid">; | ||||
| 
 | ||||
| export function setLastResult(result: Result<Mode>): void { | ||||
| export function setLastResult(result: CompletedEvent): void { | ||||
|   lastResult = result; | ||||
| } | ||||
| 
 | ||||
|  |  | |||
							
								
								
									
										42
									
								
								frontend/src/ts/types/types.d.ts
									
										
									
									
										vendored
									
									
								
							
							
						
						
									
										42
									
								
								frontend/src/ts/types/types.d.ts
									
										
									
									
										vendored
									
									
								
							|  | @ -1,4 +1,8 @@ | |||
| type ConfigValue = import("@monkeytype/contracts/schemas/configs").ConfigValue; | ||||
| type Mode = import("@monkeytype/contracts/schemas/shared").Mode; | ||||
| type Result<M extends Mode> = | ||||
|   import("@monkeytype/contracts/schemas/results").Result<M>; | ||||
| type IncompleteTest = | ||||
|   import("@monkeytype/contracts/schemas/results").IncompleteTest; | ||||
| 
 | ||||
| declare namespace MonkeyTypes { | ||||
|   type PageName = | ||||
|  | @ -237,9 +241,7 @@ declare namespace MonkeyTypes { | |||
|     config: import("@monkeytype/contracts/schemas/configs").Config; | ||||
|     tags: UserTag[]; | ||||
|     presets: SnapshotPreset[]; | ||||
|     results?: import("@monkeytype/shared-types").Result< | ||||
|       import("@monkeytype/contracts/schemas/shared").Mode | ||||
|     >[]; | ||||
|     results?: MonkeyTypes.FullResult<Mode>[]; | ||||
|     xp: number; | ||||
|     testActivity?: ModifiableTestActivityCalendar; | ||||
|     testActivityByYear?: { [key: string]: TestActivityCalendar }; | ||||
|  | @ -466,4 +468,36 @@ declare namespace MonkeyTypes { | |||
|     text: string; | ||||
|     weeks: number; | ||||
|   }; | ||||
| 
 | ||||
|   /** | ||||
|    * Result from the rest api but all omittable default values are set (and non optional) | ||||
|    */ | ||||
|   type FullResult<M extends Mode> = Omit< | ||||
|     Result<M>, | ||||
|     | "restartCount" | ||||
|     | "incompleteTestSeconds" | ||||
|     | "afkDuration" | ||||
|     | "tags" | ||||
|     | "bailedOut" | ||||
|     | "blindMode" | ||||
|     | "lazyMode" | ||||
|     | "funbox" | ||||
|     | "language" | ||||
|     | "difficulty" | ||||
|     | "numbers" | ||||
|     | "punctuation" | ||||
|   > & { | ||||
|     restartCount: number; | ||||
|     incompleteTestSeconds: number; | ||||
|     afkDuration: number; | ||||
|     tags: string[]; | ||||
|     bailedOut: boolean; | ||||
|     blindMode: boolean; | ||||
|     lazyMode: boolean; | ||||
|     funbox: string; | ||||
|     language: string; | ||||
|     difficulty: import("@monkeytype/contracts/schemas/shared").Difficulty; | ||||
|     numbers: boolean; | ||||
|     punctuation: boolean; | ||||
|   }; | ||||
| } | ||||
|  |  | |||
|  | @ -2,7 +2,7 @@ import * as Loader from "../elements/loader"; | |||
| import { envConfig } from "../constants/env-config"; | ||||
| import { lastElementFromArray } from "./arrays"; | ||||
| import * as JSONData from "./json-data"; | ||||
| import { CustomTextData, Result } from "@monkeytype/shared-types"; | ||||
| import { CustomTextData } from "@monkeytype/shared-types"; | ||||
| import { Config } from "@monkeytype/contracts/schemas/configs"; | ||||
| import { | ||||
|   Mode, | ||||
|  | @ -406,7 +406,9 @@ export function getMode2<M extends keyof PersonalBests>( | |||
|   return retVal as Mode2<M>; | ||||
| } | ||||
| 
 | ||||
| export async function downloadResultsCSV(array: Result<Mode>[]): Promise<void> { | ||||
| export async function downloadResultsCSV( | ||||
|   array: MonkeyTypes.FullResult<Mode>[] | ||||
| ): Promise<void> { | ||||
|   Loader.show(); | ||||
|   const csvString = [ | ||||
|     [ | ||||
|  | @ -435,7 +437,7 @@ export async function downloadResultsCSV(array: Result<Mode>[]): Promise<void> { | |||
|       "tags", | ||||
|       "timestamp", | ||||
|     ], | ||||
|     ...array.map((item: Result<Mode>) => [ | ||||
|     ...array.map((item: MonkeyTypes.FullResult<Mode>) => [ | ||||
|       item._id, | ||||
|       item.isPb, | ||||
|       item.wpm, | ||||
|  |  | |||
							
								
								
									
										39
									
								
								frontend/src/ts/utils/results.ts
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										39
									
								
								frontend/src/ts/utils/results.ts
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,39 @@ | |||
| import Ape from "../ape"; | ||||
| import * as Notifications from "../elements/notifications"; | ||||
| import * as DB from "../db"; | ||||
| import * as TestLogic from "../test/test-logic"; | ||||
| 
 | ||||
| export async function syncNotSignedInLastResult(uid: string): Promise<void> { | ||||
|   const notSignedInLastResult = TestLogic.notSignedInLastResult; | ||||
|   if (notSignedInLastResult === null) return; | ||||
|   TestLogic.setNotSignedInUid(uid); | ||||
| 
 | ||||
|   const response = await Ape.results.add({ | ||||
|     body: { result: notSignedInLastResult }, | ||||
|   }); | ||||
|   if (response.status !== 200) { | ||||
|     Notifications.add( | ||||
|       "Failed to save last result: " + response.body.message, | ||||
|       -1 | ||||
|     ); | ||||
|     return; | ||||
|   } | ||||
| 
 | ||||
|   const result: MonkeyTypes.FullResult<Mode> = JSON.parse( | ||||
|     JSON.stringify(notSignedInLastResult) | ||||
|   ); | ||||
|   result._id = response.body.data.insertedId; | ||||
|   if (response.body.data.isPb) { | ||||
|     result.isPb = true; | ||||
|   } | ||||
|   DB.saveLocalResult(result); | ||||
|   DB.updateLocalStats( | ||||
|     1, | ||||
|     result.testDuration + result.incompleteTestSeconds - result.afkDuration | ||||
|   ); | ||||
|   TestLogic.clearNotSignedInResult(); | ||||
|   Notifications.add( | ||||
|     `Last test result saved ${response.body.data.isPb ? `(new pb!)` : ""}`, | ||||
|     1 | ||||
|   ); | ||||
| } | ||||
|  | @ -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": { | ||||
|  |  | |||
|  | @ -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": { | ||||
|  |  | |||
|  | @ -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, | ||||
| }); | ||||
|  |  | |||
							
								
								
									
										150
									
								
								packages/contracts/src/results.ts
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										150
									
								
								packages/contracts/src/results.ts
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,150 @@ | |||
| import { initContract } from "@ts-rest/core"; | ||||
| import { z } from "zod"; | ||||
| import { | ||||
|   CommonResponses, | ||||
|   EndpointMetadata, | ||||
|   MonkeyResponseSchema, | ||||
|   responseWithData, | ||||
| } from "./schemas/api"; | ||||
| import { | ||||
|   CompletedEventSchema, | ||||
|   PostResultResponseSchema, | ||||
|   ResultSchema, | ||||
| } from "./schemas/results"; | ||||
| import { IdSchema } from "./schemas/util"; | ||||
| 
 | ||||
| export const GetResultsQuerySchema = z.object({ | ||||
|   onOrAfterTimestamp: z | ||||
|     .number() | ||||
|     .int() | ||||
|     .min(1589428800000) | ||||
|     .optional() | ||||
|     .describe( | ||||
|       "Timestamp of the earliest result to fetch. If omitted the most recent results are fetched." | ||||
|     ), | ||||
|   offset: z | ||||
|     .number() | ||||
|     .int() | ||||
|     .nonnegative() | ||||
|     .optional() | ||||
|     .describe("Offset of the item at which to begin the response."), | ||||
|   limit: z | ||||
|     .number() | ||||
|     .int() | ||||
|     .nonnegative() | ||||
|     .max(1000) | ||||
|     .optional() | ||||
|     .describe("Limit results to the given amount."), | ||||
| }); | ||||
| export type GetResultsQuery = z.infer<typeof GetResultsQuerySchema>; | ||||
| 
 | ||||
| export const GetResultsResponseSchema = responseWithData(z.array(ResultSchema)); | ||||
| export type GetResultsResponse = z.infer<typeof GetResultsResponseSchema>; | ||||
| 
 | ||||
| export const AddResultRequestSchema = z.object({ | ||||
|   result: CompletedEventSchema, | ||||
| }); | ||||
| export type AddResultRequest = z.infer<typeof AddResultRequestSchema>; | ||||
| 
 | ||||
| export const AddResultResponseSchema = responseWithData( | ||||
|   PostResultResponseSchema | ||||
| ); | ||||
| export type AddResultResponse = z.infer<typeof AddResultResponseSchema>; | ||||
| 
 | ||||
| export const UpdateResultTagsRequestSchema = z.object({ | ||||
|   tagIds: z.array(IdSchema), | ||||
|   resultId: IdSchema, | ||||
| }); | ||||
| export type UpdateResultTagsRequest = z.infer< | ||||
|   typeof UpdateResultTagsRequestSchema | ||||
| >; | ||||
| export const UpdateResultTagsResponseSchema = responseWithData( | ||||
|   z.object({ | ||||
|     tagPbs: z.array(IdSchema), | ||||
|   }) | ||||
| ); | ||||
| export type UpdateResultTagsResponse = z.infer< | ||||
|   typeof UpdateResultTagsResponseSchema | ||||
| >; | ||||
| 
 | ||||
| export const GetLastResultResponseSchema = responseWithData(ResultSchema); | ||||
| export type GetLastResultResponse = z.infer<typeof GetLastResultResponseSchema>; | ||||
| 
 | ||||
| const c = initContract(); | ||||
| export const resultsContract = c.router( | ||||
|   { | ||||
|     get: { | ||||
|       summary: "get results", | ||||
|       description: | ||||
|         "Gets up to 1000 results (endpoint limited to 30 requests per day for ape keys)", | ||||
|       method: "GET", | ||||
|       path: "", | ||||
|       query: GetResultsQuerySchema.strict(), | ||||
|       responses: { | ||||
|         200: GetResultsResponseSchema, | ||||
|       }, | ||||
|       metadata: { | ||||
|         authenticationOptions: { | ||||
|           acceptApeKeys: true, | ||||
|         }, | ||||
|       } as EndpointMetadata, | ||||
|     }, | ||||
|     add: { | ||||
|       summary: "add result", | ||||
|       description: "Add a test result for the current user", | ||||
|       method: "POST", | ||||
|       path: "", | ||||
|       body: AddResultRequestSchema.strict(), | ||||
|       responses: { | ||||
|         200: AddResultResponseSchema, | ||||
|       }, | ||||
|     }, | ||||
|     updateTags: { | ||||
|       summary: "update result tags", | ||||
|       description: "Labels a result with the specified tags", | ||||
|       method: "PATCH", | ||||
|       path: "/tags", | ||||
|       body: UpdateResultTagsRequestSchema.strict(), | ||||
|       responses: { | ||||
|         200: UpdateResultTagsResponseSchema, | ||||
|       }, | ||||
|     }, | ||||
|     deleteAll: { | ||||
|       summary: "delete all results", | ||||
|       description: "Delete all results for the current user", | ||||
|       method: "DELETE", | ||||
|       path: "", | ||||
|       body: c.noBody(), | ||||
|       responses: { | ||||
|         200: MonkeyResponseSchema, | ||||
|       }, | ||||
|       metadata: { | ||||
|         authenticationOptions: { | ||||
|           requireFreshToken: true, | ||||
|         }, | ||||
|       } as EndpointMetadata, | ||||
|     }, | ||||
|     getLast: { | ||||
|       summary: "get last result", | ||||
|       description: "Gets a user's last saved result", | ||||
|       path: "/last", | ||||
|       method: "GET", | ||||
|       responses: { | ||||
|         200: GetLastResultResponseSchema, | ||||
|       }, | ||||
|       metadata: { | ||||
|         authenticationOptions: { | ||||
|           acceptApeKeys: true, | ||||
|         }, | ||||
|       } as EndpointMetadata, | ||||
|     }, | ||||
|   }, | ||||
|   { | ||||
|     pathPrefix: "/results", | ||||
|     strictStatusCodes: true, | ||||
|     metadata: { | ||||
|       openApiTags: "results", | ||||
|     } as EndpointMetadata, | ||||
|     commonResponses: CommonResponses, | ||||
|   } | ||||
| ); | ||||
|  | @ -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. */ | ||||
|  |  | |||
							
								
								
									
										150
									
								
								packages/contracts/src/schemas/results.ts
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										150
									
								
								packages/contracts/src/schemas/results.ts
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,150 @@ | |||
| import { z } from "zod"; | ||||
| import { | ||||
|   CustomTextLimitModeSchema, | ||||
|   CustomTextModeSchema, | ||||
|   IdSchema, | ||||
|   PercentageSchema, | ||||
|   token, | ||||
|   WpmSchema, | ||||
|   LanguageSchema, | ||||
| } from "./util"; | ||||
| import { Mode, Mode2, Mode2Schema, ModeSchema } from "./shared"; | ||||
| import { DifficultySchema, FunboxSchema } from "./configs"; | ||||
| 
 | ||||
| export const IncompleteTestSchema = z.object({ | ||||
|   acc: PercentageSchema, | ||||
|   seconds: z.number().nonnegative(), | ||||
| }); | ||||
| export type IncompleteTest = z.infer<typeof IncompleteTestSchema>; | ||||
| 
 | ||||
| export const ChartDataSchema = z.object({ | ||||
|   wpm: z.array(z.number().nonnegative()).max(122), | ||||
|   raw: z.array(z.number().nonnegative()).max(122), | ||||
|   err: z.array(z.number().nonnegative()).max(122), | ||||
| }); | ||||
| export type ChartData = z.infer<typeof ChartDataSchema>; | ||||
| 
 | ||||
| export const KeyStatsSchema = z.object({ | ||||
|   average: z.number().nonnegative(), | ||||
|   sd: z.number().nonnegative(), | ||||
| }); | ||||
| export type KeyStats = z.infer<typeof KeyStatsSchema>; | ||||
| 
 | ||||
| export const CustomTextSchema = z.object({ | ||||
|   textLen: z.number().int().nonnegative(), | ||||
|   mode: CustomTextModeSchema, | ||||
|   pipeDelimiter: z.boolean(), | ||||
|   limit: z.object({ | ||||
|     mode: CustomTextLimitModeSchema, | ||||
|     value: z.number().nonnegative(), | ||||
|   }), | ||||
| }); | ||||
| export type CustomTextDataWithTextLen = z.infer<typeof CustomTextSchema>; | ||||
| 
 | ||||
| export const CharStatsSchema = z.tuple([ | ||||
|   z.number().int().nonnegative(), | ||||
|   z.number().int().nonnegative(), | ||||
|   z.number().int().nonnegative(), | ||||
|   z.number().int().nonnegative(), | ||||
| ]); | ||||
| export type CharStats = z.infer<typeof CharStatsSchema>; | ||||
| 
 | ||||
| const ResultBaseSchema = z.object({ | ||||
|   wpm: WpmSchema, | ||||
|   rawWpm: WpmSchema, | ||||
|   charStats: CharStatsSchema, | ||||
|   acc: PercentageSchema.min(75), | ||||
|   mode: ModeSchema, | ||||
|   mode2: Mode2Schema, | ||||
|   quoteLength: z.number().int().nonnegative().max(3).optional(), | ||||
|   timestamp: z.number().int().nonnegative(), | ||||
|   testDuration: z.number().min(1), | ||||
|   consistency: PercentageSchema, | ||||
|   keyConsistency: PercentageSchema, | ||||
|   chartData: ChartDataSchema.or(z.literal("toolong")), | ||||
|   uid: IdSchema, | ||||
| }); | ||||
| 
 | ||||
| //required on POST but optional in the database and might be removed to save space
 | ||||
| const ResultOmittableDefaultPropertiesSchema = z.object({ | ||||
|   restartCount: z.number().int().nonnegative(), | ||||
|   incompleteTestSeconds: z.number().nonnegative(), | ||||
|   afkDuration: z.number().nonnegative(), | ||||
|   tags: z.array(IdSchema), | ||||
|   bailedOut: z.boolean(), | ||||
|   blindMode: z.boolean(), | ||||
|   lazyMode: z.boolean(), | ||||
|   funbox: FunboxSchema, | ||||
|   language: LanguageSchema, | ||||
|   difficulty: DifficultySchema, | ||||
|   numbers: z.boolean(), | ||||
|   punctuation: z.boolean(), | ||||
| }); | ||||
| 
 | ||||
| export const ResultSchema = ResultBaseSchema.merge( | ||||
|   ResultOmittableDefaultPropertiesSchema.partial() //missing on GET if the values are the default values
 | ||||
| ).extend({ | ||||
|   _id: IdSchema, | ||||
|   keySpacingStats: KeyStatsSchema.optional(), | ||||
|   keyDurationStats: KeyStatsSchema.optional(), | ||||
|   name: z.string(), | ||||
|   isPb: z.boolean().optional(), //true or undefined
 | ||||
| }); | ||||
| 
 | ||||
| export type Result<M extends Mode> = Omit< | ||||
|   z.infer<typeof ResultSchema>, | ||||
|   "mode" | "mode2" | ||||
| > & { | ||||
|   mode: M; | ||||
|   mode2: Mode2<M>; | ||||
| }; | ||||
| 
 | ||||
| export const CompletedEventSchema = ResultBaseSchema.merge( | ||||
|   ResultOmittableDefaultPropertiesSchema //mandatory on POST
 | ||||
| ) | ||||
|   .extend({ | ||||
|     charTotal: z.number().int().nonnegative(), | ||||
|     challenge: token().max(100).optional(), | ||||
|     customText: CustomTextSchema.optional(), | ||||
|     hash: token().max(100), | ||||
|     keyDuration: z.array(z.number().nonnegative()).or(z.literal("toolong")), | ||||
|     keySpacing: z.array(z.number().nonnegative()).or(z.literal("toolong")), | ||||
|     keyOverlap: z.number().nonnegative(), | ||||
|     lastKeyToEnd: z.number().nonnegative(), | ||||
|     startToFirstKey: z.number().nonnegative(), | ||||
|     wpmConsistency: PercentageSchema, | ||||
|     stopOnLetter: z.boolean(), | ||||
|     incompleteTests: z.array(IncompleteTestSchema), | ||||
|   }) | ||||
|   .strict(); | ||||
| 
 | ||||
| export type CompletedEvent = z.infer<typeof CompletedEventSchema>; | ||||
| 
 | ||||
| export const XpBreakdownSchema = z.object({ | ||||
|   base: z.number().int().optional(), | ||||
|   fullAccuracy: z.number().int().optional(), | ||||
|   quote: z.number().int().optional(), | ||||
|   corrected: z.number().int().optional(), | ||||
|   punctuation: z.number().int().optional(), | ||||
|   numbers: z.number().int().optional(), | ||||
|   funbox: z.number().int().optional(), | ||||
|   streak: z.number().int().optional(), | ||||
|   incomplete: z.number().int().optional(), | ||||
|   daily: z.number().int().optional(), | ||||
|   accPenalty: z.number().int().optional(), | ||||
|   configMultiplier: z.number().int().optional(), | ||||
| }); | ||||
| export type XpBreakdown = z.infer<typeof XpBreakdownSchema>; | ||||
| 
 | ||||
| export const PostResultResponseSchema = z.object({ | ||||
|   insertedId: IdSchema, | ||||
|   isPb: z.boolean(), | ||||
|   tagPbs: z.array(IdSchema), | ||||
|   dailyLeaderboardRank: z.number().int().nonnegative().optional(), | ||||
|   weeklyXpLeaderboardRank: z.number().int().nonnegative().optional(), | ||||
|   xp: z.number().int().nonnegative(), | ||||
|   dailyXpBonus: z.boolean(), | ||||
|   xpBreakdown: XpBreakdownSchema, | ||||
|   streak: z.number().int().nonnegative(), | ||||
| }); | ||||
| export type PostResultResponse = z.infer<typeof PostResultResponseSchema>; | ||||
|  | @ -22,3 +22,15 @@ export const LanguageSchema = z | |||
|   .max(50) | ||||
|   .regex(/^[a-zA-Z0-9_+]+$/, "Can only contain letters [a-zA-Z0-9_+]"); | ||||
| export type Language = z.infer<typeof LanguageSchema>; | ||||
| 
 | ||||
| export const PercentageSchema = z.number().nonnegative().max(100); | ||||
| export type Percentage = z.infer<typeof PercentageSchema>; | ||||
| 
 | ||||
| export const WpmSchema = z.number().nonnegative().max(420); | ||||
| export type Wpm = z.infer<typeof WpmSchema>; | ||||
| 
 | ||||
| export const CustomTextModeSchema = z.enum(["repeat", "random", "shuffle"]); | ||||
| export type CustomTextMode = z.infer<typeof CustomTextModeSchema>; | ||||
| 
 | ||||
| export const CustomTextLimitModeSchema = z.enum(["word", "time", "section"]); | ||||
| export type CustomTextLimitMode = z.infer<typeof CustomTextLimitModeSchema>; | ||||
|  |  | |||
|  | @ -1,7 +1,3 @@ | |||
| type Difficulty = import("@monkeytype/contracts/schemas/configs").Difficulty; | ||||
| type Mode = import("@monkeytype/contracts/schemas/shared").Mode; | ||||
| type Mode2<M extends Mode> = | ||||
|   import("@monkeytype/contracts/schemas/shared").Mode2<M>; | ||||
| type PersonalBest = import("@monkeytype/contracts/schemas/shared").PersonalBest; | ||||
| type PersonalBests = | ||||
|   import("@monkeytype/contracts/schemas/shared").PersonalBests; | ||||
|  | @ -117,139 +113,16 @@ export type Configuration = { | |||
|   }; | ||||
| }; | ||||
| 
 | ||||
| export type IncompleteTest = { | ||||
|   acc: number; | ||||
|   seconds: number; | ||||
| }; | ||||
| 
 | ||||
| export type ChartData = { | ||||
|   wpm: number[]; | ||||
|   raw: number[]; | ||||
|   err: number[]; | ||||
| }; | ||||
| 
 | ||||
| export type KeyStats = { | ||||
|   average: number; | ||||
|   sd: number; | ||||
| }; | ||||
| 
 | ||||
| export type Result<M extends Mode> = { | ||||
|   _id: string; | ||||
|   wpm: number; | ||||
|   rawWpm: number; | ||||
|   charStats: [number, number, number, number]; | ||||
|   acc: number; | ||||
|   mode: M; | ||||
|   mode2: Mode2<M>; | ||||
|   quoteLength?: number; | ||||
|   timestamp: number; | ||||
|   restartCount: number; | ||||
|   incompleteTestSeconds: number; | ||||
|   incompleteTests: IncompleteTest[]; | ||||
|   testDuration: number; | ||||
|   afkDuration: number; | ||||
|   tags: string[]; | ||||
|   consistency: number; | ||||
|   keyConsistency: number; | ||||
|   chartData: ChartData | "toolong"; | ||||
|   uid: string; | ||||
|   keySpacingStats?: KeyStats; | ||||
|   keyDurationStats?: KeyStats; | ||||
|   isPb: boolean; | ||||
|   bailedOut: boolean; | ||||
|   blindMode: boolean; | ||||
|   lazyMode: boolean; | ||||
|   difficulty: Difficulty; | ||||
|   funbox: string; | ||||
|   language: string; | ||||
|   numbers: boolean; | ||||
|   punctuation: boolean; | ||||
| }; | ||||
| 
 | ||||
| export type DBResult<T extends Mode> = Omit< | ||||
|   Result<T>, | ||||
|   | "bailedOut" | ||||
|   | "blindMode" | ||||
|   | "lazyMode" | ||||
|   | "difficulty" | ||||
|   | "funbox" | ||||
|   | "language" | ||||
|   | "numbers" | ||||
|   | "punctuation" | ||||
|   | "restartCount" | ||||
|   | "incompleteTestSeconds" | ||||
|   | "afkDuration" | ||||
|   | "tags" | ||||
|   | "incompleteTests" | ||||
|   | "customText" | ||||
|   | "quoteLength" | ||||
|   | "isPb" | ||||
| > & { | ||||
|   correctChars?: number; // --------------
 | ||||
|   incorrectChars?: number; // legacy results
 | ||||
|   // --------------
 | ||||
|   name: string; | ||||
|   // -------------- fields that might be removed to save space
 | ||||
|   bailedOut?: boolean; | ||||
|   blindMode?: boolean; | ||||
|   lazyMode?: boolean; | ||||
|   difficulty?: Difficulty; | ||||
|   funbox?: string; | ||||
|   language?: string; | ||||
|   numbers?: boolean; | ||||
|   punctuation?: boolean; | ||||
|   restartCount?: number; | ||||
|   incompleteTestSeconds?: number; | ||||
|   afkDuration?: number; | ||||
|   tags?: string[]; | ||||
|   customText?: CustomTextDataWithTextLen; | ||||
|   quoteLength?: number; | ||||
|   isPb?: boolean; | ||||
| }; | ||||
| 
 | ||||
| export type CompletedEvent = Result<Mode> & { | ||||
|   keySpacing: number[] | "toolong"; | ||||
|   keyDuration: number[] | "toolong"; | ||||
|   customText?: CustomTextDataWithTextLen; | ||||
|   wpmConsistency: number; | ||||
|   challenge?: string | null; | ||||
|   keyOverlap: number; | ||||
|   lastKeyToEnd: number; | ||||
|   startToFirstKey: number; | ||||
|   charTotal: number; | ||||
|   stringified?: string; | ||||
|   hash?: string; | ||||
|   stopOnLetter: boolean; | ||||
| }; | ||||
| 
 | ||||
| export type CustomTextMode = "repeat" | "random" | "shuffle"; | ||||
| export type CustomTextLimitMode = "word" | "time" | "section"; | ||||
| export type CustomTextLimit = { | ||||
|   value: number; | ||||
|   mode: CustomTextLimitMode; | ||||
|   mode: import("@monkeytype/contracts/schemas/util").CustomTextLimitMode; | ||||
| }; | ||||
| 
 | ||||
| export type CustomTextData = { | ||||
| export type CustomTextData = Omit< | ||||
|   import("@monkeytype/contracts/schemas/results").CustomTextDataWithTextLen, | ||||
|   "textLen" | ||||
| > & { | ||||
|   text: string[]; | ||||
|   mode: CustomTextMode; | ||||
|   limit: CustomTextLimit; | ||||
|   pipeDelimiter: boolean; | ||||
| }; | ||||
| 
 | ||||
| export type CustomTextDataWithTextLen = Omit<CustomTextData, "text"> & { | ||||
|   textLen: number; | ||||
| }; | ||||
| 
 | ||||
| export type PostResultResponse = { | ||||
|   isPb: boolean; | ||||
|   tagPbs: string[]; | ||||
|   insertedId: string; | ||||
|   dailyLeaderboardRank?: number; | ||||
|   weeklyXpLeaderboardRank?: number; | ||||
|   xp: number; | ||||
|   dailyXpBonus: boolean; | ||||
|   xpBreakdown: Record<string, number>; | ||||
|   streak: number; | ||||
| }; | ||||
| 
 | ||||
| export type UserStreak = { | ||||
|  |  | |||
							
								
								
									
										180
									
								
								pnpm-lock.yaml
									
										
									
										generated
									
									
									
								
							
							
						
						
									
										180
									
								
								pnpm-lock.yaml
									
										
									
										generated
									
									
									
								
							|  | @ -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 | ||||
|  |  | |||
		Loading…
	
	Add table
		
		Reference in a new issue