diff --git a/backend/__tests__/api/controllers/dev.spec.ts b/backend/__tests__/api/controllers/dev.spec.ts index 0ac16cab2..f510a65a8 100644 --- a/backend/__tests__/api/controllers/dev.spec.ts +++ b/backend/__tests__/api/controllers/dev.spec.ts @@ -1,13 +1,24 @@ import request from "supertest"; import app from "../../../src/app"; - +import * as AuthUtils from "../../../src/utils/auth"; import { ObjectId } from "mongodb"; import * as Misc from "../../../src/utils/misc"; +import { DecodedIdToken } from "firebase-admin/auth"; const uid = new ObjectId().toHexString(); +const mockDecodedToken = { + uid, + email: "newuser@mail.com", + iat: 0, +} as DecodedIdToken; const mockApp = request(app); describe("DevController", () => { + const verifyIdTokenMock = vi.spyOn(AuthUtils, "verifyIdToken"); + beforeEach(() => { + verifyIdTokenMock.mockReset().mockResolvedValue(mockDecodedToken); + }); + describe("generate testData", () => { const isDevEnvironmentMock = vi.spyOn(Misc, "isDevEnvironment"); @@ -22,6 +33,7 @@ describe("DevController", () => { //WHEN const { body } = await mockApp .post("/dev/generateData") + .set("Authorization", "Bearer 123456789") .send({ username: "test" }) .expect(503); //THEN diff --git a/backend/__tests__/api/controllers/leaderboard.spec.ts b/backend/__tests__/api/controllers/leaderboard.spec.ts index c2f7bef45..fee9a51a7 100644 --- a/backend/__tests__/api/controllers/leaderboard.spec.ts +++ b/backend/__tests__/api/controllers/leaderboard.spec.ts @@ -155,7 +155,7 @@ describe("Loaderboard Controller", () => { validationErrors: [ '"language" Required', '"mode" Required', - '"mode2" Needs to be either a number, "zen" or "custom."', + '"mode2" Needs to be either a number, "zen" or "custom".', ], }); }); @@ -320,7 +320,7 @@ describe("Loaderboard Controller", () => { validationErrors: [ '"language" Required', '"mode" Required', - '"mode2" Needs to be either a number, "zen" or "custom."', + '"mode2" Needs to be either a number, "zen" or "custom".', ], }); }); @@ -591,7 +591,7 @@ describe("Loaderboard Controller", () => { validationErrors: [ '"language" Required', '"mode" Required', - '"mode2" Needs to be either a number, "zen" or "custom."', + '"mode2" Needs to be either a number, "zen" or "custom".', ], }); }); @@ -768,7 +768,7 @@ describe("Loaderboard Controller", () => { validationErrors: [ '"language" Required', '"mode" Required', - '"mode2" Needs to be either a number, "zen" or "custom."', + '"mode2" Needs to be either a number, "zen" or "custom".', ], }); }); diff --git a/backend/__tests__/api/controllers/public.spec.ts b/backend/__tests__/api/controllers/public.spec.ts index 9d9960e05..e5e348d72 100644 --- a/backend/__tests__/api/controllers/public.spec.ts +++ b/backend/__tests__/api/controllers/public.spec.ts @@ -72,7 +72,7 @@ describe("PublicController", () => { validationErrors: [ '"language" Required', '"mode" Required', - '"mode2" Needs to be either a number, "zen" or "custom."', + '"mode2" Needs to be either a number, "zen" or "custom".', ], }); }); diff --git a/backend/__tests__/api/controllers/user.spec.ts b/backend/__tests__/api/controllers/user.spec.ts index 25108c8e9..0b1ab1710 100644 --- a/backend/__tests__/api/controllers/user.spec.ts +++ b/backend/__tests__/api/controllers/user.spec.ts @@ -3,24 +3,38 @@ import app from "../../../src/app"; import * as Configuration from "../../../src/init/configuration"; import { generateCurrentTestActivity } from "../../../src/api/controllers/user"; import * as UserDal from "../../../src/dal/user"; -import _ from "lodash"; -import { DecodedIdToken } from "firebase-admin/lib/auth/token-verifier"; +import { DecodedIdToken } from "firebase-admin/auth"; import * as AuthUtils from "../../../src/utils/auth"; import * as BlocklistDal from "../../../src/dal/blocklist"; import * as ApeKeys from "../../../src/dal/ape-keys"; import * as PresetDal from "../../../src/dal/preset"; import * as ConfigDal from "../../../src/dal/config"; import * as ResultDal from "../../../src/dal/result"; +import * as ReportDal from "../../../src/dal/report"; import * as DailyLeaderboards from "../../../src/utils/daily-leaderboards"; +import * as LeaderboardDal from "../../../src/dal/leaderboards"; import GeorgeQueue from "../../../src/queues/george-queue"; -import * as AdminUuids from "../../../src/dal/admin-uids"; import * as DiscordUtils from "../../../src/utils/discord"; +import * as Captcha from "../../../src/utils/captcha"; +import * as FirebaseAdmin from "../../../src/init/firebase-admin"; +import { FirebaseError } from "firebase-admin"; +import * as ApeKeysDal from "../../../src/dal/ape-keys"; +import * as LogDal from "../../../src/dal/logs"; +import { ObjectId } from "mongodb"; +import { PersonalBest } from "@monkeytype/contracts/schemas/shared"; +import { pb } from "../../dal/leaderboards.spec"; +import { mockAuthenticateWithApeKey } from "../../__testData__/auth"; +import { LeaderboardRank } from "@monkeytype/contracts/schemas/leaderboards"; +import { randomUUID } from "node:crypto"; +import _ from "lodash"; +import { MonkeyMail, UserStreak } from "@monkeytype/contracts/schemas/users"; const mockApp = request(app); const configuration = Configuration.getCachedConfiguration(); +const uid = "123456789"; const mockDecodedToken: DecodedIdToken = { - uid: "123456789", + uid, email: "newuser@mail.com", iat: Date.now(), } as DecodedIdToken; @@ -34,12 +48,7 @@ describe("user controller test", () => { await enableSignup(true); }); it("should be able to check name, sign up, and get user data", async () => { - await mockApp - .get("/users/checkName/NewUser") - .set({ - Accept: "application/json", - }) - .expect(200); + await mockApp.get("/users/checkName/NewUser").expect(200); const newUser = { name: "NewUser", @@ -52,18 +61,12 @@ describe("user controller test", () => { .post("/users/signup") .set("authorization", "Uid 123456789|newuser@mail.com") .send(newUser) - .set({ - Accept: "application/json", - }) .expect(200); const response = await mockApp .get("/users") .set("authorization", "Uid 123456789") .send() - .set({ - Accept: "application/json", - }) .expect(200); const { @@ -74,18 +77,14 @@ describe("user controller test", () => { expect(userData.email).toBe(newUser.email); expect(userData.uid).toBe(newUser.uid); - await mockApp - .get("/users/checkName/NewUser") - .set({ - Accept: "application/json", - }) - .expect(409); + await mockApp.get("/users/checkName/NewUser").expect(409); }); }); describe("user signup", () => { const blocklistContainsMock = vi.spyOn(BlocklistDal, "contains"); const firebaseDeleteUserMock = vi.spyOn(AuthUtils, "deleteUser"); const usernameAvailableMock = vi.spyOn(UserDal, "isNameAvailable"); + const verifyCaptchaMock = vi.spyOn(Captcha, "verify"); beforeEach(async () => { await enableSignup(true); usernameAvailableMock.mockResolvedValue(true); @@ -98,7 +97,7 @@ describe("user controller test", () => { ].forEach((it) => it.mockReset()); }); - it("should not create user if blocklisted", async () => { + it("should fail if blocklisted", async () => { //GIVEN blocklistContainsMock.mockResolvedValue(true); firebaseDeleteUserMock.mockResolvedValue(); @@ -115,9 +114,6 @@ describe("user controller test", () => { .post("/users/signup") .set("authorization", "Uid 123456789|newuser@mail.com") .send(newUser) - .set({ - Accept: "application/json", - }) .expect(409); //THEN @@ -129,9 +125,10 @@ describe("user controller test", () => { //user will be created in firebase from the frontend, make sure we remove it expect(firebaseDeleteUserMock).toHaveBeenCalledWith("123456789"); + expect(verifyCaptchaMock).toHaveBeenCalledWith("captcha"); }); - it("should not create user domain is blacklisted", async () => { + it("should fail if domain is blacklisted", async () => { for (const domain of ["tidal.lol", "selfbot.cc"]) { //GIVEN firebaseDeleteUserMock.mockResolvedValue(); @@ -161,7 +158,7 @@ describe("user controller test", () => { } }); - it("should not create user if username is taken", async () => { + it("should fail if username is taken", async () => { //GIVEN usernameAvailableMock.mockResolvedValue(false); firebaseDeleteUserMock.mockResolvedValue(); @@ -178,9 +175,6 @@ describe("user controller test", () => { .post("/users/signup") .set("authorization", "Uid 123456789|newuser@mail.com") .send(newUser) - .set({ - Accept: "application/json", - }) .expect(409); //THEN @@ -193,6 +187,277 @@ describe("user controller test", () => { //user will be created in firebase from the frontend, make sure we remove it expect(firebaseDeleteUserMock).toHaveBeenCalledWith("123456789"); }); + it("should fail if capture is invalid", async () => { + //GIVEN + verifyCaptchaMock.mockResolvedValue(false); + + const newUser = { + name: "NewUser", + uid: "123456789", + email: "newuser@mail.com", + captcha: "captcha", + }; + + //WHEN + const { body } = await mockApp + .post("/users/signup") + .set("authorization", "Uid 123456789|newuser@mail.com") + .send(newUser) + .expect(422); + + //THEN + expect(body.message).toEqual("Captcha challenge failed"); + }); + it("should fail if username too long", async () => { + //GIVEN + const newUser = { + uid: "123456789", + email: "newuser@mail.com", + captcha: "captcha", + }; + + //WHEN + const { body } = await mockApp + .post("/users/signup") + .set("authorization", "Uid 123456789|newuser@mail.com") + .send({ ...newUser, name: new Array(17).fill("x").join("") }) + .expect(422); + + //THEN + expect(body).toEqual({ + message: "Invalid request data schema", + validationErrors: [ + '"name" String must contain at most 16 character(s)', + ], + }); + }); + it("should fail if username contains profanity", async () => { + //GIVEN + const newUser = { + uid: "123456789", + email: "newuser@mail.com", + captcha: "captcha", + }; + + //WHEN + const { body } = await mockApp + .post("/users/signup") + .set("authorization", "Uid 123456789|newuser@mail.com") + .send({ ...newUser, name: "miodec" }) + .expect(422); + + //THEN + expect(body).toEqual({ + message: "Invalid request data schema", + validationErrors: [ + '"name" Profanity detected. Please remove it. If you believe this is a mistake, please contact us. (miodec)', + ], + }); + }); + }); + describe("sendVerificationEmail", () => { + const adminGetUserMock = vi.fn(); + const adminGenerateVerificationLinkMock = vi.fn(); + const getPartialUserMock = vi.spyOn(UserDal, "getPartialUser"); + + vi.spyOn(FirebaseAdmin, "default").mockReturnValue({ + auth: () => ({ + getUser: adminGetUserMock, + generateEmailVerificationLink: adminGenerateVerificationLinkMock, + }), + } as any); + + vi.mock("../../../src/queues/email-queue", () => ({ + __esModule: true, + default: { sendVerificationEmail: vi.fn() }, + })); + + beforeEach(() => { + adminGetUserMock.mockReset().mockResolvedValue({ emailVerified: false }); + getPartialUserMock.mockReset().mockResolvedValue({ + uid, + name: "Bob", + email: "newuser@mail.com", + } as any); + }); + + it("should send verfification email", async () => { + //GIVEN + + //"HEN + const { body } = await mockApp + .get("/users/verificationEmail") + .set("authorization", `Uid ${uid}|newuser@mail.com`) + .expect(200); + + //THEN + expect(body).toEqual({ + message: "Email sent", + data: null, + }); + + expect(adminGetUserMock).toHaveBeenCalledWith(uid); + expect(getPartialUserMock).toHaveBeenCalledWith( + uid, + "request verification email", + ["uid", "name", "email"] + ); + expect(adminGenerateVerificationLinkMock).toHaveBeenCalledWith( + "newuser@mail.com", + { url: "http://localhost:3000" } + ); + }); + it("should fail with missing firebase user", async () => { + //GIVEN + adminGetUserMock.mockRejectedValue(new Error("test")); + + //WHEN + const { body } = await mockApp + .get("/users/verificationEmail") + .set("authorization", `Uid ${uid}|newuser@mail.com`) + .expect(500); + + //THEN + expect(body.message).toContain( + "Auth user not found, even though the token got decoded" + ); + }); + it("should fail with already verified email", async () => { + //GIVEN + adminGetUserMock.mockResolvedValue({ emailVerified: true }); + + //WHEN + const { body } = await mockApp + .get("/users/verificationEmail") + .set("authorization", `Uid ${uid}|newuser@mail.com`) + .expect(400); + + //THEN + expect(body.message).toEqual("Email already verified"); + }); + it("should fail with email not matching the one from the authentication", async () => { + //GIVEN + getPartialUserMock.mockResolvedValue({ + email: "nonmatching@example.com", + } as any); + + //WHEN + const { body } = await mockApp + .get("/users/verificationEmail") + .set("authorization", `Uid ${uid}|newuser@mail.com`) + .expect(400); + + //THEN + expect(body.message).toEqual( + "Authenticated email does not match the email found in the database. This might happen if you recently changed your email. Please refresh and try again." + ); + }); + + it("should fail with too many firebase requests", async () => { + //GIVEN + adminGenerateVerificationLinkMock.mockRejectedValue({ + code: "auth/internal-error", + message: "TOO_MANY_ATTEMPTS_TRY_LATER", + } as FirebaseError); + + //WHEN + const { body } = await mockApp + .get("/users/verificationEmail") + .set("authorization", `Uid ${uid}|newuser@mail.com`) + .expect(429); + + //THEN + expect(body.message).toEqual("Too many requests. Please try again later"); + }); + it("should fail with firebase user not found", async () => { + //GIVEN + adminGenerateVerificationLinkMock.mockRejectedValue({ + code: "auth/user-not-found", + } as FirebaseError); + + //WHEN + const { body } = await mockApp + .get("/users/verificationEmail") + .set("authorization", `Uid ${uid}|newuser@mail.com`) + .expect(500); + + //THEN + expect(body.message).toEqual( + "Auth user not found when the user was found in the database. Contact support with this error message and your email\n" + + 'Stack: {"decodedTokenEmail":"newuser@mail.com","userInfoEmail":"newuser@mail.com"}' + ); + }); + it("should fail with firebase errir", async () => { + //GIVEN + adminGenerateVerificationLinkMock.mockRejectedValue({ + message: "Internal error encountered.", + } as FirebaseError); + + //WHEN + const { body } = await mockApp + .get("/users/verificationEmail") + .set("authorization", `Uid ${uid}|newuser@mail.com`) + .expect(500); + + //THEN + expect(body.message).toEqual( + "Firebase failed to generate an email verification link. Please try again later." + ); + }); + }); + describe("sendForgotPasswordEmail", () => { + const sendForgotPasswordEmailMock = vi.spyOn( + AuthUtils, + "sendForgotPasswordEmail" + ); + beforeEach(() => { + sendForgotPasswordEmailMock.mockReset().mockResolvedValue(); + }); + + it("should send forgot password email without authentication", async () => { + //GIVEN + + //WHEN + const { body } = await mockApp + .post("/users/forgotPasswordEmail") + .send({ email: "bob@example.com" }); + + //THEN + expect(body).toEqual({ + message: + "Password reset request received. If the email is valid, you will receive an email shortly.", + data: null, + }); + + expect(sendForgotPasswordEmailMock).toHaveBeenCalledWith( + "bob@example.com" + ); + }); + it("should fail without mandatory properties", async () => { + //WHEN + const { body } = await mockApp + .post("/users/forgotPasswordEmail") + .expect(422); + + //THEN + expect(body).toEqual({ + message: "Invalid request data schema", + validationErrors: ['"email" Required'], + }); + }); + it("should fail without unknown properties", async () => { + //WHEN + const { body } = await mockApp + .post("/users/forgotPasswordEmail") + .send({ email: "bob@example.com", extra: "value" }) + .expect(422); + + //THEN + expect(body).toEqual({ + message: "Invalid request data schema", + validationErrors: ["Unrecognized key(s) in object: 'extra'"], + }); + }); }); describe("getTestActivity", () => { const getUserMock = vi.spyOn(UserDal, "getPartialUser"); @@ -301,135 +566,6 @@ describe("user controller test", () => { expect(testsByDays[371]).toEqual(2024094); //2024-01 }); }); - - describe("toggle ban", () => { - const getUserMock = vi.spyOn(UserDal, "getPartialUser"); - const setBannedMock = vi.spyOn(UserDal, "setBanned"); - const georgeUserBannedMock = vi.spyOn(GeorgeQueue, "userBanned"); - const isAdminMock = vi.spyOn(AdminUuids, "isAdmin"); - beforeEach(async () => { - await enableAdminFeatures(true); - - isAdminMock.mockResolvedValue(true); - }); - afterEach(() => { - [getUserMock, setBannedMock, georgeUserBannedMock, isAdminMock].forEach( - (it) => it.mockReset() - ); - }); - - it("bans user with discord", async () => { - //GIVEN - const uid = "myUid"; - const user = { - uid, - name: "name", - email: "email", - discordId: "discordId", - } as Partial as MonkeyTypes.DBUser; - getUserMock.mockResolvedValue(user); - - //WHEN - await mockApp - .post("/admin/toggleBan") - .set("Authorization", "Bearer 123456789") - .send({ uid }) - .set({ - Accept: "application/json", - }) - .expect(200); - - //THEN - expect(getUserMock).toHaveBeenLastCalledWith(uid, "toggle ban", [ - "banned", - "discordId", - ]); - expect(setBannedMock).toHaveBeenCalledWith(uid, true); - expect(georgeUserBannedMock).toHaveBeenCalledWith("discordId", true); - }); - it("bans user without discord", async () => { - //GIVEN - const uid = "myUid"; - const user = { - uid, - name: "name", - email: "email", - discordId: "", - } as Partial as MonkeyTypes.DBUser; - getUserMock.mockResolvedValue(user); - - //WHEN - await mockApp - .post("/admin/toggleBan") - .set("Authorization", "Bearer 123456789") - .send({ uid }) - .set({ - Accept: "application/json", - }) - .expect(200); - - //THEN - expect(georgeUserBannedMock).not.toHaveBeenCalled(); - }); - it("unbans user with discord", async () => { - //GIVEN - const uid = "myUid"; - - const user = { - uid, - name: "name", - email: "email", - discordId: "discordId", - banned: true, - } as Partial as MonkeyTypes.DBUser; - getUserMock.mockResolvedValue(user); - - //WHEN - await mockApp - .post("/admin/toggleBan") - .set("Authorization", "Bearer 123456789") - .send({ uid }) - .set({ - Accept: "application/json", - }) - .expect(200); - - //THEN - expect(getUserMock).toHaveBeenLastCalledWith(uid, "toggle ban", [ - "banned", - "discordId", - ]); - expect(setBannedMock).toHaveBeenCalledWith(uid, false); - expect(georgeUserBannedMock).toHaveBeenCalledWith("discordId", false); - }); - it("unbans user without discord", async () => { - //GIVEN - const uid = "myUid"; - - const user = { - uid, - name: "name", - email: "email", - discordId: "", - banned: true, - } as Partial as MonkeyTypes.DBUser; - getUserMock.mockResolvedValue(user); - - //WHEN - await mockApp - .post("/admin/toggleBan") - .set("Authorization", "Bearer 123456789") - .send({ uid }) - .set({ - Accept: "application/json", - }) - .expect(200); - - //THEN - expect(georgeUserBannedMock).not.toHaveBeenCalled(); - }); - }); - describe("delete user", () => { const getUserMock = vi.spyOn(UserDal, "getPartialUser"); const deleteUserMock = vi.spyOn(UserDal, "deleteUser"); @@ -488,9 +624,6 @@ describe("user controller test", () => { await mockApp .delete("/users/") .set("Authorization", "Bearer 123456789") - .set({ - Accept: "application/json", - }) .expect(200); //THEN @@ -522,9 +655,6 @@ describe("user controller test", () => { await mockApp .delete("/users/") .set("Authorization", "Bearer 123456789") - .set({ - Accept: "application/json", - }) .expect(200); //THEN @@ -542,6 +672,624 @@ describe("user controller test", () => { ); }); }); + describe("resetUser", () => { + const getPartialUserMock = vi.spyOn(UserDal, "getPartialUser"); + const resetUserMock = vi.spyOn(UserDal, "resetUser"); + const deleteAllApeKeysMock = vi.spyOn(ApeKeysDal, "deleteAllApeKeys"); + const deleteAllPresetsMock = vi.spyOn(PresetDal, "deleteAllPresets"); + const deleteAllResultsMock = vi.spyOn(ResultDal, "deleteAll"); + const deleteConfigMock = vi.spyOn(ConfigDal, "deleteConfig"); + const purgeUserFromDailyLeaderboardsMock = vi.spyOn( + DailyLeaderboards, + "purgeUserFromDailyLeaderboards" + ); + + const unlinkDiscordMock = vi.spyOn(GeorgeQueue, "unlinkDiscord"); + const addImportantLogMock = vi.spyOn(LogDal, "addImportantLog"); + + beforeEach(() => { + getPartialUserMock.mockReset().mockResolvedValue({ + banned: false, + name: "bob", + email: "bob@example.com", + } as any); + + [ + resetUserMock, + deleteAllApeKeysMock, + deleteAllPresetsMock, + deleteAllResultsMock, + deleteConfigMock, + purgeUserFromDailyLeaderboardsMock, + unlinkDiscordMock, + addImportantLogMock, + ].forEach((it) => it.mockReset()); + }); + + it("should reset user", async () => { + //GIVEN + + //WHEN + const { body } = await mockApp + .patch("/users/reset") + .set("authorization", `Uid ${uid}`) + .expect(200); + + //THEN + expect(body).toEqual({ + message: "User reset", + data: null, + }); + + [ + resetUserMock, + deleteAllApeKeysMock, + deleteAllPresetsMock, + deleteAllResultsMock, + deleteConfigMock, + ].forEach((it) => expect(it).toHaveBeenCalledWith(uid)); + expect(purgeUserFromDailyLeaderboardsMock).toHaveBeenCalledWith( + uid, + (await Configuration.getLiveConfiguration()).dailyLeaderboards + ); + expect(unlinkDiscordMock).not.toHaveBeenCalled(); + expect(addImportantLogMock).toHaveBeenCalledWith( + "user_reset", + "bob@example.com bob", + uid + ); + }); + it("should unlink discord", async () => { + //GIVEN + getPartialUserMock.mockResolvedValue({ discordId: "discordId" } as any); + + //WHEN + await mockApp + .patch("/users/reset") + .set("authorization", `Uid ${uid}`) + .expect(200); + + //THEN + expect(unlinkDiscordMock).toHaveBeenCalledWith("discordId", uid); + }); + it("should fail resetting a banned user", async () => { + //GIVEN + getPartialUserMock.mockResolvedValue({ banned: true } as any); + + //WHEN + const { body } = await mockApp + .patch("/users/reset") + .set("authorization", `Uid ${uid}`) + .expect(403); + + //THEN + expect(body.message).toEqual("Banned users cannot reset their account"); + }); + }); + describe("update name", () => { + const getPartialUserMock = vi.spyOn(UserDal, "getPartialUser"); + const updateNameMock = vi.spyOn(UserDal, "updateName"); + const addImportantLogMock = vi.spyOn(LogDal, "addImportantLog"); + + beforeEach(() => { + getPartialUserMock.mockReset(); + updateNameMock.mockReset(); + addImportantLogMock.mockReset(); + }); + + it("should update the username", async () => { + //GIVEN + getPartialUserMock.mockResolvedValue({ + name: "Bob", + lastNameChange: 1000, + } as any); + //WHEN + const { body } = await mockApp + .patch("/users/name") + .set("authorization", `Uid ${uid}`) + .send({ name: "newName" }) + .expect(200); + + //THEN + expect(body).toEqual({ + message: "User's name updated", + data: null, + }); + + expect(updateNameMock).toHaveBeenCalledWith(uid, "newName", "Bob"); + expect(addImportantLogMock).toHaveBeenCalledWith( + "user_name_updated", + "changed name from Bob to newName", + uid + ); + }); + it("should fail for banned users", async () => { + //GIVEN + getPartialUserMock.mockResolvedValue({ banned: true } as any); + + //WHEN + const { body } = await mockApp + .patch("/users/name") + .set("authorization", `Uid ${uid}`) + .send({ name: "newName" }) + .expect(403); + + //THEN + expect(body.message).toEqual("Banned users cannot change their name"); + expect(updateNameMock).not.toHaveBeenCalled(); + }); + it("should fail changing name within last 30 days", async () => { + //GIVEN + getPartialUserMock.mockResolvedValue({ + lastNameChange: Date.now().valueOf() - 60_000, + } as any); + + //WHEN + const { body } = await mockApp + .patch("/users/name") + .set("authorization", `Uid ${uid}`) + .send({ name: "newName" }) + .expect(409); + + //THEN + expect(body.message).toEqual( + "You can change your name once every 30 days" + ); + expect(updateNameMock).not.toHaveBeenCalled(); + }); + it("should update the username within 30 days if user needs to change", async () => { + //GIVEN + getPartialUserMock.mockResolvedValue({ + name: "Bob", + lastNameChange: Date.now().valueOf() - 60_000, + needsToChangeName: true, + } as any); + //WHEN + const { body } = await mockApp + .patch("/users/name") + .set("authorization", `Uid ${uid}`) + .send({ name: "newName" }) + .expect(200); + + //THEN + expect(body).toEqual({ + message: "User's name updated", + data: null, + }); + + expect(updateNameMock).toHaveBeenCalledWith(uid, "newName", "Bob"); + }); + it("should fail without mandatory properties", async () => { + //WHEN + const { body } = await mockApp + .patch("/users/name") + .set("authorization", `Uid ${uid}`) + .expect(422); + + //THEN + expect(body).toEqual({ + message: "Invalid request data schema", + validationErrors: ['"name" Required'], + }); + }); + it("should fail without unknown properties", async () => { + //WHEN + const { body } = await mockApp + .patch("/users/name") + .set("authorization", `Uid ${uid}`) + .send({ name: "newName", extra: "value" }) + .expect(422); + + //THEN + expect(body).toEqual({ + message: "Invalid request data schema", + validationErrors: ["Unrecognized key(s) in object: 'extra'"], + }); + }); + it("should fail if username contains profanity", async () => { + //WHEN + const { body } = await mockApp + .patch("/users/name") + .set("authorization", `Uid ${uid}`) + .send({ name: "miodec" }) + .expect(422); + + //THEN + expect(body).toEqual({ + message: "Invalid request data schema", + validationErrors: [ + '"name" Profanity detected. Please remove it. If you believe this is a mistake, please contact us. (miodec)', + ], + }); + }); + }); + describe("clear PBs", () => { + const clearPbMock = vi.spyOn(UserDal, "clearPb"); + const purgeUserFromDailyLeaderboardsMock = vi.spyOn( + DailyLeaderboards, + "purgeUserFromDailyLeaderboards" + ); + const addImportantLogMock = vi.spyOn(LogDal, "addImportantLog"); + + beforeEach(() => { + clearPbMock.mockReset(); + purgeUserFromDailyLeaderboardsMock.mockReset(); + addImportantLogMock.mockReset(); + }); + + it("should clear pb", async () => { + //GIVEN + + //WHEN + const { body } = await mockApp + .delete("/users/personalBests") + .set("authorization", `Uid ${uid}`) + .expect(200); + + //THEN + expect(body).toEqual({ + message: "User's PB cleared", + data: null, + }); + expect(clearPbMock).toHaveBeenCalledWith(uid); + expect(purgeUserFromDailyLeaderboardsMock).toHaveBeenCalledWith( + uid, + (await Configuration.getLiveConfiguration()).dailyLeaderboards + ); + expect(addImportantLogMock).toHaveBeenCalledWith( + "user_cleared_pbs", + "", + uid + ); + }); + }); + describe("opt out of leaderboard", () => { + const optOutOfLeaderboardsMock = vi.spyOn(UserDal, "optOutOfLeaderboards"); + const purgeUserFromDailyLeaderboardsMock = vi.spyOn( + DailyLeaderboards, + "purgeUserFromDailyLeaderboards" + ); + const addImportantLogMock = vi.spyOn(LogDal, "addImportantLog"); + + beforeEach(() => { + optOutOfLeaderboardsMock.mockReset(); + purgeUserFromDailyLeaderboardsMock.mockReset(); + addImportantLogMock.mockReset(); + }); + it("should opt out", async () => { + //GIVEN + + //WHEN + const { body } = await mockApp + .post("/users/optOutOfLeaderboards") + .set("authorization", `Uid ${uid}`) + .expect(200); + + //THEN + expect(body).toEqual({ + message: "User opted out of leaderboards", + data: null, + }); + + expect(optOutOfLeaderboardsMock).toHaveBeenCalledWith(uid); + expect(purgeUserFromDailyLeaderboardsMock).toHaveBeenCalledWith( + uid, + (await Configuration.getLiveConfiguration()).dailyLeaderboards + ); + expect(addImportantLogMock).toHaveBeenCalledWith( + "user_opted_out_of_leaderboards", + "", + uid + ); + }); + it("should fail with unknown properties", async () => { + //WHEN + const { body } = await mockApp + .post("/users/optOutOfLeaderboards") + .set("authorization", `Uid ${uid}`) + .send({ extra: "value" }); + //TODO.expect(422); + + //THEN + /* TODO: + expect(body).toEqual({}); + */ + }); + }); + describe("update email", () => { + const authUpdateEmailMock = vi.spyOn(AuthUtils, "updateUserEmail"); + const userUpdateEmailMock = vi.spyOn(UserDal, "updateEmail"); + const addImportantLogMock = vi.spyOn(LogDal, "addImportantLog"); + + beforeEach(() => { + authUpdateEmailMock.mockReset(); + userUpdateEmailMock.mockReset(); + addImportantLogMock.mockReset(); + }); + it("should update users email", async () => { + //GIVEN + const newEmail = "newEmail@example.com"; + + //WHEN + const { body } = await mockApp + .patch("/users/email") + .set("authorization", `Uid ${uid}`) + .send({ newEmail, previousEmail: "previousEmail@example.com" }) + .expect(200); + + //THEN + expect(body).toEqual({ + message: "Email updated", + data: null, + }); + + expect(authUpdateEmailMock).toHaveBeenCalledWith( + uid, + newEmail.toLowerCase() + ); + expect(userUpdateEmailMock).toHaveBeenCalledWith( + uid, + newEmail.toLowerCase() + ); + expect(addImportantLogMock).toHaveBeenCalledWith( + "user_email_updated", + "changed email to newemail@example.com", + uid + ); + }); + it("should fail for duplicate email", async () => { + //GIVEN + authUpdateEmailMock.mockRejectedValue({ + code: "auth/email-already-exists", + } as FirebaseError); + + //WHEN + const { body } = await mockApp + .patch("/users/email") + .set("authorization", `Uid ${uid}`) + .send({ + newEmail: "newEmail@example.com", + previousEmail: "previousEmail@example.com", + }) + .expect(409); + + expect(body.message).toEqual( + "The email address is already in use by another account" + ); + + expect(userUpdateEmailMock).not.toHaveBeenCalled(); + }); + + it("should fail for invalid email", async () => { + //GIVEN + authUpdateEmailMock.mockRejectedValue({ + code: "auth/invalid-email", + } as FirebaseError); + + //WHEN + const { body } = await mockApp + .patch("/users/email") + .set("authorization", `Uid ${uid}`) + .send({ + newEmail: "newEmail@example.com", + previousEmail: "previousEmail@example.com", + }) + .expect(400); + + expect(body.message).toEqual("Invalid email address"); + + expect(userUpdateEmailMock).not.toHaveBeenCalled(); + }); + it("should fail for too many requests", async () => { + //GIVEN + authUpdateEmailMock.mockRejectedValue({ + code: "auth/too-many-requests", + } as FirebaseError); + + //WHEN + const { body } = await mockApp + .patch("/users/email") + .set("authorization", `Uid ${uid}`) + .send({ + newEmail: "newEmail@example.com", + previousEmail: "previousEmail@example.com", + }) + .expect(429); + + expect(body.message).toEqual("Too many requests. Please try again later"); + + expect(userUpdateEmailMock).not.toHaveBeenCalled(); + }); + it("should fail for unknown user", async () => { + //GIVEN + authUpdateEmailMock.mockRejectedValue({ + code: "auth/user-not-found", + } as FirebaseError); + + //WHEN + const { body } = await mockApp + .patch("/users/email") + .set("authorization", `Uid ${uid}`) + .send({ + newEmail: "newEmail@example.com", + previousEmail: "previousEmail@example.com", + }) + .expect(404); + + expect(body.message).toEqual( + "User not found in the auth system\nStack: update email" + ); + + expect(userUpdateEmailMock).not.toHaveBeenCalled(); + }); + it("should fail for invalid user token", async () => { + //GIVEN + authUpdateEmailMock.mockRejectedValue({ + code: "auth/invalid-user-token", + } as FirebaseError); + + //WHEN + const { body } = await mockApp + .patch("/users/email") + .set("authorization", `Uid ${uid}`) + .send({ + newEmail: "newEmail@example.com", + previousEmail: "previousEmail@example.com", + }) + .expect(401); + + expect(body.message).toEqual("Invalid user token\nStack: update email"); + + expect(userUpdateEmailMock).not.toHaveBeenCalled(); + }); + it("should fail for unknown error", async () => { + //GIVEN + authUpdateEmailMock.mockRejectedValue({} as FirebaseError); + + //WHEN + await mockApp + .patch("/users/email") + .set("authorization", `Uid ${uid}`) + .send({ + newEmail: "newEmail@example.com", + previousEmail: "previousEmail@example.com", + }) + .expect(500); + }); + it("should fail without mandatory properties", async () => { + //WHEN + const { body } = await mockApp + .patch("/users/email") + .set("authorization", `Uid ${uid}`) + .expect(422); + + expect(body).toEqual({ + message: "Invalid request data schema", + validationErrors: ['"newEmail" Required', '"previousEmail" Required'], + }); + }); + it("should fail with unknown properties", async () => { + //WHEN + const { body } = await mockApp + .patch("/users/email") + .set("authorization", `Uid ${uid}`) + .send({ + newEmail: "newEmail@example.com", + previousEmail: "previousEmail@example.com", + extra: "value", + }) + .expect(422); + + expect(body).toEqual({ + message: "Invalid request data schema", + validationErrors: ["Unrecognized key(s) in object: 'extra'"], + }); + }); + }); + describe("update password", () => { + const updatePasswordMock = vi.spyOn(AuthUtils, "updateUserPassword"); + + beforeEach(() => { + updatePasswordMock.mockReset(); + }); + + it("should update password", async () => { + //WHEN + const { body } = await mockApp + .patch("/users/password") + .set("authorization", `Uid ${uid}`) + .send({ newPassword: "sw0rdf1sh" }) + .expect(200); + + //THEN + expect(body).toEqual({ + message: "Password updated", + data: null, + }); + expect(updatePasswordMock).toHaveBeenCalledWith(uid, "sw0rdf1sh"); + }); + it("should fail without mandatory properties", async () => { + //WHEN + const { body } = await mockApp + .patch("/users/password") + .set("authorization", `Uid ${uid}`) + .expect(422); + + //THEN + expect(body).toEqual({ + message: "Invalid request data schema", + validationErrors: ['"newPassword" Required'], + }); + }); + it("should fail with unknown properties", async () => { + //WHEN + const { body } = await mockApp + .patch("/users/password") + .set("authorization", `Uid ${uid}`) + .send({ newPassword: "sw0rdf1sh", extra: "value" }) + .expect(422); + + //THEN + expect(body).toEqual({ + message: "Invalid request data schema", + validationErrors: ["Unrecognized key(s) in object: 'extra'"], + }); + }); + it("should fail with password too short", async () => { + //WHEN + const { body } = await mockApp + .patch("/users/password") + .set("authorization", `Uid ${uid}`) + .send({ newPassword: "test" }) + .expect(422); + + //THEN + expect(body).toEqual({ + message: "Invalid request data schema", + validationErrors: [ + '"newPassword" String must contain at least 6 character(s)', + ], + }); + }); + }); + describe("get oauth link", () => { + const getOauthLinkMock = vi.spyOn(DiscordUtils, "getOauthLink"); + const url = "http://example.com:1234?test"; + beforeEach(() => { + enableDiscordIntegration(true); + getOauthLinkMock.mockReset().mockResolvedValue(url); + }); + + it("should get oauth link", async () => { + //WHEN + const { body } = await mockApp + .get("/users/discord/oauth") + .set("authorization", `Uid ${uid}`) + .expect(200); + + //THEN + expect(body).toEqual({ + message: "Discord oauth link generated", + data: { url }, + }); + expect(getOauthLinkMock).toHaveBeenCalledWith(uid); + }); + it("should fail if feature is not enabled", async () => { + //GIVEN + enableDiscordIntegration(false); + + //WHEN + const { body } = await mockApp + .get("/users/discord/oauth") + .set("authorization", `Uid ${uid}`) + .expect(503); + + //THEN + expect(body.message).toEqual( + "Discord integration is not available at this time" + ); + }); + }); describe("link discord", () => { const getUserMock = vi.spyOn(UserDal, "getPartialUser"); const isDiscordIdAvailableMock = vi.spyOn(UserDal, "isDiscordIdAvailable"); @@ -551,17 +1299,22 @@ describe("user controller test", () => { ); const getDiscordUserMock = vi.spyOn(DiscordUtils, "getDiscordUser"); const blocklistContainsMock = vi.spyOn(BlocklistDal, "contains"); + const userLinkDiscordMock = vi.spyOn(UserDal, "linkDiscord"); + const georgeLinkDiscordMock = vi.spyOn(GeorgeQueue, "linkDiscord"); + const addImportantLogMock = vi.spyOn(LogDal, "addImportantLog"); beforeEach(async () => { isStateValidForUserMock.mockResolvedValue(true); + getUserMock.mockResolvedValue({} as any); getDiscordUserMock.mockResolvedValue({ id: "discordUserId", - avatar: "discorUserAvatar", + avatar: "discordUserAvatar", username: "discordUserName", discriminator: "discordUserDiscriminator", }); isDiscordIdAvailableMock.mockResolvedValue(true); blocklistContainsMock.mockResolvedValue(false); + userLinkDiscordMock.mockResolvedValue(); await enableDiscordIntegration(true); }); afterEach(() => { @@ -570,10 +1323,183 @@ describe("user controller test", () => { isStateValidForUserMock, isDiscordIdAvailableMock, getDiscordUserMock, + blocklistContainsMock, + userLinkDiscordMock, + georgeLinkDiscordMock, + addImportantLogMock, ].forEach((it) => it.mockReset()); }); - it("should not link if discordId is blocked", async () => { + it("should link discord", async () => { + //GIVEN + getUserMock.mockResolvedValue({} as any); + + //WHEN + const { body } = await mockApp + .post("/users/discord/link") + .set("Authorization", `Uid ${uid}`) + .send({ + tokenType: "tokenType", + accessToken: "accessToken", + state: "statestatestatestate", + }) + .expect(200); + + //THEN + expect(body).toEqual({ + message: "Discord account linked", + data: { + discordId: "discordUserId", + discordAvatar: "discordUserAvatar", + }, + }); + expect(isStateValidForUserMock).toHaveBeenCalledWith( + "statestatestatestate", + uid + ); + expect(getUserMock).toHaveBeenCalledWith( + uid, + "link discord", + expect.any(Array) + ); + expect(getDiscordUserMock).toHaveBeenCalledWith( + "tokenType", + "accessToken" + ); + expect(isDiscordIdAvailableMock).toHaveBeenCalledWith("discordUserId"); + expect(blocklistContainsMock).toHaveBeenCalledWith({ + discordId: "discordUserId", + }); + expect(userLinkDiscordMock).toHaveBeenCalledWith( + uid, + "discordUserId", + "discordUserAvatar" + ); + expect(georgeLinkDiscordMock).toHaveBeenCalledWith("discordUserId", uid); + expect(addImportantLogMock).toHaveBeenCalledWith( + "user_discord_link", + "linked to discordUserId", + uid + ); + }); + + it("should update existing discord avatar", async () => { + //GIVEN + getUserMock.mockResolvedValue({ discordId: "existingDiscordId" } as any); + + //WHEN + const { body } = await mockApp + .post("/users/discord/link") + .set("Authorization", `Uid ${uid}`) + .send({ + tokenType: "tokenType", + accessToken: "accessToken", + state: "statestatestatestate", + }) + .expect(200); + + //THEN + expect(body).toEqual({ + message: "Discord avatar updated", + data: { + discordId: "discordUserId", + discordAvatar: "discordUserAvatar", + }, + }); + expect(userLinkDiscordMock).toHaveBeenCalledWith( + uid, + "existingDiscordId", + "discordUserAvatar" + ); + expect(isDiscordIdAvailableMock).not.toHaveBeenCalled(); + expect(blocklistContainsMock).not.toHaveBeenCalled(); + expect(georgeLinkDiscordMock).not.toHaveBeenCalled(); + expect(addImportantLogMock).not.toHaveBeenCalled(); + }); + it("should fail for user mismatch", async () => { + //GIVEN + isStateValidForUserMock.mockResolvedValue(false); + + //WHEN + const { body } = await mockApp + .post("/users/discord/link") + .set("Authorization", `Uid ${uid}`) + .send({ + tokenType: "tokenType", + accessToken: "accessToken", + state: "statestatestatestate", + }) + .expect(403); + + //THEN + expect(body.message).toEqual("Invalid user token"); + }); + it("should fail for banned users", async () => { + //GIVEN + getUserMock.mockResolvedValue({ banned: true } as any); + + //WHEN + const { body } = await mockApp + .post("/users/discord/link") + .set("Authorization", `Uid ${uid}`) + .send({ + tokenType: "tokenType", + accessToken: "accessToken", + state: "statestatestatestate", + }) + .expect(403); + + //THEN + expect(body.message).toEqual("Banned accounts cannot link with Discord"); + }); + it("should fail for unknown discordId", async () => { + //GIVEN + getDiscordUserMock.mockResolvedValue({} as any); + + //WHEN + const { body } = await mockApp + .post("/users/discord/link") + .set("Authorization", `Uid ${uid}`) + .send({ + tokenType: "tokenType", + accessToken: "accessToken", + state: "statestatestatestate", + }) + .expect(500); + + //THEN + expect(body.message).toEqual( + "Could not get Discord account info\nStack: discord id is undefined" + ); + + //THEN + expect(userLinkDiscordMock).not.toHaveBeenCalled(); + }); + it("should fail for already linked discordId", async () => { + //GIVEN + isDiscordIdAvailableMock.mockResolvedValue(false); + + //WHEN + const { body } = await mockApp + .post("/users/discord/link") + .set("Authorization", `Uid ${uid}`) + .send({ + tokenType: "tokenType", + accessToken: "accessToken", + state: "statestatestatestate", + }) + .expect(409); + + //THEN + expect(body.message).toEqual( + "This Discord account is linked to a different account" + ); + + //THEN + expect(userLinkDiscordMock).not.toHaveBeenCalled(); + }); + + it("should fail if discordId is blocked", async () => { //GIVEN const uid = mockDecodedToken.uid; const user = { @@ -588,9 +1514,6 @@ describe("user controller test", () => { const result = await mockApp .post("/users/discord/link") .set("Authorization", "Bearer 123456789") - .set({ - Accept: "application/json", - }) .send({ tokenType: "tokenType", accessToken: "accessToken", @@ -605,6 +1528,1921 @@ describe("user controller test", () => { discordId: "discordUserId", }); }); + it("should fail without mandatory properties", async () => { + //WHEN + const { body } = await mockApp + .post("/users/discord/link") + .set("Authorization", `Uid ${uid}`) + .expect(422); + + //THEN + expect(body).toEqual({ + message: "Invalid request data schema", + validationErrors: [ + '"tokenType" Required', + '"accessToken" Required', + '"state" Required', + ], + }); + }); + it("should fail with unknown properties", async () => { + //WHEN + const { body } = await mockApp + .post("/users/discord/link") + .set("Authorization", `Uid ${uid}`) + .send({ + tokenType: "tokenType", + accessToken: "accessToken", + state: "statestatestatestate", + extra: "value", + }) + .expect(422); + + //THEN + expect(body).toEqual({ + message: "Invalid request data schema", + validationErrors: ["Unrecognized key(s) in object: 'extra'"], + }); + }); + }); + describe("unlink discord", () => { + const getPartialUserMock = vi.spyOn(UserDal, "getPartialUser"); + const userUnlinkDiscordMock = vi.spyOn(UserDal, "unlinkDiscord"); + const georgeUnlinkDiscordMock = vi.spyOn(GeorgeQueue, "unlinkDiscord"); + const addImportantLogMock = vi.spyOn(LogDal, "addImportantLog"); + + beforeEach(() => { + getPartialUserMock + .mockReset() + .mockResolvedValue({ discordId: "discordId" } as any); + userUnlinkDiscordMock.mockReset(); + georgeUnlinkDiscordMock.mockReset(); + addImportantLogMock.mockReset(); + }); + + it("should unlink", async () => { + //GIVEN + + //WHEN + const { body } = await mockApp + .post("/users/discord/unlink") + .set("Authorization", `Uid ${uid}`) + .expect(200); + + //THEN + expect(body).toEqual({ + message: "Discord account unlinked", + data: null, + }); + + expect(userUnlinkDiscordMock).toHaveBeenCalledWith(uid); + expect(georgeUnlinkDiscordMock).toHaveBeenCalledWith("discordId", uid); + expect(addImportantLogMock).toHaveBeenCalledWith( + "user_discord_unlinked", + "discordId", + uid + ); + }); + it("should fail for banned user", async () => { + //GIVEN + getPartialUserMock.mockResolvedValue({ banned: true } as any); + + //WHEN + + const { body } = await mockApp + .post("/users/discord/unlink") + .set("Authorization", `Uid ${uid}`) + .expect(403); + + //THEN + expect(body.message).toEqual("Banned accounts cannot unlink Discord"); + expect(userUnlinkDiscordMock).not.toHaveBeenCalled(); + expect(georgeUnlinkDiscordMock).not.toHaveBeenCalled(); + }); + it("should fail for user without discord linked", async () => { + //GIVEN + getPartialUserMock.mockResolvedValue({ discordId: undefined } as any); + + //WHEN + + const { body } = await mockApp + .post("/users/discord/unlink") + .set("Authorization", `Uid ${uid}`) + .expect(404); + + //THEN + expect(body.message).toEqual( + "User does not have a linked Discord account" + ); + expect(userUnlinkDiscordMock).not.toHaveBeenCalled(); + expect(georgeUnlinkDiscordMock).not.toHaveBeenCalled(); + }); + }); + describe("add result filter preset", () => { + const validPreset = { + _id: "66c61b7a2a65715e66a0cc95", + name: "newPreset", + pb: { no: true, yes: true }, + difficulty: { normal: true, expert: false, master: false }, + mode: { + words: false, + time: false, + quote: true, + zen: false, + custom: false, + }, + words: { + "10": false, + "25": false, + "50": false, + "100": false, + custom: false, + }, + time: { + "15": false, + "30": false, + "60": false, + "120": false, + custom: false, + }, + quoteLength: { + short: false, + medium: false, + long: false, + thicc: false, + }, + punctuation: { + on: false, + off: true, + }, + numbers: { + on: false, + off: true, + }, + date: { + last_day: false, + last_week: false, + last_month: false, + last_3months: false, + all: true, + }, + tags: { + none: false, + }, + language: { + english: true, + }, + funbox: { + none: true, + }, + }; + const generatedId = new ObjectId(); + + const addResultFilterPresetMock = vi.spyOn( + UserDal, + "addResultFilterPreset" + ); + + beforeEach(async () => { + addResultFilterPresetMock.mockReset().mockResolvedValue(generatedId); + await enableResultFilterPresets(true); + }); + it("should add", async () => { + //GIVEN + + //WHEN + const { body } = await mockApp + .post("/users/resultFilterPresets") + .set("Authorization", `Uid ${uid}`) + .send(validPreset) + .expect(200); + + //THEN + expect(body).toEqual({ + message: "Result filter preset created", + data: generatedId.toHexString(), + }); + + expect(addResultFilterPresetMock).toHaveBeenCalledWith( + uid, + validPreset, + (await Configuration.getLiveConfiguration()).results.filterPresets + .maxPresetsPerUser + ); + }); + it("should fail without mandatory properties", async () => { + //WHEN + const { body } = await mockApp + .post("/users/resultFilterPresets") + .set("Authorization", `Uid ${uid}`) + .expect(422); + + //THEN + expect(body).toEqual({ + message: "Invalid request data schema", + validationErrors: [ + '"_id" Required', + '"name" Required', + '"pb" Required', + '"difficulty" Required', + '"mode" Required', + '"words" Required', + '"time" Required', + '"quoteLength" Required', + '"punctuation" Required', + '"numbers" Required', + '"date" Required', + '"tags" Required', + '"language" Required', + '"funbox" Required', + ], + }); + }); + it("should fail with unknown properties", async () => { + //WHEN + const { body } = await mockApp + .post("/users/resultFilterPresets") + .set("Authorization", `Uid ${uid}`) + .send({ ...validPreset, extra: "value" }) + .expect(422); + + //THEN + expect(body).toEqual({ + message: "Invalid request data schema", + validationErrors: ["Unrecognized key(s) in object: 'extra'"], + }); + }); + it("should fail if feature is disabled", async () => { + //GIVEN + enableResultFilterPresets(false); + //WHEN + const { body } = await mockApp + .post("/users/resultFilterPresets") + .set("Authorization", `Uid ${uid}`) + .send({ validPreset }) + .expect(503); + + //THEN + expect(body.message).toEqual( + "Result filter presets are not available at this time." + ); + }); + }); + describe("remove result filter preset", () => { + const removeResultFilterPresetMock = vi.spyOn( + UserDal, + "removeResultFilterPreset" + ); + + beforeEach(() => { + enableResultFilterPresets(true); + removeResultFilterPresetMock.mockReset(); + }); + + it("should remove filter preset", async () => { + //WHEN + const { body } = await mockApp + .delete("/users/resultFilterPresets/myId") + .set("Authorization", `Uid ${uid}`) + .expect(200); + + //THEN + expect(body).toEqual({ + message: "Result filter preset deleted", + data: null, + }); + expect(removeResultFilterPresetMock).toHaveBeenCalledWith(uid, "myId"); + }); + it("should fail if feature is disabled", async () => { + //GIVEN + enableResultFilterPresets(false); + + //WHEN + const { body } = await mockApp + .delete("/users/resultFilterPresets/myId") + .set("Authorization", `Uid ${uid}`) + .expect(503); + + //THEN + expect(body.message).toEqual( + "Result filter presets are not available at this time." + ); + }); + }); + describe("add tag", () => { + const addTagMock = vi.spyOn(UserDal, "addTag"); + const newTag = { + _id: new ObjectId(), + name: "tagName", + personalBests: { + time: {}, + words: {}, + quote: {}, + zen: {}, + custom: {}, + }, + }; + + beforeEach(() => { + addTagMock.mockReset().mockResolvedValue(newTag); + }); + + it("should add tag", async () => { + //GIVEN + + //WHEN + const { body } = await mockApp + .post("/users/tags") + .send({ tagName: "tagName" }) + .set("Authorization", `Uid ${uid}`) + .expect(200); + + //THEN + expect(body).toEqual({ + message: "Tag updated", + data: { ...newTag, _id: newTag._id.toHexString() }, + }); + expect(addTagMock).toHaveBeenCalledWith(uid, "tagName"); + }); + it("should fail without mandatory properties", async () => { + //GIVEN + + //WHEN + const { body } = await mockApp + .post("/users/tags") + .set("Authorization", `Uid ${uid}`) + .expect(422); + + //THEN + expect(body).toEqual({ + message: "Invalid request data schema", + validationErrors: ['"tagName" Required'], + }); + }); + it("should fail with unknown properties", async () => { + //GIVEN + + //WHEN + const { body } = await mockApp + .post("/users/tags") + .set("Authorization", `Uid ${uid}`) + .send({ tagName: "tagName", extra: "value" }) + .expect(422); + + //THEN + expect(body).toEqual({ + message: "Invalid request data schema", + validationErrors: ["Unrecognized key(s) in object: 'extra'"], + }); + }); + }); + describe("clear tag pb", () => { + const removeTagPbMock = vi.spyOn(UserDal, "removeTagPb"); + + beforeEach(() => { + removeTagPbMock.mockReset(); + }); + + it("should clear tag pb", async () => { + //GIVEN + const tagId = new ObjectId().toHexString(); + //WHEN + const { body } = await mockApp + .delete(`/users/tags/${tagId}/personalBest`) + .set("Authorization", `Uid ${uid}`) + .expect(200); + + //THEN + expect(body).toEqual({ + message: "Tag PB cleared", + data: null, + }); + expect(removeTagPbMock).toHaveBeenLastCalledWith(uid, tagId); + }); + }); + + describe("update tag", () => { + const editTagMock = vi.spyOn(UserDal, "editTag"); + beforeEach(() => { + editTagMock.mockReset(); + }); + + it("should update tag", async () => { + //GIVEN + const tagId = new ObjectId().toHexString(); + + //WHEN + const { body } = await mockApp + .patch(`/users/tags`) + .set("Authorization", `Uid ${uid}`) + .send({ tagId, newName: "newName" }) + .expect(200); + + //THEN + expect(body).toEqual({ + message: "Tag updated", + data: null, + }); + expect(editTagMock).toHaveBeenCalledWith(uid, tagId, "newName"); + }); + it("should fail without mandatory properties", async () => { + //WHEN + const { body } = await mockApp + .patch(`/users/tags`) + .set("Authorization", `Uid ${uid}`) + .expect(422); + + //THEN + expect(body).toEqual({ + message: "Invalid request data schema", + validationErrors: ['"tagId" Required', '"newName" Required'], + }); + }); + it("should fail with unknown properties", async () => { + //WHEN + const { body } = await mockApp + .patch(`/users/tags`) + .set("Authorization", `Uid ${uid}`) + .send({ + tagId: new ObjectId().toHexString(), + newName: "newName", + extra: "value", + }) + .expect(422); + + //THEN + expect(body).toEqual({ + message: "Invalid request data schema", + validationErrors: ["Unrecognized key(s) in object: 'extra'"], + }); + }); + }); + describe("remove tag", () => { + const removeTagMock = vi.spyOn(UserDal, "removeTag"); + + beforeEach(() => { + removeTagMock.mockReset(); + }); + + it("should remove tag", async () => { + //GIVEN + const tagId = new ObjectId().toHexString(); + + //WHEN + const { body } = await mockApp + .delete(`/users/tags/${tagId}`) + .set("Authorization", `Uid ${uid}`) + .expect(200); + + //THEN + expect(body).toEqual({ + message: "Tag deleted", + data: null, + }); + + expect(removeTagMock).toHaveBeenCalledWith(uid, tagId); + }); + }); + describe("get tags", () => { + const getTagsMock = vi.spyOn(UserDal, "getTags"); + + beforeEach(() => { + getTagsMock.mockReset(); + }); + + it("should get tags", async () => { + //GIVEN + const tagOne: MonkeyTypes.DBUserTag = { + _id: new ObjectId(), + name: "tagOne", + personalBests: {} as any, + }; + const tagTwo: MonkeyTypes.DBUserTag = { + _id: new ObjectId(), + name: "tagOne", + personalBests: {} as any, + }; + + getTagsMock.mockResolvedValue([tagOne, tagTwo]); + + //WHEN + const { body } = await mockApp + .get("/users/tags") + .set("Authorization", `Uid ${uid}`) + .expect(200); + + //THEN + expect(body).toEqual({ + message: "Tags retrieved", + data: [ + { ...tagOne, _id: tagOne._id.toHexString() }, + { ...tagTwo, _id: tagTwo._id.toHexString() }, + ], + }); + expect(getTagsMock).toHaveBeenCalledWith(uid); + }); + }); + describe("update lb memory", () => { + const updateLbMemoryMock = vi.spyOn(UserDal, "updateLbMemory"); + beforeEach(() => { + updateLbMemoryMock.mockReset(); + }); + + it("should update lb ", async () => { + //WHEN + const { body } = await mockApp + .patch("/users/leaderboardMemory") + .send({ + mode: "time", + mode2: "60", + language: "english", + rank: 7, + }) + .set("Authorization", `Uid ${uid}`) + .expect(200); + + //THEN + expect(body).toEqual({ + message: "Leaderboard memory updated", + data: null, + }); + + expect(updateLbMemoryMock).toHaveBeenCalledWith( + uid, + "time", + "60", + "english", + 7 + ); + }); + + it("should fail without mandatory properties", async () => { + //WHEN + const { body } = await mockApp + .patch("/users/leaderboardMemory") + .set("Authorization", `Uid ${uid}`) + .expect(422); + + //THEN + expect(body).toEqual({ + message: "Invalid request data schema", + validationErrors: [ + '"mode" Required', + '"mode2" Needs to be either a number, "zen" or "custom".', + '"language" Required', + '"rank" Required', + ], + }); + }); + it("should fail with unknown properties", async () => { + //WHEN + const { body } = await mockApp + .patch("/users/leaderboardMemory") + .set("Authorization", `Uid ${uid}`) + .send({ + mode: "time", + mode2: "60", + language: "english", + rank: 7, + extra: "value", + }) + .expect(422); + + //THEN + expect(body).toEqual({ + message: "Invalid request data schema", + validationErrors: ["Unrecognized key(s) in object: 'extra'"], + }); + }); + }); + describe("get custom themes", () => { + const getThemesMock = vi.spyOn(UserDal, "getThemes"); + beforeEach(() => { + getThemesMock.mockReset(); + }); + it("should get custom themes", async () => { + //GIVEN + const themeOne: MonkeyTypes.DBCustomTheme = { + _id: new ObjectId(), + name: "themeOne", + colors: new Array(10).fill("#000000") as any, + }; + const themeTwo: MonkeyTypes.DBCustomTheme = { + _id: new ObjectId(), + name: "themeTwo", + colors: new Array(10).fill("#FFFFFF") as any, + }; + getThemesMock.mockResolvedValue([themeOne, themeTwo]); + + //WHEN + const { body } = await mockApp + .get("/users/customThemes") + .set("Authorization", `Uid ${uid}`) + .expect(200); + + //THEN + expect(body).toEqual({ + message: "Custom themes retrieved", + data: [ + { ...themeOne, _id: themeOne._id.toHexString() }, + { ...themeTwo, _id: themeTwo._id.toHexString() }, + ], + }); + }); + }); + describe("add custom theme", () => { + const addThemeMock = vi.spyOn(UserDal, "addTheme"); + beforeEach(() => { + addThemeMock.mockReset(); + }); + + it("should add ", async () => { + //GIVEN + const addedTheme: MonkeyTypes.DBCustomTheme = { + _id: new ObjectId(), + name: "custom", + colors: new Array(10).fill("#000000") as any, + }; + addThemeMock.mockResolvedValue(addedTheme); + + //WHEN + const { body } = await mockApp + .post("/users/customThemes") + .set("Authorization", `Uid ${uid}`) + .send({ + name: "customTheme", + colors: new Array(10).fill("#000000") as any, + }) + .expect(200); + + //THEN + expect(body).toEqual({ + message: "Custom theme added", + data: { ...addedTheme, _id: addedTheme._id.toHexString() }, + }); + expect(addThemeMock).toHaveBeenCalledWith(uid, { + name: "customTheme", + colors: new Array(10).fill("#000000") as any, + }); + }); + it("should fail without mandatory properties", async () => { + //WHEN + const { body } = await mockApp + .post("/users/customThemes") + .set("Authorization", `Uid ${uid}`) + .expect(422); + + //THEN + expect(body).toEqual({ + message: "Invalid request data schema", + validationErrors: ['"name" Required', '"colors" Required'], + }); + }); + it("should fail with unknown properties", async () => { + //WHEN + const { body } = await mockApp + .post("/users/customThemes") + .set("Authorization", `Uid ${uid}`) + .send({ + name: "customTheme", + colors: new Array(10).fill("#000000") as any, + extra: "value", + }) + .expect(422); + + //THEN + expect(body).toEqual({ + message: "Invalid request data schema", + validationErrors: ["Unrecognized key(s) in object: 'extra'"], + }); + }); + it("should fail with invalid properties", async () => { + //WHEN + const { body } = await mockApp + .post("/users/customThemes") + .set("Authorization", `Uid ${uid}`) + .send({ + name: "customThemecustomThemecustomThemecustomTheme", + colors: new Array(9).fill("#000") as any, + }) + .expect(422); + + //THEN + expect(body).toEqual({ + message: "Invalid request data schema", + validationErrors: [ + '"name" String must contain at most 16 character(s)', + '"colors" Array must contain at least 10 element(s)', + ], + }); + }); + }); + describe("remove custom theme", () => { + const removeThemeMock = vi.spyOn(UserDal, "removeTheme"); + + beforeEach(() => { + removeThemeMock.mockReset(); + }); + + it("should remove theme", async () => { + //GIVEN + const themeId = new ObjectId().toHexString(); + + //WHEN + const { body } = await mockApp + .delete("/users/customThemes") + .set("Authorization", `Uid ${uid}`) + .send({ themeId }) + .expect(200); + + //THEN + expect(body).toEqual({ + message: "Custom theme removed", + data: null, + }); + expect(removeThemeMock).toHaveBeenCalledWith(uid, themeId); + }); + it("should fail without mandatory properties", async () => { + //WHEN + const { body } = await mockApp + .delete("/users/customThemes") + .set("Authorization", `Uid ${uid}`) + .expect(422); + + //THEN + expect(body).toEqual({ + message: "Invalid request data schema", + validationErrors: ['"themeId" Required'], + }); + }); + it("should fail with unknown properties", async () => { + //WHEN + const { body } = await mockApp + .delete("/users/customThemes") + .set("Authorization", `Uid ${uid}`) + .send({ themeId: new ObjectId().toHexString(), extra: "value" }) + .expect(422); + + //THEN + expect(body).toEqual({ + message: "Invalid request data schema", + validationErrors: ["Unrecognized key(s) in object: 'extra'"], + }); + }); + }); + describe("edit custom theme", () => { + const editThemeMock = vi.spyOn(UserDal, "editTheme"); + beforeEach(() => { + editThemeMock.mockReset(); + }); + + it("should edit custom theme", async () => { + //GIVEN + const themeId = new ObjectId().toHexString(); + const theme = { + name: "newName", + colors: new Array(10).fill("#000000") as any, + }; + + //WHEN + const { body } = await mockApp + .patch("/users/customThemes") + .set("Authorization", `Uid ${uid}`) + .send({ + themeId, + theme, + }) + .expect(200); + + //THEN + expect(body).toEqual({ + message: "Custom theme updated", + data: null, + }); + expect(editThemeMock).toHaveBeenCalledWith(uid, themeId, theme); + }); + it("should fail without mandatory properties", async () => { + //WHEN + const { body } = await mockApp + .patch("/users/customThemes") + .set("Authorization", `Uid ${uid}`) + .expect(422); + + //THEN + expect(body).toEqual({ + message: "Invalid request data schema", + validationErrors: ['"themeId" Required', '"theme" Required'], + }); + }); + it("should fail with unknown properties", async () => { + //WHEN + const { body } = await mockApp + .patch("/users/customThemes") + .set("Authorization", `Uid ${uid}`) + .send({ + themeId: new ObjectId().toHexString(), + theme: { + name: "newName", + colors: new Array(10).fill("#000000") as any, + extra2: "value", + }, + extra: "value", + }) + .expect(422); + + //THEN + expect(body).toEqual({ + message: "Invalid request data schema", + validationErrors: [ + `"theme" Unrecognized key(s) in object: 'extra2'`, + "Unrecognized key(s) in object: 'extra'", + ], + }); + }); + }); + describe("get personal bests", () => { + const getPBMock = vi.spyOn(UserDal, "getPersonalBests"); + beforeEach(() => { + getPBMock.mockReset(); + }); + + it("should get pbs", async () => { + //GIVEN + const personalBest: PersonalBest = pb(15); + getPBMock.mockResolvedValue(personalBest); + + //WHEN + const { body } = await mockApp + .get("/users/personalBests") + .set("Authorization", `Uid ${uid}`) + .query({ mode: "time", mode2: "15" }) + .expect(200); + + //THEN + expect(body).toEqual({ + message: "Personal bests retrieved", + data: personalBest, + }); + expect(getPBMock).toHaveBeenCalledWith(uid, "time", "15"); + }); + it("should get pbs with ape key", async () => { + //GIVEN + await acceptApeKeys(true); + const apeKey = await mockAuthenticateWithApeKey(uid, await configuration); + + //WHEN + await mockApp + .get("/users/personalBests") + .set("authorization", `ApeKey ${apeKey}`) + .query({ mode: "time", mode2: "15" }) + .expect(200); + }); + it("should fail without mandatory query parameters", async () => { + //WHEN + const { body } = await mockApp + .get("/users/personalBests") + .set("Authorization", `Uid ${uid}`) + .expect(422); + + //THEN + expect(body).toEqual({ + message: "Invalid query schema", + validationErrors: [ + '"mode" Required', + '"mode2" Needs to be either a number, "zen" or "custom".', + ], + }); + }); + it("should fail with unknown query parameters", async () => { + //WHEN + const { body } = await mockApp + .get("/users/personalBests") + .set("Authorization", `Uid ${uid}`) + .query({ mode: "time", mode2: "15", extra: "value" }) + .expect(422); + + //THEN + expect(body).toEqual({ + message: "Invalid query schema", + validationErrors: ["Unrecognized key(s) in object: 'extra'"], + }); + }); + it("should fail with invalid query parameters", async () => { + //WHEN + const { body } = await mockApp + .get("/users/personalBests") + .set("Authorization", `Uid ${uid}`) + .query({ mode: "mood", mode2: "happy" }) + + .expect(422); + + //THEN + expect(body).toEqual({ + message: "Invalid query schema", + validationErrors: [ + `"mode" Invalid enum value. Expected 'time' | 'words' | 'quote' | 'custom' | 'zen', received 'mood'`, + `"mode2" Needs to be a number or a number represented as a string e.g. "10".`, + ], + }); + }); + }); + describe("get stats", () => { + const getStatsMock = vi.spyOn(UserDal, "getStats"); + beforeEach(() => { + getStatsMock.mockReset(); + }); + + it("should get stats", async () => { + //GIVEN + const stats: Pick< + MonkeyTypes.DBUser, + "startedTests" | "completedTests" | "timeTyping" + > = { + startedTests: 5, + completedTests: 3, + timeTyping: 42, + }; + getStatsMock.mockResolvedValue(stats); + + //WHEN + const { body } = await mockApp + .get("/users/stats") + .set("Authorization", `Uid ${uid}`) + .expect(200); + + //THEN + expect(body).toEqual({ + message: "Personal stats retrieved", + data: stats, + }); + + expect(getStatsMock).toHaveBeenCalledWith(uid); + }); + it("should get stats with ape key", async () => { + //GIVEN + await acceptApeKeys(true); + const apeKey = await mockAuthenticateWithApeKey(uid, await configuration); + + //WHEN + await mockApp + .get("/users/stats") + .set("authorization", `ApeKey ${apeKey}`) + .expect(200); + }); + }); + describe("get favorite quotes", () => { + const getFavoriteQuotesMock = vi.spyOn(UserDal, "getFavoriteQuotes"); + beforeEach(() => { + getFavoriteQuotesMock.mockReset(); + }); + + it("should get favorite quites", async () => { + //GIVEN + const favoriteQuotes = { + english: ["1", "2"], + german: ["1", "3"], + }; + getFavoriteQuotesMock.mockResolvedValue(favoriteQuotes); + + //WHEN + const { body } = await mockApp + .get("/users/favoriteQuotes") + .set("Authorization", `Uid ${uid}`) + .expect(200); + + //THEN + expect(body).toEqual({ + message: "Favorite quotes retrieved", + data: favoriteQuotes, + }); + expect(getFavoriteQuotesMock).toHaveBeenCalledWith(uid); + }); + }); + describe("add favorite quotes", () => { + const addFavoriteQuoteMock = vi.spyOn(UserDal, "addFavoriteQuote"); + beforeEach(() => { + addFavoriteQuoteMock.mockReset(); + }); + it("should add", async () => { + //WHEN + const { body } = await mockApp + .post("/users/favoriteQuotes") + .set("Authorization", `Uid ${uid}`) + .send({ language: "english", quoteId: "7" }) + .expect(200); + + //THEN + expect(body).toEqual({ + message: "Quote added to favorites", + data: null, + }); + expect(addFavoriteQuoteMock).toHaveBeenCalledWith( + uid, + "english", + "7", + (await Configuration.getLiveConfiguration()).quotes.maxFavorites + ); + }); + it("should fail without mandatory properties", async () => { + //WHEN + const { body } = await mockApp + .post("/users/favoriteQuotes") + .set("Authorization", `Uid ${uid}`) + .expect(422); + + //THEN + expect(body).toEqual({ + message: "Invalid request data schema", + validationErrors: ['"language" Required', '"quoteId" Invalid input'], + }); + }); + it("should fail unknown properties", async () => { + //WHEN + const { body } = await mockApp + .post("/users/favoriteQuotes") + .set("Authorization", `Uid ${uid}`) + .send({ language: "english", quoteId: "7", extra: "value" }) + .expect(422); + + //THEN + expect(body).toEqual({ + message: "Invalid request data schema", + validationErrors: ["Unrecognized key(s) in object: 'extra'"], + }); + }); + }); + describe("remove favorite quote", () => { + const removeFavoriteQuoteMock = vi.spyOn(UserDal, "removeFavoriteQuote"); + beforeEach(() => { + removeFavoriteQuoteMock.mockReset(); + }); + + it("should remove quote", async () => { + //WHEN + const { body } = await mockApp + .delete("/users/favoriteQuotes") + .set("Authorization", `Uid ${uid}`) + .send({ language: "english", quoteId: "7" }) + .expect(200); + + //THEN + expect(body).toEqual({ + message: "Quote removed from favorites", + data: null, + }); + expect(removeFavoriteQuoteMock).toHaveBeenCalledWith(uid, "english", "7"); + }); + it("should fail without mandatory properties", async () => { + //WHEN + const { body } = await mockApp + .delete("/users/favoriteQuotes") + .set("Authorization", `Uid ${uid}`) + .expect(422); + + //THEN + expect(body).toEqual({ + message: "Invalid request data schema", + validationErrors: ['"language" Required', '"quoteId" Invalid input'], + }); + }); + it("should fail unknown properties", async () => { + //WHEN + const { body } = await mockApp + .delete("/users/favoriteQuotes") + .set("Authorization", `Uid ${uid}`) + .send({ language: "english", quoteId: "7", extra: "value" }) + .expect(422); + + //THEN + expect(body).toEqual({ + message: "Invalid request data schema", + validationErrors: ["Unrecognized key(s) in object: 'extra'"], + }); + }); + }); + describe("get profile", () => { + const getUserMock = vi.spyOn(UserDal, "getUser"); + const getUserByNameMock = vi.spyOn(UserDal, "getUserByName"); + const checkIfUserIsPremiumMock = vi.spyOn(UserDal, "checkIfUserIsPremium"); + const leaderboardGetRankMock = vi.spyOn(LeaderboardDal, "getRank"); + + const foundUser: Partial = { + _id: new ObjectId(), + uid: new ObjectId().toHexString(), + name: "bob", + banned: false, + inventory: { badges: [{ id: 1, selected: true }, { id: 2 }] }, + profileDetails: { + bio: "bio", + keyboard: "keyboard", + socialProfiles: { + twitter: "twitter", + github: "github", + }, + }, + personalBests: { + time: { + "15": [pb(15), pb(16)], + "30": [pb(30), pb(31)], + "60": [pb(60), pb(61)], + "120": [pb(120), pb(121)], + "42": [pb(42), pb(43)], + }, + words: { + "10": [pb(10), pb(11)], + "25": [pb(25), pb(26)], + "50": [pb(50), pb(51)], + "100": [pb(100), pb(101)], + "42": [pb(42), pb(43)], + }, + custom: {}, + zen: {}, + quote: {}, + }, + completedTests: 23, + startedTests: 42, + timeTyping: 234, + addedAt: 1000, + discordId: "discordId", + discordAvatar: "discordAvatar", + xp: 10, + streak: { length: 2, lastResultTimestamp: 2000, maxLength: 5 }, + lbOptOut: false, + bananas: 47, //should get removed + }; + + beforeEach(async () => { + getUserMock.mockReset(); + getUserByNameMock.mockReset(); + checkIfUserIsPremiumMock.mockReset().mockResolvedValue(true); + leaderboardGetRankMock.mockReset(); + await enableProfiles(true); + }); + + it("should get by name without authentication", async () => { + //GIVEN + getUserByNameMock.mockResolvedValue(foundUser as any); + + const rank: LeaderboardRank = { count: 100, rank: 24 }; + leaderboardGetRankMock.mockResolvedValue(rank); + + //WHEN + const { body } = await mockApp.get("/users/bob/profile").expect(200); + + //THEN + expect(body).toEqual({ + message: "Profile retrieved", + data: { + uid: foundUser.uid, + name: "bob", + banned: false, + addedAt: 1000, + typingStats: { + completedTests: 23, + startedTests: 42, + timeTyping: 234, + }, + personalBests: { + time: { + "15": foundUser.personalBests?.time["15"], + "30": foundUser.personalBests?.time["30"], + "60": foundUser.personalBests?.time["60"], + "120": foundUser.personalBests?.time["120"], + }, + words: { + "10": foundUser.personalBests?.words["10"], + "25": foundUser.personalBests?.words["25"], + "50": foundUser.personalBests?.words["50"], + "100": foundUser.personalBests?.words["100"], + }, + }, + + discordId: "discordId", + discordAvatar: "discordAvatar", + xp: 10, + streak: 2, + maxStreak: 5, + lbOptOut: false, + isPremium: true, + allTimeLbs: { + time: { + "15": { english: { count: 100, rank: 24 } }, + "60": { english: { count: 100, rank: 24 } }, + }, + }, + inventory: foundUser.inventory, + details: foundUser.profileDetails, + }, + }); + expect(getUserByNameMock).toHaveBeenCalledWith("bob", "get user profile"); + expect(getUserMock).not.toHaveBeenCalled(); + }); + it("should get base profile for banned user", async () => { + //GIVEN + getUserByNameMock.mockResolvedValue({ + ...foundUser, + banned: true, + } as any); + + const rank: LeaderboardRank = { count: 100, rank: 24 }; + leaderboardGetRankMock.mockResolvedValue(rank); + + //WHEN + const { body } = await mockApp.get("/users/bob/profile").expect(200); + + //THEN + expect(body).toEqual({ + message: "Profile retrived: banned user", + data: { + name: "bob", + banned: true, + addedAt: 1000, + typingStats: { + completedTests: 23, + startedTests: 42, + timeTyping: 234, + }, + personalBests: { + time: { + "15": foundUser.personalBests?.time["15"], + "30": foundUser.personalBests?.time["30"], + "60": foundUser.personalBests?.time["60"], + "120": foundUser.personalBests?.time["120"], + }, + words: { + "10": foundUser.personalBests?.words["10"], + "25": foundUser.personalBests?.words["25"], + "50": foundUser.personalBests?.words["50"], + "100": foundUser.personalBests?.words["100"], + }, + }, + + discordId: "discordId", + discordAvatar: "discordAvatar", + xp: 10, + streak: 2, + maxStreak: 5, + lbOptOut: false, + isPremium: true, + }, + }); + expect(getUserByNameMock).toHaveBeenCalledWith("bob", "get user profile"); + expect(getUserMock).not.toHaveBeenCalled(); + }); + it("should get by uid without authentication", async () => { + //GIVEN + const uid = foundUser.uid; + getUserMock.mockResolvedValue(foundUser as any); + + const rank: LeaderboardRank = { count: 100, rank: 24 }; + leaderboardGetRankMock.mockResolvedValue(rank); + + //WHEN + const { body } = await mockApp + .get(`/users/${uid}/profile`) + .query({ isUid: "" }) + .expect(200); + + //THEN + expect(body).toEqual({ + message: "Profile retrieved", + data: expect.objectContaining({ + uid: foundUser.uid, + }), + }); + expect(getUserByNameMock).not.toHaveBeenCalled(); + expect(getUserMock).toHaveBeenCalledWith(uid, "get user profile"); + }); + it("should fail if feature is disabled", async () => { + //GIVEN + await enableProfiles(false); + + //WHEN + const { body } = await mockApp.get(`/users/bob/profile`).expect(503); + + //THEN + expect(body.message).toEqual("Profiles are not available at this time"); + }); + }); + describe("update profile", () => { + const getPartialUserMock = vi.spyOn(UserDal, "getPartialUser"); + const updateProfileMock = vi.spyOn(UserDal, "updateProfile"); + + beforeEach(async () => { + getPartialUserMock.mockReset().mockResolvedValue({ + inventory: { + badges: [{ id: 4, selected: true }, { id: 2 }, { id: 3 }], + }, + } as any); + updateProfileMock.mockReset(); + await enableProfiles(true); + }); + + it("should update", async () => { + //GIVEN + const newProfile = { + bio: "newBio", + keyboard: "newKeyboard", + + socialProfiles: { + github: "github", + twitter: "twitter", + website: "https://monkeytype.com", + }, + }; + + //WHEN + const { body } = await mockApp + .patch("/users/profile") + .set("Authorization", `Uid ${uid}`) + .send({ + ...newProfile, + selectedBadgeId: 2, + }) + .expect(200); + + //THEN + expect(body).toEqual({ + message: "Profile updated", + data: newProfile, + }); + expect(updateProfileMock).toHaveBeenCalledWith( + uid, + { + bio: "newBio", + keyboard: "newKeyboard", + socialProfiles: { + github: "github", + twitter: "twitter", + website: "https://monkeytype.com", + }, + }, + { + badges: [{ id: 4 }, { id: 2, selected: true }, { id: 3 }], + } + ); + }); + it("should fail with unknown properties", async () => { + //WHEN + const { body } = await mockApp + .patch("/users/profile") + .set("Authorization", `Uid ${uid}`) + .send({ + extra: "value", + socialProfiles: { + extra2: "value", + }, + }) + .expect(422); + + //THEN + expect(body).toEqual({ + message: "Invalid request data schema", + validationErrors: [ + `"socialProfiles" Unrecognized key(s) in object: 'extra2'`, + "Unrecognized key(s) in object: 'extra'", + ], + }); + }); + it("should sanitize inputs", async () => { + //WHEN + await mockApp + .patch("/users/profile") + .set("Authorization", `Uid ${uid}`) + .send({ + bio: "Line1\n\n\nLine2\n\n\n\nLine3", + keyboard: " string with many spaces ", + }) + .expect(200); + + //THEN + expect(updateProfileMock).toHaveBeenCalledWith( + uid, + { + bio: "Line1\n\nLine2\n\nLine3", + keyboard: "string with many spaces", + socialProfiles: {}, + }, + expect.objectContaining({}) + ); + }); + it("should fail with profanity", async () => { + //WHEN + const { body } = await mockApp + .patch("/users/profile") + .set("Authorization", `Uid ${uid}`) + .send({ + bio: "miodec", + keyboard: "miodec", + socialProfiles: { + twitter: "miodec", + github: "miodec", + website: "https://i-luv-miodec.com", + }, + }) + .expect(422); + + //THEN + expect(body).toEqual({ + message: "Invalid request data schema", + validationErrors: [ + '"bio" Profanity detected. Please remove it. If you believe this is a mistake, please contact us. (miodec)', + '"keyboard" Profanity detected. Please remove it. If you believe this is a mistake, please contact us. (miodec)', + '"socialProfiles.twitter" Profanity detected. Please remove it. If you believe this is a mistake, please contact us. (miodec)', + '"socialProfiles.github" Profanity detected. Please remove it. If you believe this is a mistake, please contact us. (miodec)', + '"socialProfiles.website" Profanity detected. Please remove it. If you believe this is a mistake, please contact us. (https://i-luv-miodec.com)', + ], + }); + }); + it("should fail with properties exceeding max lengths", async () => { + //WHEN + const { body } = await mockApp + .patch("/users/profile") + .set("Authorization", `Uid ${uid}`) + .send({ + bio: new Array(251).fill("x").join(""), + keyboard: new Array(76).fill("x").join(""), + socialProfiles: { + twitter: new Array(21).fill("x").join(""), + github: new Array(40).fill("x").join(""), + website: + "https://" + + new Array(201 - "https://".length).fill("x").join(""), + }, + }) + .expect(422); + + //THEN + expect(body).toEqual({ + message: "Invalid request data schema", + validationErrors: [ + '"bio" String must contain at most 250 character(s)', + '"keyboard" String must contain at most 75 character(s)', + '"socialProfiles.twitter" String must contain at most 20 character(s)', + '"socialProfiles.github" String must contain at most 39 character(s)', + '"socialProfiles.website" String must contain at most 200 character(s)', + ], + }); + }); + it("should fail with website not using https", async () => { + //WHEN + const { body } = await mockApp + .patch("/users/profile") + .set("Authorization", `Uid ${uid}`) + .send({ + socialProfiles: { + website: "http://monkeytype.com", + }, + }) + .expect(422); + + //THEN + expect(body).toEqual({ + message: "Invalid request data schema", + validationErrors: [ + '"socialProfiles.website" Invalid input: must start with "https://"', + ], + }); + }); + it("should fail if feature is disabled", async () => { + //GIVEN + await enableProfiles(false); + + //WHEN + const { body } = await mockApp + .patch("/users/profile") + .set("Authorization", `Uid ${uid}`) + .send({}) + .expect(503); + + //THEN + expect(body.message).toEqual("Profiles are not available at this time"); + }); + }); + describe("get inbox", () => { + const getInboxMock = vi.spyOn(UserDal, "getInbox"); + + beforeEach(async () => { + getInboxMock.mockReset(); + await enableInbox(true); + }); + + it("shold get inbox", async () => { + //GIVEN + const mailOne: MonkeyMail = { + id: randomUUID(), + subject: "subjectOne", + body: "bodyOne", + timestamp: 100, + read: false, + rewards: [], + }; + const mailTwo: MonkeyMail = { + id: randomUUID(), + subject: "subjectTwo", + body: "bodyTwo", + timestamp: 100, + read: false, + rewards: [], + }; + getInboxMock.mockResolvedValue([mailOne, mailTwo]); + + //WHEN + const { body } = await mockApp + .get("/users/inbox") + .set("Authorization", `Uid ${uid}`) + .expect(200); + + //THEN + expect(body).toEqual({ + message: "Inbox retrieved", + data: { + inbox: [mailOne, mailTwo], + maxMail: (await Configuration.getLiveConfiguration()).users.inbox + .maxMail, + }, + }); + expect(getInboxMock).toHaveBeenCalledWith(uid); + }); + it("should fail if feature is disabled", async () => { + //GIVEN + await enableInbox(false); + + //WHEN + const { body } = await mockApp + .get("/users/inbox") + .set("Authorization", `Uid ${uid}`) + .expect(503); + + //THEN + expect(body.message).toEqual("Your inbox is not available at this time."); + }); + }); + describe("update inbox", () => { + const updateInboxMock = vi.spyOn(UserDal, "updateInbox"); + const mailIdOne = randomUUID(); + const mailIdTwo = randomUUID(); + beforeEach(async () => { + updateInboxMock.mockReset(); + await enableInbox(true); + }); + + it("should update", async () => { + //WHEN + const { body } = await mockApp + .patch("/users/inbox") + .set("Authorization", `Uid ${uid}`) + .send({ + mailIdsToDelete: [mailIdOne], + mailIdsToMarkRead: [mailIdOne, mailIdTwo], + }) + .expect(200); + + //THEN + expect(body).toEqual({ + message: "Inbox updated", + data: null, + }); + + expect(updateInboxMock).toHaveBeenCalledWith( + uid, + [mailIdOne, mailIdTwo], + [mailIdOne] + ); + }); + it("should update without body", async () => { + //WHEN + const { body } = await mockApp + .patch("/users/inbox") + .set("Authorization", `Uid ${uid}`); + //.expect(200); + console.log(body); + + //THEN + expect(body).toEqual({ + message: "Inbox updated", + data: null, + }); + + expect(updateInboxMock).toHaveBeenCalledWith(uid, [], []); + }); + it("should fail with empty arrays", async () => { + //WHEN + const { body } = await mockApp + .patch("/users/inbox") + .set("Authorization", `Uid ${uid}`) + .send({ + mailIdsToDelete: [], + mailIdsToMarkRead: [], + }) + .expect(422); + + //THEN + expect(body).toEqual({ + message: "Invalid request data schema", + validationErrors: [ + '"mailIdsToDelete" Array must contain at least 1 element(s)', + '"mailIdsToMarkRead" Array must contain at least 1 element(s)', + ], + }); + }); + it("should fail if feature is disabled", async () => { + //GIVEN + await enableInbox(false); + + //WHEN + const { body } = await mockApp + .patch("/users/inbox") + .set("Authorization", `Uid ${uid}`) + .expect(503); + + //THEN + expect(body.message).toEqual("Your inbox is not available at this time."); + }); + }); + describe("report user", () => { + const createReportMock = vi.spyOn(ReportDal, "createReport"); + const verifyCaptchaMock = vi.spyOn(Captcha, "verify"); + const getPartialUserMock = vi.spyOn(UserDal, "getPartialUser"); //todo replace with getPartialUser + beforeEach(async () => { + vi.useFakeTimers(); + vi.setSystemTime(125000); + createReportMock.mockReset().mockResolvedValue(); + verifyCaptchaMock.mockReset().mockResolvedValue(true); + getPartialUserMock.mockReset().mockResolvedValue({} as any); + + await enableReporting(true); + }); + afterEach(() => { + vi.useRealTimers(); + }); + + it("should report", async () => { + //WHEN + const uidToReport = new ObjectId().toHexString(); + + const { body } = await mockApp + .post("/users/report") + .set("Authorization", `Uid ${uid}`) + .send({ + uid: uidToReport, + reason: "Suspected cheating", + comment: "comment", + captcha: "captcha", + }) + .expect(200); + + //THEN + expect(body).toEqual({ + message: "User reported", + data: null, + }); + expect(createReportMock).toHaveBeenCalledWith( + expect.objectContaining({ + type: "user", + timestamp: 125000, + uid, + contentId: uidToReport, + reason: "Suspected cheating", + comment: "comment", + }), + (await Configuration.getLiveConfiguration()).quotes.reporting + .maxReports, + (await Configuration.getLiveConfiguration()).quotes.reporting + .contentReportLimit + ); + expect(verifyCaptchaMock).toHaveBeenCalledWith("captcha"); + }); + it("should fail without mandatory properties", async () => { + //WHEN + const { body } = await mockApp + .post("/users/report") + .set("Authorization", `Uid ${uid}`) + .expect(422); + + //THEN + expect(body).toEqual({ + message: "Invalid request data schema", + validationErrors: [ + '"uid" Required', + '"reason" Required', + '"captcha" Required', + ], + }); + }); + it("should fail with unknown properties", async () => { + //WHEN + const { body } = await mockApp + .post("/users/report") + .set("Authorization", `Uid ${uid}`) + .send({ + uid: new ObjectId().toHexString(), + reason: "Suspected cheating", + comment: "comment", + captcha: "captcha", + extra: "value", + }) + .expect(422); + + //THEN + expect(body).toEqual({ + message: "Invalid request data schema", + validationErrors: ["Unrecognized key(s) in object: 'extra'"], + }); + }); + it("should fail with invalid captcha", async () => { + //GIVEN + verifyCaptchaMock.mockResolvedValue(false); + + //WHEN + const { body } = await mockApp + .post("/users/report") + .set("Authorization", `Uid ${uid}`) + .send({ + uid: new ObjectId().toHexString(), + reason: "Suspected cheating", + comment: "comment", + captcha: "captcha", + }) + .expect(422); + + //THEN + expect(body.message).toEqual("Captcha challenge failed"); + /* TODO + expect(body).toEqual({}); + */ + }); + it("should fail with invalid properties", async () => { + //WHEN + const { body } = await mockApp + .post("/users/report") + .set("Authorization", `Uid ${uid}`) + .send({ + uid: new Array(51).fill("x").join(""), + reason: "unfriendly", + comment: new Array(251).fill("x").join(""), + captcha: "captcha", + }) + .expect(422); + + //THEN + expect(body).toEqual({ + message: "Invalid request data schema", + validationErrors: [ + `"reason" Invalid enum value. Expected 'Inappropriate name' | 'Inappropriate bio' | 'Inappropriate social links' | 'Suspected cheating', received 'unfriendly'`, + '"comment" String must contain at most 250 character(s)', + ], + }); + }); + it("should fail if user can not report", async () => { + //GIVEN + getPartialUserMock.mockResolvedValue({ canReport: false } as any); + + //WHEN + const { body } = await mockApp + .post("/users/report") + .set("Authorization", `Uid ${uid}`) + .send({ + uid: new ObjectId().toHexString(), + reason: "Suspected cheating", + comment: "comment", + captcha: "captcha", + }) + .expect(403); + + //THEN + expect(body.message).toEqual("You don't have permission to do this."); + }); + it("should fail if feature is disabled", async () => { + //GIVEN + await enableReporting(false); + + //WHEN + const { body } = await mockApp + .post("/users/report") + .set("Authorization", `Uid ${uid}`) + .send({ + uid: new ObjectId().toHexString(), + reason: "Suspected cheating", + comment: "comment", + captcha: "captcha", + }) + .expect(503); + + //THEN + expect(body.message).toEqual("User reporting is unavailable."); + }); + }); + describe("set streak hour offset", () => { + const getPartialUserMock = vi.spyOn(UserDal, "getPartialUser"); + const setStreakHourOffsetMock = vi.spyOn(UserDal, "setStreakHourOffset"); + const addImportantLogMock = vi.spyOn(LogDal, "addImportantLog"); + + beforeEach(() => { + getPartialUserMock.mockReset().mockResolvedValue({} as any); + setStreakHourOffsetMock.mockReset(); + addImportantLogMock.mockReset(); + }); + + it("should set", async () => { + //WHEN + const { body } = await mockApp + .post("/users/setStreakHourOffset") + .set("Authorization", `Uid ${uid}`) + .send({ hourOffset: -2 }) + .expect(200); + + //THEN + expect(body).toEqual({ + message: "Streak hour offset set", + data: null, + }); + + expect(setStreakHourOffsetMock).toHaveBeenCalledWith(uid, -2); + expect(addImportantLogMock).toHaveBeenCalledWith( + "user_streak_hour_offset_set", + { hourOffset: -2 }, + uid + ); + }); + it("should fail if offset already set", async () => { + //GIVEN + getPartialUserMock.mockResolvedValue({ + streak: { hourOffset: -2 }, + } as any); + + //WHEN + const { body } = await mockApp + .post("/users/setStreakHourOffset") + .set("Authorization", `Uid ${uid}`) + .send({ hourOffset: -2 }) + .expect(403); + + //THEN + expect(body.message).toEqual("Streak hour offset already set"); + expect(setStreakHourOffsetMock).not.toHaveBeenCalled(); + expect(addImportantLogMock).not.toHaveBeenCalled(); + }); + it("should fail without mandatory properties", async () => { + //WHEN + const { body } = await mockApp + .post("/users/setStreakHourOffset") + .set("Authorization", `Uid ${uid}`) + .expect(422); + + //THEN + expect(body).toEqual({ + message: "Invalid request data schema", + validationErrors: ['"hourOffset" Required'], + }); + }); + it("should fail with invalid offset", async () => { + await mockApp + .post("/users/setStreakHourOffset") + .set("Authorization", `Uid ${uid}`) + .send({ hourOffset: -12 }) + .expect(422); + + await mockApp + .post("/users/setStreakHourOffset") + .set("Authorization", `Uid ${uid}`) + .send({ hourOffset: 13 }) + .expect(422); + + await mockApp + .post("/users/setStreakHourOffset") + .set("Authorization", `Uid ${uid}`) + .send({ hourOffset: "UTC-8" }) + .expect(422); + }); + }); + describe("revoke all token", () => { + const removeTokensByUidMock = vi.spyOn(AuthUtils, "revokeTokensByUid"); + const addImportantLogMock = vi.spyOn(LogDal, "addImportantLog"); + + beforeEach(() => { + removeTokensByUidMock.mockReset(); + addImportantLogMock.mockReset(); + }); + it("should revoke all tokens", async () => { + //WHEN + const { body } = await mockApp + .post("/users/revokeAllTokens") + .set("Authorization", `Uid ${uid}`) + .expect(200); + + //THEN + expect(body).toEqual({ + message: "All tokens revoked", + data: null, + }); + expect(removeTokensByUidMock).toHaveBeenCalledWith(uid); + expect(addImportantLogMock).toHaveBeenCalledWith( + "user_tokens_revoked", + "", + uid + ); + }); }); describe("getCurrentTestActivity", () => { const getUserMock = vi.spyOn(UserDal, "getPartialUser"); @@ -667,7 +3505,7 @@ describe("user controller test", () => { .expect(200); //THEN - const streak: SharedTypes.UserStreak = result.body.data; + const streak: UserStreak = result.body.data; expect(streak).toEqual({ lastResultTimestamp: 1712102400000, length: 42, @@ -725,3 +3563,52 @@ async function enableDiscordIntegration(enabled: boolean): Promise { mockConfig ); } + +async function enableResultFilterPresets(enabled: boolean): Promise { + const mockConfig = _.merge(await configuration, { + results: { filterPresets: { enabled } }, + }); + + vi.spyOn(Configuration, "getCachedConfiguration").mockResolvedValue( + mockConfig + ); +} + +async function acceptApeKeys(enabled: boolean): Promise { + const mockConfig = _.merge(await configuration, { + apeKeys: { acceptKeys: enabled }, + }); + + vi.spyOn(Configuration, "getCachedConfiguration").mockResolvedValue( + mockConfig + ); +} + +async function enableProfiles(enabled: boolean): Promise { + const mockConfig = _.merge(await configuration, { + users: { profiles: { enabled } }, + }); + + vi.spyOn(Configuration, "getCachedConfiguration").mockResolvedValue( + mockConfig + ); +} +async function enableInbox(enabled: boolean): Promise { + const mockConfig = _.merge(await configuration, { + users: { inbox: { enabled } }, + }); + + vi.spyOn(Configuration, "getCachedConfiguration").mockResolvedValue( + mockConfig + ); +} + +async function enableReporting(enabled: boolean): Promise { + const mockConfig = _.merge(await configuration, { + quotes: { reporting: { enabled } }, + }); + + vi.spyOn(Configuration, "getCachedConfiguration").mockResolvedValue( + mockConfig + ); +} diff --git a/backend/__tests__/dal/leaderboards.spec.ts b/backend/__tests__/dal/leaderboards.spec.ts index f96822a9b..1c3925b3a 100644 --- a/backend/__tests__/dal/leaderboards.spec.ts +++ b/backend/__tests__/dal/leaderboards.spec.ts @@ -323,7 +323,7 @@ function lbBests( return result; } -function pb( +export function pb( wpm: number, acc: number = 90, timestamp: number = 1 diff --git a/backend/__tests__/dal/result.spec.ts b/backend/__tests__/dal/result.spec.ts index fef2dfc3c..e37d16f43 100644 --- a/backend/__tests__/dal/result.spec.ts +++ b/backend/__tests__/dal/result.spec.ts @@ -2,10 +2,6 @@ import * as ResultDal from "../../src/dal/result"; import { ObjectId } from "mongodb"; import * as UserDal from "../../src/dal/user"; -type MonkeyTypesResult = MonkeyTypes.WithObjectId< - SharedTypes.DBResult ->; - let uid: string = ""; const timestamp = Date.now() - 60000; @@ -60,7 +56,7 @@ async function createDummyData( language: "english", isPb: false, name: "Test", - } as MonkeyTypesResult); + } as MonkeyTypes.DBResult); } } describe("ResultDal", () => { diff --git a/backend/__tests__/dal/user.spec.ts b/backend/__tests__/dal/user.spec.ts index 4ce193a1c..e8feb2de1 100644 --- a/backend/__tests__/dal/user.spec.ts +++ b/backend/__tests__/dal/user.spec.ts @@ -2,6 +2,12 @@ import _ from "lodash"; import * as UserDAL from "../../src/dal/user"; import * as UserTestData from "../__testData__/users"; import { ObjectId } from "mongodb"; +import { MonkeyMail, ResultFilters } from "@monkeytype/contracts/schemas/users"; +import { + PersonalBest, + PersonalBests, +} from "@monkeytype/contracts/schemas/shared"; +import { CustomThemeColors } from "@monkeytype/contracts/schemas/configs"; const mockPersonalBest = { acc: 1, @@ -15,7 +21,7 @@ const mockPersonalBest = { timestamp: 13123123, }; -const mockResultFilter: SharedTypes.ResultFilters = { +const mockResultFilter: ResultFilters = { _id: "id", name: "sfdkjhgdf", pb: { @@ -193,38 +199,6 @@ describe("UserDal", () => { ).rejects.toThrow("Username already taken"); }); - it("updatename should not allow invalid usernames", async () => { - // given - const testUser = { - name: "Test", - email: "mockemail@email.com", - uid: "userId", - }; - - await UserDAL.addUser(testUser.name, testUser.email, testUser.uid); - - const invalidNames = [ - null, // falsy - undefined, // falsy - "", // empty - " ".repeat(16), // too long - ".testName", // cant begin with period - "asdasdAS$", // invalid characters - ]; - - // when, then - invalidNames.forEach( - async (invalidName) => - await expect( - UserDAL.updateName( - testUser.uid, - invalidName as unknown as string, - testUser.name - ) - ).rejects.toThrow("Invalid username") - ); - }); - it("UserDAL.updateName should change the name of a user", async () => { // given const testUser = { @@ -474,7 +448,7 @@ describe("UserDal", () => { it("addTag success", async () => { // given - const emptyPb: SharedTypes.PersonalBests = { + const emptyPb: PersonalBests = { time: {}, words: {}, quote: {}, @@ -641,21 +615,21 @@ describe("UserDal", () => { name: "tagOne", personalBests: { custom: { custom: [mockPersonalBest] }, - } as SharedTypes.PersonalBests, + } as PersonalBests, }; const tagTwo = { _id: new ObjectId(), name: "tagTwo", personalBests: { custom: { custom: [mockPersonalBest] }, - } as SharedTypes.PersonalBests, + } as PersonalBests, }; const tagThree = { _id: new ObjectId(), name: "tagThree", personalBests: { custom: { custom: [mockPersonalBest] }, - } as SharedTypes.PersonalBests, + } as PersonalBests, }; const { uid } = await UserTestData.createUser({ @@ -1112,7 +1086,7 @@ describe("UserDal", () => { //then const read = (await UserDAL.getUser(user.uid, "")).testActivity || {}; expect(read).toHaveProperty("2024"); - const year2024 = read["2024"]; + const year2024 = read["2024"] as any; expect(year2024).toHaveLength(94); //fill previous days with null expect(year2024.slice(0, 93)).toEqual(new Array(93).fill(null)); @@ -1130,7 +1104,7 @@ describe("UserDal", () => { //then const read = (await UserDAL.getUser(user.uid, "")).testActivity || {}; expect(read).toHaveProperty("2024"); - const year2024 = read["2024"]; + const year2024 = read["2024"] as any; expect(year2024).toHaveLength(94); expect(year2024[0]).toBeNull(); @@ -1149,7 +1123,7 @@ describe("UserDal", () => { //then const read = (await UserDAL.getUser(user.uid, "")).testActivity || {}; - const year2024 = read["2024"]; + const year2024 = read["2024"] as any; expect(year2024[93]).toEqual(2); }); }); @@ -1279,7 +1253,7 @@ describe("UserDal", () => { describe("updateInbox", () => { it("claims rewards on read", async () => { //GIVEN - const rewardOne: SharedTypes.MonkeyMail = { + const rewardOne: MonkeyMail = { id: "b5866d4c-0749-41b6-b101-3656249d39b9", body: "test", subject: "reward one", @@ -1291,7 +1265,7 @@ describe("UserDal", () => { { type: "badge", item: { id: 4 } }, ], }; - const rewardTwo: SharedTypes.MonkeyMail = { + const rewardTwo: MonkeyMail = { id: "3692b9f5-84fb-4d9b-bd39-9a3217b3a33a", body: "test", subject: "reward two", @@ -1299,7 +1273,7 @@ describe("UserDal", () => { read: false, rewards: [{ type: "xp", item: 2000 }], }; - const rewardThree: SharedTypes.MonkeyMail = { + const rewardThree: MonkeyMail = { id: "0d73b3e0-dc79-4abb-bcaf-66fa6b09a58a", body: "test", subject: "reward three", @@ -1307,7 +1281,7 @@ describe("UserDal", () => { read: true, rewards: [{ type: "xp", item: 3000 }], }; - const rewardFour: SharedTypes.MonkeyMail = { + const rewardFour: MonkeyMail = { id: "d852d2cf-1802-4cd0-9fb4-336650fc470a", body: "test", subject: "reward four", @@ -1349,7 +1323,7 @@ describe("UserDal", () => { it("claims rewards on delete", async () => { //GIVEN //GIVEN - const rewardOne: SharedTypes.MonkeyMail = { + const rewardOne: MonkeyMail = { id: "b5866d4c-0749-41b6-b101-3656249d39b9", body: "test", subject: "reward one", @@ -1361,7 +1335,7 @@ describe("UserDal", () => { { type: "badge", item: { id: 4 } }, ], }; - const rewardTwo: SharedTypes.MonkeyMail = { + const rewardTwo: MonkeyMail = { id: "3692b9f5-84fb-4d9b-bd39-9a3217b3a33a", body: "test", subject: "reward two", @@ -1370,7 +1344,7 @@ describe("UserDal", () => { rewards: [{ type: "xp", item: 2000 }], }; - const rewardThree: SharedTypes.MonkeyMail = { + const rewardThree: MonkeyMail = { id: "0d73b3e0-dc79-4abb-bcaf-66fa6b09a58a", body: "test", subject: "reward three", @@ -1395,7 +1369,7 @@ describe("UserDal", () => { it("updates badge", async () => { //GIVEN - const rewardOne: SharedTypes.MonkeyMail = { + const rewardOne: MonkeyMail = { id: "b5866d4c-0749-41b6-b101-3656249d39b9", body: "test", subject: "reward one", @@ -1406,7 +1380,7 @@ describe("UserDal", () => { { type: "badge", item: { id: 4 } }, ], }; - const rewardTwo: SharedTypes.MonkeyMail = { + const rewardTwo: MonkeyMail = { id: "3692b9f5-84fb-4d9b-bd39-9a3217b3a33a", body: "test", subject: "reward two", @@ -1418,7 +1392,7 @@ describe("UserDal", () => { { type: "badge", item: { id: 5 } }, ], }; - const rewardThree: SharedTypes.MonkeyMail = { + const rewardThree: MonkeyMail = { id: "0d73b3e0-dc79-4abb-bcaf-66fa6b09a58a", body: "test", subject: "reward three", @@ -1460,7 +1434,7 @@ describe("UserDal", () => { }); it("read and delete the same message does not claim reward twice", async () => { //GIVEN - const rewardOne: SharedTypes.MonkeyMail = { + const rewardOne: MonkeyMail = { id: "b5866d4c-0749-41b6-b101-3656249d39b9", body: "test", subject: "reward one", @@ -1468,7 +1442,7 @@ describe("UserDal", () => { read: false, rewards: [{ type: "xp", item: 1000 }], }; - const rewardTwo: SharedTypes.MonkeyMail = { + const rewardTwo: MonkeyMail = { id: "3692b9f5-84fb-4d9b-bd39-9a3217b3a33a", body: "test", subject: "reward two", @@ -1495,7 +1469,7 @@ describe("UserDal", () => { it("concurrent calls dont claim a reward multiple times", async () => { //GIVEN - const rewardOne: SharedTypes.MonkeyMail = { + const rewardOne: MonkeyMail = { id: "b5866d4c-0749-41b6-b101-3656249d39b9", body: "test", subject: "reward one", @@ -1507,7 +1481,7 @@ describe("UserDal", () => { { type: "badge", item: { id: 4 } }, ], }; - const rewardTwo: SharedTypes.MonkeyMail = { + const rewardTwo: MonkeyMail = { id: "3692b9f5-84fb-4d9b-bd39-9a3217b3a33a", body: "test", subject: "reward two", @@ -1515,7 +1489,7 @@ describe("UserDal", () => { read: false, rewards: [{ type: "xp", item: 2000 }], }; - const rewardThree: SharedTypes.MonkeyMail = { + const rewardThree: MonkeyMail = { id: "0d73b3e0-dc79-4abb-bcaf-66fa6b09a58a", body: "test", subject: "reward three", @@ -1649,8 +1623,8 @@ describe("UserDal", () => { personalBests: { time: { "60": [ - { wpm: 100 } as SharedTypes.PersonalBest, - { wpm: 30 } as SharedTypes.PersonalBest, //highest PB should be used + { wpm: 100 } as PersonalBest, + { wpm: 30 } as PersonalBest, //highest PB should be used ], }, } as any, @@ -1743,7 +1717,10 @@ describe("UserDal", () => { it("should return error if uid not found", async () => { // when, then await expect( - UserDAL.addTheme("non existing uid", { name: "new", colors: [] }) + UserDAL.addTheme("non existing uid", { + name: "new", + colors: [] as any, + }) ).rejects.toThrow( "Maximum number of custom themes reached\nStack: add theme" ); @@ -1755,13 +1732,13 @@ describe("UserDal", () => { customThemes: new Array(10).fill(0).map(() => ({ _id: new ObjectId(), name: "any", - colors: [], + colors: [] as any, })), }); // when, then await expect( - UserDAL.addTheme(uid, { name: "new", colors: [] }) + UserDAL.addTheme(uid, { name: "new", colors: [] as any }) ).rejects.toThrow( "Maximum number of custom themes reached\nStack: add theme" ); @@ -1772,17 +1749,18 @@ describe("UserDal", () => { const themeOne = { _id: new ObjectId(), name: "first", - colors: ["green", "white", "red"], + colors: new Array(10).fill("#123456") as CustomThemeColors, }; const { uid } = await UserTestData.createUser({ customThemes: [themeOne], }); - // when - await UserDAL.addTheme(uid, { + const newTheme = { name: "newTheme", - colors: ["red", "white", "blue"], - }); + colors: new Array(10).fill("#000000") as CustomThemeColors, + }; + // when + await UserDAL.addTheme(uid, { ...newTheme }); // then const read = await UserDAL.getUser(uid, "read"); @@ -1790,11 +1768,11 @@ describe("UserDal", () => { expect.arrayContaining([ expect.objectContaining({ name: "first", - colors: ["green", "white", "red"], + colors: themeOne.colors, }), expect.objectContaining({ name: "newTheme", - colors: ["red", "white", "blue"], + colors: newTheme.colors, }), ]) ); @@ -1807,7 +1785,7 @@ describe("UserDal", () => { await expect( UserDAL.editTheme("non existing uid", new ObjectId().toHexString(), { name: "newName", - colors: [], + colors: [] as any, }) ).rejects.toThrow("Custom theme not found\nStack: edit theme"); }); @@ -1817,7 +1795,7 @@ describe("UserDal", () => { const themeOne = { _id: new ObjectId(), name: "first", - colors: ["green", "white", "red"], + colors: ["green", "white", "red"] as any, }; const { uid } = await UserTestData.createUser({ customThemes: [themeOne], @@ -1827,7 +1805,7 @@ describe("UserDal", () => { await expect( UserDAL.editTheme(uid, new ObjectId().toHexString(), { name: "newName", - colors: [], + colors: [] as any, }) ).rejects.toThrow("Custom theme not found\nStack: edit theme"); }); @@ -1837,7 +1815,7 @@ describe("UserDal", () => { const themeOne = { _id: new ObjectId(), name: "first", - colors: ["green", "white", "red"], + colors: ["green", "white", "red"] as any, }; const { uid } = await UserTestData.createUser({ customThemes: [themeOne], @@ -1845,7 +1823,7 @@ describe("UserDal", () => { // when await UserDAL.editTheme(uid, themeOne._id.toHexString(), { name: "newThemeName", - colors: ["red", "white", "blue"], + colors: ["red", "white", "blue"] as any, }); // then @@ -1869,7 +1847,7 @@ describe("UserDal", () => { const themeOne = { _id: new ObjectId(), name: "first", - colors: ["green", "white", "red"], + colors: ["green", "white", "red"] as any, }; const { uid } = await UserTestData.createUser({ customThemes: [themeOne], @@ -1885,18 +1863,18 @@ describe("UserDal", () => { const themeOne = { _id: new ObjectId(), name: "first", - colors: [], + colors: [] as any, }; const themeTwo = { _id: new ObjectId(), name: "second", - colors: [], + colors: [] as any, }; const themeThree = { _id: new ObjectId(), name: "third", - colors: [], + colors: [] as any, }; const { uid } = await UserTestData.createUser({ diff --git a/backend/__tests__/tsconfig.json b/backend/__tests__/tsconfig.json index ddfdc95f8..1069df217 100644 --- a/backend/__tests__/tsconfig.json +++ b/backend/__tests__/tsconfig.json @@ -8,10 +8,5 @@ "files": true }, "files": ["../src/types/types.d.ts"], - "include": [ - "./**/*.ts", - "./**/*.spec.ts", - "./setup-tests.ts", - "../../shared-types/**/*.d.ts" - ] + "include": ["./**/*.ts", "./**/*.spec.ts", "./setup-tests.ts"] } diff --git a/backend/__tests__/utils/pb.spec.ts b/backend/__tests__/utils/pb.spec.ts index a534a5208..28c9f77f3 100644 --- a/backend/__tests__/utils/pb.spec.ts +++ b/backend/__tests__/utils/pb.spec.ts @@ -1,5 +1,7 @@ import _ from "lodash"; import * as pb from "../../src/utils/pb"; +import { Mode, PersonalBests } from "@monkeytype/contracts/schemas/shared"; +import { Result } from "@monkeytype/contracts/schemas/results"; describe("Pb Utils", () => { it("funboxCatGetPb", () => { @@ -34,7 +36,7 @@ describe("Pb Utils", () => { }); describe("checkAndUpdatePb", () => { it("should update personal best", () => { - const userPbs: SharedTypes.PersonalBests = { + const userPbs: PersonalBests = { time: {}, words: {}, custom: {}, @@ -53,7 +55,7 @@ describe("Pb Utils", () => { numbers: false, mode: "time", mode2: "15", - } as unknown as SharedTypes.Result; + } as unknown as Result; const run = pb.checkAndUpdatePb(userPbs, undefined, result); @@ -61,7 +63,7 @@ describe("Pb Utils", () => { expect(run.personalBests?.["time"]?.["15"]?.[0]).not.toBe(undefined); }); it("should not override default pb when saving numbers test", () => { - const userPbs: SharedTypes.PersonalBests = { + const userPbs: PersonalBests = { time: { "15": [ { @@ -95,7 +97,7 @@ describe("Pb Utils", () => { numbers: true, mode: "time", mode2: "15", - } as unknown as SharedTypes.Result; + } as unknown as Result; const run = pb.checkAndUpdatePb(userPbs, undefined, result); diff --git a/backend/__tests__/utils/validation.spec.ts b/backend/__tests__/utils/validation.spec.ts index 3176d4229..0c9cb129e 100644 --- a/backend/__tests__/utils/validation.spec.ts +++ b/backend/__tests__/utils/validation.spec.ts @@ -119,44 +119,6 @@ describe("Validation", () => { }); }); - it("containsProfanity", () => { - const testCases = [ - { - text: "https://www.fuckyou.com", - expected: true, - }, - { - text: "fucking_profane", - expected: true, - }, - { - text: "fucker", - expected: true, - }, - { - text: "Hello world!", - expected: false, - }, - { - text: "I fucking hate you", - expected: true, - }, - { - text: "I love you", - expected: false, - }, - { - text: "\n.fuck!", - expected: true, - }, - ]; - - testCases.forEach((testCase) => { - expect(Validation.containsProfanity(testCase.text, "substring")).toBe( - testCase.expected - ); - }); - }); it("isTestTooShort", () => { const testCases = [ { diff --git a/backend/package.json b/backend/package.json index 7704dfe4f..ca7297436 100644 --- a/backend/package.json +++ b/backend/package.json @@ -55,7 +55,6 @@ "simple-git": "3.16.0", "string-similarity": "4.0.4", "swagger-stats": "0.99.7", - "swagger-ui-express": "4.3.0", "ua-parser-js": "0.7.33", "uuid": "10.0.0", "winston": "3.6.0", @@ -63,9 +62,8 @@ }, "devDependencies": { "@monkeytype/eslint-config": "workspace:*", - "@monkeytype/shared-types": "workspace:*", "@monkeytype/typescript-config": "workspace:*", - "@redocly/cli": "1.19.0", + "@redocly/cli": "1.22.0", "@types/bcrypt": "5.0.2", "@types/cors": "2.8.12", "@types/cron": "1.7.3", @@ -82,7 +80,6 @@ "@types/string-similarity": "4.0.2", "@types/supertest": "2.0.12", "@types/swagger-stats": "0.95.11", - "@types/swagger-ui-express": "4.1.3", "@types/ua-parser-js": "0.7.36", "@types/uuid": "10.0.0", "@vitest/coverage-v8": "2.0.5", diff --git a/backend/redocly.yaml b/backend/redocly.yaml index 7bacf646a..71fcde5ba 100644 --- a/backend/redocly.yaml +++ b/backend/redocly.yaml @@ -35,7 +35,7 @@ features.openapi: http: delete: "#da3333" post: "#004D94" - patch: "#e2b714" + patch: "#af8d0f" get: "#009400" sidebar: backgroundColor: "#323437" diff --git a/backend/scripts/openapi.ts b/backend/scripts/openapi.ts index 19fc8c045..a1394736f 100644 --- a/backend/scripts/openapi.ts +++ b/backend/scripts/openapi.ts @@ -16,7 +16,7 @@ export function getOpenApi(): OpenAPIObject { info: { title: "Monkeytype API", description: - "Documentation for the public endpoints provided by the Monkeytype API server.\n\nNote that authentication is performed with the Authorization HTTP header in the format `Authorization: ApeKey YOUR_APE_KEY`\n\nThere is a rate limit of `30 requests per minute` across all endpoints with some endpoints being more strict. Rate limit rates are shared across all ape keys.", + "Documentation for the endpoints provided by the Monkeytype API server.\n\nNote that authentication is performed with the Authorization HTTP header in the format `Authorization: ApeKey YOUR_APE_KEY`\n\nThere is a rate limit of `30 requests per minute` across all endpoints with some endpoints being more strict. Rate limit rates are shared across all ape keys.", version: "2.0.0", termsOfService: "https://monkeytype.com/terms-of-service", contact: { @@ -50,6 +50,12 @@ export function getOpenApi(): OpenAPIObject { }, }, tags: [ + { + name: "users", + description: "User account data.", + "x-displayName": "Users", + "x-public": "yes", + }, { name: "configs", description: @@ -112,7 +118,7 @@ export function getOpenApi(): OpenAPIObject { "x-public": "yes", }, { - name: "dev", + name: "development", description: "Development related endpoints. Only available on dev environment", "x-displayName": "Development", @@ -137,7 +143,7 @@ export function getOpenApi(): OpenAPIObject { function addAuth(metadata: EndpointMetadata | undefined): object { const auth = metadata?.["authenticationOptions"] ?? {}; const security: SecurityRequirementObject[] = []; - if (!auth.isPublic === true) { + if (!auth.isPublic === true && !auth.isPublicOnDev === true) { security.push({ BearerAuth: [] }); if (auth.acceptApeKeys === true) { diff --git a/backend/src/api/controllers/user.ts b/backend/src/api/controllers/user.ts index 0bca837af..738803bb3 100644 --- a/backend/src/api/controllers/user.ts +++ b/backend/src/api/controllers/user.ts @@ -1,16 +1,18 @@ import _ from "lodash"; import * as UserDAL from "../../dal/user"; import MonkeyError from "../../utils/error"; -import { MonkeyResponse } from "../../utils/monkey-response"; +import { MonkeyResponse2 } from "../../utils/monkey-response"; import * as DiscordUtils from "../../utils/discord"; import { MILLISECONDS_IN_DAY, buildAgentLog, isDevEnvironment, + replaceObjectId, + replaceObjectIds, sanitizeString, } from "../../utils/misc"; import GeorgeQueue from "../../queues/george-queue"; -import admin, { type FirebaseError } from "firebase-admin"; +import { type FirebaseError } from "firebase-admin"; import { deleteAllApeKeys } from "../../dal/ape-keys"; import { deleteAllPresets } from "../../dal/preset"; import { deleteAll as deleteAllResults } from "../../dal/result"; @@ -27,17 +29,61 @@ import * as AuthUtil from "../../utils/auth"; import * as Dates from "date-fns"; import { UTCDateMini } from "@date-fns/utc"; import * as BlocklistDal from "../../dal/blocklist"; -import { Mode, Mode2 } from "@monkeytype/contracts/schemas/shared"; import { AllTimeLbs, - CountByYearAndDay, - RankAndCount, - TestActivity, + ResultFilters, + User, UserProfile, + CountByYearAndDay, + TestActivity, UserProfileDetails, -} from "@monkeytype/shared-types"; +} from "@monkeytype/contracts/schemas/users"; import { addImportantLog, addLog, deleteUserLogs } from "../../dal/logs"; import { sendForgotPasswordEmail as authSendForgotPasswordEmail } from "../../utils/auth"; +import { + AddCustomThemeRequest, + AddCustomThemeResponse, + AddFavoriteQuoteRequest, + AddResultFilterPresetRequest, + AddResultFilterPresetResponse, + AddTagRequest, + AddTagResponse, + CheckNamePathParameters, + CreateUserRequest, + DeleteCustomThemeRequest, + EditCustomThemeRequst, + EditTagRequest, + ForgotPasswordEmailRequest, + GetCurrentTestActivityResponse, + GetCustomThemesResponse, + GetDiscordOauthLinkResponse, + GetFavoriteQuotesResponse, + GetPersonalBestsQuery, + GetPersonalBestsResponse, + GetProfilePathParams, + GetProfileQuery, + GetProfileResponse, + GetStatsResponse, + GetStreakResponseSchema, + GetTagsResponse, + GetTestActivityResponse, + GetUserInboxResponse, + GetUserResponse, + LinkDiscordRequest, + LinkDiscordResponse, + RemoveFavoriteQuoteRequest, + RemoveResultFilterPresetPathParams, + ReportUserRequest, + SetStreakHourOffsetRequest, + TagIdPathParams, + UpdateEmailRequestSchema, + UpdateLeaderboardMemoryRequest, + UpdatePasswordRequest, + UpdateUserInboxRequest, + UpdateUserNameRequest, + UpdateUserProfileRequest, + UpdateUserProfileResponse, +} from "@monkeytype/contracts/users"; async function verifyCaptcha(captcha: string): Promise { let verified = false; @@ -56,8 +102,8 @@ async function verifyCaptcha(captcha: string): Promise { } export async function createNewUser( - req: MonkeyTypes.Request -): Promise { + req: MonkeyTypes.Request2 +): Promise { const { name, captcha } = req.body; const { email, uid } = req.ctx.decodedToken; @@ -81,7 +127,7 @@ export async function createNewUser( await UserDAL.addUser(name, email, uid); void addImportantLog("user_created", `${name} ${email}`, uid); - return new MonkeyResponse("User created"); + return new MonkeyResponse2("User created", null); } catch (e) { //user was created in firebase from the frontend, remove it await firebaseDeleteUserIgnoreError(uid); @@ -90,11 +136,11 @@ export async function createNewUser( } export async function sendVerificationEmail( - req: MonkeyTypes.Request -): Promise { + req: MonkeyTypes.Request2 +): Promise { const { email, uid } = req.ctx.decodedToken; const isVerified = ( - await admin + await FirebaseAdmin() .auth() .getUser(uid) .catch((e: unknown) => { @@ -164,25 +210,25 @@ export async function sendVerificationEmail( ); } } - await emailQueue.sendVerificationEmail(email, userInfo.name, link); - return new MonkeyResponse("Email sent"); + return new MonkeyResponse2("Email sent", null); } export async function sendForgotPasswordEmail( - req: MonkeyTypes.Request -): Promise { + req: MonkeyTypes.Request2 +): Promise { const { email } = req.body; await authSendForgotPasswordEmail(email); - return new MonkeyResponse( - "Password reset request received. If the email is valid, you will receive an email shortly." + return new MonkeyResponse2( + "Password reset request received. If the email is valid, you will receive an email shortly.", + null ); } export async function deleteUser( - req: MonkeyTypes.Request -): Promise { + req: MonkeyTypes.Request2 +): Promise { const { uid } = req.ctx.decodedToken; const userInfo = await UserDAL.getPartialUser(uid, "delete user", [ @@ -219,12 +265,12 @@ export async function deleteUser( uid ); - return new MonkeyResponse("User deleted"); + return new MonkeyResponse2("User deleted", null); } export async function resetUser( - req: MonkeyTypes.Request -): Promise { + req: MonkeyTypes.Request2 +): Promise { const { uid } = req.ctx.decodedToken; const userInfo = await UserDAL.getPartialUser(uid, "reset user", [ @@ -255,12 +301,12 @@ export async function resetUser( await Promise.all(promises); void addImportantLog("user_reset", `${userInfo.email} ${userInfo.name}`, uid); - return new MonkeyResponse("User reset"); + return new MonkeyResponse2("User reset", null); } export async function updateName( - req: MonkeyTypes.Request -): Promise { + req: MonkeyTypes.Request2 +): Promise { const { uid } = req.ctx.decodedToken; const { name } = req.body; @@ -289,12 +335,12 @@ export async function updateName( uid ); - return new MonkeyResponse("User's name updated"); + return new MonkeyResponse2("User's name updated", null); } export async function clearPb( - req: MonkeyTypes.Request -): Promise { + req: MonkeyTypes.Request2 +): Promise { const { uid } = req.ctx.decodedToken; await UserDAL.clearPb(uid); @@ -304,12 +350,12 @@ export async function clearPb( ); void addImportantLog("user_cleared_pbs", "", uid); - return new MonkeyResponse("User's PB cleared"); + return new MonkeyResponse2("User's PB cleared", null); } export async function optOutOfLeaderboards( - req: MonkeyTypes.Request -): Promise { + req: MonkeyTypes.Request2 +): Promise { const { uid } = req.ctx.decodedToken; await UserDAL.optOutOfLeaderboards(uid); @@ -319,26 +365,26 @@ export async function optOutOfLeaderboards( ); void addImportantLog("user_opted_out_of_leaderboards", "", uid); - return new MonkeyResponse("User opted out of leaderboards"); + return new MonkeyResponse2("User opted out of leaderboards", null); } export async function checkName( - req: MonkeyTypes.Request -): Promise { + req: MonkeyTypes.Request2 +): Promise { const { name } = req.params; const { uid } = req.ctx.decodedToken; - const available = await UserDAL.isNameAvailable(name as string, uid); + const available = await UserDAL.isNameAvailable(name, uid); if (!available) { throw new MonkeyError(409, "Username unavailable"); } - return new MonkeyResponse("Username available"); + return new MonkeyResponse2("Username available", null); } export async function updateEmail( - req: MonkeyTypes.Request -): Promise { + req: MonkeyTypes.Request2 +): Promise { const { uid } = req.ctx.decodedToken; let { newEmail } = req.body; @@ -377,23 +423,35 @@ export async function updateEmail( uid ); - return new MonkeyResponse("Email updated"); + return new MonkeyResponse2("Email updated", null); } export async function updatePassword( - req: MonkeyTypes.Request -): Promise { + req: MonkeyTypes.Request2 +): Promise { const { uid } = req.ctx.decodedToken; const { newPassword } = req.body; await AuthUtil.updateUserPassword(uid, newPassword); - return new MonkeyResponse("Password updated"); + return new MonkeyResponse2("Password updated", null); } -function getRelevantUserInfo( - user: MonkeyTypes.DBUser -): Partial { +type RelevantUserInfo = Omit< + MonkeyTypes.DBUser, + | "bananas" + | "lbPersonalBests" + | "inbox" + | "nameHistory" + | "lastNameChange" + | "_id" + | "lastReultHashes" //TODO fix typo + | "note" + | "ips" + | "testActivity" +>; + +function getRelevantUserInfo(user: MonkeyTypes.DBUser): RelevantUserInfo { return _.omit(user, [ "bananas", "lbPersonalBests", @@ -401,16 +459,16 @@ function getRelevantUserInfo( "nameHistory", "lastNameChange", "_id", - "lastResultHashes", + "lastReultHashes", //TODO fix typo "note", "ips", "testActivity", - ]); + ]) as RelevantUserInfo; } export async function getUser( - req: MonkeyTypes.Request -): Promise { + req: MonkeyTypes.Request2 +): Promise { const { uid } = req.ctx.decodedToken; let userInfo: MonkeyTypes.DBUser; @@ -454,7 +512,7 @@ export async function getUser( custom: {}, }; - const agentLog = buildAgentLog(req); + const agentLog = buildAgentLog(req.raw); void addLog("user_data_requested", agentLog, uid); void UserDAL.logIpAddress(uid, agentLog.ip, userInfo); @@ -472,35 +530,54 @@ export async function getUser( const allTimeLbs = await getAllTimeLbs(uid); const testActivity = generateCurrentTestActivity(userInfo.testActivity); + const relevantUserInfo = getRelevantUserInfo(userInfo); - const userData = { - ...getRelevantUserInfo(userInfo), - inboxUnreadSize: inboxUnreadSize, + const resultFilterPresets: ResultFilters[] = ( + relevantUserInfo.resultFilterPresets ?? [] + ).map((it) => replaceObjectId(it)); + delete relevantUserInfo.resultFilterPresets; + + const tags = (relevantUserInfo.tags ?? []).map((it) => replaceObjectId(it)); + delete relevantUserInfo.tags; + + const customThemes = (relevantUserInfo.customThemes ?? []).map((it) => + replaceObjectId(it) + ); + delete relevantUserInfo.customThemes; + + const userData: User = { + ...relevantUserInfo, + resultFilterPresets, + tags, + customThemes, isPremium, allTimeLbs, testActivity, }; - return new MonkeyResponse("User data retrieved", userData); + return new MonkeyResponse2("User data retrieved", { + ...userData, + inboxUnreadSize: inboxUnreadSize, + }); } export async function getOauthLink( - req: MonkeyTypes.Request -): Promise { + req: MonkeyTypes.Request2 +): Promise { const { uid } = req.ctx.decodedToken; //build the url const url = await DiscordUtils.getOauthLink(uid); //return - return new MonkeyResponse("Discord oauth link generated", { + return new MonkeyResponse2("Discord oauth link generated", { url: url, }); } export async function linkDiscord( - req: MonkeyTypes.Request -): Promise { + req: MonkeyTypes.Request2 +): Promise { const { uid } = req.ctx.decodedToken; const { tokenType, accessToken, state } = req.body; @@ -521,7 +598,7 @@ export async function linkDiscord( if (userInfo.discordId !== undefined && userInfo.discordId !== "") { await UserDAL.linkDiscord(uid, userInfo.discordId, discordAvatar); - return new MonkeyResponse("Discord avatar updated", { + return new MonkeyResponse2("Discord avatar updated", { discordId, discordAvatar, }); @@ -552,15 +629,15 @@ export async function linkDiscord( await GeorgeQueue.linkDiscord(discordId, uid); void addImportantLog("user_discord_link", `linked to ${discordId}`, uid); - return new MonkeyResponse("Discord account linked", { + return new MonkeyResponse2("Discord account linked", { discordId, discordAvatar, }); } export async function unlinkDiscord( - req: MonkeyTypes.Request -): Promise { + req: MonkeyTypes.Request2 +): Promise { const { uid } = req.ctx.decodedToken; const userInfo = await UserDAL.getPartialUser(uid, "unlink discord", [ @@ -581,12 +658,12 @@ export async function unlinkDiscord( await UserDAL.unlinkDiscord(uid); void addImportantLog("user_discord_unlinked", discordId, uid); - return new MonkeyResponse("Discord account unlinked"); + return new MonkeyResponse2("Discord account unlinked", null); } export async function addResultFilterPreset( - req: MonkeyTypes.Request -): Promise { + req: MonkeyTypes.Request2 +): Promise { const { uid } = req.ctx.decodedToken; const filter = req.body; const { maxPresetsPerUser } = req.ctx.configuration.results.filterPresets; @@ -596,153 +673,158 @@ export async function addResultFilterPreset( filter, maxPresetsPerUser ); - return new MonkeyResponse("Result filter preset created", createdId); + return new MonkeyResponse2( + "Result filter preset created", + createdId.toHexString() + ); } export async function removeResultFilterPreset( - req: MonkeyTypes.Request -): Promise { + req: MonkeyTypes.Request2< + undefined, + undefined, + RemoveResultFilterPresetPathParams + > +): Promise { const { uid } = req.ctx.decodedToken; const { presetId } = req.params; - await UserDAL.removeResultFilterPreset(uid, presetId as string); - return new MonkeyResponse("Result filter preset deleted"); + await UserDAL.removeResultFilterPreset(uid, presetId); + return new MonkeyResponse2("Result filter preset deleted", null); } export async function addTag( - req: MonkeyTypes.Request -): Promise { + req: MonkeyTypes.Request2 +): Promise { const { uid } = req.ctx.decodedToken; const { tagName } = req.body; const tag = await UserDAL.addTag(uid, tagName); - return new MonkeyResponse("Tag updated", tag); + return new MonkeyResponse2("Tag updated", replaceObjectId(tag)); } export async function clearTagPb( - req: MonkeyTypes.Request -): Promise { + req: MonkeyTypes.Request2 +): Promise { const { uid } = req.ctx.decodedToken; const { tagId } = req.params; - await UserDAL.removeTagPb(uid, tagId as string); - return new MonkeyResponse("Tag PB cleared"); + await UserDAL.removeTagPb(uid, tagId); + return new MonkeyResponse2("Tag PB cleared", null); } export async function editTag( - req: MonkeyTypes.Request -): Promise { + req: MonkeyTypes.Request2 +): Promise { const { uid } = req.ctx.decodedToken; const { tagId, newName } = req.body; await UserDAL.editTag(uid, tagId, newName); - return new MonkeyResponse("Tag updated"); + return new MonkeyResponse2("Tag updated", null); } export async function removeTag( - req: MonkeyTypes.Request -): Promise { + req: MonkeyTypes.Request2 +): Promise { const { uid } = req.ctx.decodedToken; const { tagId } = req.params; - await UserDAL.removeTag(uid, tagId as string); - return new MonkeyResponse("Tag deleted"); + await UserDAL.removeTag(uid, tagId); + return new MonkeyResponse2("Tag deleted", null); } export async function getTags( - req: MonkeyTypes.Request -): Promise { + req: MonkeyTypes.Request2 +): Promise { const { uid } = req.ctx.decodedToken; const tags = await UserDAL.getTags(uid); - return new MonkeyResponse("Tags retrieved", tags ?? []); + return new MonkeyResponse2("Tags retrieved", replaceObjectIds(tags)); } export async function updateLbMemory( - req: MonkeyTypes.Request -): Promise { + req: MonkeyTypes.Request2 +): Promise { const { uid } = req.ctx.decodedToken; const { mode, language, rank } = req.body; - const mode2 = req.body.mode2 as Mode2; + const mode2 = req.body.mode2; await UserDAL.updateLbMemory(uid, mode, mode2, language, rank); - return new MonkeyResponse("Leaderboard memory updated"); + return new MonkeyResponse2("Leaderboard memory updated", null); } export async function getCustomThemes( - req: MonkeyTypes.Request -): Promise { + req: MonkeyTypes.Request2 +): Promise { const { uid } = req.ctx.decodedToken; const customThemes = await UserDAL.getThemes(uid); - return new MonkeyResponse("Custom themes retrieved", customThemes); + return new MonkeyResponse2( + "Custom themes retrieved", + replaceObjectIds(customThemes) + ); } export async function addCustomTheme( - req: MonkeyTypes.Request -): Promise { + req: MonkeyTypes.Request2 +): Promise { const { uid } = req.ctx.decodedToken; const { name, colors } = req.body; const addedTheme = await UserDAL.addTheme(uid, { name, colors }); - return new MonkeyResponse("Custom theme added", addedTheme); + return new MonkeyResponse2("Custom theme added", replaceObjectId(addedTheme)); } export async function removeCustomTheme( - req: MonkeyTypes.Request -): Promise { + req: MonkeyTypes.Request2 +): Promise { const { uid } = req.ctx.decodedToken; const { themeId } = req.body; await UserDAL.removeTheme(uid, themeId); - return new MonkeyResponse("Custom theme removed"); + return new MonkeyResponse2("Custom theme removed", null); } export async function editCustomTheme( - req: MonkeyTypes.Request -): Promise { + req: MonkeyTypes.Request2 +): Promise { const { uid } = req.ctx.decodedToken; const { themeId, theme } = req.body; await UserDAL.editTheme(uid, themeId, theme); - return new MonkeyResponse("Custom theme updated"); + return new MonkeyResponse2("Custom theme updated", null); } export async function getPersonalBests( - req: MonkeyTypes.Request -): Promise { + req: MonkeyTypes.Request2 +): Promise { const { uid } = req.ctx.decodedToken; const { mode, mode2 } = req.query; - const data = - (await UserDAL.getPersonalBests( - uid, - mode as string, - mode2 as string | undefined - )) ?? null; - return new MonkeyResponse("Personal bests retrieved", data); + const data = (await UserDAL.getPersonalBests(uid, mode, mode2)) ?? null; + return new MonkeyResponse2("Personal bests retrieved", data); } export async function getStats( - req: MonkeyTypes.Request -): Promise { + req: MonkeyTypes.Request2 +): Promise { const { uid } = req.ctx.decodedToken; const data = (await UserDAL.getStats(uid)) ?? null; - return new MonkeyResponse("Personal stats retrieved", data); + return new MonkeyResponse2("Personal stats retrieved", data); } export async function getFavoriteQuotes( - req: MonkeyTypes.Request -): Promise { + req: MonkeyTypes.Request2 +): Promise { const { uid } = req.ctx.decodedToken; const quotes = await UserDAL.getFavoriteQuotes(uid); - return new MonkeyResponse("Favorite quotes retrieved", quotes); + return new MonkeyResponse2("Favorite quotes retrieved", quotes); } export async function addFavoriteQuote( - req: MonkeyTypes.Request -): Promise { + req: MonkeyTypes.Request2 +): Promise { const { uid } = req.ctx.decodedToken; const { language, quoteId } = req.body; @@ -754,31 +836,28 @@ export async function addFavoriteQuote( req.ctx.configuration.quotes.maxFavorites ); - return new MonkeyResponse("Quote added to favorites"); + return new MonkeyResponse2("Quote added to favorites", null); } export async function removeFavoriteQuote( - req: MonkeyTypes.Request -): Promise { + req: MonkeyTypes.Request2 +): Promise { const { uid } = req.ctx.decodedToken; const { quoteId, language } = req.body; await UserDAL.removeFavoriteQuote(uid, language, quoteId); - return new MonkeyResponse("Quote removed from favorites"); + return new MonkeyResponse2("Quote removed from favorites", null); } export async function getProfile( - req: MonkeyTypes.Request -): Promise { + req: MonkeyTypes.Request2 +): Promise { const { uidOrName } = req.params; - const { isUid } = req.query; - - const user = - isUid !== undefined - ? await UserDAL.getUser(uidOrName as string, "get user profile") - : await UserDAL.getUserByName(uidOrName as string, "get user profile"); + const user = req.query.isUid + ? await UserDAL.getUser(uidOrName, "get user profile") + : await UserDAL.getUserByName(uidOrName, "get user profile"); const { name, @@ -827,7 +906,7 @@ export async function getProfile( }; if (banned) { - return new MonkeyResponse("Profile retrived: banned user", baseProfile); + return new MonkeyResponse2("Profile retrived: banned user", baseProfile); } const allTimeLbs = await getAllTimeLbs(user.uid); @@ -840,12 +919,12 @@ export async function getProfile( uid: user.uid, } as UserProfile; - return new MonkeyResponse("Profile retrieved", profileData); + return new MonkeyResponse2("Profile retrieved", profileData); } export async function updateProfile( - req: MonkeyTypes.Request -): Promise { + req: MonkeyTypes.Request2 +): Promise { const { uid } = req.ctx.decodedToken; const { bio, keyboard, socialProfiles, selectedBadgeId } = req.body; @@ -877,36 +956,40 @@ export async function updateProfile( await UserDAL.updateProfile(uid, profileDetailsUpdates, user.inventory); - return new MonkeyResponse("Profile updated", profileDetailsUpdates); + return new MonkeyResponse2("Profile updated", profileDetailsUpdates); } export async function getInbox( - req: MonkeyTypes.Request -): Promise { + req: MonkeyTypes.Request2 +): Promise { const { uid } = req.ctx.decodedToken; const inbox = await UserDAL.getInbox(uid); - return new MonkeyResponse("Inbox retrieved", { + return new MonkeyResponse2("Inbox retrieved", { inbox, maxMail: req.ctx.configuration.users.inbox.maxMail, }); } export async function updateInbox( - req: MonkeyTypes.Request -): Promise { + req: MonkeyTypes.Request2 +): Promise { const { uid } = req.ctx.decodedToken; const { mailIdsToMarkRead, mailIdsToDelete } = req.body; - await UserDAL.updateInbox(uid, mailIdsToMarkRead, mailIdsToDelete); + await UserDAL.updateInbox( + uid, + mailIdsToMarkRead ?? [], + mailIdsToDelete ?? [] + ); - return new MonkeyResponse("Inbox updated"); + return new MonkeyResponse2("Inbox updated", null); } export async function reportUser( - req: MonkeyTypes.Request -): Promise { + req: MonkeyTypes.Request2 +): Promise { const { uid } = req.ctx.decodedToken; const { reporting: { maxReports, contentReportLimit }, @@ -924,17 +1007,17 @@ export async function reportUser( uid, contentId: `${uidToReport}`, reason, - comment, + comment: comment ?? "", }; await ReportDAL.createReport(newReport, maxReports, contentReportLimit); - return new MonkeyResponse("User reported"); + return new MonkeyResponse2("User reported", null); } export async function setStreakHourOffset( - req: MonkeyTypes.Request -): Promise { + req: MonkeyTypes.Request2 +): Promise { const { uid } = req.ctx.decodedToken; const { hourOffset } = req.body; @@ -953,43 +1036,16 @@ export async function setStreakHourOffset( void addImportantLog("user_streak_hour_offset_set", { hourOffset }, uid); - return new MonkeyResponse("Streak hour offset set"); -} - -export async function toggleBan( - req: MonkeyTypes.Request -): Promise { - const { uid } = req.body; - - const user = await UserDAL.getPartialUser(uid, "toggle ban", [ - "banned", - "discordId", - ]); - const discordId = user.discordId; - const discordIdIsValid = discordId !== undefined && discordId !== ""; - - if (user.banned) { - await UserDAL.setBanned(uid, false); - if (discordIdIsValid) await GeorgeQueue.userBanned(discordId, false); - } else { - await UserDAL.setBanned(uid, true); - if (discordIdIsValid) await GeorgeQueue.userBanned(discordId, true); - } - - void addImportantLog("user_ban_toggled", { banned: !user.banned }, uid); - - return new MonkeyResponse(`Ban toggled`, { - banned: !user.banned, - }); + return new MonkeyResponse2("Streak hour offset set", null); } export async function revokeAllTokens( - req: MonkeyTypes.Request -): Promise { + req: MonkeyTypes.Request2 +): Promise { const { uid } = req.ctx.decodedToken; await AuthUtil.revokeTokensByUid(uid); void addImportantLog("user_tokens_revoked", "", uid); - return new MonkeyResponse("All tokens revoked"); + return new MonkeyResponse2("All tokens revoked", null); } async function getAllTimeLbs(uid: string): Promise { @@ -1010,18 +1066,18 @@ async function getAllTimeLbs(uid: string): Promise { const english15 = allTime15English === false ? undefined - : ({ + : { rank: allTime15English.rank, count: allTime15English.count, - } as RankAndCount); + }; const english60 = allTime60English === false ? undefined - : ({ + : { rank: allTime60English.rank, count: allTime60English.count, - } as RankAndCount); + }; return { time: { @@ -1072,8 +1128,8 @@ export function generateCurrentTestActivity( } export async function getTestActivity( - req: MonkeyTypes.Request -): Promise { + req: MonkeyTypes.Request2 +): Promise { const { uid } = req.ctx.decodedToken; const premiumFeaturesEnabled = req.ctx.configuration.users.premium.enabled; const user = await UserDAL.getPartialUser(uid, "testActivity", [ @@ -1090,7 +1146,10 @@ export async function getTestActivity( throw new MonkeyError(503, "User does not have premium"); } - return new MonkeyResponse("Test activity data retrieved", user.testActivity); + return new MonkeyResponse2( + "Test activity data retrieved", + user.testActivity ?? null + ); } async function firebaseDeleteUserIgnoreError(uid: string): Promise { @@ -1102,23 +1161,26 @@ async function firebaseDeleteUserIgnoreError(uid: string): Promise { } export async function getCurrentTestActivity( - req: MonkeyTypes.Request -): Promise { + req: MonkeyTypes.Request2 +): Promise { const { uid } = req.ctx.decodedToken; const user = await UserDAL.getPartialUser(uid, "current test activity", [ "testActivity", ]); const data = generateCurrentTestActivity(user.testActivity); - return new MonkeyResponse("Current test activity data retrieved", data); + return new MonkeyResponse2( + "Current test activity data retrieved", + data ?? null + ); } export async function getStreak( - req: MonkeyTypes.Request -): Promise { + req: MonkeyTypes.Request2 +): Promise { const { uid } = req.ctx.decodedToken; const user = await UserDAL.getPartialUser(uid, "streak", ["streak"]); - return new MonkeyResponse("Streak data retrieved", user.streak); + return new MonkeyResponse2("Streak data retrieved", user.streak ?? null); } diff --git a/backend/src/api/routes/docs.ts b/backend/src/api/routes/docs.ts index 5892e1eda..ea67bdd3d 100644 --- a/backend/src/api/routes/docs.ts +++ b/backend/src/api/routes/docs.ts @@ -1,44 +1,29 @@ import { Response, Router } from "express"; -import * as swaggerUi from "swagger-ui-express"; -import publicSwaggerSpec from "../../documentation/public-swagger.json"; - -const SWAGGER_UI_OPTIONS = { - customCss: ".swagger-ui .topbar { display: none } .try-out { display: none }", - customSiteTitle: "Monkeytype API Documentation", -}; const router = Router(); const root = __dirname + "../../../static"; -router.use("/v2/internal", (req, res) => { +router.use("/internal", (req, res) => { setCsp(res); res.sendFile("api/internal.html", { root }); }); -router.use("/v2/internal.json", (req, res) => { +router.use("/internal.json", (req, res) => { res.setHeader("Content-Type", "application/json"); res.sendFile("api/openapi.json", { root }); }); -router.use(["/v2/public", "/v2/"], (req, res) => { +router.use(["/public", "/"], (req, res) => { setCsp(res); res.sendFile("api/public.html", { root }); }); -router.use("/v2/public.json", (req, res) => { +router.use("/public.json", (req, res) => { res.setHeader("Content-Type", "application/json"); res.sendFile("api/public.json", { root }); }); -const options = {}; - -router.use( - "/", - swaggerUi.serveFiles(publicSwaggerSpec, options), - swaggerUi.setup(publicSwaggerSpec, SWAGGER_UI_OPTIONS) -); - export default router; function setCsp(res: Response): void { diff --git a/backend/src/api/routes/index.ts b/backend/src/api/routes/index.ts index 2f24f73d7..294b7406b 100644 --- a/backend/src/api/routes/index.ts +++ b/backend/src/api/routes/index.ts @@ -40,7 +40,6 @@ const BASE_ROUTE = pathOverride !== undefined ? `/${pathOverride}` : ""; const APP_START_TIME = Date.now(); const API_ROUTE_MAP = { - "/users": users, "/webhooks": webhooks, "/docs": docs, }; @@ -57,6 +56,7 @@ const router = s.router(contract, { results, configuration, dev, + users, quotes, }); diff --git a/backend/src/api/routes/swagger.ts b/backend/src/api/routes/swagger.ts index e9fb5adb7..0bd008129 100644 --- a/backend/src/api/routes/swagger.ts +++ b/backend/src/api/routes/swagger.ts @@ -1,16 +1,27 @@ import { Application } from "express"; import { getMiddleware as getSwaggerMiddleware } from "swagger-stats"; -import internalSwaggerSpec from "../../documentation/internal-swagger.json"; import { isDevEnvironment } from "../../utils/misc"; +import { readFileSync } from "fs"; +import Logger from "../../utils/logger"; function addSwaggerMiddlewares(app: Application): void { + const openApiSpec = __dirname + "/../../static/api/openapi.json"; + let spec = {}; + try { + spec = JSON.parse(readFileSync(openApiSpec, "utf8")); + } catch (err) { + Logger.warning( + `Cannot read openApi specification from ${openApiSpec}. Swagger stats will not fully work.` + ); + } + app.use( getSwaggerMiddleware({ name: "Monkeytype API", uriPath: "/stats", authentication: !isDevEnvironment(), apdexThreshold: 100, - swaggerSpec: internalSwaggerSpec, + swaggerSpec: spec, onAuthenticate: (_req, username, password) => { return ( username === process.env["STATS_USERNAME"] && diff --git a/backend/src/api/routes/users.ts b/backend/src/api/routes/users.ts index 1febadf8d..9736847e3 100644 --- a/backend/src/api/routes/users.ts +++ b/backend/src/api/routes/users.ts @@ -1,245 +1,11 @@ -import joi from "joi"; -import { authenticateRequest } from "../../middlewares/auth"; -import { Router } from "express"; -import * as UserController from "../controllers/user"; -import * as RateLimit from "../../middlewares/rate-limit"; -import { withApeRateLimiter } from "../../middlewares/ape-rate-limit"; -import { containsProfanity, isUsernameValid } from "../../utils/validation"; -import filterSchema from "../schemas/filter-schema"; -import { asyncHandler } from "../../middlewares/utility"; +import { usersContract } from "@monkeytype/contracts/users"; +import { initServer } from "@ts-rest/express"; +import { withApeRateLimiter2 as withApeRateLimiter } from "../../middlewares/ape-rate-limit"; import { validate } from "../../middlewares/configuration"; -import { validateRequest } from "../../middlewares/validation"; import { checkUserPermissions } from "../../middlewares/permission"; - -const router = Router(); - -const tagNameValidation = joi - .string() - .required() - .regex(/^[0-9a-zA-Z_-]+$/) - .max(16) - .messages({ - "string.pattern.base": - "Tag name invalid. Name cannot contain special characters or more than 16 characters. Can include _ . and -", - "string.max": "Tag name exceeds maximum of 16 characters", - }); - -const customThemeNameValidation = joi - .string() - .max(16) - .regex(/^[0-9a-zA-Z_-]+$/) - .required() - .messages({ - "string.max": "The name must not exceed 16 characters", - "string.pattern.base": - "Name cannot contain special characters. Can include _ . and -", - }); - -const customThemeColorsValidation = joi - .array() - .items( - joi - .string() - .length(7) - .regex(/^#[0-9a-fA-F]{6}$/) - .messages({ - "string.pattern.base": "The colors must be valid hexadecimal", - "string.length": "The colors must be 7 characters long", - }) - ) - .length(10) - .required() - .messages({ - "array.length": "The colors array must have 10 colors", - }); - -const customThemeIdValidation = joi - .string() - .length(24) - .regex(/^[0-9a-fA-F]+$/) - .required() - .messages({ - "string.length": "The themeId must be 24 characters long", - "string.pattern.base": "The themeId must be valid hexadecimal string", - }); - -const usernameValidation = joi - .string() - .required() - .custom((value, helpers) => { - if (containsProfanity(value, "substring")) { - return helpers.error("string.profanity"); - } - - if (!isUsernameValid(value)) { - return helpers.error("string.pattern.base"); - } - - return value as string; - }) - .messages({ - "string.profanity": - "The username contains profanity. If you believe this is a mistake, please contact us ", - "string.pattern.base": - "Username invalid. Name cannot use special characters or contain more than 16 characters. Can include _ and - ", - }); - -const languageSchema = joi - .string() - .min(1) - .max(50) - .regex(/[\w+]+/) - .required(); -const quoteIdSchema = joi.string().min(1).max(10).regex(/\d+/).required(); - -router.get( - "/", - authenticateRequest(), - RateLimit.userGet, - asyncHandler(UserController.getUser) -); - -router.post( - "/signup", - validate({ - criteria: (configuration) => { - return configuration.users.signUp; - }, - invalidMessage: "Sign up is temporarily disabled", - }), - authenticateRequest(), - RateLimit.userSignup, - validateRequest({ - body: { - email: joi.string().email(), - name: usernameValidation, - uid: joi.string().token(), - captcha: joi - .string() - .regex(/[\w-_]+/) - .required(), - }, - }), - asyncHandler(UserController.createNewUser) -); - -router.get( - "/checkName/:name", - authenticateRequest({ - isPublic: true, - }), - RateLimit.userCheckName, - validateRequest({ - params: { - name: usernameValidation, - }, - }), - asyncHandler(UserController.checkName) -); - -router.delete( - "/", - authenticateRequest({ - requireFreshToken: true, - }), - RateLimit.userDelete, - asyncHandler(UserController.deleteUser) -); - -router.patch( - "/reset", - authenticateRequest({ - requireFreshToken: true, - }), - RateLimit.userReset, - asyncHandler(UserController.resetUser) -); - -router.patch( - "/name", - authenticateRequest({ - requireFreshToken: true, - }), - RateLimit.userUpdateName, - validateRequest({ - body: { - name: usernameValidation, - }, - }), - asyncHandler(UserController.updateName) -); - -router.patch( - "/leaderboardMemory", - authenticateRequest(), - RateLimit.userUpdateLBMemory, - validateRequest({ - body: { - mode: joi - .string() - .valid("time", "words", "quote", "zen", "custom") - .required(), - mode2: joi - .string() - .regex(/^(\d)+|custom|zen/) - .required(), - language: joi - .string() - .max(50) - .pattern(/^[a-zA-Z0-9_+]+$/) - .required(), - rank: joi.number().required(), - }, - }), - asyncHandler(UserController.updateLbMemory) -); - -router.patch( - "/email", - authenticateRequest({ - requireFreshToken: true, - }), - RateLimit.userUpdateEmail, - validateRequest({ - body: { - newEmail: joi.string().email().required(), - previousEmail: joi.string().email().required(), - }, - }), - asyncHandler(UserController.updateEmail) -); - -router.patch( - "/password", - authenticateRequest({ - requireFreshToken: true, - }), - RateLimit.userUpdateEmail, - validateRequest({ - body: { - newPassword: joi.string().min(6).required(), - }, - }), - asyncHandler(UserController.updatePassword) -); - -router.delete( - "/personalBests", - authenticateRequest({ - requireFreshToken: true, - }), - RateLimit.userClearPB, - asyncHandler(UserController.clearPb) -); - -router.post( - "/optOutOfLeaderboards", - authenticateRequest({ - requireFreshToken: true, - }), - RateLimit.userOptOutOfLeaderboards, - asyncHandler(UserController.optOutOfLeaderboards) -); +import * as RateLimit from "../../middlewares/rate-limit"; +import * as UserController from "../controllers/user"; +import { callController } from "../ts-rest-adapter"; const requireFilterPresetsEnabled = validate({ criteria: (configuration) => { @@ -248,145 +14,6 @@ const requireFilterPresetsEnabled = validate({ invalidMessage: "Result filter presets are not available at this time.", }); -router.post( - "/resultFilterPresets", - requireFilterPresetsEnabled, - authenticateRequest(), - RateLimit.userCustomFilterAdd, - validateRequest({ - body: filterSchema, - }), - asyncHandler(UserController.addResultFilterPreset) -); - -router.delete( - "/resultFilterPresets/:presetId", - requireFilterPresetsEnabled, - authenticateRequest(), - RateLimit.userCustomFilterRemove, - validateRequest({ - params: { - presetId: joi.string().token().required(), - }, - }), - asyncHandler(UserController.removeResultFilterPreset) -); - -router.get( - "/tags", - authenticateRequest({ - acceptApeKeys: true, - }), - withApeRateLimiter(RateLimit.userTagsGet), - asyncHandler(UserController.getTags) -); - -router.post( - "/tags", - authenticateRequest(), - RateLimit.userTagsAdd, - validateRequest({ - body: { - tagName: tagNameValidation, - }, - }), - asyncHandler(UserController.addTag) -); - -router.patch( - "/tags", - authenticateRequest(), - RateLimit.userTagsEdit, - validateRequest({ - body: { - tagId: joi - .string() - .regex(/^[a-f\d]{24}$/i) - .required(), - newName: tagNameValidation, - }, - }), - asyncHandler(UserController.editTag) -); - -router.delete( - "/tags/:tagId", - authenticateRequest(), - RateLimit.userTagsRemove, - validateRequest({ - params: { - tagId: joi - .string() - .regex(/^[a-f\d]{24}$/i) - .required(), - }, - }), - asyncHandler(UserController.removeTag) -); - -router.delete( - "/tags/:tagId/personalBest", - authenticateRequest(), - RateLimit.userTagsClearPB, - validateRequest({ - params: { - tagId: joi - .string() - .regex(/^[a-f\d]{24}$/i) - .required(), - }, - }), - asyncHandler(UserController.clearTagPb) -); - -router.get( - "/customThemes", - authenticateRequest(), - RateLimit.userCustomThemeGet, - asyncHandler(UserController.getCustomThemes) -); - -router.post( - "/customThemes", - authenticateRequest(), - RateLimit.userCustomThemeAdd, - validateRequest({ - body: { - name: customThemeNameValidation, - colors: customThemeColorsValidation, - }, - }), - asyncHandler(UserController.addCustomTheme) -); - -router.delete( - "/customThemes", - authenticateRequest(), - RateLimit.userCustomThemeRemove, - validateRequest({ - body: { - themeId: customThemeIdValidation, - }, - }), - asyncHandler(UserController.removeCustomTheme) -); - -router.patch( - "/customThemes", - authenticateRequest(), - RateLimit.userCustomThemeEdit, - validateRequest({ - body: { - themeId: customThemeIdValidation, - theme: { - name: customThemeNameValidation, - colors: customThemeColorsValidation, - }, - }, - }), - asyncHandler(UserController.editCustomTheme) -); - const requireDiscordIntegrationEnabled = validate({ criteria: (configuration) => { return configuration.users.discordIntegration.enabled; @@ -394,108 +21,6 @@ const requireDiscordIntegrationEnabled = validate({ invalidMessage: "Discord integration is not available at this time", }); -router.get( - "/discord/oauth", - requireDiscordIntegrationEnabled, - authenticateRequest(), - RateLimit.userDiscordLink, - asyncHandler(UserController.getOauthLink) -); - -router.post( - "/discord/link", - requireDiscordIntegrationEnabled, - authenticateRequest(), - RateLimit.userDiscordLink, - validateRequest({ - body: { - tokenType: joi.string().token().required(), - accessToken: joi.string().token().required(), - state: joi.string().length(20).token().required(), - }, - }), - asyncHandler(UserController.linkDiscord) -); - -router.post( - "/discord/unlink", - authenticateRequest(), - RateLimit.userDiscordUnlink, - asyncHandler(UserController.unlinkDiscord) -); - -router.get( - "/personalBests", - authenticateRequest({ - acceptApeKeys: true, - }), - withApeRateLimiter(RateLimit.userGet), - validateRequest({ - query: { - mode: joi - .string() - .valid("time", "words", "quote", "zen", "custom") - .required(), - mode2: joi.string().regex(/^(\d)+|custom|zen/), - }, - }), - asyncHandler(UserController.getPersonalBests) -); - -router.get( - "/stats", - authenticateRequest({ - acceptApeKeys: true, - }), - withApeRateLimiter(RateLimit.userGet), - asyncHandler(UserController.getStats) -); - -router.post( - "/setStreakHourOffset", - authenticateRequest(), - RateLimit.setStreakHourOffset, - validateRequest({ - body: { - hourOffset: joi.number().min(-11).max(12).required(), - }, - }), - asyncHandler(UserController.setStreakHourOffset) -); - -router.get( - "/favoriteQuotes", - authenticateRequest(), - RateLimit.quoteFavoriteGet, - asyncHandler(UserController.getFavoriteQuotes) -); - -router.post( - "/favoriteQuotes", - authenticateRequest(), - RateLimit.quoteFavoritePost, - validateRequest({ - body: { - language: languageSchema, - quoteId: quoteIdSchema, - }, - }), - asyncHandler(UserController.addFavoriteQuote) -); - -router.delete( - "/favoriteQuotes", - authenticateRequest(), - RateLimit.quoteFavoriteDelete, - validateRequest({ - body: { - language: languageSchema, - quoteId: quoteIdSchema, - }, - }), - asyncHandler(UserController.removeFavoriteQuote) -); - const requireProfilesEnabled = validate({ criteria: (configuration) => { return configuration.users.profiles.enabled; @@ -503,78 +28,6 @@ const requireProfilesEnabled = validate({ invalidMessage: "Profiles are not available at this time", }); -router.get( - "/:uidOrName/profile", - requireProfilesEnabled, - authenticateRequest({ - isPublic: true, - }), - withApeRateLimiter(RateLimit.userProfileGet), - validateRequest({ - params: { - uidOrName: joi.alternatives().try( - joi - .string() - .regex(/^[\da-zA-Z._-]+$/) - .max(16), - joi.string().token().max(50) - ), - }, - query: { - isUid: joi.string().valid("").messages({ - "any.only": "isUid must be empty", - }), - }, - }), - asyncHandler(UserController.getProfile) -); - -const profileDetailsBase = joi - .string() - .allow("") - .custom((value, helpers) => { - if (containsProfanity(value, "word")) { - return helpers.error("string.profanity"); - } - - return value as string; - }) - .messages({ - "string.profanity": - "Profanity detected. Please remove it. (if you believe this is a mistake, please contact us)", - }); - -router.patch( - "/profile", - requireProfilesEnabled, - authenticateRequest(), - RateLimit.userProfileUpdate, - validateRequest({ - body: { - bio: profileDetailsBase.max(250), - keyboard: profileDetailsBase.max(75), - selectedBadgeId: joi.number(), - socialProfiles: joi.object({ - twitter: profileDetailsBase.regex(/^[0-9a-zA-Z_.-]+$/).max(20), - github: profileDetailsBase.regex(/^[0-9a-zA-Z_.-]+$/).max(39), - website: profileDetailsBase - .uri({ - scheme: "https", - domain: { - tlds: { - allow: true, - }, - }, - }) - .max(200), - }), - }, - }), - asyncHandler(UserController.updateProfile) -); - -const mailIdSchema = joi.array().items(joi.string().guid()).min(1).default([]); - const requireInboxEnabled = validate({ criteria: (configuration) => { return configuration.users.inbox.enabled; @@ -582,122 +35,207 @@ const requireInboxEnabled = validate({ invalidMessage: "Your inbox is not available at this time.", }); -router.get( - "/inbox", - requireInboxEnabled, - authenticateRequest(), - RateLimit.userMailGet, - asyncHandler(UserController.getInbox) -); - -router.patch( - "/inbox", - requireInboxEnabled, - authenticateRequest(), - RateLimit.userMailUpdate, - validateRequest({ - body: { - mailIdsToDelete: mailIdSchema, - mailIdsToMarkRead: mailIdSchema, - }, - }), - asyncHandler(UserController.updateInbox) -); - -const withCustomMessages = joi.string().messages({ - "string.pattern.base": "Invalid parameter format", +const s = initServer(); +export default s.router(usersContract, { + get: { + middleware: [RateLimit.userGet], + handler: async (r) => callController(UserController.getUser)(r), + }, + create: { + middleware: [ + validate({ + criteria: (configuration) => { + return configuration.users.signUp; + }, + invalidMessage: "Sign up is temporarily disabled", + }), + RateLimit.userSignup, + ], + handler: async (r) => callController(UserController.createNewUser)(r), + }, + getNameAvailability: { + middleware: [RateLimit.userCheckName], + handler: async (r) => callController(UserController.checkName)(r), + }, + delete: { + middleware: [RateLimit.userDelete], + handler: async (r) => callController(UserController.deleteUser)(r), + }, + reset: { + middleware: [RateLimit.userReset], + handler: async (r) => callController(UserController.resetUser)(r), + }, + updateName: { + middleware: [RateLimit.userUpdateName], + handler: async (r) => callController(UserController.updateName)(r), + }, + updateLeaderboardMemory: { + middleware: [RateLimit.userUpdateLBMemory], + handler: async (r) => callController(UserController.updateLbMemory)(r), + }, + updateEmail: { + middleware: [RateLimit.userUpdateEmail], + handler: async (r) => callController(UserController.updateEmail)(r), + }, + updatePassword: { + middleware: [RateLimit.userUpdateEmail], + handler: async (r) => callController(UserController.updatePassword)(r), + }, + getPersonalBests: { + middleware: [withApeRateLimiter(RateLimit.userGet)], + handler: async (r) => callController(UserController.getPersonalBests)(r), + }, + deletePersonalBests: { + middleware: [RateLimit.userClearPB], + handler: async (r) => callController(UserController.clearPb)(r), + }, + optOutOfLeaderboards: { + middleware: [RateLimit.userOptOutOfLeaderboards], + handler: async (r) => + callController(UserController.optOutOfLeaderboards)(r), + }, + addResultFilterPreset: { + middleware: [requireFilterPresetsEnabled, RateLimit.userCustomFilterAdd], + handler: async (r) => + callController(UserController.addResultFilterPreset)(r), + }, + removeResultFilterPreset: { + middleware: [requireFilterPresetsEnabled, RateLimit.userCustomFilterRemove], + handler: async (r) => + callController(UserController.removeResultFilterPreset)(r), + }, + getTags: { + middleware: [withApeRateLimiter(RateLimit.userTagsGet)], + handler: async (r) => callController(UserController.getTags)(r), + }, + createTag: { + middleware: [RateLimit.userTagsAdd], + handler: async (r) => callController(UserController.addTag)(r), + }, + editTag: { + middleware: [RateLimit.userTagsEdit], + handler: async (r) => callController(UserController.editTag)(r), + }, + deleteTag: { + middleware: [RateLimit.userTagsRemove], + handler: async (r) => callController(UserController.removeTag)(r), + }, + deleteTagPersonalBest: { + middleware: [RateLimit.userTagsClearPB], + handler: async (r) => callController(UserController.clearTagPb)(r), + }, + getCustomThemes: { + middleware: [RateLimit.userCustomThemeGet], + handler: async (r) => callController(UserController.getCustomThemes)(r), + }, + addCustomTheme: { + middleware: [RateLimit.userCustomThemeAdd], + handler: async (r) => callController(UserController.addCustomTheme)(r), + }, + deleteCustomTheme: { + middleware: [RateLimit.userCustomThemeRemove], + handler: async (r) => callController(UserController.removeCustomTheme)(r), + }, + editCustomTheme: { + middleware: [RateLimit.userCustomThemeEdit], + handler: async (r) => callController(UserController.editCustomTheme)(r), + }, + getDiscordOAuth: { + middleware: [requireDiscordIntegrationEnabled, RateLimit.userDiscordLink], + handler: async (r) => callController(UserController.getOauthLink)(r), + }, + linkDiscord: { + middleware: [requireDiscordIntegrationEnabled, RateLimit.userDiscordLink], + handler: async (r) => callController(UserController.linkDiscord)(r), + }, + unlinkDiscord: { + middleware: [RateLimit.userDiscordUnlink], + handler: async (r) => callController(UserController.unlinkDiscord)(r), + }, + getStats: { + middleware: [withApeRateLimiter(RateLimit.userGet)], + handler: async (r) => callController(UserController.getStats)(r), + }, + setStreakHourOffset: { + middleware: [RateLimit.setStreakHourOffset], + handler: async (r) => callController(UserController.setStreakHourOffset)(r), + }, + getFavoriteQuotes: { + middleware: [RateLimit.quoteFavoriteGet], + handler: async (r) => callController(UserController.getFavoriteQuotes)(r), + }, + addQuoteToFavorites: { + middleware: [RateLimit.quoteFavoritePost], + handler: async (r) => callController(UserController.addFavoriteQuote)(r), + }, + removeQuoteFromFavorites: { + middleware: [RateLimit.quoteFavoriteDelete], + handler: async (r) => callController(UserController.removeFavoriteQuote)(r), + }, + getProfile: { + middleware: [ + requireProfilesEnabled, + withApeRateLimiter(RateLimit.userProfileGet), + ], + handler: async (r) => callController(UserController.getProfile)(r), + }, + updateProfile: { + middleware: [ + requireProfilesEnabled, + withApeRateLimiter(RateLimit.userProfileUpdate), + ], + handler: async (r) => callController(UserController.updateProfile)(r), + }, + getInbox: { + middleware: [requireInboxEnabled, RateLimit.userMailGet], + handler: async (r) => callController(UserController.getInbox)(r), + }, + updateInbox: { + middleware: [requireInboxEnabled, RateLimit.userMailUpdate], + handler: async (r) => callController(UserController.updateInbox)(r), + }, + report: { + middleware: [ + validate({ + criteria: (configuration) => { + return configuration.quotes.reporting.enabled; + }, + invalidMessage: "User reporting is unavailable.", + }), + checkUserPermissions(["canReport"], { + criteria: (user) => { + return user.canReport !== false; + }, + }), + RateLimit.quoteReportSubmit, + ], + handler: async (r) => callController(UserController.reportUser)(r), + }, + verificationEmail: { + middleware: [RateLimit.userRequestVerificationEmail], + handler: async (r) => + callController(UserController.sendVerificationEmail)(r), + }, + forgotPasswordEmail: { + middleware: [RateLimit.userForgotPasswordEmail], + handler: async (r) => + callController(UserController.sendForgotPasswordEmail)(r), + }, + revokeAllTokens: { + middleware: [RateLimit.userRevokeAllTokens], + handler: async (r) => callController(UserController.revokeAllTokens)(r), + }, + getTestActivity: { + middleware: [RateLimit.userTestActivity], + handler: async (r) => callController(UserController.getTestActivity)(r), + }, + getCurrentTestActivity: { + middleware: [withApeRateLimiter(RateLimit.userCurrentTestActivity)], + handler: async (r) => + callController(UserController.getCurrentTestActivity)(r), + }, + getStreak: { + middleware: [withApeRateLimiter(RateLimit.userStreak)], + handler: async (r) => callController(UserController.getStreak)(r), + }, }); - -router.post( - "/report", - validate({ - criteria: (configuration) => { - return configuration.quotes.reporting.enabled; - }, - invalidMessage: "User reporting is unavailable.", - }), - authenticateRequest(), - RateLimit.quoteReportSubmit, - validateRequest({ - body: { - uid: withCustomMessages.token().max(50).required(), - reason: joi - .string() - .valid( - "Inappropriate name", - "Inappropriate bio", - "Inappropriate social links", - "Suspected cheating" - ) - .required(), - comment: withCustomMessages - .allow("") - .regex(/^([.]|[^/<>])+$/) - .max(250) - .required(), - captcha: withCustomMessages.regex(/[\w-_]+/).required(), - }, - }), - checkUserPermissions(["canReport"], { - criteria: (user) => { - return user.canReport !== false; - }, - }), - asyncHandler(UserController.reportUser) -); - -router.get( - "/verificationEmail", - authenticateRequest({ - noCache: true, - }), - RateLimit.userRequestVerificationEmail, - asyncHandler(UserController.sendVerificationEmail) -); - -router.post( - "/forgotPasswordEmail", - RateLimit.userForgotPasswordEmail, - validateRequest({ - body: { - email: joi.string().email().required(), - }, - }), - asyncHandler(UserController.sendForgotPasswordEmail) -); - -router.post( - "/revokeAllTokens", - RateLimit.userRevokeAllTokens, - authenticateRequest({ - requireFreshToken: true, - noCache: true, - }), - asyncHandler(UserController.revokeAllTokens) -); - -router.get( - "/testActivity", - authenticateRequest(), - RateLimit.userTestActivity, - asyncHandler(UserController.getTestActivity) -); - -router.get( - "/currentTestActivity", - authenticateRequest({ - acceptApeKeys: true, - }), - withApeRateLimiter(RateLimit.userCurrentTestActivity), - asyncHandler(UserController.getCurrentTestActivity) -); - -router.get( - "/streak", - authenticateRequest({ - acceptApeKeys: true, - }), - withApeRateLimiter(RateLimit.userStreak), - asyncHandler(UserController.getStreak) -); -export default router; diff --git a/backend/src/dal/blocklist.ts b/backend/src/dal/blocklist.ts index 1c94bcf45..c6992fe27 100644 --- a/backend/src/dal/blocklist.ts +++ b/backend/src/dal/blocklist.ts @@ -1,7 +1,7 @@ import { Collection } from "mongodb"; import * as db from "../init/db"; import { createHash } from "crypto"; -import { User } from "@monkeytype/shared-types"; +import { User } from "@monkeytype/contracts/schemas/users"; type BlocklistEntryProperties = Pick; // Export for use in tests diff --git a/backend/src/dal/user.ts b/backend/src/dal/user.ts index da65ebaa3..6e36ecf08 100644 --- a/backend/src/dal/user.ts +++ b/backend/src/dal/user.ts @@ -1,5 +1,4 @@ import _ from "lodash"; -import { containsProfanity, isUsernameValid } from "../utils/validation"; import { canFunboxGetPb, checkAndUpdatePb } from "../utils/pb"; import * as db from "../init/db"; import MonkeyError from "../utils/error"; @@ -23,14 +22,14 @@ import { UserProfileDetails, UserQuoteRatings, UserStreak, -} from "@monkeytype/shared-types"; + ResultFilters, +} from "@monkeytype/contracts/schemas/users"; import { Mode, Mode2, PersonalBest, } 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"; import { Configuration } from "@monkeytype/contracts/schemas/configuration"; @@ -131,12 +130,6 @@ export async function updateName( if (name === previousName) { throw new MonkeyError(400, "New name is the same as the old name"); } - if (!isUsernameValid(name)) { - throw new MonkeyError(400, "Invalid username"); - } - if (containsProfanity(name, "substring")) { - throw new MonkeyError(400, "Username contains profanity"); - } if ( name?.toLowerCase() !== previousName?.toLowerCase() && @@ -549,7 +542,7 @@ export async function updateLastHashes( { uid }, { $set: { - lastReultHashes: lastHashes, + lastReultHashes: lastHashes, //TODO fix typo }, } ); @@ -764,7 +757,7 @@ export async function getStats( export async function getFavoriteQuotes( uid -): Promise { +): Promise> { const user = await getPartialUser(uid, "get favorite quotes", [ "favoriteQuotes", ]); @@ -896,7 +889,7 @@ export async function updateProfile( export async function getInbox( uid: string -): Promise { +): Promise> { const user = await getPartialUser(uid, "get inbox", ["inbox"]); return user.inbox ?? []; } diff --git a/backend/src/documentation/internal-swagger.json b/backend/src/documentation/internal-swagger.json deleted file mode 100644 index 042c967dc..000000000 --- a/backend/src/documentation/internal-swagger.json +++ /dev/null @@ -1,418 +0,0 @@ -{ - "swagger": "2.0", - "info": { - "description": "These are the set of `internal` endpoints dedicated to the Monkeytype web client. Authentication for these endpoints requires a user account.\nNote: We are currently re-working our APIs. Some endpoints are documented at https://api.monkeytype.com/docs/v2/internal", - "version": "1.0.0", - "title": "Monkeytype", - "termsOfService": "https://monkeytype.com/terms-of-service", - "contact": { - "name": "Support", - "email": "support@monkeytype.com" - } - }, - "host": "api.monkeytype.com", - "schemes": ["https"], - "consumes": ["application/json"], - "produces": ["application/json"], - "tags": [ - { - "name": "index", - "description": "Server status information" - }, - { - "name": "users", - "description": "User data and related operations" - } - ], - "paths": { - "/": { - "get": { - "tags": ["index"], - "summary": "Gets the server's status data", - "responses": { - "default": { - "description": "", - "schema": { - "$ref": "#/definitions/Response" - } - } - } - } - }, - "/users": { - "get": { - "tags": ["users"], - "summary": "Returns a user's data", - "responses": { - "default": { - "description": "", - "schema": { - "$ref": "#/definitions/Response" - } - } - } - }, - "delete": { - "tags": ["users"], - "summary": "Deletes a user's account", - "responses": { - "default": { - "description": "", - "schema": { - "$ref": "#/definitions/Response" - } - } - } - } - }, - "/users/name": { - "patch": { - "tags": ["users"], - "summary": "Updates a user's name", - "parameters": [ - { - "in": "body", - "name": "body", - "required": true, - "schema": { - "type": "object", - "properties": { - "name": { - "type": "string" - } - } - } - } - ], - "responses": { - "default": { - "description": "", - "schema": { - "$ref": "#/definitions/Response" - } - } - } - } - }, - "/users/signup": { - "post": { - "tags": ["users"], - "summary": "Creates a new user", - "parameters": [ - { - "in": "body", - "name": "body", - "required": true, - "schema": { - "type": "object", - "properties": { - "email": { - "type": "string" - }, - "name": { - "type": "string" - }, - "uid": { - "type": "string" - } - } - } - } - ], - "responses": { - "default": { - "description": "", - "schema": { - "$ref": "#/definitions/Response" - } - } - } - } - }, - "/users/checkName/{name}": { - "get": { - "tags": ["users"], - "summary": "Checks to see if a username is available", - "parameters": [ - { - "name": "name", - "in": "path", - "description": "", - "required": true, - "type": "string" - } - ], - "responses": { - "default": { - "description": "", - "schema": { - "$ref": "#/definitions/Response" - } - } - } - } - }, - "/users/leaderboardMemory": { - "patch": { - "tags": ["users"], - "summary": "Updates a user's cached leaderboard state", - "parameters": [ - { - "in": "body", - "name": "body", - "required": true, - "schema": { - "type": "object", - "properties": { - "mode": { - "type": "string" - }, - "mode2": { - "type": "string" - }, - "language": { - "type": "string" - }, - "rank": { - "type": "number" - } - } - } - } - ], - "responses": { - "default": { - "description": "", - "schema": { - "$ref": "#/definitions/Response" - } - } - } - } - }, - "/users/discord/link": { - "post": { - "tags": ["users"], - "summary": "Links a user's account with a discord account", - "parameters": [ - { - "in": "body", - "name": "body", - "required": true, - "schema": { - "type": "object", - "properties": { - "tokenType": { - "type": "string" - }, - "accessToken": { - "type": "string" - }, - "uid": { - "type": "string" - } - } - } - } - ], - "responses": { - "default": { - "description": "", - "schema": { - "$ref": "#/definitions/Response" - } - } - } - } - }, - "/users/discord/unlink": { - "post": { - "tags": ["users"], - "summary": "Unlinks a user's account with a discord account", - "responses": { - "default": { - "description": "", - "schema": { - "$ref": "#/definitions/Response" - } - } - } - } - }, - "/users/email": { - "patch": { - "tags": ["users"], - "summary": "Updates a user's email", - "parameters": [ - { - "in": "body", - "name": "body", - "required": true, - "schema": { - "type": "object", - "properties": { - "newEmail": { - "type": "string" - }, - "previousEmail": { - "type": "string" - } - } - } - } - ], - "responses": { - "default": { - "description": "", - "schema": { - "$ref": "#/definitions/Response" - } - } - } - } - }, - "/users/personalBests": { - "delete": { - "tags": ["users"], - "summary": "Gets a user's personal bests", - "responses": { - "default": { - "description": "", - "schema": { - "$ref": "#/definitions/Response" - } - } - } - } - }, - "/users/tags": { - "get": { - "tags": ["users"], - "summary": "Gets a user's tags", - "responses": { - "default": { - "description": "", - "schema": { - "$ref": "#/definitions/Response" - } - } - } - }, - "post": { - "tags": ["users"], - "summary": "Creates a new tag", - "parameters": [ - { - "in": "body", - "name": "body", - "required": true, - "schema": { - "type": "object", - "properties": { - "tagName": { - "type": "string" - } - } - } - } - ], - "responses": { - "default": { - "description": "", - "schema": { - "$ref": "#/definitions/Response" - } - } - } - }, - "patch": { - "tags": ["users"], - "summary": "Updates an existing tag", - "parameters": [ - { - "in": "body", - "name": "body", - "required": true, - "schema": { - "type": "object", - "properties": { - "tagId": { - "type": "string" - }, - "newName": { - "type": "string" - } - } - } - } - ], - "responses": { - "default": { - "description": "", - "schema": { - "$ref": "#/definitions/Response" - } - } - } - } - }, - "/users/tags/{tagId}": { - "delete": { - "tags": ["users"], - "summary": "Deletes a tag", - "parameters": [ - { - "in": "path", - "name": "tagId", - "required": true, - "type": "string" - } - ], - "responses": { - "default": { - "description": "", - "schema": { - "$ref": "#/definitions/Response" - } - } - } - } - }, - "/users/tags/{tagId}/personalBest": { - "delete": { - "tags": ["users"], - "summary": "Removes personal bests associated with a tag", - "parameters": [ - { - "in": "path", - "name": "tagId", - "required": true, - "type": "string" - } - ], - "responses": { - "default": { - "description": "", - "schema": { - "$ref": "#/definitions/Response" - } - } - } - } - } - }, - "definitions": { - "Response": { - "type": "object", - "required": ["message", "data"], - "properties": { - "message": { - "type": "string" - }, - "data": { - "type": "object" - } - } - } - } -} diff --git a/backend/src/documentation/public-swagger.json b/backend/src/documentation/public-swagger.json deleted file mode 100644 index 30c9d8a0f..000000000 --- a/backend/src/documentation/public-swagger.json +++ /dev/null @@ -1,493 +0,0 @@ -{ - "swagger": "2.0", - "info": { - "description": "Documentation for the public endpoints provided by the Monkeytype API server.\n\nNote that authentication is performed with the Authorization HTTP header in the format `Authorization: ApeKey YOUR_APE_KEY`\n\nThere is a rate limit of `30 requests per minute` across all endpoints with some endpoints being more strict. Rate limit rates are shared across all ape keys.\n\nNote: We are currently re-working our APIs. Some endpoints are documented at https://api.monkeytype.com/docs/v2/public", - "version": "1.0.0", - "title": "Monkeytype API", - "termsOfService": "https://monkeytype.com/terms-of-service", - "contact": { - "name": "Support", - "email": "support@monkeytype.com" - } - }, - "host": "api.monkeytype.com", - "schemes": ["https"], - "basePath": "/", - "consumes": ["application/json"], - "produces": ["application/json"], - "tags": [ - { - "name": "users", - "description": "User data and related operations" - } - ], - "paths": { - "/users/personalBests": { - "get": { - "tags": ["users"], - "summary": "Gets a user's personal best data", - "parameters": [ - { - "name": "mode", - "in": "query", - "description": "The primary mode (i.e., time)", - "required": true, - "type": "string" - }, - { - "name": "mode2", - "in": "query", - "description": "The secondary mode (i.e., 60)", - "required": false, - "type": "string" - } - ], - "responses": { - "200": { - "description": "", - "schema": { - "$ref": "#/definitions/PersonalBest" - } - } - } - } - }, - "/users/stats": { - "get": { - "tags": ["users"], - "summary": "Gets a user's typing stats data", - "responses": { - "200": { - "description": "", - "schema": { - "$ref": "#/definitions/Stats" - } - } - } - } - }, - "/users/tags": { - "get": { - "tags": ["users"], - "summary": "Gets a user's tags data", - "responses": { - "200": { - "description": "", - "schema": { - "$ref": "#/definitions/Tags" - } - } - } - } - }, - "/users/{uidOrName}/profile": { - "get": { - "tags": ["users"], - "summary": "Gets a user's profile", - "parameters": [ - { - "name": "uidOrName", - "in": "path", - "description": "The user uid or name. Defaults to the user name. To filter by uid set the parameter `isUid` to ``.", - "required": true, - "type": "string" - }, - { - "name": "isUid", - "in": "query", - "description": "Indicates the parameter `uidOrName` is an uid.", - "required": false, - "type": "string", - "minLength": 0, - "maxLength": 0 - } - ], - "responses": { - "200": { - "description": "", - "schema": { - "$ref": "#/definitions/Profile" - } - } - } - } - }, - "/users/currentTestActivity": { - "get": { - "tags": ["users"], - "summary": "Gets a user's test activity data for the last ~52 weeks", - "responses": { - "200": { - "description": "", - "schema": { - "$ref": "#/definitions/CurrentTestActivity" - } - } - } - } - }, - "/users/streak": { - "get": { - "tags": ["users"], - "summary": "Gets a user's streak", - "responses": { - "200": { - "description": "", - "schema": { - "$ref": "#/definitions/UserStreak" - } - } - } - } - } - }, - "definitions": { - "Response": { - "type": "object", - "required": ["message", "data"], - "properties": { - "message": { - "type": "string" - }, - "data": { - "type": "object" - } - } - }, - "PersonalBest": { - "type": "object", - "properties": { - "acc": { - "type": "number", - "format": "double", - "example": 94.44 - }, - "consistency": { - "type": "number", - "format": "double", - "example": 75.98 - }, - "difficulty": { - "type": "string", - "example": "normal" - }, - "lazyMode": { - "type": "boolean", - "example": false - }, - "language": { - "type": "string", - "example": "english" - }, - "punctuation": { - "type": "boolean", - "example": false - }, - "raw": { - "type": "number", - "format": "double", - "example": 116.6 - }, - "wpm": { - "type": "number", - "format": "double", - "example": 107.6 - }, - "timestamp": { - "type": "integer", - "example": 1644438189583 - } - } - }, - "Profile": { - "type": "object", - "properties": { - "name": { - "type": "string", - "example": "example_name" - }, - "banned": { - "type": "boolean", - "example": true - }, - "addedAt": { - "type": "integer", - "example": 1644438189583 - }, - "typingStats": { - "type": "object", - "properties": { - "startedTests": { - "type": "integer", - "example": 578 - }, - "completedTests": { - "type": "integer", - "example": 451 - }, - "timeTyping": { - "type": "number", - "format": "double", - "example": 3941.6 - } - } - }, - "personalBests": { - "type": "object", - "properties": { - "time": { - "type": "object", - "properties": { - "15": { - "type": "array", - "items": { - "$ref": "#/definitions/PersonalBest" - } - }, - "30": { - "type": "array", - "items": { - "$ref": "#/definitions/PersonalBest" - } - }, - "60": { - "type": "array", - "items": { - "$ref": "#/definitions/PersonalBest" - } - }, - "120": { - "type": "array", - "items": { - "$ref": "#/definitions/PersonalBest" - } - } - } - }, - "words": { - "type": "object", - "properties": { - "10": { - "type": "array", - "items": { - "$ref": "#/definitions/PersonalBest" - } - }, - "25": { - "type": "array", - "items": { - "$ref": "#/definitions/PersonalBest" - } - }, - "50": { - "type": "array", - "items": { - "$ref": "#/definitions/PersonalBest" - } - }, - "100": { - "type": "array", - "items": { - "$ref": "#/definitions/PersonalBest" - } - } - } - } - } - }, - "discordId": { - "type": "string", - "example": "974761412044437307" - }, - "discordAvatar": { - "type": "string", - "example": "6226b17aebc27a4a8d1ce04b" - }, - "inventory": { - "type": "object", - "properties": { - "badges": { - "type": "array", - "items": { - "type": "object", - "properties": { - "id": { - "type": "integer", - "example": 1 - }, - "selected": { - "type": "boolean", - "example": true - } - } - } - } - } - }, - "details": { - "type": "object", - "properties": { - "bio": { - "type": "string", - "example": "I love MonkeyType!" - }, - "keyboard": { - "type": "string", - "example": "Keychron V4" - }, - "socialProfiles": { - "type": "object", - "properties": { - "twitter": { - "type": "string", - "example": "monkeytype" - }, - "github": { - "type": "string", - "example": "monkeytype" - }, - "website": { - "type": "string", - "example": "https://monkeytype.com/" - } - } - } - } - } - } - }, - "Stats": { - "type": "object", - "properties": { - "startedTests": { - "type": "integer", - "example": 578 - }, - "completedTests": { - "type": "integer", - "example": 451 - }, - "timeTyping": { - "type": "number", - "format": "double", - "example": 3941.6 - } - } - }, - "Tags": { - "type": "array", - "items": { - "type": "object", - "properties": { - "_id": { - "type": "string", - "example": "63fde8d39312642481070f5d" - }, - "name": { - "type": "string", - "example": "example_tag" - }, - "personalBests": { - "type": "object", - "properties": { - "time": { - "type": "object", - "properties": { - "15": { - "type": "array", - "items": { - "$ref": "#/definitions/PersonalBest" - } - }, - "30": { - "type": "array", - "items": { - "$ref": "#/definitions/PersonalBest" - } - }, - "60": { - "type": "array", - "items": { - "$ref": "#/definitions/PersonalBest" - } - }, - "120": { - "type": "array", - "items": { - "$ref": "#/definitions/PersonalBest" - } - } - } - }, - "words": { - "type": "object", - "properties": { - "10": { - "type": "array", - "items": { - "$ref": "#/definitions/PersonalBest" - } - }, - "25": { - "type": "array", - "items": { - "$ref": "#/definitions/PersonalBest" - } - }, - "50": { - "type": "array", - "items": { - "$ref": "#/definitions/PersonalBest" - } - }, - "100": { - "type": "array", - "items": { - "$ref": "#/definitions/PersonalBest" - } - } - } - } - } - } - } - } - }, - "CurrentTestActivity": { - "type": "object", - "properties": { - "testByDays": { - "type": "array", - "items": { - "type": "number", - "nullable": true - }, - "example": [null, null, null, 1, 2, 3, null, 4], - "description": "Test activity by day. Last element of the array are the tests on the date specified by the `lastDay` property. All dates are in UTC." - }, - "lastDay": { - "type": "integer", - "example": 1712140496000 - } - } - }, - "UserStreak": { - "type": "object", - "properties": { - "lastResultTimestamp": { - "type": "integer" - }, - "length": { - "type": "integer" - }, - "maxLength": { - "type": "integer" - }, - "hourOffset": { - "type": "integer", - "nullable": true - } - } - } - } -} diff --git a/backend/src/middlewares/ape-rate-limit.ts b/backend/src/middlewares/ape-rate-limit.ts index ea277a475..6197b1f8c 100644 --- a/backend/src/middlewares/ape-rate-limit.ts +++ b/backend/src/middlewares/ape-rate-limit.ts @@ -6,7 +6,6 @@ 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; @@ -54,10 +53,10 @@ export function withApeRateLimiter( }; } -export function withApeRateLimiter2( +export function withApeRateLimiter2( defaultRateLimiter: RateLimitRequestHandler, apeRateLimiterOverride?: RateLimitRequestHandler -): TsRestRequestHandler { +): MonkeyTypes.RequestHandler { return (req: TsRestRequestWithCtx, res: Response, next: NextFunction) => { if (req.ctx.decodedToken.type === "ApeKey") { const rateLimiter = apeRateLimiterOverride ?? apeRateLimiter; diff --git a/backend/src/middlewares/configuration.ts b/backend/src/middlewares/configuration.ts index d6e475ef6..042728ced 100644 --- a/backend/src/middlewares/configuration.ts +++ b/backend/src/middlewares/configuration.ts @@ -1,6 +1,7 @@ -import type { Response, NextFunction, RequestHandler } from "express"; +import type { Response, NextFunction } from "express"; import MonkeyError from "../utils/error"; import { Configuration } from "@monkeytype/contracts/schemas/configuration"; +import { TsRestRequestWithCtx } from "./auth"; export type ValidationOptions = { criteria: (data: T) => boolean; @@ -13,13 +14,13 @@ export type ValidationOptions = { */ export function validate( options: ValidationOptions -): RequestHandler { +): MonkeyTypes.RequestHandler { const { criteria, invalidMessage = "This service is currently unavailable.", } = options; - return (req: MonkeyTypes.Request, _res: Response, next: NextFunction) => { + return (req: TsRestRequestWithCtx, _res: Response, next: NextFunction) => { const configuration = req.ctx.configuration; const validated = criteria(configuration); diff --git a/backend/src/middlewares/utility.ts b/backend/src/middlewares/utility.ts index 383675c15..0ae2386e7 100644 --- a/backend/src/middlewares/utility.ts +++ b/backend/src/middlewares/utility.ts @@ -52,7 +52,7 @@ export function recordClientVersion(): RequestHandler { }; } -export function onlyAvailableOnDev(): RequestHandler { +export function onlyAvailableOnDev(): MonkeyTypes.RequestHandler { return validate({ criteria: () => { return isDevEnvironment(); diff --git a/backend/src/types/types.d.ts b/backend/src/types/types.d.ts index 51dd1dcb6..3a7a4a85f 100644 --- a/backend/src/types/types.d.ts +++ b/backend/src/types/types.d.ts @@ -30,8 +30,11 @@ declare namespace MonkeyTypes { raw: Readonly; }; + /* eslint-disable-next-line @typescript-eslint/no-explicit-any */ + type RequestHandler = import("@ts-rest/core").TsRestRequestHandler; + type DBUser = Omit< - import("@monkeytype/shared-types").User, + import("@monkeytype/contracts/schemas/users").User, | "resultFilterPresets" | "tags" | "customThemes" @@ -41,26 +44,28 @@ declare namespace MonkeyTypes { > & { _id: ObjectId; resultFilterPresets?: WithObjectId< - import("@monkeytype/shared-types").ResultFilters + import("@monkeytype/contracts/schemas/users").ResultFilters >[]; tags?: DBUserTag[]; lbPersonalBests?: LbPersonalBests; customThemes?: DBCustomTheme[]; autoBanTimestamps?: number[]; - inbox?: import("@monkeytype/shared-types").MonkeyMail[]; + inbox?: import("@monkeytype/contracts/schemas/users").MonkeyMail[]; ips?: string[]; canReport?: boolean; lastNameChange?: number; canManageApeKeys?: boolean; bananas?: number; - testActivity?: import("@monkeytype/shared-types").CountByYearAndDay; + testActivity?: import("@monkeytype/contracts/schemas/users").CountByYearAndDay; }; type DBCustomTheme = WithObjectId< - import("@monkeytype/shared-types").CustomTheme + import("@monkeytype/contracts/schemas/users").CustomTheme >; - type DBUserTag = WithObjectId; + type DBUserTag = WithObjectId< + import("@monkeytype/contracts/schemas/users").UserTag + >; type LbPersonalBests = { time: Record< diff --git a/backend/src/utils/misc.ts b/backend/src/utils/misc.ts index 2db7e0b05..985784642 100644 --- a/backend/src/utils/misc.ts +++ b/backend/src/utils/misc.ts @@ -51,7 +51,7 @@ type AgentLog = { device?: string; }; -export function buildAgentLog(req: MonkeyTypes.Request): AgentLog { +export function buildAgentLog(req: TsRestRequest): AgentLog { const agent = uaparser(req.headers["user-agent"]); const agentLog: AgentLog = { diff --git a/backend/src/utils/monkey-mail.ts b/backend/src/utils/monkey-mail.ts index 45f2a7eb1..d97bb8665 100644 --- a/backend/src/utils/monkey-mail.ts +++ b/backend/src/utils/monkey-mail.ts @@ -1,4 +1,4 @@ -import { MonkeyMail } from "@monkeytype/shared-types"; +import { MonkeyMail } from "@monkeytype/contracts/schemas/users"; import { v4 } from "uuid"; type MonkeyMailOptions = Partial>; diff --git a/backend/src/utils/validation.ts b/backend/src/utils/validation.ts index 424a92c74..6876091b0 100644 --- a/backend/src/utils/validation.ts +++ b/backend/src/utils/validation.ts @@ -1,7 +1,5 @@ import _ from "lodash"; -import { replaceHomoglyphs } from "../constants/homoglyphs"; -import { profanities } from "../constants/profanities"; -import { intersect, sanitizeString } from "./misc"; +import { intersect } from "./misc"; import { default as FunboxList } from "../constants/funbox-list"; import { CompletedEvent } from "@monkeytype/contracts/schemas/results"; @@ -19,28 +17,6 @@ export function isUsernameValid(name: string): boolean { return VALID_NAME_PATTERN.test(name); } -export function containsProfanity( - text: string, - mode: "word" | "substring" -): boolean { - const normalizedText = text - .toLowerCase() - .split(/[.,"/#!?$%^&*;:{}=\-_`~()\s\n]+/g) - .map((str) => { - return replaceHomoglyphs(sanitizeString(str) ?? ""); - }); - - const hasProfanity = profanities.some((profanity) => { - return normalizedText.some((word) => { - return mode === "word" - ? word.startsWith(profanity) - : word.includes(profanity); - }); - }); - - return hasProfanity; -} - export function isTagPresetNameValid(name: string): boolean { if (_.isNil(name) || !inRange(name.length, 1, 16)) { return false; diff --git a/backend/src/workers/later-worker.ts b/backend/src/workers/later-worker.ts index 1c9d9a35c..2621cd53c 100644 --- a/backend/src/workers/later-worker.ts +++ b/backend/src/workers/later-worker.ts @@ -15,7 +15,7 @@ import LaterQueue, { } from "../queues/later-queue"; import { recordTimeToCompleteJob } from "../utils/prometheus"; import { WeeklyXpLeaderboard } from "../services/weekly-xp-leaderboard"; -import { MonkeyMail } from "@monkeytype/shared-types"; +import { MonkeyMail } from "@monkeytype/contracts/schemas/users"; async function handleDailyLeaderboardResults( ctx: LaterTaskContexts["daily-leaderboard-results"] diff --git a/frontend/__tests__/tsconfig.json b/frontend/__tests__/tsconfig.json index ce1b8aff4..b1d8a156f 100644 --- a/frontend/__tests__/tsconfig.json +++ b/frontend/__tests__/tsconfig.json @@ -10,9 +10,5 @@ "files": true }, "files": ["../src/ts/types/types.d.ts", "vitest.d.ts"], - "include": [ - "./**/*.spec.ts", - "./setup-tests.ts", - "../../shared-types/**/*.d.ts" - ] + "include": ["./**/*.spec.ts", "./setup-tests.ts"] } diff --git a/frontend/__tests__/utils/format.spec.ts b/frontend/__tests__/utils/format.spec.ts index be2b23c5d..6b5b7f376 100644 --- a/frontend/__tests__/utils/format.spec.ts +++ b/frontend/__tests__/utils/format.spec.ts @@ -1,5 +1,6 @@ import { Formatting } from "../../src/ts/utils/format"; import DefaultConfig from "../../src/ts/constants/default-config"; +import { Config } from "@monkeytype/contracts/schemas/configs"; describe("format.ts", () => { describe("typingsSpeed", () => { @@ -272,7 +273,7 @@ describe("format.ts", () => { }); }); -function getInstance(config?: Partial): Formatting { - const target: SharedTypes.Config = { ...DefaultConfig, ...config }; +function getInstance(config?: Partial): Formatting { + const target: Config = { ...DefaultConfig, ...config }; return new Formatting(target); } diff --git a/frontend/package.json b/frontend/package.json index 708289c01..40a562e6e 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -31,7 +31,6 @@ "devDependencies": { "@fortawesome/fontawesome-free": "5.15.4", "@monkeytype/eslint-config": "workspace:*", - "@monkeytype/shared-types": "workspace:*", "@monkeytype/typescript-config": "workspace:*", "@types/canvas-confetti": "1.4.3", "@types/chartjs-plugin-trendline": "1.0.1", diff --git a/frontend/src/ts/ape/adapters/axios-adapter.ts b/frontend/src/ts/ape/adapters/axios-adapter.ts deleted file mode 100644 index 3e39442af..000000000 --- a/frontend/src/ts/ape/adapters/axios-adapter.ts +++ /dev/null @@ -1,138 +0,0 @@ -import { getAuthenticatedUser, isAuthenticated } from "../../firebase"; -import { getIdToken } from "firebase/auth"; -import axios, { AxiosRequestConfig, AxiosResponse, isAxiosError } from "axios"; -import { envConfig } from "../../constants/env-config"; -import { createErrorMessage } from "../../utils/misc"; - -type AxiosClientMethod = ( - endpoint: string, - config: AxiosRequestConfig -) => Promise; - -type AxiosClientDataMethod = ( - endpoint: string, - data: unknown, - config: AxiosRequestConfig -) => Promise; - -async function adaptRequestOptions( - options: Ape.RequestOptionsWithPayload -): Promise { - const idToken = isAuthenticated() - ? await getIdToken(getAuthenticatedUser()) - : ""; - - return { - params: options.searchQuery, - data: options.payload, - headers: { - ...options.headers, - Accept: "application/json", - "Content-Type": "application/json", - ...(idToken && { Authorization: `Bearer ${idToken}` }), - "X-Client-Version": envConfig.clientVersion, - }, - }; -} - -function apeifyClientMethod( - clientMethod: AxiosClientMethod | AxiosClientDataMethod, - methodType: Ape.HttpMethodTypes -): Ape.HttpClientMethod | Ape.HttpClientMethodWithPayload { - return async function ( - endpoint: string, - options: Ape.RequestOptionsWithPayload = {} - ): Ape.EndpointResponse { - let errorMessage = ""; - - let requestOptions: AxiosRequestConfig; - try { - requestOptions = await adaptRequestOptions(options); - } catch (error) { - console.error("Failed to adapt request options"); - console.error(error); - - if ((error as Error).message.includes("auth/network-request-failed")) { - return { - status: 400, - message: - "Network error while trying to authenticate. Please try again.", - data: null, - }; - } - - const message = createErrorMessage( - error, - "Failed to adapt request options" - ); - return { - status: 400, - message: message, - data: null, - }; - } - - try { - let response; - if (methodType === "get" || methodType === "delete") { - response = await (clientMethod as AxiosClientMethod)( - endpoint, - requestOptions - ); - } else { - response = await (clientMethod as AxiosClientDataMethod)( - endpoint, - requestOptions.data, - requestOptions - ); - } - - const { message, data } = response.data; - - return { - status: response.status, - message, - data, - }; - } catch (error) { - console.error(error); - - const typedError = error as Error; - errorMessage = typedError.message; - - if (isAxiosError(typedError)) { - const data = typedError.response?.data as { data: TData }; - - return { - status: typedError.response?.status ?? 500, - message: typedError.message, - ...data, - }; - } - } - - return { - status: 500, - message: errorMessage, - data: null, - }; - }; -} - -export function buildHttpClient( - baseURL: string, - timeout: number -): Ape.HttpClient { - const axiosClient = axios.create({ - baseURL, - timeout, - }); - - return { - get: apeifyClientMethod(axiosClient.get, "get"), - post: apeifyClientMethod(axiosClient.post, "post"), - put: apeifyClientMethod(axiosClient.put, "put"), - patch: apeifyClientMethod(axiosClient.patch, "patch"), - delete: apeifyClientMethod(axiosClient.delete, "delete"), - }; -} diff --git a/frontend/src/ts/ape/endpoints/index.ts b/frontend/src/ts/ape/endpoints/index.ts deleted file mode 100644 index 22f979796..000000000 --- a/frontend/src/ts/ape/endpoints/index.ts +++ /dev/null @@ -1,5 +0,0 @@ -import Users from "./users"; - -export default { - Users, -}; diff --git a/frontend/src/ts/ape/endpoints/users.ts b/frontend/src/ts/ape/endpoints/users.ts deleted file mode 100644 index fda651de7..000000000 --- a/frontend/src/ts/ape/endpoints/users.ts +++ /dev/null @@ -1,293 +0,0 @@ -import { - CountByYearAndDay, - CustomTheme, - UserProfile, - UserProfileDetails, - UserTag, -} from "@monkeytype/shared-types"; -import { Mode, Mode2 } from "@monkeytype/contracts/schemas/shared"; -import { ResultFilters } from "@monkeytype/contracts/schemas/users"; - -const BASE_PATH = "/users"; - -export default class Users { - constructor(private httpClient: Ape.HttpClient) { - this.httpClient = httpClient; - } - - async getData(): Ape.EndpointResponse { - return await this.httpClient.get(BASE_PATH); - } - - async create( - name: string, - captcha: string, - email?: string, - uid?: string - ): Ape.EndpointResponse { - const payload = { - email, - name, - uid, - captcha, - }; - - return await this.httpClient.post(`${BASE_PATH}/signup`, { payload }); - } - - async getNameAvailability(name: string): Ape.EndpointResponse { - const encoded = encodeURIComponent(name); - return await this.httpClient.get(`${BASE_PATH}/checkName/${encoded}`); - } - - async delete(): Ape.EndpointResponse { - return await this.httpClient.delete(BASE_PATH); - } - - async reset(): Ape.EndpointResponse { - return await this.httpClient.patch(`${BASE_PATH}/reset`); - } - - async optOutOfLeaderboards(): Ape.EndpointResponse { - return await this.httpClient.post(`${BASE_PATH}/optOutOfLeaderboards`); - } - - async updateName(name: string): Ape.EndpointResponse { - return await this.httpClient.patch(`${BASE_PATH}/name`, { - payload: { name }, - }); - } - - async updateLeaderboardMemory( - mode: string, - mode2: Mode2, - language: string, - rank: number - ): Ape.EndpointResponse { - const payload = { - mode, - mode2, - language, - rank, - }; - - return await this.httpClient.patch(`${BASE_PATH}/leaderboardMemory`, { - payload, - }); - } - - async updateEmail( - newEmail: string, - previousEmail: string - ): Ape.EndpointResponse { - const payload = { - newEmail, - previousEmail, - }; - - return await this.httpClient.patch(`${BASE_PATH}/email`, { payload }); - } - - async updatePassword(newPassword: string): Ape.EndpointResponse { - return await this.httpClient.patch(`${BASE_PATH}/password`, { - payload: { newPassword }, - }); - } - - async deletePersonalBests(): Ape.EndpointResponse { - return await this.httpClient.delete(`${BASE_PATH}/personalBests`); - } - - async addResultFilterPreset( - filter: ResultFilters - ): Ape.EndpointResponse { - return await this.httpClient.post(`${BASE_PATH}/resultFilterPresets`, { - payload: filter, - }); - } - - async removeResultFilterPreset(id: string): Ape.EndpointResponse { - const encoded = encodeURIComponent(id); - return await this.httpClient.delete( - `${BASE_PATH}/resultFilterPresets/${encoded}` - ); - } - - async createTag(tagName: string): Ape.EndpointResponse { - return await this.httpClient.post(`${BASE_PATH}/tags`, { - payload: { tagName }, - }); - } - - async editTag(tagId: string, newName: string): Ape.EndpointResponse { - const payload = { - tagId, - newName, - }; - - return await this.httpClient.patch(`${BASE_PATH}/tags`, { payload }); - } - - async deleteTag(tagId: string): Ape.EndpointResponse { - const encoded = encodeURIComponent(tagId); - return await this.httpClient.delete(`${BASE_PATH}/tags/${encoded}`); - } - - async deleteTagPersonalBest(tagId: string): Ape.EndpointResponse { - const encoded = encodeURIComponent(tagId); - return await this.httpClient.delete( - `${BASE_PATH}/tags/${encoded}/personalBest` - ); - } - - async getCustomThemes(): Ape.EndpointResponse { - return await this.httpClient.get(`${BASE_PATH}/customThemes`); - } - - async editCustomTheme( - themeId: string, - newTheme: Partial - ): Ape.EndpointResponse { - const payload = { - themeId: themeId, - theme: { - name: newTheme.name, - colors: newTheme.colors, - }, - }; - return await this.httpClient.patch(`${BASE_PATH}/customThemes`, { - payload, - }); - } - - async deleteCustomTheme(themeId: string): Ape.EndpointResponse { - const payload = { - themeId: themeId, - }; - return await this.httpClient.delete(`${BASE_PATH}/customThemes`, { - payload, - }); - } - - async addCustomTheme( - newTheme: Partial - ): Ape.EndpointResponse { - const payload = { name: newTheme.name, colors: newTheme.colors }; - return await this.httpClient.post(`${BASE_PATH}/customThemes`, { payload }); - } - - async getOauthLink(): Ape.EndpointResponse { - return await this.httpClient.get(`${BASE_PATH}/discord/oauth`); - } - - async linkDiscord( - tokenType: string, - accessToken: string, - state: string - ): Ape.EndpointResponse { - return await this.httpClient.post(`${BASE_PATH}/discord/link`, { - payload: { tokenType, accessToken, state }, - }); - } - - async unlinkDiscord(): Ape.EndpointResponse { - return await this.httpClient.post(`${BASE_PATH}/discord/unlink`); - } - - async addQuoteToFavorites( - language: string, - quoteId: string - ): Ape.EndpointResponse { - const payload = { language, quoteId }; - return await this.httpClient.post(`${BASE_PATH}/favoriteQuotes`, { - payload, - }); - } - - async removeQuoteFromFavorites( - language: string, - quoteId: string - ): Ape.EndpointResponse { - const payload = { language, quoteId }; - return await this.httpClient.delete(`${BASE_PATH}/favoriteQuotes`, { - payload, - }); - } - - async getProfileByUid(uid: string): Ape.EndpointResponse { - const encoded = encodeURIComponent(uid); - return await this.httpClient.get(`${BASE_PATH}/${encoded}/profile?isUid`); - } - - async getProfileByName(name: string): Ape.EndpointResponse { - const encoded = encodeURIComponent(name); - return await this.httpClient.get(`${BASE_PATH}/${encoded}/profile`); - } - - async updateProfile( - profileUpdates: Partial, - selectedBadgeId?: number - ): Ape.EndpointResponse { - return await this.httpClient.patch(`${BASE_PATH}/profile`, { - payload: { - ...profileUpdates, - selectedBadgeId, - }, - }); - } - - async getInbox(): Ape.EndpointResponse { - return await this.httpClient.get(`${BASE_PATH}/inbox`); - } - - async updateInbox(options: { - mailIdsToDelete?: string[]; - mailIdsToMarkRead?: string[]; - }): Ape.EndpointResponse { - const payload = { - mailIdsToDelete: options.mailIdsToDelete, - mailIdsToMarkRead: options.mailIdsToMarkRead, - }; - return await this.httpClient.patch(`${BASE_PATH}/inbox`, { payload }); - } - - async report( - uid: string, - reason: string, - comment: string, - captcha: string - ): Ape.EndpointResponse { - const payload = { - uid, - reason, - comment, - captcha, - }; - - return await this.httpClient.post(`${BASE_PATH}/report`, { payload }); - } - - async verificationEmail(): Ape.EndpointResponse { - return await this.httpClient.get(`${BASE_PATH}/verificationEmail`); - } - - async forgotPasswordEmail(email: string): Ape.EndpointResponse { - return await this.httpClient.post(`${BASE_PATH}/forgotPasswordEmail`, { - payload: { email }, - }); - } - - async setStreakHourOffset(hourOffset: number): Ape.EndpointResponse { - return await this.httpClient.post(`${BASE_PATH}/setStreakHourOffset`, { - payload: { hourOffset }, - }); - } - - async revokeAllTokens(): Ape.EndpointResponse { - return await this.httpClient.post(`${BASE_PATH}/revokeAllTokens`); - } - - async getTestActivity(): Ape.EndpointResponse { - return await this.httpClient.get(`${BASE_PATH}/testActivity`); - } -} diff --git a/frontend/src/ts/ape/index.ts b/frontend/src/ts/ape/index.ts index a7501d498..52083d0db 100644 --- a/frontend/src/ts/ape/index.ts +++ b/frontend/src/ts/ape/index.ts @@ -1,22 +1,16 @@ -import endpoints from "./endpoints"; -import { buildHttpClient } from "./adapters/axios-adapter"; import { envConfig } from "../constants/env-config"; import { buildClient } from "./adapters/ts-rest-adapter"; import { contract } from "@monkeytype/contracts"; import { devContract } from "@monkeytype/contracts/dev"; -const API_PATH = ""; const BASE_URL = envConfig.backendUrl; -const API_URL = `${BASE_URL}${API_PATH}`; -const httpClient = buildHttpClient(API_URL, 10_000); const tsRestClient = buildClient(contract, BASE_URL, 10_000); const devClient = buildClient(devContract, BASE_URL, 240_000); // API Endpoints const Ape = { ...tsRestClient, - users: new endpoints.Users(httpClient), dev: devClient, }; diff --git a/frontend/src/ts/ape/types/ape.d.ts b/frontend/src/ts/ape/types/ape.d.ts deleted file mode 100644 index 9a7e56d28..000000000 --- a/frontend/src/ts/ape/types/ape.d.ts +++ /dev/null @@ -1,40 +0,0 @@ -declare namespace Ape { - type RequestOptions = { - headers?: Record; - searchQuery?: Record; - }; - - type HttpClientMethod = ( - endpoint: string, - options?: Ape.RequestOptions - ) => Ape.EndpointResponse; - - type RequestOptionsWithPayload = { - headers?: Record; - searchQuery?: Record; - payload?: TPayload; - }; - - type HttpClientMethodWithPayload = ( - endpoint: string, - options?: Ape.RequestOptionsWithPayload - ) => Ape.EndpointResponse; - - type HttpClientResponse = { - status: number; - message: string; - data: TData | null; - }; - - type EndpointResponse = Promise>; - - type HttpClient = { - get: HttpClientMethod; - post: HttpClientMethodWithPayload; - put: HttpClientMethodWithPayload; - patch: HttpClientMethodWithPayload; - delete: HttpClientMethodWithPayload; - }; - - type HttpMethodTypes = keyof HttpClient; -} diff --git a/frontend/src/ts/ape/types/users.d.ts b/frontend/src/ts/ape/types/users.d.ts deleted file mode 100644 index 5c4ae13e8..000000000 --- a/frontend/src/ts/ape/types/users.d.ts +++ /dev/null @@ -1,18 +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.Users { - type GetUser = import("@monkeytype/shared-types").User & { - inboxUnreadSize: number; - isPremium: boolean; - }; - type GetOauthLink = { - url: string; - }; - type LinkDiscord = { - discordId: string; - discordAvatar: string; - }; - type GetInbox = { - inbox: MonkeyMail[] | undefined; - }; -} diff --git a/frontend/src/ts/ape/utils.ts b/frontend/src/ts/ape/utils.ts deleted file mode 100644 index 81bce25e5..000000000 --- a/frontend/src/ts/ape/utils.ts +++ /dev/null @@ -1,53 +0,0 @@ -type ShouldRetryCallback = ( - statusCode: number, - response?: Ape.HttpClientResponse -) => boolean; - -type RetryOptions = { - shouldRetry?: ShouldRetryCallback; - retryAttempts?: number; - retryDelayMs?: number; -}; - -const wait = async (delay: number): Promise => - new Promise((resolve) => window.setTimeout(resolve, delay)); - -const DEFAULT_RETRY_OPTIONS: Required = { - shouldRetry: (statusCode: number): boolean => - statusCode >= 500 && statusCode !== 503, - retryAttempts: 3, - retryDelayMs: 3000, -}; - -export async function withRetry( - fn: () => Ape.EndpointResponse, - opts?: RetryOptions -): Ape.EndpointResponse { - const retry = async ( - previousData: Ape.HttpClientResponse, - completeOpts: Required> - ): Promise> => { - const { retryAttempts, shouldRetry, retryDelayMs } = completeOpts; - - if (retryAttempts <= 0 || !shouldRetry(previousData.status, previousData)) { - return previousData; - } - - const data = await fn(); - const { status } = data; - - if (shouldRetry(status, data)) { - await wait(retryDelayMs); - - --completeOpts.retryAttempts; - return await retry(data, completeOpts); - } - - return data; - }; - - return await retry(await fn(), { - ...DEFAULT_RETRY_OPTIONS, - ...opts, - }); -} diff --git a/frontend/src/ts/controllers/account-controller.ts b/frontend/src/ts/controllers/account-controller.ts index f74a52ee7..cbcee49f3 100644 --- a/frontend/src/ts/controllers/account-controller.ts +++ b/frontend/src/ts/controllers/account-controller.ts @@ -66,7 +66,7 @@ async function sendVerificationEmail(): Promise { if (result.status !== 200) { Loader.hide(); Notifications.add( - "Failed to request verification email: " + result.message, + "Failed to request verification email: " + result.body.message, -1 ); } else { @@ -563,14 +563,16 @@ async function signUp(): Promise { password ); - const signInResponse = await Ape.users.create( - nname, - captchaToken, - email, - createdAuthUser.user.uid - ); + const signInResponse = await Ape.users.create({ + body: { + name: nname, + captcha: captchaToken, + email, + uid: createdAuthUser.user.uid, + }, + }); if (signInResponse.status !== 200) { - throw new Error(`Failed to sign in: ${signInResponse.message}`); + throw new Error(`Failed to sign in: ${signInResponse.body.message}`); } await updateProfile(createdAuthUser.user, { displayName: nname }); diff --git a/frontend/src/ts/controllers/profile-search-controller.ts b/frontend/src/ts/controllers/profile-search-controller.ts index f80fb6fa7..c8d098f62 100644 --- a/frontend/src/ts/controllers/profile-search-controller.ts +++ b/frontend/src/ts/controllers/profile-search-controller.ts @@ -53,7 +53,7 @@ async function lookupProfile(): Promise { await sleep(500); - const response = await Ape.users.getProfileByName(name); + const response = await Ape.users.getProfile({ params: { uidOrName: name } }); enableInputs(); if (response.status === 404) { focusInput(); @@ -61,12 +61,12 @@ async function lookupProfile(): Promise { return; } else if (response.status !== 200) { focusInput(); - searchIndicator.show("error", `Error: ${response.message}`); + searchIndicator.show("error", `Error: ${response.body.message}`); return; } searchIndicator.hide(); navigate(`/profile/${name}`, { - data: response.data, + data: response.body.data, }); } diff --git a/frontend/src/ts/controllers/quotes-controller.ts b/frontend/src/ts/controllers/quotes-controller.ts index 09a63edda..71bc025b9 100644 --- a/frontend/src/ts/controllers/quotes-controller.ts +++ b/frontend/src/ts/controllers/quotes-controller.ts @@ -217,10 +217,12 @@ class QuotesController { if (!isFavorite) { // Remove from favorites - const response = await Ape.users.removeQuoteFromFavorites( - quote.language, - `${quote.id}` - ); + const response = await Ape.users.removeQuoteFromFavorites({ + body: { + language: quote.language, + quoteId: `${quote.id}`, + }, + }); if (response.status === 200) { const quoteIndex = snapshot.favoriteQuotes?.[quote.language]?.indexOf( @@ -228,14 +230,16 @@ class QuotesController { ) as number; snapshot.favoriteQuotes?.[quote.language]?.splice(quoteIndex, 1); } else { - throw new Error(response.message); + throw new Error(response.body.message); } } else { // Remove from favorites - const response = await Ape.users.addQuoteToFavorites( - quote.language, - `${quote.id}` - ); + const response = await Ape.users.addQuoteToFavorites({ + body: { + language: quote.language, + quoteId: `${quote.id}`, + }, + }); if (response.status === 200) { if (snapshot.favoriteQuotes === undefined) { @@ -246,7 +250,7 @@ class QuotesController { } snapshot.favoriteQuotes[quote.language]?.push(`${quote.id}`); } else { - throw new Error(response.message); + throw new Error(response.body.message); } } } diff --git a/frontend/src/ts/db.ts b/frontend/src/ts/db.ts index a9dfecfd2..b1618839a 100644 --- a/frontend/src/ts/db.ts +++ b/frontend/src/ts/db.ts @@ -15,7 +15,7 @@ import { } from "./elements/test-activity-calendar"; import * as Loader from "./elements/loader"; -import { Badge } from "@monkeytype/shared-types"; +import { Badge } from "@monkeytype/contracts/schemas/users"; import { Config, Difficulty } from "@monkeytype/contracts/schemas/configs"; import { Mode, @@ -78,14 +78,14 @@ export async function initSnapshot(): Promise< // LoadingPage.updateText("Downloading user..."); const [userResponse, configResponse, presetsResponse] = await Promise.all([ - Ape.users.getData(), + Ape.users.get(), Ape.configs.get(), Ape.presets.get(), ]); if (userResponse.status !== 200) { throw new SnapshotInitError( - `${userResponse.message} (user)`, + `${userResponse.body.message} (user)`, userResponse.status ); } @@ -102,7 +102,7 @@ export async function initSnapshot(): Promise< ); } - const userData = userResponse.data; + const userData = userResponse.body.data; const configData = configResponse.body.data; const presetsData = presetsResponse.body.data; @@ -155,7 +155,7 @@ export async function initSnapshot(): Promise< snap.streak = userData?.streak?.length ?? 0; snap.maxStreak = userData?.streak?.maxLength ?? 0; snap.filterPresets = userData.resultFilterPresets ?? []; - snap.isPremium = userData?.isPremium; + snap.isPremium = userData?.isPremium ?? false; snap.allTimeLbs = userData.allTimeLbs; if (userData.testActivity !== undefined) { @@ -349,20 +349,23 @@ export async function addCustomTheme( return false; } - const response = await Ape.users.addCustomTheme(theme); + const response = await Ape.users.addCustomTheme({ body: { ...theme } }); if (response.status !== 200) { - Notifications.add("Error adding custom theme: " + response.message, -1); + Notifications.add( + "Error adding custom theme: " + response.body.message, + -1 + ); return false; } - if (response.data === null) { + if (response.body.data === null) { Notifications.add("Error adding custom theme: No data returned", -1); return false; } const newCustomTheme: MonkeyTypes.CustomTheme = { ...theme, - _id: response.data._id, + _id: response.body.data._id, }; dbSnapshot.customThemes.push(newCustomTheme); @@ -389,9 +392,14 @@ export async function editCustomTheme( return false; } - const response = await Ape.users.editCustomTheme(themeId, newTheme); + const response = await Ape.users.editCustomTheme({ + body: { themeId, theme: newTheme }, + }); if (response.status !== 200) { - Notifications.add("Error editing custom theme: " + response.message, -1); + Notifications.add( + "Error editing custom theme: " + response.body.message, + -1 + ); return false; } @@ -413,9 +421,12 @@ export async function deleteCustomTheme(themeId: string): Promise { const customTheme = dbSnapshot.customThemes?.find((t) => t._id === themeId); if (!customTheme) return false; - const response = await Ape.users.deleteCustomTheme(themeId); + const response = await Ape.users.deleteCustomTheme({ body: { themeId } }); if (response.status !== 200) { - Notifications.add("Error deleting custom theme: " + response.message, -1); + Notifications.add( + "Error deleting custom theme: " + response.body.message, + -1 + ); return false; } @@ -908,7 +919,9 @@ export async function updateLbMemory( const mem = snapshot.lbMemory[timeMode][timeMode2]; mem[language] = rank; if (api && current !== rank) { - await Ape.users.updateLeaderboardMemory(mode, mode2, language, rank); + await Ape.users.updateLeaderboardMemory({ + body: { mode, mode2, language, rank }, + }); } setSnapshot(snapshot); } @@ -1024,7 +1037,7 @@ export async function getTestActivityCalendar( const response = await Ape.users.getTestActivity(); if (response.status !== 200) { Notifications.add( - "Error getting test activities: " + response.message, + "Error getting test activities: " + response.body.message, -1 ); Loader.hide(); @@ -1032,9 +1045,9 @@ export async function getTestActivityCalendar( } dbSnapshot.testActivityByYear = {}; - for (const year in response.data) { + for (const year in response.body.data) { if (year === currentYear) continue; - const testsByDays = response.data[year] ?? []; + const testsByDays = response.body.data[year] ?? []; const lastDay = Dates.addDays( new Date(parseInt(year), 0, 1), testsByDays.length diff --git a/frontend/src/ts/elements/account/result-filters.ts b/frontend/src/ts/elements/account/result-filters.ts index fc7759dcf..c886f1f17 100644 --- a/frontend/src/ts/elements/account/result-filters.ts +++ b/frontend/src/ts/elements/account/result-filters.ts @@ -183,15 +183,20 @@ function addFilterPresetToSnapshot(filter: ResultFilters): void { export async function createFilterPreset(name: string): Promise { name = name.replace(/ /g, "_"); Loader.show(); - const result = await Ape.users.addResultFilterPreset({ ...filters, name }); + const result = await Ape.users.addResultFilterPreset({ + body: { ...filters, name }, + }); Loader.hide(); if (result.status === 200) { - addFilterPresetToSnapshot({ ...filters, name, _id: result.data as string }); + addFilterPresetToSnapshot({ ...filters, name, _id: result.body.data }); void updateFilterPresets(); Notifications.add("Filter preset created", 1); } else { - Notifications.add("Error creating filter preset: " + result.message, -1); - console.log("error creating filter preset: " + result.message); + Notifications.add( + "Error creating filter preset: " + result.body.message, + -1 + ); + console.log("error creating filter preset: " + result.body.message); } } @@ -210,7 +215,9 @@ function removeFilterPresetFromSnapshot(id: string): void { // deletes the currently selected filter preset async function deleteFilterPreset(id: string): Promise { Loader.show(); - const result = await Ape.users.removeResultFilterPreset(id); + const result = await Ape.users.removeResultFilterPreset({ + params: { presetId: id }, + }); Loader.hide(); if (result.status === 200) { removeFilterPresetFromSnapshot(id); @@ -218,8 +225,11 @@ async function deleteFilterPreset(id: string): Promise { reset(); Notifications.add("Filter preset deleted", 1); } else { - Notifications.add("Error deleting filter preset: " + result.message, -1); - console.log("error deleting filter preset", result.message); + Notifications.add( + "Error deleting filter preset: " + result.body.message, + -1 + ); + console.log("error deleting filter preset", result.body.message); } } diff --git a/frontend/src/ts/elements/alerts.ts b/frontend/src/ts/elements/alerts.ts index 38d0043e8..f48a2cfcf 100644 --- a/frontend/src/ts/elements/alerts.ts +++ b/frontend/src/ts/elements/alerts.ts @@ -39,13 +39,15 @@ function hide(): void { if (mailToMarkRead.length === 0 && mailToDelete.length === 0) return; const updateResponse = await Ape.users.updateInbox({ - mailIdsToMarkRead: - mailToMarkRead.length > 0 ? mailToMarkRead : undefined, - mailIdsToDelete: mailToDelete.length > 0 ? mailToDelete : undefined, + body: { + mailIdsToMarkRead: + mailToMarkRead.length > 0 ? mailToMarkRead : undefined, + mailIdsToDelete: mailToDelete.length > 0 ? mailToDelete : undefined, + }, }); const status = updateResponse.status; - const message = updateResponse.message; + const message = updateResponse.body.message; if (status !== 200) { Notifications.add(`Failed to update inbox: ${message}`, -1); return; @@ -146,15 +148,12 @@ async function getAccountAlerts(): Promise { } else if (inboxResponse.status !== 200) { $("#alertsPopup .accountAlerts .list").html(`
- Error getting inbox: ${inboxResponse.message} Please try again later + Error getting inbox: ${inboxResponse.body.message} Please try again later
`); return; } - const inboxData = inboxResponse.data as { - inbox: MonkeyTypes.MonkeyMail[]; - maxMail: number; - }; + const inboxData = inboxResponse.body.data; accountAlerts = inboxData.inbox; diff --git a/frontend/src/ts/elements/profile.ts b/frontend/src/ts/elements/profile.ts index dae149d25..f4cf7ef7e 100644 --- a/frontend/src/ts/elements/profile.ts +++ b/frontend/src/ts/elements/profile.ts @@ -11,7 +11,7 @@ import * as ActivePage from "../states/active-page"; import { formatDistanceToNowStrict } from "date-fns/formatDistanceToNowStrict"; import { getHtmlByUserFlags } from "../controllers/user-flag-controller"; import Format from "../utils/format"; -import { RankAndCount, UserProfile } from "@monkeytype/shared-types"; +import { UserProfile, RankAndCount } from "@monkeytype/contracts/schemas/users"; type ProfileViewPaths = "profile" | "account"; type UserProfileOrSnapshot = UserProfile | MonkeyTypes.Snapshot; @@ -246,9 +246,9 @@ export async function update( details.find(".keyboard .value").text(profile.details?.keyboard ?? ""); if ( - profile.details?.socialProfiles.github !== undefined || - profile.details?.socialProfiles.twitter !== undefined || - profile.details?.socialProfiles.website !== undefined + profile.details?.socialProfiles?.github !== undefined || + profile.details?.socialProfiles?.twitter !== undefined || + profile.details?.socialProfiles?.website !== undefined ) { socials = true; const socialsEl = details.find(".socials .value"); @@ -302,8 +302,8 @@ export async function update( } else { profileElement.find(".leaderboardsPositions").removeClass("hidden"); - const t15 = profile.allTimeLbs.time?.["15"]?.["english"] ?? null; - const t60 = profile.allTimeLbs.time?.["60"]?.["english"] ?? null; + const t15 = profile.allTimeLbs?.time?.["15"]?.["english"] ?? null; + const t60 = profile.allTimeLbs?.time?.["60"]?.["english"] ?? null; if (t15 === null && t60 === null) { profileElement.find(".leaderboardsPositions").addClass("hidden"); diff --git a/frontend/src/ts/modals/edit-profile.ts b/frontend/src/ts/modals/edit-profile.ts index 3da573c09..1ca19fc06 100644 --- a/frontend/src/ts/modals/edit-profile.ts +++ b/frontend/src/ts/modals/edit-profile.ts @@ -7,7 +7,7 @@ import * as ConnectionState from "../states/connection"; import AnimatedModal from "../utils/animated-modal"; import * as Profile from "../elements/profile"; import { CharacterCounter } from "../elements/character-counter"; -import { Badge, UserProfileDetails } from "@monkeytype/shared-types"; +import { Badge, UserProfileDetails } from "@monkeytype/contracts/schemas/users"; export function show(): void { if (!ConnectionState.get()) { @@ -125,8 +125,8 @@ async function updateProfile(): Promise { // check for length resctrictions before sending server requests const githubLengthLimit = 39; if ( - updates.socialProfiles.github !== undefined && - updates.socialProfiles.github.length > githubLengthLimit + updates.socialProfiles?.github !== undefined && + updates.socialProfiles?.github.length > githubLengthLimit ) { Notifications.add( `GitHub username exceeds maximum allowed length (${githubLengthLimit} characters).`, @@ -137,8 +137,8 @@ async function updateProfile(): Promise { const twitterLengthLimit = 20; if ( - updates.socialProfiles.twitter !== undefined && - updates.socialProfiles.twitter.length > twitterLengthLimit + updates.socialProfiles?.twitter !== undefined && + updates.socialProfiles?.twitter.length > twitterLengthLimit ) { Notifications.add( `Twitter username exceeds maximum allowed length (${twitterLengthLimit} characters).`, @@ -148,18 +148,20 @@ async function updateProfile(): Promise { } Loader.show(); - const response = await Ape.users.updateProfile( - updates, - currentSelectedBadgeId - ); + const response = await Ape.users.updateProfile({ + body: { + ...updates, + selectedBadgeId: currentSelectedBadgeId, + }, + }); Loader.hide(); if (response.status !== 200) { - Notifications.add("Failed to update profile: " + response.message, -1); + Notifications.add("Failed to update profile: " + response.body.message, -1); return; } - snapshot.details = response.data ?? updates; + snapshot.details = response.body.data ?? updates; snapshot.inventory?.badges.forEach((badge) => { if (badge.id === currentSelectedBadgeId) { badge.selected = true; diff --git a/frontend/src/ts/modals/edit-tag.ts b/frontend/src/ts/modals/edit-tag.ts index b5ec99e80..be066b753 100644 --- a/frontend/src/ts/modals/edit-tag.ts +++ b/frontend/src/ts/modals/edit-tag.ts @@ -86,15 +86,16 @@ async function apply(): Promise { Loader.show(); if (action === "add") { - const response = await Ape.users.createTag(tagName); + const response = await Ape.users.createTag({ body: { tagName } }); if (response.status !== 200) { Notifications.add( - "Failed to add tag: " + response.message.replace(tagName, propTagName), + "Failed to add tag: " + + response.body.message.replace(tagName, propTagName), -1 ); } else { - if (response.data === null) { + if (response.body.data === null) { Notifications.add("Tag was added but data returned was null", -1); Loader.hide(); return; @@ -103,8 +104,8 @@ async function apply(): Promise { Notifications.add("Tag added", 1); DB.getSnapshot()?.tags?.push({ display: propTagName, - name: response.data.name, - _id: response.data._id, + name: response.body.data.name, + _id: response.body.data._id, personalBests: { time: {}, words: {}, @@ -116,10 +117,12 @@ async function apply(): Promise { void Settings.update(); } } else if (action === "edit") { - const response = await Ape.users.editTag(tagId, tagName); + const response = await Ape.users.editTag({ + body: { tagId, newName: tagName }, + }); if (response.status !== 200) { - Notifications.add("Failed to edit tag: " + response.message, -1); + Notifications.add("Failed to edit tag: " + response.body.message, -1); } else { Notifications.add("Tag updated", 1); DB.getSnapshot()?.tags?.forEach((tag) => { @@ -131,10 +134,10 @@ async function apply(): Promise { void Settings.update(); } } else if (action === "remove") { - const response = await Ape.users.deleteTag(tagId); + const response = await Ape.users.deleteTag({ params: { tagId } }); if (response.status !== 200) { - Notifications.add("Failed to remove tag: " + response.message, -1); + Notifications.add("Failed to remove tag: " + response.body.message, -1); } else { Notifications.add("Tag removed", 1); DB.getSnapshot()?.tags?.forEach((tag, index: number) => { @@ -145,10 +148,12 @@ async function apply(): Promise { void Settings.update(); } } else if (action === "clearPb") { - const response = await Ape.users.deleteTagPersonalBest(tagId); + const response = await Ape.users.deleteTagPersonalBest({ + params: { tagId }, + }); if (response.status !== 200) { - Notifications.add("Failed to clear tag pb: " + response.message, -1); + Notifications.add("Failed to clear tag pb: " + response.body.message, -1); } else { Notifications.add("Tag PB cleared", 1); DB.getSnapshot()?.tags?.forEach((tag) => { diff --git a/frontend/src/ts/modals/google-sign-up.ts b/frontend/src/ts/modals/google-sign-up.ts index eca55df88..1c0ac59cc 100644 --- a/frontend/src/ts/modals/google-sign-up.ts +++ b/frontend/src/ts/modals/google-sign-up.ts @@ -77,9 +77,9 @@ async function apply(): Promise { const name = $("#googleSignUpModal input").val() as string; try { if (name.length === 0) throw new Error("Name cannot be empty"); - const response = await Ape.users.create(name, captcha); + const response = await Ape.users.create({ body: { name, captcha } }); if (response.status !== 200) { - throw new Error(`Failed to create user: ${response.message}`); + throw new Error(`Failed to create user: ${response.body.message}`); } if (response.status === 200) { @@ -152,31 +152,23 @@ const nameIndicator = new InputIndicator($("#googleSignUpModal input"), { const checkNameDebounced = debounce(1000, async () => { const val = $("#googleSignUpModal input").val() as string; if (!val) return; - const response = await Ape.users.getNameAvailability(val); + const response = await Ape.users.getNameAvailability({ + params: { name: val }, + }); if (response.status === 200) { - nameIndicator.show("available", response.message); + nameIndicator.show("available", response.body.message); enableButton(); - return; - } - - if (response.status === 422) { - nameIndicator.show("unavailable", response.message); - return; - } - - if (response.status === 409) { - nameIndicator.show("taken", response.message); - return; - } - - if (response.status !== 200) { + } else if (response.status === 422) { + nameIndicator.show("unavailable", response.body.message); + } else if (response.status === 409) { + nameIndicator.show("taken", response.body.message); + } else { nameIndicator.show("unavailable"); Notifications.add( - "Failed to check name availability: " + response.message, + "Failed to check name availability: " + response.body.message, -1 ); - return; } }); diff --git a/frontend/src/ts/modals/share-test-settings.ts b/frontend/src/ts/modals/share-test-settings.ts index aebc3178c..8a0cf4ba5 100644 --- a/frontend/src/ts/modals/share-test-settings.ts +++ b/frontend/src/ts/modals/share-test-settings.ts @@ -6,7 +6,6 @@ import { compressToURI } from "lz-ts"; import AnimatedModal, { ShowOptions } from "../utils/animated-modal"; import { Difficulty } from "@monkeytype/contracts/schemas/configs"; import { Mode, Mode2 } from "@monkeytype/contracts/schemas/shared"; -import { CustomTextData } from "@monkeytype/shared-types"; function getCheckboxValue(checkbox: string): boolean { return $(`#shareTestSettingsModal label.${checkbox} input`).prop( @@ -17,7 +16,7 @@ function getCheckboxValue(checkbox: string): boolean { type SharedTestSettings = [ Mode | null, Mode2 | null, - CustomTextData | null, + MonkeyTypes.CustomTextData | null, boolean | null, boolean | null, string | null, diff --git a/frontend/src/ts/modals/simple-modals.ts b/frontend/src/ts/modals/simple-modals.ts index f40330366..89bc1195b 100644 --- a/frontend/src/ts/modals/simple-modals.ts +++ b/frontend/src/ts/modals/simple-modals.ts @@ -252,15 +252,14 @@ list.updateEmail = new SimpleModal({ }; } - const response = await Ape.users.updateEmail( - email, - reauth.user.email as string - ); + const response = await Ape.users.updateEmail({ + body: { newEmail: email, previousEmail: reauth.user.email as string }, + }); if (response.status !== 200) { return { status: -1, - message: "Failed to update email: " + response.message, + message: "Failed to update email: " + response.body.message, }; } @@ -463,7 +462,9 @@ list.updateName = new SimpleModal({ }; } - const checkNameResponse = await Ape.users.getNameAvailability(newName); + const checkNameResponse = await Ape.users.getNameAvailability({ + params: { name: newName }, + }); if (checkNameResponse.status === 409) { return { @@ -473,15 +474,17 @@ list.updateName = new SimpleModal({ } else if (checkNameResponse.status !== 200) { return { status: -1, - message: "Failed to check name: " + checkNameResponse.message, + message: "Failed to check name: " + checkNameResponse.body.message, }; } - const updateNameResponse = await Ape.users.updateName(newName); + const updateNameResponse = await Ape.users.updateName({ + body: { name: newName }, + }); if (updateNameResponse.status !== 200) { return { status: -1, - message: "Failed to update name: " + updateNameResponse.message, + message: "Failed to update name: " + updateNameResponse.body.message, }; } @@ -539,24 +542,24 @@ list.updatePassword = new SimpleModal({ execFn: async ( _thisPopup, previousPass, - newPass, + newPassword, newPassConfirm ): Promise => { - if (newPass !== newPassConfirm) { + if (newPassword !== newPassConfirm) { return { status: 0, message: "New passwords don't match", }; } - if (newPass === previousPass) { + if (newPassword === previousPass) { return { status: 0, message: "New password must be different from previous password", }; } - if (!isDevEnvironment() && !isPasswordStrong(newPass)) { + if (!isDevEnvironment() && !isPasswordStrong(newPassword)) { return { status: 0, message: @@ -572,12 +575,14 @@ list.updatePassword = new SimpleModal({ }; } - const response = await Ape.users.updatePassword(newPass); + const response = await Ape.users.updatePassword({ + body: { newPassword }, + }); if (response.status !== 200) { return { status: -1, - message: "Failed to update password: " + response.message, + message: "Failed to update password: " + response.body.message, }; } @@ -668,16 +673,18 @@ list.addPasswordAuth = new SimpleModal({ }; } - const response = await Ape.users.updateEmail( - email, - reauth.user.email as string - ); + const response = await Ape.users.updateEmail({ + body: { + newEmail: email, + previousEmail: reauth.user.email as string, + }, + }); if (response.status !== 200) { return { status: -1, message: "Password authentication added but updating the database email failed. This shouldn't happen, please contact support. Error: " + - response.message, + response.body.message, }; } @@ -717,7 +724,7 @@ list.deleteAccount = new SimpleModal({ if (usersResponse.status !== 200) { return { status: -1, - message: "Failed to delete user data: " + usersResponse.message, + message: "Failed to delete user data: " + usersResponse.body.message, }; } @@ -767,7 +774,7 @@ list.resetAccount = new SimpleModal({ if (response.status !== 200) { return { status: -1, - message: "Failed to reset account: " + response.message, + message: "Failed to reset account: " + response.body.message, }; } @@ -813,7 +820,7 @@ list.optOutOfLeaderboards = new SimpleModal({ if (response.status !== 200) { return { status: -1, - message: "Failed to opt out: " + response.message, + message: "Failed to opt out: " + response.body.message, }; } @@ -840,11 +847,13 @@ list.clearTagPb = new SimpleModal({ buttonText: "clear", execFn: async (thisPopup): Promise => { const tagId = thisPopup.parameters[0] as string; - const response = await Ape.users.deleteTagPersonalBest(tagId); + const response = await Ape.users.deleteTagPersonalBest({ + params: { tagId }, + }); if (response.status !== 200) { return { status: -1, - message: "Failed to clear tag PB: " + response.message, + message: "Failed to clear tag PB: " + response.body.message, }; } @@ -917,7 +926,7 @@ list.resetPersonalBests = new SimpleModal({ if (response.status !== 200) { return { status: -1, - message: "Failed to reset personal bests: " + response.message, + message: "Failed to reset personal bests: " + response.body.message, }; } @@ -992,7 +1001,7 @@ list.revokeAllTokens = new SimpleModal({ if (response.status !== 200) { return { status: -1, - message: "Failed to revoke tokens: " + response.message, + message: "Failed to revoke tokens: " + response.body.message, }; } @@ -1033,7 +1042,7 @@ list.unlinkDiscord = new SimpleModal({ if (response.status !== 200) { return { status: -1, - message: "Failed to unlink Discord: " + response.message, + message: "Failed to unlink Discord: " + response.body.message, }; } @@ -1220,17 +1229,19 @@ list.forgotPassword = new SimpleModal({ ], buttonText: "send", execFn: async (_thisPopup, email): Promise => { - const result = await Ape.users.forgotPasswordEmail(email.trim()); + const result = await Ape.users.forgotPasswordEmail({ + body: { email: email.trim() }, + }); if (result.status !== 200) { return { status: -1, - message: "Failed to send password reset email: " + result.message, + message: "Failed to send password reset email: " + result.body.message, }; } return { status: 1, - message: result.message, + message: result.body.message, notificationOptions: { duration: 8, }, diff --git a/frontend/src/ts/modals/streak-hour-offset.ts b/frontend/src/ts/modals/streak-hour-offset.ts index bd01854f0..1b6e0089e 100644 --- a/frontend/src/ts/modals/streak-hour-offset.ts +++ b/frontend/src/ts/modals/streak-hour-offset.ts @@ -82,11 +82,13 @@ async function apply(): Promise { Loader.show(); - const response = await Ape.users.setStreakHourOffset(value); + const response = await Ape.users.setStreakHourOffset({ + body: { hourOffset: value }, + }); Loader.hide(); if (response.status !== 200) { Notifications.add( - "Failed to set streak hour offset: " + response.message, + "Failed to set streak hour offset: " + response.body.message, -1 ); } else { diff --git a/frontend/src/ts/modals/user-report.ts b/frontend/src/ts/modals/user-report.ts index 7491060fe..8d7a78117 100644 --- a/frontend/src/ts/modals/user-report.ts +++ b/frontend/src/ts/modals/user-report.ts @@ -7,6 +7,7 @@ import SlimSelect from "slim-select"; import AnimatedModal from "../utils/animated-modal"; import { isAuthenticated } from "../firebase"; import { CharacterCounter } from "../elements/character-counter"; +import { ReportUserReason } from "@monkeytype/contracts/schemas/users"; type State = { userUid?: string; @@ -80,7 +81,7 @@ async function submitReport(): Promise { return; } - const reason = $("#userReportModal .reason").val() as string; + const reason = $("#userReportModal .reason").val() as ReportUserReason; const comment = $("#userReportModal .comment").val() as string; const captcha = captchaResponse; @@ -114,16 +115,18 @@ async function submitReport(): Promise { } Loader.show(); - const response = await Ape.users.report( - state.userUid as string, - reason, - comment, - captcha - ); + const response = await Ape.users.report({ + body: { + uid: state.userUid as string, + reason, + comment, + captcha, + }, + }); Loader.hide(); if (response.status !== 200) { - Notifications.add("Failed to report user: " + response.message, -1); + Notifications.add("Failed to report user: " + response.body.message, -1); return; } diff --git a/frontend/src/ts/pages/account-settings.ts b/frontend/src/ts/pages/account-settings.ts index e835e3692..403c1bc7a 100644 --- a/frontend/src/ts/pages/account-settings.ts +++ b/frontend/src/ts/pages/account-settings.ts @@ -8,6 +8,7 @@ import Ape from "../ape"; import * as StreakHourOffsetModal from "../modals/streak-hour-offset"; import * as Loader from "../elements/loader"; import * as ApeKeyTable from "../elements/account-settings/ape-key-table"; +import * as Notifications from "../elements/notifications"; const pageElement = $(".page.pageAccountSettings"); @@ -190,8 +191,15 @@ $( ".page.pageAccountSettings .section.discordIntegration .getLinkAndGoToOauth" ).on("click", () => { Loader.show(); - void Ape.users.getOauthLink().then((res) => { - window.open(res.data?.url as string, "_self"); + void Ape.users.getDiscordOAuth().then((response) => { + if (response.status === 200) { + window.open(response.body.data.url, "_self"); + } else { + Notifications.add( + "Failed to get OAuth from discord: " + response.body.message, + -1 + ); + } }); }); diff --git a/frontend/src/ts/pages/login.ts b/frontend/src/ts/pages/login.ts index c89d662c4..51b574ece 100644 --- a/frontend/src/ts/pages/login.ts +++ b/frontend/src/ts/pages/login.ts @@ -58,18 +58,20 @@ const checkNameDebounced = debounce(1000, async () => { updateSignupButton(); return; } - const response = await Ape.users.getNameAvailability(val); + const response = await Ape.users.getNameAvailability({ + params: { name: val }, + }); if (response.status === 200) { - nameIndicator.show("available", response.message); + nameIndicator.show("available", response.body.message); } else if (response.status === 422) { - nameIndicator.show("unavailable", response.message); + nameIndicator.show("unavailable", response.body.message); } else if (response.status === 409) { - nameIndicator.show("taken", response.message); + nameIndicator.show("taken", response.body.message); } else { - nameIndicator.show("unavailable", response.message); + nameIndicator.show("unavailable", response.body.message); Notifications.add( - "Failed to check name availability: " + response.message, + "Failed to check name availability: " + response.body.message, -1 ); } diff --git a/frontend/src/ts/pages/profile.ts b/frontend/src/ts/pages/profile.ts index 2b392fe6b..55f7dbcc2 100644 --- a/frontend/src/ts/pages/profile.ts +++ b/frontend/src/ts/pages/profile.ts @@ -6,7 +6,7 @@ import * as Notifications from "../elements/notifications"; import { checkIfGetParameterExists } from "../utils/misc"; import * as UserReportModal from "../modals/user-report"; import * as Skeleton from "../utils/skeleton"; -import { UserProfile } from "@monkeytype/shared-types"; +import { UserProfile } from "@monkeytype/contracts/schemas/users"; import { PersonalBests } from "@monkeytype/contracts/schemas/shared"; function reset(): void { @@ -172,30 +172,36 @@ async function update(options: UpdateOptions): Promise { true ); } else if (options.uidOrName !== undefined && options.uidOrName !== "") { - const response = getParamExists - ? await Ape.users.getProfileByUid(options.uidOrName) - : await Ape.users.getProfileByName(options.uidOrName); + const response = await Ape.users.getProfile({ + params: { uidOrName: options.uidOrName }, + query: { isUid: getParamExists }, + }); + $(".page.pageProfile .preloader").addClass("hidden"); - if (response.status === 404 || response.data === null) { + if (response.status === 404) { const message = getParamExists ? "User not found" : `User ${options.uidOrName} not found`; $(".page.pageProfile .preloader").addClass("hidden"); $(".page.pageProfile .error").removeClass("hidden"); $(".page.pageProfile .error .message").text(message); - } else if (response.status !== 200) { - // $(".page.pageProfile .failedToLoad").removeClass("hidden"); - Notifications.add("Failed to load profile: " + response.message, -1); - return; - } else { - window.history.replaceState(null, "", `/profile/${response.data.name}`); - await Profile.update("profile", response.data); + } else if (response.status === 200) { + window.history.replaceState( + null, + "", + `/profile/${response.body.data.name}` + ); + await Profile.update("profile", response.body.data); // this cast is fine because pb tables can handle the partial data inside user profiles PbTables.update( - response.data.personalBests as unknown as PersonalBests, + response.body.data.personalBests as unknown as PersonalBests, true ); + } else { + // $(".page.pageProfile .failedToLoad").removeClass("hidden"); + Notifications.add("Failed to load profile: " + response.body.message, -1); + return; } } else { Notifications.add("Missing update parameter!", -1); diff --git a/frontend/src/ts/test/custom-text.ts b/frontend/src/ts/test/custom-text.ts index 748a0dee8..5959d1ee3 100644 --- a/frontend/src/ts/test/custom-text.ts +++ b/frontend/src/ts/test/custom-text.ts @@ -1,4 +1,3 @@ -import { CustomTextData, CustomTextLimit } from "@monkeytype/shared-types"; import { CustomTextLimitMode, CustomTextMode, @@ -47,7 +46,7 @@ let text: string[] = [ ]; let mode: CustomTextMode = "repeat"; -const limit: CustomTextLimit = { +const limit: MonkeyTypes.CustomTextLimit = { value: 9, mode: "word", }; @@ -71,7 +70,7 @@ export function setMode(val: CustomTextMode): void { limit.value = text.length; } -export function getLimit(): CustomTextLimit { +export function getLimit(): MonkeyTypes.CustomTextLimit { return limit; } @@ -99,7 +98,7 @@ export function setPipeDelimiter(val: boolean): void { pipeDelimiter = val; } -export function getData(): CustomTextData { +export function getData(): MonkeyTypes.CustomTextData { return { text, mode, diff --git a/frontend/src/ts/test/practise-words.ts b/frontend/src/ts/test/practise-words.ts index 773aa2885..4a9e08634 100644 --- a/frontend/src/ts/test/practise-words.ts +++ b/frontend/src/ts/test/practise-words.ts @@ -6,13 +6,12 @@ import * as TestInput from "./test-input"; import * as ConfigEvent from "../observables/config-event"; import { setCustomTextName } from "../states/custom-text-name"; import { Mode } from "@monkeytype/contracts/schemas/shared"; -import { CustomTextData } from "@monkeytype/shared-types"; type Before = { mode: Mode | null; punctuation: boolean | null; numbers: boolean | null; - customText: CustomTextData | null; + customText: MonkeyTypes.CustomTextData | null; }; export const before: Before = { diff --git a/frontend/src/ts/test/result.ts b/frontend/src/ts/test/result.ts index 66cb7aaa7..a28271dd5 100644 --- a/frontend/src/ts/test/result.ts +++ b/frontend/src/ts/test/result.ts @@ -1046,13 +1046,15 @@ $(".pageTest #favoriteQuoteButton").on("click", async () => { if ($button.hasClass("fas")) { // Remove from favorites Loader.show(); - const response = await Ape.users.removeQuoteFromFavorites( - quoteLang, - quoteId - ); + const response = await Ape.users.removeQuoteFromFavorites({ + body: { + language: quoteLang, + quoteId, + }, + }); Loader.hide(); - Notifications.add(response.message, response.status === 200 ? 1 : -1); + Notifications.add(response.body.message, response.status === 200 ? 1 : -1); if (response.status === 200) { $button.removeClass("fas").addClass("far"); @@ -1064,10 +1066,12 @@ $(".pageTest #favoriteQuoteButton").on("click", async () => { } else { // Add to favorites Loader.show(); - const response = await Ape.users.addQuoteToFavorites(quoteLang, quoteId); + const response = await Ape.users.addQuoteToFavorites({ + body: { language: quoteLang, quoteId }, + }); Loader.hide(); - Notifications.add(response.message, response.status === 200 ? 1 : -1); + Notifications.add(response.body.message, response.status === 200 ? 1 : -1); if (response.status === 200) { $button.removeClass("far").addClass("fas"); diff --git a/frontend/src/ts/types/types.d.ts b/frontend/src/ts/types/types.d.ts index b6e7dd6a6..07c632a74 100644 --- a/frontend/src/ts/types/types.d.ts +++ b/frontend/src/ts/types/types.d.ts @@ -209,13 +209,13 @@ declare namespace MonkeyTypes { type QuoteRatings = Record>; - type UserTag = import("@monkeytype/shared-types").UserTag & { + type UserTag = import("@monkeytype/contracts/schemas/users").UserTag & { active?: boolean; display: string; }; type Snapshot = Omit< - import("@monkeytype/shared-types").User, + import("@monkeytype/contracts/schemas/users").User, | "timeTyping" | "startedTests" | "completedTests" @@ -231,7 +231,7 @@ declare namespace MonkeyTypes { startedTests: number; completedTests: number; }; - details?: import("@monkeytype/shared-types").UserProfileDetails; + details?: import("@monkeytype/contracts/schemas/users").UserProfileDetails; inboxUnreadSize: number; streak: number; maxStreak: number; @@ -435,8 +435,8 @@ declare namespace MonkeyTypes { type BadgeReward = { type: "badge"; - item: import("@monkeytype/shared-types").Badge; - } & Reward; + item: import("@monkeytype/contracts/schemas/users").Badge; + } & Reward; type AllRewards = XpReward | BadgeReward; @@ -500,4 +500,15 @@ declare namespace MonkeyTypes { numbers: boolean; punctuation: boolean; }; + type CustomTextLimit = { + value: number; + mode: import("@monkeytype/contracts/schemas/util").CustomTextLimitMode; + }; + + type CustomTextData = Omit< + import("@monkeytype/contracts/schemas/results").CustomTextDataWithTextLen, + "textLen" + > & { + text: string[]; + }; } diff --git a/frontend/src/ts/utils/misc.ts b/frontend/src/ts/utils/misc.ts index 6a3c4070e..28a9d89a1 100644 --- a/frontend/src/ts/utils/misc.ts +++ b/frontend/src/ts/utils/misc.ts @@ -2,7 +2,6 @@ import * as Loader from "../elements/loader"; import { envConfig } from "../constants/env-config"; import { lastElementFromArray } from "./arrays"; import * as JSONData from "./json-data"; -import { CustomTextData } from "@monkeytype/shared-types"; import { Config } from "@monkeytype/contracts/schemas/configs"; import { Mode, @@ -227,7 +226,7 @@ export function canQuickRestart( mode: string, words: number, time: number, - CustomText: CustomTextData, + CustomText: MonkeyTypes.CustomTextData, customTextIsLong: boolean ): boolean { const wordsLong = mode === "words" && (words >= 1000 || words === 0); diff --git a/frontend/src/ts/utils/url-handler.ts b/frontend/src/ts/utils/url-handler.ts index 9cbad5052..97c80ef36 100644 --- a/frontend/src/ts/utils/url-handler.ts +++ b/frontend/src/ts/utils/url-handler.ts @@ -13,7 +13,6 @@ import { restart as restartTest } from "../test/test-logic"; import * as ChallengeController from "../controllers/challenge-controller"; import { Mode, Mode2 } from "@monkeytype/contracts/schemas/shared"; import { Difficulty } from "@monkeytype/contracts/schemas/configs"; -import { CustomTextData } from "@monkeytype/shared-types"; export async function linkDiscord(hashOverride: string): Promise { if (!hashOverride) return; @@ -25,25 +24,27 @@ export async function linkDiscord(hashOverride: string): Promise { const state = fragment.get("state") as string; Loader.show(); - const response = await Ape.users.linkDiscord(tokenType, accessToken, state); + const response = await Ape.users.linkDiscord({ + body: { tokenType, accessToken, state }, + }); Loader.hide(); if (response.status !== 200) { - Notifications.add("Failed to link Discord: " + response.message, -1); + Notifications.add("Failed to link Discord: " + response.body.message, -1); return; } - if (response.data === null) { + if (response.body.data === null) { Notifications.add("Failed to link Discord: data returned was null", -1); return; } - Notifications.add(response.message, 1); + Notifications.add(response.body.message, 1); const snapshot = DB.getSnapshot(); if (!snapshot) return; - const { discordId, discordAvatar } = response.data; + const { discordId, discordAvatar } = response.body.data; if (discordId !== undefined) { snapshot.discordId = discordId; } else { @@ -108,7 +109,7 @@ export function loadCustomThemeFromUrl(getOverride?: string): void { type SharedTestSettings = [ Mode | null, Mode2 | null, - CustomTextData | null, + MonkeyTypes.CustomTextData | null, boolean | null, boolean | null, string | null, diff --git a/packages/contracts/__test__/tsconfig.json b/packages/contracts/__test__/tsconfig.json new file mode 100644 index 000000000..1069df217 --- /dev/null +++ b/packages/contracts/__test__/tsconfig.json @@ -0,0 +1,12 @@ +{ + "extends": "@monkeytype/typescript-config/base.json", + "compilerOptions": { + "noEmit": true, + "types": ["vitest/globals"] + }, + "ts-node": { + "files": true + }, + "files": ["../src/types/types.d.ts"], + "include": ["./**/*.ts", "./**/*.spec.ts", "./setup-tests.ts"] +} diff --git a/packages/contracts/__test__/validation/validation.spec.ts b/packages/contracts/__test__/validation/validation.spec.ts new file mode 100644 index 000000000..58903a05e --- /dev/null +++ b/packages/contracts/__test__/validation/validation.spec.ts @@ -0,0 +1,42 @@ +import * as Validation from "../../src/validation/validation"; + +describe("validation", () => { + it("containsProfanity", () => { + const testCases = [ + { + text: "https://www.fuckyou.com", + expected: true, + }, + { + text: "fucking_profane", + expected: true, + }, + { + text: "fucker", + expected: true, + }, + { + text: "Hello world!", + expected: false, + }, + { + text: "I fucking hate you", + expected: true, + }, + { + text: "I love you", + expected: false, + }, + { + text: "\n.fuck!", + expected: true, + }, + ]; + + testCases.forEach((testCase) => { + expect(Validation.containsProfanity(testCase.text, "substring")).toBe( + testCase.expected + ); + }); + }); +}); diff --git a/packages/contracts/package.json b/packages/contracts/package.json index 7c9efed10..8f97eb285 100644 --- a/packages/contracts/package.json +++ b/packages/contracts/package.json @@ -4,6 +4,7 @@ "scripts": { "dev": "rimraf ./dist && node esbuild.config.js --watch", "build": "rimraf ./dist && npm run madge && node esbuild.config.js", + "test": "vitest run", "madge": " madge --circular --extensions ts ./src", "ts-check": "tsc --noEmit", "lint": "eslint \"./**/*.ts\"" @@ -20,7 +21,8 @@ "eslint": "8.57.0", "madge": "8.0.0", "rimraf": "6.0.1", - "typescript": "5.5.4" + "typescript": "5.5.4", + "vitest": "2.0.5" }, "exports": { ".": { diff --git a/packages/contracts/src/dev.ts b/packages/contracts/src/dev.ts index 1a5a19623..d0656b60b 100644 --- a/packages/contracts/src/dev.ts +++ b/packages/contracts/src/dev.ts @@ -48,9 +48,9 @@ export const devContract = c.router( pathPrefix: "/dev", strictStatusCodes: true, metadata: { - openApiTags: "dev", + openApiTags: "development", authenticationOptions: { - isPublic: true, + isPublicOnDev: true, }, } as EndpointMetadata, commonResponses: CommonResponses, diff --git a/packages/contracts/src/index.ts b/packages/contracts/src/index.ts index df9ef2ced..dc84d01f8 100644 --- a/packages/contracts/src/index.ts +++ b/packages/contracts/src/index.ts @@ -9,6 +9,7 @@ import { leaderboardsContract } from "./leaderboards"; import { resultsContract } from "./results"; import { configurationContract } from "./configuration"; import { devContract } from "./dev"; +import { usersContract } from "./users"; import { quotesContract } from "./quotes"; const c = initContract(); @@ -24,5 +25,6 @@ export const contract = c.router({ results: resultsContract, configuration: configurationContract, dev: devContract, + users: usersContract, quotes: quotesContract, }); diff --git a/packages/contracts/src/schemas/api.ts b/packages/contracts/src/schemas/api.ts index c882c8a34..ac05bd219 100644 --- a/packages/contracts/src/schemas/api.ts +++ b/packages/contracts/src/schemas/api.ts @@ -10,7 +10,8 @@ export type OpenApiTag = | "leaderboards" | "results" | "configuration" - | "dev" + | "development" + | "users" | "quotes"; export type EndpointMetadata = { @@ -37,7 +38,7 @@ export const MonkeyResponseSchema = z.object({ export type MonkeyResponseType = z.infer; export const MonkeyValidationErrorSchema = MonkeyResponseSchema.extend({ - validationErrors: z.array(z.string()).nonempty(), + validationErrors: z.array(z.string()), }); export type MonkeyValidationError = z.infer; diff --git a/packages/contracts/src/schemas/shared.ts b/packages/contracts/src/schemas/shared.ts index 5eb97b5d4..e80e9a2d8 100644 --- a/packages/contracts/src/schemas/shared.ts +++ b/packages/contracts/src/schemas/shared.ts @@ -25,8 +25,14 @@ export type PersonalBest = z.infer; //used by user and config export const PersonalBestsSchema = z.object({ - time: z.record(StringNumberSchema, z.array(PersonalBestSchema)), - words: z.record(StringNumberSchema, z.array(PersonalBestSchema)), + time: z.record( + StringNumberSchema.describe("Number of seconds as string"), + z.array(PersonalBestSchema) + ), + words: z.record( + StringNumberSchema.describe("Number of words as string"), + z.array(PersonalBestSchema) + ), quote: z.record(StringNumberSchema, z.array(PersonalBestSchema)), custom: z.record(z.literal("custom"), z.array(PersonalBestSchema)), zen: z.record(z.literal("zen"), z.array(PersonalBestSchema)), @@ -41,7 +47,7 @@ export const Mode2Schema = z.union( [StringNumberSchema, literal("zen"), literal("custom")], { errorMap: () => ({ - message: 'Needs to be either a number, "zen" or "custom."', + message: 'Needs to be either a number, "zen" or "custom".', }), } ); diff --git a/packages/contracts/src/schemas/users.ts b/packages/contracts/src/schemas/users.ts index 43d058321..c7630ead7 100644 --- a/packages/contracts/src/schemas/users.ts +++ b/packages/contracts/src/schemas/users.ts @@ -1,62 +1,371 @@ -import { z } from "zod"; -import { IdSchema } from "./util"; -import { ModeSchema } from "./shared"; +import { z, ZodEffects, ZodOptional, ZodString } from "zod"; +import { IdSchema, LanguageSchema, StringNumberSchema } from "./util"; +import { ModeSchema, Mode2Schema, PersonalBestsSchema } from "./shared"; +import { CustomThemeColorsSchema } from "./configs"; +import { doesNotContainProfanity } from "../validation/validation"; export const ResultFiltersSchema = z.object({ _id: IdSchema, - name: z.string(), - pb: z.object({ - no: z.boolean(), - yes: z.boolean(), - }), - difficulty: z.object({ - normal: z.boolean(), - expert: z.boolean(), - master: z.boolean(), - }), + name: z + .string() + .regex(/^[0-9a-zA-Z_.-]+$/) + .max(16), + pb: z + .object({ + no: z.boolean(), + yes: z.boolean(), + }) + .strict(), + difficulty: z + .object({ + normal: z.boolean(), + expert: z.boolean(), + master: z.boolean(), + }) + .strict(), mode: z.record(ModeSchema, z.boolean()), - words: z.object({ - "10": z.boolean(), - "25": z.boolean(), - "50": z.boolean(), - "100": z.boolean(), - custom: z.boolean(), - }), - time: z.object({ - "15": z.boolean(), - "30": z.boolean(), - "60": z.boolean(), - "120": z.boolean(), - custom: z.boolean(), - }), - quoteLength: z.object({ - short: z.boolean(), - medium: z.boolean(), - long: z.boolean(), - thicc: z.boolean(), - }), - punctuation: z.object({ - on: z.boolean(), - off: z.boolean(), - }), - numbers: z.object({ - on: z.boolean(), - off: z.boolean(), - }), - date: z.object({ - last_day: z.boolean(), - last_week: z.boolean(), - last_month: z.boolean(), - last_3months: z.boolean(), - all: z.boolean(), - }), - tags: z.record(z.boolean()), - language: z.record(z.boolean()), - funbox: z.record(z.boolean()), + words: z + .object({ + "10": z.boolean(), + "25": z.boolean(), + "50": z.boolean(), + "100": z.boolean(), + custom: z.boolean(), + }) + .strict(), + time: z + .object({ + "15": z.boolean(), + "30": z.boolean(), + "60": z.boolean(), + "120": z.boolean(), + custom: z.boolean(), + }) + .strict(), + quoteLength: z + .object({ + short: z.boolean(), + medium: z.boolean(), + long: z.boolean(), + thicc: z.boolean(), + }) + .strict(), + punctuation: z + .object({ + on: z.boolean(), + off: z.boolean(), + }) + .strict(), + numbers: z + .object({ + on: z.boolean(), + off: z.boolean(), + }) + .strict(), + date: z + .object({ + last_day: z.boolean(), + last_week: z.boolean(), + last_month: z.boolean(), + last_3months: z.boolean(), + all: z.boolean(), + }) + .strict(), + tags: z.record(z.string(), z.boolean()), + language: z.record(LanguageSchema, z.boolean()), + funbox: z.record(z.string(), z.boolean()), }); export type ResultFilters = z.infer; +export const StreakHourOffsetSchema = z.number().int().min(-11).max(12); +export type StreakHourOffset = z.infer; + +export const UserStreakSchema = z + .object({ + lastResultTimestamp: z.number().int().nonnegative(), + length: z.number().int().nonnegative(), + maxLength: z.number().int().nonnegative(), + hourOffset: StreakHourOffsetSchema.optional(), + }) + .strict(); +export type UserStreak = z.infer; + +export const UserTagSchema = z + .object({ + _id: IdSchema, + name: z.string(), + personalBests: PersonalBestsSchema, + }) + .strict(); +export type UserTag = z.infer; + +function profileDetailsBase( + schema: ZodString +): ZodEffects>> { + return doesNotContainProfanity("word", schema) + .optional() + .transform((value) => (value === null ? undefined : value)); +} + +export const UserProfileDetailsSchema = z + .object({ + bio: profileDetailsBase(z.string().max(250)), + keyboard: profileDetailsBase(z.string().max(75)), + socialProfiles: z + .object({ + twitter: profileDetailsBase( + z + .string() + .max(20) + .regex(/^[0-9a-zA-Z_.-]+$/) + ), + github: profileDetailsBase( + z + .string() + .max(39) + .regex(/^[0-9a-zA-Z_.-]+$/) + ), + website: profileDetailsBase( + z.string().url().max(200).startsWith("https://") + ), + }) + .strict() + .optional(), + }) + .strict(); +export type UserProfileDetails = z.infer; + +export const CustomThemeNameSchema = z + .string() + .regex(/^[0-9a-zA-Z_-]+$/) + .max(16); +export type CustomThemeName = z.infer; + +export const CustomThemeSchema = z + .object({ + _id: IdSchema, + name: CustomThemeNameSchema, + colors: CustomThemeColorsSchema, + }) + .strict(); +export type CustomTheme = z.infer; + +export const PremiumInfoSchema = z.object({ + startTimestamp: z.number().int().nonnegative(), + expirationTimestamp: z + .number() + .int() + .nonnegative() + .or(z.literal(-1).describe("lifetime premium")), +}); +export type PremiumInfo = z.infer; + +export const UserQuoteRatingsSchema = z.record( + LanguageSchema, + z.record( + StringNumberSchema.describe("quoteId as string"), + z.number().nonnegative() + ) +); +export type UserQuoteRatings = z.infer; + +export const UserLbMemorySchema = z.record( + ModeSchema, + z.record( + Mode2Schema, + z.record(LanguageSchema, z.number().int().nonnegative()) + ) +); +export type UserLbMemory = z.infer; + +export const RankAndCountSchema = z.object({ + rank: z.number().int().nonnegative().optional(), + count: z.number().int().nonnegative(), +}); +export type RankAndCount = z.infer; + +export const AllTimeLbsSchema = z.object({ + time: z.record( + Mode2Schema, + z.record(LanguageSchema, RankAndCountSchema.optional()) + ), +}); +export type AllTimeLbs = z.infer; + +export const BadgeSchema = z + .object({ + id: z.number().int().nonnegative(), + selected: z.boolean().optional(), + }) + .strict(); +export type Badge = z.infer; + +export const UserInventorySchema = z + .object({ + badges: z.array(BadgeSchema), + }) + .strict(); +export type UserInventory = z.infer; + +export const QuoteModSchema = z + .boolean() + .describe("Admin for all languages if true") + .or(LanguageSchema.describe("Admin for the given language")); +export type QuoteMod = z.infer; + +export const TestActivitySchema = z + .object({ + testsByDays: z + .array(z.number().int().nonnegative().or(z.null())) + .describe( + "Number of tests by day. Last element of the array is on the date `lastDay`. `null` means no tests on that day." + ), + lastDay: z + .number() + .int() + .nonnegative() + .describe("Timestamp of the last day included in the test activity"), + }) + .strict(); +export type TestActivity = z.infer; + +export const CountByYearAndDaySchema = z.record( + StringNumberSchema.describe("year"), + z.array( + z + .number() + .int() + .nonnegative() + .nullable() + .describe("number of tests, position in the array is the day of the year") + ) +); +export type CountByYearAndDay = z.infer; + +//Record; + +export const UserSchema = z.object({ + name: z.string(), + email: z.string().email(), + uid: z.string(), //defined by firebase, no validation should be applied + addedAt: z.number().int().nonnegative(), + personalBests: PersonalBestsSchema, + lastReultHashes: z.array(z.string()).optional(), //todo: fix typo (its in the db too) + completedTests: z.number().int().nonnegative().optional(), + startedTests: z.number().int().nonnegative().optional(), + timeTyping: z + .number() + .nonnegative() + .optional() + .describe("time typing in seconds"), + streak: UserStreakSchema.optional(), + xp: z.number().int().nonnegative().optional(), + discordId: z.string().optional(), + discordAvatar: z.string().optional(), + tags: z.array(UserTagSchema).optional(), + profileDetails: UserProfileDetailsSchema.optional(), + customThemes: z.array(CustomThemeSchema).optional(), + premium: PremiumInfoSchema.optional(), + isPremium: z.boolean().optional(), + quoteRatings: UserQuoteRatingsSchema.optional(), + favoriteQuotes: FavoriteQuotesSchema.optional(), + lbMemory: UserLbMemorySchema.optional(), + allTimeLbs: AllTimeLbsSchema, + inventory: UserInventorySchema.optional(), + banned: z.boolean().optional(), + lbOptOut: z.boolean().optional(), + verified: z.boolean().optional(), + needsToChangeName: z.boolean().optional(), + quoteMod: QuoteModSchema.optional(), + resultFilterPresets: z.array(ResultFiltersSchema).optional(), + testActivity: TestActivitySchema.optional(), +}); +export type User = z.infer; + export type ResultFiltersGroup = keyof ResultFilters; export type ResultFiltersGroupItem = keyof ResultFilters[T]; + +export const TagNameSchema = z + .string() + .regex(/^[0-9a-zA-Z_.-]+$/) + .max(16); +export type TagName = z.infer; + +export const TypingStatsSchema = z.object({ + completedTests: z.number().int().nonnegative().optional(), + startedTests: z.number().int().nonnegative().optional(), + timeTyping: z.number().int().nonnegative().optional(), +}); +export type TypingStats = z.infer; + +export const UserProfileSchema = UserSchema.pick({ + uid: true, + name: true, + banned: true, + addedAt: true, + discordId: true, + discordAvatar: true, + xp: true, + lbOptOut: true, + isPremium: true, + inventory: true, + allTimeLbs: true, +}) + .extend({ + typingStats: TypingStatsSchema, + personalBests: PersonalBestsSchema.pick({ time: true, words: true }), + streak: z.number().int().nonnegative(), + maxStreak: z.number().int().nonnegative(), + details: UserProfileDetailsSchema, + }) + .partial({ + //omitted for banned users + inventory: true, + details: true, + allTimeLbs: true, + uid: true, + }); +export type UserProfile = z.infer; + +export const RewardTypeSchema = z.enum(["xp", "badge"]); +export type RewardType = z.infer; + +export const XpRewardSchema = z.object({ + type: z.literal(RewardTypeSchema.enum.xp), + item: z.number().int(), +}); +export type XpReward = z.infer; + +export const BadgeRewardSchema = z.object({ + type: z.literal(RewardTypeSchema.enum.badge), + item: BadgeSchema, +}); +export type BadgeReward = z.infer; + +export const AllRewardsSchema = XpRewardSchema.or(BadgeRewardSchema); +export type AllRewards = z.infer; + +export const MonkeyMailSchema = z.object({ + id: IdSchema, + subject: z.string(), + body: z.string(), + timestamp: z.number().int().nonnegative(), + read: z.boolean(), + rewards: z.array(AllRewardsSchema), +}); +export type MonkeyMail = z.infer; + +export const ReportUserReasonSchema = z.enum([ + "Inappropriate name", + "Inappropriate bio", + "Inappropriate social links", + "Suspected cheating", +]); +export type ReportUserReason = z.infer; diff --git a/packages/contracts/src/users.ts b/packages/contracts/src/users.ts new file mode 100644 index 000000000..d2f34a265 --- /dev/null +++ b/packages/contracts/src/users.ts @@ -0,0 +1,807 @@ +import { initContract } from "@ts-rest/core"; +import { z } from "zod"; +import { + CommonResponses, + EndpointMetadata, + MonkeyClientError, + MonkeyResponseSchema, + responseWithData, + responseWithNullableData, +} from "./schemas/api"; +import { + CountByYearAndDaySchema, + CustomThemeNameSchema, + CustomThemeSchema, + FavoriteQuotesSchema, + MonkeyMailSchema, + ResultFiltersSchema, + StreakHourOffsetSchema, + TagNameSchema, + TestActivitySchema, + UserProfileDetailsSchema, + UserProfileSchema, + ReportUserReasonSchema, + UserSchema, + UserStreakSchema, + UserTagSchema, +} from "./schemas/users"; +import { Mode2Schema, ModeSchema, PersonalBestSchema } from "./schemas/shared"; +import { IdSchema, LanguageSchema, StringNumberSchema } from "./schemas/util"; +import { CustomThemeColorsSchema } from "./schemas/configs"; +import { doesNotContainProfanity } from "./validation/validation"; + +export const GetUserResponseSchema = responseWithData( + UserSchema.extend({ + inboxUnreadSize: z.number().int().nonnegative(), + }) +); +export type GetUserResponse = z.infer; + +const UserNameSchema = doesNotContainProfanity( + "substring", + z + .string() + .min(1) + .max(16) + .regex(/^[\da-zA-Z_-]+$/) +); + +export const CreateUserRequestSchema = z.object({ + email: z.string().email().optional(), + name: UserNameSchema, + uid: z.string().optional(), //defined by firebase, no validation should be applied + captcha: z.string(), //defined by google recaptcha, no validation should be applied +}); +export type CreateUserRequest = z.infer; + +export const CheckNamePathParametersSchema = z.object({ + name: UserNameSchema, +}); +export type CheckNamePathParameters = z.infer< + typeof CheckNamePathParametersSchema +>; + +export const UpdateUserNameRequestSchema = z.object({ + name: UserNameSchema, +}); +export type UpdateUserNameRequest = z.infer; + +export const UpdateLeaderboardMemoryRequestSchema = z.object({ + mode: ModeSchema, + mode2: Mode2Schema, + language: LanguageSchema, + rank: z.number().int().nonnegative(), +}); +export type UpdateLeaderboardMemoryRequest = z.infer< + typeof UpdateLeaderboardMemoryRequestSchema +>; + +export const UpdateEmailRequestSchema = z.object({ + newEmail: z.string().email(), + previousEmail: z.string().email(), +}); +export type UpdateEmailRequestSchema = z.infer; + +export const UpdatePasswordRequestSchema = z.object({ + newPassword: z.string().min(6), +}); +export type UpdatePasswordRequest = z.infer; + +export const GetPersonalBestsQuerySchema = z.object({ + mode: ModeSchema, + mode2: Mode2Schema, +}); +export type GetPersonalBestsQuery = z.infer; + +export const GetPersonalBestsResponseSchema = + responseWithNullableData(PersonalBestSchema); +export type GetPersonalBestsResponse = z.infer< + typeof GetPersonalBestsResponseSchema +>; + +export const AddResultFilterPresetRequestSchema = ResultFiltersSchema; +export type AddResultFilterPresetRequest = z.infer< + typeof AddResultFilterPresetRequestSchema +>; +export const AddResultFilterPresetResponseSchema = responseWithData( + IdSchema.describe("Id of the created result filter preset") +); +export type AddResultFilterPresetResponse = z.infer< + typeof AddResultFilterPresetResponseSchema +>; + +export const RemoveResultFilterPresetPathParamsSchema = z.object({ + presetId: IdSchema, +}); +export type RemoveResultFilterPresetPathParams = z.infer< + typeof RemoveResultFilterPresetPathParamsSchema +>; + +export const GetTagsResponseSchema = responseWithData(z.array(UserTagSchema)); +export type GetTagsResponse = z.infer; + +export const AddTagRequestSchema = z.object({ + tagName: TagNameSchema, +}); +export type AddTagRequest = z.infer; + +export const AddTagResponseSchema = responseWithData(UserTagSchema); +export type AddTagResponse = z.infer; + +export const EditTagRequestSchema = z.object({ + tagId: IdSchema, + newName: TagNameSchema, +}); +export type EditTagRequest = z.infer; + +export const TagIdPathParamsSchema = z.object({ + tagId: IdSchema, +}); +export type TagIdPathParams = z.infer; + +export const GetCustomThemesResponseSchema = responseWithData( + z.array(CustomThemeSchema) +); +export type GetCustomThemesResponse = z.infer< + typeof GetCustomThemesResponseSchema +>; + +export const AddCustomThemeRequestSchema = z.object({ + name: CustomThemeNameSchema, + colors: CustomThemeColorsSchema, +}); +export type AddCustomThemeRequest = z.infer; + +export const AddCustomThemeResponseSchema = responseWithData( + CustomThemeSchema.pick({ _id: true, name: true }) +); +export type AddCustomThemeResponse = z.infer< + typeof AddCustomThemeResponseSchema +>; + +export const DeleteCustomThemeRequestSchema = z.object({ + themeId: IdSchema, +}); +export type DeleteCustomThemeRequest = z.infer< + typeof DeleteCustomThemeRequestSchema +>; + +export const EditCustomThemeRequstSchema = z.object({ + themeId: IdSchema, + theme: CustomThemeSchema.pick({ name: true, colors: true }), +}); +export type EditCustomThemeRequst = z.infer; + +export const GetDiscordOauthLinkResponseSchema = responseWithData( + z.object({ + url: z.string().url(), + }) +); +export type GetDiscordOauthLinkResponse = z.infer< + typeof GetDiscordOauthLinkResponseSchema +>; + +export const LinkDiscordRequestSchema = z.object({ + tokenType: z.string(), + accessToken: z.string(), + state: z.string().length(20), +}); +export type LinkDiscordRequest = z.infer; + +export const LinkDiscordResponseSchema = responseWithData( + UserSchema.pick({ discordId: true, discordAvatar: true }) +); +export type LinkDiscordResponse = z.infer; + +export const GetStatsResponseSchema = responseWithData( + UserSchema.pick({ + completedTests: true, + startedTests: true, + timeTyping: true, + }) +); +export type GetStatsResponse = z.infer; + +export const SetStreakHourOffsetRequestSchema = z.object({ + hourOffset: StreakHourOffsetSchema, +}); +export type SetStreakHourOffsetRequest = z.infer< + typeof SetStreakHourOffsetRequestSchema +>; + +export const GetFavoriteQuotesResponseSchema = + responseWithData(FavoriteQuotesSchema); +export type GetFavoriteQuotesResponse = z.infer< + typeof GetFavoriteQuotesResponseSchema +>; + +export const AddFavoriteQuoteRequestSchema = z.object({ + language: LanguageSchema, + quoteId: StringNumberSchema, +}); +export type AddFavoriteQuoteRequest = z.infer< + typeof AddFavoriteQuoteRequestSchema +>; + +export const RemoveFavoriteQuoteRequestSchema = z.object({ + language: LanguageSchema, + quoteId: StringNumberSchema, +}); +export type RemoveFavoriteQuoteRequest = z.infer< + typeof RemoveFavoriteQuoteRequestSchema +>; + +export const GetProfilePathParamsSchema = z.object({ + uidOrName: z.string(), +}); +export type GetProfilePathParams = z.infer; + +//TODO test?! +export const GetProfileQuerySchema = z.object({ + isUid: z + .string() + .length(0) + .transform((it) => it === "") + .or(z.boolean()) + .default(false), +}); +export type GetProfileQuery = z.infer; + +export const GetProfileResponseSchema = responseWithData(UserProfileSchema); +export type GetProfileResponse = z.infer; + +export const UpdateUserProfileRequestSchema = UserProfileDetailsSchema.extend({ + selectedBadgeId: z + .number() + .int() + .nonnegative() + .optional() + .or(z.literal(-1).describe("no badge selected")), //TODO remove the -1, use optional? +}); +export type UpdateUserProfileRequest = z.infer< + typeof UpdateUserProfileRequestSchema +>; + +export const UpdateUserProfileResponseSchema = responseWithData( + UserProfileDetailsSchema +); +export type UpdateUserProfileResponse = z.infer< + typeof UpdateUserProfileResponseSchema +>; + +export const GetUserInboxResponseSchema = responseWithData( + z.object({ + inbox: z.array(MonkeyMailSchema), + maxMail: z.number().int(), + }) +); +export type GetUserInboxResponse = z.infer; + +export const UpdateUserInboxRequestSchema = z.object({ + mailIdsToDelete: z.array(z.string().uuid()).min(1).optional(), + mailIdsToMarkRead: z.array(z.string().uuid()).min(1).optional(), +}); +export type UpdateUserInboxRequest = z.infer< + typeof UpdateUserInboxRequestSchema +>; + +export const ReportUserRequestSchema = z.object({ + uid: z.string(), + reason: ReportUserReasonSchema, + comment: z + .string() + .regex(/^([.]|[^/<>])+$/) + .max(250) + .optional() + .or(z.string().length(0)), + captcha: z.string(), //we don't generate the captcha so there should be no validation +}); +export type ReportUserRequest = z.infer; + +export const ForgotPasswordEmailRequestSchema = z.object({ + email: z.string().email(), +}); +export type ForgotPasswordEmailRequest = z.infer< + typeof ForgotPasswordEmailRequestSchema +>; + +export const GetTestActivityResponseSchema = responseWithNullableData( + CountByYearAndDaySchema +); +export type GetTestActivityResponse = z.infer< + typeof GetTestActivityResponseSchema +>; + +export const GetCurrentTestActivityResponseSchema = + responseWithNullableData(TestActivitySchema); +export type GetCurrentTestActivityResponse = z.infer< + typeof GetCurrentTestActivityResponseSchema +>; + +export const GetStreakResponseSchema = + responseWithNullableData(UserStreakSchema); +export type GetStreakResponseSchema = z.infer; + +const c = initContract(); + +export const usersContract = c.router( + { + get: { + summary: "get user", + description: "Get a user's data.", + method: "GET", + path: "", + responses: { + 200: GetUserResponseSchema, + }, + }, + create: { + summary: "create user", + description: "Creates a new user", + method: "POST", + path: "/signup", + body: CreateUserRequestSchema.strict(), + responses: { + 200: MonkeyResponseSchema, + }, + }, + getNameAvailability: { + summary: "check name", + description: "Checks to see if a username is available", + method: "GET", + path: "/checkName/:name", + pathParams: CheckNamePathParametersSchema.strict(), + responses: { + 200: MonkeyResponseSchema.describe("Name is available"), + 409: MonkeyResponseSchema.describe("Name is not available"), + }, + metadata: { + authenticationOptions: { isPublic: true }, + } as EndpointMetadata, + }, + delete: { + summary: "delete user", + description: "Deletes a user's account", + method: "DELETE", + path: "", + body: c.noBody(), + responses: { + 200: MonkeyResponseSchema, + }, + metadata: { + authenticationOptions: { requireFreshToken: true }, + } as EndpointMetadata, + }, + reset: { + summary: "reset user", + description: "Completely resets a user's account to a blank state", + method: "PATCH", + path: "/reset", + body: c.noBody(), + responses: { + 200: MonkeyResponseSchema, + }, + metadata: { + authenticationOptions: { requireFreshToken: true }, + } as EndpointMetadata, + }, + updateName: { + summary: "update username", + description: "Updates a user's name", + method: "PATCH", + path: "/name", + body: UpdateUserNameRequestSchema.strict(), + responses: { + 200: MonkeyResponseSchema, + }, + metadata: { + authenticationOptions: { requireFreshToken: true }, + } as EndpointMetadata, + }, + updateLeaderboardMemory: { + summary: "update lbMemory", + description: "Updates a user's cached leaderboard state", + method: "PATCH", + path: "/leaderboardMemory", + body: UpdateLeaderboardMemoryRequestSchema.strict(), + responses: { + 200: MonkeyResponseSchema, + }, + }, + updateEmail: { + summary: "update email", + description: "Updates a user's email", + method: "PATCH", + path: "/email", + body: UpdateEmailRequestSchema.strict(), + responses: { + 200: MonkeyResponseSchema, + }, + metadata: { + authenticationOptions: { requireFreshToken: true }, + } as EndpointMetadata, + }, + updatePassword: { + summary: "update password", + description: "Updates a user's email", + method: "PATCH", + path: "/password", + body: UpdatePasswordRequestSchema.strict(), + responses: { + 200: MonkeyResponseSchema, + }, + metadata: { + authenticationOptions: { requireFreshToken: true }, + } as EndpointMetadata, + }, + getPersonalBests: { + summary: "get personal bests", + description: "Get user's personal bests", + method: "GET", + path: "/personalBests", + query: GetPersonalBestsQuerySchema.strict(), + responses: { + 200: GetPersonalBestsResponseSchema, + }, + metadata: { + authenticationOptions: { acceptApeKeys: true }, + } as EndpointMetadata, + }, + deletePersonalBests: { + summary: "delete personal bests", + description: "Deletes a user's personal bests", + method: "DELETE", + path: "/personalBests", + body: c.noBody(), + responses: { + 200: MonkeyResponseSchema, + }, + metadata: { + authenticationOptions: { requireFreshToken: true }, + } as EndpointMetadata, + }, + optOutOfLeaderboards: { + summary: "leaderboards opt out", + description: "Opt out of the leaderboards", + method: "POST", + path: "/optOutOfLeaderboards", + body: c.noBody(), + responses: { + 200: MonkeyResponseSchema, + }, + metadata: { + authenticationOptions: { requireFreshToken: true }, + } as EndpointMetadata, + }, + addResultFilterPreset: { + summary: "add result filter preset", + description: "Add a result filter preset", + method: "POST", + path: "/resultFilterPresets", + body: AddResultFilterPresetRequestSchema.strict(), + responses: { + 200: AddResultFilterPresetResponseSchema, + }, + }, + removeResultFilterPreset: { + summary: "remove result filter preset", + description: "Remove a result filter preset", + method: "DELETE", + path: "/resultFilterPresets/:presetId", + pathParams: RemoveResultFilterPresetPathParamsSchema.strict(), + body: c.noBody(), + responses: { + 200: MonkeyResponseSchema, + }, + }, + getTags: { + summary: "get tags", + description: "Get the users tags", + method: "GET", + path: "/tags", + responses: { + 200: GetTagsResponseSchema, + }, + metadata: { + authenticationOptions: { acceptApeKeys: true }, + } as EndpointMetadata, + }, + createTag: { + summary: "add tag", + description: "Add a tag for the current user", + method: "POST", + path: "/tags", + body: AddTagRequestSchema.strict(), + responses: { + 200: AddTagResponseSchema, + }, + }, + editTag: { + summary: "edit tag", + description: "Edit a tag", + method: "PATCH", + path: "/tags", + body: EditTagRequestSchema.strict(), + responses: { + 200: MonkeyResponseSchema, + }, + }, + deleteTag: { + summary: "delete tag", + description: "Delete a tag", + method: "DELETE", + path: "/tags/:tagId", + pathParams: TagIdPathParamsSchema.strict(), + body: c.noBody(), + responses: { + 200: MonkeyResponseSchema, + }, + }, + deleteTagPersonalBest: { + summary: "delete tag PBs", + description: "Delete personal bests of a tag", + method: "DELETE", + path: "/tags/:tagId/personalBest", + pathParams: TagIdPathParamsSchema.strict(), + body: c.noBody(), + responses: { + 200: MonkeyResponseSchema, + }, + }, + getCustomThemes: { + summary: "get custom themes", + description: "Get custom themes for the current user", + method: "GET", + path: "/customThemes", + responses: { + 200: GetCustomThemesResponseSchema, + }, + }, + addCustomTheme: { + summary: "add custom themes", + description: "Add a custom theme for the current user", + method: "POST", + path: "/customThemes", + body: AddCustomThemeRequestSchema.strict(), + responses: { + 200: AddCustomThemeResponseSchema, + }, + }, + deleteCustomTheme: { + summary: "delete custom themes", + description: "Delete a custom theme", + method: "DELETE", + path: "/customThemes", + body: DeleteCustomThemeRequestSchema.strict(), + responses: { + 200: MonkeyResponseSchema, + }, + }, + editCustomTheme: { + summary: "edit custom themes", + description: "Edit a custom theme", + method: "PATCH", + path: "/customThemes", + body: EditCustomThemeRequstSchema.strict(), + responses: { + 200: MonkeyResponseSchema, + }, + }, + getDiscordOAuth: { + summary: "discord oauth", + description: "Start OAuth authentication with discord", + method: "GET", + path: "/discord/oauth", + responses: { + 200: GetDiscordOauthLinkResponseSchema, + }, + }, + linkDiscord: { + summary: "link with discord", + description: "Links a user's account with a discord account", + method: "POST", + path: "/discord/link", + body: LinkDiscordRequestSchema.strict(), + responses: { + 200: LinkDiscordResponseSchema, + }, + metadata: {} as EndpointMetadata, + }, + unlinkDiscord: { + summary: "unlink discord", + description: "Unlinks a user's account with a discord account", + method: "POST", + path: "/discord/unlink", + body: c.noBody(), + responses: { + 200: MonkeyResponseSchema, + }, + }, + getStats: { + summary: "get stats", + description: "Gets a user's typing stats data", + method: "GET", + path: "/stats", + responses: { + 200: GetStatsResponseSchema, + }, + metadata: { + authenticationOptions: { acceptApeKeys: true }, + } as EndpointMetadata, + }, + setStreakHourOffset: { + summary: "set streak hour offset", + description: "Sets a user's streak hour offset", + method: "POST", + path: "/setStreakHourOffset", + body: SetStreakHourOffsetRequestSchema.strict(), + responses: { + 200: MonkeyResponseSchema, + }, + }, + getFavoriteQuotes: { + summary: "get favorite quotes", + description: "Gets a user's favorite quotes", + method: "GET", + path: "/favoriteQuotes", + responses: { + 200: GetFavoriteQuotesResponseSchema, + }, + }, + addQuoteToFavorites: { + summary: "add favorite quotes", + description: "Add a quote to the user's favorite quotes", + method: "POST", + path: "/favoriteQuotes", + body: AddFavoriteQuoteRequestSchema.strict(), + responses: { + 200: MonkeyResponseSchema, + }, + }, + removeQuoteFromFavorites: { + summary: "remove favorite quotes", + description: "Remove a quote to the user's favorite quotes", + method: "DELETE", + path: "/favoriteQuotes", + body: RemoveFavoriteQuoteRequestSchema.strict(), + responses: { + 200: MonkeyResponseSchema, + }, + }, + getProfile: { + summary: "get profile", + description: "Gets a user's profile", + method: "GET", + path: "/:uidOrName/profile", + pathParams: GetProfilePathParamsSchema.strict(), + query: GetProfileQuerySchema.strict(), + responses: { + 200: GetProfileResponseSchema, + 404: MonkeyClientError.describe("User not found"), + }, + metadata: { + authenticationOptions: { isPublic: true }, + } as EndpointMetadata, + }, + updateProfile: { + summary: "update profile", + description: "Update a user's profile", + method: "PATCH", + path: "/profile", + body: UpdateUserProfileRequestSchema.strict(), + responses: { + 200: UpdateUserProfileResponseSchema, + }, + }, + getInbox: { + summary: "get inbox", + description: "Gets the user's inbox", + method: "GET", + path: "/inbox", + responses: { + 200: GetUserInboxResponseSchema, + }, + }, + updateInbox: { + summary: "update inbox", + description: "Updates the user's inbox", + method: "PATCH", + body: UpdateUserInboxRequestSchema.strict(), + path: "/inbox", + responses: { + 200: MonkeyResponseSchema, + }, + }, + report: { + summary: "report user", + description: "Report a user", + method: "POST", + path: "/report", + body: ReportUserRequestSchema.strict(), + responses: { + 200: MonkeyResponseSchema, + }, + }, + verificationEmail: { + summary: "send verification email", + description: "Send a verification email", + method: "GET", + path: "/verificationEmail", + responses: { + 200: MonkeyResponseSchema, + }, + metadata: { + authenticationOptions: { noCache: true }, + } as EndpointMetadata, + }, + forgotPasswordEmail: { + summary: "send forgot password email", + description: "Send a forgot password email", + method: "POST", + path: "/forgotPasswordEmail", + body: ForgotPasswordEmailRequestSchema.strict(), + responses: { + 200: MonkeyResponseSchema, + }, + metadata: { + authenticationOptions: { isPublic: true }, + } as EndpointMetadata, + }, + revokeAllTokens: { + summary: "revoke all tokens", + description: "Revoke all tokens for the current user.", + method: "POST", + path: "/revokeAllTokens", + body: c.noBody(), + responses: { + 200: MonkeyResponseSchema, + }, + metadata: { + authenticationOptions: { requireFreshToken: true, noCache: true }, + } as EndpointMetadata, + }, + getTestActivity: { + summary: "get test activity", + description: "Get user's test activity", + method: "GET", + path: "/testActivity", + responses: { + 200: GetTestActivityResponseSchema, + }, + }, + getCurrentTestActivity: { + summary: "get current test activity", + description: + "Get test activity for the last up to 372 days for the current user ", + method: "GET", + path: "/currentTestActivity", + responses: { + 200: GetCurrentTestActivityResponseSchema, + }, + metadata: { + authenticationOptions: { acceptApeKeys: true }, + } as EndpointMetadata, + }, + getStreak: { + summary: "get streak", + description: "Get user's streak data", + method: "GET", + path: "/streak", + responses: { + 200: GetStreakResponseSchema, + }, + metadata: { + authenticationOptions: { acceptApeKeys: true }, + } as EndpointMetadata, + }, + }, + { + pathPrefix: "/users", + strictStatusCodes: true, + metadata: { + openApiTags: "users", + } as EndpointMetadata, + + commonResponses: CommonResponses, + } +); diff --git a/backend/src/constants/homoglyphs.ts b/packages/contracts/src/validation/homoglyphs.ts similarity index 94% rename from backend/src/constants/homoglyphs.ts rename to packages/contracts/src/validation/homoglyphs.ts index 57fbb6ee4..25897eabd 100644 --- a/backend/src/constants/homoglyphs.ts +++ b/packages/contracts/src/validation/homoglyphs.ts @@ -34,7 +34,7 @@ const obj = { export function replaceHomoglyphs(str: string): string { for (const key in obj) { - obj[key].forEach((value) => { + obj[key as keyof typeof obj].forEach((value) => { str = str.replace(value, key); }); } diff --git a/backend/src/constants/profanities.ts b/packages/contracts/src/validation/validation.ts similarity index 78% rename from backend/src/constants/profanities.ts rename to packages/contracts/src/validation/validation.ts index ef7330efb..ae19602d2 100644 --- a/backend/src/constants/profanities.ts +++ b/packages/contracts/src/validation/validation.ts @@ -1,7 +1,56 @@ -import _ from "lodash"; +import { replaceHomoglyphs } from "./homoglyphs"; +import { ZodEffects, ZodString } from "zod"; + +export function containsProfanity( + text: string, + mode: "word" | "substring" +): boolean { + const normalizedText = text + .toLowerCase() + .split(/[.,"/#!?$%^&*;:{}=\-_`~()\s\n]+/g) + .map((str) => { + return replaceHomoglyphs(sanitizeString(str) ?? ""); + }); + + const hasProfanity = profanities.some((profanity) => { + return normalizedText.some((word) => { + return mode === "word" + ? word.startsWith(profanity) + : word.includes(profanity); + }); + }); + + return hasProfanity; +} + +function sanitizeString(str: string | undefined): string | undefined { + if (str === undefined || str === "") { + return str; + } + + return str + .replace(/[\u0300-\u036F]/g, "") + .trim() + .replace(/\n{3,}/g, "\n\n") + .replace(/\s{3,}/g, " "); +} + +export function doesNotContainProfanity( + mode: "word" | "substring", + schema: ZodString +): ZodEffects { + return schema.refine( + (val) => { + return !containsProfanity(val, mode); + }, + (val) => ({ + message: `Profanity detected. Please remove it. If you believe this is a mistake, please contact us. (${val})`, + }) + ); +} // Sorry for the bad words -export const profanities = [ +const profanities = [ "miodec", "bitly", "niqqa", @@ -390,15 +439,3 @@ export const profanities = [ "wichser", "zabourah", ]; - -export const regexProfanities = profanities.map((profanity) => { - const normalizedProfanity = _.escapeRegExp(profanity.toLowerCase()); - return `${normalizedProfanity}.*`; -}); - -export function findProfanities(string: string): string[] { - const filtered = profanities.filter((profanity) => - string.includes(profanity) - ); - return filtered ?? []; -} diff --git a/packages/contracts/tsconfig.json b/packages/contracts/tsconfig.json index 5130063ef..4d12c2f9c 100644 --- a/packages/contracts/tsconfig.json +++ b/packages/contracts/tsconfig.json @@ -6,7 +6,8 @@ "declaration": true, "declarationMap": true, "moduleResolution": "Node", - "module": "ES6" + "module": "ES6", + "target": "ES2015" }, "include": ["src"], "exclude": ["node_modules", "dist"] diff --git a/packages/contracts/vitest.config.js b/packages/contracts/vitest.config.js new file mode 100644 index 000000000..d071c79ce --- /dev/null +++ b/packages/contracts/vitest.config.js @@ -0,0 +1,11 @@ +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + test: { + globals: true, + environment: "node", + coverage: { + include: ["**/*.ts"], + }, + }, +}); diff --git a/packages/shared-types/.eslintrc.cjs b/packages/shared-types/.eslintrc.cjs deleted file mode 100644 index 922de4abe..000000000 --- a/packages/shared-types/.eslintrc.cjs +++ /dev/null @@ -1,5 +0,0 @@ -/** @type {import("eslint").Linter.Config} */ -module.exports = { - root: true, - extends: ["@monkeytype/eslint-config"], -}; diff --git a/packages/shared-types/package.json b/packages/shared-types/package.json deleted file mode 100644 index 1377c2641..000000000 --- a/packages/shared-types/package.json +++ /dev/null @@ -1,30 +0,0 @@ -{ - "name": "@monkeytype/shared-types", - "private": true, - "scripts": { - "dev": "rimraf ./dist && tsc --watch --preserveWatchOutput", - "build": "rimraf ./dist && tsc", - "ts-check": "tsc --noEmit", - "lint": "eslint \"./**/*.ts\"" - }, - "dependencies": { - "@monkeytype/contracts": "workspace:*" - }, - "devDependencies": { - "@monkeytype/eslint-config": "workspace:*", - "@monkeytype/typescript-config": "workspace:*", - "rimraf": "6.0.1", - "typescript": "5.5.4", - "eslint": "8.57.0" - }, - "exports": { - ".": { - "default": "./dist/index.js", - "types": "./dist/index.d.ts" - }, - "./*": { - "default": "./dist/*.js", - "types": "./dist/*.d.ts" - } - } -} diff --git a/packages/shared-types/src/index.ts b/packages/shared-types/src/index.ts deleted file mode 100644 index 7e4613bb5..000000000 --- a/packages/shared-types/src/index.ts +++ /dev/null @@ -1,172 +0,0 @@ -type PersonalBest = import("@monkeytype/contracts/schemas/shared").PersonalBest; -type PersonalBests = - import("@monkeytype/contracts/schemas/shared").PersonalBests; - -export type CustomTextLimit = { - value: number; - mode: import("@monkeytype/contracts/schemas/util").CustomTextLimitMode; -}; - -export type CustomTextData = Omit< - import("@monkeytype/contracts/schemas/results").CustomTextDataWithTextLen, - "textLen" -> & { - text: string[]; -}; - -export type UserStreak = { - lastResultTimestamp: number; - length: number; - maxLength: number; - hourOffset?: number; -}; - -export type UserTag = { - _id: string; - name: string; - personalBests: PersonalBests; -}; - -export type UserProfileDetails = { - bio?: string; - keyboard?: string; - socialProfiles: { - twitter?: string; - github?: string; - website?: string; - }; -}; - -export type CustomTheme = { - _id: string; - name: string; - colors: import("@monkeytype/contracts/schemas/configs").CustomThemeColors; -}; - -export type PremiumInfo = { - startTimestamp: number; - expirationTimestamp: number; -}; - -// Record> -export type UserQuoteRatings = Record>; - -export type UserLbMemory = Record< - string, - Record> ->; - -export type UserInventory = { - badges: Badge[]; -}; - -export type Badge = { - id: number; - selected?: boolean; -}; - -export type User = { - name: string; - email: string; - uid: string; - addedAt: number; - personalBests: PersonalBests; - lastReultHashes?: string[]; //todo: fix typo (its in the db too) - completedTests?: number; - startedTests?: number; - timeTyping?: number; - streak?: UserStreak; - xp?: number; - discordId?: string; - discordAvatar?: string; - tags?: UserTag[]; - profileDetails?: UserProfileDetails; - customThemes?: CustomTheme[]; - premium?: PremiumInfo; - isPremium?: boolean; - quoteRatings?: UserQuoteRatings; - favoriteQuotes?: Record; - lbMemory?: UserLbMemory; - allTimeLbs: AllTimeLbs; - inventory?: UserInventory; - banned?: boolean; - lbOptOut?: boolean; - verified?: boolean; - needsToChangeName?: boolean; - quoteMod?: boolean | string; - resultFilterPresets?: import("@monkeytype/contracts/schemas/users").ResultFilters[]; - testActivity?: TestActivity; -}; - -export type Reward = { - type: string; - item: T; -}; - -export type XpReward = { - type: "xp"; - item: number; -} & Reward; - -export type BadgeReward = { - type: "badge"; - item: Badge; -} & Reward; - -export type AllRewards = XpReward | BadgeReward; - -export type MonkeyMail = { - id: string; - subject: string; - body: string; - timestamp: number; - read: boolean; - rewards: AllRewards[]; -}; - -export type UserProfile = Pick< - User, - | "name" - | "banned" - | "addedAt" - | "discordId" - | "discordAvatar" - | "xp" - | "lbOptOut" - | "inventory" - | "uid" - | "isPremium" - | "allTimeLbs" -> & { - typingStats: { - completedTests: User["completedTests"]; - startedTests: User["startedTests"]; - timeTyping: User["timeTyping"]; - }; - streak: UserStreak["length"]; - maxStreak: UserStreak["maxLength"]; - details: UserProfileDetails; - personalBests: { - time: Pick, "15" | "30" | "60" | "120">; - words: Pick< - Record<`${number}`, PersonalBest[]>, - "10" | "25" | "50" | "100" - >; - }; -}; - -export type AllTimeLbs = { - time: Record>; -}; - -export type RankAndCount = { - rank?: number; - count: number; -}; - -export type TestActivity = { - testsByDays: (number | null)[]; - lastDay: number; -}; - -export type CountByYearAndDay = { [key: string]: (number | null)[] }; diff --git a/packages/shared-types/tsconfig.json b/packages/shared-types/tsconfig.json deleted file mode 100644 index c0e629adb..000000000 --- a/packages/shared-types/tsconfig.json +++ /dev/null @@ -1,11 +0,0 @@ -{ - "extends": "@monkeytype/typescript-config/base.json", - "compilerOptions": { - "outDir": "./dist", - "rootDir": "./src", - "declaration": true, - "declarationMap": true - }, - "include": ["src"], - "exclude": ["node_modules", "dist"] -} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 8200654fd..39bdcc2bf 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -146,9 +146,6 @@ importers: swagger-stats: specifier: 0.99.7 version: 0.99.7(prom-client@15.1.3) - swagger-ui-express: - specifier: 4.3.0 - version: 4.3.0(express@4.19.2) ua-parser-js: specifier: 0.7.33 version: 0.7.33 @@ -165,15 +162,12 @@ importers: '@monkeytype/eslint-config': specifier: workspace:* version: link:../packages/eslint-config - '@monkeytype/shared-types': - specifier: workspace:* - version: link:../packages/shared-types '@monkeytype/typescript-config': specifier: workspace:* version: link:../packages/typescript-config '@redocly/cli': - specifier: 1.19.0 - version: 1.19.0(encoding@0.1.13)(enzyme@3.11.0) + specifier: 1.22.0 + version: 1.22.0(encoding@0.1.13)(enzyme@3.11.0) '@types/bcrypt': specifier: 5.0.2 version: 5.0.2 @@ -222,9 +216,6 @@ importers: '@types/swagger-stats': specifier: 0.95.11 version: 0.95.11 - '@types/swagger-ui-express': - specifier: 4.1.3 - version: 4.1.3 '@types/ua-parser-js': specifier: 0.7.36 version: 0.7.36 @@ -352,9 +343,6 @@ importers: '@monkeytype/eslint-config': specifier: workspace:* version: link:../packages/eslint-config - '@monkeytype/shared-types': - specifier: workspace:* - version: link:../packages/shared-types '@monkeytype/typescript-config': specifier: workspace:* version: link:../packages/typescript-config @@ -494,6 +482,9 @@ importers: typescript: specifier: 5.5.4 version: 5.5.4 + vitest: + specifier: 2.0.5 + version: 2.0.5(@types/node@20.14.11)(happy-dom@13.4.1)(sass@1.70.0)(terser@5.31.3) packages/eslint-config: devDependencies: @@ -541,28 +532,6 @@ importers: specifier: 3.1.4 version: 3.1.4 - packages/shared-types: - dependencies: - '@monkeytype/contracts': - specifier: workspace:* - version: link:../contracts - devDependencies: - '@monkeytype/eslint-config': - specifier: workspace:* - version: link:../eslint-config - '@monkeytype/typescript-config': - specifier: workspace:* - version: link:../typescript-config - eslint: - specifier: 8.57.0 - version: 8.57.0 - rimraf: - specifier: 6.0.1 - version: 6.0.1 - typescript: - specifier: 5.5.4 - version: 5.5.4 - packages/typescript-config: {} packages: @@ -2336,16 +2305,16 @@ packages: '@redocly/ajv@8.11.0': resolution: {integrity: sha512-9GWx27t7xWhDIR02PA18nzBdLcKQRgc46xNQvjFkrYk4UOmvKhJ/dawwiX0cCOeetN5LcaaiqQbVOWYK62SGHw==} - '@redocly/cli@1.19.0': - resolution: {integrity: sha512-ev6J0eD+quprvW9PVCl9JmRFZbj6cuK+mnYPAjcrPvesy2RF752fflcpgQjGnyFaGb1Cj+DiwDi3dYr3EAp04A==} + '@redocly/cli@1.22.0': + resolution: {integrity: sha512-KXWTVKcyM4u4AHmxF9aDQOLbUWKwfEH8tM/CprcWnVvi9Gc0aPz1Y3aTrcohDE1oIgzJfn/Fj6TNdof86bNZvw==} engines: {node: '>=14.19.0', npm: '>=7.0.0'} hasBin: true - '@redocly/config@0.7.0': - resolution: {integrity: sha512-6GKxTo/9df0654Mtivvr4lQnMOp+pRj9neVywmI5+BwfZLTtkJnj2qB3D6d8FHTr4apsNOf6zTa5FojX0Evh4g==} + '@redocly/config@0.10.1': + resolution: {integrity: sha512-H3LnKVGzOaxskwJu8pmJYwBOWjP61qOK7TuTrbafqArDVckE06fhA6l0nO4KvBbjLPjy1Al7UnlxOu23V4Nl0w==} - '@redocly/openapi-core@1.19.0': - resolution: {integrity: sha512-ezK6qr80sXvjDgHNrk/zmRs9vwpIAeHa0T/qmo96S+ib4ThQ5a8f3qjwEqxMeVxkxCTbkaY9sYSJKOxv4ejg5w==} + '@redocly/openapi-core@1.22.0': + resolution: {integrity: sha512-IXazrCCUwRkwgVGlaWghFEyyLrz5EM1VM+Kn3/By4QGaNVd04oxC1c92h3kbt1StAxtrTfxBAGwS7bqqCF7nsw==} engines: {node: '>=14.19.0', npm: '>=7.0.0'} '@rollup/plugin-babel@5.3.1': @@ -2713,9 +2682,6 @@ packages: '@types/swagger-stats@0.95.11': resolution: {integrity: sha512-npTTS5lv0FmkgKeChxUrp9nTqiFdFP5XRlewfGP7JVeFwV7u1yE0SOUh8eXMrgVLE/mJNJuhGoAoVClHc+rsGA==} - '@types/swagger-ui-express@4.1.3': - resolution: {integrity: sha512-jqCjGU/tGEaqIplPy3WyQg+Nrp6y80DCFnDEAvVKWkJyv0VivSSDCChkppHRHAablvInZe6pijDFMnavtN0vqA==} - '@types/throttle-debounce@2.1.0': resolution: {integrity: sha512-5eQEtSCoESnh2FsiLTxE121IiE60hnMqcb435fShf4bpLRjEu1Eoekht23y6zXS9Ts3l+Szu3TARnTsA0GkOkQ==} @@ -8444,15 +8410,6 @@ packages: peerDependencies: prom-client: '>= 10 <= 14' - swagger-ui-dist@5.17.14: - resolution: {integrity: sha512-CVbSfaLpstV65OnSjbXfVd6Sta3q3F7Cj/yYuvHMp1P90LztOLs6PfUnKEVAeiIVQt9u2SaPwv0LiH/OyMjHRw==} - - swagger-ui-express@4.3.0: - resolution: {integrity: sha512-jN46SEEe9EoXa3ZgZoKgnSF6z0w3tnM1yqhO4Y+Q4iZVc8JOQB960EZpIAz6rNROrDApVDwcMHR0mhlnc/5Omw==} - engines: {node: '>= v0.10.32'} - peerDependencies: - express: '>=4.0.0' - swagger2openapi@7.0.8: resolution: {integrity: sha512-upi/0ZGkYgEcLeGieoz8gT74oWHA0E7JivX7aN9mAf+Tc7BQoRBvnIGHoPDw+f9TXTW4s6kGYCZJtauP6OYp7g==} hasBin: true @@ -11552,9 +11509,9 @@ snapshots: require-from-string: 2.0.2 uri-js: 4.4.1 - '@redocly/cli@1.19.0(encoding@0.1.13)(enzyme@3.11.0)': + '@redocly/cli@1.22.0(encoding@0.1.13)(enzyme@3.11.0)': dependencies: - '@redocly/openapi-core': 1.19.0(encoding@0.1.13) + '@redocly/openapi-core': 1.22.0(encoding@0.1.13) abort-controller: 3.0.0 chokidar: 3.6.0 colorette: 1.4.0 @@ -11581,12 +11538,12 @@ snapshots: - supports-color - utf-8-validate - '@redocly/config@0.7.0': {} + '@redocly/config@0.10.1': {} - '@redocly/openapi-core@1.19.0(encoding@0.1.13)': + '@redocly/openapi-core@1.22.0(encoding@0.1.13)': dependencies: '@redocly/ajv': 8.11.0 - '@redocly/config': 0.7.0 + '@redocly/config': 0.10.1 colorette: 1.4.0 https-proxy-agent: 7.0.5 js-levenshtein: 1.1.6 @@ -11941,11 +11898,6 @@ snapshots: transitivePeerDependencies: - supports-color - '@types/swagger-ui-express@4.1.3': - dependencies: - '@types/express': 4.17.21 - '@types/serve-static': 1.15.7 - '@types/throttle-debounce@2.1.0': {} '@types/tough-cookie@4.0.5': {} @@ -18016,7 +17968,7 @@ snapshots: redoc@2.1.5(core-js@3.37.1)(encoding@0.1.13)(enzyme@3.11.0)(mobx@6.13.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(styled-components@6.1.12(react-dom@18.3.1(react@18.3.1))(react@18.3.1)): dependencies: '@cfaester/enzyme-adapter-react-18': 0.8.0(enzyme@3.11.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - '@redocly/openapi-core': 1.19.0(encoding@0.1.13) + '@redocly/openapi-core': 1.22.0(encoding@0.1.13) classnames: 2.5.1 core-js: 3.37.1 decko: 1.2.0 @@ -18934,13 +18886,6 @@ snapshots: transitivePeerDependencies: - supports-color - swagger-ui-dist@5.17.14: {} - - swagger-ui-express@4.3.0(express@4.19.2): - dependencies: - express: 4.19.2 - swagger-ui-dist: 5.17.14 - swagger2openapi@7.0.8(encoding@0.1.13): dependencies: call-me-maybe: 1.0.2